diff --git a/.changeset/wet-regions-fold.md b/.changeset/wet-regions-fold.md new file mode 100644 index 00000000..3a35e5b0 --- /dev/null +++ b/.changeset/wet-regions-fold.md @@ -0,0 +1,5 @@ +--- +'@openai/agents-core': patch +--- + +Continue agent execution when function calls are pending diff --git a/packages/agents-core/src/runImplementation.ts b/packages/agents-core/src/runImplementation.ts index c41a6f47..e79887c2 100644 --- a/packages/agents-core/src/runImplementation.ts +++ b/packages/agents-core/src/runImplementation.ts @@ -53,6 +53,7 @@ export type ProcessedResponse = { functions: ToolRunFunction[]; computerActions: ToolRunComputer[]; toolsUsed: string[]; + hasToolsOrApprovalsToRun(): boolean; }; /** @@ -147,6 +148,13 @@ export function processModelResponse( functions: runFunctions, computerActions: runComputerActions, toolsUsed: toolsUsed, + hasToolsOrApprovalsToRun(): boolean { + return ( + runHandoffs.length > 0 || + runFunctions.length > 0 || + runComputerActions.length > 0 + ); + }, }; } @@ -414,7 +422,10 @@ export async function executeToolsAndSideEffects( ); } - if (agent.outputType === 'text') { + if ( + agent.outputType === 'text' && + !processedResponse.hasToolsOrApprovalsToRun() + ) { return new SingleStepResult( originalInput, newResponse, @@ -425,7 +436,8 @@ export async function executeToolsAndSideEffects( output: potentialFinalOutput, }, ); - } else { + } else if (agent.outputType !== 'text' && potentialFinalOutput) { + // Structured output schema => always leads to a final output if we have text const { parser } = getSchemaAndParserFromInputType( agent.outputType, 'final_output', diff --git a/packages/agents-core/src/runState.ts b/packages/agents-core/src/runState.ts index 9ab5d928..6acb049a 100644 --- a/packages/agents-core/src/runState.ts +++ b/packages/agents-core/src/runState.ts @@ -694,7 +694,7 @@ async function deserializeProcessedResponse( }), ); - return { + const result = { newItems: serializedProcessedResponse.newItems.map((item) => deserializeItem(item, agentMap), ), @@ -735,4 +735,15 @@ async function deserializeProcessedResponse( }, ), }; + + return { + ...result, + hasToolsOrApprovalsToRun(): boolean { + return ( + result.handoffs.length > 0 || + result.functions.length > 0 || + result.computerActions.length > 0 + ); + }, + }; } diff --git a/packages/agents-core/test/runImplementation.test.ts b/packages/agents-core/test/runImplementation.test.ts index fdcf8ac9..96dc08d2 100644 --- a/packages/agents-core/test/runImplementation.test.ts +++ b/packages/agents-core/test/runImplementation.test.ts @@ -25,6 +25,7 @@ import { executeFunctionToolCalls, executeComputerActions, executeHandoffCalls, + executeToolsAndSideEffects, } from '../src/runImplementation'; import { FunctionTool, FunctionToolResult, tool } from '../src/tool'; import { handoff } from '../src/handoff'; @@ -78,6 +79,7 @@ describe('processModelResponse', () => { expect(result.newItems[1].rawItem).toEqual( TEST_MODEL_RESPONSE_WITH_FUNCTION.output[1], ); + expect(result.hasToolsOrApprovalsToRun()).toBe(true); }); }); @@ -438,6 +440,7 @@ describe('processModelResponse edge cases', () => { expect(result.computerActions[0]?.toolCall).toBe(compCall); expect(result.handoffs[0]?.toolCall).toBe(handCall); expect(result.toolsUsed).toEqual(['test', 'computer_use', h.toolName]); + expect(result.hasToolsOrApprovalsToRun()).toBe(true); expect(result.newItems[3]).toBeInstanceOf(MessageOutputItem); }); }); @@ -790,3 +793,133 @@ describe('empty execution helpers', () => { expect(comp).toEqual([]); }); }); + +describe('hasToolsOrApprovalsToRun method', () => { + it('returns true when handoffs are pending', () => { + const target = new Agent({ name: 'Target' }); + const h = handoff(target); + const response: ModelResponse = { + output: [{ ...TEST_MODEL_FUNCTION_CALL, name: h.toolName }], + usage: new Usage(), + } as any; + + const result = processModelResponse(response, TEST_AGENT, [], [h]); + expect(result.hasToolsOrApprovalsToRun()).toBe(true); + }); + + it('returns true when function calls are pending', () => { + const result = processModelResponse( + TEST_MODEL_RESPONSE_WITH_FUNCTION, + TEST_AGENT, + [TEST_TOOL], + [], + ); + expect(result.hasToolsOrApprovalsToRun()).toBe(true); + }); + + it('returns true when computer actions are pending', () => { + const computer = computerTool({ + computer: { + environment: 'mac', + dimensions: [10, 10], + screenshot: vi.fn(async () => 'img'), + click: vi.fn(async () => {}), + doubleClick: vi.fn(async () => {}), + drag: vi.fn(async () => {}), + keypress: vi.fn(async () => {}), + move: vi.fn(async () => {}), + scroll: vi.fn(async () => {}), + type: vi.fn(async () => {}), + wait: vi.fn(async () => {}), + }, + }); + const compCall: protocol.ComputerUseCallItem = { + id: 'c1', + type: 'computer_call', + callId: 'c1', + status: 'completed', + action: { type: 'screenshot' }, + }; + const response: ModelResponse = { + output: [compCall], + usage: new Usage(), + } as any; + + const result = processModelResponse(response, TEST_AGENT, [computer], []); + expect(result.hasToolsOrApprovalsToRun()).toBe(true); + }); + + it('returns false when no tools or approvals are pending', () => { + const response: ModelResponse = { + output: [TEST_MODEL_MESSAGE], + usage: new Usage(), + } as any; + + const result = processModelResponse(response, TEST_AGENT, [], []); + expect(result.hasToolsOrApprovalsToRun()).toBe(false); + }); +}); + +describe('executeToolsAndSideEffects', () => { + let runner: Runner; + let state: RunState; + + beforeEach(() => { + runner = new Runner({ tracingDisabled: true }); + state = new RunState(new RunContext(), 'test input', TEST_AGENT, 1); + }); + + it('continues execution when text agent has tools pending', async () => { + const textAgent = new Agent({ name: 'TextAgent', outputType: 'text' }); + const processedResponse = processModelResponse( + TEST_MODEL_RESPONSE_WITH_FUNCTION, + textAgent, + [TEST_TOOL], + [], + ); + + expect(processedResponse.hasToolsOrApprovalsToRun()).toBe(true); + + const result = await withTrace('test', () => + executeToolsAndSideEffects( + textAgent, + 'test input', + [], + TEST_MODEL_RESPONSE_WITH_FUNCTION, + processedResponse, + runner, + state, + ), + ); + + expect(result.nextStep.type).toBe('next_step_run_again'); + }); + + it('returns final output when text agent has no tools pending', async () => { + const textAgent = new Agent({ name: 'TextAgent', outputType: 'text' }); + const response: ModelResponse = { + output: [TEST_MODEL_MESSAGE], + usage: new Usage(), + } as any; + const processedResponse = processModelResponse(response, textAgent, [], []); + + expect(processedResponse.hasToolsOrApprovalsToRun()).toBe(false); + + const result = await withTrace('test', () => + executeToolsAndSideEffects( + textAgent, + 'test input', + [], + response, + processedResponse, + runner, + state, + ), + ); + + expect(result.nextStep.type).toBe('next_step_final_output'); + if (result.nextStep.type === 'next_step_final_output') { + expect(result.nextStep.output).toBe('Hello World'); + } + }); +}); diff --git a/packages/agents-core/test/runState.test.ts b/packages/agents-core/test/runState.test.ts index 609e9394..9984d100 100644 --- a/packages/agents-core/test/runState.test.ts +++ b/packages/agents-core/test/runState.test.ts @@ -253,6 +253,7 @@ describe('deserialize helpers', () => { handoffs: [], computerActions: [{ toolCall: call, computer: tool }], toolsUsed: [], + hasToolsOrApprovalsToRun: () => true, }; const restored = await RunState.fromString(agent, state.toString()); @@ -277,6 +278,7 @@ describe('deserialize helpers', () => { handoffs: [], computerActions: [{ toolCall: call, computer: tool }], toolsUsed: [], + hasToolsOrApprovalsToRun: () => true, }; state._currentStep = { type: 'next_step_handoff',