Skip to content

Commit 6e4bef7

Browse files
committed
fix(agent): guard empty catalog detail loops
1 parent 3a73b25 commit 6e4bef7

2 files changed

Lines changed: 324 additions & 5 deletions

File tree

agent/protocol.go

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,12 @@ type discoveryState struct {
8080
// One empty lexical/coverage search is allowed and returned with concrete
8181
// recovery guidance. A second blind search is rejected before dispatch so
8282
// the actor must consume known ids or enumerate the catalog by kind.
83-
emptySearchStreak int
83+
emptySearchStreak int
84+
// One unresolved id-detail lookup is likewise allowed with concrete known-id
85+
// guidance. A second unknown guess is rejected before dispatch. A detail id
86+
// already surfaced by this run remains eligible for lookup so following the
87+
// recovery guidance can resolve and reset the streak.
88+
emptyDetailStreak int
8489
capabilities *CapabilityProfile
8590
observe func(ActionEvent)
8691
coverageSearchUsed bool
@@ -252,6 +257,7 @@ func (r *protocolRuntime) QueryCatalog(ctx context.Context, args map[string]any)
252257
}
253258
r.addNamespace(args)
254259
searchRequest := isCatalogSearchRequest(args)
260+
detailIDs := detailIDsFromArgs(args)
255261
requestKey := stringify(normalizeValue(args))
256262
if searchRequest && r.state.emptySearchStreak > 0 {
257263
r.state.emptySearchStreak++
@@ -260,6 +266,13 @@ func (r *protocolRuntime) QueryCatalog(ctx context.Context, args map[string]any)
260266
r.state.finishAction(action, "query_catalog", args, out, nil)
261267
return out, nil
262268
}
269+
if len(detailIDs) != 0 && r.state.emptyDetailStreak > 0 && !r.state.hasKnownCatalogID(detailIDs) {
270+
r.state.emptyDetailStreak++
271+
out := r.state.emptyDetailExhaustedResult(detailIDs)
272+
action := r.state.startAction("model", "query_catalog", args)
273+
r.state.finishAction(action, "query_catalog", args, out, nil)
274+
return out, nil
275+
}
263276
if requestKey != "" && r.state.catalogRequestKeys[requestKey] {
264277
message := "duplicate query_catalog call rejected: this exact request already returned catalog evidence; reuse the prior result and follow its next guidance instead of searching again"
265278
if r.state.lastExecution != nil {
@@ -292,12 +305,26 @@ func (r *protocolRuntime) QueryCatalog(ctx context.Context, args map[string]any)
292305
r.state.recordCatalog(args, out, false)
293306
if len(catalogCards(out)) != 0 {
294307
r.state.emptySearchStreak = 0
308+
if len(detailIDs) == 0 {
309+
r.state.emptyDetailStreak = 0
310+
}
295311
} else if searchRequest {
296312
r.state.emptySearchStreak = 1
297313
out = attachEmptySearchRecovery(out, r.state.emptySearchNext(
298314
"No catalog cards matched. Drop search filters and inspect a known id, or enumerate tables with query_catalog({kind:\"table\"}) instead of trying another blind search.",
299315
))
300316
}
317+
if len(detailIDs) != 0 {
318+
returned := returnedCatalogDetailIDs(detailIDs, out)
319+
if len(returned) != 0 {
320+
r.state.emptyDetailStreak = 0
321+
} else {
322+
r.state.emptyDetailStreak = 1
323+
out = attachEmptyDetailRecovery(out, detailIDs, r.state.emptyDetailNext(
324+
"The requested catalog id was not returned. Inspect a known id or enumerate tables by kind before trying another detail lookup.",
325+
))
326+
}
327+
}
301328
}
302329
r.state.finishAction(action, "query_catalog", args, out, err)
303330
return out, err
@@ -326,6 +353,25 @@ func attachEmptySearchRecovery(out any, next map[string]any) any {
326353
return result
327354
}
328355

356+
func attachEmptyDetailRecovery(out any, missedIDs []string, next map[string]any) any {
357+
result := cloneAnyMap(mapValue(out))
358+
if len(catalogCards(result)) == 0 {
359+
result["cards"] = []any{}
360+
}
361+
recovery := map[string]any{
362+
"kind": "empty_detail",
363+
"instruction": "The requested catalog detail was not found. Use a known catalog id from this run or enumerate tables by kind instead of guessing another id.",
364+
"missed_ids": append([]string(nil), missedIDs...),
365+
"known_ids": next["known_ids"],
366+
"next": next,
367+
}
368+
if len(missedIDs) == 1 {
369+
recovery["missed_id"] = missedIDs[0]
370+
}
371+
result["recovery"] = recovery
372+
return result
373+
}
374+
329375
func (s *discoveryState) emptySearchExhaustedResult() executeResult {
330376
next := s.emptySearchNext("A prior catalog search already returned no cards. Enumerate tables by kind or inspect a known id before searching again.")
331377
return executeResult{
@@ -350,6 +396,40 @@ func (s *discoveryState) emptySearchExhaustedResult() executeResult {
350396
}
351397
}
352398

399+
func (s *discoveryState) emptyDetailExhaustedResult(missedIDs []string) executeResult {
400+
next := s.emptyDetailNext("A prior catalog detail lookup returned no matching card. Inspect a known id or enumerate tables by kind before trying another detail lookup.")
401+
repair := map[string]any{
402+
"kind": "empty_detail_exhausted",
403+
"missed_ids": append([]string(nil), missedIDs...),
404+
"known_ids": next["known_ids"],
405+
"next": next,
406+
}
407+
if len(missedIDs) == 1 {
408+
repair["missed_id"] = missedIDs[0]
409+
}
410+
recovery := map[string]any{
411+
"kind": "empty_detail_exhausted",
412+
"instruction": "Do not guess another catalog id. Inspect one of the known ids or enumerate tables by kind.",
413+
"missed_ids": repair["missed_ids"],
414+
"known_ids": next["known_ids"],
415+
"next": next,
416+
}
417+
if len(missedIDs) == 1 {
418+
recovery["missed_id"] = missedIDs[0]
419+
}
420+
return executeResult{
421+
Errors: []ErrorInfo{{
422+
Message: "consecutive empty catalog detail lookup rejected; stop guessing ids and use a known catalog id or enumerate tables by kind",
423+
Extensions: map[string]any{
424+
"code": "empty_detail_exhausted",
425+
"retryable": true,
426+
"graphjin_repair": repair,
427+
},
428+
}},
429+
Recovery: recovery,
430+
}
431+
}
432+
353433
func (s *discoveryState) emptySearchNext(reason string) map[string]any {
354434
knownIDs := s.knownCatalogIDs(emptySearchKnownIDLimit)
355435
return map[string]any{
@@ -360,6 +440,27 @@ func (s *discoveryState) emptySearchNext(reason string) map[string]any {
360440
}
361441
}
362442

443+
func (s *discoveryState) emptyDetailNext(reason string) map[string]any {
444+
return s.emptySearchNext(reason)
445+
}
446+
447+
func (s *discoveryState) hasKnownCatalogID(ids []string) bool {
448+
if s == nil {
449+
return false
450+
}
451+
for _, id := range ids {
452+
if s.catalogIDs[id] {
453+
return true
454+
}
455+
for knownID := range s.catalogIDs {
456+
if strings.EqualFold(strings.TrimSpace(id), strings.TrimSpace(knownID)) {
457+
return true
458+
}
459+
}
460+
}
461+
return false
462+
}
463+
363464
func (s *discoveryState) knownCatalogIDs(limit int) []string {
364465
if s == nil || limit <= 0 {
365466
return nil
@@ -469,6 +570,7 @@ func (r *protocolRuntime) ExecuteSavedQuery(ctx context.Context, args map[string
469570
r.state.cacheSuccessfulExecution(executionKey, out)
470571
if !executionFailed(out) {
471572
r.state.emptySearchStreak = 0
573+
r.state.emptyDetailStreak = 0
472574
}
473575
// A rejected out-of-order attempt is recoverable inside the same actor
474576
// run. Once the model has inspected the exact detail and the governed
@@ -594,6 +696,7 @@ func (r *protocolRuntime) ExecuteGraphQL(ctx context.Context, args map[string]an
594696
r.state.cacheSuccessfulExecution(queryKey, out)
595697
if !executionFailed(out) {
596698
r.state.emptySearchStreak = 0
699+
r.state.emptyDetailStreak = 0
597700
r.state.resolveRawGraphQLDiscoveryViolations()
598701
}
599702
if isWatchDefinitionMutation(query) {
@@ -1030,6 +1133,7 @@ func (s *discoveryState) selectCachedExecution(tool string, args map[string]any,
10301133
return
10311134
}
10321135
s.emptySearchStreak = 0
1136+
s.emptyDetailStreak = 0
10331137
s.lastExecution = map[string]any{
10341138
"tool": tool,
10351139
"args": redactArgs(args),

0 commit comments

Comments
 (0)