From 0cb7d446d2a63ff0346418c843f2137bb3833ca3 Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Fri, 24 Jul 2026 08:41:20 -0400 Subject: [PATCH] fix(eve): keep approvals pending across a same-step runtime action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When one model step requested both an approval-gated tool call and a runtime action (subagent or remote-agent call), the park classification returned on the runtime-action batch before the input-request park ran, silently dropping the approval: input.requested was never emitted, and the action-result resume called the model with the gated call's tool_use still unanswered — AI_MissingToolResultsError, failing the turn. The classification now persists both batches (the input batch with empty responseMessages, since the action batch owns the step's assistant messages) and emits input.requested immediately. On the action-result resume, executeStepBody's existing staged resolution re-parks on the still-unanswered batch, committing the resolved action results to history and closing the turn with the standard waiting boundary. Signed-off-by: Rui Conti --- .changeset/hitl-runtime-action-collision.md | 5 ++ packages/eve/src/harness/tool-loop.test.ts | 74 ++++++++++++++++++++ packages/eve/src/harness/tool-loop.ts | 76 +++++++++++++++++---- 3 files changed, 143 insertions(+), 12 deletions(-) create mode 100644 .changeset/hitl-runtime-action-collision.md 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), }; }