diff --git a/.changeset/hitl-runtime-action-collision.md b/.changeset/hitl-runtime-action-collision.md new file mode 100644 index 000000000..692152a4b --- /dev/null +++ b/.changeset/hitl-runtime-action-collision.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +A model step that requests a tool approval (or question) and a subagent or remote-agent call in the same response no longer drops the approval. The harness now parks on both: the input request surfaces immediately, the delegation runs, and when its result arrives the turn re-parks on the still-pending approval instead of calling the model with a dangling tool call (`AI_MissingToolResultsError`). diff --git a/packages/eve/src/harness/tool-loop.test.ts b/packages/eve/src/harness/tool-loop.test.ts index 74ea2c3ed..10f56a741 100644 --- a/packages/eve/src/harness/tool-loop.test.ts +++ b/packages/eve/src/harness/tool-loop.test.ts @@ -913,6 +913,80 @@ describe("createToolLoopHarness", () => { ]); }); + it("parks on both batches when one step carries a runtime action and an approval", async () => { + const gateToolCall = { + input: { action: "run" }, + toolCallId: "gate-1", + toolName: "add", + type: "tool-call" as const, + }; + const delegateToolCall = { + input: { message: "probe" }, + toolCallId: "delegate-1", + toolName: "delegate", + type: "tool-call" as const, + }; + setupMockAgent({ + content: [ + gateToolCall, + { approvalId: "approval-1", toolCallId: "gate-1", type: "tool-approval-request" }, + delegateToolCall, + ], + finishReason: "tool-calls", + response: { + messages: [ + { + content: [ + gateToolCall, + { approvalId: "approval-1", toolCallId: "gate-1", type: "tool-approval-request" }, + delegateToolCall, + ], + role: "assistant", + }, + ], + }, + text: "", + toolCalls: [gateToolCall, delegateToolCall], + toolResults: [], + }); + + const { emit, events } = createEventCollector(); + const runStep = createToolLoopHarness( + createTestConfig("conversation", emit, { tools: createDelegationToolMap() }), + ); + + const parked = await runStep(createTestSession(), { message: "Gate and delegate." }); + + expect(parked.next).toBeNull(); + expect(getPendingRuntimeActionBatch(parked.session.state)?.actions).toEqual([ + expect.objectContaining({ callId: "delegate-1", kind: "subagent-call" }), + ]); + expect(hasPendingInputBatch(parked.session.state)).toBe(true); + expect(events.filter((event) => event.type === "input.requested")).toHaveLength(1); + + // Runtime action results resume the step; the unanswered approval must + // re-park with the resolved results committed to history — not reach the + // model with a dangling `gate-1` tool call. + const reparked = await runStep(parked.session, { + runtimeActionResults: [ + { + callId: "delegate-1", + kind: "subagent-result", + output: "delegated-done", + subagentName: "worker", + }, + ], + }); + + expect(reparked.next).toBeNull(); + expect(getPendingRuntimeActionBatch(reparked.session.state)).toBeUndefined(); + expect(hasPendingInputBatch(reparked.session.state)).toBe(true); + const toolMessages = reparked.session.history.filter((message) => message.role === "tool"); + expect(JSON.stringify(toolMessages)).toContain("delegated-done"); + expect(events.filter((event) => event.type === "subagent.completed")).toHaveLength(1); + expect(events.at(-1)?.type).toBe("session.waiting"); + }); + it("publishes declared subagent calls from deeply nested sessions", async () => { setupMockAgent({ finishReason: "tool-calls", diff --git a/packages/eve/src/harness/tool-loop.ts b/packages/eve/src/harness/tool-loop.ts index a9f205249..e3f05670f 100644 --- a/packages/eve/src/harness/tool-loop.ts +++ b/packages/eve/src/harness/tool-loop.ts @@ -585,6 +585,29 @@ export function createToolLoopHarness(config: ToolLoopHarnessConfig): StepFn { }; } + // A step that parked on both runtime actions and input requests + // resumes here with the action results resolved into `messages` but + // the input batch still unanswered. Commit the resolved messages + // before re-parking — parking on the untouched session would drop + // the action results from history — and close the turn like the + // plain input park does, so clients see the waiting boundary. + if (resolvedRuntimeActions.outcome === "resolved") { + let parkedSession: HarnessSession = { + ...pending.session, + history: [...resolvedRuntimeActions.messages], + }; + if (emit && config.mode === "conversation") { + emissionState = await emitTurnEpilogue( + emit, + emissionState, + config.mode, + parkedSession.continuationToken, + ); + parkedSession = setHarnessEmissionState(parkedSession, emissionState); + } + return { next: null, session: parkedSession }; + } + return { next: null, session: pending.session }; } @@ -1905,21 +1928,50 @@ async function handleStepResult(input: { // parked session carries the default emission state (turnId ""), // because the post-preamble `setHarnessEmissionState` is dropped by // the later `session = pending.session` / `maybeCompact` rebinds. - return { - next: null, - session: setHarnessEmissionState( - setPendingRuntimeActionBatch({ - actions: pendingRuntimeActions, - event: { + let parkedSession = setPendingRuntimeActionBatch({ + actions: pendingRuntimeActions, + event: { + sequence: emissionState.sequence, + stepIndex: emissionState.stepIndex, + turnId: emissionState.turnId, + }, + responseMessages, + session: { ...baseSession, history: [...promptMessages] }, + }); + + // The same step may also carry approval or question requests. Persist + // them alongside the runtime-action batch — with empty + // `responseMessages`, since the action batch owns the step's assistant + // messages — and surface them now so channels collect the answer while + // the actions run. The resumed step resolves the action results first, + // then re-parks on this batch (see `executeStepBody`). + if (inputRequests.length > 0) { + parkedSession = setPendingInputBatch({ + event: { + sequence: emissionState.sequence, + stepIndex: emissionState.stepIndex, + turnId: emissionState.turnId, + }, + requests: inputRequests, + responseMessages: [], + session: parkedSession, + }); + + if (emit) { + await emit( + createInputRequestedEvent({ + requests: inputRequests, sequence: emissionState.sequence, stepIndex: emissionState.stepIndex, turnId: emissionState.turnId, - }, - responseMessages, - session: { ...baseSession, history: [...promptMessages] }, - }), - emissionState, - ), + }), + ); + } + } + + return { + next: null, + session: setHarnessEmissionState(parkedSession, emissionState), }; }