@@ -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+
329375func (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+
353433func (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+
363464func (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