Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { defineAgent } from "eve";

export default defineAgent({
description:
"Test-only child for session-limit propagation. Call it exactly when the user asks for the limited-worker subagent.",
limits: {
maxInputTokensPerSession: 1,
},
model: process.env.EVE_E2E_MODEL ?? "openai/gpt-5.6-sol",
reasoning: "high",
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
You are the limited-worker session-limit fixture.

Call `complete-step` exactly once. After it returns, reply with exactly
`CHILD_LIMIT_CONTINUED` and nothing else.
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { defineTool } from "eve/tools";
import { z } from "zod";

export default defineTool({
description:
"Completes the limited-worker's deterministic test step. Call this exactly once before replying.",
inputSchema: z.object({}),
execute() {
return { completed: true };
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { defineEval } from "eve/evals";
import { equals, satisfies } from "eve/evals/expect";

const CHILD_TOKEN = "CHILD_LIMIT_CONTINUED";
const ROOT_RECOVERY_TOKEN = "ROOT_AFTER_DESCENDANT_STOP";

const DELEGATE_PROMPT = [
"Call the limited-worker subagent exactly once.",
"Tell it to follow its instructions.",
`After it returns, reply with exactly ${CHILD_TOKEN} and nothing else.`,
].join(" ");

/**
* The limited child crosses its one-token budget after calling complete-step.
* Its continuation prompt must surface on the root session and the answer must
* route back to the child that minted it.
*/
export default defineEval({
description:
"A descendant session-limit prompt reaches the root; continue resumes the child and stop leaves the root session reusable.",
timeoutMs: 90_000,
async test(t) {
await t.send(DELEGATE_PROMPT);
const continueRequest = t.requireInputRequest({
display: "confirmation",
optionIds: ["continue", "stop"],
toolName: "session_limit_continuation",
});
const rootSessionId = t.sessionId;
if (rootSessionId === undefined) {
throw new Error("The root session did not expose its session id.");
}
await t.require(
continueRequest.requestId,
satisfies(
(requestId: string) => !requestId.startsWith(`${rootSessionId}:limit:`),
"continuation request belongs to a descendant session",
),
);

const resumed = await t.respond({
optionId: "continue",
requestId: continueRequest.requestId,
});
resumed.expectOk();
t.succeeded();
t.calledSubagent("limited-worker", { count: 1, output: CHILD_TOKEN });
t.messageIncludes(CHILD_TOKEN);
t.noFailedActions();

const stopSession = t.newSession();
await stopSession.send(DELEGATE_PROMPT);
const stopRequest = stopSession.requireInputRequest({
display: "confirmation",
optionIds: ["continue", "stop"],
toolName: "session_limit_continuation",
});

const stopped = await stopSession.respond({
optionId: "stop",
requestId: stopRequest.requestId,
});
stopped.expectOk();
stopSession.notEvent("turn.failed");
stopSession.notEvent("session.failed");
stopSession.notEvent("session.completed");
stopSession.event("turn.cancelled");
t.check(stopped.status, equals("waiting"));

const recovered = await stopSession.send(
`Do not call any tool or subagent. Reply with exactly ${ROOT_RECOVERY_TOKEN} and nothing else.`,
);
recovered.expectOk();
stopSession.succeeded();
stopSession.calledSubagent("limited-worker", { count: 1, status: "pending" });
stopSession.messageIncludes(ROOT_RECOVERY_TOKEN);
},
});
15 changes: 8 additions & 7 deletions packages/eve/src/execution/route-child-delivery.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import type { DeliverPayload, SessionAuthContext } from "#channel/types.js";
import { coalesceDeliverPayloads } from "#execution/deliver-payloads.js";
import type { DurableSessionState } from "#execution/durable-session-store.js";
import { routeProxiedDeliverStep } from "#execution/workflow-steps.js";
import { routeProxiedDeliverStep, type RoutedDeliverResult } from "#execution/workflow-steps.js";

/**
* Coalesces inbound deliver payloads and routes any descendant-bound input
* responses down to the owning child, returning the parent-local remainder
* (or `undefined` when the whole payload routed away).
* responses down to the owning child. A descendant session-limit Stop is
* returned as parent-owned turn control after the child consumes the answer.
*
* Short-circuits via `hasProxyInputRequests` so the common no-active-descendant
* path skips a durable step boundary. Lives in its own non-step module so both
Expand All @@ -18,15 +18,16 @@ export async function routeDeliverToChildren(input: {
readonly parentWritable: WritableStream<Uint8Array>;
readonly payloads: readonly DeliverPayload[];
readonly sessionState: DurableSessionState;
}): Promise<DeliverPayload | undefined> {
}): Promise<RoutedDeliverResult> {
const payload = coalesceDeliverPayloads(input.payloads);
if (!input.sessionState.hasProxyInputRequests) return payload;
if (!input.sessionState.hasProxyInputRequests) {
return { kind: "continue", remainder: payload };
}

const routed = await routeProxiedDeliverStep({
return await routeProxiedDeliverStep({
auth: input.auth,
parentWritable: input.parentWritable,
payload,
sessionState: input.sessionState,
});
return routed.remainder;
}
9 changes: 8 additions & 1 deletion packages/eve/src/execution/settle-cancelled-turn-step.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
} from "#harness/emission.js";
import {
clearAllProxyInputRequests,
getProxyInputRequests,
hasProxyInputRequests,
} from "#harness/proxy-input-requests.js";
import { clearPendingRuntimeActionBatch } from "#harness/runtime-actions.js";
Expand Down Expand Up @@ -71,8 +72,14 @@ export async function settleCancelledTurnStep(input: {
// A descendant HITL wait already streamed this turn's waiting boundary
// (the proxy epilogue clears the turn id); re-emitting would fabricate
// a turn id and duplicate the boundary.
const proxyRequests = getProxyInputRequests(durableSession.state);
const stoppedAtDescendantLimit = [...proxyRequests.values()].some(
(request) => request.kind === "session-limit",
);
const alreadyEpilogued =
isHarnessBetweenTurns(session) && hasProxyInputRequests(durableSession.state);
isHarnessBetweenTurns(session) &&
hasProxyInputRequests(durableSession.state) &&
!stoppedAtDescendantLimit;

if (!alreadyEpilogued) {
const writer = input.parentWritable.getWriter();
Expand Down
6 changes: 2 additions & 4 deletions packages/eve/src/execution/subagent-event-proxy-step.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { reconcileSessionContinuationToken } from "#execution/reconcile-session-
import { hydrateDurableSession } from "#execution/session.js";
import { emitProxiedInputRequest } from "#execution/subagent-hitl-proxy.js";
import { upsertProxyInputRequests } from "#harness/proxy-input-requests.js";
import type { ProxyInputRequest } from "#harness/proxy-input-requests.js";
import type { HarnessSession } from "#harness/types.js";
import type { UnstampedMessageStreamEvent } from "#protocol/message.js";
import { encodeMessageStreamEvent, stampMessageStreamEvent } from "#protocol/message.js";
Expand All @@ -28,10 +29,7 @@ type SubagentEventHookPayload =
| SubagentAuthorizationEventHookPayload
| SubagentInputRequestHookPayload;

type ProxyInputRequestEntries = readonly (readonly [
requestId: string,
childContinuationToken: string,
])[];
type ProxyInputRequestEntries = readonly (readonly [requestId: string, route: ProxyInputRequest])[];

interface ProxySubagentEventResult {
readonly serializedContext: Record<string, unknown>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,15 @@ describe("subagent HITL proxy → Slack-style text-approve regression (Finding #
expect((afterEmitAdapter.state as SlackishState | undefined)?.pendingRequests).toEqual([
approvalRequest,
]);
expect(entries).toEqual([["req-approve-1", "subagent:parent:call-1"]]);
expect(entries).toEqual([
[
"req-approve-1",
{
childContinuationToken: "subagent:parent:call-1",
kind: "tool-approval",
},
],
]);

// The parent is in conversation mode, so the helper follows the
// proxied `input.requested` with a `turn.completed` +
Expand Down
29 changes: 26 additions & 3 deletions packages/eve/src/execution/subagent-hitl-proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,10 @@ function createSession(state?: Record<string, unknown>): HarnessSession {
describe("routeDeliverPayload", () => {
it("routes responses to matching descendants and keeps unknown ones on forSelf", () => {
const session = upsertProxyInputRequests({
entries: [["req-a", "child-a"]],
entries: [["req-a", { childContinuationToken: "child-a", kind: "tool-approval" }]],
forChildContinuationToken: "child-a",
session: upsertProxyInputRequests({
entries: [["req-b", "child-b"]],
entries: [["req-b", { childContinuationToken: "child-b", kind: "tool-approval" }]],
forChildContinuationToken: "child-b",
session: createSession(),
}),
Expand Down Expand Up @@ -69,7 +69,7 @@ describe("routeDeliverPayload", () => {

it("returns forSelf as undefined when every response routes to a descendant", () => {
const session = upsertProxyInputRequests({
entries: [["req-a", "child-a"]],
entries: [["req-a", { childContinuationToken: "child-a", kind: "tool-approval" }]],
forChildContinuationToken: "child-a",
session: createSession(),
});
Expand All @@ -84,4 +84,27 @@ describe("routeDeliverPayload", () => {
expect(routed.forChildren).toHaveLength(1);
expect(routed.forSelf).toBeUndefined();
});

it("asks the parent to cancel after routing Stop to a descendant session-limit request", () => {
const session = upsertProxyInputRequests({
entries: [["req-limit", { childContinuationToken: "child-a", kind: "session-limit" }]],
forChildContinuationToken: "child-a",
session: createSession(),
});

const routed = routeDeliverPayload({
payload: {
inputResponses: [{ optionId: "stop", requestId: "req-limit" }],
},
state: session.state,
});

expect(routed.forChildren).toEqual([
{
childContinuationToken: "child-a",
payload: { inputResponses: [{ optionId: "stop", requestId: "req-limit" }] },
},
]);
expect(routed.parentAction).toEqual({ kind: "cancel-turn" });
});
});
20 changes: 14 additions & 6 deletions packages/eve/src/execution/subagent-hitl-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ import {
getProxyInputRequests,
toProxyInputRequestEntries,
} from "#harness/proxy-input-requests.js";
import type { ProxyInputRequest } from "#harness/proxy-input-requests.js";
import type { HarnessEmitFn, HarnessSession, SessionStateMap } from "#harness/types.js";
import { createInputRequestedEvent } from "#protocol/message.js";
import type { RunMode } from "#shared/run-mode.js";
import type { InputResponse } from "#runtime/input/types.js";
import { SESSION_LIMIT_STOP_OPTION_ID } from "#harness/session-limit-continuation.js";

// ---------------------------------------------------------------------------
// Upward proxy emission
Expand All @@ -28,7 +30,7 @@ export async function emitProxiedInputRequest(input: {
readonly mode: RunMode;
readonly session: HarnessSession;
}): Promise<{
readonly entries: readonly (readonly [requestId: string, childContinuationToken: string])[];
readonly entries: readonly (readonly [requestId: string, route: ProxyInputRequest])[];
readonly session: HarnessSession;
}> {
await input.emit(
Expand Down Expand Up @@ -74,6 +76,7 @@ export interface RoutedDeliverPayload {
readonly payload: { readonly inputResponses: readonly InputResponse[] };
}[];
readonly forSelf: DeliverPayload | undefined;
readonly parentAction: { readonly kind: "cancel-turn" } | undefined;
}

/** Splits a deliver payload into parent-local and proxied-child buckets. */
Expand All @@ -86,19 +89,24 @@ export function routeDeliverPayload(input: {

const responsesByChild = new Map<string, InputResponse[]>();
const unroutedResponses: InputResponse[] = [];
let parentAction: RoutedDeliverPayload["parentAction"];

for (const response of inputResponses) {
const childContinuationToken = entries.get(response.requestId);
const route = entries.get(response.requestId);

if (childContinuationToken === undefined) {
if (route === undefined) {
unroutedResponses.push(response);
continue;
}

const existing = responsesByChild.get(childContinuationToken);
if (route.kind === "session-limit" && response.optionId === SESSION_LIMIT_STOP_OPTION_ID) {
parentAction = { kind: "cancel-turn" };
}

const existing = responsesByChild.get(route.childContinuationToken);

if (existing === undefined) {
responsesByChild.set(childContinuationToken, [response]);
responsesByChild.set(route.childContinuationToken, [response]);
} else {
existing.push(response);
}
Expand Down Expand Up @@ -130,5 +138,5 @@ export function routeDeliverPayload(input: {

const forSelf = Object.keys(remainder).length > 0 ? (remainder as DeliverPayload) : undefined;

return { forChildren, forSelf };
return { forChildren, forSelf, parentAction };
}
Loading
Loading