Skip to content
Closed
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/quiet-tools-wait.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eve": patch
---

Preserve input requests when the same model step also starts a subagent or remote-agent action.
15 changes: 15 additions & 0 deletions packages/eve/src/harness/input-requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
29 changes: 29 additions & 0 deletions packages/eve/src/harness/messages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }] },
Expand Down
6 changes: 6 additions & 0 deletions packages/eve/src/harness/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -49,6 +51,10 @@ export function coalesceTurnInputs(a: StepInput, b: StepInput): StepInput {
result.outputSchema = outputSchema;
}

if (runtimeActionResults.length > 0) {
result.runtimeActionResults = runtimeActionResults;
}

return result;
}

Expand Down
1 change: 1 addition & 0 deletions packages/eve/src/harness/runtime-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [] });
});
Expand Down
1 change: 1 addition & 0 deletions packages/eve/src/harness/runtime-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,7 @@ export async function resolvePendingRuntimeActions(input: {
role: "tool",
});
}
nextSession = { ...nextSession, history: messages };
return {
messages,
outcome: "resolved",
Expand Down
141 changes: 141 additions & 0 deletions packages/eve/src/harness/tool-loop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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",
Expand Down
61 changes: 47 additions & 14 deletions packages/eve/src/harness/tool-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;

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

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

Expand Down
Loading