From 33dd25930bd7bbfbd9c50d73bbff1876f9055ef4 Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Mon, 3 Aug 2026 15:01:38 -0400 Subject: [PATCH 1/4] fix(eve): coalesce task-await results with task wakes Signed-off-by: Rui Conti --- .changeset/task-await-wake-coalescing.md | 5 + packages/eve/src/channel/types.ts | 5 + .../execution/parked-delivery-wait.test.ts | 73 +++++++++++++++ .../eve/src/execution/parked-delivery-wait.ts | 21 ++++- .../execution/settle-cancelled-turn-step.ts | 19 ++-- packages/eve/src/execution/tasks/dispatch.ts | 11 ++- packages/eve/src/execution/tasks/run-steps.ts | 13 ++- .../execution/tasks/wake-suppression-step.ts | 41 +++++++++ .../eve/src/execution/workflow-entry.test.ts | 6 ++ packages/eve/src/execution/workflow-entry.ts | 3 +- .../eve/src/tasks/wake-suppression.test.ts | 55 +++++++++++ packages/eve/src/tasks/wake-suppression.ts | 91 +++++++++++++++++++ 12 files changed, 328 insertions(+), 15 deletions(-) create mode 100644 .changeset/task-await-wake-coalescing.md create mode 100644 packages/eve/src/execution/parked-delivery-wait.test.ts create mode 100644 packages/eve/src/execution/tasks/wake-suppression-step.ts create mode 100644 packages/eve/src/tasks/wake-suppression.test.ts create mode 100644 packages/eve/src/tasks/wake-suppression.ts diff --git a/.changeset/task-await-wake-coalescing.md b/.changeset/task-await-wake-coalescing.md new file mode 100644 index 000000000..7f658b379 --- /dev/null +++ b/.changeset/task-await-wake-coalescing.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Prevent task completion from starting a duplicate parent turn when `task_await` already reports that ready transition. Cancelled awaits release their wake claims so later task completion still wakes the parent. diff --git a/packages/eve/src/channel/types.ts b/packages/eve/src/channel/types.ts index 3b8abac3f..c462fbcc0 100644 --- a/packages/eve/src/channel/types.ts +++ b/packages/eve/src/channel/types.ts @@ -173,6 +173,11 @@ export interface DeliverPayload { readonly message?: string | UserContent; readonly context?: readonly string[]; readonly outputSchema?: JsonObject; + /** Framework task-ready notification; never authored by a channel caller. */ + readonly taskNotification?: { + readonly status: "input_required" | "completed" | "failed" | "cancelled"; + readonly taskId: string; + }; readonly [key: string]: unknown; } diff --git a/packages/eve/src/execution/parked-delivery-wait.test.ts b/packages/eve/src/execution/parked-delivery-wait.test.ts new file mode 100644 index 000000000..590ee4eb5 --- /dev/null +++ b/packages/eve/src/execution/parked-delivery-wait.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { DeliverHookPayload } from "#channel/types.js"; +import { nextTurnDelivery } from "#execution/parked-delivery-wait.js"; +import type { SessionCommandInbox } from "#execution/session-command-inbox.js"; +import { filterAwaitedTaskWakePayloadsStep } from "#execution/tasks/wake-suppression-step.js"; +import { routeDeliverToChildren } from "#execution/route-child-delivery.js"; + +vi.mock("./tasks/wake-suppression-step.js", () => ({ + filterAwaitedTaskWakePayloadsStep: vi.fn(), +})); + +vi.mock("./route-child-delivery.js", () => ({ + routeDeliverToChildren: vi.fn(), +})); + +describe("nextTurnDelivery task wake suppression", () => { + it("routes only unsuppressed payloads and carries the updated session state", async () => { + const taskWake = { + kind: "deliver", + payloads: [ + { + message: "task done", + taskNotification: { status: "completed", taskId: "task_1" }, + }, + { message: "ordinary delivery" }, + ], + } satisfies DeliverHookPayload; + const initialState = { + continuationToken: "token", + emissionState: { sequence: 0, sessionStarted: false, stepIndex: 0, turnId: "turn" }, + hasProxyInputRequests: false, + sessionId: "session", + version: 1, + } as const; + const filteredState = { ...initialState, continuationToken: "next-token" }; + vi.mocked(filterAwaitedTaskWakePayloadsStep).mockResolvedValue({ + payloads: [{ message: "ordinary delivery" }], + sessionState: filteredState, + }); + vi.mocked(routeDeliverToChildren).mockResolvedValue({ + kind: "continue", + remainder: { message: "ordinary delivery" }, + }); + const commandInbox: SessionCommandInbox = { + claimStable: vi.fn(), + consumeNext: vi.fn(), + next: vi.fn(), + rekeyContinuation: vi.fn(), + }; + + const result = await nextTurnDelivery({ + bufferedDeliveries: [taskWake], + bufferedSessionControls: [], + commandInbox, + driverWritable: new WritableStream(), + serializedContext: {}, + sessionState: initialState, + }); + + expect(result).toMatchObject({ + kind: "turn", + remainder: { message: "ordinary delivery" }, + sessionState: filteredState, + }); + expect(routeDeliverToChildren).toHaveBeenCalledWith( + expect.objectContaining({ + payloads: [{ message: "ordinary delivery" }], + sessionState: filteredState, + }), + ); + }); +}); diff --git a/packages/eve/src/execution/parked-delivery-wait.ts b/packages/eve/src/execution/parked-delivery-wait.ts index 84c0fb1a2..19abab0d4 100644 --- a/packages/eve/src/execution/parked-delivery-wait.ts +++ b/packages/eve/src/execution/parked-delivery-wait.ts @@ -2,6 +2,7 @@ import type { DeliverHookPayload, DeliverPayload, SessionCommand } from "#channe import type { DurableSessionState } from "#execution/durable-session-store.js"; import { routeDeliverToChildren } from "#execution/route-child-delivery.js"; import type { SessionCommandInbox } from "#execution/session-command-inbox.js"; +import { filterAwaitedTaskWakePayloadsStep } from "#execution/tasks/wake-suppression-step.js"; import { coalesceDeliveries } from "#harness/messages.js"; type NextSessionAction = @@ -26,6 +27,7 @@ export type NextTurnInstruction = readonly kind: "turn"; readonly deliver: DeliverHookPayload; readonly remainder: DeliverPayload; + readonly sessionState: DurableSessionState; }; /** @@ -40,8 +42,10 @@ export async function nextTurnDelivery(input: { readonly bufferedSessionControls: Array<"clear" | "compact" | "expired" | "reset">; readonly commandInbox: SessionCommandInbox; readonly driverWritable: WritableStream; + readonly serializedContext: Record; readonly sessionState: DurableSessionState; }): Promise { + let sessionState = input.sessionState; while (true) { const nextAction = await waitForNextSessionAction({ bufferedDeliveries: input.bufferedDeliveries, @@ -58,11 +62,22 @@ export async function nextTurnDelivery(input: { return { kind: "closed" }; } + const filtered = await filterAwaitedTaskWakePayloadsStep({ + payloads: deliver.payloads, + serializedContext: input.serializedContext, + sessionState, + }); + sessionState = filtered.sessionState; + if (filtered.payloads.length === 0) { + // A completed task_await already reported every task in this delivery. + continue; + } + const routed = await routeDeliverToChildren({ auth: deliver.auth, parentWritable: input.driverWritable, - payloads: deliver.payloads, - sessionState: input.sessionState, + payloads: filtered.payloads, + sessionState, }); if (routed.kind === "cancel-turn") { @@ -74,7 +89,7 @@ export async function nextTurnDelivery(input: { continue; } - return { deliver, kind: "turn", remainder: routed.remainder }; + return { deliver, kind: "turn", remainder: routed.remainder, sessionState }; } } diff --git a/packages/eve/src/execution/settle-cancelled-turn-step.ts b/packages/eve/src/execution/settle-cancelled-turn-step.ts index 2c65f258b..5104dce37 100644 --- a/packages/eve/src/execution/settle-cancelled-turn-step.ts +++ b/packages/eve/src/execution/settle-cancelled-turn-step.ts @@ -34,6 +34,7 @@ import { type UnstampedMessageStreamEvent, stampMessageStreamEvent, } from "#protocol/message.js"; +import { clearAwaitedTaskWakeSuppressions } from "#tasks/wake-suppression.js"; import { BundleKey, ChannelKey } from "#runtime/sessions/runtime-context-keys.js"; import { resolveEffectiveAgentRuntime } from "#execution/effective-agent-config.js"; @@ -132,17 +133,19 @@ export async function settleCancelledTurnStep(input: { // the cancelled turn's inbox is gone, so a child settlement can never // reach this store again. This is the last write that can move those // handles out of `running`. - const cancelledSession = reconcileSessionContinuationToken( - ctx, - setHarnessEmissionState( - clearPendingSessionLimitPrompt( - clearAllProxyInputRequests( - clearPendingWorkflowInterrupt( - clearPendingRuntimeActionBatch(abandonRunningAgentTurns(session)), + const cancelledSession = clearAwaitedTaskWakeSuppressions( + reconcileSessionContinuationToken( + ctx, + setHarnessEmissionState( + clearPendingSessionLimitPrompt( + clearAllProxyInputRequests( + clearPendingWorkflowInterrupt( + clearPendingRuntimeActionBatch(abandonRunningAgentTurns(session)), + ), ), ), + emissionState, ), - emissionState, ), ); diff --git a/packages/eve/src/execution/tasks/dispatch.ts b/packages/eve/src/execution/tasks/dispatch.ts index 2440ff773..713648830 100644 --- a/packages/eve/src/execution/tasks/dispatch.ts +++ b/packages/eve/src/execution/tasks/dispatch.ts @@ -36,6 +36,7 @@ import { } from "#runtime/framework-tools/tasks.js"; import type { SessionTaskIndexEntry } from "#tasks/session-index.js"; import { isReadyTaskStatus, type TaskView } from "#tasks/types.js"; +import { suppressAwaitedTaskWakes } from "#tasks/wake-suppression.js"; export { beginDelegatedTask, @@ -110,7 +111,10 @@ export async function executeTaskControlAction(input: { case TASK_AWAIT_TOOL_NAME: { const views = await readTaskViews(entries); if (views.every((view) => isReadyTaskStatus(view.status))) { - return { result: createTaskViewsResult(action, views), session }; + return { + result: createTaskViewsResult(action, views), + session: suppressAwaitedTaskWakes(session, taskIds), + }; } if (input.parentContinuationToken === undefined) { return { @@ -133,7 +137,10 @@ export async function executeTaskControlAction(input: { toolName: action.toolName, }, ]); - return { result: undefined, session }; + return { + result: undefined, + session: suppressAwaitedTaskWakes(session, taskIds), + }; } default: return { diff --git a/packages/eve/src/execution/tasks/run-steps.ts b/packages/eve/src/execution/tasks/run-steps.ts index 0c8c50af1..eb6f1d1fe 100644 --- a/packages/eve/src/execution/tasks/run-steps.ts +++ b/packages/eve/src/execution/tasks/run-steps.ts @@ -10,7 +10,7 @@ import type { DeliverHookPayload } from "#channel/types.js"; import { resumeHook } from "#internal/workflow/runtime.js"; import { createLogger } from "#internal/logging.js"; import { walkCauseChain } from "#shared/errors.js"; -import { TASK_SNAPSHOT_STREAM_NAMESPACE, type TaskView } from "#tasks/types.js"; +import { TASK_SNAPSHOT_STREAM_NAMESPACE, type TaskStatus, type TaskView } from "#tasks/types.js"; const log = createLogger("execution.tasks.run"); @@ -50,6 +50,10 @@ export async function wakeTaskParentStep(input: { payloads: [ { message: formatTaskNotification(input.view), + taskNotification: { + status: readyNotificationStatus(input.view.status), + taskId: input.view.taskId, + }, }, ], }; @@ -67,6 +71,13 @@ export async function wakeTaskParentStep(input: { } } +function readyNotificationStatus(status: TaskStatus): Exclude { + if (status === "working") { + throw new Error("Cannot wake a parent for a working task."); + } + return status; +} + function formatTaskNotification(view: TaskView): string { const subject = `Background task ${view.taskId} (${view.metadata.name})`; if (view.status === "input_required") { diff --git a/packages/eve/src/execution/tasks/wake-suppression-step.ts b/packages/eve/src/execution/tasks/wake-suppression-step.ts new file mode 100644 index 000000000..68dc26350 --- /dev/null +++ b/packages/eve/src/execution/tasks/wake-suppression-step.ts @@ -0,0 +1,41 @@ +import type { DeliverPayload } from "#channel/types.js"; +import { deserializeContext } from "#context/serialize.js"; +import { + createDurableSessionState, + type DurableSessionState, + readDurableSession, +} from "#execution/durable-session-store.js"; +import { hydrateDurableSession } from "#execution/session.js"; +import { BundleKey } from "#runtime/sessions/runtime-context-keys.js"; +import { consumeAwaitedTaskWakes } from "#tasks/wake-suppression.js"; + +/** Filters task wake payloads already consumed by a completed `task_await`. */ +export async function filterAwaitedTaskWakePayloadsStep(input: { + readonly payloads: readonly DeliverPayload[]; + readonly serializedContext: Record; + readonly sessionState: DurableSessionState; +}): Promise<{ + readonly payloads: readonly DeliverPayload[]; + readonly sessionState: DurableSessionState; +}> { + "use step"; + + const durable = await readDurableSession(input.sessionState); + const ctx = await deserializeContext(input.serializedContext); + const bundle = ctx.require(BundleKey); + const session = hydrateDurableSession({ + compactionOverrides: { + thresholdPercent: bundle.resolvedAgent.config.compaction?.thresholdPercent, + }, + durable, + turnAgent: bundle.turnAgent, + }); + const filtered = consumeAwaitedTaskWakes(session, input.payloads); + return { + payloads: filtered.payloads, + sessionState: + filtered.session === session + ? input.sessionState + : createDurableSessionState({ session: filtered.session }), + }; +} diff --git a/packages/eve/src/execution/workflow-entry.test.ts b/packages/eve/src/execution/workflow-entry.test.ts index a4aab7980..162f611f2 100644 --- a/packages/eve/src/execution/workflow-entry.test.ts +++ b/packages/eve/src/execution/workflow-entry.test.ts @@ -64,6 +64,12 @@ vi.mock("./route-child-delivery.js", () => ({ })), })); +vi.mock("./tasks/wake-suppression-step.js", () => ({ + filterAwaitedTaskWakePayloadsStep: vi + .fn() + .mockImplementation(async ({ payloads, sessionState }) => ({ payloads, sessionState })), +})); + vi.mock("./delegated-parent-notification.js", () => ({ notifyDelegatedParentStep: vi.fn().mockResolvedValue(undefined), notifyTurnCallerStep: vi.fn().mockResolvedValue(undefined), diff --git a/packages/eve/src/execution/workflow-entry.ts b/packages/eve/src/execution/workflow-entry.ts index 7db5ba710..1384287df 100644 --- a/packages/eve/src/execution/workflow-entry.ts +++ b/packages/eve/src/execution/workflow-entry.ts @@ -438,6 +438,7 @@ async function runDriverLoop(input: { bufferedSessionControls, commandInbox, driverWritable: input.driverWritable, + serializedContext: action.serializedContext, sessionState: action.sessionState, }); @@ -503,7 +504,7 @@ async function runDriverLoop(input: { requestId: next.deliver.requestId, }, serializedContext: action.serializedContext, - sessionState: action.sessionState, + sessionState: next.sessionState, }); input.crashCleanupState.lastSessionState = action.sessionState; } diff --git a/packages/eve/src/tasks/wake-suppression.test.ts b/packages/eve/src/tasks/wake-suppression.test.ts new file mode 100644 index 000000000..d8020c542 --- /dev/null +++ b/packages/eve/src/tasks/wake-suppression.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; + +import type { DeliverPayload } from "#channel/types.js"; +import type { HarnessSession } from "#harness/types.js"; +import { + clearAwaitedTaskWakeSuppressions, + consumeAwaitedTaskWakes, + suppressAwaitedTaskWakes, +} from "#tasks/wake-suppression.js"; + +function createSession(): HarnessSession { + return { + agent: { modelReference: { id: "test-model" }, system: "", tools: [] }, + compaction: { recentWindowSize: 4, threshold: 1_000_000 }, + continuationToken: "continuation_parent", + history: [], + sessionId: "session_parent", + }; +} + +const taskWake: DeliverPayload = { + message: "Background task task_1 is completed.", + taskNotification: { status: "completed", taskId: "task_1" }, +}; + +describe("task wake suppression", () => { + it("drops the wake claimed by task_await", () => { + const session = suppressAwaitedTaskWakes(createSession(), ["task_1"]); + + const consumed = consumeAwaitedTaskWakes(session, [taskWake]); + + expect(consumed.payloads).toEqual([]); + }); + + it("keeps the wake after a cancelled await releases its claim", () => { + const session = clearAwaitedTaskWakeSuppressions( + suppressAwaitedTaskWakes(createSession(), ["task_1"]), + ); + + const consumed = consumeAwaitedTaskWakes(session, [taskWake]); + + expect(consumed.payloads).toEqual([taskWake]); + }); + + it("leaves unrelated deliveries and their later suppression intact", () => { + const session = suppressAwaitedTaskWakes(createSession(), ["task_1"]); + const unrelated = { message: "hello" }; + + const first = consumeAwaitedTaskWakes(session, [unrelated]); + const second = consumeAwaitedTaskWakes(first.session, [taskWake]); + + expect(first.payloads).toEqual([unrelated]); + expect(second.payloads).toEqual([]); + }); +}); diff --git a/packages/eve/src/tasks/wake-suppression.ts b/packages/eve/src/tasks/wake-suppression.ts new file mode 100644 index 000000000..30873e233 --- /dev/null +++ b/packages/eve/src/tasks/wake-suppression.ts @@ -0,0 +1,91 @@ +import type { DeliverPayload } from "#channel/types.js"; +import type { HarnessSession, SessionStateMap } from "#harness/types.js"; + +const TASK_WAKE_SUPPRESSIONS_STATE_KEY = "eve.tasks.wakeSuppressions"; + +interface TaskWakeSuppression { + readonly taskId: string; +} + +interface TaskWakeSuppressionStore { + readonly entries: readonly TaskWakeSuppression[]; +} + +/** Claims each task's next ready wake for the active `task_await`. */ +export function suppressAwaitedTaskWakes( + session: HarnessSession, + taskIds: readonly string[], +): HarnessSession { + const existing = readSuppressions(session.state); + return writeSuppressions(session, [...existing, ...taskIds.map((taskId) => ({ taskId }))]); +} + +/** + * Drops task wake payloads claimed by `task_await`. Every matching entry is + * consumed: one await suppresses only the ready transition it observes. + * Turn cancellation clears outstanding claims before the parent parks again. + */ +export function consumeAwaitedTaskWakes( + session: HarnessSession, + payloads: readonly DeliverPayload[], +): { readonly payloads: readonly DeliverPayload[]; readonly session: HarnessSession } { + const existing = readSuppressions(session.state); + if (existing.length === 0) return { payloads, session }; + + const consumedTaskIds = new Set(); + const kept = payloads.filter((payload) => { + const taskId = payload.taskNotification?.taskId; + if (taskId === undefined || !existing.some((entry) => entry.taskId === taskId)) return true; + consumedTaskIds.add(taskId); + return false; + }); + if (consumedTaskIds.size === 0) return { payloads, session }; + + return { + payloads: kept, + session: writeSuppressions( + session, + existing.filter((entry) => !consumedTaskIds.has(entry.taskId)), + ), + }; +} + +/** Releases claims from a cancelled task-await turn. */ +export function clearAwaitedTaskWakeSuppressions(session: HarnessSession): HarnessSession { + return writeSuppressions(session, []); +} + +function readSuppressions(state: SessionStateMap | undefined): readonly TaskWakeSuppression[] { + const raw = state?.[TASK_WAKE_SUPPRESSIONS_STATE_KEY]; + if (raw === undefined) return []; + if (raw === null || typeof raw !== "object" || !("entries" in raw)) { + throw new Error(`Corrupt task wake suppressions under "${TASK_WAKE_SUPPRESSIONS_STATE_KEY}".`); + } + const entries = raw.entries; + if (!Array.isArray(entries)) { + throw new Error(`Corrupt task wake suppressions under "${TASK_WAKE_SUPPRESSIONS_STATE_KEY}".`); + } + return entries.map((entry) => { + if ( + entry === null || + typeof entry !== "object" || + !("taskId" in entry) || + typeof entry.taskId !== "string" + ) { + throw new Error( + `Corrupt task wake suppressions under "${TASK_WAKE_SUPPRESSIONS_STATE_KEY}".`, + ); + } + return { taskId: entry.taskId }; + }); +} + +function writeSuppressions( + session: HarnessSession, + entries: readonly TaskWakeSuppression[], +): HarnessSession { + const state = { ...session.state }; + if (entries.length === 0) delete state[TASK_WAKE_SUPPRESSIONS_STATE_KEY]; + else state[TASK_WAKE_SUPPRESSIONS_STATE_KEY] = { entries } satisfies TaskWakeSuppressionStore; + return { ...session, state: Object.keys(state).length === 0 ? undefined : state }; +} From 1e69cf987565f0af03115ab5b76e1d1d47e33e90 Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Thu, 6 Aug 2026 12:17:37 -0400 Subject: [PATCH 2/4] fix(eve): complete background task lifecycle routing Signed-off-by: Rui Conti --- .changeset/remote-create-operation-id.md | 5 + .changeset/task-await-wake-coalescing.md | 5 - .changeset/tasks-experimental-subagents.md | 2 +- docs/channels/eve.mdx | 8 + docs/tools/human-in-the-loop.md | 2 + packages/eve/src/channel/session-callback.ts | 1 + packages/eve/src/channel/types.ts | 18 +- .../src/cli/dev/tui/tool-presentation.test.ts | 1 - .../eve/src/cli/dev/tui/tool-presentation.ts | 8 - .../src/execution/agent-handle-dispatch.ts | 3 + .../delegated-parent-notification.test.ts | 41 +++ .../delegated-parent-notification.ts | 40 +++ .../eve/src/execution/deliver-payloads.ts | 12 +- ...h-runtime-actions-step.integration.test.ts | 39 +++ .../dispatch-runtime-actions-step.ts | 194 ++++--------- packages/eve/src/execution/node-step.test.ts | 4 +- .../execution/parked-delivery-wait.test.ts | 72 ++--- .../eve/src/execution/parked-delivery-wait.ts | 93 +++++-- .../eve/src/execution/proxied-deliver-step.ts | 94 +++++++ .../execution/remote-agent-dispatch.test.ts | 51 ++++ .../src/execution/remote-agent-dispatch.ts | 20 ++ .../execution/route-child-delivery.test.ts | 100 +++++++ .../eve/src/execution/route-child-delivery.ts | 49 +++- .../execution/settle-cancelled-turn-step.ts | 19 +- .../execution/subagent-event-proxy-step.ts | 27 +- .../src/execution/subagent-hitl-proxy.test.ts | 34 +++ .../eve/src/execution/subagent-hitl-proxy.ts | 51 +++- .../eve/src/execution/subagent-start-local.ts | 134 +++++++++ .../src/execution/subagent-start-remote.ts | 1 + .../eve/src/execution/tasks/await-steps.ts | 104 ------- .../execution/tasks/await-workflow.test.ts | 71 ----- .../eve/src/execution/tasks/await-workflow.ts | 60 ----- .../execution/tasks/continuation-admission.ts | 118 ++++++++ .../eve/src/execution/tasks/control-shared.ts | 72 ++++- .../eve/src/execution/tasks/delegate.test.ts | 53 ++++ packages/eve/src/execution/tasks/delegate.ts | 39 ++- .../eve/src/execution/tasks/dispatch.test.ts | 175 ++++++++++++ packages/eve/src/execution/tasks/dispatch.ts | 150 ++++++----- .../src/execution/tasks/hitl-proxy-steps.ts | 61 +++++ .../eve/src/execution/tasks/run-control.ts | 47 +++- packages/eve/src/execution/tasks/run-steps.ts | 99 +++++-- .../src/execution/tasks/run-workflow.test.ts | 255 +++++++++++++++++- .../eve/src/execution/tasks/run-workflow.ts | 81 +++++- packages/eve/src/execution/tasks/send.test.ts | 212 +++++++++++++++ packages/eve/src/execution/tasks/send.ts | 201 ++++---------- .../execution/tasks/wake-suppression-step.ts | 41 --- .../execution/turn-control-receiver.test.ts | 77 ++++++ .../src/execution/turn-control-receiver.ts | 62 ++++- packages/eve/src/execution/turn-dispatch.ts | 6 + .../eve/src/execution/turn-workflow.test.ts | 6 + packages/eve/src/execution/turn-workflow.ts | 5 + .../eve/src/execution/workflow-entry.test.ts | 9 +- packages/eve/src/execution/workflow-entry.ts | 23 ++ .../eve/src/execution/workflow-runtime.ts | 15 +- .../eve/src/execution/workflow-steps.test.ts | 199 +++++++++++++- packages/eve/src/execution/workflow-steps.ts | 45 +--- .../eve/src/harness/advertised-tools.test.ts | 1 - packages/eve/src/harness/execute-tool.ts | 2 +- .../src/harness/proxy-input-requests.test.ts | 22 ++ .../eve/src/harness/proxy-input-requests.ts | 77 +++++- packages/eve/src/harness/runtime-actions.ts | 5 + packages/eve/src/public/channels/eve.test.ts | 77 +++++- packages/eve/src/public/channels/eve.ts | 98 ++++++- .../src/runtime/framework-tools/tasks.test.ts | 17 ++ .../eve/src/runtime/framework-tools/tasks.ts | 39 +-- .../runtime/session-callback-route.test.ts | 26 ++ .../eve/src/runtime/session-callback-route.ts | 45 ++++ packages/eve/src/tasks/session-index.test.ts | 40 +-- packages/eve/src/tasks/session-index.ts | 8 + packages/eve/src/tasks/task-id.ts | 6 + packages/eve/src/tasks/transitions.test.ts | 62 ++++- packages/eve/src/tasks/transitions.ts | 96 ++++++- packages/eve/src/tasks/types.ts | 98 ++++++- .../eve/src/tasks/wake-suppression.test.ts | 55 ---- packages/eve/src/tasks/wake-suppression.ts | 91 ------- packages/eve/src/tasks/wire.test.ts | 38 ++- packages/eve/src/tasks/wire.ts | 34 ++- research/subagents-as-tasks-implementation.md | 37 ++- research/tools-as-tasks.md | 64 ++--- 79 files changed, 3285 insertions(+), 1167 deletions(-) create mode 100644 .changeset/remote-create-operation-id.md delete mode 100644 .changeset/task-await-wake-coalescing.md create mode 100644 packages/eve/src/execution/proxied-deliver-step.ts create mode 100644 packages/eve/src/execution/route-child-delivery.test.ts create mode 100644 packages/eve/src/execution/subagent-start-local.ts delete mode 100644 packages/eve/src/execution/tasks/await-steps.ts delete mode 100644 packages/eve/src/execution/tasks/await-workflow.test.ts delete mode 100644 packages/eve/src/execution/tasks/await-workflow.ts create mode 100644 packages/eve/src/execution/tasks/continuation-admission.ts create mode 100644 packages/eve/src/execution/tasks/delegate.test.ts create mode 100644 packages/eve/src/execution/tasks/dispatch.test.ts create mode 100644 packages/eve/src/execution/tasks/hitl-proxy-steps.ts create mode 100644 packages/eve/src/execution/tasks/send.test.ts delete mode 100644 packages/eve/src/execution/tasks/wake-suppression-step.ts create mode 100644 packages/eve/src/runtime/framework-tools/tasks.test.ts delete mode 100644 packages/eve/src/tasks/wake-suppression.test.ts delete mode 100644 packages/eve/src/tasks/wake-suppression.ts diff --git a/.changeset/remote-create-operation-id.md b/.changeset/remote-create-operation-id.md new file mode 100644 index 000000000..28d86a063 --- /dev/null +++ b/.changeset/remote-create-operation-id.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Make subagent dispatch replay-safe. Remote create-session requests now carry a replay-stable `operationId`, and the built-in `POST /eve/v1/session` route returns the child it already created for that operation instead of starting a second one. A replayed local start adopts the child holding its deterministic continuation token rather than reporting a start failure. diff --git a/.changeset/task-await-wake-coalescing.md b/.changeset/task-await-wake-coalescing.md deleted file mode 100644 index 7f658b379..000000000 --- a/.changeset/task-await-wake-coalescing.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"eve": patch ---- - -Prevent task completion from starting a duplicate parent turn when `task_await` already reports that ready transition. Cancelled awaits release their wake claims so later task completion still wakes the parent. diff --git a/.changeset/tasks-experimental-subagents.md b/.changeset/tasks-experimental-subagents.md index 8c70aab74..dd6edec70 100644 --- a/.changeset/tasks-experimental-subagents.md +++ b/.changeset/tasks-experimental-subagents.md @@ -2,4 +2,4 @@ "eve": patch --- -Add experimental background tasks for subagents. With `experimental.tasks` on the root agent, subagent calls return a task receipt immediately instead of blocking the turn, and the model manages the delegated work with the new `task_peek`, `task_await`, `task_cancel`, `task_send`, and `task_sleep` tools. Terminal results and input requests wake the parent through the normal session delivery path. Without the flag, nothing changes. +Add experimental background tasks for subagents. Child input requests surface on the parent session and client responses route directly back without a parent model turn; `task_send` continues a finished task, and one child session owns at most one nonterminal task. Without `experimental.tasks`, nothing changes. diff --git a/docs/channels/eve.mdx b/docs/channels/eve.mdx index 25e9fe8b2..3f5820d08 100644 --- a/docs/channels/eve.mdx +++ b/docs/channels/eve.mdx @@ -41,6 +41,14 @@ curl -X POST https:///eve/v1/session \ # {"continuationToken":"eve:7f3c...","ok":true,"sessionId":"ses_01h..."} ``` +A caller that may retry a create request can pass its own `operationId` to get create-once semantics. eve derives that operation's continuation token from the id and the authenticated caller, so a retry with the same id returns the session it already created instead of starting a second one, and one caller's id can never address another caller's session. The guarantee holds while that session is still resumable; eve keeps no record of operations whose session has already ended. + +```bash +curl -X POST https:///eve/v1/session \ + -H "Content-Type: application/json" \ + -d '{"message":"What is the weather in Paris?","operationId":"order-4213-research"}' +``` + Stream that session's events as newline-delimited JSON (`application/x-ndjson; charset=utf-8`), one event object per line: ```bash diff --git a/docs/tools/human-in-the-loop.md b/docs/tools/human-in-the-loop.md index d48d20edc..d2af53a0d 100644 --- a/docs/tools/human-in-the-loop.md +++ b/docs/tools/human-in-the-loop.md @@ -109,6 +109,8 @@ semantics. The run picks back up exactly where it parked. Because the pause is durable, nothing is held in memory while it waits — the process can restart and the parked turn survives. +When a background subagent requests input, eve emits the same `input.requested` event on its parent session. Answering through that parent session routes the response directly to the blocked child without invoking the parent model. + For approval requests, unrelated follow-up text does not deny the tool call. eve keeps the approval pending and holds that text until the approval is answered, then replays it as the next message in the session. See [Sessions, runs & streaming](/docs/concepts/sessions-runs-and-streaming) for the full event and resume contract that this builds on. diff --git a/packages/eve/src/channel/session-callback.ts b/packages/eve/src/channel/session-callback.ts index 31c60dec6..868f234a2 100644 --- a/packages/eve/src/channel/session-callback.ts +++ b/packages/eve/src/channel/session-callback.ts @@ -19,6 +19,7 @@ const sessionCallbackSchema = z .object({ callId: z.string().min(1), subagentName: z.string().min(1), + taskId: z.string().min(1).optional(), token: z.string().min(1), url: z.string().min(1), }) diff --git a/packages/eve/src/channel/types.ts b/packages/eve/src/channel/types.ts index c462fbcc0..9f687f9a6 100644 --- a/packages/eve/src/channel/types.ts +++ b/packages/eve/src/channel/types.ts @@ -22,6 +22,8 @@ export type RunSessionLimits = Pick< /** Identifies the session turn to cancel. */ export interface CancelTurnInput { readonly sessionId: string; + /** Framework task whose queued child deliveries should be discarded. */ + readonly taskId?: string; /** Limits the request to the turn the caller observed. */ readonly turnId?: string; } @@ -151,6 +153,8 @@ export type EventEmitFn = (event: UnstampedMessageStreamEvent) => Promise; export interface TurnCaller { readonly callId: string; readonly subagentName: string; + /** Present when this turn is the executor for a durable background task. */ + readonly taskId?: string; readonly replyTo: | { readonly kind: "hook"; readonly token: string } | { readonly kind: "callback"; readonly url: string }; @@ -173,11 +177,11 @@ export interface DeliverPayload { readonly message?: string | UserContent; readonly context?: readonly string[]; readonly outputSchema?: JsonObject; - /** Framework task-ready notification; never authored by a channel caller. */ - readonly taskNotification?: { - readonly status: "input_required" | "completed" | "failed" | "cancelled"; + /** Framework-only task HITL envelopes consumed before adapter/model delivery. */ + readonly taskInputRequests?: readonly { + readonly hookPayload: SubagentInputRequestHookPayload; readonly taskId: string; - }; + }[]; readonly [key: string]: unknown; } @@ -189,8 +193,10 @@ export type SessionCommand = readonly kind: "send"; readonly payload: DeliverPayload; readonly requestId?: string; + /** Replay-stable identity for one task-owned child delivery. */ + readonly taskDeliveryId?: string; } - | { readonly kind: "cancel"; readonly turnId?: string } + | { readonly kind: "cancel"; readonly taskId?: string; readonly turnId?: string } | { readonly kind: "compact" } | { readonly kind: "clear" } | { readonly kind: "reset"; readonly reason?: string }; @@ -240,6 +246,7 @@ export interface DeliverHookPayload { readonly caller?: TurnCaller; /** Inbound channel request id used only for workflow attributes. */ readonly requestId?: string; + readonly taskDeliveryId?: string; readonly kind: "deliver"; readonly payloads: readonly DeliverPayload[]; } @@ -344,6 +351,7 @@ export type HookPayload = export interface SessionCallback { readonly callId: string; readonly subagentName: string; + readonly taskId?: string; readonly token: string; readonly url: string; } diff --git a/packages/eve/src/cli/dev/tui/tool-presentation.test.ts b/packages/eve/src/cli/dev/tui/tool-presentation.test.ts index 1180b974f..1de393e84 100644 --- a/packages/eve/src/cli/dev/tui/tool-presentation.test.ts +++ b/packages/eve/src/cli/dev/tui/tool-presentation.test.ts @@ -178,7 +178,6 @@ describe("presentTool", () => { grep: { pattern: "useEve" }, load_skill: { skill: "commit" }, read_file: { filePath: "/workspace/a.ts" }, - task_await: { taskIds: ["task_abc"] }, task_cancel: { taskIds: ["task_abc"] }, task_peek: { taskIds: ["task_abc"] }, task_send: { message: "Continue with the next region.", taskId: "task_abc" }, diff --git a/packages/eve/src/cli/dev/tui/tool-presentation.ts b/packages/eve/src/cli/dev/tui/tool-presentation.ts index b29641e02..a27542e24 100644 --- a/packages/eve/src/cli/dev/tui/tool-presentation.ts +++ b/packages/eve/src/cli/dev/tui/tool-presentation.ts @@ -123,14 +123,6 @@ const BUILTIN_TOOL_COPY: Readonly> = { singularNoun: "file", pluralNoun: "files", }, - task_await: { - verb: "Await", - pastVerb: "Awaited", - argKey: "taskIds", - extractItem: taskIdsArg, - singularNoun: "task", - pluralNoun: "tasks", - }, task_cancel: { verb: "Cancel", pastVerb: "Cancelled", diff --git a/packages/eve/src/execution/agent-handle-dispatch.ts b/packages/eve/src/execution/agent-handle-dispatch.ts index 3792dffc8..c6662591e 100644 --- a/packages/eve/src/execution/agent-handle-dispatch.ts +++ b/packages/eve/src/execution/agent-handle-dispatch.ts @@ -32,6 +32,7 @@ import { createWorkflowCallbackUrl } from "#execution/workflow-callback-url.js"; import { createLogger, logError } from "#internal/logging.js"; import { createEveCallbackRoutePath } from "#protocol/routes.js"; import { err, ok, type Result } from "#shared/result.js"; +import { readTaskIdFromCommandToken } from "#tasks/task-id.js"; const log = createLogger("execution.agent-handle-dispatch"); @@ -255,6 +256,7 @@ async function deliverToAgentHandle(input: { callback: { callId: action.callId, subagentName: identity.name, + taskId: readTaskIdFromCommandToken(input.parentToken), token: input.parentToken, url: createWorkflowCallbackUrl( address.callbackBaseUrl, @@ -287,6 +289,7 @@ async function deliverToAgentHandle(input: { callId: action.callId, replyTo: { kind: "hook", token: input.parentToken }, subagentName: identity.name, + taskId: readTaskIdFromCommandToken(input.parentToken), }, kind: "send", payload: { diff --git a/packages/eve/src/execution/delegated-parent-notification.test.ts b/packages/eve/src/execution/delegated-parent-notification.test.ts index a77ae2882..a44ebc63a 100644 --- a/packages/eve/src/execution/delegated-parent-notification.test.ts +++ b/packages/eve/src/execution/delegated-parent-notification.test.ts @@ -7,6 +7,7 @@ import { BundleKey, ChannelKey } from "#runtime/sessions/runtime-context-keys.js import { getCompiledRuntimeAgentBundle } from "#runtime/sessions/compiled-agent-cache.js"; import { notifyDelegatedParentStep, + notifyTaskTurnStartedStep, notifyTurnCallerStep, resolveInitialTurnCallerStep, } from "#execution/delegated-parent-notification.js"; @@ -177,6 +178,46 @@ describe("turn caller notification", () => { expect(resumeHookMock).not.toHaveBeenCalled(); }); + it("binds a local task hook to the exact child turn before execution", async () => { + await notifyTaskTurnStartedStep({ + caller: { + callId: "call-task", + replyTo: { kind: "hook", token: "task-token" }, + subagentName: "research", + taskId: "task-1", + }, + childSessionId: "child-session", + childTurnId: "turn_child_7", + }); + + expect(resumeHookMock).toHaveBeenCalledWith("task-token", { + childSessionId: "child-session", + childTurnId: "turn_child_7", + kind: "task-child-turn-started", + taskId: "task-1", + }); + }); + + it("posts the same task turn identity through a remote callback", async () => { + await notifyTaskTurnStartedStep({ + caller: { + callId: "call-task", + replyTo: { kind: "callback", url: "https://parent.example/eve/v1/callback/task-token" }, + subagentName: "research", + taskId: "task-1", + }, + childSessionId: "child-session", + childTurnId: "turn_child_7", + }); + + expect(JSON.parse(fetchMock.mock.calls[0]?.[1]?.body as string)).toMatchObject({ + kind: "turn.started", + sessionId: "child-session", + taskId: "task-1", + turnId: "turn_child_7", + }); + }); + it("uses the adapter state for the child's first settled turn", async () => { const serializedContext = createSerializedContext(); const caller = await resolveInitialTurnCallerStep({ serializedContext }); diff --git a/packages/eve/src/execution/delegated-parent-notification.ts b/packages/eve/src/execution/delegated-parent-notification.ts index 66b39334a..0020babce 100644 --- a/packages/eve/src/execution/delegated-parent-notification.ts +++ b/packages/eve/src/execution/delegated-parent-notification.ts @@ -22,6 +22,8 @@ import { parseJsonValue } from "#shared/json.js"; import type { TokenUsage } from "#shared/token-usage.js"; import { resumeHook } from "#internal/workflow/runtime.js"; import { postSessionCallbackRequest } from "#execution/session-callback-request.js"; +import type { TaskInboundTurnStarted } from "#tasks/types.js"; +import { readTaskIdFromCommandToken } from "#tasks/task-id.js"; const log = createLogger("execution.delegated-parent-notification"); @@ -129,6 +131,42 @@ export async function notifyTurnCallerStep(input: { await resumeSettledTurnHook(input.caller.replyTo.token, result); } +/** Binds a durable task to the exact child turn before execution starts. */ +export async function notifyTaskTurnStartedStep(input: { + readonly caller: TurnCaller | undefined; + readonly childSessionId: string; + readonly childTurnId: string; +}): Promise { + "use step"; + + const taskId = input.caller?.taskId; + if (input.caller === undefined || taskId === undefined) return; + const payload: TaskInboundTurnStarted = { + childSessionId: input.childSessionId, + childTurnId: input.childTurnId, + kind: "task-child-turn-started", + taskId, + }; + if (input.caller.replyTo.kind === "hook") { + await resumeHook(input.caller.replyTo.token, payload); + return; + } + const response = await postSessionCallbackRequest({ + body: { + callId: input.caller.callId, + kind: "turn.started", + sessionId: input.childSessionId, + subagentName: input.caller.subagentName, + taskId, + turnId: input.childTurnId, + }, + url: input.caller.replyTo.url, + }); + if (!response.ok) { + throw new Error(`Task turn-start callback failed with HTTP ${response.status}.`); + } +} + function createSettledTurnResult(input: { readonly caller: TurnCaller; readonly lifecycle: AgentTurnOutcome["kind"]; @@ -193,6 +231,7 @@ export async function resolveInitialTurnCallerStep(input: { callId: parsed.callback.callId, replyTo: { kind: "callback", url: parsed.callback.url }, subagentName: parsed.callback.subagentName, + taskId: parsed.callback.taskId ?? readTaskIdFromCommandToken(parsed.callback.token), }; } @@ -206,6 +245,7 @@ export async function resolveInitialTurnCallerStep(input: { callId: adapter.state.callId, replyTo: { kind: "hook", token: adapter.state.parentContinuationToken }, subagentName: adapter.state.subagentName, + taskId: readTaskIdFromCommandToken(adapter.state.parentContinuationToken), }; } diff --git a/packages/eve/src/execution/deliver-payloads.ts b/packages/eve/src/execution/deliver-payloads.ts index 8aeff8087..bbe1fa9ea 100644 --- a/packages/eve/src/execution/deliver-payloads.ts +++ b/packages/eve/src/execution/deliver-payloads.ts @@ -2,7 +2,13 @@ import type { DeliverPayload } from "#channel/types.js"; import { coalesceTurnInputs } from "#harness/messages.js"; import type { StepInput } from "#harness/types.js"; -const COALESCED_DELIVER_FIELDS = ["context", "inputResponses", "message", "outputSchema"] as const; +const COALESCED_DELIVER_FIELDS = [ + "context", + "inputResponses", + "message", + "outputSchema", + "taskInputRequests", +] as const; /** Coalesces channel payloads while preserving turn input and adapter-specific fields. */ export function coalesceDeliverPayloads(payloads: readonly DeliverPayload[]): DeliverPayload { @@ -10,9 +16,11 @@ export function coalesceDeliverPayloads(payloads: readonly DeliverPayload[]): De if (payloads.length === 1) return payloads[0] ?? {}; const merged: Record = {}; + const taskInputRequests: NonNullable[number][] = []; let turnInput: StepInput = {}; for (const payload of payloads) { + taskInputRequests.push(...(payload.taskInputRequests ?? [])); for (const [key, value] of Object.entries(payload)) { if (value !== undefined) { merged[key] = value; @@ -25,5 +33,7 @@ export function coalesceDeliverPayloads(payloads: readonly DeliverPayload[]): De delete merged[field]; } + if (taskInputRequests.length > 0) merged.taskInputRequests = taskInputRequests; + return Object.assign(merged, turnInput); } diff --git a/packages/eve/src/execution/dispatch-runtime-actions-step.integration.test.ts b/packages/eve/src/execution/dispatch-runtime-actions-step.integration.test.ts index 44eb77389..a26ca91d9 100644 --- a/packages/eve/src/execution/dispatch-runtime-actions-step.integration.test.ts +++ b/packages/eve/src/execution/dispatch-runtime-actions-step.integration.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ChannelAdapter } from "#channel/adapter.js"; import { RemoteAgentContinueRequestError } from "#execution/remote-agent-dispatch.js"; +import { RuntimeSessionOwnershipConflictError } from "#execution/runtime-errors.js"; import type { DurableSessionState } from "#execution/durable-session-store.js"; import { dispatchRuntimeActionsStep } from "#execution/dispatch-runtime-actions-step.js"; import { @@ -217,6 +218,44 @@ describe("dispatchRuntimeActionsStep child starts", () => { }); }); + it("adopts the child a replayed start already created instead of failing it", async () => { + const session = createStartSession({ kind: "local" }); + installContext(session, { + definition: { description: "Research", kind: "subagent" }, + nodeId: "subagents/research", + }); + // A retried dispatch step re-derives the same deterministic child + // continuation token, so the first attempt's child owns it. + mocks.createSession.mockRejectedValue( + new RuntimeSessionOwnershipConflictError({ + continuationToken: "subagent:parent-session:call-1", + ownerSessionId: CHILD_SESSION_ID, + sessionId: "duplicate-child", + }), + ); + + const result = await dispatchRuntimeActionsStep({ + parentContinuationToken: "turn-inbox", + parentWritable: createWritable(), + serializedContext: {}, + sessionState: BASE_STATE, + }); + + expect(result.results).toEqual([]); + expect(getAgentHandleStore(readResultSessionState(result, session))).toEqual({ + handles: [ + expect.objectContaining({ + address: { + continuationToken: "subagent:parent-session:call-1", + kind: "agent/local", + sessionId: CHILD_SESSION_ID, + }, + phase: "running", + }), + ], + }); + }); + it("owns a remote child with its confirmed remote address", async () => { const session = createStartSession({ kind: "remote" }); installContext(session, { diff --git a/packages/eve/src/execution/dispatch-runtime-actions-step.ts b/packages/eve/src/execution/dispatch-runtime-actions-step.ts index 4a7d99c9d..e3371d5a4 100644 --- a/packages/eve/src/execution/dispatch-runtime-actions-step.ts +++ b/packages/eve/src/execution/dispatch-runtime-actions-step.ts @@ -32,13 +32,7 @@ import { type RuntimeSession, } from "#execution/agent-handle-dispatch.js"; import { createAgentContinuationBundle } from "#execution/agent-continuation-bundle.js"; -import { SUBAGENT_START_FAILED } from "#harness/agent-handle-errors.js"; import { getAgentHandleStore } from "#harness/handles/store.js"; -import { - confirmAgentStarted, - prepareAgentStart, - rejectAgentEffect, -} from "#harness/handles/transitions.js"; import { getPendingRuntimeActionBatch } from "#harness/runtime-actions.js"; import { createSubagentCalledEvent, @@ -56,7 +50,6 @@ import type { import { beginDelegatedTask, executeTaskControlAction, - failDelegatedDispatch, isTaskControlAction, settleDelegatedDispatch, } from "#execution/tasks/dispatch.js"; @@ -70,17 +63,23 @@ import { createUnavailableDynamicSubagentResult, getSubagentName, } from "#execution/dispatch-action-failures.js"; -import { mintStartOperation } from "#execution/dispatch-start-operation.js"; +import { startLocalSubagent } from "#execution/subagent-start-local.js"; import { startRemoteSubagent } from "#execution/subagent-start-remote.js"; import { hydrateDurableSession } from "#execution/session.js"; import { buildSubagentRunInput, type SubagentInputSource } from "#execution/subagent-tool.js"; -import { createWorkflowRuntime, workflowEntryReference } from "#execution/workflow-runtime.js"; +import { workflowEntryReference } from "#execution/workflow-runtime.js"; import { createLogger, logError } from "#internal/logging.js"; -import { toErrorMessage } from "#shared/errors.js"; import { readSessionTraceContext } from "#tracing/agent-trace-context-store.js"; import { resolveSubagentDepth } from "#harness/subagent-depth.js"; import { getDynamicSubagentSelection } from "#context/dynamic-subagent-lifecycle.js"; import { resolveEffectiveAgentRuntime } from "#execution/effective-agent-config.js"; +import { reconcileSettledTaskHandles } from "#execution/tasks/control-shared.js"; +import { + checkTaskContinuationAdmission, + reserveTaskContinuation, + settleTaskDispatchError, + type ReservedTaskContinuation, +} from "#execution/tasks/continuation-admission.js"; const log = createLogger("execution.dispatch-runtime-actions"); @@ -145,7 +144,7 @@ export async function dispatchRuntimeActionsStep(input: { const ctx = await deserializeContext(input.serializedContext); const bundle = ctx.require(BundleKey); const effectiveAgent = resolveEffectiveAgentRuntime(bundle, ctx); - const session = hydrateDurableSession({ + let session = hydrateDurableSession({ compactionOverrides: { thresholdPercent: effectiveAgent.thresholdPercent, }, @@ -159,10 +158,9 @@ export async function dispatchRuntimeActionsStep(input: { const initiatorAuth = ctx.get(InitiatorAuthKey) ?? null; const adapterCtx = buildAdapterContext(adapter, ctx); - // Read here, not in the child: trace state is scoped to one session's - // context, so this is the last place the parent's window is visible. const parentTraceContext = readSessionTraceContext(input.serializedContext, session.sessionId); const tasksEnabled = bundle.resolvedAgent.config.experimental?.tasks === true; + if (tasksEnabled) session = await reconcileSettledTaskHandles(session); // Background tasks require resumable children: the flag implies // conversation-mode dispatch so `experimental.tasks` and // `experimental.subagentPersistentSessions` never produce a third mode. @@ -199,7 +197,7 @@ export async function dispatchRuntimeActionsStep(input: { const control = await executeTaskControlAction({ action: entry.action, bundle, - parentContinuationToken: input.parentContinuationToken, + parentStepIndex: batch.event.stepIndex, parentTurnId: batch.event.turnId, session: nextSession, }); @@ -210,18 +208,40 @@ export async function dispatchRuntimeActionsStep(input: { continue; } - // Delegated execution: the durable task record exists before the - // child dispatch side effect, and the child's reply address is the - // task run's private hook instead of the parent turn's inbox. + if (tasksEnabled && entry.kind === "resume") { + const busy = await checkTaskContinuationAdmission({ + action: entry.action, + agentId: entry.agentId, + parentStepIndex: batch.event.stepIndex, + parentTurnId: batch.event.turnId, + session: nextSession, + }); + if (busy !== undefined) { + results.push(busy); + continue; + } + } + const delegated = tasksEnabled ? await beginDelegatedTask({ ...describeDelegatedEntry(entry), parentSessionId: session.sessionId, + parentStepIndex: batch.event.stepIndex, parentTurnId: batch.event.turnId, session: nextSession, }) : undefined; const delegatedParentToken = delegated?.commandToken; + let reservedContinuation: ReservedTaskContinuation | undefined; + if (entry.kind === "resume") { + reservedContinuation = await reserveTaskContinuation({ + action: entry.action, + agentId: entry.agentId, + delegated, + session: nextSession, + }); + nextSession = reservedContinuation?.session ?? nextSession; + } let outcome: DispatchOutcome; switch (entry.kind) { @@ -262,23 +282,32 @@ export async function dispatchRuntimeActionsStep(input: { nextSession = outcome.session; if (outcome.kind === "error") { - if (delegated !== undefined) { - await failDelegatedDispatch({ error: outcome.result.output, task: delegated }); - } - results.push(outcome.result); + results.push( + await settleTaskDispatchError({ + agentId: entry.kind === "resume" ? entry.agentId : undefined, + delegated, + outcome, + reserved: reservedContinuation, + session: nextSession, + }), + ); continue; } if (delegated !== undefined) { - const settled = await settleDelegatedDispatch({ - callId: outcome.callId, - childSessionId: outcome.address.sessionId, - session: nextSession, - subagentName: outcome.toolName, - task: delegated, - }); - nextSession = settled.session; - results.push(settled.receipt); + if (reservedContinuation !== undefined) { + results.push(reservedContinuation.receipt); + } else { + const settled = await settleDelegatedDispatch({ + callId: outcome.callId, + childSessionId: outcome.address.sessionId, + session: nextSession, + subagentName: outcome.toolName, + task: delegated, + }); + nextSession = settled.session; + results.push(settled.receipt); + } } // Emission is observability, not control flow: a failure here must not @@ -534,111 +563,6 @@ async function startSubagent(input: { } } -async function startLocalSubagent(input: { - readonly action: RuntimeSubagentCallActionRequest; - readonly auth: Parameters[0]["auth"]; - readonly batchEvent: { readonly sequence: number; readonly turnId: string }; - readonly bundle: CompiledBundle; - readonly capabilities: Parameters[0]["capabilities"]; - readonly channelMetadata: Parameters[0]["channelMetadata"]; - readonly currentSession: RuntimeSession; - readonly dynamicSubagentAgentConfig?: DynamicSubagentAgentConfig; - readonly fanoutSize: number; - readonly initiatorAuth: Parameters[0]["initiatorAuth"]; - readonly parentContinuationToken: string | undefined; - readonly parentTraceContext: Parameters[0]["parentTraceContext"]; - readonly persistentSessions: boolean; - readonly session: RuntimeSession; - readonly source: SubagentInputSource; -}): Promise { - const { action, source } = input; - const childRuntime = createWorkflowRuntime({ - compiledArtifactsSource: input.bundle.compiledArtifactsSource, - dynamicSubagentAgentConfig: input.dynamicSubagentAgentConfig, - nodeId: action.nodeId, - }); - const { childContinuationToken, runInput } = buildSubagentRunInput({ - action, - auth: input.auth, - batchEvent: input.batchEvent, - capabilities: input.capabilities, - channelMetadata: input.channelMetadata, - fanoutSize: input.fanoutSize, - initiatorAuth: input.initiatorAuth, - parentContinuationToken: input.parentContinuationToken, - parentTraceContext: input.parentTraceContext, - persistentSessions: input.persistentSessions, - session: input.session, - source, - }); - - const targetKind = source.type === "runtime" ? ("agent/self" as const) : ("agent/local" as const); - const { identity, operation } = mintStartOperation({ - callId: action.callId, - name: action.subagentName, - nodeId: action.nodeId, - parentSessionId: input.session.sessionId, - parentTurnId: input.batchEvent.turnId, - }); - // Ownership is recorded before the start side effect, and the prepared - // (or rejected) store rides every outcome into the step result. The - // guarantee is intra-step: a crash between the accepted start and the - // step-result commit still replays the whole dispatch step, so the - // orphan window shrinks to that boundary rather than disappearing. - const preparedSession = prepareAgentStart(input.currentSession, { - identity, - operation, - target: { continuationToken: childContinuationToken, kind: targetKind }, - }); - - let childSessionId: string; - try { - const handle = await childRuntime.createSession(runInput); - childSessionId = handle.sessionId; - } catch (error) { - logError(log, "local subagent start failed", error, { - callId: action.callId, - nodeId: action.nodeId, - subagentName: action.subagentName, - }); - return { - kind: "error", - result: { - callId: action.callId, - isError: true, - kind: "subagent-result", - origin: "dispatch", - output: { - code: SUBAGENT_START_FAILED, - message: toErrorMessage(error), - }, - subagentName: action.subagentName, - }, - session: rejectAgentEffect(preparedSession, { - disposition: "dead", - operationId: operation.id, - }), - }; - } - - const address = { - continuationToken: childContinuationToken, - kind: targetKind, - sessionId: childSessionId, - } as const; - return { - address, - callId: action.callId, - kind: "called", - name: action.name, - session: confirmAgentStarted(preparedSession, { - address, - operationId: operation.id, - }), - toolName: action.subagentName, - }; -} - /** Names one delegated dispatch for its task record, before any child exists. */ function describeDelegatedEntry(entry: Extract): { readonly callId: string; diff --git a/packages/eve/src/execution/node-step.test.ts b/packages/eve/src/execution/node-step.test.ts index 9f3f3c84d..77dc06da4 100644 --- a/packages/eve/src/execution/node-step.test.ts +++ b/packages/eve/src/execution/node-step.test.ts @@ -276,7 +276,7 @@ describe("createNodeHarnessTools", () => { it("does not inject task tools without experimental.tasks", () => { const tools = createNodeHarnessTools({ node: createTestNode() }); - for (const name of ["task_peek", "task_await", "task_cancel", "task_send", "task_sleep"]) { + for (const name of ["task_peek", "task_cancel", "task_send", "task_sleep"]) { expect(tools.has(name)).toBe(false); } }); @@ -293,7 +293,7 @@ describe("createNodeHarnessTools", () => { }, }); - for (const name of ["task_peek", "task_await", "task_cancel", "task_send"]) { + for (const name of ["task_peek", "task_cancel", "task_send"]) { expect(tools.get(name)?.runtimeAction).toEqual({ kind: "task-control" }); expect(tools.get(name)?.execute).toBeUndefined(); } diff --git a/packages/eve/src/execution/parked-delivery-wait.test.ts b/packages/eve/src/execution/parked-delivery-wait.test.ts index 590ee4eb5..c1bad2085 100644 --- a/packages/eve/src/execution/parked-delivery-wait.test.ts +++ b/packages/eve/src/execution/parked-delivery-wait.test.ts @@ -1,73 +1,57 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { DeliverHookPayload } from "#channel/types.js"; import { nextTurnDelivery } from "#execution/parked-delivery-wait.js"; import type { SessionCommandInbox } from "#execution/session-command-inbox.js"; -import { filterAwaitedTaskWakePayloadsStep } from "#execution/tasks/wake-suppression-step.js"; import { routeDeliverToChildren } from "#execution/route-child-delivery.js"; -vi.mock("./tasks/wake-suppression-step.js", () => ({ - filterAwaitedTaskWakePayloadsStep: vi.fn(), -})); - vi.mock("./route-child-delivery.js", () => ({ routeDeliverToChildren: vi.fn(), })); -describe("nextTurnDelivery task wake suppression", () => { - it("routes only unsuppressed payloads and carries the updated session state", async () => { - const taskWake = { - kind: "deliver", - payloads: [ - { - message: "task done", - taskNotification: { status: "completed", taskId: "task_1" }, - }, - { message: "ordinary delivery" }, - ], - } satisfies DeliverHookPayload; - const initialState = { +describe("nextTurnDelivery routing", () => { + beforeEach(() => vi.clearAllMocks()); + it("keeps waiting instead of starting a parent turn for a fully routed task response", async () => { + const sessionState = { continuationToken: "token", emissionState: { sequence: 0, sessionStarted: false, stepIndex: 0, turnId: "turn" }, - hasProxyInputRequests: false, + hasProxyInputRequests: true, sessionId: "session", version: 1, } as const; - const filteredState = { ...initialState, continuationToken: "next-token" }; - vi.mocked(filterAwaitedTaskWakePayloadsStep).mockResolvedValue({ - payloads: [{ message: "ordinary delivery" }], - sessionState: filteredState, - }); - vi.mocked(routeDeliverToChildren).mockResolvedValue({ - kind: "continue", - remainder: { message: "ordinary delivery" }, - }); + vi.mocked(routeDeliverToChildren) + .mockResolvedValueOnce({ + kind: "continue", + remainder: undefined, + serializedContext: {}, + sessionState, + }) + .mockResolvedValueOnce({ + kind: "continue", + remainder: { message: "ordinary" }, + serializedContext: {}, + sessionState, + }); + const commands = [ + { kind: "send" as const, payload: { inputResponses: [{ requestId: "task-request" }] } }, + { kind: "send" as const, payload: { message: "ordinary" } }, + ]; const commandInbox: SessionCommandInbox = { claimStable: vi.fn(), consumeNext: vi.fn(), - next: vi.fn(), + next: vi.fn(async () => ({ done: false as const, value: commands.shift()! })), rekeyContinuation: vi.fn(), }; const result = await nextTurnDelivery({ - bufferedDeliveries: [taskWake], + bufferedDeliveries: [], bufferedSessionControls: [], commandInbox, driverWritable: new WritableStream(), serializedContext: {}, - sessionState: initialState, + sessionState, }); - expect(result).toMatchObject({ - kind: "turn", - remainder: { message: "ordinary delivery" }, - sessionState: filteredState, - }); - expect(routeDeliverToChildren).toHaveBeenCalledWith( - expect.objectContaining({ - payloads: [{ message: "ordinary delivery" }], - sessionState: filteredState, - }), - ); + expect(result).toMatchObject({ kind: "turn", remainder: { message: "ordinary" } }); + expect(routeDeliverToChildren).toHaveBeenCalledTimes(2); }); }); diff --git a/packages/eve/src/execution/parked-delivery-wait.ts b/packages/eve/src/execution/parked-delivery-wait.ts index 19abab0d4..2da6d1260 100644 --- a/packages/eve/src/execution/parked-delivery-wait.ts +++ b/packages/eve/src/execution/parked-delivery-wait.ts @@ -2,7 +2,6 @@ import type { DeliverHookPayload, DeliverPayload, SessionCommand } from "#channe import type { DurableSessionState } from "#execution/durable-session-store.js"; import { routeDeliverToChildren } from "#execution/route-child-delivery.js"; import type { SessionCommandInbox } from "#execution/session-command-inbox.js"; -import { filterAwaitedTaskWakePayloadsStep } from "#execution/tasks/wake-suppression-step.js"; import { coalesceDeliveries } from "#harness/messages.js"; type NextSessionAction = @@ -16,7 +15,7 @@ type NextSessionAction = }; /** What the parked driver should do with the next session activity. */ -export type NextTurnInstruction = +type NextTurnOutcome = | { readonly kind: "clear" } | { readonly kind: "compact" } | { readonly kind: "expired" } @@ -30,6 +29,11 @@ export type NextTurnInstruction = readonly sessionState: DurableSessionState; }; +export type NextTurnInstruction = NextTurnOutcome & { + readonly serializedContext: Record; + readonly sessionState: DurableSessionState; +}; + /** * Awaits the next delivery that requires driver action while the session * is parked. Deliveries fully routed to a descendant leave the parent with @@ -40,48 +44,47 @@ export type NextTurnInstruction = export async function nextTurnDelivery(input: { readonly bufferedDeliveries: DeliverHookPayload[]; readonly bufferedSessionControls: Array<"clear" | "compact" | "expired" | "reset">; + readonly cancelledTaskIds?: Set; readonly commandInbox: SessionCommandInbox; readonly driverWritable: WritableStream; readonly serializedContext: Record; + readonly seenTaskDeliveries?: Set; readonly sessionState: DurableSessionState; }): Promise { let sessionState = input.sessionState; + let serializedContext = input.serializedContext; + const cancelledTaskIds = input.cancelledTaskIds ?? new Set(); + const seenTaskDeliveries = input.seenTaskDeliveries ?? new Set(); while (true) { const nextAction = await waitForNextSessionAction({ bufferedDeliveries: input.bufferedDeliveries, bufferedSessionControls: input.bufferedSessionControls, + cancelledTaskIds, commandInbox: input.commandInbox, + seenTaskDeliveries, }); if (nextAction.kind !== "delivery") { - return { kind: nextAction.kind }; + return { kind: nextAction.kind, serializedContext, sessionState }; } const deliver = nextAction.delivery; if (deliver === null) { - return { kind: "closed" }; - } - - const filtered = await filterAwaitedTaskWakePayloadsStep({ - payloads: deliver.payloads, - serializedContext: input.serializedContext, - sessionState, - }); - sessionState = filtered.sessionState; - if (filtered.payloads.length === 0) { - // A completed task_await already reported every task in this delivery. - continue; + return { kind: "closed", serializedContext, sessionState }; } const routed = await routeDeliverToChildren({ auth: deliver.auth, parentWritable: input.driverWritable, - payloads: filtered.payloads, + payloads: deliver.payloads, + serializedContext, sessionState, }); + serializedContext = routed.serializedContext ?? serializedContext; + sessionState = routed.sessionState ?? sessionState; if (routed.kind === "cancel-turn") { - return { kind: "cancel-turn" }; + return { kind: "cancel-turn", serializedContext, sessionState }; } if (routed.remainder === undefined) { @@ -89,20 +92,34 @@ export async function nextTurnDelivery(input: { continue; } - return { deliver, kind: "turn", remainder: routed.remainder, sessionState }; + return { + deliver, + kind: "turn", + remainder: routed.remainder, + serializedContext, + sessionState, + }; } } async function waitForNextSessionAction(input: { readonly bufferedDeliveries: DeliverHookPayload[]; readonly bufferedSessionControls: Array<"clear" | "compact" | "expired" | "reset">; + readonly cancelledTaskIds: Set; readonly commandInbox: SessionCommandInbox; + readonly seenTaskDeliveries: Set; }): Promise { const pendingSessionControl = input.bufferedSessionControls.shift(); if (pendingSessionControl !== undefined) { return { kind: pendingSessionControl }; } + while ( + input.bufferedDeliveries[0] !== undefined && + isCancelledTaskDelivery(input.bufferedDeliveries[0], input.cancelledTaskIds) + ) { + input.bufferedDeliveries.shift(); + } if (input.bufferedDeliveries.length > 0) { return { delivery: takeBufferedTurnDelivery(input.bufferedDeliveries), @@ -131,9 +148,24 @@ async function waitForNextSessionAction(input: { } if (first.value.kind === "cancel") { + if (first.value.taskId !== undefined) { + input.cancelledTaskIds.add(first.value.taskId); + const kept = input.bufferedDeliveries.filter( + (delivery) => !isCancelledTaskDelivery(delivery, input.cancelledTaskIds), + ); + input.bufferedDeliveries.splice(0, input.bufferedDeliveries.length, ...kept); + } continue; } + const deliveryId = first.value.taskDeliveryId ?? first.value.caller?.taskId; + if (deliveryId !== undefined && isCancelledTaskDeliveryId(deliveryId, input.cancelledTaskIds)) { + continue; + } + if (deliveryId !== undefined) { + if (input.seenTaskDeliveries.has(deliveryId)) continue; + input.seenTaskDeliveries.add(deliveryId); + } return { delivery: commandToDelivery(first.value), kind: "delivery" }; } } @@ -147,9 +179,27 @@ function commandToDelivery( kind: "deliver", payloads: [command.payload], requestId: command.requestId, + taskDeliveryId: command.taskDeliveryId, }; } +function isCancelledTaskDelivery( + delivery: DeliverHookPayload, + cancelledTaskIds: ReadonlySet, +): boolean { + const deliveryId = delivery.taskDeliveryId ?? delivery.caller?.taskId; + return deliveryId !== undefined && isCancelledTaskDeliveryId(deliveryId, cancelledTaskIds); +} + +function isCancelledTaskDeliveryId( + deliveryId: string, + cancelledTaskIds: ReadonlySet, +): boolean { + return [...cancelledTaskIds].some( + (taskId) => deliveryId === taskId || deliveryId.startsWith(`${taskId}:`), + ); +} + function takeBufferedTurnDelivery(bufferedDeliveries: DeliverHookPayload[]): DeliverHookPayload { const first = bufferedDeliveries.shift(); if (first === undefined) { @@ -160,7 +210,12 @@ function takeBufferedTurnDelivery(bufferedDeliveries: DeliverHookPayload[]): Del let caller = first.caller; while (bufferedDeliveries.length > 0) { const next = bufferedDeliveries[0]; - if (next === undefined || (caller !== undefined && next.caller !== undefined)) { + if ( + next === undefined || + first.taskDeliveryId !== undefined || + next.taskDeliveryId !== undefined || + (caller !== undefined && next.caller !== undefined) + ) { break; } diff --git a/packages/eve/src/execution/proxied-deliver-step.ts b/packages/eve/src/execution/proxied-deliver-step.ts new file mode 100644 index 000000000..44e731224 --- /dev/null +++ b/packages/eve/src/execution/proxied-deliver-step.ts @@ -0,0 +1,94 @@ +import type { DeliverPayload, SessionAuthContext, SessionCommand } from "#channel/types.js"; +import { type DurableSessionState, readDurableSession } from "#execution/durable-session-store.js"; +import { routeDeliverPayload } from "#execution/subagent-hitl-proxy.js"; +import { sendTaskInboundPayload } from "#execution/tasks/run-control.js"; +import { resumeHook } from "#internal/workflow/runtime.js"; +import type { InputResponse } from "#runtime/input/types.js"; +import { findSessionTaskEntry } from "#tasks/session-index.js"; + +export type RoutedDeliverResult = + | { + readonly kind: "cancel-turn"; + readonly serializedContext: Record; + readonly sessionState: DurableSessionState; + } + | { + readonly kind: "continue"; + readonly remainder: DeliverPayload | undefined; + readonly serializedContext: Record; + readonly sessionState: DurableSessionState; + }; + +/** Validates task routes and forwards descendant-bound input responses. */ +export async function routeProxiedDeliverStep(input: { + readonly auth?: SessionAuthContext | null; + readonly parentWritable: WritableStream; + readonly payload: DeliverPayload; + readonly serializedContext?: Record; + readonly sessionState: DurableSessionState; +}): Promise { + "use step"; + + const durableSession = await readDurableSession(input.sessionState); + // A task-owned route is only routable while this session still owns + // the task; anything else stays parent-local and reaches the model as + // ordinary stale input. + const routed = routeDeliverPayload({ + allowRoute: (_requestId, route) => + route.taskId === undefined || + findSessionTaskEntry(durableSession.state, route.taskId) !== undefined, + payload: input.payload, + state: durableSession.state, + }); + + const strandedResponses: InputResponse[] = []; + for (const forChild of routed.forChildren) { + // Task-owned children are addressed through their run, never + // directly: the run must forward and clear the batch under one + // durable decision, or a late answer could unblock a question the + // child raised after this one. + if (forChild.taskId !== undefined) { + const entry = findSessionTaskEntry(durableSession.state, forChild.taskId); + const delivery = + entry === undefined + ? "unreachable" + : await sendTaskInboundPayload({ + commandToken: entry.commandToken, + payload: { + auth: input.auth, + childContinuationToken: forChild.childContinuationToken, + inputResponses: forChild.payload.inputResponses, + kind: "task-answer-input", + taskId: forChild.taskId, + }, + }); + if (delivery === "unreachable") strandedResponses.push(...forChild.payload.inputResponses); + continue; + } + + const command = { + auth: input.auth, + kind: "send", + payload: forChild.payload, + } satisfies SessionCommand; + await resumeHook(forChild.childContinuationToken, command); + } + + const context = { + serializedContext: input.serializedContext ?? {}, + sessionState: input.sessionState, + }; + // Answers to a task that finished mid-flight rejoin the parent-local + // remainder, where the model sees them as stale rather than silently + // vanishing. + const remainder = + strandedResponses.length === 0 + ? routed.forSelf + : ({ + ...routed.forSelf, + inputResponses: [...(routed.forSelf?.inputResponses ?? []), ...strandedResponses], + } satisfies DeliverPayload); + return routed.parentAction === undefined + ? { ...context, kind: "continue", remainder } + : { ...context, ...routed.parentAction }; +} diff --git a/packages/eve/src/execution/remote-agent-dispatch.test.ts b/packages/eve/src/execution/remote-agent-dispatch.test.ts index 588049471..f7b3624cf 100644 --- a/packages/eve/src/execution/remote-agent-dispatch.test.ts +++ b/packages/eve/src/execution/remote-agent-dispatch.test.ts @@ -76,6 +76,37 @@ describe("startRemoteAgentSession", () => { vi.unstubAllEnvs(); }); + it("carries a replay-stable operation id so the receiver can create once", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue( + Response.json( + { continuationToken: "remote-token", ok: true, sessionId: "remote-session" }, + { status: 202 }, + ), + ); + vi.stubGlobal("fetch", fetchMock); + + await startRemoteAgentSession({ + action: createAction(), + callbackBaseUrl: "https://caller.example.com", + operationId: "operation-1", + remote: createRemoteAgent(), + session: { + agent: { modelReference: { id: "mock/test" }, system: "", tools: [] }, + compaction: { recentWindowSize: 10, threshold: 100000 }, + continuationToken: "eve:parent-token", + history: [], + sessionId: "parent-session", + state: {}, + }, + }); + + expect(JSON.parse(fetchMock.mock.calls[0]?.[1]?.body as string)).toMatchObject({ + operationId: "operation-1", + }); + }); + it("posts the formatted subagent message and callback metadata", async () => { const fetchMock = vi.fn().mockResolvedValue( new Response( @@ -678,6 +709,26 @@ describe("cancelRemoteAgentTurn", () => { }); }); + it("sends the observed child turn guard", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue( + Response.json({ ok: true, sessionId: "child-1", status: "accepted" }, { status: 202 }), + ); + vi.stubGlobal("fetch", fetchMock); + + await cancelRemoteAgentTurn({ + remote: createRemoteAgent(), + sessionId: "child-1", + turnId: "turn_child_7", + }); + + expect(fetchMock).toHaveBeenCalledWith( + "https://remote.example.com/eve/v1/session/child-1/cancel", + expect.objectContaining({ body: JSON.stringify({ turnId: "turn_child_7" }), method: "POST" }), + ); + }); + it("preserves a prefixed remote base path on cancel-turn requests", async () => { const fetchMock = vi .fn() diff --git a/packages/eve/src/execution/remote-agent-dispatch.ts b/packages/eve/src/execution/remote-agent-dispatch.ts index 3114bd51d..2c134ee11 100644 --- a/packages/eve/src/execution/remote-agent-dispatch.ts +++ b/packages/eve/src/execution/remote-agent-dispatch.ts @@ -18,6 +18,7 @@ import type { DynamicRemoteAgentConfig } from "#runtime/subagents/dynamic-remote import type { ResolvedRuntimeRemoteAgentNode } from "#runtime/types.js"; import { expectFunction, expectObjectRecord } from "#internal/authored-module.js"; import type { JsonObject } from "#shared/json.js"; +import { readTaskIdFromCommandToken } from "#tasks/task-id.js"; const CreateSessionResponseSchema = z.object({ // Older eve deployments do not return a continuationToken. Their children @@ -50,6 +51,12 @@ export async function startRemoteAgentSession(input: { readonly callbackToken?: string; /** The root initiator's principal, forwarded alongside {@link auth}. */ readonly initiatorAuth?: SessionAuthContext | null; + /** + * Replay-stable identity of this create attempt. A retried dispatch step + * re-sends the same value, letting the receiver return the child it already + * created instead of starting a second one. + */ + readonly operationId?: string; /** * Whether the dispatching agent opted into * `experimental.subagentPersistentSessions`. Persistent remote children run @@ -73,18 +80,21 @@ export async function startRemoteAgentSession(input: { callback: { callId: string; subagentName: string; + taskId?: string; token: string; url: string; }; forwardedPrincipal?: ForwardedPrincipal; message: string; mode: "conversation" | "task"; + operationId?: string; outputSchema?: object; } = { capabilities: {}, callback: { callId: input.action.callId, subagentName: input.action.remoteAgentName, + taskId: readTaskIdFromCommandToken(callbackToken), token: callbackToken, url: createWorkflowCallbackUrl( input.callbackBaseUrl, @@ -103,6 +113,9 @@ export async function startRemoteAgentSession(input: { if (forwardedPrincipal !== undefined) { requestBody.forwardedPrincipal = forwardedPrincipal; } + if (input.operationId !== undefined) { + requestBody.operationId = input.operationId; + } const headers = await resolveRemoteAgentRequestHeaders(input.remote); const response = await fetch(createRemoteAgentSessionUrl(input.remote), { @@ -154,6 +167,7 @@ export async function continueRemoteAgentSession(input: { readonly callback: { readonly callId: string; readonly subagentName: string; + readonly taskId?: string; readonly token: string; readonly url: string; }; @@ -257,9 +271,15 @@ function buildForwardedPrincipalField(input: { export async function cancelRemoteAgentTurn(input: { readonly remote: ResolvedRuntimeRemoteAgentNode; readonly sessionId: string; + readonly taskId?: string; + readonly turnId?: string; }): Promise { const headers = await resolveRemoteAgentRequestHeaders(input.remote); const response = await fetch(createRemoteAgentCancelTurnUrl(input.remote, input.sessionId), { + body: + input.turnId === undefined && input.taskId === undefined + ? undefined + : JSON.stringify({ taskId: input.taskId, turnId: input.turnId }), headers, method: "POST", }); diff --git a/packages/eve/src/execution/route-child-delivery.test.ts b/packages/eve/src/execution/route-child-delivery.test.ts new file mode 100644 index 000000000..d22464685 --- /dev/null +++ b/packages/eve/src/execution/route-child-delivery.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { routeDeliverToChildren } from "#execution/route-child-delivery.js"; +import { emitRecordedTaskInputRequestStep } from "#execution/subagent-event-proxy-step.js"; +import { recordTaskInputRequestStep } from "#execution/tasks/hitl-proxy-steps.js"; +import { routeProxiedDeliverStep } from "#execution/proxied-deliver-step.js"; +import type { DurableSessionState } from "#execution/durable-session-store.js"; + +vi.mock("#execution/subagent-event-proxy-step.js", () => ({ + emitRecordedTaskInputRequestStep: vi.fn(), +})); +vi.mock("#execution/tasks/hitl-proxy-steps.js", () => ({ + recordTaskInputRequestStep: vi.fn(), +})); +vi.mock("#execution/proxied-deliver-step.js", () => ({ + routeProxiedDeliverStep: vi.fn(), +})); + +const state = (hasProxyInputRequests: boolean): DurableSessionState => ({ + continuationToken: "parent-token", + emissionState: { sequence: 0, sessionStarted: true, stepIndex: 0, turnId: "" }, + hasProxyInputRequests, + sessionId: "parent-session", + version: 1, +}); + +const hookPayload = { + callId: "call-task", + childContinuationToken: "child-token", + childSessionId: "child-session", + event: { + requests: [ + { + action: { callId: "call-q", input: {}, kind: "tool-call" as const, toolName: "ask" }, + kind: "question" as const, + prompt: "Which?", + requestId: "request-1", + }, + ], + sequence: 1, + stepIndex: 2, + turnId: "turn_child", + }, + kind: "subagent-input-request" as const, + subagentName: "research", +}; + +describe("task HITL delivery routing", () => { + beforeEach(() => vi.resetAllMocks()); + + it("commits the task route before emitting and consumes the framework-only delivery", async () => { + const recordedState = state(true); + vi.mocked(recordTaskInputRequestStep).mockResolvedValue({ + accepted: true, + sessionState: recordedState, + }); + vi.mocked(emitRecordedTaskInputRequestStep).mockResolvedValue({ + serializedContext: { adapter: "updated" }, + sessionState: recordedState, + }); + vi.mocked(routeProxiedDeliverStep).mockResolvedValue({ + kind: "continue", + remainder: undefined, + serializedContext: { adapter: "updated" }, + sessionState: recordedState, + }); + + const result = await routeDeliverToChildren({ + parentWritable: new WritableStream(), + payloads: [{ taskInputRequests: [{ hookPayload, taskId: "task-1" }] }], + serializedContext: {}, + sessionState: state(false), + }); + + expect(result).toMatchObject({ kind: "continue", remainder: undefined }); + expect(recordTaskInputRequestStep).toHaveBeenCalledOnce(); + expect(emitRecordedTaskInputRequestStep).toHaveBeenCalledOnce(); + expect(vi.mocked(recordTaskInputRequestStep).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(emitRecordedTaskInputRequestStep).mock.invocationCallOrder[0] ?? 0, + ); + }); + + it("drops an unowned task envelope before it can reach the parent model", async () => { + vi.mocked(recordTaskInputRequestStep).mockResolvedValue({ + accepted: false, + sessionState: state(false), + }); + + const result = await routeDeliverToChildren({ + parentWritable: new WritableStream(), + payloads: [{ taskInputRequests: [{ hookPayload, taskId: "foreign-task" }] }], + serializedContext: {}, + sessionState: state(false), + }); + + expect(result).toMatchObject({ kind: "continue", remainder: undefined }); + expect(emitRecordedTaskInputRequestStep).not.toHaveBeenCalled(); + expect(routeProxiedDeliverStep).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/eve/src/execution/route-child-delivery.ts b/packages/eve/src/execution/route-child-delivery.ts index d3424fd66..b733e852a 100644 --- a/packages/eve/src/execution/route-child-delivery.ts +++ b/packages/eve/src/execution/route-child-delivery.ts @@ -1,7 +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, type RoutedDeliverResult } from "#execution/workflow-steps.js"; +import { + routeProxiedDeliverStep, + type RoutedDeliverResult, +} from "#execution/proxied-deliver-step.js"; +import { emitRecordedTaskInputRequestStep } from "#execution/subagent-event-proxy-step.js"; +import { recordTaskInputRequestStep } from "#execution/tasks/hitl-proxy-steps.js"; /** * Coalesces inbound deliver payloads and routes any descendant-bound input @@ -18,16 +23,50 @@ export async function routeDeliverToChildren(input: { readonly parentWritable: WritableStream; readonly payloads: readonly DeliverPayload[]; readonly sessionState: DurableSessionState; + readonly serializedContext: Record; }): Promise { - const payload = coalesceDeliverPayloads(input.payloads); - if (!input.sessionState.hasProxyInputRequests) { - return { kind: "continue", remainder: payload }; + let payload = coalesceDeliverPayloads(input.payloads); + let serializedContext = input.serializedContext; + let sessionState = input.sessionState; + + for (const request of payload.taskInputRequests ?? []) { + const recorded = await recordTaskInputRequestStep({ + hookPayload: request.hookPayload, + serializedContext, + sessionState, + taskId: request.taskId, + }); + sessionState = recorded.sessionState; + if (!recorded.accepted) continue; + const emitted = await emitRecordedTaskInputRequestStep({ + hookPayload: request.hookPayload, + parentWritable: input.parentWritable, + serializedContext, + sessionState, + }); + serializedContext = emitted.serializedContext; + sessionState = emitted.sessionState; + } + + if (payload.taskInputRequests !== undefined) { + const ordinaryPayload = { ...payload }; + delete ordinaryPayload.taskInputRequests; + payload = ordinaryPayload; + } + if (!sessionState.hasProxyInputRequests) { + return { + kind: "continue", + remainder: Object.keys(payload).length === 0 ? undefined : payload, + serializedContext, + sessionState, + }; } return await routeProxiedDeliverStep({ auth: input.auth, parentWritable: input.parentWritable, payload, - sessionState: input.sessionState, + serializedContext, + sessionState, }); } diff --git a/packages/eve/src/execution/settle-cancelled-turn-step.ts b/packages/eve/src/execution/settle-cancelled-turn-step.ts index 5104dce37..2c65f258b 100644 --- a/packages/eve/src/execution/settle-cancelled-turn-step.ts +++ b/packages/eve/src/execution/settle-cancelled-turn-step.ts @@ -34,7 +34,6 @@ import { type UnstampedMessageStreamEvent, stampMessageStreamEvent, } from "#protocol/message.js"; -import { clearAwaitedTaskWakeSuppressions } from "#tasks/wake-suppression.js"; import { BundleKey, ChannelKey } from "#runtime/sessions/runtime-context-keys.js"; import { resolveEffectiveAgentRuntime } from "#execution/effective-agent-config.js"; @@ -133,19 +132,17 @@ export async function settleCancelledTurnStep(input: { // the cancelled turn's inbox is gone, so a child settlement can never // reach this store again. This is the last write that can move those // handles out of `running`. - const cancelledSession = clearAwaitedTaskWakeSuppressions( - reconcileSessionContinuationToken( - ctx, - setHarnessEmissionState( - clearPendingSessionLimitPrompt( - clearAllProxyInputRequests( - clearPendingWorkflowInterrupt( - clearPendingRuntimeActionBatch(abandonRunningAgentTurns(session)), - ), + const cancelledSession = reconcileSessionContinuationToken( + ctx, + setHarnessEmissionState( + clearPendingSessionLimitPrompt( + clearAllProxyInputRequests( + clearPendingWorkflowInterrupt( + clearPendingRuntimeActionBatch(abandonRunningAgentTurns(session)), ), ), - emissionState, ), + emissionState, ), ); diff --git a/packages/eve/src/execution/subagent-event-proxy-step.ts b/packages/eve/src/execution/subagent-event-proxy-step.ts index 8416a7b2d..dce9bf9ba 100644 --- a/packages/eve/src/execution/subagent-event-proxy-step.ts +++ b/packages/eve/src/execution/subagent-event-proxy-step.ts @@ -57,12 +57,33 @@ export async function runProxySubagentEventStep(input: { }); } +/** Emits a task request whose proxy routes were committed by a prior step. */ +export async function emitRecordedTaskInputRequestStep(input: { + readonly hookPayload: SubagentInputRequestHookPayload; + readonly parentWritable: WritableStream; + readonly serializedContext: Record; + readonly sessionState: DurableSessionState; +}): Promise { + "use step"; + + const durableSession = await readDurableSession(input.sessionState); + const ctx = await deserializeContext(input.serializedContext); + return emitProxiedSubagentEvent({ + ctx, + durableSession, + hookPayload: input.hookPayload, + parentWritable: input.parentWritable, + recordProxyInputRequests: false, + }); +} + /** Applies one proxied child event to an already-hydrated parent context. */ export async function emitProxiedSubagentEvent(input: { readonly ctx: ContextContainer; readonly durableSession: DurableSession; readonly hookPayload: SubagentEventHookPayload; readonly parentWritable: WritableStream; + readonly recordProxyInputRequests?: boolean; }): Promise { const { ctx } = input; const adapter = ctx.require(ChannelKey); @@ -110,7 +131,11 @@ export async function emitProxiedSubagentEvent(input: { setChannelContext(ctx, { ...adapter, state: { ...adapterCtx.state } }); - if (proxyEntries !== undefined && input.hookPayload.kind === "subagent-input-request") { + if ( + input.recordProxyInputRequests !== false && + proxyEntries !== undefined && + input.hookPayload.kind === "subagent-input-request" + ) { scopedSession = upsertProxyInputRequests({ entries: proxyEntries, forChildContinuationToken: input.hookPayload.childContinuationToken, diff --git a/packages/eve/src/execution/subagent-hitl-proxy.test.ts b/packages/eve/src/execution/subagent-hitl-proxy.test.ts index c6af68016..35490c5c8 100644 --- a/packages/eve/src/execution/subagent-hitl-proxy.test.ts +++ b/packages/eve/src/execution/subagent-hitl-proxy.test.ts @@ -85,6 +85,40 @@ describe("routeDeliverPayload", () => { expect(routed.forSelf).toBeUndefined(); }); + it("keeps one task response batch atomic and marks it for its task run", () => { + const session = upsertProxyInputRequests({ + entries: [ + ["req-a", { childContinuationToken: "child-a", kind: "tool-approval", taskId: "task-1" }], + ["req-b", { childContinuationToken: "child-a", kind: "question", taskId: "task-1" }], + ], + forChildContinuationToken: "child-a", + session: createSession(), + }); + + const routed = routeDeliverPayload({ + payload: { + inputResponses: [ + { optionId: "approve", requestId: "req-a" }, + { text: "west", requestId: "req-b" }, + ], + }, + state: session.state, + }); + + expect(routed.forChildren).toEqual([ + { + childContinuationToken: "child-a", + payload: { + inputResponses: [ + { optionId: "approve", requestId: "req-a" }, + { text: "west", requestId: "req-b" }, + ], + }, + taskId: "task-1", + }, + ]); + }); + 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" }]], diff --git a/packages/eve/src/execution/subagent-hitl-proxy.ts b/packages/eve/src/execution/subagent-hitl-proxy.ts index ef411516a..5b1083074 100644 --- a/packages/eve/src/execution/subagent-hitl-proxy.ts +++ b/packages/eve/src/execution/subagent-hitl-proxy.ts @@ -74,6 +74,8 @@ export interface RoutedDeliverPayload { readonly forChildren: readonly { readonly childContinuationToken: string; readonly payload: { readonly inputResponses: readonly InputResponse[] }; + /** Present when the child is owned by a task run, which delivers on the parent's behalf. */ + readonly taskId?: string; }[]; readonly forSelf: DeliverPayload | undefined; readonly parentAction: { readonly kind: "cancel-turn" } | undefined; @@ -81,20 +83,28 @@ export interface RoutedDeliverPayload { /** Splits a deliver payload into parent-local and proxied-child buckets. */ export function routeDeliverPayload(input: { + readonly allowRoute?: (requestId: string, route: ProxyInputRequest) => boolean; readonly payload: DeliverPayload; readonly state: SessionStateMap | undefined; }): RoutedDeliverPayload { const entries = getProxyInputRequests(input.state); const inputResponses = input.payload.inputResponses ?? []; - const responsesByChild = new Map(); + const responsesByChild = new Map< + string, + { + readonly childContinuationToken: string; + readonly responses: InputResponse[]; + readonly taskId?: string; + } + >(); const unroutedResponses: InputResponse[] = []; let parentAction: RoutedDeliverPayload["parentAction"]; for (const response of inputResponses) { const route = entries.get(response.requestId); - if (route === undefined) { + if (route === undefined || input.allowRoute?.(response.requestId, route) === false) { unroutedResponses.push(response); continue; } @@ -103,20 +113,41 @@ export function routeDeliverPayload(input: { parentAction = { kind: "cancel-turn" }; } - const existing = responsesByChild.get(route.childContinuationToken); + const bucketKey = + route.taskId === undefined + ? route.childContinuationToken + : `${route.childContinuationToken}\0${route.taskId}`; + const existing = responsesByChild.get(bucketKey); if (existing === undefined) { - responsesByChild.set(route.childContinuationToken, [response]); + const bucket: { + childContinuationToken: string; + responses: InputResponse[]; + taskId?: string; + } = { + childContinuationToken: route.childContinuationToken, + responses: [response], + }; + if (route.taskId !== undefined) bucket.taskId = route.taskId; + responsesByChild.set(bucketKey, bucket); } else { - existing.push(response); + existing.responses.push(response); } } - const forChildren: RoutedDeliverPayload["forChildren"] = [...responsesByChild.entries()].map( - ([childContinuationToken, responses]) => ({ - childContinuationToken, - payload: { inputResponses: responses }, - }), + const forChildren: RoutedDeliverPayload["forChildren"] = [...responsesByChild.values()].map( + ({ childContinuationToken, responses, taskId }) => { + const routed: { + childContinuationToken: string; + payload: { inputResponses: InputResponse[] }; + taskId?: string; + } = { + childContinuationToken, + payload: { inputResponses: responses }, + }; + if (taskId !== undefined) routed.taskId = taskId; + return routed; + }, ); // Preserve every non-`inputResponses` field on the original payload diff --git a/packages/eve/src/execution/subagent-start-local.ts b/packages/eve/src/execution/subagent-start-local.ts new file mode 100644 index 000000000..480dd27e5 --- /dev/null +++ b/packages/eve/src/execution/subagent-start-local.ts @@ -0,0 +1,134 @@ +import type { DispatchOutcome, RuntimeSession } from "#execution/agent-handle-dispatch.js"; +import { mintStartOperation } from "#execution/dispatch-start-operation.js"; +import { isRuntimeSessionOwnershipConflictError } from "#execution/runtime-errors.js"; +import { buildSubagentRunInput, type SubagentInputSource } from "#execution/subagent-tool.js"; +import { createWorkflowRuntime } from "#execution/workflow-runtime.js"; +import { SUBAGENT_START_FAILED } from "#harness/agent-handle-errors.js"; +import { + confirmAgentStarted, + prepareAgentStart, + rejectAgentEffect, +} from "#harness/handles/transitions.js"; +import { createLogger, logError } from "#internal/logging.js"; +import type { RuntimeSubagentCallActionRequest } from "#runtime/actions/types.js"; +import type { CompiledBundle } from "#runtime/sessions/runtime-context-keys.js"; +import { toErrorMessage } from "#shared/errors.js"; + +const log = createLogger("execution.subagent-start-local"); + +type DynamicSubagentAgentConfig = Parameters< + typeof createWorkflowRuntime +>[0]["dynamicSubagentAgentConfig"]; + +/** Starts one local subagent after dispatch planning has selected its target. */ +export async function startLocalSubagent(input: { + readonly action: RuntimeSubagentCallActionRequest; + readonly auth: Parameters[0]["auth"]; + readonly batchEvent: { readonly sequence: number; readonly turnId: string }; + readonly bundle: CompiledBundle; + readonly capabilities: Parameters[0]["capabilities"]; + readonly channelMetadata: Parameters[0]["channelMetadata"]; + readonly currentSession: RuntimeSession; + readonly dynamicSubagentAgentConfig?: DynamicSubagentAgentConfig; + readonly fanoutSize: number; + readonly initiatorAuth: Parameters[0]["initiatorAuth"]; + readonly parentContinuationToken: string | undefined; + readonly parentTraceContext: Parameters[0]["parentTraceContext"]; + readonly persistentSessions: boolean; + readonly session: RuntimeSession; + readonly source: SubagentInputSource; +}): Promise { + const { action, source } = input; + const childRuntime = createWorkflowRuntime({ + compiledArtifactsSource: input.bundle.compiledArtifactsSource, + dynamicSubagentAgentConfig: input.dynamicSubagentAgentConfig, + nodeId: action.nodeId, + }); + const { childContinuationToken, runInput } = buildSubagentRunInput({ + action, + auth: input.auth, + batchEvent: input.batchEvent, + capabilities: input.capabilities, + channelMetadata: input.channelMetadata, + fanoutSize: input.fanoutSize, + initiatorAuth: input.initiatorAuth, + parentContinuationToken: input.parentContinuationToken, + parentTraceContext: input.parentTraceContext, + persistentSessions: input.persistentSessions, + session: input.session, + source, + }); + + const targetKind = source.type === "runtime" ? ("agent/self" as const) : ("agent/local" as const); + const { identity, operation } = mintStartOperation({ + callId: action.callId, + name: action.subagentName, + nodeId: action.nodeId, + parentSessionId: input.session.sessionId, + parentTurnId: input.batchEvent.turnId, + }); + // Ownership is recorded before the start side effect, and the prepared + // (or rejected) store rides every outcome into the step result. The + // guarantee is intra-step: a crash between the accepted start and the + // step-result commit still replays the whole dispatch step, so the + // orphan window shrinks to that boundary rather than disappearing. + const preparedSession = prepareAgentStart(input.currentSession, { + identity, + operation, + target: { continuationToken: childContinuationToken, kind: targetKind }, + }); + + let childSessionId: string; + try { + const handle = await childRuntime.createSession(runInput); + childSessionId = handle.sessionId; + } catch (error) { + if (!isRuntimeSessionOwnershipConflictError(error)) { + logError(log, "local subagent start failed", error, { + callId: action.callId, + nodeId: action.nodeId, + subagentName: action.subagentName, + }); + return { + kind: "error", + result: { + callId: action.callId, + isError: true, + kind: "subagent-result", + origin: "dispatch", + output: { + code: SUBAGENT_START_FAILED, + message: toErrorMessage(error), + }, + subagentName: action.subagentName, + }, + session: rejectAgentEffect(preparedSession, { + disposition: "dead", + operationId: operation.id, + }), + }; + } + // A replayed step re-derives the same deterministic child continuation + // token, so the run holding it is the child this operation already + // started. Adopt it instead of failing live work and starting a second + // child on the next attempt. + childSessionId = error.ownerSessionId; + } + + const address = { + continuationToken: childContinuationToken, + kind: targetKind, + sessionId: childSessionId, + } as const; + return { + address, + callId: action.callId, + kind: "called", + name: action.name, + session: confirmAgentStarted(preparedSession, { + address, + operationId: operation.id, + }), + toolName: action.subagentName, + }; +} diff --git a/packages/eve/src/execution/subagent-start-remote.ts b/packages/eve/src/execution/subagent-start-remote.ts index 5669b7a91..57697ad55 100644 --- a/packages/eve/src/execution/subagent-start-remote.ts +++ b/packages/eve/src/execution/subagent-start-remote.ts @@ -82,6 +82,7 @@ export async function startRemoteSubagent(input: { callbackBaseUrl, callbackToken: input.parentContinuationToken, initiatorAuth: input.initiatorAuth, + operationId: operation.id, persistentSessions: input.persistentSessions, remote: resolvedRemote, session: input.session, diff --git a/packages/eve/src/execution/tasks/await-steps.ts b/packages/eve/src/execution/tasks/await-steps.ts deleted file mode 100644 index 00be82311..000000000 --- a/packages/eve/src/execution/tasks/await-steps.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { - EntityConflictError, - HookNotFoundError, - RunExpiredError, - WorkflowRunNotFoundError, -} from "#compiled/@workflow/errors/index.js"; - -import type { AwaitedTaskRef } from "#execution/tasks/await-workflow.js"; -import { readLatestTaskSnapshot } from "#execution/tasks/run-control.js"; -import { getHookByToken } from "#internal/workflow/runtime.js"; -import { createLogger } from "#internal/logging.js"; -import type { RuntimeActionResult } from "#runtime/actions/types.js"; -import { walkCauseChain } from "#shared/errors.js"; -import { taskViewsToJson } from "#tasks/json.js"; -import type { TaskView } from "#tasks/types.js"; -import { resumeHook } from "#internal/workflow/runtime.js"; - -const log = createLogger("execution.tasks.await"); - -/** - * Reads the latest snapshot of every awaited task, or reports that the - * waiting turn's inbox is gone so the aggregation run can stop polling. - * - * A run that has not published its first snapshot yet reads as - * `working` — the caller holds the creation receipt, which says the - * same thing. - */ -export async function readAwaitedTaskViewsStep(input: { - readonly replyToken: string; - readonly tasks: readonly AwaitedTaskRef[]; -}): Promise< - { readonly kind: "listener-gone" } | { readonly kind: "views"; readonly views: TaskView[] } -> { - "use step"; - - try { - await getHookByToken(input.replyToken); - } catch (error) { - if (isGoneListener(error)) { - return { kind: "listener-gone" }; - } - throw error; - } - - const views = await Promise.all( - input.tasks.map( - async (task) => - (await readLatestTaskSnapshot({ taskRunId: task.taskRunId })) ?? - createPendingView(task.taskId), - ), - ); - return { kind: "views", views }; -} - -/** Resolves the pending `task_await` key with its aggregated views. */ -export async function postTaskAwaitResultStep(input: { - readonly callId: string; - readonly replyToken: string; - readonly toolName: string; - readonly views: readonly TaskView[]; -}): Promise { - "use step"; - - const result: RuntimeActionResult = { - callId: input.callId, - kind: "tool-result", - output: taskViewsToJson(input.views), - toolName: input.toolName, - }; - try { - await resumeHook(input.replyToken, { kind: "runtime-action-result", results: [result] }); - } catch (error) { - if (isGoneListener(error)) { - log.warn("task_await listener disappeared before its result posted", { - callId: input.callId, - toolName: input.toolName, - }); - return; - } - throw error; - } -} - -function createPendingView(taskId: string): TaskView { - return { - metadata: { kind: "subagent", mode: "local", name: "unknown" }, - status: "working", - taskId, - }; -} - -function isGoneListener(error: unknown): boolean { - for (const candidate of walkCauseChain(error)) { - if ( - HookNotFoundError.is(candidate) || - WorkflowRunNotFoundError.is(candidate) || - RunExpiredError.is(candidate) || - EntityConflictError.is(candidate) - ) { - return true; - } - } - return false; -} diff --git a/packages/eve/src/execution/tasks/await-workflow.test.ts b/packages/eve/src/execution/tasks/await-workflow.test.ts deleted file mode 100644 index 0b6a88344..000000000 --- a/packages/eve/src/execution/tasks/await-workflow.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { sleep } from "#compiled/@workflow/core/index.js"; - -import { postTaskAwaitResultStep, readAwaitedTaskViewsStep } from "#execution/tasks/await-steps.js"; -import { taskAwaitWorkflow } from "#execution/tasks/await-workflow.js"; -import type { TaskView } from "#tasks/types.js"; - -vi.mock("#compiled/@workflow/core/index.js", () => ({ - sleep: vi.fn(), -})); - -vi.mock("./await-steps.js", () => ({ - postTaskAwaitResultStep: vi.fn(), - readAwaitedTaskViewsStep: vi.fn(), -})); - -afterEach(() => { - vi.resetAllMocks(); -}); - -function createView(taskId: string, status: TaskView["status"]): TaskView { - return { - metadata: { kind: "subagent", mode: "local", name: "research" }, - status, - taskId, - }; -} - -const INPUT = { - callId: "call-await-1", - replyToken: "turn-inbox-token", - tasks: [ - { taskId: "task_a", taskRunId: "run-a" }, - { taskId: "task_b", taskRunId: "run-b" }, - ], - toolName: "task_await", -}; - -describe("taskAwaitWorkflow", () => { - it("polls until every task is ready, then posts one aggregated result", async () => { - vi.mocked(readAwaitedTaskViewsStep) - .mockResolvedValueOnce({ - kind: "views", - views: [createView("task_a", "completed"), createView("task_b", "working")], - }) - .mockResolvedValueOnce({ - kind: "views", - views: [createView("task_a", "completed"), createView("task_b", "input_required")], - }); - vi.mocked(sleep).mockResolvedValue(undefined); - - await taskAwaitWorkflow(INPUT); - - expect(sleep).toHaveBeenCalledTimes(1); - expect(postTaskAwaitResultStep).toHaveBeenCalledTimes(1); - expect(vi.mocked(postTaskAwaitResultStep).mock.calls[0]?.[0]).toMatchObject({ - callId: "call-await-1", - replyToken: "turn-inbox-token", - toolName: "task_await", - }); - }); - - it("stops polling without posting when the waiting turn is gone", async () => { - vi.mocked(readAwaitedTaskViewsStep).mockResolvedValue({ kind: "listener-gone" }); - - await taskAwaitWorkflow(INPUT); - - expect(postTaskAwaitResultStep).not.toHaveBeenCalled(); - expect(sleep).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/eve/src/execution/tasks/await-workflow.ts b/packages/eve/src/execution/tasks/await-workflow.ts deleted file mode 100644 index a8376ff93..000000000 --- a/packages/eve/src/execution/tasks/await-workflow.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { sleep } from "#compiled/@workflow/core/index.js"; - -import { postTaskAwaitResultStep, readAwaitedTaskViewsStep } from "#execution/tasks/await-steps.js"; -import { isReadyTaskStatus } from "#tasks/types.js"; - -const DEFAULT_POLL_INTERVAL_MS = 10_000; - -/** One awaited task: the model-visible id plus its run's read coordinates. */ -export interface AwaitedTaskRef { - readonly taskId: string; - readonly taskRunId: string; -} - -/** Input for one `task_await` aggregation run. */ -export interface TaskAwaitWorkflowInput { - readonly callId: string; - readonly pollIntervalMs?: number; - /** The waiting turn's inbox token; the result resumes the existing wait. */ - readonly replyToken: string; - readonly tasks: readonly AwaitedTaskRef[]; - readonly toolName: string; -} - -/** - * Aggregates one `task_await` call across its selected task runs. - * - * `task_await` returns when *every* selected task is terminal or - * `input_required`, so someone must observe all of them; the task runs - * are single-task writers and the parent turn can only wait on its - * inbox. This small durable run polls the snapshot streams and, once - * every task is ready, posts the one `tool-result` the pending - * `task_await` key is waiting for. - * - * The run exits without posting when the waiting turn is gone (its - * inbox was disposed by completion or cancellation) — the model asked, - * then stopped listening. - */ -export async function taskAwaitWorkflow(input: TaskAwaitWorkflowInput): Promise { - "use workflow"; - - while (true) { - const observation = await readAwaitedTaskViewsStep({ - replyToken: input.replyToken, - tasks: input.tasks, - }); - if (observation.kind === "listener-gone") return; - - if (observation.views.every((view) => isReadyTaskStatus(view.status))) { - await postTaskAwaitResultStep({ - callId: input.callId, - replyToken: input.replyToken, - toolName: input.toolName, - views: observation.views, - }); - return; - } - - await sleep(input.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS); - } -} diff --git a/packages/eve/src/execution/tasks/continuation-admission.ts b/packages/eve/src/execution/tasks/continuation-admission.ts new file mode 100644 index 000000000..e0879455d --- /dev/null +++ b/packages/eve/src/execution/tasks/continuation-admission.ts @@ -0,0 +1,118 @@ +import type { + DispatchOutcome, + RuntimeAgentHandleAction, + RuntimeSession, +} from "#execution/agent-handle-dispatch.js"; +import { + failDelegatedDispatch, + settleDelegatedDispatch, + type DelegatedTask, +} from "#execution/tasks/delegate.js"; +import { findConflictingTaskForChildSession } from "#execution/tasks/control-shared.js"; +import { AGENT_BUSY, AGENT_UNREACHABLE } from "#harness/agent-handle-errors.js"; +import { getAgentHandleStore } from "#harness/handles/store.js"; +import type { RuntimeSubagentDispatchFailure } from "#runtime/actions/types.js"; +import type { JsonValue } from "#shared/json.js"; + +export type ReservedTaskContinuation = Awaited>; + +/** Returns the task-derived busy result for one persistent-agent continuation. */ +export async function checkTaskContinuationAdmission(input: { + readonly action: RuntimeAgentHandleAction; + readonly agentId: string; + readonly parentStepIndex: number; + readonly parentTurnId: string; + readonly session: RuntimeSession; +}): Promise { + const handle = getAgentHandleStore(input.session.state)?.handles.find( + (candidate) => candidate.identity.id === input.agentId, + ); + if (handle?.phase !== "running" && handle?.phase !== "parked") return undefined; + const conflicting = await findConflictingTaskForChildSession({ + childSessionId: handle.address.sessionId, + parentStepIndex: input.parentStepIndex, + parentTurnId: input.parentTurnId, + session: input.session, + }); + if (conflicting === undefined) return undefined; + return { + callId: input.action.callId, + isError: true, + kind: "subagent-result", + origin: "dispatch", + output: { + code: AGENT_BUSY, + message: `Agent "${input.agentId}" is busy with task "${conflicting.view.taskId}" (${conflicting.view.status}).`, + }, + subagentName: + input.action.kind === "remote-agent-call" + ? input.action.remoteAgentName + : input.action.subagentName, + }; +} + +/** Reserves a persistent child before its ambiguous continuation side effect. */ +export async function reserveTaskContinuation(input: { + readonly action: RuntimeAgentHandleAction; + readonly agentId: string; + readonly delegated: DelegatedTask | undefined; + readonly session: RuntimeSession; +}): Promise { + if (input.delegated === undefined) return undefined; + const handle = getAgentHandleStore(input.session.state)?.handles.find( + (candidate) => + (candidate.phase === "running" || candidate.phase === "parked") && + candidate.identity.id === input.agentId, + ); + if (handle === undefined || (handle.phase !== "running" && handle.phase !== "parked")) { + return undefined; + } + return settleDelegatedDispatch({ + callId: input.action.callId, + childSessionId: handle.address.sessionId, + session: input.session, + subagentName: handle.identity.name, + task: input.delegated, + }); +} + +/** Terminates definitive failures while retaining ambiguous reservations. */ +export async function settleTaskDispatchError(input: { + readonly agentId: string | undefined; + readonly delegated: DelegatedTask | undefined; + readonly outcome: Extract; + readonly reserved: ReservedTaskContinuation | undefined; + readonly session: RuntimeSession; +}): Promise { + const retainedHandle = + input.agentId !== undefined && + getAgentHandleStore(input.session.state)?.handles.some( + (handle) => handle.identity.id === input.agentId, + ) === true; + const ambiguous = + input.reserved !== undefined && + readErrorCode(input.outcome.result.output) === AGENT_UNREACHABLE && + retainedHandle; + if (input.delegated !== undefined && !ambiguous) { + await failDelegatedDispatch({ error: input.outcome.result.output, task: input.delegated }); + } + return input.reserved === undefined + ? input.outcome.result + : { + ...input.outcome.result, + output: attachTaskId(input.outcome.result.output, input.delegated?.taskId), + }; +} + +function attachTaskId(output: JsonValue, taskId: string | undefined): JsonValue { + if (taskId === undefined) return output; + return output !== null && typeof output === "object" && !Array.isArray(output) + ? { ...output, taskId } + : { error: output, taskId }; +} + +function readErrorCode(output: JsonValue): string | undefined { + if (output === null || typeof output !== "object" || Array.isArray(output)) return undefined; + const code = Reflect.get(output, "code"); + return typeof code === "string" ? code : undefined; +} diff --git a/packages/eve/src/execution/tasks/control-shared.ts b/packages/eve/src/execution/tasks/control-shared.ts index d98b18e0e..33a1cdcab 100644 --- a/packages/eve/src/execution/tasks/control-shared.ts +++ b/packages/eve/src/execution/tasks/control-shared.ts @@ -1,14 +1,19 @@ import type { RuntimeSession } from "#execution/agent-handle-dispatch.js"; import { readLatestTaskSnapshot } from "#execution/tasks/run-control.js"; import { getAgentHandleStore, type AgentHandle } from "#harness/handles/store.js"; +import { settleAgentTurn } from "#harness/handles/transitions.js"; import type { RuntimeActionResult, RuntimeToolCallActionRequest } from "#runtime/actions/types.js"; import { taskViewsToJson } from "#tasks/json.js"; -import { findSessionTaskEntry, type SessionTaskIndexEntry } from "#tasks/session-index.js"; -import type { TaskView } from "#tasks/types.js"; +import { + findSessionTaskEntry, + getSessionTaskIndex, + type SessionTaskIndexEntry, +} from "#tasks/session-index.js"; +import { isTerminalTaskStatus, type TaskView } from "#tasks/types.js"; /** * Result and lookup helpers shared by the task-control executors - * (`task_peek`/`task_await`/`task_cancel` in the dispatch module, + * (`task_peek`/`task_cancel` in the dispatch module, * `task_send` in its own). */ @@ -69,6 +74,67 @@ export function findAddressableHandle( .find((candidate) => candidate.address.sessionId === childSessionId); } +/** Finds the task that reserves a child for this batch or is still nonterminal. */ +export async function findConflictingTaskForChildSession(input: { + readonly childSessionId: string; + readonly parentStepIndex?: number; + readonly parentTurnId: string; + readonly session: RuntimeSession; +}): Promise<{ readonly entry: SessionTaskIndexEntry; readonly view: TaskView } | undefined> { + for (const entry of getSessionTaskIndex(input.session.state)) { + if (entry.childSessionId !== input.childSessionId) continue; + const view = + (await readLatestTaskSnapshot({ taskRunId: entry.taskRunId })) ?? + createPendingTaskView(entry.taskId); + if ( + (entry.createdByTurnId === input.parentTurnId && + entry.createdByStepIndex === input.parentStepIndex) || + !isTerminalTaskStatus(view.status) + ) { + return { entry, view }; + } + } + return undefined; +} + +/** Applies actual terminal task outcomes to handles left running by task receipts. */ +export async function reconcileSettledTaskHandles( + session: RuntimeSession, +): Promise { + let nextSession = session; + for (const entry of getSessionTaskIndex(session.state)) { + const view = await readLatestTaskSnapshot({ taskRunId: entry.taskRunId }); + if ( + view === undefined || + !isTerminalTaskStatus(view.status) || + view.metadata.childLifecycle === undefined + ) { + continue; + } + const result = + view.status === "completed" + ? { kind: "succeeded" as const, output: view.lastOutput?.data ?? "" } + : view.status === "failed" + ? { error: view.lastOutput?.data ?? "Task failed.", kind: "failed" as const } + : { kind: "cancelled" as const }; + const settled = settleAgentTurn(nextSession, { + operationId: entry.operationId, + outcome: { + kind: view.metadata.childLifecycle, + result, + usageDelta: { + cacheReadTokens: 0, + cacheWriteTokens: 0, + inputTokens: 0, + outputTokens: 0, + }, + }, + }); + if (settled.kind === "settled") nextSession = settled.session; + } + return nextSession; +} + /** One successful task-control result carrying full task views. */ export function createTaskViewsResult( action: RuntimeToolCallActionRequest, diff --git a/packages/eve/src/execution/tasks/delegate.test.ts b/packages/eve/src/execution/tasks/delegate.test.ts new file mode 100644 index 000000000..818cc6b5f --- /dev/null +++ b/packages/eve/src/execution/tasks/delegate.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { settleDelegatedDispatch, type DelegatedTask } from "#execution/tasks/delegate.js"; +import { sendTaskCommandToOwner } from "#execution/tasks/run-control.js"; +import type { RuntimeSession } from "#execution/agent-handle-dispatch.js"; +import { getSessionTaskIndex } from "#tasks/session-index.js"; + +vi.mock("#execution/tasks/run-control.js", () => ({ + sendTaskCommandToOwner: vi.fn(), +})); + +function createSession(): RuntimeSession { + return { + agent: { modelReference: { id: "model" }, system: "", tools: [] }, + compaction: { recentWindowSize: 4, threshold: 1_000_000 }, + continuationToken: "parent-token", + history: [], + sessionId: "parent-session", + } as RuntimeSession; +} + +describe("delegated task settlement", () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(sendTaskCommandToOwner).mockResolvedValue({ runId: "run-owner" }); + }); + + it("indexes the command-hook owner rather than a replay-losing candidate run", async () => { + const task: DelegatedTask = { + commandToken: "task-token", + createdByTurnId: "turn-parent", + operationId: "operation-1", + taskId: "task-1", + taskRunId: "run-candidate", + }; + + const result = await settleDelegatedDispatch({ + callId: "call-task", + childSessionId: "child-session", + session: createSession(), + subagentName: "research", + task, + }); + + expect(sendTaskCommandToOwner).toHaveBeenCalledWith( + expect.objectContaining({ command: { childSessionId: "child-session", kind: "describe" } }), + ); + expect(getSessionTaskIndex(result.session.state)[0]).toMatchObject({ + taskId: "task-1", + taskRunId: "run-owner", + }); + }); +}); diff --git a/packages/eve/src/execution/tasks/delegate.ts b/packages/eve/src/execution/tasks/delegate.ts index 840e360fe..9d78b0a8f 100644 --- a/packages/eve/src/execution/tasks/delegate.ts +++ b/packages/eve/src/execution/tasks/delegate.ts @@ -1,6 +1,12 @@ import type { RuntimeSession } from "#execution/agent-handle-dispatch.js"; -import { sendTaskCommand, startTaskRun } from "#execution/tasks/run-control.js"; +import { + sendTaskCommand, + sendTaskCommandToOwner, + startTaskRun, +} from "#execution/tasks/run-control.js"; import type { RuntimeSubagentChildResult } from "#runtime/actions/types.js"; +import { sessionCommandHookToken } from "#execution/session-command-token.js"; +import { deriveAgentOperationId } from "#harness/handles/operation-id.js"; import type { JsonValue } from "#shared/json.js"; import { recordSessionTask } from "#tasks/session-index.js"; import { deriveTaskCommandToken, deriveTaskId } from "#tasks/task-id.js"; @@ -8,6 +14,9 @@ import { deriveTaskCommandToken, deriveTaskId } from "#tasks/task-id.js"; /** A prepared delegated task: identity plus its started durable run. */ export interface DelegatedTask { readonly commandToken: string; + readonly createdByTurnId: string; + readonly createdByStepIndex?: number; + readonly operationId: string; readonly taskId: string; readonly taskRunId: string; } @@ -23,6 +32,7 @@ export async function beginDelegatedTask(input: { readonly mode: "local" | "remote"; readonly name: string; readonly parentSessionId: string; + readonly parentStepIndex?: number; readonly parentTurnId: string; readonly session: RuntimeSession; }): Promise { @@ -31,6 +41,11 @@ export async function beginDelegatedTask(input: { parentSessionId: input.parentSessionId, parentTurnId: input.parentTurnId, }); + const operationId = deriveAgentOperationId({ + callId: input.callId, + parentSessionId: input.parentSessionId, + parentTurnId: input.parentTurnId, + }); const commandToken = deriveTaskCommandToken({ parentContinuationToken: input.session.continuationToken, taskId, @@ -42,9 +57,16 @@ export async function beginDelegatedTask(input: { status: "working", taskId, }, - wakeToken: input.session.continuationToken, + wakeToken: sessionCommandHookToken(input.session.sessionId), }); - return { commandToken, taskId, taskRunId: run.runId }; + return { + commandToken, + createdByStepIndex: input.parentStepIndex ?? 0, + createdByTurnId: input.parentTurnId, + operationId, + taskId, + taskRunId: run.runId, + }; } /** @@ -65,11 +87,14 @@ export async function settleDelegatedDispatch(input: { }): Promise<{ readonly receipt: RuntimeSubagentChildResult; readonly session: RuntimeSession }> { // The freshly started task run may not have registered its hook yet; // ride out that startup window instead of dropping the acknowledgement. - await sendTaskCommand({ + const owner = await sendTaskCommandToOwner({ command: { childSessionId: input.childSessionId, kind: "describe" }, commandToken: input.task.commandToken, retryUnreachable: { attempts: 20, delayMs: 250 }, }); + if (owner === undefined) { + throw new Error(`Task run "${input.task.taskId}" did not accept its child acknowledgement.`); + } const receiptOutput = { status: "working" as const, taskId: input.task.taskId }; return { receipt: { @@ -89,9 +114,13 @@ export async function settleDelegatedDispatch(input: { subagentName: input.subagentName, }, session: recordSessionTask(input.session, { + childSessionId: input.childSessionId, commandToken: input.task.commandToken, + createdByStepIndex: input.task.createdByStepIndex, + createdByTurnId: input.task.createdByTurnId, + operationId: input.task.operationId, taskId: input.task.taskId, - taskRunId: input.task.taskRunId, + taskRunId: owner.runId, }), }; } diff --git a/packages/eve/src/execution/tasks/dispatch.test.ts b/packages/eve/src/execution/tasks/dispatch.test.ts new file mode 100644 index 000000000..f8a63d762 --- /dev/null +++ b/packages/eve/src/execution/tasks/dispatch.test.ts @@ -0,0 +1,175 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { RuntimeSession } from "#execution/agent-handle-dispatch.js"; +import { + cancelRemoteAgentTurn, + resolveRemoteAgentForAction, +} from "#execution/remote-agent-dispatch.js"; +import { executeTaskControlAction } from "#execution/tasks/dispatch.js"; +import { readLatestTaskSnapshot, sendTaskCommand } from "#execution/tasks/run-control.js"; +import { requestWorkflowTurnCancellation } from "#execution/workflow-runtime.js"; +import { AGENT_HANDLES_STATE_KEY } from "#harness/handles/store.js"; +import type { RuntimeToolCallActionRequest } from "#runtime/actions/types.js"; +import type { CompiledBundle } from "#runtime/sessions/runtime-context-keys.js"; +import { SESSION_TASKS_STATE_KEY } from "#tasks/session-index.js"; + +vi.mock("#execution/tasks/run-control.js", () => ({ + readLatestTaskSnapshot: vi.fn(), + sendTaskCommand: vi.fn(), +})); +vi.mock("#execution/workflow-runtime.js", async (importOriginal) => ({ + ...(await importOriginal()), + requestWorkflowTurnCancellation: vi.fn(), +})); +vi.mock("#execution/remote-agent-dispatch.js", async (importOriginal) => ({ + ...(await importOriginal()), + cancelRemoteAgentTurn: vi.fn(), + resolveRemoteAgentForAction: vi.fn(), +})); + +const action: RuntimeToolCallActionRequest = { + callId: "call-cancel", + input: { taskIds: ["task-1"] }, + kind: "tool-call", + toolName: "task_cancel", +}; + +function createSession(mode: "local" | "remote"): RuntimeSession { + const address = + mode === "local" + ? { continuationToken: "child-token", kind: "agent/local" as const, sessionId: "child-1" } + : { + callbackBaseUrl: "https://parent.example", + continuationToken: "child-token", + kind: "agent/remote" as const, + sessionId: "child-1", + url: "https://child.example", + }; + return { + agent: { modelReference: { id: "model" }, system: "", tools: [] }, + compaction: { recentWindowSize: 4, threshold: 1_000_000 }, + continuationToken: "parent-token", + history: [], + sessionId: "parent-session", + state: { + [AGENT_HANDLES_STATE_KEY]: { + handles: [ + { + address, + identity: { id: "agent-1", name: "research", nodeId: "node-1" }, + operation: { + callId: "call-task", + id: "operation-1", + kind: "continue", + parentTurnId: "turn-parent", + previousStatus: "idle", + }, + phase: "running", + }, + ], + }, + [SESSION_TASKS_STATE_KEY]: { + tasks: [ + { + childSessionId: "child-1", + commandToken: "task-token", + createdByTurnId: "turn-1", + operationId: "operation-1", + taskId: "task-1", + taskRunId: "run-1", + }, + ], + }, + }, + } as RuntimeSession; +} + +describe("task cancellation identity", () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(sendTaskCommand).mockResolvedValue("delivered"); + vi.mocked(readLatestTaskSnapshot).mockResolvedValue({ + metadata: { + childSessionId: "child-1", + childTurnId: "turn_child_7", + kind: "subagent", + mode: "local", + name: "research", + }, + status: "cancelled", + taskId: "task-1", + }); + vi.mocked(resolveRemoteAgentForAction).mockReturnValue({ name: "research" } as never); + vi.mocked(requestWorkflowTurnCancellation).mockResolvedValue({ status: "accepted" }); + vi.mocked(cancelRemoteAgentTurn).mockResolvedValue({ status: "accepted" }); + }); + + it.each(["local", "remote"] as const)( + "guards %s cancellation with the task's child turn", + async (mode) => { + const result = await executeTaskControlAction({ + action, + bundle: { subagentRegistry: { subagentsByNodeId: new Map() } } as never, + parentTurnId: "turn-parent", + session: createSession(mode), + }); + + if (mode === "local") { + expect(requestWorkflowTurnCancellation).toHaveBeenCalledWith({ + sessionId: "child-1", + taskId: "task-1", + turnId: "turn_child_7", + }); + expect(cancelRemoteAgentTurn).not.toHaveBeenCalled(); + } else { + expect(cancelRemoteAgentTurn).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "child-1", + taskId: "task-1", + turnId: "turn_child_7", + }), + ); + expect(requestWorkflowTurnCancellation).not.toHaveBeenCalled(); + } + expect(result.result).toMatchObject({ output: { tasks: [{ status: "cancelled" }] } }); + expect(result.session.state?.[AGENT_HANDLES_STATE_KEY]).toMatchObject({ + handles: [{ phase: "parked" }], + }); + }, + ); + + it("uses task-scoped cancellation before child-turn identity arrives", async () => { + vi.mocked(readLatestTaskSnapshot).mockResolvedValue({ + metadata: { childSessionId: "child-1", kind: "subagent", mode: "local", name: "research" }, + status: "cancelled", + taskId: "task-1", + }); + + await executeTaskControlAction({ + action, + bundle: {} as CompiledBundle, + parentTurnId: "turn-parent", + session: createSession("local"), + }); + + expect(requestWorkflowTurnCancellation).toHaveBeenCalledWith({ + sessionId: "child-1", + taskId: "task-1", + }); + expect(cancelRemoteAgentTurn).not.toHaveBeenCalled(); + }); + + it("does not propagate a repeated cancel after the task hook is disposed", async () => { + vi.mocked(sendTaskCommand).mockResolvedValue("unreachable"); + + await executeTaskControlAction({ + action, + bundle: {} as CompiledBundle, + parentTurnId: "turn-parent", + session: createSession("local"), + }); + + expect(requestWorkflowTurnCancellation).not.toHaveBeenCalled(); + expect(cancelRemoteAgentTurn).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/eve/src/execution/tasks/dispatch.ts b/packages/eve/src/execution/tasks/dispatch.ts index 713648830..d2081f897 100644 --- a/packages/eve/src/execution/tasks/dispatch.ts +++ b/packages/eve/src/execution/tasks/dispatch.ts @@ -14,12 +14,7 @@ import { } from "#execution/tasks/control-shared.js"; import { executeTaskSend } from "#execution/tasks/send.js"; import { readLatestTaskSnapshot, sendTaskCommand } from "#execution/tasks/run-control.js"; -import type { AwaitedTaskRef } from "#execution/tasks/await-workflow.js"; -import { - requestWorkflowTurnCancellation, - startWorkflowPreferLatest, - taskAwaitWorkflowReference, -} from "#execution/workflow-runtime.js"; +import { requestWorkflowTurnCancellation } from "#execution/workflow-runtime.js"; import { createLogger, logError } from "#internal/logging.js"; import type { RuntimeActionRequest, @@ -28,15 +23,14 @@ import type { } from "#runtime/actions/types.js"; import type { CompiledBundle } from "#runtime/sessions/runtime-context-keys.js"; import { - TASK_AWAIT_TOOL_NAME, TASK_CANCEL_TOOL_NAME, TASK_CONTROL_TOOL_NAMES, TASK_PEEK_TOOL_NAME, TASK_SEND_TOOL_NAME, } from "#runtime/framework-tools/tasks.js"; import type { SessionTaskIndexEntry } from "#tasks/session-index.js"; -import { isReadyTaskStatus, type TaskView } from "#tasks/types.js"; -import { suppressAwaitedTaskWakes } from "#tasks/wake-suppression.js"; +import { isTerminalTaskStatus, type TaskView } from "#tasks/types.js"; +import { settleAgentTurn } from "#harness/handles/transitions.js"; export { beginDelegatedTask, @@ -50,7 +44,7 @@ const log = createLogger("execution.tasks.dispatch"); const CANCEL_COMMIT_POLL_ATTEMPTS = 10; const CANCEL_COMMIT_POLL_DELAY_MS = 250; -/** True for `task_peek` / `task_await` / `task_cancel` / `task_send` calls. */ +/** True for `task_peek` / `task_cancel` / `task_send` calls. */ export function isTaskControlAction( action: RuntimeActionRequest, ): action is RuntimeToolCallActionRequest { @@ -60,9 +54,7 @@ export function isTaskControlAction( /** * Executes one task-control call inside the dispatch step, which holds * the durable session state (ownership index) and world access the - * tools need. `task_await` is the exception: when any selected task is - * still working it starts the aggregation run and returns no result, - * leaving the pending key to the turn's existing inbox wait. + * tools need. * * Returns the (possibly updated) session: `task_send` follow-ups record * new tasks and settle the continued agent handle. @@ -70,7 +62,7 @@ export function isTaskControlAction( export async function executeTaskControlAction(input: { readonly action: RuntimeToolCallActionRequest; readonly bundle: CompiledBundle; - readonly parentContinuationToken: string | undefined; + readonly parentStepIndex?: number; readonly parentTurnId: string; readonly session: RuntimeSession; }): Promise<{ @@ -103,44 +95,18 @@ export async function executeTaskControlAction(input: { return { result: createTaskViewsResult(action, views), session }; } case TASK_CANCEL_TOOL_NAME: { - const views = await Promise.all( - entries.map((entry) => cancelOneTask({ bundle: input.bundle, entry, session })), - ); - return { result: createTaskViewsResult(action, views), session }; - } - case TASK_AWAIT_TOOL_NAME: { - const views = await readTaskViews(entries); - if (views.every((view) => isReadyTaskStatus(view.status))) { - return { - result: createTaskViewsResult(action, views), - session: suppressAwaitedTaskWakes(session, taskIds), - }; - } - if (input.parentContinuationToken === undefined) { - return { - result: createTaskControlError( - action, - "task_await is unavailable on this session driver.", - ), - session, - }; + let nextSession = session; + const views: TaskView[] = []; + for (const entry of entries) { + const cancelled = await cancelOneTask({ + bundle: input.bundle, + entry, + session: nextSession, + }); + nextSession = cancelled.session; + views.push(cancelled.view); } - const tasks: AwaitedTaskRef[] = entries.map((entry) => ({ - taskId: entry.taskId, - taskRunId: entry.taskRunId, - })); - await startWorkflowPreferLatest(taskAwaitWorkflowReference, [ - { - callId: action.callId, - replyToken: input.parentContinuationToken, - tasks, - toolName: action.toolName, - }, - ]); - return { - result: undefined, - session: suppressAwaitedTaskWakes(session, taskIds), - }; + return { result: createTaskViewsResult(action, views), session: nextSession }; } default: return { @@ -154,9 +120,12 @@ async function cancelOneTask(input: { readonly bundle: CompiledBundle; readonly entry: SessionTaskIndexEntry; readonly session: RuntimeSession; -}): Promise { +}): Promise<{ readonly session: RuntimeSession; readonly view: TaskView }> { const { entry } = input; - await sendTaskCommand({ command: { kind: "cancel" }, commandToken: entry.commandToken }); + const delivery = await sendTaskCommand({ + command: { kind: "cancel" }, + commandToken: entry.commandToken, + }); // The `cancelled` state must commit before the executor abort // propagates, so a late child result can never revive the task. @@ -164,7 +133,7 @@ async function cancelOneTask(input: { for ( let attempt = 0; attempt < CANCEL_COMMIT_POLL_ATTEMPTS && - !(view !== undefined && isReadyTaskStatus(view.status)); + !(view !== undefined && isTerminalTaskStatus(view.status)); attempt += 1 ) { await new Promise((resolve) => setTimeout(resolve, CANCEL_COMMIT_POLL_DELAY_MS)); @@ -172,10 +141,16 @@ async function cancelOneTask(input: { } const settledView = view ?? createPendingTaskView(entry.taskId); - if (settledView.status === "cancelled") { - await propagateTaskCancel({ bundle: input.bundle, session: input.session, view: settledView }); + if (settledView.status === "cancelled" && delivery === "delivered") { + const session = await propagateTaskCancel({ + bundle: input.bundle, + entry, + session: input.session, + view: settledView, + }); + return { session, view: settledView }; } - return settledView; + return { session: input.session, view: settledView }; } /** @@ -185,11 +160,15 @@ async function cancelOneTask(input: { */ async function propagateTaskCancel(input: { readonly bundle: CompiledBundle; + readonly entry: SessionTaskIndexEntry; readonly session: RuntimeSession; readonly view: TaskView; -}): Promise { +}): Promise { const childSessionId = input.view.metadata.childSessionId; - if (childSessionId === undefined) return; + const childTurnId = input.view.metadata.childTurnId; + if (childSessionId === undefined || childSessionId !== input.entry.childSessionId) { + return input.session; + } const handle = findAddressableHandle(input.session, childSessionId); try { @@ -199,18 +178,63 @@ async function propagateTaskCancel(input: { remoteAgentName: handle.identity.name, registry: input.bundle.subagentRegistry.subagentsByNodeId, }); - await cancelRemoteAgentTurn({ + const cancelInput: { + remote: typeof resolved; + sessionId: string; + taskId: string; + turnId?: string; + } = { remote: { ...resolved, url: handle.address.url }, sessionId: childSessionId, - }); - return; + taskId: input.view.taskId, + }; + if (childTurnId !== undefined) cancelInput.turnId = childTurnId; + const result = await cancelRemoteAgentTurn(cancelInput); + if (result.status === "no_active_turn") { + await cancelRemoteAgentTurn({ + remote: { ...resolved, url: handle.address.url }, + sessionId: childSessionId, + taskId: input.view.taskId, + }); + } + } else { + const cancelInput: { + sessionId: string; + taskId: string; + turnId?: string; + } = { + sessionId: childSessionId, + taskId: input.view.taskId, + }; + if (childTurnId !== undefined) cancelInput.turnId = childTurnId; + const result = await requestWorkflowTurnCancellation(cancelInput); + if (result.status === "no_active_turn") { + await requestWorkflowTurnCancellation({ + sessionId: childSessionId, + taskId: input.view.taskId, + }); + } } - await requestWorkflowTurnCancellation({ sessionId: childSessionId }); + const settled = settleAgentTurn(input.session, { + operationId: input.entry.operationId, + outcome: { + kind: "parked", + result: { kind: "cancelled" }, + usageDelta: { + cacheReadTokens: 0, + cacheWriteTokens: 0, + inputTokens: 0, + outputTokens: 0, + }, + }, + }); + return settled.kind === "settled" ? settled.session : input.session; } catch (error) { logError(log, "task cancel propagation failed; the child may run to completion", error, { childSessionId, taskId: input.view.taskId, }); + return input.session; } } diff --git a/packages/eve/src/execution/tasks/hitl-proxy-steps.ts b/packages/eve/src/execution/tasks/hitl-proxy-steps.ts new file mode 100644 index 000000000..d0fa32e69 --- /dev/null +++ b/packages/eve/src/execution/tasks/hitl-proxy-steps.ts @@ -0,0 +1,61 @@ +import type { SubagentInputRequestHookPayload } from "#channel/types.js"; +import { type DurableSessionState, readDurableSession } from "#execution/durable-session-store.js"; +import { readLatestTaskSnapshot } from "#execution/tasks/run-control.js"; +import { + toProxyInputRequestEntries, + upsertProxyInputRequestState, +} from "#harness/proxy-input-requests.js"; +import { findSessionTaskEntry } from "#tasks/session-index.js"; +import { isInputRequest } from "#runtime/input/types.js"; + +/** Validates and durably records one task-owned child HITL route batch. */ +export async function recordTaskInputRequestStep(input: { + readonly hookPayload: SubagentInputRequestHookPayload; + readonly serializedContext: Record; + readonly sessionState: DurableSessionState; + readonly taskId: string; +}): Promise<{ readonly accepted: boolean; readonly sessionState: DurableSessionState }> { + "use step"; + + const durableSession = await readDurableSession(input.sessionState); + const entry = findSessionTaskEntry(durableSession.state, input.taskId); + if (entry === undefined || entry.childSessionId !== input.hookPayload.childSessionId) { + return { accepted: false, sessionState: input.sessionState }; + } + const view = await readLatestTaskSnapshot({ taskRunId: entry.taskRunId }); + const eventRequestIds = input.hookPayload.event.requests.map((request) => request.requestId); + const viewRequestIds = + view?.inputRequests?.map((request) => + request !== null && typeof request === "object" && !Array.isArray(request) + ? Reflect.get(request, "requestId") + : undefined, + ) ?? []; + if ( + view?.status !== "input_required" || + !input.hookPayload.event.requests.every(isInputRequest) || + view.metadata.mode !== "local" || + view.metadata.childSessionId !== input.hookPayload.childSessionId || + new Set(eventRequestIds).size !== eventRequestIds.length || + eventRequestIds.length !== viewRequestIds.length || + eventRequestIds.some((requestId, index) => requestId !== viewRequestIds[index]) + ) { + return { accepted: false, sessionState: input.sessionState }; + } + + const state = upsertProxyInputRequestState({ + entries: toProxyInputRequestEntries(input.hookPayload, input.taskId), + forChildContinuationToken: input.hookPayload.childContinuationToken, + state: durableSession.state, + }); + return { + accepted: true, + sessionState: { + ...input.sessionState, + hasProxyInputRequests: true, + snapshot: { + session: { ...durableSession, state }, + version: input.sessionState.version, + }, + }, + }; +} diff --git a/packages/eve/src/execution/tasks/run-control.ts b/packages/eve/src/execution/tasks/run-control.ts index 5e14ac126..93c7d0d98 100644 --- a/packages/eve/src/execution/tasks/run-control.ts +++ b/packages/eve/src/execution/tasks/run-control.ts @@ -17,6 +17,7 @@ import { isReadyTaskStatus, type TaskCommand, type TaskCommandHookPayload, + type TaskRunInboundPayload, type TaskView, } from "#tasks/types.js"; @@ -51,24 +52,64 @@ export async function sendTaskCommand(input: { readonly commandToken: string; readonly retryUnreachable?: { readonly attempts: number; readonly delayMs: number }; }): Promise<"delivered" | "unreachable"> { + return (await sendTaskCommandToOwner(input)) === undefined ? "unreachable" : "delivered"; +} + +/** Delivers one command and returns the accepting task workflow's run id. */ +export async function sendTaskCommandToOwner(input: { + readonly command: TaskCommand; + readonly commandToken: string; + readonly retryUnreachable?: { readonly attempts: number; readonly delayMs: number }; +}): Promise<{ readonly runId: string } | undefined> { const payload: TaskCommandHookPayload = { command: input.command, kind: "task-command" }; const attempts = Math.max(1, input.retryUnreachable?.attempts ?? 1); for (let attempt = 0; ; attempt += 1) { try { - await resumeHook(input.commandToken, payload); - return "delivered"; + const owner = await resumeHook(input.commandToken, payload); + if ( + typeof owner !== "object" || + owner === null || + !("runId" in owner) || + typeof owner.runId !== "string" + ) { + throw new Error(`Task command hook "${input.commandToken}" returned no owner run id.`); + } + return { runId: owner.runId }; } catch (error) { if (!isFinishedTaskRunTarget(error)) { throw error; } if (attempt + 1 >= attempts) { - return "unreachable"; + return undefined; } await new Promise((resolve) => setTimeout(resolve, input.retryUnreachable?.delayMs ?? 250)); } } } +/** + * Hands one non-command inbound payload to a task run. + * + * Used for payloads the run must act on before it may record them — + * today only answered input batches, which it forwards to the child + * first. `unreachable` means the task already finished and disposed its + * hook, so the payload is stale by definition. + */ +export async function sendTaskInboundPayload(input: { + readonly commandToken: string; + readonly payload: TaskRunInboundPayload; +}): Promise<"delivered" | "unreachable"> { + try { + await resumeHook(input.commandToken, input.payload); + return "delivered"; + } catch (error) { + if (!isFinishedTaskRunTarget(error)) { + throw error; + } + return "unreachable"; + } +} + /** * Reads the latest snapshot a task run has published, or `undefined` * when the run has not committed its first snapshot yet (the caller diff --git a/packages/eve/src/execution/tasks/run-steps.ts b/packages/eve/src/execution/tasks/run-steps.ts index eb6f1d1fe..2547ce0ff 100644 --- a/packages/eve/src/execution/tasks/run-steps.ts +++ b/packages/eve/src/execution/tasks/run-steps.ts @@ -6,11 +6,20 @@ import { WorkflowRunNotFoundError, } from "#compiled/@workflow/errors/index.js"; -import type { DeliverHookPayload } from "#channel/types.js"; +import type { + SessionAuthContext, + SessionCommand, + SubagentInputRequestHookPayload, +} from "#channel/types.js"; import { resumeHook } from "#internal/workflow/runtime.js"; import { createLogger } from "#internal/logging.js"; import { walkCauseChain } from "#shared/errors.js"; -import { TASK_SNAPSHOT_STREAM_NAMESPACE, type TaskStatus, type TaskView } from "#tasks/types.js"; +import { + TASK_SNAPSHOT_STREAM_NAMESPACE, + type TaskInboundAnswerInput, + type TaskInboundInputRequest, + type TaskView, +} from "#tasks/types.js"; const log = createLogger("execution.tasks.run"); @@ -45,20 +54,14 @@ export async function wakeTaskParentStep(input: { }): Promise { "use step"; - const payload: DeliverHookPayload = { - kind: "deliver", - payloads: [ - { - message: formatTaskNotification(input.view), - taskNotification: { - status: readyNotificationStatus(input.view.status), - taskId: input.view.taskId, - }, - }, - ], + const command: SessionCommand = { + kind: "send", + payload: { + message: formatTaskNotification(input.view), + }, }; try { - await resumeHook(input.token, payload); + await resumeHook(input.token, command); } catch (error) { if (isGoneParentTarget(error)) { log.warn("task wake target is gone; the parent session already ended", { @@ -71,11 +74,71 @@ export async function wakeTaskParentStep(input: { } } -function readyNotificationStatus(status: TaskStatus): Exclude { - if (status === "working") { - throw new Error("Cannot wake a parent for a working task."); +/** Sends an exact local-task HITL batch to the parent's pre-model router. */ +export async function wakeTaskInputRequestParentStep(input: { + readonly request: TaskInboundInputRequest; + readonly taskId: string; + readonly token: string; +}): Promise { + "use step"; + + const command: SessionCommand = { + kind: "send", + payload: { + taskInputRequests: [ + { + hookPayload: input.request as SubagentInputRequestHookPayload, + taskId: input.taskId, + }, + ], + }, + }; + try { + await resumeHook(input.token, command); + } catch (error) { + if (isGoneParentTarget(error)) return; + throw error; + } +} + +/** + * Forwards answered input to the blocked child. + * + * The task run performs this itself so the child unblocks and the + * snapshot leaves `input_required` under one durable decision. Returns + * `unreachable` when the child hook is already gone, which leaves the + * outstanding batch untouched rather than reporting a task as working + * when nothing received the answer. + */ +export async function deliverTaskInputResponsesStep(input: { + readonly answer: TaskInboundAnswerInput; + readonly requestIds: readonly string[]; +}): Promise<"delivered" | "unreachable"> { + "use step"; + + const answered = new Set(input.requestIds); + const command: SessionCommand = { + auth: input.answer.auth as SessionAuthContext | null | undefined, + kind: "send", + payload: { + inputResponses: input.answer.inputResponses.filter((response) => + answered.has(response.requestId), + ), + }, + taskDeliveryId: `${input.answer.taskId}:${[...input.requestIds].sort().join(",")}`, + }; + try { + await resumeHook(input.answer.childContinuationToken, command); + return "delivered"; + } catch (error) { + if (isGoneParentTarget(error)) { + log.warn("task input answer target is gone; the child turn already ended", { + taskId: input.answer.taskId, + }); + return "unreachable"; + } + throw error; } - return status; } function formatTaskNotification(view: TaskView): string { diff --git a/packages/eve/src/execution/tasks/run-workflow.test.ts b/packages/eve/src/execution/tasks/run-workflow.test.ts index f540f071e..f372875c8 100644 --- a/packages/eve/src/execution/tasks/run-workflow.test.ts +++ b/packages/eve/src/execution/tasks/run-workflow.test.ts @@ -2,9 +2,19 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { createHook, type Hook } from "#compiled/@workflow/core/index.js"; import { claimHookOwnership, disposeHook } from "#execution/hook-ownership.js"; -import { appendTaskSnapshotStep, wakeTaskParentStep } from "#execution/tasks/run-steps.js"; +import { + appendTaskSnapshotStep, + deliverTaskInputResponsesStep, + wakeTaskInputRequestParentStep, + wakeTaskParentStep, +} from "#execution/tasks/run-steps.js"; import { taskRunWorkflow } from "#execution/tasks/run-workflow.js"; -import type { TaskCommandHookPayload, TaskRunInboundPayload, TaskView } from "#tasks/types.js"; +import type { + TaskCommandHookPayload, + TaskInboundAnswerInput, + TaskRunInboundPayload, + TaskView, +} from "#tasks/types.js"; vi.mock("#compiled/@workflow/core/index.js", () => ({ createHook: vi.fn(), @@ -18,6 +28,8 @@ vi.mock("../hook-ownership.js", async (importOriginal) => ({ vi.mock("./run-steps.js", () => ({ appendTaskSnapshotStep: vi.fn(), + deliverTaskInputResponsesStep: vi.fn(), + wakeTaskInputRequestParentStep: vi.fn(), wakeTaskParentStep: vi.fn(), })); @@ -60,10 +72,13 @@ describe("taskRunWorkflow", () => { it("publishes the initial snapshot, applies commands, and stops at terminal", async () => { mockCommandHook([ { - command: { inputRequests: [{ question: "which?" }], kind: "require-input" }, + command: { + inputRequests: [{ question: "which?", requestId: "req-1" }], + kind: "require-input", + }, kind: "task-command", }, - { command: { kind: "resume-working" }, kind: "task-command" }, + { command: { kind: "answered", requestIds: ["req-1"] }, kind: "task-command" }, { command: { data: "done", kind: "complete" }, kind: "task-command" }, // Never consumed: the run stops at the terminal transition. { command: { kind: "cancel" }, kind: "task-command" }, @@ -77,7 +92,7 @@ describe("taskRunWorkflow", () => { it("skips snapshots for rejected and noop commands", async () => { mockCommandHook([ - { command: { kind: "resume-working" }, kind: "task-command" }, // noop on working + { command: { kind: "answered", requestIds: ["req-1"] }, kind: "task-command" }, // noop on working { command: { kind: "cancel" }, kind: "task-command" }, ]); @@ -145,6 +160,33 @@ describe("taskRunWorkflow", () => { }); }); + it("keeps a fast terminal task hook alive until dispatch acknowledgement", async () => { + mockCommandHook([ + { + kind: "runtime-action-result", + results: [ + { + outcome: { + kind: "parked", + result: { kind: "succeeded", output: "fast" }, + }, + output: "fast", + }, + ], + }, + { command: { childSessionId: "child-session-1", kind: "describe" }, kind: "task-command" }, + ]); + + await taskRunWorkflow({ + commandToken: "task-token", + initialView: createWorkingView(), + wakeToken: "parent-session-token", + }); + + expect(appendedStatuses()).toEqual(["working", "completed"]); + expect(disposeHook).toHaveBeenCalledTimes(1); + }); + it("does not wake without a wake token and never wakes twice for one blocked child", async () => { mockCommandHook([ { command: { inputRequests: [{ q: 1 }], kind: "require-input" }, kind: "task-command" }, @@ -159,13 +201,210 @@ describe("taskRunWorkflow", () => { }); // input_required wakes once; the second require-input replaces the - // batch without leaving the ready state, and completing from ready - // does not re-wake. - expect(wakeTaskParentStep).toHaveBeenCalledTimes(1); + // batch without leaving the ready state, while terminal settlement + // still wakes independently after direct HITL responses. + expect(wakeTaskParentStep).toHaveBeenCalledTimes(2); vi.mocked(wakeTaskParentStep).mockClear(); mockCommandHook([{ command: { data: "done", kind: "complete" }, kind: "task-command" }]); await taskRunWorkflow({ commandToken: "task-token", initialView: createWorkingView() }); expect(wakeTaskParentStep).not.toHaveBeenCalled(); }); + + it("commits and forwards every exact local task HITL batch before terminal wake", async () => { + const request = (requestId: string) => ({ + action: { + callId: `call-${requestId}`, + input: {}, + kind: "tool-call" as const, + toolName: "ask", + }, + kind: "question" as const, + prompt: requestId, + requestId, + }); + const inbound = (requestId: string): TaskRunInboundPayload => ({ + callId: "call-task", + childContinuationToken: "child-token", + childSessionId: "child-session-1", + event: { requests: [request(requestId)], sequence: 1, stepIndex: 2, turnId: "turn_child" }, + kind: "subagent-input-request", + subagentName: "research", + }); + mockCommandHook([ + inbound("q1"), + inbound("q2"), + { command: { data: "done", kind: "complete" }, kind: "task-command" }, + ]); + + await taskRunWorkflow({ + commandToken: "task-token", + initialView: createWorkingView(), + wakeToken: "parent-session-token", + }); + + expect(wakeTaskInputRequestParentStep).toHaveBeenCalledTimes(2); + expect( + vi.mocked(wakeTaskInputRequestParentStep).mock.calls.map(([input]) => { + const request = input.request.event.requests[0]; + return request !== null && typeof request === "object" + ? Reflect.get(request, "requestId") + : undefined; + }), + ).toEqual(["q1", "q2"]); + expect(wakeTaskParentStep).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ view: expect.objectContaining({ status: "completed" }) }), + ); + const firstInputWakeOrder = + vi.mocked(wakeTaskInputRequestParentStep).mock.invocationCallOrder[0] ?? 0; + const firstInputAppendOrder = + vi.mocked(appendTaskSnapshotStep).mock.invocationCallOrder[1] ?? 0; + expect(firstInputAppendOrder).toBeLessThan(firstInputWakeOrder); + }); + + it("defers a fast HITL batch until describe binds the child session", async () => { + mockCommandHook([ + { + callId: "call-task", + childContinuationToken: "child-token", + childSessionId: "child-session-1", + event: { + requests: [ + { + action: { callId: "call-q", input: {}, kind: "tool-call", toolName: "ask" }, + kind: "question", + prompt: "q", + requestId: "q1", + }, + ], + sequence: 1, + stepIndex: 2, + turnId: "turn_child", + }, + kind: "subagent-input-request", + subagentName: "research", + }, + { command: { childSessionId: "child-session-1", kind: "describe" }, kind: "task-command" }, + ]); + + await taskRunWorkflow({ + commandToken: "task-token", + initialView: { + ...createWorkingView(), + metadata: { kind: "subagent", mode: "local", name: "research" }, + }, + wakeToken: "parent-session-token", + }); + + expect(wakeTaskInputRequestParentStep).toHaveBeenCalledTimes(1); + expect(wakeTaskParentStep).not.toHaveBeenCalled(); + expect(appendedStatuses()).toEqual(["working", "input_required", "input_required"]); + }); +}); + +describe("taskRunWorkflow answered input", () => { + function requireInput(...requestIds: readonly string[]): TaskRunInboundPayload { + return { + command: { + inputRequests: requestIds.map((requestId) => ({ prompt: requestId, requestId })), + kind: "require-input", + }, + kind: "task-command", + }; + } + + function answer(...requestIds: readonly string[]): TaskInboundAnswerInput { + return { + childContinuationToken: "child-token", + inputResponses: requestIds.map((requestId) => ({ requestId, text: "answer" })), + kind: "task-answer-input", + taskId: "task_abc123", + }; + } + + it("forwards the answer to the child before recording it as unblocked", async () => { + vi.mocked(deliverTaskInputResponsesStep).mockResolvedValue("delivered"); + mockCommandHook([ + requireInput("q1"), + answer("q1"), + { command: { data: "done", kind: "complete" }, kind: "task-command" }, + ]); + + await taskRunWorkflow({ commandToken: "task-token", initialView: createWorkingView() }); + + expect(deliverTaskInputResponsesStep).toHaveBeenCalledWith({ + answer: answer("q1"), + requestIds: ["q1"], + }); + expect(appendedStatuses()).toEqual(["working", "input_required", "working", "completed"]); + const deliveryOrder = vi.mocked(deliverTaskInputResponsesStep).mock.invocationCallOrder[0] ?? 0; + const unblockOrder = vi.mocked(appendTaskSnapshotStep).mock.invocationCallOrder[2] ?? 0; + expect(deliveryOrder).toBeLessThan(unblockOrder); + }); + + it("never lets an answer to a superseded batch reach the child", async () => { + vi.mocked(deliverTaskInputResponsesStep).mockResolvedValue("delivered"); + mockCommandHook([ + requireInput("q1"), + requireInput("q2"), + answer("q1"), + { command: { data: "done", kind: "complete" }, kind: "task-command" }, + ]); + + await taskRunWorkflow({ commandToken: "task-token", initialView: createWorkingView() }); + + expect(deliverTaskInputResponsesStep).not.toHaveBeenCalled(); + expect(appendedStatuses()).toEqual([ + "working", + "input_required", + "input_required", + "completed", + ]); + }); + + it("stays blocked on the requests an answer did not cover", async () => { + vi.mocked(deliverTaskInputResponsesStep).mockResolvedValue("delivered"); + mockCommandHook([ + requireInput("q1", "q2"), + answer("q1", "unknown"), + { command: { data: "done", kind: "complete" }, kind: "task-command" }, + ]); + + await taskRunWorkflow({ commandToken: "task-token", initialView: createWorkingView() }); + + expect(deliverTaskInputResponsesStep).toHaveBeenCalledWith({ + answer: answer("q1", "unknown"), + requestIds: ["q1"], + }); + const blockedAgain = vi.mocked(appendTaskSnapshotStep).mock.calls[2]?.[0].view; + expect(blockedAgain?.status).toBe("input_required"); + expect(blockedAgain?.inputRequests).toEqual([{ prompt: "q2", requestId: "q2" }]); + }); + + it("keeps the task blocked when the child never received the answer", async () => { + vi.mocked(deliverTaskInputResponsesStep).mockResolvedValue("unreachable"); + mockCommandHook([ + requireInput("q1"), + answer("q1"), + { command: { data: "done", kind: "complete" }, kind: "task-command" }, + ]); + + await taskRunWorkflow({ commandToken: "task-token", initialView: createWorkingView() }); + + expect(appendedStatuses()).toEqual(["working", "input_required", "completed"]); + }); + + it("ignores an answer addressed to a different task", async () => { + vi.mocked(deliverTaskInputResponsesStep).mockResolvedValue("delivered"); + mockCommandHook([ + requireInput("q1"), + { ...answer("q1"), taskId: "task_other" }, + { command: { data: "done", kind: "complete" }, kind: "task-command" }, + ]); + + await taskRunWorkflow({ commandToken: "task-token", initialView: createWorkingView() }); + + expect(deliverTaskInputResponsesStep).not.toHaveBeenCalled(); + expect(appendedStatuses()).toEqual(["working", "input_required", "completed"]); + }); }); diff --git a/packages/eve/src/execution/tasks/run-workflow.ts b/packages/eve/src/execution/tasks/run-workflow.ts index 4d9eaf6a4..62b0d67bd 100644 --- a/packages/eve/src/execution/tasks/run-workflow.ts +++ b/packages/eve/src/execution/tasks/run-workflow.ts @@ -1,12 +1,21 @@ import { createHook } from "#compiled/@workflow/core/index.js"; import { claimHookOwnership, disposeHook, isHookConflictError } from "#execution/hook-ownership.js"; -import { appendTaskSnapshotStep, wakeTaskParentStep } from "#execution/tasks/run-steps.js"; +import { + appendTaskSnapshotStep, + deliverTaskInputResponsesStep, + wakeTaskInputRequestParentStep, + wakeTaskParentStep, +} from "#execution/tasks/run-steps.js"; import { applyTaskTransition } from "#tasks/transitions.js"; import { translateTaskInboundPayload } from "#tasks/wire.js"; import { isReadyTaskStatus, isTerminalTaskStatus, + readTaskInputRequestId, + type TaskCommand, + type TaskInboundAnswerInput, + type TaskInboundInputRequest, type TaskRunInboundPayload, type TaskView, } from "#tasks/types.js"; @@ -67,21 +76,54 @@ export async function taskRunWorkflow(input: TaskRunWorkflowInput): Promise { + if (answer.taskId !== view.taskId || view.status !== "input_required") return undefined; + + const outstanding = new Set( + (view.inputRequests ?? []).flatMap((request) => { + const requestId = readTaskInputRequestId(request); + return requestId === undefined ? [] : [requestId]; + }), + ); + const requestIds = answer.inputResponses + .map((response) => response.requestId) + .filter((requestId) => outstanding.has(requestId)); + if (requestIds.length === 0) return undefined; + + const delivery = await deliverTaskInputResponsesStep({ answer, requestIds }); + return delivery === "delivered" ? { kind: "answered", requestIds } : undefined; +} diff --git a/packages/eve/src/execution/tasks/send.test.ts b/packages/eve/src/execution/tasks/send.test.ts new file mode 100644 index 000000000..bc990c347 --- /dev/null +++ b/packages/eve/src/execution/tasks/send.test.ts @@ -0,0 +1,212 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { dispatchToAgentHandle, type RuntimeSession } from "#execution/agent-handle-dispatch.js"; +import { + beginDelegatedTask, + failDelegatedDispatch, + settleDelegatedDispatch, +} from "#execution/tasks/delegate.js"; +import { readLatestTaskSnapshot } from "#execution/tasks/run-control.js"; +import { executeTaskSend } from "#execution/tasks/send.js"; +import { AGENT_HANDLES_STATE_KEY } from "#harness/handles/store.js"; +import type { RuntimeToolCallActionRequest } from "#runtime/actions/types.js"; +import type { CompiledBundle } from "#runtime/sessions/runtime-context-keys.js"; +import { SESSION_TASKS_STATE_KEY, type SessionTaskIndexEntry } from "#tasks/session-index.js"; +import type { TaskStatus, TaskView } from "#tasks/types.js"; + +vi.mock("#execution/agent-handle-dispatch.js", async (importOriginal) => ({ + ...(await importOriginal()), + dispatchToAgentHandle: vi.fn(), +})); +vi.mock("#execution/tasks/delegate.js", () => ({ + beginDelegatedTask: vi.fn(), + failDelegatedDispatch: vi.fn(), + settleDelegatedDispatch: vi.fn(), +})); +vi.mock("#execution/tasks/run-control.js", () => ({ + readLatestTaskSnapshot: vi.fn(), +})); + +const childSessionId = "child-session"; +const metadata = { + childSessionId, + kind: "subagent" as const, + mode: "local" as const, + name: "research", +}; + +function entry( + taskId: string, + createdByTurnId: string, + createdByStepIndex?: number, +): SessionTaskIndexEntry { + return { + childSessionId, + commandToken: `token-${taskId}`, + createdByStepIndex, + createdByTurnId, + operationId: `operation-${taskId}`, + taskId, + taskRunId: `run-${taskId}`, + }; +} + +function view(taskId: string, status: TaskStatus): TaskView { + return { metadata, status, taskId }; +} + +function session(tasks: readonly SessionTaskIndexEntry[]): RuntimeSession { + return { + agent: { modelReference: { id: "model" }, system: "", tools: [] }, + compaction: { recentWindowSize: 4, threshold: 1_000_000 }, + continuationToken: "parent-token", + history: [], + sessionId: "parent-session", + state: { + [AGENT_HANDLES_STATE_KEY]: { + handles: [ + { + address: { + continuationToken: "child-token", + kind: "agent/local", + sessionId: childSessionId, + }, + identity: { id: "agent-1", name: "research", nodeId: "node-research" }, + lastStatus: "idle", + phase: "parked", + }, + ], + }, + [SESSION_TASKS_STATE_KEY]: { tasks }, + }, + } as RuntimeSession; +} + +const action: RuntimeToolCallActionRequest = { + callId: "call-send", + input: { message: "continue", taskId: "task_terminal" }, + kind: "tool-call", + toolName: "task_send", +}; + +describe("task_send child-session admission", () => { + beforeEach(() => vi.resetAllMocks()); + + it.each([ + ["nonterminal task", "turn-old", "working" as const], + ["task admitted in this batch", "turn-current", "completed" as const], + ])("rejects a follow-up for a %s", async (_name, createdByTurnId, conflictingStatus) => { + const current = session([ + entry("task_terminal", "turn-old"), + entry("task_conflict", createdByTurnId), + ]); + vi.mocked(readLatestTaskSnapshot).mockImplementation(async ({ taskRunId }) => + taskRunId === "run-task_terminal" + ? view("task_terminal", "completed") + : view("task_conflict", conflictingStatus), + ); + + const result = await executeTaskSend({ + action, + bundle: {} as CompiledBundle, + parentTurnId: "turn-current", + session: current, + }); + + expect(result.result).toMatchObject({ + isError: true, + output: { message: expect.stringContaining("AGENT_BUSY") }, + }); + expect(beginDelegatedTask).not.toHaveBeenCalled(); + expect(dispatchToAgentHandle).not.toHaveBeenCalled(); + }); + + it("allows reuse in a later batch of the same turn after terminal settlement", async () => { + const current = session([ + entry("task_terminal", "turn-old"), + entry("task_previous", "turn-current", 0), + ]); + vi.mocked(readLatestTaskSnapshot).mockResolvedValue(view("task_terminal", "completed")); + vi.mocked(beginDelegatedTask).mockResolvedValue({ + commandToken: "task-token-new", + createdByTurnId: "turn-current", + operationId: "operation-new", + taskId: "task_new", + taskRunId: "run-new", + }); + vi.mocked(dispatchToAgentHandle).mockResolvedValue({ + address: { continuationToken: "child-token", kind: "agent/local", sessionId: childSessionId }, + callId: action.callId, + kind: "called", + name: "research", + session: current, + toolName: "research", + }); + vi.mocked(settleDelegatedDispatch).mockResolvedValue({ + receipt: {} as never, + session: current, + }); + + const result = await executeTaskSend({ + action, + bundle: {} as CompiledBundle, + parentStepIndex: 1, + parentTurnId: "turn-current", + session: current, + }); + + expect(result.result).toMatchObject({ output: { status: "working", taskId: "task_new" } }); + expect(failDelegatedDispatch).not.toHaveBeenCalled(); + }); + + it("reserves the child before an ambiguous continuation delivery", async () => { + const current = session([entry("task_terminal", "turn-old")]); + const reserved = session([ + entry("task_terminal", "turn-old"), + entry("task_new", "turn-current"), + ]); + vi.mocked(readLatestTaskSnapshot).mockResolvedValue(view("task_terminal", "completed")); + vi.mocked(beginDelegatedTask).mockResolvedValue({ + commandToken: "task-token-new", + createdByTurnId: "turn-current", + operationId: "operation-new", + taskId: "task_new", + taskRunId: "run-new", + }); + vi.mocked(settleDelegatedDispatch).mockResolvedValue({ + receipt: {} as never, + session: reserved, + }); + vi.mocked(dispatchToAgentHandle).mockResolvedValue({ + kind: "error", + result: { + callId: action.callId, + isError: true, + kind: "subagent-result", + origin: "dispatch", + output: { code: "AGENT_UNREACHABLE", message: "response lost" }, + subagentName: "research", + }, + session: reserved, + }); + + const result = await executeTaskSend({ + action, + bundle: {} as CompiledBundle, + parentTurnId: "turn-current", + session: current, + }); + + expect(vi.mocked(settleDelegatedDispatch).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(dispatchToAgentHandle).mock.invocationCallOrder[0] ?? 0, + ); + expect(dispatchToAgentHandle).toHaveBeenCalledWith( + expect.objectContaining({ currentSession: reserved }), + ); + expect(result.result).toMatchObject({ + isError: true, + output: { taskId: "task_new" }, + }); + expect(result.session).toBe(reserved); + }); +}); diff --git a/packages/eve/src/execution/tasks/send.ts b/packages/eve/src/execution/tasks/send.ts index 1aae403f2..3eed5bb14 100644 --- a/packages/eve/src/execution/tasks/send.ts +++ b/packages/eve/src/execution/tasks/send.ts @@ -6,42 +6,36 @@ import { import { createPendingTaskView, createTaskControlError, - createTaskViewsResult, createUnknownTasksError, findAddressableHandle, + findConflictingTaskForChildSession, } from "#execution/tasks/control-shared.js"; import { beginDelegatedTask, failDelegatedDispatch, settleDelegatedDispatch, } from "#execution/tasks/delegate.js"; -import { readLatestTaskSnapshot, sendTaskCommand } from "#execution/tasks/run-control.js"; -import { createWorkflowRuntime } from "#execution/workflow-runtime.js"; +import { readLatestTaskSnapshot } from "#execution/tasks/run-control.js"; import { AGENT_BUSY, AGENT_UNREACHABLE } from "#harness/agent-handle-errors.js"; -import { deriveAgentOperationId } from "#harness/handles/operation-id.js"; -import { settleAgentTurn } from "#harness/handles/transitions.js"; -import { createLogger, logError } from "#internal/logging.js"; import type { RuntimeActionResult, RuntimeToolCallActionRequest } from "#runtime/actions/types.js"; import type { CompiledBundle } from "#runtime/sessions/runtime-context-keys.js"; -import { findSessionTaskEntry, type SessionTaskIndexEntry } from "#tasks/session-index.js"; -import { applyTaskTransition } from "#tasks/transitions.js"; +import { findSessionTaskEntry } from "#tasks/session-index.js"; import type { TaskView } from "#tasks/types.js"; -const log = createLogger("execution.tasks.send"); - /** * Routes one `task_send`: * * - `working` tasks are busy — the send surfaces `AGENT_BUSY` instead * of queueing (settled decision; queueing is the reversible follow-up); - * - `input_required` tasks accept an `inputResponses` batch, delivered - * to the parked child session, and return to `working`; + * - `input_required` tasks reject model-authored sends; clients answer + * them through the parent channel's direct HITL route; * - terminal tasks accept a `message` follow-up, which starts a new * task bound to the same child session and returns its receipt. */ export async function executeTaskSend(input: { readonly action: RuntimeToolCallActionRequest; readonly bundle: CompiledBundle; + readonly parentStepIndex?: number; readonly parentTurnId: string; readonly session: RuntimeSession; }): Promise<{ @@ -67,119 +61,38 @@ export async function executeTaskSend(input: { return { result: createTaskControlError( action, - `${AGENT_BUSY}: task "${view.taskId}" is still working. Wait for it with task_await, or cancel it first.`, + `${AGENT_BUSY}: task "${view.taskId}" is still working. Let it finish, or cancel it first.`, ), session, }; } if (view.status === "input_required") { - if (send.body.kind !== "input-responses") { - return { - result: createTaskControlError( - action, - `Task "${view.taskId}" is waiting on input; answer it with inputResponses.`, - ), - session, - }; - } - return answerBlockedTask({ - action, - bundle: input.bundle, - entry, - responses: send.body.inputResponses, - session, - view, - }); - } - - if (send.body.kind !== "message") { return { result: createTaskControlError( action, - `Task "${view.taskId}" is ${view.status}; send a follow-up message to continue its agent.`, + `Task "${view.taskId}" is waiting on input; the human must answer through the parent channel.`, ), session, }; } + return followUpTerminalTask({ action, bundle: input.bundle, - message: send.body.message, + message: send.message, + parentStepIndex: input.parentStepIndex, parentTurnId: input.parentTurnId, session, view, }); } -async function answerBlockedTask(input: { - readonly action: RuntimeToolCallActionRequest; - readonly bundle: CompiledBundle; - readonly entry: SessionTaskIndexEntry; - readonly responses: readonly { readonly requestId: string }[]; - readonly session: RuntimeSession; - readonly view: TaskView; -}): Promise<{ readonly result: RuntimeActionResult; readonly session: RuntimeSession }> { - const { action, session, view } = input; - const handle = findAddressableHandle(session, view.metadata.childSessionId); - if (handle === undefined || handle.address.kind === "agent/remote") { - return { - result: createTaskControlError( - action, - `${AGENT_UNREACHABLE}: task "${view.taskId}" has no reachable child session for input responses.`, - ), - session, - }; - } - - const childRuntime = createWorkflowRuntime({ - compiledArtifactsSource: input.bundle.compiledArtifactsSource, - nodeId: handle.identity.nodeId, - }); - try { - // The child parked waiting on this batch; its next settled turn - // reports to the same task run through the caller reply token. - const result = await childRuntime.dispatchSession({ - command: { - caller: { - callId: action.callId, - replyTo: { kind: "hook", token: input.entry.commandToken }, - subagentName: handle.identity.name, - }, - kind: "send", - payload: { inputResponses: [...input.responses] }, - }, - sessionId: handle.address.sessionId, - }); - if (result.status === "session_not_active") { - throw new Error(`Agent session "${handle.address.sessionId}" is no longer active.`); - } - } catch (error) { - logError(log, "task_send input-response delivery failed", error, { - childSessionId: handle.address.sessionId, - taskId: view.taskId, - }); - return { - result: createTaskControlError( - action, - `${AGENT_UNREACHABLE}: task "${view.taskId}"'s child session did not accept the responses.`, - ), - session, - }; - } - - await sendTaskCommand({ - command: { kind: "resume-working" }, - commandToken: input.entry.commandToken, - }); - const resumed = applyTaskTransition(view, { kind: "resume-working" }); - return { result: createTaskViewsResult(action, [resumed.view]), session }; -} - async function followUpTerminalTask(input: { readonly action: RuntimeToolCallActionRequest; readonly bundle: CompiledBundle; readonly message: string; + readonly parentStepIndex?: number; readonly parentTurnId: string; readonly session: RuntimeSession; readonly view: TaskView; @@ -195,6 +108,21 @@ async function followUpTerminalTask(input: { session: input.session, }; } + const conflicting = await findConflictingTaskForChildSession({ + childSessionId: handle.address.sessionId, + parentStepIndex: input.parentStepIndex, + parentTurnId: input.parentTurnId, + session: input.session, + }); + if (conflicting !== undefined) { + return { + result: createTaskControlError( + action, + `${AGENT_BUSY}: agent "${handle.identity.id}" is busy with task "${conflicting.view.taskId}" (${conflicting.view.status}).`, + ), + session: input.session, + }; + } const continuation: RuntimeAgentHandleAction = handle.address.kind === "agent/remote" @@ -222,57 +150,43 @@ async function followUpTerminalTask(input: { mode: handle.address.kind === "agent/remote" ? "remote" : "local", name: handle.identity.name, parentSessionId: input.session.sessionId, + parentStepIndex: input.parentStepIndex, parentTurnId: input.parentTurnId, session: input.session, }); + // Reserve the child before the ambiguous delivery side effect. If the + // transport response is lost, this task remains the sole admitted owner. + const reserved = await settleDelegatedDispatch({ + callId: action.callId, + childSessionId: handle.address.sessionId, + session: input.session, + subagentName: handle.identity.name, + task, + }); const outcome = await dispatchToAgentHandle({ action: continuation, agentId: handle.identity.id, bundle: input.bundle, - currentSession: input.session, + currentSession: reserved.session, parentToken: task.commandToken, parentTurnId: input.parentTurnId, }); if (outcome.kind === "error") { - await failDelegatedDispatch({ error: outcome.result.output, task }); + if (findAddressableHandle(outcome.session, handle.address.sessionId) === undefined) { + await failDelegatedDispatch({ error: outcome.result.output, task }); + } return { result: { callId: action.callId, isError: true, kind: "tool-result", - output: outcome.result.output, + output: { error: outcome.result.output, taskId: task.taskId }, toolName: action.toolName, }, session: outcome.session, }; } - const settled = await settleDelegatedDispatch({ - callId: outcome.callId, - childSessionId: outcome.address.sessionId, - session: outcome.session, - subagentName: outcome.toolName, - task, - }); - // task_send's own result is a tool-result, so the receipt never flows - // through the handle-settling resolve path; park the continued handle - // here to keep it addressable for later sends. - const operationId = deriveAgentOperationId({ - callId: action.callId, - parentSessionId: input.session.sessionId, - parentTurnId: input.parentTurnId, - }); - const settledHandle = settleAgentTurn(settled.session, { - operationId, - outcome: { - kind: "parked", - result: { - kind: "succeeded", - output: `Delegated as background task ${task.taskId} (working).`, - }, - usageDelta: { cacheReadTokens: 0, cacheWriteTokens: 0, inputTokens: 0, outputTokens: 0 }, - }, - }); return { result: { callId: action.callId, @@ -280,22 +194,13 @@ async function followUpTerminalTask(input: { output: { status: "working", taskId: task.taskId }, toolName: action.toolName, }, - session: settledHandle.kind === "settled" ? settledHandle.session : settled.session, + session: outcome.session, }; } type TaskSendInput = | { readonly kind: "invalid"; readonly message: string } - | { - readonly body: - | { readonly kind: "message"; readonly message: string } - | { - readonly inputResponses: readonly { readonly requestId: string }[]; - readonly kind: "input-responses"; - }; - readonly kind: "send"; - readonly taskId: string; - }; + | { readonly kind: "send"; readonly message: string; readonly taskId: string }; function readTaskSendInput(input: Record): TaskSendInput { const taskId = @@ -305,22 +210,8 @@ function readTaskSendInput(input: Record): TaskSendInput { } const message = typeof input.message === "string" && input.message.trim() !== "" ? input.message : undefined; - const responses = Array.isArray(input.inputResponses) - ? input.inputResponses.filter( - (candidate): candidate is { readonly requestId: string } => - typeof candidate === "object" && - candidate !== null && - typeof (candidate as { requestId?: unknown }).requestId === "string", - ) - : undefined; - if (message !== undefined && responses !== undefined) { - return { kind: "invalid", message: "Provide either `message` or `inputResponses`, not both." }; - } if (message !== undefined) { - return { body: { kind: "message", message }, kind: "send", taskId }; - } - if (responses !== undefined && responses.length > 0) { - return { body: { inputResponses: responses, kind: "input-responses" }, kind: "send", taskId }; + return { kind: "send", message, taskId }; } - return { kind: "invalid", message: "Provide either `message` or a non-empty `inputResponses`." }; + return { kind: "invalid", message: "Provide a non-empty `message`." }; } diff --git a/packages/eve/src/execution/tasks/wake-suppression-step.ts b/packages/eve/src/execution/tasks/wake-suppression-step.ts deleted file mode 100644 index 68dc26350..000000000 --- a/packages/eve/src/execution/tasks/wake-suppression-step.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { DeliverPayload } from "#channel/types.js"; -import { deserializeContext } from "#context/serialize.js"; -import { - createDurableSessionState, - type DurableSessionState, - readDurableSession, -} from "#execution/durable-session-store.js"; -import { hydrateDurableSession } from "#execution/session.js"; -import { BundleKey } from "#runtime/sessions/runtime-context-keys.js"; -import { consumeAwaitedTaskWakes } from "#tasks/wake-suppression.js"; - -/** Filters task wake payloads already consumed by a completed `task_await`. */ -export async function filterAwaitedTaskWakePayloadsStep(input: { - readonly payloads: readonly DeliverPayload[]; - readonly serializedContext: Record; - readonly sessionState: DurableSessionState; -}): Promise<{ - readonly payloads: readonly DeliverPayload[]; - readonly sessionState: DurableSessionState; -}> { - "use step"; - - const durable = await readDurableSession(input.sessionState); - const ctx = await deserializeContext(input.serializedContext); - const bundle = ctx.require(BundleKey); - const session = hydrateDurableSession({ - compactionOverrides: { - thresholdPercent: bundle.resolvedAgent.config.compaction?.thresholdPercent, - }, - durable, - turnAgent: bundle.turnAgent, - }); - const filtered = consumeAwaitedTaskWakes(session, input.payloads); - return { - payloads: filtered.payloads, - sessionState: - filtered.session === session - ? input.sessionState - : createDurableSessionState({ session: filtered.session }), - }; -} diff --git a/packages/eve/src/execution/turn-control-receiver.test.ts b/packages/eve/src/execution/turn-control-receiver.test.ts index af5b1d63e..3390ec7a9 100644 --- a/packages/eve/src/execution/turn-control-receiver.test.ts +++ b/packages/eve/src/execution/turn-control-receiver.test.ts @@ -128,6 +128,80 @@ describe("TurnControlReceiver", () => { expect(bufferedSessionControls).toEqual(["clear", "compact", "expired"]); }); + it("consumes a replayed task delivery only once", async () => { + installControlHook([parkResult()], true); + const bufferedDeliveries: DeliverHookPayload[] = []; + const caller = { + callId: "call-task", + replyTo: { kind: "hook" as const, token: "task-token" }, + subagentName: "research", + taskId: "task-1", + }; + + await runReceiver(bufferedDeliveries, { + commandInbox: createCommandInbox([ + { caller, kind: "send", payload: { message: "once" } }, + { caller, kind: "send", payload: { message: "duplicate" } }, + ]), + seenTaskDeliveries: new Set(), + }); + + expect(bufferedDeliveries.map((delivery) => delivery.payloads[0]?.message)).toEqual(["once"]); + }); + + it("deduplicates a replayed task response without dropping a different partial response", async () => { + installControlHook([parkResult()], true); + const bufferedDeliveries: DeliverHookPayload[] = []; + + await runReceiver(bufferedDeliveries, { + commandInbox: createCommandInbox([ + { + kind: "send", + payload: { inputResponses: [{ requestId: "q1" }] }, + taskDeliveryId: "task-1:q1", + }, + { + kind: "send", + payload: { inputResponses: [{ requestId: "q1" }] }, + taskDeliveryId: "task-1:q1", + }, + { + kind: "send", + payload: { inputResponses: [{ requestId: "q2" }] }, + taskDeliveryId: "task-1:q2", + }, + ]), + seenTaskDeliveries: new Set(), + }); + + expect( + bufferedDeliveries.map((delivery) => delivery.payloads[0]?.inputResponses?.[0]?.requestId), + ).toEqual(["q1", "q2"]); + }); + + it("discards queued deliveries when their task is cancelled", async () => { + installControlHook([parkResult()], true); + const bufferedDeliveries: DeliverHookPayload[] = []; + + await runReceiver(bufferedDeliveries, { + commandInbox: createCommandInbox([ + { + kind: "send", + payload: { inputResponses: [{ requestId: "q1" }] }, + taskDeliveryId: "task-1:q1", + }, + { kind: "cancel", taskId: "task-1", turnId: "turn_3" }, + ]), + seenTaskDeliveries: new Set(), + }); + + expect(bufferedDeliveries).toEqual([]); + expect(forwardTurnCancellationStep).toHaveBeenCalledWith({ + payload: {}, + token: "turn-control:cancel", + }); + }); + it("forwards cancel and reset through the active turn's private hook", async () => { installControlHook([parkResult()], true); const bufferedSessionControls: Array<"clear" | "compact" | "expired" | "reset"> = []; @@ -158,12 +232,15 @@ function runReceiver( options: { readonly bufferedSessionControls?: Array<"clear" | "compact" | "expired" | "reset">; readonly commandInbox?: SessionCommandInbox; + readonly seenTaskDeliveries?: Set; } = {}, ): ReturnType { const receiver = new TurnControlReceiver({ bufferedDeliveries, bufferedSessionControls: options.bufferedSessionControls ?? [], commandInbox: options.commandInbox ?? createCommandInbox(), + expectedTurnId: "turn_0", + seenTaskDeliveries: options.seenTaskDeliveries, token: "turn-control", }); return receiver.waitForAction().finally(() => receiver.dispose()); diff --git a/packages/eve/src/execution/turn-control-receiver.ts b/packages/eve/src/execution/turn-control-receiver.ts index 053a3b9ce..ae11c254e 100644 --- a/packages/eve/src/execution/turn-control-receiver.ts +++ b/packages/eve/src/execution/turn-control-receiver.ts @@ -21,19 +21,28 @@ export class TurnControlReceiver { private readonly commandInbox: SessionCommandInbox; private readonly control: Hook; private readonly controlIterator: AsyncIterator; + private readonly expectedTurnId: string; + private readonly cancelledTaskIds: Set; + private readonly seenTaskDeliveries: Set; private pendingControl: Promise> | null = null; constructor(input: { readonly bufferedDeliveries: DeliverHookPayload[]; readonly bufferedSessionControls: Array<"clear" | "compact" | "expired" | "reset">; + readonly cancelledTaskIds?: Set; readonly commandInbox: SessionCommandInbox; + readonly expectedTurnId: string; + readonly seenTaskDeliveries?: Set; readonly token: string; }) { this.bufferedDeliveries = input.bufferedDeliveries; this.bufferedSessionControls = input.bufferedSessionControls; + this.cancelledTaskIds = input.cancelledTaskIds ?? new Set(); this.commandInbox = input.commandInbox; + this.seenTaskDeliveries = input.seenTaskDeliveries ?? new Set(); this.control = createHook({ token: input.token }); this.controlIterator = this.control[Symbol.asyncIterator](); + this.expectedTurnId = input.expectedTurnId; } /** Token passed to the turn workflow so it can publish control messages. */ @@ -72,6 +81,7 @@ export class TurnControlReceiver { command: SessionInboxPayload, ): Promise { if (command.kind === "send") { + if (!this.acceptTaskDelivery(command)) return undefined; this.bufferedDeliveries.push(commandToDelivery(command)); return undefined; } @@ -84,8 +94,15 @@ export class TurnControlReceiver { return undefined; } if (command.kind === "cancel") { + if (command.taskId !== undefined) this.discardTaskDeliveries(command.taskId); + const turnId = + command.taskId !== undefined && + command.turnId !== undefined && + command.turnId !== this.expectedTurnId + ? undefined + : command.turnId; await forwardTurnCancellationStep({ - payload: command.turnId === undefined ? {} : { turnId: command.turnId }, + payload: turnId === undefined ? {} : { turnId }, token: turnCancellationHookToken(this.control.token), }); return undefined; @@ -105,7 +122,9 @@ export class TurnControlReceiver { payload: Extract, ): void { if (payload.bufferedDeliveries !== undefined) { - this.bufferedDeliveries.unshift(...payload.bufferedDeliveries); + this.bufferedDeliveries.unshift( + ...payload.bufferedDeliveries.filter((delivery) => !this.shouldDiscard(delivery)), + ); } } @@ -193,6 +212,7 @@ export class TurnControlReceiver { this.commandInbox.consumeNext(); if (winner.value.value.kind === "send") { + if (!this.acceptTaskDelivery(winner.value.value)) continue; delivery = commandToDelivery(winner.value.value); continue; } @@ -234,7 +254,7 @@ export class TurnControlReceiver { if (winner.kind === "command") { const terminal = await this.handleSessionCommand(winner.command); if (terminal !== undefined) { - this.bufferedDeliveries.unshift(outstanding); + if (!this.shouldDiscard(outstanding)) this.bufferedDeliveries.unshift(outstanding); return terminal; } continue; @@ -247,18 +267,49 @@ export class TurnControlReceiver { } if (payload.kind === "turn-delivery-cancelled" && payload.requestId === requestId) { - this.bufferedDeliveries.unshift(outstanding); + if (!this.shouldDiscard(outstanding)) this.bufferedDeliveries.unshift(outstanding); return undefined; } if (payload.kind === "turn-result") { - this.bufferedDeliveries.unshift(outstanding); + if (!this.shouldDiscard(outstanding)) this.bufferedDeliveries.unshift(outstanding); } const terminal = this.readTerminalControl(payload); if (terminal !== undefined) return terminal; } } + + private acceptTaskDelivery(command: Extract): boolean { + const deliveryId = command.taskDeliveryId ?? command.caller?.taskId; + if (deliveryId === undefined) return true; + if ( + [...this.cancelledTaskIds].some( + (taskId) => deliveryId === taskId || deliveryId.startsWith(`${taskId}:`), + ) + ) { + return false; + } + if (this.seenTaskDeliveries.has(deliveryId)) return false; + this.seenTaskDeliveries.add(deliveryId); + return true; + } + + private discardTaskDeliveries(taskId: string): void { + this.cancelledTaskIds.add(taskId); + const kept = this.bufferedDeliveries.filter((delivery) => !this.shouldDiscard(delivery)); + this.bufferedDeliveries.splice(0, this.bufferedDeliveries.length, ...kept); + } + + private shouldDiscard(delivery: DeliverHookPayload): boolean { + const deliveryId = delivery.taskDeliveryId ?? delivery.caller?.taskId; + return ( + deliveryId !== undefined && + [...this.cancelledTaskIds].some( + (taskId) => deliveryId === taskId || deliveryId.startsWith(`${taskId}:`), + ) + ); + } } function unsupportedSessionCommand(command: never): never { @@ -274,5 +325,6 @@ function commandToDelivery( kind: "deliver", payloads: [command.payload], requestId: command.requestId, + taskDeliveryId: command.taskDeliveryId, }; } diff --git a/packages/eve/src/execution/turn-dispatch.ts b/packages/eve/src/execution/turn-dispatch.ts index 3f1c28615..36ddb9d0d 100644 --- a/packages/eve/src/execution/turn-dispatch.ts +++ b/packages/eve/src/execution/turn-dispatch.ts @@ -5,6 +5,7 @@ import type { SessionCommandInbox } from "#execution/session-command-inbox.js"; import { dispatchTurnStep } from "#execution/workflow-steps.js"; import type { TurnDriverAction } from "#execution/turn-control-receiver.js"; import type { RunMode } from "#shared/run-mode.js"; +import { activeTurnId } from "#harness/active-turn-id.js"; /** One settled turn: its terminal driver action plus deferred hook cleanup. */ export interface DispatchedTurn { @@ -25,18 +26,23 @@ export async function dispatchAndAwaitTurn(input: { readonly bufferedDeliveries: DeliverHookPayload[]; readonly bufferedSessionControls: Array<"clear" | "compact" | "expired" | "reset">; readonly capabilities?: SessionCapabilities; + readonly cancelledTaskIds?: Set; readonly controlToken: string; readonly delivery: HookPayload; readonly commandInbox: SessionCommandInbox; readonly mode: RunMode; readonly parentWritable: WritableStream; readonly serializedContext: Record; + readonly seenTaskDeliveries?: Set; readonly sessionState: DurableSessionState; }): Promise { const control = new TurnControlReceiver({ bufferedDeliveries: input.bufferedDeliveries, bufferedSessionControls: input.bufferedSessionControls, + cancelledTaskIds: input.cancelledTaskIds, commandInbox: input.commandInbox, + expectedTurnId: activeTurnId(input.sessionState.emissionState), + seenTaskDeliveries: input.seenTaskDeliveries ?? new Set(), token: input.controlToken, }); diff --git a/packages/eve/src/execution/turn-workflow.test.ts b/packages/eve/src/execution/turn-workflow.test.ts index e1c491ed4..b2aab8b38 100644 --- a/packages/eve/src/execution/turn-workflow.test.ts +++ b/packages/eve/src/execution/turn-workflow.test.ts @@ -789,6 +789,8 @@ describe("turnWorkflow", () => { vi.mocked(routeDeliverToChildren).mockResolvedValue({ kind: "continue", remainder: undefined, + serializedContext: { state: "proxied" }, + sessionState: proxyState, }); vi.mocked(turnStep) .mockResolvedValueOnce({ @@ -864,6 +866,8 @@ describe("turnWorkflow", () => { }); vi.mocked(routeDeliverToChildren).mockResolvedValue({ kind: "cancel-turn", + serializedContext: { state: "proxied" }, + sessionState: proxyState, }); vi.mocked(turnStep).mockResolvedValueOnce({ action: "park", @@ -1077,6 +1081,8 @@ describe("turnWorkflow", () => { vi.mocked(routeDeliverToChildren).mockResolvedValue({ kind: "continue", remainder: undefined, + serializedContext: {}, + sessionState: pendingState, }); vi.mocked(turnStep) .mockResolvedValueOnce({ diff --git a/packages/eve/src/execution/turn-workflow.ts b/packages/eve/src/execution/turn-workflow.ts index 1e7b52039..5440076d5 100644 --- a/packages/eve/src/execution/turn-workflow.ts +++ b/packages/eve/src/execution/turn-workflow.ts @@ -370,8 +370,13 @@ async function waitForRuntimeActionResults(input: { auth: value.delivery.auth, parentWritable: input.cursor.parentWritable, payloads: value.delivery.payloads, + serializedContext: input.cursor.serializedContext, sessionState: input.cursor.sessionState, }); + await input.cursor.adopt({ + serializedContext: routed.serializedContext ?? input.cursor.serializedContext, + sessionState: routed.sessionState ?? input.cursor.sessionState, + }); if (routed.kind === "cancel-turn") { return routed.kind; } diff --git a/packages/eve/src/execution/workflow-entry.test.ts b/packages/eve/src/execution/workflow-entry.test.ts index 162f611f2..dca9d25a7 100644 --- a/packages/eve/src/execution/workflow-entry.test.ts +++ b/packages/eve/src/execution/workflow-entry.test.ts @@ -64,14 +64,9 @@ vi.mock("./route-child-delivery.js", () => ({ })), })); -vi.mock("./tasks/wake-suppression-step.js", () => ({ - filterAwaitedTaskWakePayloadsStep: vi - .fn() - .mockImplementation(async ({ payloads, sessionState }) => ({ payloads, sessionState })), -})); - vi.mock("./delegated-parent-notification.js", () => ({ notifyDelegatedParentStep: vi.fn().mockResolvedValue(undefined), + notifyTaskTurnStartedStep: vi.fn().mockResolvedValue(undefined), notifyTurnCallerStep: vi.fn().mockResolvedValue(undefined), resolveInitialTurnCallerStep: vi.fn().mockResolvedValue(undefined), })); @@ -1106,6 +1101,8 @@ describe("workflowEntry", () => { vi.mocked(routeDeliverToChildren).mockResolvedValueOnce({ kind: "continue", remainder: undefined, + serializedContext: {}, + sessionState, }); installHookMocks({ deliveryHooks: [ diff --git a/packages/eve/src/execution/workflow-entry.ts b/packages/eve/src/execution/workflow-entry.ts index 1384287df..361ebaed4 100644 --- a/packages/eve/src/execution/workflow-entry.ts +++ b/packages/eve/src/execution/workflow-entry.ts @@ -13,6 +13,7 @@ import type { RunMode } from "#shared/run-mode.js"; import type { DurableCompiledArtifactsSource } from "#runtime/durable-compiled-artifacts-source.js"; import { notifyDelegatedParentStep, + notifyTaskTurnStartedStep, notifyTurnCallerStep, resolveInitialTurnCallerStep, } from "#execution/delegated-parent-notification.js"; @@ -33,6 +34,7 @@ import { emitTerminalSessionFailureStep } from "#execution/terminal-session-fail import { fireSessionCallbackStep } from "#execution/session-callback-step.js"; import { disposeHook } from "#execution/hook-ownership.js"; import { createSessionCommandInbox } from "#execution/session-command-inbox.js"; +import { activeTurnId } from "#harness/active-turn-id.js"; import { sessionCommandHookToken } from "#execution/session-command-token.js"; import { DEFAULT_SESSION_TIMEOUT_MS } from "#execution/session-timeout.js"; import { emitTerminalSessionCompletionStep } from "#execution/terminal-session-completion-step.js"; @@ -307,6 +309,8 @@ async function runDriverLoop(input: { const bufferedDeliveries: DeliverHookPayload[] = []; const bufferedSessionControls: Array<"clear" | "compact" | "expired" | "reset"> = []; + const cancelledTaskIds = new Set(); + const seenTaskDeliveries = new Set(); const commandInbox = createSessionCommandInbox(); const stableCommandToken = sessionCommandHookToken(input.sessionState.sessionId); await commandInbox.claimStable(stableCommandToken); @@ -325,9 +329,19 @@ async function runDriverLoop(input: { readonly serializedContext: Record; readonly sessionState: DurableSessionState; }): Promise => { + const caller = input.crashCleanupState.caller; + if (caller?.taskId !== undefined) { + seenTaskDeliveries.add(caller.taskId); + await notifyTaskTurnStartedStep({ + caller, + childSessionId: args.sessionState.sessionId, + childTurnId: activeTurnId(args.sessionState.emissionState), + }); + } const turn = await dispatchAndAwaitTurn({ bufferedDeliveries, bufferedSessionControls, + cancelledTaskIds, capabilities: input.capabilities, commandInbox, controlToken: nextTurnControlToken(), @@ -335,6 +349,7 @@ async function runDriverLoop(input: { mode: input.mode, parentWritable: input.driverWritable, serializedContext: args.serializedContext, + seenTaskDeliveries, sessionState: args.sessionState, }); await disposeSettledTurnControl?.(); @@ -436,11 +451,19 @@ async function runDriverLoop(input: { const next = await nextTurnDelivery({ bufferedDeliveries, bufferedSessionControls, + cancelledTaskIds, commandInbox, driverWritable: input.driverWritable, serializedContext: action.serializedContext, + seenTaskDeliveries, sessionState: action.sessionState, }); + action = { + ...action, + serializedContext: next.serializedContext, + sessionState: next.sessionState, + }; + input.crashCleanupState.lastSessionState = action.sessionState; if (next.kind === "expired") { return { diff --git a/packages/eve/src/execution/workflow-runtime.ts b/packages/eve/src/execution/workflow-runtime.ts index 693f1562c..140393cb5 100644 --- a/packages/eve/src/execution/workflow-runtime.ts +++ b/packages/eve/src/execution/workflow-runtime.ts @@ -54,7 +54,6 @@ const WORKFLOW_ENTRY_NAME = "workflowEntry"; const TURN_WORKFLOW_NAME = "turnWorkflow"; const SESSION_TIMEOUT_WORKFLOW_NAME = "sessionTimeoutWorkflow"; const TASK_RUN_WORKFLOW_NAME = "taskRunWorkflow"; -const TASK_AWAIT_WORKFLOW_NAME = "taskAwaitWorkflow"; const EVE_PACKAGE_INFO = resolveInstalledPackageInfo(); const COMMAND_HOOK_READY_TIMEOUT_MS = 30_000; @@ -76,7 +75,6 @@ export const STABLE_WORKFLOW_NAMES: ReadonlySet = new Set([ TURN_WORKFLOW_NAME, SESSION_TIMEOUT_WORKFLOW_NAME, TASK_RUN_WORKFLOW_NAME, - TASK_AWAIT_WORKFLOW_NAME, ]); const STABLE_ID_BASE = EVE_PACKAGE_INFO.name; @@ -118,11 +116,6 @@ export const taskRunWorkflowReference = { workflowId: `workflow//${STABLE_ID_BASE}//${TASK_RUN_WORKFLOW_NAME}`, }; -/** Stable workflow reference for `task_await` aggregation runs. */ -export const taskAwaitWorkflowReference = { - workflowId: `workflow//${STABLE_ID_BASE}//${TASK_AWAIT_WORKFLOW_NAME}`, -}; - /** * Creates a workflow-backed runtime whose long-lived driver owns the * event stream and dispatches each turn as a child workflow run. @@ -318,10 +311,12 @@ function inactiveCommandResult( export async function requestWorkflowTurnCancellation( input: CancelTurnInput, ): Promise { - return await dispatchWorkflowCommand(sessionCommandHookToken(input.sessionId), { + const command: { kind: "cancel"; taskId?: string; turnId?: string } = { kind: "cancel", - turnId: input.turnId, - }); + }; + if (input.taskId !== undefined) command.taskId = input.taskId; + if (input.turnId !== undefined) command.turnId = input.turnId; + return await dispatchWorkflowCommand(sessionCommandHookToken(input.sessionId), command); } function classifyInactiveCancelTarget(error: unknown): string | undefined { diff --git a/packages/eve/src/execution/workflow-steps.test.ts b/packages/eve/src/execution/workflow-steps.test.ts index c3431ca3e..0b07b5725 100644 --- a/packages/eve/src/execution/workflow-steps.test.ts +++ b/packages/eve/src/execution/workflow-steps.test.ts @@ -20,7 +20,7 @@ import { setPendingRuntimeActionBatch } from "#harness/runtime-actions.js"; import { getAgentHandleStore } from "#harness/handles/store.js"; import { requestTurnSleep } from "#harness/turn-sleep.js"; import { getPendingAuthorization, setPendingAuthorization } from "#harness/authorization.js"; -import { upsertProxyInputRequests } from "#harness/proxy-input-requests.js"; +import { getProxyInputRequests, upsertProxyInputRequests } from "#harness/proxy-input-requests.js"; import { setPendingInputBatch } from "#harness/input-requests.js"; import type { HarnessSession, StepResult } from "#harness/types.js"; import { createEmptyHookRegistry } from "#runtime/hooks/registry.js"; @@ -38,13 +38,15 @@ import { buildRuntimeIdentity, createExecutionNodeStep } from "#execution/node-s import { defineTool } from "#public/definitions/tool.js"; import { dispatchRuntimeActionsStep } from "#execution/dispatch-runtime-actions-step.js"; import { runProxySubagentEventStep } from "#execution/subagent-event-proxy-step.js"; +import { readLatestTaskSnapshot, sendTaskInboundPayload } from "#execution/tasks/run-control.js"; +import { recordTaskInputRequestStep } from "#execution/tasks/hitl-proxy-steps.js"; import { emitTerminalSessionFailureStep } from "#execution/terminal-session-failure-step.js"; import { dispatchTurnStep, - routeProxiedDeliverStep, resolveEffectiveOutputSchema, turnStep, } from "#execution/workflow-steps.js"; +import { routeProxiedDeliverStep } from "#execution/proxied-deliver-step.js"; import { LATEST_DEPLOYMENT_UNSUPPORTED_MESSAGE, turnWorkflowReference, @@ -59,6 +61,10 @@ vi.mock("./durable-session-store.js", async (importOriginal) => { readDurableSession: vi.fn(), }; }); +vi.mock("./tasks/run-control.js", () => ({ + readLatestTaskSnapshot: vi.fn(), + sendTaskInboundPayload: vi.fn(), +})); function installSessionStoreMocks(sessions: HarnessSession[]): void { // Each `readDurableSession` invocation pops the next prepared session @@ -204,6 +210,9 @@ afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); vi.restoreAllMocks(); + vi.mocked(readLatestTaskSnapshot).mockReset(); + vi.mocked(sendTaskInboundPayload).mockReset(); + vi.mocked(sendTaskInboundPayload).mockResolvedValue("delivered"); }); describe("routeProxiedDeliverStep", () => { @@ -237,7 +246,7 @@ describe("routeProxiedDeliverStep", () => { sessionId: "parent-session", }), }), - ).resolves.toEqual({ kind: "continue", remainder: undefined }); + ).resolves.toMatchObject({ kind: "continue", remainder: undefined }); expect(resumeHookMock).toHaveBeenCalledWith("child-token", { auth, @@ -247,6 +256,190 @@ describe("routeProxiedDeliverStep", () => { }, }); }); + + function createTaskRouteSession(options?: { readonly owned?: boolean }): HarnessSession { + return upsertProxyInputRequests({ + entries: [ + [ + "request-1", + { childContinuationToken: "child-token", kind: "tool-approval", taskId: "task-1" }, + ], + ], + forChildContinuationToken: "child-token", + session: createStubSession({ + continuationToken: "parent-token", + sessionId: "parent-session", + state: + options?.owned === false + ? undefined + : { + "eve.tasks": { + tasks: [ + { + childSessionId: "child-session", + commandToken: "task-token", + createdByTurnId: "turn-parent", + operationId: "operation-1", + taskId: "task-1", + taskRunId: "run-1", + }, + ], + }, + }, + }), + }); + } + + const taskRouteInput = { + parentWritable: createTestWritable(), + payload: { inputResponses: [{ optionId: "approve", requestId: "request-1" }] }, + sessionState: createStubSessionState({ hasProxyInputRequests: true }), + }; + + it("hands a task-owned answer to the task run instead of the child", async () => { + installSessionStoreMocks([createTaskRouteSession()]); + + await expect( + routeProxiedDeliverStep({ ...taskRouteInput, parentWritable: createTestWritable() }), + ).resolves.toMatchObject({ kind: "continue", remainder: undefined }); + expect(sendTaskInboundPayload).toHaveBeenCalledWith({ + commandToken: "task-token", + payload: { + auth: undefined, + childContinuationToken: "child-token", + inputResponses: [{ optionId: "approve", requestId: "request-1" }], + kind: "task-answer-input", + taskId: "task-1", + }, + }); + expect(resumeHookMock).not.toHaveBeenCalled(); + }); + + it("keeps a response for a task this session does not own on the parent", async () => { + installSessionStoreMocks([createTaskRouteSession({ owned: false })]); + + await expect( + routeProxiedDeliverStep({ ...taskRouteInput, parentWritable: createTestWritable() }), + ).resolves.toMatchObject({ + kind: "continue", + remainder: { inputResponses: [{ optionId: "approve", requestId: "request-1" }] }, + }); + expect(sendTaskInboundPayload).not.toHaveBeenCalled(); + expect(resumeHookMock).not.toHaveBeenCalled(); + }); + + it("returns answers to the parent when the task run already finished", async () => { + installSessionStoreMocks([createTaskRouteSession()]); + vi.mocked(sendTaskInboundPayload).mockResolvedValue("unreachable"); + + await expect( + routeProxiedDeliverStep({ ...taskRouteInput, parentWritable: createTestWritable() }), + ).resolves.toMatchObject({ + kind: "continue", + remainder: { inputResponses: [{ optionId: "approve", requestId: "request-1" }] }, + }); + }); + + it("keeps a task answer retryable after delivery fails", async () => { + const session = createTaskRouteSession(); + installSessionStoreMocks([session, session]); + vi.mocked(sendTaskInboundPayload) + .mockRejectedValueOnce(new Error("transient")) + .mockResolvedValueOnce("delivered"); + + await expect( + routeProxiedDeliverStep({ ...taskRouteInput, parentWritable: createTestWritable() }), + ).rejects.toThrow("transient"); + await expect( + routeProxiedDeliverStep({ ...taskRouteInput, parentWritable: createTestWritable() }), + ).resolves.toMatchObject({ kind: "continue", remainder: undefined }); + expect(sendTaskInboundPayload).toHaveBeenCalledTimes(2); + }); +}); + +describe("recordTaskInputRequestStep", () => { + const hookPayload: SubagentInputRequestHookPayload = { + callId: "call-task", + childContinuationToken: "child-token", + childSessionId: "child-session", + event: { + requests: [ + { + action: { callId: "call-q", input: {}, kind: "tool-call", toolName: "ask" }, + kind: "question", + prompt: "Which?", + requestId: "request-1", + }, + ], + sequence: 1, + stepIndex: 2, + turnId: "turn_child", + }, + kind: "subagent-input-request", + subagentName: "research", + }; + + it("records an exact route only for a current task owned by this parent", async () => { + const session = createStubSession({ + state: { + "eve.tasks": { + tasks: [ + { + childSessionId: "child-session", + commandToken: "task-token", + createdByTurnId: "turn-parent", + operationId: "operation-1", + taskId: "task-1", + taskRunId: "run-1", + }, + ], + }, + }, + }); + installSessionStoreMocks([session]); + vi.mocked(readLatestTaskSnapshot).mockResolvedValue({ + metadata: { + childSessionId: "child-session", + kind: "subagent", + mode: "local", + name: "research", + }, + inputRequests: hookPayload.event.requests, + status: "input_required", + taskId: "task-1", + }); + + const result = await recordTaskInputRequestStep({ + hookPayload, + serializedContext: createSerializedContext(), + sessionState: createStubSessionState(), + taskId: "task-1", + }); + + expect(result.accepted).toBe(true); + expect( + getProxyInputRequests(result.sessionState.snapshot?.session.state).get("request-1"), + ).toEqual({ + childContinuationToken: "child-token", + kind: "question", + taskId: "task-1", + }); + }); + + it("rejects cross-session and stale batches without recording a route", async () => { + const session = createStubSession(); + installSessionStoreMocks([session, session]); + vi.mocked(readLatestTaskSnapshot).mockResolvedValue(undefined); + + const result = await recordTaskInputRequestStep({ + hookPayload, + serializedContext: createSerializedContext(), + sessionState: createStubSessionState(), + taskId: "foreign-task", + }); + + expect(result).toEqual({ accepted: false, sessionState: createStubSessionState() }); + }); }); describe("dispatchTurnStep", () => { diff --git a/packages/eve/src/execution/workflow-steps.ts b/packages/eve/src/execution/workflow-steps.ts index f6f59e52c..568669540 100644 --- a/packages/eve/src/execution/workflow-steps.ts +++ b/packages/eve/src/execution/workflow-steps.ts @@ -1,6 +1,6 @@ import { buildAdapterContext } from "#channel/adapter-context.js"; import { callAdapterEventHandler, defaultDeliverResult } from "#channel/adapter.js"; -import type { DeliverPayload, SessionAuthContext, SessionCommand } from "#channel/types.js"; +import type { DeliverPayload } from "#channel/types.js"; import { dispatchStreamEventHooks } from "#context/hook-lifecycle.js"; import { dispatchDynamicInstructionEvent } from "#context/dynamic-instruction-lifecycle.js"; import { dispatchDynamicModelEvent } from "#context/dynamic-model-lifecycle.js"; @@ -71,7 +71,6 @@ import { type TurnWorkflowDispatchInput, } from "#execution/durable-session-migrations/turn-workflow.js"; import { buildRuntimeIdentity, createExecutionNodeStep } from "#execution/node-step.js"; -import { routeDeliverPayload } from "#execution/subagent-hitl-proxy.js"; import { resolveEffectiveAgentRuntime } from "#execution/effective-agent-config.js"; import { recordSubagentUsageSpans } from "#execution/subagent-usage-span.js"; import { reconcileSessionContinuationToken } from "#execution/reconcile-session-continuation-token.js"; @@ -84,7 +83,6 @@ import { startWorkflowPreferLatest, turnWorkflowReference, } from "#execution/workflow-runtime.js"; -import { resumeHook } from "#internal/workflow/runtime.js"; /** * Result of one durable harness step, consumed by the turn workflow. @@ -616,47 +614,6 @@ export function resolveEffectiveOutputSchema(input: { return session; } -export type RoutedDeliverResult = - | { - readonly kind: "cancel-turn"; - } - | { - readonly kind: "continue"; - /** `undefined` when the entire payload was routed to descendants. */ - readonly remainder: DeliverPayload | undefined; - }; - -/** - * Splits an inbound deliver payload into parent-local and - * proxied-child buckets and forwards the child buckets via - * `resumeHook`. Read-only: never appends a snapshot. - */ -export async function routeProxiedDeliverStep(input: { - readonly auth?: SessionAuthContext | null; - readonly parentWritable: WritableStream; - readonly payload: DeliverPayload; - readonly sessionState: DurableSessionState; -}): Promise { - "use step"; - - const durableSession = await readDurableSession(input.sessionState); - const routed = routeDeliverPayload({ - payload: input.payload, - state: durableSession.state, - }); - - for (const forChild of routed.forChildren) { - const command = { - auth: input.auth, - kind: "send", - payload: forChild.payload, - } satisfies SessionCommand; - await resumeHook(forChild.childContinuationToken, command); - } - - return routed.parentAction ?? { kind: "continue", remainder: routed.forSelf }; -} - /** Starts a per-turn child workflow for the current driver session. */ export async function dispatchTurnStep( input: TurnWorkflowDispatchInput, diff --git a/packages/eve/src/harness/advertised-tools.test.ts b/packages/eve/src/harness/advertised-tools.test.ts index ebe962214..4b68c600f 100644 --- a/packages/eve/src/harness/advertised-tools.test.ts +++ b/packages/eve/src/harness/advertised-tools.test.ts @@ -150,7 +150,6 @@ describe("getAdvertisedTools for definition arrays", () => { it("removes the task tools from delegated sessions", () => { const tools = new Map([ ["add", createTool("add")], - ["task_await", createTaskControlTool("task_await")], ["task_cancel", createTaskControlTool("task_cancel")], ["task_peek", createTaskControlTool("task_peek")], ["task_sleep", createTool("task_sleep")], diff --git a/packages/eve/src/harness/execute-tool.ts b/packages/eve/src/harness/execute-tool.ts index 2042f0f91..d26bf9745 100644 --- a/packages/eve/src/harness/execute-tool.ts +++ b/packages/eve/src/harness/execute-tool.ts @@ -10,7 +10,7 @@ import type { ToolExecuteOptions } from "#shared/tool-definition.js"; * The harness records the tool call and the runtime executes it later. * * `task-control` marks the `experimental.tasks` parent tools - * (`task_peek`, `task_await`, `task_cancel`): they carry no child + * (`task_peek`, `task_cancel`): they carry no child * address of their own — the dispatch step resolves targets through the * session task index by tool name. */ diff --git a/packages/eve/src/harness/proxy-input-requests.test.ts b/packages/eve/src/harness/proxy-input-requests.test.ts index 9c6197bd3..4d2d056b9 100644 --- a/packages/eve/src/harness/proxy-input-requests.test.ts +++ b/packages/eve/src/harness/proxy-input-requests.test.ts @@ -39,6 +39,28 @@ describe("upsertProxyInputRequests", () => { }); }); + it("round-trips task ownership without exposing malformed ownership as an unscoped route", () => { + const next = upsertProxyInputRequests({ + entries: [ + ["req-1", { childContinuationToken: "child-a", kind: "question", taskId: "task-1" }], + ], + forChildContinuationToken: "child-a", + session: createSession(), + }); + expect(getProxyInputRequests(next.state).get("req-1")).toEqual({ + childContinuationToken: "child-a", + kind: "question", + taskId: "task-1", + }); + expect( + getProxyInputRequests({ + "eve.runtime.proxyInputRequests": { + "req-1": { childContinuationToken: "child-a", kind: "question", taskId: 42 }, + }, + }).size, + ).toBe(0); + }); + it("replaces prior entries for the same child continuation token", () => { let session = upsertProxyInputRequests({ entries: [["req-1", { childContinuationToken: "child-a", kind: "question" }]], diff --git a/packages/eve/src/harness/proxy-input-requests.ts b/packages/eve/src/harness/proxy-input-requests.ts index 3ae2c1f2d..cc60b41a3 100644 --- a/packages/eve/src/harness/proxy-input-requests.ts +++ b/packages/eve/src/harness/proxy-input-requests.ts @@ -14,6 +14,8 @@ const PROXY_INPUT_REQUEST_KINDS = { export interface ProxyInputRequest { readonly childContinuationToken: string; readonly kind: InputRequestKind; + /** Present when the route is authorized by a parent-owned durable task. */ + readonly taskId?: string; } /** `requestId → route` map stored on the parent session. */ @@ -50,9 +52,25 @@ export function upsertProxyInputRequests(input: { readonly forChildContinuationToken: string; readonly session: HarnessSession; }): HarnessSession { + return { + ...input.session, + state: upsertProxyInputRequestState({ + entries: input.entries, + forChildContinuationToken: input.forChildContinuationToken, + state: input.session.state, + }), + }; +} + +/** State-only variant for control-plane steps that already hold a durable projection. */ +export function upsertProxyInputRequestState(input: { + readonly entries: readonly (readonly [requestId: string, route: ProxyInputRequest])[]; + readonly forChildContinuationToken: string; + readonly state: SessionStateMap | undefined; +}): SessionStateMap | undefined { const next: Record = {}; - for (const [requestId, route] of Object.entries(readMap(input.session.state))) { + for (const [requestId, route] of Object.entries(readMap(input.state))) { if (route.childContinuationToken !== input.forChildContinuationToken) { next[requestId] = route; } @@ -62,7 +80,13 @@ export function upsertProxyInputRequests(input: { next[requestId] = route; } - return writeMap(input.session, next); + const state = { ...input.state }; + if (Object.keys(next).length === 0) { + delete state[PROXY_INPUT_REQUESTS_KEY]; + } else { + state[PROXY_INPUT_REQUESTS_KEY] = next; + } + return Object.keys(state).length > 0 ? state : undefined; } /** @@ -92,6 +116,24 @@ export function clearProxyInputRequestsForChild( return writeMap(session, next); } +/** Removes every proxy route owned by one durable task. */ +export function clearProxyInputRequestsForTask( + session: HarnessSession, + taskId: string, +): HarnessSession { + const current = readMap(session.state); + const next: Record = {}; + let changed = false; + for (const [requestId, route] of Object.entries(current)) { + if (route.taskId === taskId) { + changed = true; + } else { + next[requestId] = route; + } + } + return changed ? writeMap(session, next) : session; +} + /** * Removes every proxy entry. Called when a cancelled turn orphans its * descendants so stale HITL responses no longer route to them. @@ -109,17 +151,20 @@ export function clearAllProxyInputRequests(session: HarnessSession): HarnessSess */ export function toProxyInputRequestEntries( payload: SubagentInputRequestHookPayload, + taskId?: string, ): readonly (readonly [requestId: string, route: ProxyInputRequest])[] { - return payload.event.requests.map( - (request) => - [ - request.requestId, - { - childContinuationToken: payload.childContinuationToken, - kind: request.kind, - }, - ] as const, - ); + return payload.event.requests.map((request) => { + const route: { + childContinuationToken: string; + kind: InputRequestKind; + taskId?: string; + } = { + childContinuationToken: payload.childContinuationToken, + kind: request.kind, + }; + if (taskId !== undefined) route.taskId = taskId; + return [request.requestId, route] as const; + }); } function readMap(state: SessionStateMap | undefined): ProxyInputRequestMap { @@ -167,10 +212,16 @@ function parseProxyInputRequest(value: unknown): ProxyInputRequest | undefined { if (typeof value.childContinuationToken !== "string" || !isInputRequestKind(value.kind)) { return undefined; } - return { + const taskId = "taskId" in value ? value.taskId : undefined; + if (taskId !== undefined && (typeof taskId !== "string" || taskId.length === 0)) { + return undefined; + } + const request: { childContinuationToken: string; kind: InputRequestKind; taskId?: string } = { childContinuationToken: value.childContinuationToken, kind: value.kind, }; + if (typeof taskId === "string") request.taskId = taskId; + return request; } function isInputRequestKind(value: unknown): value is InputRequestKind { diff --git a/packages/eve/src/harness/runtime-actions.ts b/packages/eve/src/harness/runtime-actions.ts index f670604ce..056493789 100644 --- a/packages/eve/src/harness/runtime-actions.ts +++ b/packages/eve/src/harness/runtime-actions.ts @@ -238,6 +238,11 @@ export async function resolvePendingRuntimeActions(input: { if (result.kind !== "subagent-result" || result.origin !== "child") { continue; } + // A background receipt confirms task admission, not child-turn settlement. + // The task snapshot later carries the actual parked/terminal outcome. + if (readBackgroundTaskReceipt(result) !== undefined) { + continue; + } const handle = findRunningAgentHandle(nextSession.state, { callId: result.callId }); if (handle === undefined) { continue; diff --git a/packages/eve/src/public/channels/eve.test.ts b/packages/eve/src/public/channels/eve.test.ts index c04910504..915be33bc 100644 --- a/packages/eve/src/public/channels/eve.test.ts +++ b/packages/eve/src/public/channels/eve.test.ts @@ -52,12 +52,20 @@ const OVERRIDE_AUTH: SessionAuthContext = { * Returns a `fetch(req)` function and a `send` mock so tests can inspect * what the handler passed through. */ -function createEveCreateHandler(input: EveChannelInput) { +function createEveCreateHandler( + input: EveChannelInput, + options: { readonly activeSessionId?: string } = {}, +) { const channel = eveChannel(input); const createRoute = channel.routes.find( (r) => r.method === "POST" && r.path === "/eve/v1/session", ); if (!createRoute) throw new Error("No create POST route found"); + const resolveActiveSession = vi + .fn() + .mockResolvedValue( + options.activeSessionId === undefined ? undefined : { sessionId: options.activeSessionId }, + ); const mockSend = vi.fn().mockResolvedValue({ id: "test-session-id", @@ -74,11 +82,12 @@ function createEveCreateHandler(input: EveChannelInput) { } satisfies ChannelSession); return { + resolveActiveSession, send: mockSend, async fetch(req: Request) { const args: RouteHandlerArgs = { send: mockSend, - resolveActiveSession: async () => undefined, + resolveActiveSession, cancel: vi.fn(), clear: vi.fn(), compact: vi.fn(), @@ -815,6 +824,70 @@ describe("eveChannel — onMessage", () => { }); }); +describe("eveChannel — create session idempotency", () => { + it("creates once for an operation id and reuses its continuation token", async () => { + const handler = createEveCreateHandler({ auth: none() }); + + const response = await handler.fetch( + createJsonMessageRequest({ message: "hi", operationId: "operation-1" }), + ); + + expect(response.status).toBe(202); + const token = handler.send.mock.calls[0]?.[1]?.continuationToken; + expect(token).toMatch(/^eve:op:[0-9a-f]{32}$/); + expect(handler.resolveActiveSession).toHaveBeenCalledWith({ continuationToken: token }); + }); + + it("returns the existing child for a replayed operation without dispatching again", async () => { + const handler = createEveCreateHandler({ auth: none() }, { activeSessionId: "child-1" }); + + const response = await handler.fetch( + createJsonMessageRequest({ message: "hi", operationId: "operation-1" }), + ); + + expect(response.status).toBe(202); + await expect(response.json()).resolves.toMatchObject({ ok: true, sessionId: "child-1" }); + expect(handler.send).not.toHaveBeenCalled(); + }); + + it("scopes the operation token to the authenticated principal", async () => { + const tokenFor = async (principalId: string): Promise => { + const handler = createEveCreateHandler({ + auth: () => ({ + attributes: {}, + authenticator: "test", + principalId, + principalType: "service", + }), + }); + await handler.fetch(createJsonMessageRequest({ message: "hi", operationId: "operation-1" })); + return handler.send.mock.calls[0]?.[1]?.continuationToken; + }; + + expect(await tokenFor("caller-a")).not.toBe(await tokenFor("caller-b")); + }); + + it("rejects a non-string operation id", async () => { + const handler = createEveCreateHandler({ auth: none() }); + + const response = await handler.fetch( + createJsonMessageRequest({ message: "hi", operationId: 42 }), + ); + + expect(response.status).toBe(400); + expect(handler.send).not.toHaveBeenCalled(); + }); + + it("mints a random continuation token when no operation id is supplied", async () => { + const handler = createEveCreateHandler({ auth: none() }); + + await handler.fetch(createJsonMessageRequest({ message: "hi" })); + + expect(handler.send.mock.calls[0]?.[1]?.continuationToken).toMatch(/^eve:(?!op:)/); + expect(handler.resolveActiveSession).not.toHaveBeenCalled(); + }); +}); + describe("eveChannel — create session (text)", () => { it("accepts a plain-string message and opens a new session", async () => { const handler = createEveCreateHandler({ auth: none() }); diff --git a/packages/eve/src/public/channels/eve.ts b/packages/eve/src/public/channels/eve.ts index db0dc9dd1..4753a1f77 100644 --- a/packages/eve/src/public/channels/eve.ts +++ b/packages/eve/src/public/channels/eve.ts @@ -223,7 +223,7 @@ export function eveChannel(input: EveChannelInput): EveChannel { return await respond(); }), - POST("/eve/v1/session", async (req, { send }) => { + POST("/eve/v1/session", async (req, { resolveActiveSession, send }) => { const authResult = await routeAuth(req, input.auth); if (authResult instanceof Response) return authResult; const sessionAuth = authResult; @@ -252,6 +252,32 @@ export function eveChannel(input: EveChannelInput): EveChannel { const policyRejection = checkUploadPolicy(body, uploadPolicy); if (policyRejection !== null) return policyRejection; + // A caller that supplies an operation id gets create-once semantics: + // a retried request resolves the child it already created instead of + // starting a second one and re-running `onMessage`. + const operationToken = + body.operationId === undefined + ? undefined + : await deriveOperationContinuationToken({ + auth: sessionAuth, + operationId: body.operationId, + }); + if (operationToken !== undefined) { + const owner = await resolveActiveSession({ continuationToken: operationToken }); + if (owner !== undefined) { + return Response.json( + { continuationToken: operationToken, ok: true, sessionId: owner.sessionId }, + { + headers: { + "cache-control": "no-store", + [EVE_SESSION_ID_HEADER]: owner.sessionId, + }, + status: 202, + }, + ); + } + } + const messageResult = await resolveOnMessage({ auth: forwarded.auth, config: input, @@ -261,7 +287,7 @@ export function eveChannel(input: EveChannelInput): EveChannel { if (messageResult instanceof Response) return messageResult; if (!messageResult.dispatch) return droppedMessageResponse(); - const token = `eve:${crypto.randomUUID()}`; + const token = operationToken ?? `eve:${crypto.randomUUID()}`; const context = mergeContext(body.context, messageResult.context); const sendOptions: SendOptions = { @@ -494,7 +520,12 @@ export function eveChannel(input: EveChannelInput): EveChannel { if (agent === undefined) { throw new Error("Missing route agent."); } - result = await agent.cancelTurn({ sessionId, turnId: body.turnId }); + const cancelInput: { sessionId: string; taskId?: string; turnId?: string } = { + sessionId, + }; + if (body.taskId !== undefined) cancelInput.taskId = body.taskId; + if (body.turnId !== undefined) cancelInput.turnId = body.turnId; + result = await agent.cancelTurn(cancelInput); } catch (error) { const errorId = logError(log, "cancel-turn request failed", error, { sessionId }); return Response.json( @@ -683,9 +714,33 @@ interface ParsedCreateBody { message: string | UserContent; mode?: RunMode; context?: readonly string[]; + operationId?: string; outputSchema?: JsonObject; } +/** + * Derives the replay-stable continuation token for one create operation. + * + * The authenticated caller is part of the digest, so an operation id alone + * never addresses another principal's session: only the principal that + * created a child can resolve it again. + */ +async function deriveOperationContinuationToken(input: { + readonly auth: SessionAuthContext | null; + readonly operationId: string; +}): Promise { + const principal = + input.auth === null ? "" : `${input.auth.authenticator}\u0000${input.auth.principalId}`; + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(`${principal}\u0000${input.operationId}`), + ); + const hex = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join( + "", + ); + return `eve:op:${hex.slice(0, 32)}`; +} + function parseCreateBody(payload: Record): ParsedCreateBody | Response { const message = parseMessageField(payload.message); if (message instanceof Response) return message; @@ -712,7 +767,24 @@ function parseCreateBody(payload: Record): ParsedCreateBody | R ); } - return { callback, capabilities, message, mode, context, outputSchema }; + const rawOperationId = payload.operationId; + if (rawOperationId !== undefined && (typeof rawOperationId !== "string" || !rawOperationId)) { + return Response.json( + { error: "Expected 'operationId' to be a non-empty string.", ok: false }, + { status: 400 }, + ); + } + + const body: ParsedCreateBody = { + callback, + capabilities, + message, + mode, + context, + outputSchema, + }; + if (typeof rawOperationId === "string") body.operationId = rawOperationId; + return body; } interface ParsedContinueBody { @@ -775,6 +847,7 @@ function parseContinueBody(payload: Record): ParsedContinueBody } interface ParsedCancelTurnBody { + taskId?: string; turnId?: string; } @@ -829,16 +902,23 @@ async function parseCancelTurnBody(req: Request): Promise { + it("accepts only terminal-task message follow-ups", () => { + expect( + TASK_SEND_INPUT_SCHEMA.safeParse({ message: "continue", taskId: "task_1" }).success, + ).toBe(true); + expect( + TASK_SEND_INPUT_SCHEMA.safeParse({ + inputResponses: [{ optionId: "approve", requestId: "request_1" }], + taskId: "task_1", + }).success, + ).toBe(false); + }); +}); diff --git a/packages/eve/src/runtime/framework-tools/tasks.ts b/packages/eve/src/runtime/framework-tools/tasks.ts index a7ed4c506..188dcb433 100644 --- a/packages/eve/src/runtime/framework-tools/tasks.ts +++ b/packages/eve/src/runtime/framework-tools/tasks.ts @@ -9,14 +9,13 @@ import type { ResolvedToolDefinition } from "#runtime/types.js"; * * With the flag on, subagent calls return a task receipt instead of * blocking the parent turn; these tools are the model's controls over - * that delegated work. `task_peek`, `task_await`, and `task_cancel` are + * that delegated work. `task_peek` and `task_cancel` are * execute-less runtime actions — they need durable session state and * world access, so the runtime-action dispatch step executes them. * `task_sleep` only records a durable pause and executes in-loop. */ export const TASK_PEEK_TOOL_NAME = "task_peek"; -export const TASK_AWAIT_TOOL_NAME = "task_await"; export const TASK_CANCEL_TOOL_NAME = "task_cancel"; export const TASK_SEND_TOOL_NAME = "task_send"; export const TASK_SLEEP_TOOL_NAME = "task_sleep"; @@ -24,7 +23,6 @@ export const TASK_SLEEP_TOOL_NAME = "task_sleep"; /** Every model-visible task tool name, for gating and dispatch matching. */ export const TASK_TOOL_NAMES: ReadonlySet = new Set([ TASK_PEEK_TOOL_NAME, - TASK_AWAIT_TOOL_NAME, TASK_CANCEL_TOOL_NAME, TASK_SEND_TOOL_NAME, TASK_SLEEP_TOOL_NAME, @@ -33,7 +31,6 @@ export const TASK_TOOL_NAMES: ReadonlySet = new Set([ /** Task-control tools executed by the runtime-action dispatch step. */ export const TASK_CONTROL_TOOL_NAMES: ReadonlySet = new Set([ TASK_PEEK_TOOL_NAME, - TASK_AWAIT_TOOL_NAME, TASK_CANCEL_TOOL_NAME, TASK_SEND_TOOL_NAME, ]); @@ -44,25 +41,11 @@ const TASK_IDS_SCHEMA = z .describe("Task ids from earlier subagent task receipts."); export const TASK_PEEK_INPUT_SCHEMA = z.strictObject({ taskIds: TASK_IDS_SCHEMA }); -export const TASK_AWAIT_INPUT_SCHEMA = z.strictObject({ taskIds: TASK_IDS_SCHEMA }); export const TASK_CANCEL_INPUT_SCHEMA = z.strictObject({ taskIds: TASK_IDS_SCHEMA }); export const TASK_SEND_INPUT_SCHEMA = z.strictObject({ - inputResponses: z - .array( - z.strictObject({ - optionId: z.string().optional(), - requestId: z.string(), - text: z.string().optional(), - }), - ) - .optional() - .describe( - "Your answers to an input_required task's outstanding requests; each requestId comes from the task's inputRequests. Provide exactly one of inputResponses or message.", - ), message: z .string() - .optional() .describe( "Follow-up message for a finished task's agent; starts a new task in the same conversation.", ), @@ -107,23 +90,19 @@ const TASK_PEEK_DESCRIPTION = "Read the current state of one or more background tasks without waiting. " + "Returns each task's status and, for finished tasks, its output. Does not wake or change the task."; -const TASK_AWAIT_DESCRIPTION = - "Wait until every selected background task is finished (completed, failed, or cancelled) or needs input. " + - "Tasks that are already in one of those states return immediately. Returns the same views as task_peek."; - const TASK_CANCEL_DESCRIPTION = "Request cooperative cancellation of one or more background tasks. " + "Cancellation is final: a task that finishes after you cancel it stays cancelled. Cancelling an already-finished task changes nothing."; const TASK_SEND_DESCRIPTION = - "Reply to one of your background tasks. " + - "An input_required task means its agent stopped to ask you something: read the questions from the task's inputRequests (via task_peek) and answer them with inputResponses. " + - "A finished task accepts a follow-up message instead, which starts a new task in the same conversation and returns its receipt. " + + "Send a follow-up message to a finished background task's agent. " + + "This starts a new task in the same conversation and returns its receipt. " + + "Human input for an input_required task is routed directly through the parent channel. " + "A task that is still working cannot receive sends."; const TASK_SLEEP_DESCRIPTION = "Pause durably before continuing, for paced background-task checks. " + - "Does not read or change any task; follow it with task_peek or task_await."; + "Does not read or change any task; follow it with task_peek."; /** * Builds the harness definitions injected when the root agent enables @@ -140,13 +119,6 @@ export function createTaskToolHarnessDefinitions(): readonly HarnessToolDefiniti outputSchema: TASK_VIEWS_OUTPUT_SCHEMA, runtimeAction: { kind: "task-control" }, }, - { - description: TASK_AWAIT_DESCRIPTION, - inputSchema: TASK_AWAIT_INPUT_SCHEMA, - name: TASK_AWAIT_TOOL_NAME, - outputSchema: TASK_VIEWS_OUTPUT_SCHEMA, - runtimeAction: { kind: "task-control" }, - }, { description: TASK_CANCEL_DESCRIPTION, inputSchema: TASK_CANCEL_INPUT_SCHEMA, @@ -217,7 +189,6 @@ function createResolvedTaskToolStub(input: { */ export const TASK_TOOL_DEFINITIONS: readonly ResolvedToolDefinition[] = [ createResolvedTaskToolStub({ description: TASK_PEEK_DESCRIPTION, name: TASK_PEEK_TOOL_NAME }), - createResolvedTaskToolStub({ description: TASK_AWAIT_DESCRIPTION, name: TASK_AWAIT_TOOL_NAME }), createResolvedTaskToolStub({ description: TASK_CANCEL_DESCRIPTION, name: TASK_CANCEL_TOOL_NAME }), createResolvedTaskToolStub({ description: TASK_SEND_DESCRIPTION, name: TASK_SEND_TOOL_NAME }), createResolvedTaskToolStub({ description: TASK_SLEEP_DESCRIPTION, name: TASK_SLEEP_TOOL_NAME }), diff --git a/packages/eve/src/runtime/session-callback-route.test.ts b/packages/eve/src/runtime/session-callback-route.test.ts index 62aaa7444..974434b8f 100644 --- a/packages/eve/src/runtime/session-callback-route.test.ts +++ b/packages/eve/src/runtime/session-callback-route.test.ts @@ -36,6 +36,32 @@ describe("session callback route", () => { expect([...names].some((name) => name.startsWith(".well-known/"))).toBe(false); }); + it("forwards remote task turn-start identity to the task hook", async () => { + resumeHookMock.mockResolvedValue(undefined); + const response = await handleSessionCallbackRequest( + new Request("https://app.example.com/eve/v1/callback/task-token", { + body: JSON.stringify({ + callId: "call-task", + kind: "turn.started", + sessionId: "child-session", + subagentName: "research", + taskId: "task-1", + turnId: "turn_child_7", + }), + method: "POST", + }), + createRouteContext({ token: "task-token" }), + ); + + expect(response.status).toBe(202); + expect(resumeHookMock).toHaveBeenCalledWith("task-token", { + childSessionId: "child-session", + childTurnId: "turn_child_7", + kind: "task-child-turn-started", + taskId: "task-1", + }); + }); + it("synthesizes a terminal outcome envelope for session.completed", async () => { resumeHookMock.mockResolvedValue(undefined); diff --git a/packages/eve/src/runtime/session-callback-route.ts b/packages/eve/src/runtime/session-callback-route.ts index a5f9e6ae9..322cc2ad5 100644 --- a/packages/eve/src/runtime/session-callback-route.ts +++ b/packages/eve/src/runtime/session-callback-route.ts @@ -7,6 +7,7 @@ import type { RuntimeSubagentChildResult } from "#runtime/actions/types.js"; import { agentTurnOutcomeSchema, type AgentTurnOutcome } from "#shared/agent-turn-outcome.js"; import type { JsonValue } from "#shared/json.js"; import { tokenUsageSchema, type TokenUsage } from "#shared/token-usage.js"; +import type { TaskInboundTurnStarted } from "#tasks/types.js"; export const HTTP_SESSION_CALLBACK_CHANNEL_NAME_PREFIX = "eve/v1/callback"; @@ -27,6 +28,14 @@ const ZERO_TOKEN_USAGE: TokenUsage = { * deployments may omit it. */ type SessionCallbackPayload = + | { + readonly callId: string; + readonly kind: "turn.started"; + readonly sessionId: string; + readonly subagentName: string; + readonly taskId: string; + readonly turnId: string; + } | { readonly callId: string; readonly kind: "session.completed"; @@ -101,6 +110,17 @@ export async function handleSessionCallbackRequest( return Response.json({ error: "Invalid JSON body.", ok: false }, { status: 400 }); } + const started = projectTaskTurnStarted(body); + if (started instanceof Response) return started; + if (started !== undefined) { + try { + await resumeHook(token, started); + } catch { + return Response.json({ error: "Session callback not pending.", ok: false }, { status: 404 }); + } + return Response.json({ ok: true }, { status: 202 }); + } + const result = projectSessionCallbackResult(body); if (result instanceof Response) { return result; @@ -118,6 +138,31 @@ export async function handleSessionCallbackRequest( return Response.json({ ok: true }, { status: 202 }); } +function projectTaskTurnStarted(value: unknown): TaskInboundTurnStarted | Response | undefined { + if (value === null || typeof value !== "object") return undefined; + const payload = value as Partial; + if (payload.kind !== "turn.started") return undefined; + if ( + typeof payload.sessionId !== "string" || + payload.sessionId.length === 0 || + typeof payload.taskId !== "string" || + payload.taskId.length === 0 || + typeof payload.turnId !== "string" || + payload.turnId.length === 0 + ) { + return Response.json( + { error: "Invalid task turn-start callback.", ok: false }, + { status: 400 }, + ); + } + return { + childSessionId: payload.sessionId, + childTurnId: payload.turnId, + kind: "task-child-turn-started", + taskId: payload.taskId, + }; +} + function projectSessionCallbackResult(value: unknown): RuntimeSubagentChildResult | Response { if (value === null || typeof value !== "object") { return Response.json({ error: "Expected a JSON object.", ok: false }, { status: 400 }); diff --git a/packages/eve/src/tasks/session-index.test.ts b/packages/eve/src/tasks/session-index.test.ts index 8af416bb8..503f39453 100644 --- a/packages/eve/src/tasks/session-index.test.ts +++ b/packages/eve/src/tasks/session-index.test.ts @@ -8,6 +8,19 @@ import { recordSessionTask, } from "#tasks/session-index.js"; import { deriveTaskId } from "#tasks/task-id.js"; +import type { SessionTaskIndexEntry } from "#tasks/session-index.js"; + +function taskEntry(overrides: Partial = {}): SessionTaskIndexEntry { + return { + childSessionId: "child-1", + commandToken: "task:token-1", + createdByTurnId: "turn-1", + operationId: "operation-1", + taskId: "task_a", + taskRunId: "run-1", + ...overrides, + }; +} function createSession(state?: HarnessSession["state"]): HarnessSession { return { @@ -31,14 +44,13 @@ describe("session task index", () => { }); it("records a task and finds it by id", () => { - const session = recordSessionTask(createSession(), { - commandToken: "task:token-1", - taskId: "task_a", - taskRunId: "run-1", - }); + const session = recordSessionTask(createSession(), taskEntry()); expect(findSessionTaskEntry(session.state, "task_a")).toEqual({ + childSessionId: "child-1", commandToken: "task:token-1", + createdByTurnId: "turn-1", + operationId: "operation-1", taskId: "task_a", taskRunId: "run-1", }); @@ -46,16 +58,14 @@ describe("session task index", () => { }); it("replaces the entry on replayed creation instead of duplicating it", () => { - let session = recordSessionTask(createSession(), { - commandToken: "task:token-1", - taskId: "task_a", - taskRunId: "run-1", - }); - session = recordSessionTask(session, { - commandToken: "task:token-2", - taskId: "task_a", - taskRunId: "run-2", - }); + let session = recordSessionTask(createSession(), taskEntry()); + session = recordSessionTask( + session, + taskEntry({ + commandToken: "task:token-2", + taskRunId: "run-2", + }), + ); const entries = getSessionTaskIndex(session.state); expect(entries).toHaveLength(1); diff --git a/packages/eve/src/tasks/session-index.ts b/packages/eve/src/tasks/session-index.ts index 8c3908aa7..c26f00c3b 100644 --- a/packages/eve/src/tasks/session-index.ts +++ b/packages/eve/src/tasks/session-index.ts @@ -22,13 +22,21 @@ export const SESSION_TASKS_STATE_KEY = "eve.tasks"; * `taskId` only, and lookup verifies ownership through this index. */ export interface SessionTaskIndexEntry { + readonly childSessionId: string; readonly taskId: string; readonly taskRunId: string; readonly commandToken: string; + readonly createdByTurnId: string; + readonly createdByStepIndex?: number; + readonly operationId: string; } const sessionTaskIndexEntrySchema: z.ZodType = z.strictObject({ + childSessionId: z.string().min(1), commandToken: z.string().min(1), + createdByTurnId: z.string().min(1), + createdByStepIndex: z.number().int().nonnegative().optional(), + operationId: z.string().min(1), taskId: z.string().min(1), taskRunId: z.string().min(1), }); diff --git a/packages/eve/src/tasks/task-id.ts b/packages/eve/src/tasks/task-id.ts index 51c776df1..daf04dec5 100644 --- a/packages/eve/src/tasks/task-id.ts +++ b/packages/eve/src/tasks/task-id.ts @@ -42,3 +42,9 @@ export function deriveTaskCommandToken(input: { .digest("hex") .slice(0, 32)}`; } + +/** Reads the non-secret task id embedded in a private task command token. */ +export function readTaskIdFromCommandToken(token: string): string | undefined { + const match = /^task:(task_[^:]+):[a-f0-9]{32}$/.exec(token); + return match?.[1]; +} diff --git a/packages/eve/src/tasks/transitions.test.ts b/packages/eve/src/tasks/transitions.test.ts index 865c3f256..515bd7bef 100644 --- a/packages/eve/src/tasks/transitions.test.ts +++ b/packages/eve/src/tasks/transitions.test.ts @@ -23,7 +23,7 @@ const ALL_COMMANDS: readonly TaskCommand[] = [ { data: { message: "boom" }, kind: "fail" }, { kind: "cancel" }, { inputRequests: [{ question: "which?" }], kind: "require-input" }, - { kind: "resume-working" }, + { kind: "answered", requestIds: ["req-1"] }, { childSessionId: "child-session-2", kind: "describe" }, ]; @@ -61,20 +61,55 @@ describe("applyTaskTransition", () => { expect(result.view.inputRequests).toEqual([{ question: "which region?" }]); }); - it("returns input_required to working and clears the batch", () => { + it("returns input_required to working once the whole batch is answered", () => { const blocked = applyTaskTransition(createView("working"), { - inputRequests: [{ question: "which region?" }], + inputRequests: [{ question: "which region?", requestId: "req-1" }], kind: "require-input", }); expect(blocked.outcome).toBe("accepted"); - const result = applyTaskTransition(blocked.view, { kind: "resume-working" }); + const result = applyTaskTransition(blocked.view, { kind: "answered", requestIds: ["req-1"] }); expect(result.outcome).toBe("accepted"); expect(result.view.status).toBe("working"); expect(result.view.inputRequests).toBeUndefined(); }); + it("keeps the task blocked on the remainder of a partly answered batch", () => { + const blocked = applyTaskTransition(createView("working"), { + inputRequests: [ + { question: "which region?", requestId: "req-1" }, + { question: "which size?", requestId: "req-2" }, + ], + kind: "require-input", + }); + expect(blocked.outcome).toBe("accepted"); + + const result = applyTaskTransition(blocked.view, { kind: "answered", requestIds: ["req-1"] }); + + expect(result.outcome).toBe("accepted"); + expect(result.view.status).toBe("input_required"); + expect(result.view.inputRequests).toEqual([{ question: "which size?", requestId: "req-2" }]); + }); + + it("ignores an answer to a batch that was already replaced", () => { + const first = applyTaskTransition(createView("working"), { + inputRequests: [{ question: "first", requestId: "req-1" }], + kind: "require-input", + }); + const second = applyTaskTransition(first.view, { + inputRequests: [{ question: "second", requestId: "req-2" }], + kind: "require-input", + }); + expect(second.outcome).toBe("accepted"); + + const stale = applyTaskTransition(second.view, { kind: "answered", requestIds: ["req-1"] }); + + expect(stale.outcome).toBe("noop"); + expect(stale.view.status).toBe("input_required"); + expect(stale.view.inputRequests).toEqual([{ question: "second", requestId: "req-2" }]); + }); + it("replaces the outstanding batch on repeated require-input", () => { const first = applyTaskTransition(createView("working"), { inputRequests: [{ question: "first" }], @@ -107,8 +142,11 @@ describe("applyTaskTransition", () => { expect(cancelled.view.status).toBe("cancelled"); }); - it("treats resume-working on a working task as a noop", () => { - const result = applyTaskTransition(createView("working"), { kind: "resume-working" }); + it("treats an answer to a working task as a noop", () => { + const result = applyTaskTransition(createView("working"), { + kind: "answered", + requestIds: ["req-1"], + }); expect(result.outcome).toBe("noop"); expect(result.view.status).toBe("working"); @@ -171,6 +209,18 @@ describe("applyTaskTransition", () => { expect(again.outcome).toBe("noop"); }); + it("never rebinds a task turn to a different child session", () => { + const result = applyTaskTransition(createView("working"), { + childSessionId: "other-child", + childTurnId: "turn_9", + kind: "start-turn", + taskId: "task_abc123", + }); + + expect(result.outcome).toBe("rejected"); + expect(result.view.metadata.childSessionId).toBe("child-session-1"); + }); + it("is deterministic for replayed commands", () => { const view = createView("working"); const command: TaskCommand = { data: { answer: 1 }, kind: "complete" }; diff --git a/packages/eve/src/tasks/transitions.ts b/packages/eve/src/tasks/transitions.ts index 7e9a70fda..070f534e8 100644 --- a/packages/eve/src/tasks/transitions.ts +++ b/packages/eve/src/tasks/transitions.ts @@ -1,12 +1,12 @@ import type { TaskCommand, TaskView } from "#tasks/types.js"; -import { isTerminalTaskStatus } from "#tasks/types.js"; +import { isTerminalTaskStatus, readTaskInputRequestId } from "#tasks/types.js"; /** * Outcome of applying one command to a task snapshot. * * - `accepted`: the state changed; the new view must be appended. * - `noop`: the command is recognized and benign (idempotent cancel, - * redundant resume); nothing changed and nothing is appended. + * stale answer); nothing changed and nothing is appended. * - `rejected`: the command is invalid for the current status; the * reason is diagnostic only. */ @@ -50,7 +50,10 @@ export function applyTaskTransition(view: TaskView, command: TaskCommand): TaskT return { outcome: "accepted", view: { - metadata: view.metadata, + metadata: + command.lifecycle === undefined + ? view.metadata + : { ...view.metadata, childLifecycle: command.lifecycle }, lastOutput: { data: command.data, type: "result" }, status: "completed", statusMessage: view.statusMessage, @@ -61,7 +64,10 @@ export function applyTaskTransition(view: TaskView, command: TaskCommand): TaskT return { outcome: "accepted", view: { - metadata: view.metadata, + metadata: + command.lifecycle === undefined + ? view.metadata + : { ...view.metadata, childLifecycle: command.lifecycle }, lastOutput: { data: command.data, type: "error" }, status: "failed", statusMessage: view.statusMessage, @@ -72,7 +78,10 @@ export function applyTaskTransition(view: TaskView, command: TaskCommand): TaskT return { outcome: "accepted", view: { - metadata: view.metadata, + metadata: + command.lifecycle === undefined + ? view.metadata + : { ...view.metadata, childLifecycle: command.lifecycle }, status: "cancelled", statusMessage: view.statusMessage, taskId: view.taskId, @@ -89,10 +98,36 @@ export function applyTaskTransition(view: TaskView, command: TaskCommand): TaskT taskId: view.taskId, }, }; - case "resume-working": { - if (view.status === "working") { + case "answered": { + if (view.status !== "input_required") { + return { outcome: "noop", view }; + } + + const answered = new Set(command.requestIds); + const outstanding = view.inputRequests ?? []; + const remaining = outstanding.filter((request) => { + const requestId = readTaskInputRequestId(request); + return requestId === undefined || !answered.has(requestId); + }); + // An answer that matches nothing outstanding is stale: the batch + // it was written against was already cleared or replaced. Leaving + // the current batch intact is what stops it from unblocking a + // question the human never saw. + if (remaining.length === outstanding.length) { return { outcome: "noop", view }; } + if (remaining.length > 0) { + return { + outcome: "accepted", + view: { + inputRequests: remaining, + metadata: view.metadata, + status: "input_required", + statusMessage: view.statusMessage, + taskId: view.taskId, + }, + }; + } return { outcome: "accepted", @@ -105,6 +140,17 @@ export function applyTaskTransition(view: TaskView, command: TaskCommand): TaskT }; } case "describe": { + if ( + view.metadata.childTurnId !== undefined && + view.metadata.childSessionId !== undefined && + view.metadata.childSessionId !== command.childSessionId + ) { + return { + outcome: "rejected", + reason: `Task child session "${command.childSessionId}" does not match the active turn owner.`, + view, + }; + } if (view.metadata.childSessionId === command.childSessionId) { return { outcome: "noop", view }; } @@ -117,5 +163,41 @@ export function applyTaskTransition(view: TaskView, command: TaskCommand): TaskT }, }; } + case "start-turn": { + if (command.taskId !== view.taskId) { + return { + outcome: "rejected", + reason: `Task turn identity "${command.taskId}" does not match "${view.taskId}".`, + view, + }; + } + if ( + view.metadata.childSessionId !== undefined && + view.metadata.childSessionId !== command.childSessionId + ) { + return { + outcome: "rejected", + reason: `Task child session "${command.childSessionId}" does not match "${view.metadata.childSessionId}".`, + view, + }; + } + if ( + view.metadata.childSessionId === command.childSessionId && + view.metadata.childTurnId === command.childTurnId + ) { + return { outcome: "noop", view }; + } + return { + outcome: "accepted", + view: { + ...view, + metadata: { + ...view.metadata, + childSessionId: command.childSessionId, + childTurnId: command.childTurnId, + }, + }, + }; + } } } diff --git a/packages/eve/src/tasks/types.ts b/packages/eve/src/tasks/types.ts index c2887ff80..2a218a6be 100644 --- a/packages/eve/src/tasks/types.ts +++ b/packages/eve/src/tasks/types.ts @@ -15,9 +15,8 @@ import type { JsonValue } from "#shared/json.js"; * Task lifecycle status. * * `completed`, `failed`, and `cancelled` are terminal and final. - * `input_required` is not terminal but is ready for parent action, so - * `task_await` returns for it — a parent must never deadlock while its - * child waits for input. + * `input_required` is not terminal but is ready for parent action; the + * child must wake its parent rather than deadlock while waiting for input. */ export type TaskStatus = "working" | "input_required" | "completed" | "failed" | "cancelled"; @@ -37,6 +36,10 @@ export interface TaskMetadata { readonly name: string; /** Child session acknowledged at dispatch. */ readonly childSessionId?: string; + /** Exact child turn executing this task; retained privately for guarded cancellation. */ + readonly childTurnId?: string; + /** Child engine verdict used to reconcile the persistent agent handle. */ + readonly childLifecycle?: "parked" | "terminal"; /** Remote children only: the child agent's base URL. */ readonly url?: string; } @@ -58,6 +61,32 @@ export type TaskOutput = */ export type TaskInputRequest = JsonValue; +/** + * One human answer to an outstanding request. Structural on purpose: it + * mirrors the input contract's `InputResponse` without importing its + * zod-backed module into a workflow-bundled file. + */ +export interface TaskInputResponse { + readonly optionId?: string; + readonly requestId: string; + readonly text?: string; +} + +/** + * Request id of the synthetic entry that blocks a task while its child + * waits for authorization. Giving the block an id keeps its release + * bound to the same entry, so completing authorization can never clear + * an unrelated request batch the child raised in the meantime. + */ +export const TASK_AUTHORIZATION_REQUEST_ID = "task:authorization"; + +/** Reads the `requestId` of one opaque outstanding request. */ +export function readTaskInputRequestId(request: TaskInputRequest): string | undefined { + if (request === null || typeof request !== "object" || Array.isArray(request)) return undefined; + const requestId = Reflect.get(request, "requestId"); + return typeof requestId === "string" ? requestId : undefined; +} + /** * Full durable task snapshot. The task run appends one per accepted * command; readers always observe a complete view, never a delta. @@ -78,12 +107,27 @@ export interface TaskView { /** Commands accepted by the durable task run's transition function. */ export type TaskCommand = - | { readonly kind: "complete"; readonly data: JsonValue } - | { readonly kind: "fail"; readonly data: JsonValue } - | { readonly kind: "cancel" } + | { + readonly kind: "complete"; + readonly data: JsonValue; + readonly lifecycle?: "parked" | "terminal"; + } + | { readonly kind: "fail"; readonly data: JsonValue; readonly lifecycle?: "parked" | "terminal" } + | { readonly kind: "cancel"; readonly lifecycle?: "parked" | "terminal" } | { readonly kind: "require-input"; readonly inputRequests: readonly TaskInputRequest[] } - | { readonly kind: "resume-working" } - | { readonly kind: "describe"; readonly childSessionId: string }; + /** + * Clears the listed requests from the outstanding batch. Bound to + * ids rather than unbound like the former `resume`, so an answer can + * only ever release the batch it was written against. + */ + | { readonly kind: "answered"; readonly requestIds: readonly string[] } + | { readonly kind: "describe"; readonly childSessionId: string } + | { + readonly kind: "start-turn"; + readonly childSessionId: string; + readonly childTurnId: string; + readonly taskId: string; + }; /** Hook payload envelope commanding a durable task run. */ export interface TaskCommandHookPayload { @@ -119,8 +163,24 @@ export interface TaskInboundChildResult { } export interface TaskInboundInputRequest { + readonly callId: string; + readonly childContinuationToken: string; + readonly childSessionId: string; readonly kind: "subagent-input-request"; - readonly event: { readonly requests: readonly TaskInputRequest[] }; + readonly event: { + readonly requests: readonly TaskInputRequest[]; + readonly sequence: number; + readonly stepIndex: number; + readonly turnId: string; + }; + readonly subagentName: string; +} + +export interface TaskInboundTurnStarted { + readonly childSessionId: string; + readonly childTurnId: string; + readonly kind: "task-child-turn-started"; + readonly taskId: string; } export interface TaskInboundAuthorizationEvent { @@ -128,12 +188,28 @@ export interface TaskInboundAuthorizationEvent { readonly event: { readonly type: "authorization.required" | "authorization.completed" }; } +/** + * Human answers routed to the task run rather than straight to the + * child. The run owns both the delivery and the state change, so the + * batch it clears is exactly the batch it forwarded — the parent can no + * longer unblock the child while the run still believes it is blocked. + */ +export interface TaskInboundAnswerInput { + readonly auth?: unknown; + readonly childContinuationToken: string; + readonly inputResponses: readonly TaskInputResponse[]; + readonly kind: "task-answer-input"; + readonly taskId: string; +} + /** Everything a task run's command hook may receive. */ export type TaskRunInboundPayload = | TaskCommandHookPayload | TaskInboundChildResult | TaskInboundInputRequest - | TaskInboundAuthorizationEvent; + | TaskInboundTurnStarted + | TaskInboundAuthorizationEvent + | TaskInboundAnswerInput; /** Namespaced run stream carrying `TaskView` snapshots. */ export const TASK_SNAPSHOT_STREAM_NAMESPACE = "eve.task"; @@ -143,7 +219,7 @@ export function isTerminalTaskStatus(status: TaskStatus): boolean { return status === "completed" || status === "failed" || status === "cancelled"; } -/** True when `task_await` should stop waiting on this status. */ +/** True when a transition into this status should wake the parent. */ export function isReadyTaskStatus(status: TaskStatus): boolean { return status === "input_required" || isTerminalTaskStatus(status); } diff --git a/packages/eve/src/tasks/wake-suppression.test.ts b/packages/eve/src/tasks/wake-suppression.test.ts deleted file mode 100644 index d8020c542..000000000 --- a/packages/eve/src/tasks/wake-suppression.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import type { DeliverPayload } from "#channel/types.js"; -import type { HarnessSession } from "#harness/types.js"; -import { - clearAwaitedTaskWakeSuppressions, - consumeAwaitedTaskWakes, - suppressAwaitedTaskWakes, -} from "#tasks/wake-suppression.js"; - -function createSession(): HarnessSession { - return { - agent: { modelReference: { id: "test-model" }, system: "", tools: [] }, - compaction: { recentWindowSize: 4, threshold: 1_000_000 }, - continuationToken: "continuation_parent", - history: [], - sessionId: "session_parent", - }; -} - -const taskWake: DeliverPayload = { - message: "Background task task_1 is completed.", - taskNotification: { status: "completed", taskId: "task_1" }, -}; - -describe("task wake suppression", () => { - it("drops the wake claimed by task_await", () => { - const session = suppressAwaitedTaskWakes(createSession(), ["task_1"]); - - const consumed = consumeAwaitedTaskWakes(session, [taskWake]); - - expect(consumed.payloads).toEqual([]); - }); - - it("keeps the wake after a cancelled await releases its claim", () => { - const session = clearAwaitedTaskWakeSuppressions( - suppressAwaitedTaskWakes(createSession(), ["task_1"]), - ); - - const consumed = consumeAwaitedTaskWakes(session, [taskWake]); - - expect(consumed.payloads).toEqual([taskWake]); - }); - - it("leaves unrelated deliveries and their later suppression intact", () => { - const session = suppressAwaitedTaskWakes(createSession(), ["task_1"]); - const unrelated = { message: "hello" }; - - const first = consumeAwaitedTaskWakes(session, [unrelated]); - const second = consumeAwaitedTaskWakes(first.session, [taskWake]); - - expect(first.payloads).toEqual([unrelated]); - expect(second.payloads).toEqual([]); - }); -}); diff --git a/packages/eve/src/tasks/wake-suppression.ts b/packages/eve/src/tasks/wake-suppression.ts deleted file mode 100644 index 30873e233..000000000 --- a/packages/eve/src/tasks/wake-suppression.ts +++ /dev/null @@ -1,91 +0,0 @@ -import type { DeliverPayload } from "#channel/types.js"; -import type { HarnessSession, SessionStateMap } from "#harness/types.js"; - -const TASK_WAKE_SUPPRESSIONS_STATE_KEY = "eve.tasks.wakeSuppressions"; - -interface TaskWakeSuppression { - readonly taskId: string; -} - -interface TaskWakeSuppressionStore { - readonly entries: readonly TaskWakeSuppression[]; -} - -/** Claims each task's next ready wake for the active `task_await`. */ -export function suppressAwaitedTaskWakes( - session: HarnessSession, - taskIds: readonly string[], -): HarnessSession { - const existing = readSuppressions(session.state); - return writeSuppressions(session, [...existing, ...taskIds.map((taskId) => ({ taskId }))]); -} - -/** - * Drops task wake payloads claimed by `task_await`. Every matching entry is - * consumed: one await suppresses only the ready transition it observes. - * Turn cancellation clears outstanding claims before the parent parks again. - */ -export function consumeAwaitedTaskWakes( - session: HarnessSession, - payloads: readonly DeliverPayload[], -): { readonly payloads: readonly DeliverPayload[]; readonly session: HarnessSession } { - const existing = readSuppressions(session.state); - if (existing.length === 0) return { payloads, session }; - - const consumedTaskIds = new Set(); - const kept = payloads.filter((payload) => { - const taskId = payload.taskNotification?.taskId; - if (taskId === undefined || !existing.some((entry) => entry.taskId === taskId)) return true; - consumedTaskIds.add(taskId); - return false; - }); - if (consumedTaskIds.size === 0) return { payloads, session }; - - return { - payloads: kept, - session: writeSuppressions( - session, - existing.filter((entry) => !consumedTaskIds.has(entry.taskId)), - ), - }; -} - -/** Releases claims from a cancelled task-await turn. */ -export function clearAwaitedTaskWakeSuppressions(session: HarnessSession): HarnessSession { - return writeSuppressions(session, []); -} - -function readSuppressions(state: SessionStateMap | undefined): readonly TaskWakeSuppression[] { - const raw = state?.[TASK_WAKE_SUPPRESSIONS_STATE_KEY]; - if (raw === undefined) return []; - if (raw === null || typeof raw !== "object" || !("entries" in raw)) { - throw new Error(`Corrupt task wake suppressions under "${TASK_WAKE_SUPPRESSIONS_STATE_KEY}".`); - } - const entries = raw.entries; - if (!Array.isArray(entries)) { - throw new Error(`Corrupt task wake suppressions under "${TASK_WAKE_SUPPRESSIONS_STATE_KEY}".`); - } - return entries.map((entry) => { - if ( - entry === null || - typeof entry !== "object" || - !("taskId" in entry) || - typeof entry.taskId !== "string" - ) { - throw new Error( - `Corrupt task wake suppressions under "${TASK_WAKE_SUPPRESSIONS_STATE_KEY}".`, - ); - } - return { taskId: entry.taskId }; - }); -} - -function writeSuppressions( - session: HarnessSession, - entries: readonly TaskWakeSuppression[], -): HarnessSession { - const state = { ...session.state }; - if (entries.length === 0) delete state[TASK_WAKE_SUPPRESSIONS_STATE_KEY]; - else state[TASK_WAKE_SUPPRESSIONS_STATE_KEY] = { entries } satisfies TaskWakeSuppressionStore; - return { ...session, state: Object.keys(state).length === 0 ? undefined : state }; -} diff --git a/packages/eve/src/tasks/wire.test.ts b/packages/eve/src/tasks/wire.test.ts index 55f6c8f4a..1c7359d1a 100644 --- a/packages/eve/src/tasks/wire.test.ts +++ b/packages/eve/src/tasks/wire.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; +import { TASK_AUTHORIZATION_REQUEST_ID } from "#tasks/types.js"; import { translateTaskInboundPayload } from "#tasks/wire.js"; const ZERO_USAGE = { cacheReadTokens: 0, cacheWriteTokens: 0, inputTokens: 0, outputTokens: 0 }; @@ -27,7 +28,7 @@ describe("translateTaskInboundPayload", () => { }, ], }), - ).toEqual({ data: "answer", kind: "complete" }); + ).toEqual({ data: "answer", kind: "complete", lifecycle: kind }); } }); @@ -46,7 +47,7 @@ describe("translateTaskInboundPayload", () => { }, ], }), - ).toEqual({ data: { message: "boom" }, kind: "fail" }); + ).toEqual({ data: { message: "boom" }, kind: "fail", lifecycle: "terminal" }); expect( translateTaskInboundPayload({ @@ -58,7 +59,7 @@ describe("translateTaskInboundPayload", () => { }, ], }), - ).toEqual({ kind: "cancel" }); + ).toEqual({ kind: "cancel", lifecycle: "terminal" }); }); it("falls back to isError when a result carries no outcome", () => { @@ -82,24 +83,47 @@ describe("translateTaskInboundPayload", () => { it("marks the task input_required on a forwarded HITL batch", () => { expect( translateTaskInboundPayload({ - event: { requests: [{ prompt: "Which region?" }] }, + callId: "call-1", + childContinuationToken: "child-token", + childSessionId: "child-session", + event: { + requests: [{ prompt: "Which region?" }], + sequence: 0, + stepIndex: 0, + turnId: "turn_0", + }, kind: "subagent-input-request", + subagentName: "research", }), ).toEqual({ inputRequests: [{ prompt: "Which region?" }], kind: "require-input" }); }); - it("blocks on authorization.required and resumes on authorization.completed", () => { + it("blocks authorization under a reserved id that only its completion clears", () => { expect( translateTaskInboundPayload({ event: { type: "authorization.required" }, kind: "subagent-authorization-event", }), - ).toEqual({ inputRequests: [{ blockedOn: "authorization" }], kind: "require-input" }); + ).toEqual({ + inputRequests: [{ blockedOn: "authorization", requestId: TASK_AUTHORIZATION_REQUEST_ID }], + kind: "require-input", + }); expect( translateTaskInboundPayload({ event: { type: "authorization.completed" }, kind: "subagent-authorization-event", }), - ).toEqual({ kind: "resume-working" }); + ).toEqual({ kind: "answered", requestIds: [TASK_AUTHORIZATION_REQUEST_ID] }); + }); + + it("leaves answered input to the run, which must deliver before recording it", () => { + expect( + translateTaskInboundPayload({ + childContinuationToken: "child-token", + inputResponses: [{ requestId: "req-1", text: "west" }], + kind: "task-answer-input", + taskId: "task-1", + }), + ).toBeUndefined(); }); }); diff --git a/packages/eve/src/tasks/wire.ts b/packages/eve/src/tasks/wire.ts index 4ab31594b..f02cdd912 100644 --- a/packages/eve/src/tasks/wire.ts +++ b/packages/eve/src/tasks/wire.ts @@ -1,4 +1,5 @@ import type { TaskCommand, TaskRunInboundPayload } from "#tasks/types.js"; +import { TASK_AUTHORIZATION_REQUEST_ID } from "#tasks/types.js"; /** * Translates one inbound hook payload into a lifecycle command. @@ -13,9 +14,14 @@ import type { TaskCommand, TaskRunInboundPayload } from "#tasks/types.js"; * - a forwarded HITL batch marks the task `input_required` with the * outstanding requests; * - `authorization.required` also blocks the task (the child cannot - * proceed without the parent's user), and `authorization.completed` - * returns it to `working`. Authorization payloads never enter the - * snapshot — only the fact that the child is blocked does. + * proceed without the parent's user) under a reserved request id, and + * `authorization.completed` clears exactly that id. Authorization + * payloads never enter the snapshot — only the fact that the child is + * blocked does. + * + * `task-answer-input` is deliberately absent: the run must forward the + * answers to the child before it may record them, so it builds that + * command itself rather than translating one here. * * Returns `undefined` for unrecognized payloads, which the run ignores. */ @@ -31,11 +37,11 @@ export function translateTaskInboundPayload( if (result.outcome !== undefined) { switch (result.outcome.result.kind) { case "succeeded": - return { data: result.output, kind: "complete" }; + return { data: result.output, kind: "complete", lifecycle: result.outcome.kind }; case "failed": - return { data: result.output, kind: "fail" }; + return { data: result.output, kind: "fail", lifecycle: result.outcome.kind }; case "cancelled": - return { kind: "cancel" }; + return { kind: "cancel", lifecycle: result.outcome.kind }; } } return result.isError === true @@ -44,10 +50,22 @@ export function translateTaskInboundPayload( } case "subagent-input-request": return { inputRequests: payload.event.requests, kind: "require-input" }; + case "task-child-turn-started": + return { + childSessionId: payload.childSessionId, + childTurnId: payload.childTurnId, + kind: "start-turn", + taskId: payload.taskId, + }; case "subagent-authorization-event": return payload.event.type === "authorization.required" - ? { inputRequests: [{ blockedOn: "authorization" }], kind: "require-input" } - : { kind: "resume-working" }; + ? { + inputRequests: [ + { blockedOn: "authorization", requestId: TASK_AUTHORIZATION_REQUEST_ID }, + ], + kind: "require-input", + } + : { kind: "answered", requestIds: [TASK_AUTHORIZATION_REQUEST_ID] }; default: return undefined; } diff --git a/research/subagents-as-tasks-implementation.md b/research/subagents-as-tasks-implementation.md index c2f9229b2..f6c6a1f75 100644 --- a/research/subagents-as-tasks-implementation.md +++ b/research/subagents-as-tasks-implementation.md @@ -37,7 +37,7 @@ Tasks must not build a second addressing mechanism. The task record composes wit - the **handle** identifies the reusable child session (agent-messaging owns it); - the **task** identifies one unit of work against that handle and adds what handles lack: a durable record that survives terminal settlement, `working` / `input_required` status, - progress messages, receipts instead of turn-blocking, and `task_await`. + progress messages, and receipts instead of turn-blocking. Task identity reuses the operation-id derivation, `hash(parentSessionId, parentTurnId, callId)`, so replayed creation for the same originating call yields the same task without new machinery. @@ -86,8 +86,8 @@ New `packages/eve/src/tasks/` module cluster: - `TaskStatus`, `TaskView`, `TaskMetadata`, `TaskOutput`, and a pure transition function enforcing the lifecycle rules (terminal is final; `working <-> input_required`). This stage - also settles the design doc's open API questions that block types: the discriminated - `task_send` input and how `input_required` exposes its outstanding `InputRequest[]`. + also settles how `input_required` exposes its outstanding `InputRequest[]`; `task_send` + accepts only a message follow-up for a terminal task. - The **durable task run**: a dedicated small workflow per task (precedent: the session-timeout run). It is the single writer for transitions, consumes commands over its own hook, and appends a full `TaskView` snapshot per accepted command. Competing completion, cancellation, @@ -102,20 +102,14 @@ lifecycle path. ### Stage 2 — task tools, undiscoverable -Register the parent tools (`task_peek`, `task_await`, `task_send`, `task_cancel`, `task_sleep`) +Register the parent tools (`task_peek`, `task_send`, `task_cancel`, `task_sleep`) as framework tools, filtered out of the tool set unless the flag is on, plus the child `task_message` tool advertised only when a task binding is present (the same capability-gating pattern as `ask_question`). - `task_sleep` reuses the existing durable turn-sleep request. -- `task_peek`, `task_cancel`, and `task_send` read the session task index and command the task - run; `task_send` resolves the child address through the agent handle store and the existing - continuation dispatch. -- `task_await` introduces the one new turn-workflow wait: a new optional wait arm on the durable - step result that subscribes to selected task runs and returns when every selected task is - terminal or `input_required` (already-ready tasks return immediately). This is the only piece - of stage 2 that touches the turn workflow, and it rides an optional field nothing produces - when the flag is off. +- `task_peek`, `task_cancel`, and `task_send` read the session task index; `task_send` resolves a + terminal task's child address through the agent handle store and starts a new task. Verification: unit tests per tool; a scenario test that the tools are absent from advertised tool sets and `/agent-info` when the flag is off. @@ -134,8 +128,8 @@ In the runtime-action dispatch step, add a delegated mode alongside the existing Step 4 is what keeps the parent turn moving and history provider-valid: the receipt is the one result the existing key-based batch matching consumes, so the turn continues without a second -result path. The eventual outcome reaches the model only through `task_await`, `task_peek`, or a -task notification. Nothing selects this mode yet. +result path. A task notification starts or nudges a parent turn, and the model can read additional +current state through `task_peek`. Nothing selects this mode yet. Verification: integration tests invoking the mode directly; replay tests proving the same originating call returns the same task and never dispatches twice. @@ -150,7 +144,7 @@ Carry the six flows over the task contract for local and remote children alike, | Terminal result or failure | `task.update` command to the task run, terminal snapshot | | Input request / approval | `task.update` with `input_required` plus the outstanding batch | | Authorization event | `task.authorization` through the task binding | -| Input response | `task_send`, routed via the handle address | +| Input response | Parent-session HITL proxy, routed directly to the blocked child | | Cancellation | `task_cancel`: commit `cancelled`, then propagate executor abort | | Progress | `task_message` from the child, recorded as latest `statusMessage` | @@ -160,9 +154,9 @@ Concretely: the parent turn hook; the existing adapter is untouched; - the remote callback route gains task payload kinds alongside the existing session and turn kinds; old payloads are unchanged and old deployments never receive the new kinds; -- routing policy: terminal and `input_required` snapshots wake a parked parent through the - session delivery path; progress updates task state and client-visible events only. During an - active turn, inbound task events wait for the next safe step boundary; +- routing policy: a local `input_required` snapshot commits task-owned proxy routes and emits the + exact request on the parent session; a fully routed response starts no parent model turn. + Terminal snapshots still wake the parent through the session delivery path; - parent-session finalization extends the existing end-of-session child termination to cooperatively cancel live tasks first. @@ -183,11 +177,14 @@ deliberately; they get their own plans if anything nontrivial surfaces. 2. **`task_send` to a busy child.** A send to a `working` task surfaces `AGENT_BUSY` as a tool error, matching handle-continuation semantics. Queuing on the task run is deferred; it is the reversible follow-up if busy errors prove noisy in practice. -3. **Failure taxonomy.** Child failure maps to the `failed` status, and as a consequence of +3. **One task per child session.** A child session owns at most one nonterminal task. Admission + rejects a second task in the same batch or a later turn; cancellation carries the recorded + child turn id, and queued task sends are unsupported. +4. **Failure taxonomy.** Child failure maps to the `failed` status, and as a consequence of that transition the task's output carries the error (`TaskOutput.error`). Failure is the state; the error output is its consequence. This intentionally diverges from MCP, which reserves `failed` for protocol-level errors. -4. **Progress is deferred.** The child-facing `task_message` tool and the progress flow are cut +5. **Progress is deferred.** The child-facing `task_message` tool and the progress flow are cut from the first implementation; the stage 4 table's progress row lands in a follow-up. The five remaining flows ship first. diff --git a/research/tools-as-tasks.md b/research/tools-as-tasks.md index cc4e9edfd..104fb711c 100644 --- a/research/tools-as-tasks.md +++ b/research/tools-as-tasks.md @@ -97,7 +97,7 @@ dispatch returns once the executor acknowledges the work; the task stays `workin later transition arrives over the task wire. `completed`, `failed`, and `cancelled` are terminal statuses. `input_required` is not terminal, -but it is ready for parent action. `task_await` must return for either condition so a parent never +but it is ready for parent action. Entering either condition wakes the parent so it never deadlocks while its child waits for input. ## Authoring contract @@ -121,25 +121,20 @@ The parent receives these framework-owned tools: interface TaskParentTools { task_cancel(input: { taskIds: string[] }): Promise>; task_peek(input: { taskIds: string[] }): Promise>; - task_send(input: { taskId: string; input: unknown }): Promise>; - task_await(input: { taskIds: string[] }): Promise>; + task_send(input: { taskId: string; message: string }): Promise>; task_sleep(input: { durationMs: number }): Promise>; } ``` -The exact `TaskToolResult` error shape and `task_send.input` union remain open. Before -implementation, `task_send.input` should become a discriminated eve-owned type that separates an -`InputResponse[]` batch from an arbitrary child message. +Human responses are not a model capability. Clients answer the ordinary `input.requested` event +on the parent session, and eve routes matching responses directly to the blocked child. The controls have distinct behavior: - `task_peek` reads current state without blocking and does not return credentials or routing handles. -- `task_await` durably pauses the current turn until every selected task is terminal or - `input_required`. An already-ready task returns immediately. -- `task_send` answers an `input_required` task or sends a follow-up message to the addressed child - session. A message sent after the prior task became terminal creates a new task bound to the - same child session; it never reopens the terminal task. +- `task_send` sends a follow-up message after the prior task became terminal, creating a new task + bound to the same child session. It never reopens a terminal task or answers HITL. - `task_cancel` requests cooperative cancellation. A committed terminal state is final, so a late child result cannot revive a cancelled task. - `task_sleep` durably pauses the current turn for paced checks. It does not poll or mutate a task. @@ -210,9 +205,9 @@ gets one receipt: } ``` -The eventual child result must not become a second result for that call. It reaches the model -through `task_await`, `task_peek`, or a later framework-authored task notification. This keeps -history append-only and leaves no dangling provider tool call. +The eventual child result must not become a second result for that call. A framework-authored +task notification starts or nudges a parent turn, and the model reads any additional current state +with `task_peek`. This keeps history append-only and leaves no dangling provider tool call. ## Task state and ownership @@ -297,8 +292,8 @@ type ParentInbound = An `input_required` transition carries the full outstanding request batch in its task snapshot. The parent emits those requests through the normal `input.requested` stream contract. Matching -responses route back through `task_send`. Authorization uses the same task binding but remains a -distinct event because it has different disclosure rules. +responses sent to the parent session route directly through its private child proxy. Authorization +uses the same task binding but remains a distinct event because it has different disclosure rules. The five flows that split across two transports today all converge on this one contract, for local and remote children alike: @@ -308,7 +303,7 @@ local and remote children alike: | Terminal result or failure | `task.update` with a terminal snapshot | | Input request, including approval | `task.update` with `input_required` and the outstanding batch | | Authorization event | `task.authorization` | -| Input response | `task_send` addressed to the owning task | +| Input response | Parent-session proxy addressed by the recorded request id | | Cancellation | `task_cancel`, committed then propagated to the executor | Progress is the sixth flow, new in this plan: `task.message` from the child's `task_message` @@ -331,12 +326,12 @@ sequenceDiagram H->>C: dispatch with TaskBinding C-->>H: acknowledge childSessionId H-->>M: task receipt - M->>H: continue turn or task_await + M->>H: continue turn C->>T: task.update, task.message, or authorization T->>H: full task snapshot H->>H: queue until safe boundary - H-->>M: await result or later task notification + H-->>M: task notification; task_peek if needed ``` ### Agent-to-agent dependency @@ -347,11 +342,11 @@ one record: - the task identifies the current unit of work; - the handle identifies the reusable child session; -- the task's private `TaskExecutorBinding` lets `task_send` address in-flight work; +- the task's private binding lets the parent session route HITL and guarded cancellation; - resuming that child creates a new task bound to the same handle. The A2A draft currently records a handle after the child's first result. That is too late for -`task_send` during `working` or `input_required`. Task dispatch must persist the private executor +direct HITL and cancellation during `working` or `input_required`. Task dispatch must persist the private executor binding as soon as the child acknowledges its session. The same acknowledgement may create or update the reusable agent handle. Both records may reuse the A2A inbox route, but routing tokens belong in one shared credential store rather than two independent session-addressing mechanisms. @@ -366,9 +361,8 @@ The current [MCP Tasks extension] provides the closest standard vocabulary: - full task snapshots in `notifications/tasks`; - terminal states that do not change. -eve's `task_peek`, `task_send`, and `task_cancel` map to those operations. `task_await` and -`task_sleep` are eve controls, not MCP methods. eve is not implementing the MCP wire protocol in -this work. +eve's `task_peek`, `task_send`, and `task_cancel` map to those operations. `task_sleep` is an eve +control, not an MCP method. eve is not implementing the MCP wire protocol in this work. One semantic difference is decided. MCP treats a tool-level `isError: true` result as `completed`; `failed` is reserved for JSON-RPC execution failure. eve diverges: child failure @@ -410,11 +404,13 @@ than MCP compatibility. - Local and remote subagents support the same six parent-child flows. - A child dispatched as a task can call `task_message`; the parent observes the durable latest message through `task_peek` without a model turn starting on its own. -- `task_await` returns on terminal status and `input_required`, including when the task was already - ready before the call. +- Terminal and `input_required` transitions wake the parent; the model can inspect all relevant + task views with `task_peek` and decide whether the available state is sufficient. - `task_peek` observes current state without waking or mutating the executor. -- `task_send` routes each response or message to the intended child session and cannot cross - parent-session ownership. +- Parent-session responses route directly to the intended local task child without a parent model + turn and cannot cross task or session ownership. `task_send` accepts terminal follow-up messages only. +- One child session owns at most one nonterminal task; repeated sends return `AGENT_BUSY` and do + not queue. - Cancellation is cooperative and idempotent. A late completion cannot overwrite `cancelled`. - Progress is durable latest state and a client-visible event, but does not create unbounded model history or unsolicited model turns. @@ -426,14 +422,10 @@ than MCP compatibility. ## Open questions -1. What is the exact discriminated input for `task_send`, including arbitrary messages and - `InputResponse[]` batches? -2. Should `TaskView` include a monotonic revision for notification deduplication, or can the task - run's stream index remain internal? -3. What retention and TTL apply to terminal records and unanswered `input_required` tasks? -4. What is the cross-deployment version negotiation for task callbacks during rolling deploys? -5. Which task events enter model context, and how are repeated progress messages coalesced? -6. How are child token usage and remaining parent budgets accounted after a background child +1. What retention and TTL apply to terminal records and unanswered `input_required` tasks? +2. What is the cross-deployment version negotiation for task callbacks during rolling deploys? +3. Which task events enter model context, and how are repeated progress messages coalesced? +4. How are child token usage and remaining parent budgets accounted after a background child completes on a later turn? Two former open questions are settled and recorded in the [delivery plan]: failure taxonomy From 6632fe69607b1fed7faee9dfdb46761253f1997a Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Thu, 6 Aug 2026 19:20:51 -0400 Subject: [PATCH 3/4] fix(eve): adopt concurrent create-once owners quietly Signed-off-by: Rui Conti --- packages/eve/src/channel/routes.ts | 6 ++- packages/eve/src/channel/send.test.ts | 44 +++++++++++++++++++ packages/eve/src/channel/send.ts | 9 +++- .../workflow-entry.integration.test.ts | 17 +------ .../eve/src/execution/workflow-entry.test.ts | 40 +++++------------ packages/eve/src/execution/workflow-entry.ts | 13 +++++- .../eve/src/execution/workflow-runtime.ts | 2 +- packages/eve/src/public/channels/eve.test.ts | 1 + packages/eve/src/public/channels/eve.ts | 1 + 9 files changed, 84 insertions(+), 49 deletions(-) diff --git a/packages/eve/src/channel/routes.ts b/packages/eve/src/channel/routes.ts index f853c9866..643bd280d 100644 --- a/packages/eve/src/channel/routes.ts +++ b/packages/eve/src/channel/routes.ts @@ -95,9 +95,11 @@ type BaseSendOptions = { continuationToken: string; /** * `"resume"` requires an active session and propagates a typed no-active-session - * error. `"resume-or-start"` preserves the default channel behavior. + * error. `"create-once"` adopts an existing or concurrently-created owner + * without delivering the duplicate input. `"resume-or-start"` preserves the + * default channel behavior. */ - intent?: "resume" | "resume-or-start"; + intent?: "create-once" | "resume" | "resume-or-start"; /** * The original (top-level) caller's auth for a newly started session, * becoming `session.auth.initiator`. Defaults to {@link auth} when omitted diff --git a/packages/eve/src/channel/send.test.ts b/packages/eve/src/channel/send.test.ts index 124a25084..f0a509435 100644 --- a/packages/eve/src/channel/send.test.ts +++ b/packages/eve/src/channel/send.test.ts @@ -144,6 +144,50 @@ describe("createSendFn", () => { expect(runtime.dispatchContinuation).toHaveBeenCalledTimes(2); }); + it("adopts a concurrent create-once winner without delivering duplicate input", async () => { + const runtime = createRuntime(); + vi.mocked(runtime.createSession).mockRejectedValue( + new RuntimeSessionOwnershipConflictError({ + continuationToken: "test:token", + ownerSessionId: "winner", + sessionId: "loser", + }), + ); + vi.mocked(runtime.resolveContinuation).mockResolvedValue(undefined); + + await expect( + createSendFn( + runtime, + ADAPTER, + "test", + )("hello", { + auth: null, + continuationToken: "token", + intent: "create-once", + }), + ).resolves.toMatchObject({ id: "winner" }); + expect(runtime.dispatchContinuation).not.toHaveBeenCalled(); + }); + + it("adopts an existing create-once owner without delivering duplicate input", async () => { + const runtime = createRuntime(); + vi.mocked(runtime.resolveContinuation).mockResolvedValue({ sessionId: "winner" }); + + await expect( + createSendFn( + runtime, + ADAPTER, + "test", + )("hello", { + auth: null, + continuationToken: "token", + intent: "create-once", + }), + ).resolves.toMatchObject({ id: "winner" }); + expect(runtime.dispatchContinuation).not.toHaveBeenCalled(); + expect(runtime.createSession).not.toHaveBeenCalled(); + }); + it("forwards the turn caller on the session command", async () => { const runtime = createRuntime({ sessionId: "existing-session-id", status: "accepted" }); const caller = { diff --git a/packages/eve/src/channel/send.ts b/packages/eve/src/channel/send.ts index c4cdfcb26..d20e3b454 100644 --- a/packages/eve/src/channel/send.ts +++ b/packages/eve/src/channel/send.ts @@ -55,7 +55,11 @@ export function createSendFn( : undefined; }; - const existing = await dispatch(); + const existingOwner = async (): Promise => { + const owner = await runtime.resolveContinuation(continuationToken); + return owner === undefined ? undefined : createSession(owner.sessionId, rawToken, runtime); + }; + const existing = intent === "create-once" ? await existingOwner() : await dispatch(); if (existing !== undefined) return existing; if (intent === "resume") throw new RuntimeNoActiveSessionError(continuationToken); @@ -91,6 +95,9 @@ export function createSendFn( return createSession(handle.sessionId, rawToken, runtime); } catch (error) { if (!isRuntimeSessionOwnershipConflictError(error)) throw error; + if (intent === "create-once") { + return createSession(error.ownerSessionId, rawToken, runtime); + } const winner = await dispatch(); if (winner !== undefined) return winner; throw error; diff --git a/packages/eve/src/execution/workflow-entry.integration.test.ts b/packages/eve/src/execution/workflow-entry.integration.test.ts index 75394dcfd..d04a39bf2 100644 --- a/packages/eve/src/execution/workflow-entry.integration.test.ts +++ b/packages/eve/src/execution/workflow-entry.integration.test.ts @@ -497,7 +497,7 @@ describe("workflowEntry integration", () => { }); }); - it("fails a competing continuation owner before its first turn", async () => { + it("exits a competing continuation owner before its first turn", async () => { const runtime = createTestRuntime({ agent: { name: "workflow-entry-hook-owner" } }); const continuationToken = "http:workflow-entry-hook-owner"; @@ -528,20 +528,8 @@ describe("workflowEntry integration", () => { }), }, ]); - const contenderStream = captureTurnEvents(contender); - try { - const contenderEvents = await contenderStream.nextTurn(); - - expect(contenderEvents.at(-1)?.type).toBe("session.failed"); - expect( - contenderEvents.some( - (event) => event.type === "message.completed" || event.type === "turn.started", - ), - ).toBe(false); - await expect(contender.returnValue).rejects.toThrow( - /Agent workflow failed\. Inspect the private session trace for details\./, - ); + await expect(contender.returnValue).resolves.toEqual({ output: "" }); await resumeHook(continuationToken, { kind: "send", @@ -558,7 +546,6 @@ describe("workflowEntry integration", () => { ), ).toBe(true); } finally { - contenderStream.dispose(); ownerStream.dispose(); await owner.cancel(); } diff --git a/packages/eve/src/execution/workflow-entry.test.ts b/packages/eve/src/execution/workflow-entry.test.ts index dca9d25a7..6f99e5b9c 100644 --- a/packages/eve/src/execution/workflow-entry.test.ts +++ b/packages/eve/src/execution/workflow-entry.test.ts @@ -64,6 +64,12 @@ vi.mock("./route-child-delivery.js", () => ({ })), })); +vi.mock("./tasks/wake-suppression-step.js", () => ({ + filterAwaitedTaskWakePayloadsStep: vi + .fn() + .mockImplementation(async ({ payloads, sessionState }) => ({ payloads, sessionState })), +})); + vi.mock("./delegated-parent-notification.js", () => ({ notifyDelegatedParentStep: vi.fn().mockResolvedValue(undefined), notifyTaskTurnStartedStep: vi.fn().mockResolvedValue(undefined), @@ -199,7 +205,7 @@ describe("workflowEntry", () => { expect(terminateChildSessionsStep).toHaveBeenCalledWith({ sessionState }); }); - it("fails a conflicting delivery hook before dispatching the first turn", async () => { + it("exits a conflicting initial continuation before dispatching the first turn", async () => { const sessionState = createBaseSessionState(); const dispose = vi.fn(); vi.mocked(createSessionStep).mockResolvedValue(createSessionStepResultForMock(sessionState)); @@ -219,25 +225,14 @@ describe("workflowEntry", () => { input: { message: "duplicate" }, serializedContext: createSerializedContext(), }), - ).rejects.toMatchObject({ - message: "Agent workflow failed. Inspect the private session trace for details.", - name: "EveWorkflowFailure", - }); + ).resolves.toEqual({ output: "" }); - expect(emitTerminalSessionFailureStep).toHaveBeenCalledWith( - expect.objectContaining({ - error: expect.objectContaining({ - conflictingRunId: "wrun_owner", - name: "HookConflictError", - token: "http:test", - }), - }), - ); + expect(emitTerminalSessionFailureStep).not.toHaveBeenCalled(); expect(dispatchTurnStep).not.toHaveBeenCalled(); expect(dispose).toHaveBeenCalledOnce(); }); - it("normalizes the getConflict fallback error before dispatching the first turn", async () => { + it("also exits when a legacy world reports the initial continuation conflict", async () => { const sessionState = createBaseSessionState(); const dispose = vi.fn(); const fallbackError = Object.assign(new Error("legacy hook conflict"), { @@ -263,20 +258,9 @@ describe("workflowEntry", () => { input: { message: "duplicate" }, serializedContext: createSerializedContext(), }), - ).rejects.toMatchObject({ - message: "Agent workflow failed. Inspect the private session trace for details.", - name: "EveWorkflowFailure", - }); + ).resolves.toEqual({ output: "" }); - expect(emitTerminalSessionFailureStep).toHaveBeenCalledWith( - expect.objectContaining({ - error: expect.objectContaining({ - message: 'Hook token "http:test" is already in use', - name: "HookConflictError", - token: "http:test", - }), - }), - ); + expect(emitTerminalSessionFailureStep).not.toHaveBeenCalled(); expect(dispatchTurnStep).not.toHaveBeenCalled(); expect(dispose).toHaveBeenCalledOnce(); }); diff --git a/packages/eve/src/execution/workflow-entry.ts b/packages/eve/src/execution/workflow-entry.ts index 361ebaed4..785992828 100644 --- a/packages/eve/src/execution/workflow-entry.ts +++ b/packages/eve/src/execution/workflow-entry.ts @@ -32,7 +32,7 @@ import { createSessionStep } from "#execution/create-session-step.js"; import { settleCancelledTurnStep } from "#execution/settle-cancelled-turn-step.js"; import { emitTerminalSessionFailureStep } from "#execution/terminal-session-failure-step.js"; import { fireSessionCallbackStep } from "#execution/session-callback-step.js"; -import { disposeHook } from "#execution/hook-ownership.js"; +import { disposeHook, isHookConflictError } from "#execution/hook-ownership.js"; import { createSessionCommandInbox } from "#execution/session-command-inbox.js"; import { activeTurnId } from "#harness/active-turn-id.js"; import { sessionCommandHookToken } from "#execution/session-command-token.js"; @@ -359,7 +359,16 @@ async function runDriverLoop(input: { try { if (input.sessionState.continuationToken) { - await commandInbox.rekeyContinuation(input.sessionState.continuationToken); + try { + await commandInbox.rekeyContinuation(input.sessionState.continuationToken); + } catch (error) { + // A concurrent create can start two candidate runs before either + // publishes the shared continuation alias. The runtime adopts the + // alias owner; the losing candidate must exit before its first turn + // instead of emitting a second session failure for the same create. + if (!isHookConflictError(error)) throw error; + return { kind: "result", result: { output: "" } }; + } } await sessionTimeout?.start(); diff --git a/packages/eve/src/execution/workflow-runtime.ts b/packages/eve/src/execution/workflow-runtime.ts index 140393cb5..b3d340059 100644 --- a/packages/eve/src/execution/workflow-runtime.ts +++ b/packages/eve/src/execution/workflow-runtime.ts @@ -179,7 +179,6 @@ export function createWorkflowRuntime(config: { throw error; } - await waitForOwnedCommandHook(sessionCommandHookToken(run.runId), run.runId); if (input.continuationToken) { const owner = await waitForCommandHookOwner(input.continuationToken); if (owner.runId !== run.runId) { @@ -190,6 +189,7 @@ export function createWorkflowRuntime(config: { }); } } + await waitForOwnedCommandHook(sessionCommandHookToken(run.runId), run.runId); let events: ReadableStream | undefined; const getEvents = () => { diff --git a/packages/eve/src/public/channels/eve.test.ts b/packages/eve/src/public/channels/eve.test.ts index 915be33bc..6d3ea53b5 100644 --- a/packages/eve/src/public/channels/eve.test.ts +++ b/packages/eve/src/public/channels/eve.test.ts @@ -835,6 +835,7 @@ describe("eveChannel — create session idempotency", () => { expect(response.status).toBe(202); const token = handler.send.mock.calls[0]?.[1]?.continuationToken; expect(token).toMatch(/^eve:op:[0-9a-f]{32}$/); + expect(handler.send.mock.calls[0]?.[1]?.intent).toBe("create-once"); expect(handler.resolveActiveSession).toHaveBeenCalledWith({ continuationToken: token }); }); diff --git a/packages/eve/src/public/channels/eve.ts b/packages/eve/src/public/channels/eve.ts index 4753a1f77..a585377f5 100644 --- a/packages/eve/src/public/channels/eve.ts +++ b/packages/eve/src/public/channels/eve.ts @@ -297,6 +297,7 @@ export function eveChannel(input: EveChannelInput): EveChannel { continuationToken: token, mode: body.mode, }; + if (operationToken !== undefined) sendOptions.intent = "create-once"; if (forwarded.accepted) { sendOptions.initiatorAuth = forwarded.initiatorAuth; } From b96e45cbabe3b0712d5817939789c9c25884efb7 Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Thu, 6 Aug 2026 20:09:38 -0400 Subject: [PATCH 4/4] fix(eve): retain background child usage on terminal task snapshots Signed-off-by: Rui Conti --- .changeset/task-usage-retention.md | 5 ++ packages/eve/src/tasks/json.ts | 2 + packages/eve/src/tasks/transitions.test.ts | 25 ++++++++ packages/eve/src/tasks/transitions.ts | 63 ++++++++++++--------- packages/eve/src/tasks/types.ts | 66 +++++++++++++++++++++- packages/eve/src/tasks/wire.test.ts | 51 ++++++++++++++++- packages/eve/src/tasks/wire.ts | 27 +++++++-- research/tools-as-tasks.md | 5 ++ 8 files changed, 207 insertions(+), 37 deletions(-) create mode 100644 .changeset/task-usage-retention.md diff --git a/.changeset/task-usage-retention.md b/.changeset/task-usage-retention.md new file mode 100644 index 000000000..50b66e112 --- /dev/null +++ b/.changeset/task-usage-retention.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Background task terminal snapshots now retain the child's reported token usage instead of dropping it at the task wire. Accounting is unchanged: background children get a best-effort budget capped at dispatch time, and aggregate spend across sequential dispatches is not yet reserved against the parent's session limits. diff --git a/packages/eve/src/tasks/json.ts b/packages/eve/src/tasks/json.ts index 86f769749..7d84312cc 100644 --- a/packages/eve/src/tasks/json.ts +++ b/packages/eve/src/tasks/json.ts @@ -34,6 +34,8 @@ export function taskViewToJson(view: TaskView): JsonObject { if (view.inputRequests !== undefined) { json.inputRequests = [...view.inputRequests]; } + // `view.usage` is deliberately not disclosed: it is internal retention + // for future budget accounting, not part of the model-visible contract. return json; } diff --git a/packages/eve/src/tasks/transitions.test.ts b/packages/eve/src/tasks/transitions.test.ts index 515bd7bef..bd5121d4f 100644 --- a/packages/eve/src/tasks/transitions.test.ts +++ b/packages/eve/src/tasks/transitions.test.ts @@ -39,6 +39,31 @@ describe("applyTaskTransition", () => { expect(result.view.lastOutput).toEqual({ data: { answer: 42 }, type: "result" }); }); + it("retains reported child usage on the terminal snapshot only", () => { + const usage = { cacheReadTokens: 1, cacheWriteTokens: 2, inputTokens: 300, outputTokens: 40 }; + for (const command of [ + { data: "done", kind: "complete", usage }, + { data: "boom", kind: "fail", usage }, + { kind: "cancel", usage }, + ] as const) { + const result = applyTaskTransition(createView("working"), command); + expect(result.outcome).toBe("accepted"); + expect(result.view.usage).toEqual(usage); + } + + const withoutUsage = applyTaskTransition(createView("working"), { + data: "done", + kind: "complete", + }); + expect(withoutUsage.view.usage).toBeUndefined(); + + const blocked = applyTaskTransition(createView("working"), { + inputRequests: [{ question: "which?", requestId: "req-1" }], + kind: "require-input", + }); + expect(blocked.view.usage).toBeUndefined(); + }); + it("fails a working task and carries the error as its output", () => { const result = applyTaskTransition(createView("working"), { data: { message: "boom" }, diff --git a/packages/eve/src/tasks/transitions.ts b/packages/eve/src/tasks/transitions.ts index 070f534e8..aab8291c5 100644 --- a/packages/eve/src/tasks/transitions.ts +++ b/packages/eve/src/tasks/transitions.ts @@ -1,4 +1,4 @@ -import type { TaskCommand, TaskView } from "#tasks/types.js"; +import type { TaskCommand, TaskOutput, TaskStatus, TaskUsage, TaskView } from "#tasks/types.js"; import { isTerminalTaskStatus, readTaskInputRequestId } from "#tasks/types.js"; /** @@ -32,6 +32,37 @@ export type TaskTransitionResult = * what serializes competing completion, cancellation, and input * transitions. */ +/** + * Builds the settled snapshot for one terminal command, carrying the + * child's lifecycle verdict and reported usage when present. Usage is + * retention-only: nothing folds it into parent budgets yet. + */ +function terminalView( + view: TaskView, + command: Extract, + settled: { readonly lastOutput?: TaskOutput; readonly status: TaskStatus }, +): TaskView { + const next: { + lastOutput?: TaskOutput; + metadata: TaskView["metadata"]; + status: TaskStatus; + statusMessage?: string; + taskId: string; + usage?: TaskUsage; + } = { + metadata: + command.lifecycle === undefined + ? view.metadata + : { ...view.metadata, childLifecycle: command.lifecycle }, + status: settled.status, + statusMessage: view.statusMessage, + taskId: view.taskId, + }; + if (settled.lastOutput !== undefined) next.lastOutput = settled.lastOutput; + if (command.usage !== undefined) next.usage = command.usage; + return next; +} + export function applyTaskTransition(view: TaskView, command: TaskCommand): TaskTransitionResult { if (isTerminalTaskStatus(view.status)) { if (command.kind === "cancel" && view.status === "cancelled") { @@ -49,43 +80,23 @@ export function applyTaskTransition(view: TaskView, command: TaskCommand): TaskT case "complete": return { outcome: "accepted", - view: { - metadata: - command.lifecycle === undefined - ? view.metadata - : { ...view.metadata, childLifecycle: command.lifecycle }, + view: terminalView(view, command, { lastOutput: { data: command.data, type: "result" }, status: "completed", - statusMessage: view.statusMessage, - taskId: view.taskId, - }, + }), }; case "fail": return { outcome: "accepted", - view: { - metadata: - command.lifecycle === undefined - ? view.metadata - : { ...view.metadata, childLifecycle: command.lifecycle }, + view: terminalView(view, command, { lastOutput: { data: command.data, type: "error" }, status: "failed", - statusMessage: view.statusMessage, - taskId: view.taskId, - }, + }), }; case "cancel": return { outcome: "accepted", - view: { - metadata: - command.lifecycle === undefined - ? view.metadata - : { ...view.metadata, childLifecycle: command.lifecycle }, - status: "cancelled", - statusMessage: view.statusMessage, - taskId: view.taskId, - }, + view: terminalView(view, command, { status: "cancelled" }), }; case "require-input": return { diff --git a/packages/eve/src/tasks/types.ts b/packages/eve/src/tasks/types.ts index 2a218a6be..87b81ba51 100644 --- a/packages/eve/src/tasks/types.ts +++ b/packages/eve/src/tasks/types.ts @@ -61,6 +61,45 @@ export type TaskOutput = */ export type TaskInputRequest = JsonValue; +/** + * Provider token usage a child turn reported. Structural on purpose: it + * mirrors `TokenUsage` (#shared/token-usage.js) without importing its + * zod-backed module into a workflow-bundled file. + */ +export interface TaskUsage { + readonly cacheReadTokens: number; + readonly cacheWriteTokens: number; + readonly inputTokens: number; + readonly outputTokens: number; +} + +/** + * Validates one wire-carried usage value into {@link TaskUsage}. + * Anything malformed is dropped rather than rejected: retention is + * best-effort and must never fail a lifecycle transition. + */ +export function readTaskUsage(value: unknown): TaskUsage | undefined { + if (value === null || typeof value !== "object" || Array.isArray(value)) return undefined; + const cacheReadTokens = readUsageAxis(value, "cacheReadTokens"); + const cacheWriteTokens = readUsageAxis(value, "cacheWriteTokens"); + const inputTokens = readUsageAxis(value, "inputTokens"); + const outputTokens = readUsageAxis(value, "outputTokens"); + if ( + cacheReadTokens === undefined || + cacheWriteTokens === undefined || + inputTokens === undefined || + outputTokens === undefined + ) { + return undefined; + } + return { cacheReadTokens, cacheWriteTokens, inputTokens, outputTokens }; +} + +function readUsageAxis(value: object, key: string): number | undefined { + const field = Reflect.get(value, key); + return typeof field === "number" && Number.isFinite(field) && field >= 0 ? field : undefined; +} + /** * One human answer to an outstanding request. Structural on purpose: it * mirrors the input contract's `InputResponse` without importing its @@ -103,6 +142,13 @@ export interface TaskView { readonly lastOutput?: TaskOutput; /** Outstanding requests; present exactly when `status` is `input_required`. */ readonly inputRequests?: readonly TaskInputRequest[]; + /** + * Provider usage the child reported at settlement; present when the + * terminal command carried it. Retained for later accounting only — + * budgets stay best-effort until a reservation model lands — and + * deliberately excluded from model-visible task views (tasks/json.ts). + */ + readonly usage?: TaskUsage; } /** Commands accepted by the durable task run's transition function. */ @@ -111,9 +157,19 @@ export type TaskCommand = readonly kind: "complete"; readonly data: JsonValue; readonly lifecycle?: "parked" | "terminal"; + readonly usage?: TaskUsage; + } + | { + readonly kind: "fail"; + readonly data: JsonValue; + readonly lifecycle?: "parked" | "terminal"; + readonly usage?: TaskUsage; + } + | { + readonly kind: "cancel"; + readonly lifecycle?: "parked" | "terminal"; + readonly usage?: TaskUsage; } - | { readonly kind: "fail"; readonly data: JsonValue; readonly lifecycle?: "parked" | "terminal" } - | { readonly kind: "cancel"; readonly lifecycle?: "parked" | "terminal" } | { readonly kind: "require-input"; readonly inputRequests: readonly TaskInputRequest[] } /** * Clears the listed requests from the outstanding batch. Bound to @@ -155,7 +211,11 @@ export interface TaskInboundChildResult { | { readonly kind: "succeeded"; readonly output: JsonValue } | { readonly error: JsonValue; readonly kind: "failed" } | { readonly kind: "cancelled" }; - /** Provider usage this turn added; accounting is deferred to a later stage. */ + /** + * Provider usage this turn added. Retained on the terminal task + * snapshot ({@link TaskView.usage}); folding it into parent + * budgets is deferred until a reservation model lands. + */ readonly usageDelta?: unknown; }; readonly output: JsonValue; diff --git a/packages/eve/src/tasks/wire.test.ts b/packages/eve/src/tasks/wire.test.ts index 1c7359d1a..b800f9417 100644 --- a/packages/eve/src/tasks/wire.test.ts +++ b/packages/eve/src/tasks/wire.test.ts @@ -4,6 +4,7 @@ import { TASK_AUTHORIZATION_REQUEST_ID } from "#tasks/types.js"; import { translateTaskInboundPayload } from "#tasks/wire.js"; const ZERO_USAGE = { cacheReadTokens: 0, cacheWriteTokens: 0, inputTokens: 0, outputTokens: 0 }; +const USAGE = { cacheReadTokens: 1, cacheWriteTokens: 2, inputTokens: 300, outputTokens: 40 }; describe("translateTaskInboundPayload", () => { it("passes explicit task commands through", () => { @@ -28,7 +29,46 @@ describe("translateTaskInboundPayload", () => { }, ], }), - ).toEqual({ data: "answer", kind: "complete", lifecycle: kind }); + ).toEqual({ data: "answer", kind: "complete", lifecycle: kind, usage: ZERO_USAGE }); + } + }); + + it("retains nonzero child usage on complete, fail, and cancel commands", () => { + const outcomes = [ + { + expected: { data: "done", kind: "complete" }, + result: { kind: "succeeded", output: "done" }, + }, + { expected: { data: "done", kind: "fail" }, result: { error: "boom", kind: "failed" } }, + { expected: { kind: "cancel" }, result: { kind: "cancelled" } }, + ] as const; + for (const { expected, result } of outcomes) { + expect( + translateTaskInboundPayload({ + kind: "runtime-action-result", + results: [{ outcome: { kind: "terminal", result, usageDelta: USAGE }, output: "done" }], + }), + ).toEqual({ ...expected, lifecycle: "terminal", usage: USAGE }); + } + }); + + it("drops malformed usage rather than failing the transition command", () => { + for (const usageDelta of [null, "n/a", { inputTokens: -1 }, { inputTokens: 1 }]) { + expect( + translateTaskInboundPayload({ + kind: "runtime-action-result", + results: [ + { + outcome: { + kind: "terminal", + result: { kind: "succeeded", output: "ok" }, + usageDelta, + }, + output: "ok", + }, + ], + }), + ).toEqual({ data: "ok", kind: "complete", lifecycle: "terminal" }); } }); @@ -47,7 +87,12 @@ describe("translateTaskInboundPayload", () => { }, ], }), - ).toEqual({ data: { message: "boom" }, kind: "fail", lifecycle: "terminal" }); + ).toEqual({ + data: { message: "boom" }, + kind: "fail", + lifecycle: "terminal", + usage: ZERO_USAGE, + }); expect( translateTaskInboundPayload({ @@ -59,7 +104,7 @@ describe("translateTaskInboundPayload", () => { }, ], }), - ).toEqual({ kind: "cancel", lifecycle: "terminal" }); + ).toEqual({ kind: "cancel", lifecycle: "terminal", usage: ZERO_USAGE }); }); it("falls back to isError when a result carries no outcome", () => { diff --git a/packages/eve/src/tasks/wire.ts b/packages/eve/src/tasks/wire.ts index f02cdd912..c8c2ed303 100644 --- a/packages/eve/src/tasks/wire.ts +++ b/packages/eve/src/tasks/wire.ts @@ -1,5 +1,5 @@ -import type { TaskCommand, TaskRunInboundPayload } from "#tasks/types.js"; -import { TASK_AUTHORIZATION_REQUEST_ID } from "#tasks/types.js"; +import type { TaskCommand, TaskRunInboundPayload, TaskUsage } from "#tasks/types.js"; +import { TASK_AUTHORIZATION_REQUEST_ID, readTaskUsage } from "#tasks/types.js"; /** * Translates one inbound hook payload into a lifecycle command. @@ -35,13 +35,22 @@ export function translateTaskInboundPayload( const result = payload.results[0]; if (result === undefined) return undefined; if (result.outcome !== undefined) { + // Usage retention: the settled outcome's `usageDelta` survives into + // the terminal command so the snapshot keeps the child's spend. + const usage = readTaskUsage(result.outcome.usageDelta); switch (result.outcome.result.kind) { case "succeeded": - return { data: result.output, kind: "complete", lifecycle: result.outcome.kind }; + return withUsage( + { data: result.output, kind: "complete", lifecycle: result.outcome.kind }, + usage, + ); case "failed": - return { data: result.output, kind: "fail", lifecycle: result.outcome.kind }; + return withUsage( + { data: result.output, kind: "fail", lifecycle: result.outcome.kind }, + usage, + ); case "cancelled": - return { kind: "cancel", lifecycle: result.outcome.kind }; + return withUsage({ kind: "cancel", lifecycle: result.outcome.kind }, usage); } } return result.isError === true @@ -70,3 +79,11 @@ export function translateTaskInboundPayload( return undefined; } } + +function withUsage( + command: Extract, + usage: TaskUsage | undefined, +): TaskCommand { + if (usage === undefined) return command; + return { ...command, usage }; +} diff --git a/research/tools-as-tasks.md b/research/tools-as-tasks.md index 104fb711c..793370148 100644 --- a/research/tools-as-tasks.md +++ b/research/tools-as-tasks.md @@ -419,6 +419,11 @@ than MCP compatibility. - Completing the parent session cancels its live tasks. - Resuming an existing child session creates a new task with a new task ID and the same `childSessionId`. +- Background budgets are best-effort: each local child is capped from the parent's remainder at + its dispatch time, but no reservation couples sequential dispatches, so aggregate grants can + exceed the parent's remaining session limits. The child's reported usage is retained on the + terminal task snapshot (internal, not model-visible) so strict accounting can land later + without data loss. ## Open questions