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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/remote-create-operation-id.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/task-usage-retention.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion .changeset/tasks-experimental-subagents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
8 changes: 8 additions & 0 deletions docs/channels/eve.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ curl -X POST https://<deployment>/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://<deployment>/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
Expand Down
2 changes: 2 additions & 0 deletions docs/tools/human-in-the-loop.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 4 additions & 2 deletions packages/eve/src/channel/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions packages/eve/src/channel/send.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
9 changes: 8 additions & 1 deletion packages/eve/src/channel/send.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,11 @@ export function createSendFn<TState = undefined>(
: undefined;
};

const existing = await dispatch();
const existingOwner = async (): Promise<Session | undefined> => {
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);

Expand Down Expand Up @@ -91,6 +95,9 @@ export function createSendFn<TState = undefined>(
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;
Expand Down
1 change: 1 addition & 0 deletions packages/eve/src/channel/session-callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
})
Expand Down
15 changes: 14 additions & 1 deletion packages/eve/src/channel/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -151,6 +153,8 @@ export type EventEmitFn = (event: UnstampedMessageStreamEvent) => Promise<void>;
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 };
Expand All @@ -173,6 +177,11 @@ export interface DeliverPayload {
readonly message?: string | UserContent;
readonly context?: readonly string[];
readonly outputSchema?: JsonObject;
/** Framework-only task HITL envelopes consumed before adapter/model delivery. */
readonly taskInputRequests?: readonly {
readonly hookPayload: SubagentInputRequestHookPayload;
readonly taskId: string;
}[];
readonly [key: string]: unknown;
}

Expand All @@ -184,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 };
Expand Down Expand Up @@ -235,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[];
}
Expand Down Expand Up @@ -339,6 +351,7 @@ export type HookPayload =
export interface SessionCallback {
readonly callId: string;
readonly subagentName: string;
readonly taskId?: string;
readonly token: string;
readonly url: string;
}
Expand Down
1 change: 0 additions & 1 deletion packages/eve/src/cli/dev/tui/tool-presentation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
8 changes: 0 additions & 8 deletions packages/eve/src/cli/dev/tui/tool-presentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,14 +123,6 @@ const BUILTIN_TOOL_COPY: Readonly<Record<string, BuiltinToolCopy>> = {
singularNoun: "file",
pluralNoun: "files",
},
task_await: {
verb: "Await",
pastVerb: "Awaited",
argKey: "taskIds",
extractItem: taskIdsArg,
singularNoun: "task",
pluralNoun: "tasks",
},
task_cancel: {
verb: "Cancel",
pastVerb: "Cancelled",
Expand Down
3 changes: 3 additions & 0 deletions packages/eve/src/execution/agent-handle-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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: {
Expand Down
41 changes: 41 additions & 0 deletions packages/eve/src/execution/delegated-parent-notification.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 });
Expand Down
40 changes: 40 additions & 0 deletions packages/eve/src/execution/delegated-parent-notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

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

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

Expand Down
Loading