@@ -14,6 +14,10 @@ import { AutohandAgent } from '../../src/core/agent.js';
1414import { ReactionParser } from '../../src/core/agent/ReactionParser.js' ;
1515import { runAgentReactLoop } from '../../src/core/agent/ReactLoopRunner.js' ;
1616import { ToolReflectionGuard } from '../../src/core/agent/ToolLoopPolicy.js' ;
17+ import {
18+ DEFAULT_RESPONSE_COMPLETION_HOOKS ,
19+ type ResponseCompletionHook ,
20+ } from '../../src/core/agent/ResponseCompletionClassifier.js' ;
1721import type {
1822 AgentRuntime ,
1923 AssistantReactPayload ,
@@ -45,7 +49,10 @@ function createNativeToolCall(id: string, name = 'read_file', args: Record<strin
4549 } ;
4650}
4751
48- function createReactLoopHarness ( completions : LLMResponse [ ] ) {
52+ function createReactLoopHarness (
53+ completions : LLMResponse [ ] ,
54+ harnessOptions : { responseCompletionHooks ?: readonly ResponseCompletionHook [ ] } = { } ,
55+ ) {
4956 const parser = createParser ( ) ;
5057 const messages : LLMMessage [ ] = [ { role : 'user' , content : 'check reflection' } ] ;
5158 const systemNotes : string [ ] = [ ] ;
@@ -69,6 +76,9 @@ function createReactLoopHarness(completions: LLMResponse[]) {
6976
7077 const host = {
7178 activeProvider : 'openai' as const ,
79+ ...( harnessOptions . responseCompletionHooks
80+ ? { responseCompletionHooks : harnessOptions . responseCompletionHooks }
81+ : { } ) ,
7282 autoReportManager : { reportError : vi . fn ( async ( ) => { } ) } ,
7383 consecutiveCancellations : 0 ,
7484 contextOrchestrator : {
@@ -383,15 +393,29 @@ describe('Reflection loop guard logic', () => {
383393 expect ( needsReflection ) . toBe ( false ) ;
384394 } ) ;
385395
386- it ( 'forces a final response after the reflection violation limit is exceeded ' , ( ) => {
396+ it ( 'stops blocking after one reminder instead of stranding the turn ' , ( ) => {
387397 const guard = new ToolReflectionGuard ( ) ;
388398 const payload : AssistantReactPayload = {
389399 toolCalls : [ { tool : 'read_file' , args : { path : 'a.ts' } } ]
390400 } ;
391401 guard . expectReflection ( ) ;
392402
393403 expect ( guard . evaluate ( payload ) ) . toEqual ( { type : 'require_reflection' } ) ;
394- expect ( guard . evaluate ( payload ) ) . toEqual ( { type : 'force_final' } ) ;
404+ expect ( guard . evaluate ( payload ) ) . toEqual ( { type : 'proceed_unreflected' } ) ;
405+ } ) ;
406+
407+ it ( 'resets after standing down so a later reminder still fires once' , ( ) => {
408+ const guard = new ToolReflectionGuard ( ) ;
409+ const payload : AssistantReactPayload = {
410+ toolCalls : [ { tool : 'read_file' , args : { path : 'a.ts' } } ]
411+ } ;
412+
413+ guard . expectReflection ( ) ;
414+ expect ( guard . evaluate ( payload ) ) . toEqual ( { type : 'require_reflection' } ) ;
415+ expect ( guard . evaluate ( payload ) ) . toEqual ( { type : 'proceed_unreflected' } ) ;
416+
417+ guard . expectReflection ( ) ;
418+ expect ( guard . evaluate ( payload ) ) . toEqual ( { type : 'require_reflection' } ) ;
395419 } ) ;
396420
397421 it ( 'does not trigger guard on first iteration (no prior tool results)' , ( ) => {
@@ -505,7 +529,7 @@ describe('Reflection guard integration', () => {
505529 expect ( emittedMessages ) . toContain ( 'Stopped after reminder.' ) ;
506530 } ) ;
507531
508- it ( 'forces a tool-free response after a second unreflected follow-up attempt' , async ( ) => {
532+ it ( 'blocks the follow-up call once and then lets the next attempt through ' , async ( ) => {
509533 const { host, systemNotes, executedCalls, emittedMessages, complete } = createReactLoopHarness ( [
510534 {
511535 content : 'Initial lookup' ,
@@ -517,19 +541,20 @@ describe('Reflection guard integration', () => {
517541 } ,
518542 {
519543 content : 'still short' ,
520- toolCalls : [ createNativeToolCall ( 'call_3' , 'read_file' , { path : 'blocked-twice .ts' } ) ] ,
544+ toolCalls : [ createNativeToolCall ( 'call_3' , 'read_file' , { path : 'allowed-after-reminder .ts' } ) ] ,
521545 } ,
522546 {
523- content : '{"finalResponse":"Stopped after two missing reflections ."}' ,
547+ content : '{"finalResponse":"Finished after one reflection reminder ."}' ,
524548 } ,
525549 ] ) ;
526550
527551 await runAgentReactLoop ( host , new AbortController ( ) ) ;
528552
529- expect ( executedCalls . map ( ( call ) => call . id ) ) . toEqual ( [ 'call_1' ] ) ;
530- expect ( systemNotes . some ( ( note ) => note . startsWith ( '[Critical Reflection Guard]' ) ) ) . toBe ( true ) ;
531- expect ( complete . mock . calls [ 3 ] ?. [ 0 ] ?. tools ) . toBeUndefined ( ) ;
532- expect ( emittedMessages ) . toContain ( 'Stopped after two missing reflections.' ) ;
553+ expect ( executedCalls . map ( ( call ) => call . id ) ) . toEqual ( [ 'call_1' , 'call_3' ] ) ;
554+ expect ( systemNotes . some ( ( note ) => note . startsWith ( '[Reflection Required]' ) ) ) . toBe ( true ) ;
555+ expect ( systemNotes . some ( ( note ) => note . startsWith ( '[Critical Reflection Guard]' ) ) ) . toBe ( false ) ;
556+ expect ( complete . mock . calls [ 3 ] ?. [ 0 ] ?. tools ) . toBeDefined ( ) ;
557+ expect ( emittedMessages ) . toContain ( 'Finished after one reflection reminder.' ) ;
533558 } ) ;
534559
535560 it ( 'treats a missing-tool-output reflection as an integrity failure instead of re-running tools' , async ( ) => {
@@ -634,3 +659,140 @@ describe('System prompt includes reflection instructions', () => {
634659 expect ( prompt ) . toContain ( 'Reason + Reflect + Act' ) ;
635660 } ) ;
636661} ) ;
662+
663+ /* ── Regression: reflection guard must not strand a turn ──── */
664+
665+ /**
666+ * Reported symptom: the agent printed a reflection ending in "Let me try
667+ * reading those files now." and then stopped, never reading anything.
668+ *
669+ * Two independent defects produced it:
670+ * 1. The reflection guard escalated to a permanent tool ban, so the assistant
671+ * could no longer act even after it produced the reflection it was asked
672+ * for.
673+ * 2. `responseCompletionHooks` was never wired onto the real react-loop host,
674+ * so an announced-but-unexecuted action was rendered as the final answer
675+ * instead of being rejected and retried.
676+ */
677+ describe ( 'Reflection guard dead-end regression' , ( ) => {
678+ it ( 'lets the assistant keep working after a second unreflected tool call' , async ( ) => {
679+ const { host, systemNotes, executedCalls, emittedMessages, complete } = createReactLoopHarness ( [
680+ {
681+ content : 'Initial lookup' ,
682+ toolCalls : [ createNativeToolCall ( 'call_1' , 'read_file' , { path : 'first.ts' } ) ] ,
683+ } ,
684+ {
685+ content : 'short' ,
686+ toolCalls : [ createNativeToolCall ( 'call_2' , 'read_file' , { path : 'reminded.ts' } ) ] ,
687+ } ,
688+ {
689+ content : 'still short' ,
690+ toolCalls : [ createNativeToolCall ( 'call_3' , 'read_file' , { path : 'recovered.ts' } ) ] ,
691+ } ,
692+ {
693+ content : '{"finalResponse":"Both files read."}' ,
694+ } ,
695+ ] ) ;
696+
697+ await runAgentReactLoop ( host , new AbortController ( ) ) ;
698+
699+ expect ( executedCalls . map ( ( call ) => call . args ?. path ) ) . toEqual ( [ 'first.ts' , 'recovered.ts' ] ) ;
700+ expect ( systemNotes . some ( ( note ) => note . startsWith ( '[Reflection Required]' ) ) ) . toBe ( true ) ;
701+ expect ( systemNotes . some ( ( note ) => note . startsWith ( '[Critical Reflection Guard]' ) ) ) . toBe ( false ) ;
702+ expect ( complete . mock . calls [ 2 ] ?. [ 0 ] ?. tools ) . toBeDefined ( ) ;
703+ expect ( emittedMessages ) . toContain ( 'Both files read.' ) ;
704+ } ) ;
705+
706+ it ( 'does not ban tools for the rest of the turn once a reminder is ignored' , async ( ) => {
707+ const { host, complete, emittedMessages } = createReactLoopHarness ( [
708+ {
709+ content : 'Initial lookup' ,
710+ toolCalls : [ createNativeToolCall ( 'call_1' , 'read_file' , { path : 'first.ts' } ) ] ,
711+ } ,
712+ {
713+ content : 'short' ,
714+ toolCalls : [ createNativeToolCall ( 'call_2' , 'read_file' , { path : 'reminded.ts' } ) ] ,
715+ } ,
716+ {
717+ content : 'still short' ,
718+ toolCalls : [ createNativeToolCall ( 'call_3' , 'read_file' , { path : 'recovered.ts' } ) ] ,
719+ } ,
720+ {
721+ content : '{"reflection":"Both files described the delegator.","thought":"Now I can answer."}' ,
722+ toolCalls : [ createNativeToolCall ( 'call_4' , 'read_file' , { path : 'follow-up.ts' } ) ] ,
723+ } ,
724+ {
725+ content : '{"finalResponse":"Answered after recovering."}' ,
726+ } ,
727+ ] ) ;
728+
729+ await runAgentReactLoop ( host , new AbortController ( ) ) ;
730+
731+ for ( const call of complete . mock . calls ) {
732+ expect ( call [ 0 ] ?. tools ) . toBeDefined ( ) ;
733+ }
734+ expect ( emittedMessages ) . toContain ( 'Answered after recovering.' ) ;
735+ } ) ;
736+
737+ it ( 'rejects an announced-but-unexecuted action instead of presenting it as the answer' , async ( ) => {
738+ const announcement = [
739+ "I need to stop and reflect on what I've gathered so far before proceeding." ,
740+ '' ,
741+ 'I was trying to read the actual implementation files but the tool calls were blocked.' ,
742+ 'I need to read `src/commands/agents.ts` and `src/core/agents/AgentDelegator.ts` implementation' ,
743+ 'before I can plan the "kill/stop" feature.' ,
744+ '' ,
745+ 'Let me try reading those files now.' ,
746+ ] . join ( '\n' ) ;
747+
748+ const { host, systemNotes, executedCalls, emittedMessages } = createReactLoopHarness (
749+ [
750+ { content : announcement } ,
751+ {
752+ content : 'Reading the delegator now.' ,
753+ toolCalls : [ createNativeToolCall ( 'call_1' , 'read_file' , { path : 'AgentDelegator.ts' } ) ] ,
754+ } ,
755+ { content : '{"finalResponse":"`/agents` delegates through AgentDelegator."}' } ,
756+ ] ,
757+ { responseCompletionHooks : DEFAULT_RESPONSE_COMPLETION_HOOKS } ,
758+ ) ;
759+
760+ await runAgentReactLoop ( host , new AbortController ( ) ) ;
761+
762+ expect ( systemNotes . some ( ( note ) => note . includes ( 'announced an action but emitted no tool calls' ) ) ) . toBe ( true ) ;
763+ expect ( executedCalls . map ( ( call ) => call . args ?. path ) ) . toEqual ( [ 'AgentDelegator.ts' ] ) ;
764+ expect ( emittedMessages ) . toContain ( '`/agents` delegates through AgentDelegator.' ) ;
765+ expect ( emittedMessages ) . not . toContain ( announcement ) ;
766+ } ) ;
767+
768+ it ( 'does not police announced actions on a turn where tools were withheld' , async ( ) => {
769+ const { host, systemNotes, emittedMessages } = createReactLoopHarness (
770+ [
771+ {
772+ content : 'Initial lookup' ,
773+ toolCalls : [ createNativeToolCall ( 'call_1' , 'read_file' , { path : 'same.ts' } ) ] ,
774+ } ,
775+ {
776+ content : '{"reflection":"The previous tool outputs weren\'t visible in my context.","thought":"I should retry."}' ,
777+ toolCalls : [ createNativeToolCall ( 'call_2' , 'read_file' , { path : 'same.ts' } ) ] ,
778+ } ,
779+ { content : 'I need to read the file again before I can answer.' } ,
780+ ] ,
781+ { responseCompletionHooks : DEFAULT_RESPONSE_COMPLETION_HOOKS } ,
782+ ) ;
783+
784+ await runAgentReactLoop ( host , new AbortController ( ) ) ;
785+
786+ expect ( systemNotes . some ( ( note ) => note . startsWith ( '[Tool Result Integrity]' ) ) ) . toBe ( true ) ;
787+ expect ( systemNotes . some ( ( note ) => note . includes ( 'announced an action but emitted no tool calls' ) ) ) . toBe ( false ) ;
788+ expect ( emittedMessages ) . toContain ( 'I need to read the file again before I can answer.' ) ;
789+ } ) ;
790+
791+ it ( 'wires the response-completion hooks onto the real react-loop host' , ( ) => {
792+ const agent = createMinimalAgent ( ) ;
793+ const host = agent . createReactLoopHost ( ) ;
794+
795+ expect ( host . responseCompletionHooks ) . toEqual ( DEFAULT_RESPONSE_COMPLETION_HOOKS ) ;
796+ expect ( host . responseCompletionHooks . length ) . toBeGreaterThan ( 0 ) ;
797+ } ) ;
798+ } ) ;
0 commit comments