@@ -1077,26 +1077,47 @@ export async function runInvestigation({ task, max_steps = 20, resume_run_id })
10771077 // guards against.
10781078 const results = await Promise . all ( functionCalls . map ( async ( part ) => {
10791079 const { name, args, id } = part . functionCall ;
1080- const fn = FUNCTIONS . find ( ( f ) => f . name === name ) ;
1080+ // Stuck-loop detection (fix #4): a signature identifies an exact
1081+ // repeat of a call already made this run. `isRepeat` reflects
1082+ // whether this signature has been SEEN before (checked before the
1083+ // increment below); repeatCounts itself is incremented regardless
1084+ // of whether it's a repeat, purely for observability/debugging --
1085+ // only the boolean matters to the stuck-loop logic further down.
1086+ const signature = `${ name } :${ JSON . stringify ( args || { } ) } ` ;
1087+ const isRepeat = repeatCounts . has ( signature ) ;
1088+ repeatCounts . set ( signature , ( repeatCounts . get ( signature ) || 0 ) + 1 ) ;
1089+
10811090 let resultText ;
1082- if ( ! fn ) {
1083- resultText = `Error: unknown function "${ name } ".` ;
1091+ let servedFromCache = false ;
1092+ if ( isRepeat && resultCache . has ( signature ) ) {
1093+ // Exact repeat -- don't re-execute at all, just return what this
1094+ // same call returned last time. This is the free win: no network
1095+ // call, no wasted budget, regardless of whether the run as a
1096+ // whole turns out to be stuck (see allRepeatsThisStep below).
1097+ resultText = resultCache . get ( signature ) ;
1098+ servedFromCache = true ;
10841099 } else {
1085- try {
1086- resultText = await fn . execute ( args || { } ) ;
1087- } catch ( err ) {
1088- resultText = `Error: ${ err ?. message ?? String ( err ) } ` ;
1100+ const fn = FUNCTIONS . find ( ( f ) => f . name === name ) ;
1101+ if ( ! fn ) {
1102+ resultText = `Error: unknown function "${ name } ".` ;
1103+ } else {
1104+ try {
1105+ resultText = await fn . execute ( args || { } ) ;
1106+ } catch ( err ) {
1107+ resultText = `Error: ${ err ?. message ?? String ( err ) } ` ;
1108+ }
10891109 }
1110+ // Defensive: every FUNCTIONS[].execute() is expected to return a
1111+ // string. Guard against a future one accidentally returning
1112+ // something else (object, undefined, etc.) so this can't throw
1113+ // mid-transcript and take down the whole step -- see the outer
1114+ // catch below for why that matters.
1115+ if ( typeof resultText !== "string" ) {
1116+ resultText = `Error: ${ name } returned a non-string result (${ typeof resultText } ); this is a bug in the function's execute().` ;
1117+ }
1118+ resultCache . set ( signature , resultText ) ;
10901119 }
1091- // Defensive: every FUNCTIONS[].execute() is expected to return a
1092- // string. Guard against a future one accidentally returning
1093- // something else (object, undefined, etc.) so this can't throw
1094- // mid-transcript and take down the whole step -- see the outer
1095- // catch below for why that matters.
1096- if ( typeof resultText !== "string" ) {
1097- resultText = `Error: ${ name } returned a non-string result (${ typeof resultText } ); this is a bug in the function's execute().` ;
1098- }
1099- return { name, args, id, resultText } ;
1120+ return { name, args, id, resultText, isRepeat, servedFromCache } ;
11001121 } ) ) ;
11011122
11021123 for ( const r of results ) {
0 commit comments