Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/hitl-runtime-action-collision.md
Original file line number Diff line number Diff line change
@@ -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`).
74 changes: 74 additions & 0 deletions packages/eve/src/harness/tool-loop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
76 changes: 64 additions & 12 deletions packages/eve/src/harness/tool-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}

Expand Down Expand Up @@ -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),
};
}

Expand Down
Loading