diff --git a/.changeset/quiet-tools-wait.md b/.changeset/quiet-tools-wait.md new file mode 100644 index 0000000000..4025347049 --- /dev/null +++ b/.changeset/quiet-tools-wait.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Preserve input requests when the same model step also starts a subagent or remote-agent action. diff --git a/packages/eve/src/harness/input-requests.ts b/packages/eve/src/harness/input-requests.ts index d9a61541b6..8d8b588909 100644 --- a/packages/eve/src/harness/input-requests.ts +++ b/packages/eve/src/harness/input-requests.ts @@ -107,6 +107,21 @@ export function consumeDeferredStepInput(input: { }; } +/** + * Queues model-facing step input until a parked prerequisite resolves. + * Runtime-action results are intentionally omitted because callers resolve + * those against their own pending batch before deferring the remaining input. + */ +export function deferStepInput(input: { + readonly input?: StepInput; + readonly session: HarnessSession; +}): HarnessSession { + const deferredInput = compactStepInput(input.input); + return Object.keys(deferredInput).length === 0 + ? input.session + : queueDeferredStepInput(input.session, deferredInput); +} + /** * Returns true when the session carries queued follow-up input for the next * internal harness step. diff --git a/packages/eve/src/harness/messages.test.ts b/packages/eve/src/harness/messages.test.ts index daf28ba26d..7fd7f97d35 100644 --- a/packages/eve/src/harness/messages.test.ts +++ b/packages/eve/src/harness/messages.test.ts @@ -82,6 +82,35 @@ describe("coalesceTurnInputs", () => { }); }); + it("preserves runtime action results while merging deferred input", () => { + const first = { + callId: "call-1", + isError: true as const, + kind: "subagent-result" as const, + origin: "dispatch" as const, + output: "first failed", + subagentName: "researcher", + }; + const second = { + callId: "call-2", + isError: true as const, + kind: "subagent-result" as const, + origin: "dispatch" as const, + output: "second failed", + subagentName: "writer", + }; + + expect( + coalesceTurnInputs( + { inputResponses: [{ optionId: "deny", requestId: "approval-1" }], runtimeActionResults: [first] }, + { runtimeActionResults: [second] }, + ), + ).toEqual({ + inputResponses: [{ optionId: "deny", requestId: "approval-1" }], + runtimeActionResults: [first, second], + }); + }); + it("combines messages and inputResponses", () => { const result = coalesceTurnInputs( { message: "hello", inputResponses: [{ requestId: "r1", optionId: "approve" }] }, diff --git a/packages/eve/src/harness/messages.ts b/packages/eve/src/harness/messages.ts index 2479fdb3a7..2297ca56ec 100644 --- a/packages/eve/src/harness/messages.ts +++ b/packages/eve/src/harness/messages.ts @@ -25,12 +25,14 @@ export function coalesceTurnInputs(a: StepInput, b: StepInput): StepInput { b: b.context, }); const outputSchema = b.outputSchema ?? a.outputSchema; + const runtimeActionResults = [...(a.runtimeActionResults ?? []), ...(b.runtimeActionResults ?? [])]; const result: { inputResponses?: readonly InputResponse[]; message?: string | UserContent; context?: readonly string[]; outputSchema?: StepInput["outputSchema"]; + runtimeActionResults?: StepInput["runtimeActionResults"]; } = {}; if (inputResponses !== undefined) { @@ -49,6 +51,10 @@ export function coalesceTurnInputs(a: StepInput, b: StepInput): StepInput { result.outputSchema = outputSchema; } + if (runtimeActionResults.length > 0) { + result.runtimeActionResults = runtimeActionResults; + } + return result; } diff --git a/packages/eve/src/harness/runtime-actions.test.ts b/packages/eve/src/harness/runtime-actions.test.ts index 89241038f7..cbae3c8f71 100644 --- a/packages/eve/src/harness/runtime-actions.test.ts +++ b/packages/eve/src/harness/runtime-actions.test.ts @@ -127,6 +127,7 @@ describe("resolvePendingRuntimeActions", () => { }); expect(resolved.outcome).toBe("resolved"); + expect(resolved.session.history).toEqual(resolved.messages); expect(getPendingRuntimeActionBatch(resolved.session.state)).toBeUndefined(); expect(getAgentHandleStore(resolved.session.state)).toEqual({ handles: [] }); }); diff --git a/packages/eve/src/harness/runtime-actions.ts b/packages/eve/src/harness/runtime-actions.ts index 05cdc445d5..c0f158fb54 100644 --- a/packages/eve/src/harness/runtime-actions.ts +++ b/packages/eve/src/harness/runtime-actions.ts @@ -324,6 +324,7 @@ export async function resolvePendingRuntimeActions(input: { role: "tool", }); } + nextSession = { ...nextSession, history: messages }; return { messages, outcome: "resolved", diff --git a/packages/eve/src/harness/tool-loop.test.ts b/packages/eve/src/harness/tool-loop.test.ts index d5ab19cef5..01853b15dd 100644 --- a/packages/eve/src/harness/tool-loop.test.ts +++ b/packages/eve/src/harness/tool-loop.test.ts @@ -182,6 +182,65 @@ function createDelegationToolMap(): ToolLoopHarnessConfig["tools"] { ]); } +function setupMixedInputRuntimeActionStep() { + const gatedCall = { + input: { action: "run" }, + toolCallId: "gated-call", + toolName: "add", + type: "tool-call" as const, + }; + const delegatedCall = { + input: { message: "probe" }, + toolCallId: "delegated-call", + toolName: "delegate", + type: "tool-call" as const, + }; + const approvalRequest = { + approvalId: "approval-1", + toolCallId: gatedCall.toolCallId, + type: "tool-approval-request" as const, + }; + setupMockAgent({ + content: [gatedCall, approvalRequest, delegatedCall], + finishReason: "tool-calls", + response: { + messages: [ + { + content: [gatedCall, approvalRequest, delegatedCall], + role: "assistant", + }, + ], + }, + text: "", + toolCalls: [gatedCall, delegatedCall], + toolResults: [], + }); + + return { + delegatedCall, + runtimeActionResults: [ + { + callId: delegatedCall.toolCallId, + isError: true as const, + kind: "subagent-result" as const, + origin: "dispatch" as const, + output: "delegated-failed", + subagentName: "worker", + }, + ], + }; +} + +function setupTerminalMockAgent(): void { + setupMockAgent({ + finishReason: "stop", + response: { messages: [{ content: "Done.", role: "assistant" }] }, + text: "Done.", + toolCalls: [], + toolResults: [], + }); +} + function createScheduleContext(): ContextContainer { const ctx = new ContextContainer(); ctx.set(AuthKey, SCHEDULE_APP_AUTH); @@ -1140,6 +1199,88 @@ describe("createToolLoopHarness", () => { ]); }); + it("keeps input requests pending after a same-step runtime action resolves", async () => { + const { delegatedCall, runtimeActionResults } = setupMixedInputRuntimeActionStep(); + const { emit, events } = createEventCollector(); + const runStep = createToolLoopHarness( + createTestConfig("conversation", emit, { tools: createDelegationToolMap() }), + ); + const parked = await runStep(createTestSession(), { message: "Gate and delegate." }); + + expect(getPendingRuntimeActionBatch(parked.session.state)?.actions).toEqual([ + expect.objectContaining({ callId: delegatedCall.toolCallId, kind: "subagent-call" }), + ]); + expect(hasPendingInputBatch(parked.session.state)).toBe(true); + expect(events.filter((event) => event.type === "input.requested")).toHaveLength(1); + + const reparked = await runStep(parked.session, { runtimeActionResults }); + + expect(reparked.next).toBeNull(); + expect(getPendingRuntimeActionBatch(reparked.session.state)).toBeUndefined(); + expect(hasPendingInputBatch(reparked.session.state)).toBe(true); + expect(JSON.stringify(reparked.session.history)).toContain("delegated-failed"); + expect(events.at(-1)?.type).toBe("session.waiting"); + + setupTerminalMockAgent(); + const completed = await runStep(reparked.session, { + inputResponses: [{ optionId: "deny", requestId: "approval-1" }], + }); + + expect(hasPendingInputBatch(completed.session.state)).toBe(false); + expect( + completed.session.history.filter( + (message) => + message.role === "assistant" && + JSON.stringify(message).includes(delegatedCall.toolCallId), + ), + ).toHaveLength(1); + expect(events.filter((event) => event.type === "turn.started")).toHaveLength(2); + expect(events.filter((event) => event.type === "turn.completed")).toHaveLength(2); + }); + + it("defers an input response until its same-step runtime action resolves", async () => { + const { delegatedCall, runtimeActionResults } = setupMixedInputRuntimeActionStep(); + const { emit, events } = createEventCollector(); + const runStep = createToolLoopHarness( + createTestConfig("conversation", emit, { tools: createDelegationToolMap() }), + ); + const parked = await runStep(createTestSession(), { message: "Gate and delegate." }); + const answeredEarly = await runStep(parked.session, { + inputResponses: [{ optionId: "deny", requestId: "approval-1" }], + }); + + expect(answeredEarly.next).toBeNull(); + expect(hasDeferredStepInput(answeredEarly.session)).toBe(true); + expect(hasPendingInputBatch(answeredEarly.session.state)).toBe(true); + + setupTerminalMockAgent(); + const completed = await runStep(answeredEarly.session, { runtimeActionResults }); + + expect(completed.next).toBeNull(); + expect(getPendingRuntimeActionBatch(completed.session.state)).toBeUndefined(); + expect(hasPendingInputBatch(completed.session.state)).toBe(false); + expect(hasDeferredStepInput(completed.session)).toBe(false); + expect( + completed.session.history.filter( + (message) => + message.role === "assistant" && + JSON.stringify(message).includes(delegatedCall.toolCallId), + ), + ).toHaveLength(1); + const turnEvents = events.filter( + (event) => event.type === "turn.started" || event.type === "turn.completed", + ); + expect( + turnEvents + .filter((event) => event.type === "turn.completed") + .map((event) => event.data.turnId), + ).toEqual( + turnEvents + .filter((event) => event.type === "turn.started") + .map((event) => event.data.turnId), + ); + }); + it("parks dynamic subagent calls as pending runtime actions", async () => { setupMockAgent({ finishReason: "tool-calls", diff --git a/packages/eve/src/harness/tool-loop.ts b/packages/eve/src/harness/tool-loop.ts index fbf2c0350c..02016e778c 100644 --- a/packages/eve/src/harness/tool-loop.ts +++ b/packages/eve/src/harness/tool-loop.ts @@ -121,6 +121,7 @@ import { resolveParentLineage } from "#harness/parent-lineage.js"; import { ChannelKey } from "#runtime/sessions/runtime-context-keys.js"; import { consumeDeferredStepInput, + deferStepInput, getApprovedTools, getPendingInputRequestIds, hasDeferredStepInput, @@ -657,7 +658,13 @@ export function createToolLoopHarness(config: ToolLoopHarnessConfig): StepFn { stepInput: stepInput.input, }); if (resolvedRuntimeActions.outcome === "unresolved") { - return { next: null, session: resolvedRuntimeActions.session }; + return { + next: null, + session: deferStepInput({ + input: stepInput.input, + session: resolvedRuntimeActions.session, + }), + }; } session = resolvedRuntimeActions.session; @@ -700,6 +707,14 @@ export function createToolLoopHarness(config: ToolLoopHarnessConfig): StepFn { }; } + if (emit && config.mode === "conversation" && resolvedRuntimeActions.outcome === "resolved") { + emissionState = await emitTurnEpilogue(emit, emissionState, config.mode); + return { + next: null, + session: setHarnessEmissionState(pending.session, emissionState), + }; + } + return { next: null, session: pending.session }; } @@ -2115,21 +2130,39 @@ 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. + const requestEvent = { + sequence: emissionState.sequence, + stepIndex: emissionState.stepIndex, + turnId: emissionState.turnId, + }; + let parkedSession = setPendingRuntimeActionBatch({ + actions: pendingRuntimeActions, + event: requestEvent, + responseMessages, + session: { ...baseSession, history: [...promptMessages] }, + }); + + if (inputRequests.length > 0) { + parkedSession = setPendingInputBatch({ + event: requestEvent, + requests: inputRequests, + responseMessages: [], + session: parkedSession, + }); + + await emit?.( + createInputRequestedEvent({ + requests: inputRequests, + sequence: emissionState.sequence, + stepIndex: emissionState.stepIndex, + turnId: emissionState.turnId, + }), + ); + } + return { next: null, - session: setHarnessEmissionState( - setPendingRuntimeActionBatch({ - actions: pendingRuntimeActions, - event: { - sequence: emissionState.sequence, - stepIndex: emissionState.stepIndex, - turnId: emissionState.turnId, - }, - responseMessages, - session: { ...baseSession, history: [...promptMessages] }, - }), - emissionState, - ), + session: setHarnessEmissionState(parkedSession, emissionState), }; }