diff --git a/.changeset/direct-agent-routing.md b/.changeset/direct-agent-routing.md new file mode 100644 index 0000000000..ad00dc37ae --- /dev/null +++ b/.changeset/direct-agent-routing.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Add direct static-descendant invocation across the eve HTTP API, TypeScript client, fixed sessions, and channel sends. Session creation can select a descendant default, while existing sessions can route one turn without losing shared history, channel context, or sandbox state. diff --git a/docs/channels/custom.mdx b/docs/channels/custom.mdx index b66e8fe3ab..4177e49471 100644 --- a/docs/channels/custom.mdx +++ b/docs/channels/custom.mdx @@ -147,6 +147,51 @@ Attaching does no lookup. The first operation reports whether the ID is active. Call `resolveSession(address)` only when you explicitly need to snapshot an address's current owner as a fixed handle. +## Dispatch to a declared subagent + +Set `agent` on a channel message when the platform already identifies the declared specialist that should handle it. This supports slash commands without creating a delegated child session: + +```ts title="agent/channels/support.ts" +import { defineChannel, POST } from "eve/channels"; + +export default defineChannel({ + routes: [ + POST("/threads/:threadId/commands", async (request, { from, params }) => { + const body = await request.json(); + const [, agent, message] = body.text.match(/^\/(\S+)\s+(.+)$/) ?? []; + + if (!agent || !message) { + return new Response("Expected / ", { status: 400 }); + } + + const session = await from(params.threadId).send(message, { + agent, + auth: null, + }); + return Response.json({ sessionId: session.id }, { status: 202 }); + }), + ], +}); +``` + +For an unowned channel address, the selected descendant becomes the new session's default. For an existing address, `agent` overrides only that turn; the next unqualified message returns to the session default. The same option is available on fixed `Session.send(...)` and cross-channel sends: + +```ts +await attachSession(sessionId).send("Investigate this turn.", { + agent: "researcher", + auth, +}); + +await ctx.to(slack, { channelId }).send("Investigate this incident.", { + agent: "researcher/critic", + auth, +}); +``` + +Cross-channel `receive(input, { from })` hooks inherit `input.agent` when they call `from(...).send(...)`, so a receive adapter does not need to forward the selector manually. The selected turn's ordinary events reach the channel's existing event handlers with the same session and continuation context. Direct selection does not emit synthetic `subagent.called` or `subagent.completed` events; nested delegation still emits its normal lifecycle events. + +Agent paths are root-relative and must resolve entirely through statically declared local descendants. Resolution failures throw `AgentTargetError` with an actionable `code`; dynamic, remote, malformed, and missing targets are not accepted. Do not set `agent` on an input-response delivery because HITL and authorization callbacks automatically resume the requesting agent. + ## Operation semantics - `cancel` cooperatively stops the active turn. Confirm it with `turn.cancelled` diff --git a/docs/channels/eve.mdx b/docs/channels/eve.mdx index dd67da68ed..936e63085c 100644 --- a/docs/channels/eve.mdx +++ b/docs/channels/eve.mdx @@ -47,12 +47,34 @@ curl -X POST https:///eve/v1/session \ # {"ok":true,"sessionId":"wrun_A","status":"accepted"} ``` +Set `agent` to start the session with a statically declared local descendant as its default agent. Paths are root-relative and may address nested descendants: + +```bash +curl -X POST https:///eve/v1/session \ + -H "Content-Type: application/json" \ + -d '{"agent":"researcher/critic","message":"Review this evidence."}' +``` + +Every later message without `agent` continues as `researcher/critic`. To select a descendant for only one turn in an existing session, include `agent` on that message: + +```bash +curl -X POST https:///eve/v1/session/wrun_A \ + -H "Content-Type: application/json" \ + -d '{"agent":"researcher","message":"Investigate this turn."}' +``` + +The override keeps the same session ID, continuation address, auth, channel state, and model history. The descendant sees prior history, and the session default sees the descendant's assistant and tool history on the next unqualified turn. The selected turn uses the descendant's own model, instructions, tools, skills, hooks, connections, sandbox, and nested subagents. + +Only entirely static paths through local descendants are directly invocable. Malformed paths and dynamic or remote targets return `400`; missing targets return `404`. Extension namespaces remain part of the path, such as `crm__reviewer/auditor`. The route's existing auth policy covers every target. + Authenticated callers that may retry a create request can pass their own `operationId` for create-once semantics. The same operation under the same authenticated principal returns the active session it already created instead of dispatching the input again. The first accepted payload wins; retries with different input still return that first session. Anonymous callers cannot use `operationId`, and operation ownership expires when the session is no longer resumable. +For targeted creates, the normalized `agent` path is part of the operation identity. Reusing one `operationId` for the root and for `researcher` creates distinct sessions. + ```bash curl -X POST https:///eve/v1/session \ -H "Authorization: Bearer " \ @@ -62,7 +84,7 @@ curl -X POST https:///eve/v1/session \ ``` The first request requires `message`. A follow-up request accepts exactly one of -`message` or `inputResponses`; use the latter to answer a pending HITL request: +`message` or `inputResponses`; use the latter to answer a pending HITL request. Do not send `agent` with `inputResponses`: eve automatically resumes the agent that requested the input. ```bash curl -X POST https:///eve/v1/session/wrun_A \ @@ -163,7 +185,7 @@ export default eveChannel({ const callerId = ctx.eve.caller?.principalId ?? "anonymous"; return { auth: defaultEveAuth(ctx), - context: [`HTTP caller ${callerId} sent: ${message}`], + context: [`HTTP caller ${callerId} sent to ${ctx.eve.agent ?? "root"}: ${message}`], }; }, events: { @@ -176,7 +198,7 @@ export default eveChannel({ }); ``` -`onMessage` must return an auth result. Return `title` alongside `auth` to set the title when the dispatch starts a run. A successful canonical eve HTTP message always dispatches and therefore always produces or continues a session. +`ctx.eve.agent` is the normalized requested path for targeted creates and existing-session messages, and is `undefined` for unqualified messages. `onMessage` must return an auth result. Return `title` alongside `auth` to set the title when the dispatch starts a run. A successful canonical eve HTTP message always dispatches and therefore always produces or continues a session. ## Clients diff --git a/docs/guides/client/messages.mdx b/docs/guides/client/messages.mdx index 5cc804ec75..4a5ca82b78 100644 --- a/docs/guides/client/messages.mdx +++ b/docs/guides/client/messages.mdx @@ -55,6 +55,34 @@ await response.result(); `clientContext` is one-turn context for the next model call. Strings become user-role context messages, arrays of strings become multiple context messages, and objects are JSON-serialized into one context message. It isn't persisted to durable session history and doesn't dispatch a turn by itself. +## Send a turn to a declared subagent + +Set `agent` on `create()` when a statically declared local descendant should own the session. Later unqualified messages keep that default: + +```ts +const { session, response } = await client.sessions.create({ + agent: "researcher", + message: "Investigate this report.", +}); + +await response.result(); +await session.send("Check another source."); // runs as researcher +``` + +Set `agent` on `send()` for a one-turn override. The next unqualified message returns to the session default while retaining the selected turn's assistant and tool history: + +```ts +await session.send("Audit the evidence.", { + agent: "researcher/critic", +}); + +await session.send("Summarize the result."); // runs as researcher +``` + +Paths are root-relative runtime names. Nested descendants use `/`, and mounted extension names keep their namespace, such as `crm__reviewer/auditor`. Only entirely static local paths are supported; malformed, dynamic, remote, and missing targets reject the request before the turn starts. + +Do not pass `agent` with `inputResponses`. `session.respond()` automatically resumes the agent that requested the input. + ## Send attachments `send()` accepts AI SDK `UserContent`, so a message can mix text and file parts: diff --git a/docs/subagents/index.mdx b/docs/subagents/index.mdx index 1ce7a710ae..ebd5c52afb 100644 --- a/docs/subagents/index.mdx +++ b/docs/subagents/index.mdx @@ -50,6 +50,39 @@ export default defineAgent({ A mounted extension can also contribute declared subagents from `extension/subagents/`. The mount namespace prefixes the subagent visible to the consuming agent node: mounting an extension as `crm` exposes its `reviewer` subagent as `crm__reviewer`. The contributed subagent keeps its own isolated tools, connections, skills, hooks, instructions, sandbox, and nested subagents, and its modules can read configuration from the extension handle. See [Extensions](./extensions#add-a-subagent) for the authoring and override behavior. +### Invoke a declared subagent directly + +Use the root-relative `agent` selector when an application or channel already knows which declared specialist should handle a message. A targeted session starts with that subagent as its default: + +```ts +import { Client } from "eve/client"; + +const client = new Client({ host: "https://" }); +const { session, response } = await client.sessions.create({ + agent: "researcher", + message: "Investigate this report.", +}); + +await response.result(); +await session.send("Check one more source."); // researcher remains the default +``` + +Set `agent` on one `session.send()` call to override only that turn. The selected subagent sees the session's existing model history, and its assistant and tool history remains available when the next unqualified message returns to the session default: + +```ts +await session.send("Audit the evidence.", { + agent: "researcher/critic", +}); + +await session.send("Summarize the result."); // returns to researcher +``` + +The path follows the static subagent directory tree. Nested descendants use `/`, and extension mounts keep their runtime-visible namespace, such as `crm__reviewer/auditor`. Every segment must resolve to a statically declared local subagent. Dynamic, remote, malformed, and missing targets fail before eve accepts the turn. + +Direct invocation runs the selected subagent's model, instructions, tools, skills, hooks, connections, sandbox, and nested subagents inside the existing session. It does not create a delegated child session or emit synthetic `subagent.called` and `subagent.completed` events. A one-turn override also does not replay the target's `initialMessages`; those apply when the targeted subagent owns a newly created session. + +Route or channel authentication remains the access boundary for the full static descendant tree. Put approvals on sensitive tools and connections; direct invocation does not add a separate subagent allowlist. See the [eve HTTP channel](./channels/eve#start-and-continue-a-session) and [custom channel direct dispatch](./channels/custom#dispatch-to-a-declared-subagent) for the other public surfaces. + ### Conditional availability To expose a declared subagent only for certain sessions or turns, export diff --git a/e2e/fixtures/agent-subagents/agent/channels/direct.ts b/e2e/fixtures/agent-subagents/agent/channels/direct.ts new file mode 100644 index 0000000000..9a38abf6a6 --- /dev/null +++ b/e2e/fixtures/agent-subagents/agent/channels/direct.ts @@ -0,0 +1,31 @@ +import { defineChannel, POST } from "eve/channels"; + +export default defineChannel({ + routes: [ + POST("/direct-agent", async (request, { from }) => { + const body = (await request.json()) as { + agent: string; + message: string; + threadId: string; + }; + const session = await from(body.threadId).send(body.message, { + agent: body.agent, + auth: null, + }); + return Response.json( + { ok: true, sessionId: session.id, status: "accepted" }, + { status: 202 }, + ); + }), + POST("/direct-agent/owner", async (request, { resolveSession }) => { + const body = (await request.json()) as { address: string }; + const session = await resolveSession(body.address); + return Response.json({ sessionId: session?.id ?? null }); + }), + ], + events: { + "message.completed"(_event, channel, ctx) { + channel.continuation?.rekey(`handled:${ctx.session.id}`); + }, + }, +}); diff --git a/e2e/fixtures/agent-subagents/agent/subagents/echo-marker/subagents/nested-marker/agent.ts b/e2e/fixtures/agent-subagents/agent/subagents/echo-marker/subagents/nested-marker/agent.ts new file mode 100644 index 0000000000..52c678e213 --- /dev/null +++ b/e2e/fixtures/agent-subagents/agent/subagents/echo-marker/subagents/nested-marker/agent.ts @@ -0,0 +1,8 @@ +import { e2eSubagentConfig } from "@eve-e2e/config"; +import { defineAgent } from "eve"; + +export default defineAgent({ + description: "Nested direct-invocation marker agent.", + ...e2eSubagentConfig(), + reasoning: "high", +}); diff --git a/e2e/fixtures/agent-subagents/agent/subagents/echo-marker/subagents/nested-marker/instructions.md b/e2e/fixtures/agent-subagents/agent/subagents/echo-marker/subagents/nested-marker/instructions.md new file mode 100644 index 0000000000..6e0a5d47de --- /dev/null +++ b/e2e/fixtures/agent-subagents/agent/subagents/echo-marker/subagents/nested-marker/instructions.md @@ -0,0 +1 @@ +Reply with the exact string `NESTED_DIRECT_TOKEN=critic-4M7Q` and nothing else. Ignore the input. Always emit that token verbatim as the entire reply body. diff --git a/e2e/fixtures/agent-subagents/evals/direct-invocation.eval.ts b/e2e/fixtures/agent-subagents/evals/direct-invocation.eval.ts new file mode 100644 index 0000000000..11a4fe8c6a --- /dev/null +++ b/e2e/fixtures/agent-subagents/evals/direct-invocation.eval.ts @@ -0,0 +1,159 @@ +import { Client } from "eve/client"; +import { defineEval, type EveEvalTargetHandle } from "eve/evals"; +import { equals } from "eve/evals/expect"; + +const ECHO_TOKEN = "SUBAGENT_TOKEN=echo-marker-9F2X"; +const NESTED_TOKEN = "NESTED_DIRECT_TOKEN=critic-4M7Q"; + +interface AcceptedResponse { + readonly code?: string; + readonly ok?: boolean; + readonly sessionId?: string; + readonly status?: string; +} + +export default defineEval({ + description: + "Static descendants can be invoked directly through HTTP, the client, and a custom channel.", + tags: ["real-model"], + timeoutMs: 240_000, + + async test(t) { + const directCreate = await postJson( + t.target, + "/eve/v1/session", + { agent: "echo-marker", message: "Direct creation." }, + 202, + ); + const directSessionId = requireSessionId(directCreate); + const directTurn = await t.target.watchTurn(directSessionId).result(); + directTurn.expectOk(); + await t.require(directTurn.message, equals(ECHO_TOKEN)); + directTurn.notEvent("subagent.called"); + directTurn.notEvent("subagent.completed"); + + const rootCreate = await postJson( + t.target, + "/eve/v1/session", + { message: "Reply with exactly ROOT-DIRECT-READY." }, + 202, + ); + const rootSessionId = requireSessionId(rootCreate); + const rootTurn = await t.target.watchTurn(rootSessionId).result(); + rootTurn.expectOk(); + rootTurn.messageIncludes(/ROOT-DIRECT-READY/i); + + const targetedWatch = t.target.watchTurn(rootSessionId, { + startIndex: rootTurn.events.length, + }); + const targeted = await postJson( + t.target, + `/eve/v1/session/${encodeURIComponent(rootSessionId)}`, + { agent: "echo-marker", message: "Run this turn directly." }, + 202, + ); + await t.require(targeted.sessionId, equals(rootSessionId)); + const targetedTurn = await targetedWatch.result(); + targetedTurn.expectOk(); + await t.require(targetedTurn.message, equals(ECHO_TOKEN)); + + const client = new Client({ host: t.target.url }); + const clientCreate = await client.sessions.create({ + agent: "echo-marker", + message: "Create through the TypeScript client.", + }); + const clientDefault = await clientCreate.response.result(); + await t.require(clientDefault.message, equals(ECHO_TOKEN)); + const nested = await clientCreate.session + .send("Invoke the nested marker for one turn.", { + agent: "echo-marker/nested-marker", + }) + .then((response) => response.result()); + await t.require(nested.message, equals(NESTED_TOKEN)); + await t.require(nested.sessionId, equals(clientDefault.sessionId)); + const returnedToDefault = await clientCreate.session + .send("Return to the direct session default.") + .then((response) => response.result()); + await t.require(returnedToDefault.message, equals(ECHO_TOKEN)); + + const threadId = crypto.randomUUID(); + const channelCreate = await postJson( + t.target, + "/direct-agent", + { agent: "echo-marker", message: "Dispatch from a slash command.", threadId }, + 202, + ); + const channelSessionId = requireSessionId(channelCreate); + const channelTurn = await t.target.watchTurn(channelSessionId).result(); + channelTurn.expectOk(); + await t.require(channelTurn.message, equals(ECHO_TOKEN)); + await waitForOwner(t.target, `handled:${channelSessionId}`, channelSessionId); + + await expectRejection(t.target, "/echo-marker", 400, "invalid_agent_path"); + await expectRejection(t.target, "missing", 404, "agent_not_found"); + await expectRejection(t.target, "conditional-marker", 400, "agent_not_directly_invocable"); + await expectRejection(t.target, "remote-loopback", 400, "agent_not_directly_invocable"); + }, +}); + +async function postJson( + target: EveEvalTargetHandle, + path: string, + body: unknown, + expectedStatus: number, +): Promise { + const response = await target.fetch(path, { + body: JSON.stringify(body), + headers: { "content-type": "application/json" }, + method: "POST", + }); + const text = await response.text(); + if (response.status !== expectedStatus) { + throw new Error( + `POST ${path} returned ${response.status}, expected ${expectedStatus}: ${text}`, + ); + } + return JSON.parse(text) as T; +} + +function requireSessionId(response: AcceptedResponse): string { + if (response.ok !== true || response.status !== "accepted" || response.sessionId === undefined) { + throw new Error(`Expected an accepted session response, received ${JSON.stringify(response)}.`); + } + return response.sessionId; +} + +async function expectRejection( + target: EveEvalTargetHandle, + agent: string, + status: number, + code: string, +): Promise { + const response = await postJson( + target, + "/eve/v1/session", + { agent, message: "Reject this direct invocation." }, + status, + ); + if (response.ok !== false || response.code !== code) { + throw new Error(`Expected ${code}, received ${JSON.stringify(response)}.`); + } +} + +async function waitForOwner( + target: EveEvalTargetHandle, + address: string, + expectedSessionId: string, +): Promise { + for (let attempt = 0; attempt < 50; attempt += 1) { + const owner = await postJson<{ sessionId: string | null }>( + target, + "/direct-agent/owner", + { address }, + 200, + ); + if (owner.sessionId === expectedSessionId) return; + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`Channel event handler did not rekey ${expectedSessionId}.`); +} diff --git a/packages/eve/extension-contracts/compatibility/channel/v9.ts b/packages/eve/extension-contracts/compatibility/channel/v9.ts new file mode 100644 index 0000000000..f9d1a08722 --- /dev/null +++ b/packages/eve/extension-contracts/compatibility/channel/v9.ts @@ -0,0 +1,20 @@ +import { defineChannel, POST } from "#public/channels/index.js"; + +export default defineChannel({ + state: { threadId: null as string | null }, + metadata(state) { + return { threadId: state.threadId }; + }, + routes: [ + POST("/input", async (_request, { from }) => { + await from("thread-1").respond([{ optionId: "approve", requestId: "approval-1" }], { + auth: null, + }); + return new Response("ok"); + }), + ], + async fetchFile(url) { + return url.startsWith("https://files.example.com/") ? Buffer.from("example") : null; + }, + turnPolicy: "queue", +}); diff --git a/packages/eve/extension-contracts/compatibility/schedule/v3.ts b/packages/eve/extension-contracts/compatibility/schedule/v3.ts new file mode 100644 index 0000000000..0d30c387a6 --- /dev/null +++ b/packages/eve/extension-contracts/compatibility/schedule/v3.ts @@ -0,0 +1,18 @@ +import channel from "../channel/v2.js"; +import { defineSchedule } from "#public/schedules/index.js"; + +export default defineSchedule({ + cron: "0 0 * * *", + async run({ appAuth, to, waitUntil }) { + waitUntil( + (async () => { + const session = await to(channel, { sessionRef: "daily" }).send("Start review", { + auth: appAuth, + }); + await session.respond([{ optionId: "approve", requestId: "approval-1" }], { + auth: appAuth, + }); + })(), + ); + }, +}); diff --git a/packages/eve/extension-contracts/compatibility/tool/v18.ts b/packages/eve/extension-contracts/compatibility/tool/v18.ts new file mode 100644 index 0000000000..79660c5bd8 --- /dev/null +++ b/packages/eve/extension-contracts/compatibility/tool/v18.ts @@ -0,0 +1,18 @@ +import { z } from "zod"; + +import { defineTool } from "#public/tools/index.js"; + +export default defineTool({ + description: "Start a report.", + execution: "background", + inputSchema: z.object({ reportId: z.string() }), + execute(input, _ctx, task) { + void Promise.resolve().then(() => + task.send({ data: { reportId: input.reportId }, kind: "complete" }), + ); + return task.delegated({ + executor: { data: { reportId: input.reportId }, kind: "report" }, + receipt: { reportId: input.reportId }, + }); + }, +}); diff --git a/packages/eve/extension-contracts/entrypoints/channel.ts b/packages/eve/extension-contracts/entrypoints/channel.ts index a96385e13e..530ddb1c0f 100644 --- a/packages/eve/extension-contracts/entrypoints/channel.ts +++ b/packages/eve/extension-contracts/entrypoints/channel.ts @@ -1,4 +1,5 @@ export { + AgentTargetError, DELETE, GET, HEAD, diff --git a/packages/eve/extension-contracts/reports/channel/v10.json b/packages/eve/extension-contracts/reports/channel/v10.json new file mode 100644 index 0000000000..fcac03ab34 --- /dev/null +++ b/packages/eve/extension-contracts/reports/channel/v10.json @@ -0,0 +1,22 @@ +{ + "kind": "eve-extension-capability-contract", + "capability": "channel", + "epoch": 10, + "sha256": "7c86ac332a106b0b376090d8e51fdec1e3aecaa45669195e44b77d629fb20d5f", + "exports": [ + "AgentTargetError", + "DELETE", + "GET", + "HEAD", + "OPTIONS", + "PATCH", + "POST", + "PUT", + "WS", + "createWebSocketUpgradeServer", + "defineChannel", + "disableRoute", + "isChannel", + "isDisabledRouteSentinel" + ] +} diff --git a/packages/eve/extension-contracts/reports/schedule/v4.json b/packages/eve/extension-contracts/reports/schedule/v4.json new file mode 100644 index 0000000000..610fdf4d7e --- /dev/null +++ b/packages/eve/extension-contracts/reports/schedule/v4.json @@ -0,0 +1,14 @@ +{ + "kind": "eve-extension-capability-contract", + "capability": "schedule", + "epoch": 4, + "sha256": "ba4786ffdf41c3b2973d14584a8a18cb098ddd9a234da068c9127b16fd0bf26e", + "exports": [ + "ScheduleDefinition", + "ScheduleHandlerArgs", + "ScheduleRunHandler", + "ScheduleToFn", + "TypedReceiveTarget", + "defineSchedule" + ] +} diff --git a/packages/eve/extension-contracts/reports/tool/v19.json b/packages/eve/extension-contracts/reports/tool/v19.json new file mode 100644 index 0000000000..3b041529b2 --- /dev/null +++ b/packages/eve/extension-contracts/reports/tool/v19.json @@ -0,0 +1,19 @@ +{ + "kind": "eve-extension-capability-contract", + "capability": "tool", + "epoch": 19, + "sha256": "357e5de4ee5618e215c9f2d363d5ca3a5402d873b20396e749f4b8a29bfd0524", + "exports": [ + "defaultWebSearch", + "defineTool", + "disableTool", + "experimental_workflow", + "isDisabledToolSentinel", + "isExperimentalWorkflowToolDefinition", + "isWebSearchToolDefinition", + "toolOutput", + "toolOutputPart", + "toolResultFrom", + "webSearch" + ] +} diff --git a/packages/eve/src/channel/channel-address.test.ts b/packages/eve/src/channel/channel-address.test.ts index 6ed63f55fd..cd99769127 100644 --- a/packages/eve/src/channel/channel-address.test.ts +++ b/packages/eve/src/channel/channel-address.test.ts @@ -15,6 +15,45 @@ function createRuntime(): Runtime { } describe("createChannelAddress", () => { + it("routes an existing address delivery to the resolved agent node", async () => { + const runtime = createRuntime(); + const resolveAgentTarget = vi.fn().mockReturnValue({ + nodeId: "node:researcher", + path: "researcher", + }); + const address = createChannelAddress({ + adapter: { kind: "slack" }, + channelName: "slack", + continuationToken: "C1:T1", + resolveAgentTarget, + runtime, + }); + + await address.send("investigate", { agent: "researcher", auth: null }); + + expect(runtime.dispatchContinuation).toHaveBeenCalledWith({ + command: expect.objectContaining({ agentNodeId: "node:researcher", kind: "send" }), + continuationToken: "slack:C1:T1", + }); + }); + + it("rejects an agent selector on input responses", async () => { + const runtime = createRuntime(); + const address = createChannelAddress({ + agent: "researcher", + adapter: { kind: "slack" }, + channelName: "slack", + continuationToken: "C1:T1", + resolveAgentTarget: vi.fn(), + runtime, + }); + + await expect( + address.respond([{ optionId: "yes", requestId: "input-1" }], { auth: null }), + ).rejects.toThrow("Input responses resume the agent that requested them"); + expect(runtime.dispatchContinuation).not.toHaveBeenCalled(); + }); + it("rejects channel addresses in the framework-reserved session namespace", () => { expect(() => createChannelAddress({ diff --git a/packages/eve/src/channel/channel-address.ts b/packages/eve/src/channel/channel-address.ts index 4795fc80f9..3fb95e8b94 100644 --- a/packages/eve/src/channel/channel-address.ts +++ b/packages/eve/src/channel/channel-address.ts @@ -24,8 +24,10 @@ import { DEFAULT_TURN_POLICY } from "#channel/types.js"; import { isRuntimeSessionOwnershipConflictError } from "#execution/runtime-errors.js"; import { isReservedSessionCommandToken } from "#execution/session-command-token.js"; import type { RunMode } from "#shared/run-mode.js"; +import type { AgentTargetResolver } from "#runtime/agent-target.js"; interface BaseChannelAddressDeliveryOptions { + readonly agent?: string; readonly auth: SessionAuthContext | null; readonly callback?: SessionCallback; readonly initiatorAuth?: SessionAuthContext | null; @@ -68,11 +70,13 @@ export type ChannelAddressFn = ( /** Creates one channel address backed by the runtime's continuation dispatch primitive. */ export function createChannelAddress(input: { + readonly agent?: string; readonly adapter: ChannelAdapter; readonly channelName: string; readonly continuationToken: string; readonly metadata?: ChannelDeliverySource; readonly runtime: Runtime; + readonly resolveAgentTarget?: AgentTargetResolver; readonly turnPolicy?: TurnPolicy; }): ChannelAddress { const metadata: Partial = input.metadata ?? {}; @@ -89,8 +93,17 @@ export function createChannelAddress(input: { ? createChannelDeliveryMetadata(metadata as ChannelDeliverySource) : undefined; const payload = normalizeSendInput(sendInput); + const configuredAgent = options.agent ?? input.agent; + if (configuredAgent !== undefined && payload.inputResponses !== undefined) { + throw new Error( + "Cannot select an agent when delivering inputResponses. Input responses resume the agent that requested them.", + ); + } + const requestedAgent = payload.message === undefined ? undefined : configuredAgent; + const agentTarget = resolveRequestedAgent(input.resolveAgentTarget, requestedAgent); const caller = sessionCallbackToTurnCaller(options.callback); const commandWithoutCaller = { + agentNodeId: agentTarget?.nodeId, auth: options.auth, delivery, kind: "send" as const, @@ -114,6 +127,7 @@ export function createChannelAddress(input: { return result.status === "accepted" ? createSession(result.sessionId, input.runtime, { ...metadata, + resolveAgentTarget: input.resolveAgentTarget, turnPolicy: input.turnPolicy, }) : undefined; @@ -154,9 +168,12 @@ export function createChannelAddress(input: { title: options.title, }; try { - const handle = await input.runtime.createSession(runInput); + const handle = await input.runtime.createSession(runInput, { + agentNodeId: agentTarget?.nodeId, + }); return createSession(handle.sessionId, input.runtime, { ...metadata, + resolveAgentTarget: input.resolveAgentTarget, turnPolicy: input.turnPolicy, }); } catch (error) { @@ -205,6 +222,7 @@ export function createChannelAddress(input: { ? undefined : createSession(owner.sessionId, input.runtime, { ...metadata, + resolveAgentTarget: input.resolveAgentTarget, turnPolicy: input.turnPolicy, }); }, @@ -213,11 +231,24 @@ export function createChannelAddress(input: { /** Builds a request-scoped factory for channel addresses on one authored channel. */ export function createChannelAddressFn(input: { + readonly agent?: string; readonly adapter: ChannelAdapter; readonly channelName: string; readonly metadata?: ChannelDeliverySource; readonly runtime: Runtime; + readonly resolveAgentTarget?: AgentTargetResolver; readonly turnPolicy?: TurnPolicy; }): ChannelAddressFn { return (continuationToken) => createChannelAddress({ ...input, continuationToken }); } + +function resolveRequestedAgent( + resolver: AgentTargetResolver | undefined, + agent: string | undefined, +) { + if (agent === undefined) return undefined; + if (resolver === undefined) { + throw new Error("Agent selection is unavailable in this channel runtime."); + } + return resolver(agent); +} diff --git a/packages/eve/src/channel/channel-operations.ts b/packages/eve/src/channel/channel-operations.ts index ddf457037f..b1720628a3 100644 --- a/packages/eve/src/channel/channel-operations.ts +++ b/packages/eve/src/channel/channel-operations.ts @@ -25,8 +25,11 @@ import { } from "#shared/input.js"; import type { JsonObject } from "#shared/json.js"; import type { RunMode } from "#shared/run-mode.js"; +import type { AgentTargetResolver } from "#runtime/agent-target.js"; interface BaseChannelSendOptions { + /** Root-relative static local descendant to receive this message. */ + readonly agent?: string; readonly auth: SessionAuthContext | null; readonly callback?: SessionCallback; readonly context?: readonly string[]; @@ -95,10 +98,12 @@ export interface InternalChannelSource extends ChannelSource /** Creates request-scoped channel operations backed by continuation dispatch. */ export function createChannelOperations(input: { + readonly agent?: string; readonly adapter: ChannelAdapter; readonly channelName: string; readonly metadata?: ChannelDeliverySource; readonly runtime: Runtime; + readonly resolveAgentTarget?: AgentTargetResolver; readonly turnPolicy?: TurnPolicy; }): ChannelReceiveContext { const channelAddress = createChannelAddressFn(input); diff --git a/packages/eve/src/channel/compiled-channel.ts b/packages/eve/src/channel/compiled-channel.ts index 7124fbe220..1f45916774 100644 --- a/packages/eve/src/channel/compiled-channel.ts +++ b/packages/eve/src/channel/compiled-channel.ts @@ -39,6 +39,7 @@ export interface CompiledChannel< readonly __metadata?: TMetadata; readonly receive?: ( input: { + readonly agent?: string; readonly message: string | UserContent; readonly target: Readonly; readonly auth: SessionAuthContext | null; diff --git a/packages/eve/src/channel/cross-channel-receive.test.ts b/packages/eve/src/channel/cross-channel-receive.test.ts index 7bc21fb2c5..a737dfbc62 100644 --- a/packages/eve/src/channel/cross-channel-receive.test.ts +++ b/packages/eve/src/channel/cross-channel-receive.test.ts @@ -117,6 +117,38 @@ describe("createCrossChannelToFn", () => { expect(typeof ctx.from).toBe("function"); }); + it("propagates agent selection through receive and its channel operations", async () => { + const runtime = makeRuntime(); + vi.mocked(runtime.dispatchContinuation).mockResolvedValue({ + sessionId: "sess_1", + status: "accepted", + }); + const target = makeChannel("slack"); + target.receive.mockImplementation(async (input, ctx) => + ctx.from("C1").send(input.message, { auth: input.auth }), + ); + const resolveAgentTarget = vi.fn().mockReturnValue({ + nodeId: "node:researcher", + path: "researcher", + }); + const to = createCrossChannelToFn(runtime, [target.target], resolveAgentTarget); + + await to(target.definition, { channelId: "C1" }).send("investigate", { + agent: "researcher", + auth: null, + }); + + expect(target.receive).toHaveBeenCalledWith( + expect.objectContaining({ agent: "researcher", message: "investigate" }), + expect.any(Object), + ); + expect(runtime.dispatchContinuation).toHaveBeenCalledWith( + expect.objectContaining({ + command: expect.objectContaining({ agentNodeId: "node:researcher" }), + }), + ); + }); + it("resolves the target by reference identity even when multiple channels are registered", async () => { const slack = makeChannel("slack"); const twilio = makeChannel("twilio"); diff --git a/packages/eve/src/channel/cross-channel-receive.ts b/packages/eve/src/channel/cross-channel-receive.ts index e914ff5843..c1cff1ec6b 100644 --- a/packages/eve/src/channel/cross-channel-receive.ts +++ b/packages/eve/src/channel/cross-channel-receive.ts @@ -10,12 +10,15 @@ import { import type { InferReceiveTarget } from "#channel/receive-target.js"; import type { Session } from "#channel/session.js"; import type { Runtime, SessionAuthContext, TurnPolicy } from "#channel/types.js"; +import type { AgentTargetResolver } from "#runtime/agent-target.js"; import type { ResolvedChannelDefinition } from "#runtime/types.js"; /** * Options for sending a message to a channel selected with `ctx.to(...)`. */ export interface CrossChannelSendOptions { + /** Root-relative static local descendant to receive this message. */ + readonly agent?: string; readonly auth: SessionAuthContext | null; readonly turnPolicy?: TurnPolicy; } @@ -75,15 +78,18 @@ export function toCrossChannelTargets( export function createCrossChannelToFn( runtime: Runtime, channels: readonly CrossChannelTarget[], + resolveAgentTarget?: AgentTargetResolver, ): CrossChannelToFn { return (channel, target) => { const targetChannel = resolveTargetByReference(channel, channels); return { async send(message, options) { return await invokeChannelReceive({ + resolveAgentTarget, runtime, target: targetChannel, input: { + agent: options.agent, message, target: target as Readonly>, auth: options.auth, @@ -101,9 +107,11 @@ export function createCrossChannelToFn( } interface InvokeChannelReceiveInput { + readonly resolveAgentTarget?: AgentTargetResolver; readonly runtime: Runtime; readonly target: Pick; readonly input: { + readonly agent?: string; readonly message: string | UserContent; readonly target: Readonly>; readonly auth: SessionAuthContext | null; @@ -124,9 +132,11 @@ export async function invokeChannelReceive(args: InvokeChannelReceiveInput): Pro throw new Error(args.describeMissingAdapter()); } const channelOperations = createChannelOperations({ + agent: args.input.agent, adapter: args.target.adapter, channelName: args.target.name, runtime: args.runtime, + resolveAgentTarget: args.resolveAgentTarget, turnPolicy: args.turnPolicy ?? args.target.turnPolicy, }); return await args.target.receive(args.input, channelOperations); diff --git a/packages/eve/src/channel/schedule.ts b/packages/eve/src/channel/schedule.ts index 163c2e321b..91c2b4c3f7 100644 --- a/packages/eve/src/channel/schedule.ts +++ b/packages/eve/src/channel/schedule.ts @@ -10,6 +10,7 @@ import type { ScheduleRunHandler, } from "#public/definitions/schedule.js"; import type { ResolvedChannelDefinition } from "#runtime/types.js"; +import type { AgentTargetResolver } from "#runtime/agent-target.js"; export { SCHEDULE_APP_AUTH } from "#channel/schedule-auth.js"; @@ -62,19 +63,26 @@ export interface ScheduleDispatchResult { export class ScheduleDispatcher { private readonly runtime: Runtime; private readonly channels: readonly ResolvedChannelDefinition[]; + private readonly resolveAgentTarget?: AgentTargetResolver; constructor(config: { readonly runtime: Runtime; readonly channels: readonly ResolvedChannelDefinition[]; + readonly resolveAgentTarget?: AgentTargetResolver; }) { this.runtime = config.runtime; this.channels = config.channels; + this.resolveAgentTarget = config.resolveAgentTarget; } async trigger(input: ScheduleDispatchInput): Promise { const sessions: Session[] = []; const waitUntilTasks: Promise[] = []; - const toChannel = createCrossChannelToFn(this.runtime, toCrossChannelTargets(this.channels)); + const toChannel = createCrossChannelToFn( + this.runtime, + toCrossChannelTargets(this.channels), + this.resolveAgentTarget, + ); const args: ScheduleHandlerArgs = { appAuth: SCHEDULE_APP_AUTH, diff --git a/packages/eve/src/channel/session.test.ts b/packages/eve/src/channel/session.test.ts index d33cc4515e..9b235761c8 100644 --- a/packages/eve/src/channel/session.test.ts +++ b/packages/eve/src/channel/session.test.ts @@ -84,6 +84,23 @@ describe("createSession#cancel", () => { }); describe("fixed session operations", () => { + it("resolves an agent selector before dispatching a fixed-session turn", async () => { + const runtime = createRuntime(); + const resolveAgentTarget = vi.fn().mockReturnValue({ + nodeId: "node:researcher", + path: "researcher", + }); + const session = createSession("sess_1", runtime, { resolveAgentTarget }); + + await session.send("investigate", { agent: " researcher ", auth: null }); + + expect(resolveAgentTarget).toHaveBeenCalledWith(" researcher "); + expect(runtime.dispatchSession).toHaveBeenCalledWith({ + command: expect.objectContaining({ agentNodeId: "node:researcher", kind: "send" }), + sessionId: "sess_1", + }); + }); + it("keeps the session turn policy out of channel delivery metadata", async () => { const runtime = createRuntime(); const session = createSession("sess_1", runtime, { diff --git a/packages/eve/src/channel/session.ts b/packages/eve/src/channel/session.ts index 48dbc30701..26083ef6c5 100644 --- a/packages/eve/src/channel/session.ts +++ b/packages/eve/src/channel/session.ts @@ -28,6 +28,7 @@ import { } from "#shared/input.js"; import type { JsonObject } from "#shared/json.js"; import { toChannelLocalContinuationToken } from "#shared/continuation-token.js"; +import type { AgentTargetResolver } from "#runtime/agent-target.js"; /** Immutable-ID handle for one exact durable session. */ export interface Session { @@ -63,7 +64,11 @@ interface SessionDeliveryOptions { } /** Options for sending a message through a fixed session handle. */ -export type SessionSendOptions = SessionDeliveryOptions & { readonly turnPolicy?: TurnPolicy }; +export type SessionSendOptions = SessionDeliveryOptions & { + /** Root-relative static local descendant to run for this turn. */ + readonly agent?: string; + readonly turnPolicy?: TurnPolicy; +}; /** Options for answering pending input requests through a fixed session handle. */ export type SessionRespondOptions = SessionDeliveryOptions; @@ -87,11 +92,15 @@ export interface SessionHandle { export function createSession( id: string, runtime: Runtime, - metadata: Partial & { readonly turnPolicy?: TurnPolicy } = {}, + metadata: Partial & { + readonly resolveAgentTarget?: AgentTargetResolver; + readonly turnPolicy?: TurnPolicy; + } = {}, ): Session { return { id, async send(message, options) { + const agentTarget = resolveRequestedAgent(metadata.resolveAgentTarget, options.agent); const delivery = createDelivery(metadata); const caller = sessionCallbackToTurnCaller(options.callback); const payload: { @@ -102,6 +111,7 @@ export function createSession( if (options.context !== undefined) payload.context = options.context; if (options.outputSchema !== undefined) payload.outputSchema = options.outputSchema; const commandWithoutCaller = { + agentNodeId: agentTarget?.nodeId, auth: options.auth, delivery, kind: "send" as const, @@ -170,11 +180,25 @@ export function createSession( /** Builds an I/O-free factory for fixed session-ID handles. */ export function createAttachSessionFn( runtime: Runtime, - metadata: Partial & { readonly turnPolicy?: TurnPolicy } = {}, + metadata: Partial & { + readonly resolveAgentTarget?: AgentTargetResolver; + readonly turnPolicy?: TurnPolicy; + } = {}, ): (sessionId: string) => Session { return (sessionId) => createSession(sessionId, runtime, metadata); } +function resolveRequestedAgent( + resolver: AgentTargetResolver | undefined, + agent: string | undefined, +) { + if (agent === undefined) return undefined; + if (resolver === undefined) { + throw new Error("Agent selection is unavailable on this session handle."); + } + return resolver(agent); +} + function createDelivery( metadata: Partial, ): ReturnType | undefined { diff --git a/packages/eve/src/channel/types.ts b/packages/eve/src/channel/types.ts index 86db554468..cb593ff513 100644 --- a/packages/eve/src/channel/types.ts +++ b/packages/eve/src/channel/types.ts @@ -189,6 +189,8 @@ export const DEFAULT_TURN_POLICY: TurnPolicy = "steer"; /** One command accepted by a durable session inbox. */ export type SessionCommand = | { + /** Resolved runtime node for a one-turn direct agent override. */ + readonly agentNodeId?: string; readonly auth?: SessionAuthContext | null; readonly caller?: TurnCaller; readonly kind: "send"; @@ -249,6 +251,8 @@ export interface DispatchSessionInput; + createSession(input: RunInput, options?: { readonly agentNodeId?: string }): Promise; dispatchContinuation( input: DispatchContinuationInput, diff --git a/packages/eve/src/client/session.ts b/packages/eve/src/client/session.ts index e1a2b43fc7..7ceb242941 100644 --- a/packages/eve/src/client/session.ts +++ b/packages/eve/src/client/session.ts @@ -312,6 +312,12 @@ function createMessageBody( ): Record | null { const body: Record = {}; if (input.message !== undefined) body.message = input.message; + if ("agent" in input && input.agent !== undefined) { + if (input.inputResponses !== undefined) { + throw new Error("agent cannot be sent alongside inputResponses."); + } + body.agent = input.agent; + } if (input.inputResponses !== undefined && input.inputResponses.length > 0) { body.inputResponses = input.inputResponses; } diff --git a/packages/eve/src/client/sessions.test.ts b/packages/eve/src/client/sessions.test.ts index d150031fcc..34b141d937 100644 --- a/packages/eve/src/client/sessions.test.ts +++ b/packages/eve/src/client/sessions.test.ts @@ -34,6 +34,23 @@ describe("Client.sessions", () => { expect(session.state).toEqual({ sessionId: "wrun_A", streamIndex: 1 }); }); + it("serializes the same agent selector on create and one-turn sends", async () => { + const bodies: unknown[] = []; + vi.spyOn(globalThis, "fetch").mockImplementation(async (_request, init) => { + bodies.push(JSON.parse(String(init?.body))); + return Response.json({ ok: true, sessionId: "wrun_A", status: "accepted" }, { status: 202 }); + }); + const client = new Client({ host: "https://eve.test" }); + + const { session } = await client.sessions.create({ agent: "researcher", message: "hello" }); + await session.send("review", { agent: "researcher/critic" }); + + expect(bodies).toEqual([ + { agent: "researcher", message: "hello" }, + { agent: "researcher/critic", message: "review" }, + ]); + }); + it("attaches without I/O and sends every operation through ID-only routes", async () => { const requests: Array<{ readonly body?: string; readonly url: string }> = []; vi.spyOn(globalThis, "fetch").mockImplementation(async (request, init) => { diff --git a/packages/eve/src/client/types.ts b/packages/eve/src/client/types.ts index ec140d4cb6..75165735fd 100644 --- a/packages/eve/src/client/types.ts +++ b/packages/eve/src/client/types.ts @@ -108,7 +108,7 @@ export interface SendTurnInput extends SendTurnOptions { +interface BaseTurnOptions { /** Policy for a message sent while the fixed session has an active turn. */ readonly turnPolicy?: TurnPolicy; @@ -150,8 +150,19 @@ export interface SendTurnOptions { readonly headers?: Readonly>; } +/** Options for sending one message turn on a client session. */ +export interface SendTurnOptions extends BaseTurnOptions { + /** + * Root-relative static local descendant to run for this turn. + * + * On session creation this becomes the session default. On an existing + * session it applies only to this turn. + */ + readonly agent?: string; +} + /** Options for answering pending HITL input requests on a client session. */ -export type RespondTurnOptions = SendTurnOptions; +export type RespondTurnOptions = BaseTurnOptions; /** @internal Transport envelope used by stores and command adapters. */ export type SendTurnPayload = diff --git a/packages/eve/src/compiler/extension-compatibility.ts b/packages/eve/src/compiler/extension-compatibility.ts index 3006291078..c7532f8fbc 100644 --- a/packages/eve/src/compiler/extension-compatibility.ts +++ b/packages/eve/src/compiler/extension-compatibility.ts @@ -22,8 +22,8 @@ interface ExtensionCapabilityContract { const EXTENSION_CAPABILITY_CONTRACTS = { extension: { current: 1, supported: [1], dropped: {} }, tool: { - current: 18, - supported: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 16, 17, 18], + current: 19, + supported: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 16, 17, 18, 19], dropped: { 15: "TaskExec replaces stageEffect with send" }, }, dynamicTool: { @@ -31,8 +31,8 @@ const EXTENSION_CAPABILITY_CONTRACTS = { supported: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19], dropped: {}, }, - channel: { current: 9, supported: [1, 2, 3, 4, 5, 6, 7, 8, 9], dropped: {} }, - schedule: { current: 3, supported: [1, 2, 3], dropped: {} }, + channel: { current: 10, supported: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], dropped: {} }, + schedule: { current: 4, supported: [1, 2, 3, 4], dropped: {} }, subagent: { current: 3, supported: [3], diff --git a/packages/eve/src/context/keys.ts b/packages/eve/src/context/keys.ts index fb9b2943d1..0c6c43fffd 100644 --- a/packages/eve/src/context/keys.ts +++ b/packages/eve/src/context/keys.ts @@ -72,12 +72,16 @@ export const SessionIdKey = new ContextKey("eve.sessionId"); export const ContinuationTokenKey = new ContextKey("eve.continuationToken"); export const ChannelRequestIdKey = new ContextKey("eve.channelRequestId"); export const ChannelDeliveryKey = new ContextKey("eve.channelDelivery"); +/** Sandbox owner for the session's durable default agent bundle. */ +export const DefaultSandboxOwnerNodeIdKey = new ContextKey("eve.defaultSandboxOwnerNodeId"); /** Task-reporting phase for the active root turn. */ export const TurnTaskDeliveryKey = new ContextKey<"none" | "initiating" | "pending" | "settled">( "eve.turnTaskDelivery", ); /** Framework-authored task state supplied to the model without altering user-message history. */ export const TurnTaskStateKey = new ContextKey("eve.turnTaskState"); +/** Direct-agent override retained only while that turn is waiting for HITL or authorization. */ +export const PendingTurnAgentNodeIdKey = new ContextKey("eve.pendingTurnAgentNodeId"); export interface ActiveChannelDelivery { readonly agentName?: string; readonly delivery: InstrumentationChannelDeliveryRef; diff --git a/packages/eve/src/context/provider.ts b/packages/eve/src/context/provider.ts index 6510965446..08403b2c30 100644 --- a/packages/eve/src/context/provider.ts +++ b/packages/eve/src/context/provider.ts @@ -31,7 +31,11 @@ export interface FrameworkContextProvider { session: HarnessSession, ): ProviderResult | undefined | Promise | undefined>; - commit?(value: T, session: HarnessSession): HarnessSession | Promise; + commit?( + value: T, + session: HarnessSession, + ctx: ContextContainer, + ): HarnessSession | Promise; /** Rolls back provider-owned effects when the callback or a later commit fails. */ rollback?(value: T, cause: unknown): void | Promise; diff --git a/packages/eve/src/context/providers/sandbox.test.ts b/packages/eve/src/context/providers/sandbox.test.ts index e1a77bcd07..31c2df19b4 100644 --- a/packages/eve/src/context/providers/sandbox.test.ts +++ b/packages/eve/src/context/providers/sandbox.test.ts @@ -4,7 +4,7 @@ import { ensureSandboxAccess } from "#execution/sandbox/ensure.js"; import type { HarnessSession } from "#harness/types.js"; import { createBundledRuntimeCompiledArtifactsSource } from "#runtime/compiled-artifacts-source.js"; import type { RuntimeSandboxRegistry } from "#runtime/sandbox/registry.js"; -import { SessionIdKey } from "#context/keys.js"; +import { DefaultSandboxOwnerNodeIdKey, SessionIdKey } from "#context/keys.js"; import { BundleKey, ChannelKey, @@ -37,6 +37,7 @@ function createHarnessSession(): HarnessSession { function createBundle(input: { readonly agentName: string; + readonly nodeId?: string; readonly registry: RuntimeSandboxRegistry; }): CompiledBundle { return { @@ -48,7 +49,7 @@ function createBundle(input: { name: input.agentName, }, }, - nodeId: "__root__", + nodeId: input.nodeId ?? "__root__", sandboxRegistry: input.registry, }, }, @@ -106,4 +107,42 @@ describe("sandboxProvider", () => { }), ); }); + + it("preserves sandbox snapshots by resolved owner while alternating agents", async () => { + const ctx = new ContextContainer(); + const registry: RuntimeSandboxRegistry = createStubSandboxRegistry(); + const rootState = { initialized: true, session: null }; + const researcherState = { initialized: false, session: null }; + const nextResearcherState = { initialized: true, session: null }; + const access = { + captureState: vi.fn().mockResolvedValue(nextResearcherState), + get: vi.fn().mockResolvedValue(null), + stop: vi.fn().mockResolvedValue(undefined), + }; + vi.mocked(ensureSandboxAccess).mockResolvedValue(access); + ctx.set( + BundleKey, + createBundle({ agentName: "researcher", nodeId: "node:researcher", registry }), + ); + ctx.set(DefaultSandboxOwnerNodeIdKey, "__root__"); + ctx.set(ChannelKey, { kind: "slack" }); + ctx.set(SessionIdKey, "session_1"); + const session = { + ...createHarnessSession(), + sandboxState: rootState, + sandboxStates: { __root__: rootState, "node:researcher": researcherState }, + }; + + const created = await sandboxProvider.create(ctx, session); + const committed = await sandboxProvider.commit!(created!.value, created!.session!, ctx); + + expect(ensureSandboxAccess).toHaveBeenCalledWith( + expect.objectContaining({ state: researcherState }), + ); + expect(committed.sandboxStates).toEqual({ + __root__: rootState, + "node:researcher": nextResearcherState, + }); + expect(committed.sandboxState).toBe(nextResearcherState); + }); }); diff --git a/packages/eve/src/context/providers/sandbox.ts b/packages/eve/src/context/providers/sandbox.ts index 22ab893664..3bd25888c6 100644 --- a/packages/eve/src/context/providers/sandbox.ts +++ b/packages/eve/src/context/providers/sandbox.ts @@ -4,7 +4,7 @@ import type { SandboxAccess, SandboxState } from "#sandbox/state.js"; import { type ChannelAdapter, getAdapterKind } from "#channel/adapter.js"; import type { ContextContainer } from "#context/container.js"; import { contextStorage } from "#context/container.js"; -import { SandboxKey, SessionIdKey } from "#context/keys.js"; +import { DefaultSandboxOwnerNodeIdKey, SandboxKey, SessionIdKey } from "#context/keys.js"; import { BundleKey, ChannelKey, @@ -21,6 +21,8 @@ export const sandboxProvider: FrameworkContextProvider = { if (bundle === undefined) return undefined; const node = getActiveRuntimeNode(ctx); const registry = node.sandboxRegistry; + const ownerNodeId = registry.sandbox?.inheritance?.nodeId ?? node.nodeId; + const defaultOwnerNodeId = ctx.get(DefaultSandboxOwnerNodeIdKey) ?? ownerNodeId; const sessionId = ctx.require(SessionIdKey); const channel = ctx.get(ChannelKey); const adapterState = channel?.state as Record | undefined; @@ -30,26 +32,42 @@ export const sandboxProvider: FrameworkContextProvider = { const sharesSandbox = inheritsParent || sharedSandboxSessionId !== undefined; const sandboxSessionId = sharesSandbox ? (sharedSandboxSessionId ?? sessionId) : sessionId; + const sandboxStates = + session.sandboxStates ?? + (session.sandboxState === undefined ? {} : { [defaultOwnerNodeId]: session.sandboxState }); + const persistedState = sandboxStates[ownerNodeId]; + const access = await ensureSandboxAccess({ + compiledArtifactsSource: bundle.compiledArtifactsSource, + nodeId: node.nodeId, + registry, + runOnSession: async (callback) => await contextStorage.run(ctx, callback), + sessionId: sandboxSessionId, + state: persistedState ?? (sharesSandbox ? parentSandboxState : undefined) ?? null, + tags: { + agent: resolveTagAgentName({ bundle, node }), + channel: resolveTagChannelKind(channel), + sessionId, + }, + }); + const { sandboxState: _previousSandboxState, ...sessionWithoutSandboxState } = session; return { - value: await ensureSandboxAccess({ - compiledArtifactsSource: bundle.compiledArtifactsSource, - nodeId: node.nodeId, - registry, - runOnSession: async (callback) => await contextStorage.run(ctx, callback), - sessionId: sandboxSessionId, - state: session.sandboxState ?? (sharesSandbox ? parentSandboxState : undefined) ?? null, - tags: { - agent: resolveTagAgentName({ bundle, node }), - channel: resolveTagChannelKind(channel), - sessionId, - }, - }), + value: access, + session: + persistedState === undefined + ? { ...sessionWithoutSandboxState, sandboxStates } + : { ...sessionWithoutSandboxState, sandboxState: persistedState, sandboxStates }, }; }, - async commit(access, session) { + async commit(access, session, ctx) { const state = await access.captureState(); - return { ...session, sandboxState: state }; + const node = getActiveRuntimeNode(ctx); + const ownerNodeId = node.sandboxRegistry.sandbox?.inheritance?.nodeId ?? node.nodeId; + return { + ...session, + sandboxState: state, + sandboxStates: { ...session.sandboxStates, [ownerNodeId]: state }, + }; }, }; diff --git a/packages/eve/src/context/run-step.ts b/packages/eve/src/context/run-step.ts index efaeac6c98..6a1d10793f 100644 --- a/packages/eve/src/context/run-step.ts +++ b/packages/eve/src/context/run-step.ts @@ -59,7 +59,7 @@ export async function withContextScope( let committed = scopeResult.session; for (const provider of createdProviders) { if (provider.commit !== undefined) { - committed = await provider.commit(ctx.require(provider.key), committed); + committed = await provider.commit(ctx.require(provider.key), committed, ctx); } } diff --git a/packages/eve/src/eve-channel/index.ts b/packages/eve/src/eve-channel/index.ts index 33bb58d64a..4d558c7800 100644 --- a/packages/eve/src/eve-channel/index.ts +++ b/packages/eve/src/eve-channel/index.ts @@ -11,9 +11,11 @@ import { handleTaskInputResponseRequest } from "#execution/task-input-response-r import { createLogger, logError } from "#internal/logging.js"; import { readAgentInfoRouteResponse, + readAgentTargetResolver, readRemoteAgentStreamHeadersResolver, readRouteSessionCreator, } from "#internal/nitro/routes/channel-route-context.js"; +import { AgentTargetError, type ResolvedAgentTarget } from "#runtime/agent-target.js"; import { EVE_SESSION_ID_HEADER, EVE_STREAM_FORMAT_HEADER, @@ -134,6 +136,8 @@ export function eveChannel(input: EveChannelInput): EveChannel { const body = parseCreateBody(payload); if (body instanceof Response) return body; + const agentTarget = resolveRouteAgentTarget(args, body.agent); + if (agentTarget instanceof Response) return agentTarget; // Top-level sessions own their trace. Callback sessions are delegated // remote agents and intentionally continue the dispatching agent trace. const parentTraceContext = @@ -154,6 +158,7 @@ export function eveChannel(input: EveChannelInput): EveChannel { body.operationId === undefined ? undefined : await deriveOperationContinuationToken({ + agent: agentTarget?.path, auth: forwarded.auth, operationId: body.operationId, }); @@ -174,6 +179,7 @@ export function eveChannel(input: EveChannelInput): EveChannel { } const messageResult = await resolveOnMessage({ + agent: agentTarget?.path, auth: forwarded.auth, config: input, message: body.message, @@ -191,6 +197,7 @@ export function eveChannel(input: EveChannelInput): EveChannel { let handle: Awaited>; try { handle = await createSession({ + agentNodeId: agentTarget?.nodeId, auth: messageResult.auth, capabilities: body.capabilities ?? (body.mode === "task" ? undefined : { requestInput: true }), @@ -240,11 +247,11 @@ export function eveChannel(input: EveChannelInput): EveChannel { ); }), - POST(EVE_SESSION_ROUTE_PATTERN, async (req, { attachSession, params }) => { + POST(EVE_SESSION_ROUTE_PATTERN, async (req, args) => { const authResult = await routeAuth(req, input.auth); if (authResult instanceof Response) return authResult; - const sessionId = requireSessionId(params); + const sessionId = requireSessionId(args.params); if (sessionId instanceof Response) return sessionId; const payload = await parseJsonRequest(req); if (payload instanceof Response) return payload; @@ -256,6 +263,8 @@ export function eveChannel(input: EveChannelInput): EveChannel { if (forwarded instanceof Response) return forwarded; const body = parseSessionMessageBody(payload); if (body instanceof Response) return body; + const agentTarget = resolveRouteAgentTarget(args, body.agent); + if (agentTarget instanceof Response) return agentTarget; const policyRejection = checkUploadPolicy(body, uploadPolicy); if (policyRejection !== null) return policyRejection; @@ -264,6 +273,7 @@ export function eveChannel(input: EveChannelInput): EveChannel { let dispatchAuth: SessionAuthContext | null = forwarded.auth; if (body.message !== undefined) { const messageResult = await resolveOnMessage({ + agent: agentTarget?.path, auth: forwarded.auth, config: input, message: body.message, @@ -277,8 +287,9 @@ export function eveChannel(input: EveChannelInput): EveChannel { let result: Awaited>; try { - const session = attachSession(sessionId); + const session = args.attachSession(sessionId); const options = { + agent: agentTarget?.path, auth: dispatchAuth, callback: body.callback, context, @@ -558,3 +569,28 @@ export function eveChannel(input: EveChannelInput): EveChannel { events: input.events, }); } + +function resolveRouteAgentTarget( + args: Parameters>[1], + agent: string | undefined, +): ResolvedAgentTarget | Response | undefined { + if (agent === undefined) return undefined; + const resolve = readAgentTargetResolver(args); + if (resolve === undefined) { + return Response.json( + { error: "Agent selection requires internal channel dispatch context.", ok: false }, + { status: 500 }, + ); + } + try { + return resolve(agent); + } catch (error) { + if (error instanceof AgentTargetError) { + return Response.json( + { code: error.code, error: error.message, ok: false }, + { status: error.status }, + ); + } + throw error; + } +} diff --git a/packages/eve/src/eve-channel/request.test.ts b/packages/eve/src/eve-channel/request.test.ts new file mode 100644 index 0000000000..22d677bc41 --- /dev/null +++ b/packages/eve/src/eve-channel/request.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; + +import { + deriveOperationContinuationToken, + parseCreateBody, + parseSessionMessageBody, +} from "#eve-channel/request.js"; + +const auth = { + attributes: {}, + authenticator: "test", + principalId: "user_1", + principalType: "user", +}; + +describe("eve request agent selector", () => { + it("parses agent on creates and message turns", () => { + expect(parseCreateBody({ agent: "researcher", message: "hello" })).toMatchObject({ + agent: "researcher", + message: "hello", + }); + expect(parseSessionMessageBody({ agent: "researcher", message: "hello" })).toMatchObject({ + agent: "researcher", + message: "hello", + }); + }); + + it("rejects agent alongside HITL responses", async () => { + const parsed = parseSessionMessageBody({ + agent: "researcher", + inputResponses: [{ requestId: "request_1", text: "yes" }], + }); + expect(parsed).toBeInstanceOf(Response); + expect((parsed as Response).status).toBe(400); + await expect((parsed as Response).json()).resolves.toMatchObject({ + error: expect.stringContaining("cannot be sent alongside"), + }); + }); + + it("preserves root operation identity and isolates targeted creates", async () => { + const root = await deriveOperationContinuationToken({ auth, operationId: "op_1" }); + const rootAgain = await deriveOperationContinuationToken({ auth, operationId: "op_1" }); + const researcher = await deriveOperationContinuationToken({ + agent: "researcher", + auth, + operationId: "op_1", + }); + const critic = await deriveOperationContinuationToken({ + agent: "researcher/critic", + auth, + operationId: "op_1", + }); + + expect(rootAgain).toBe(root); + expect(new Set([root, researcher, critic])).toHaveLength(3); + }); +}); diff --git a/packages/eve/src/eve-channel/request.ts b/packages/eve/src/eve-channel/request.ts index 773044f756..432dcd74a8 100644 --- a/packages/eve/src/eve-channel/request.ts +++ b/packages/eve/src/eve-channel/request.ts @@ -28,6 +28,7 @@ import { parseJsonObject, type JsonObject } from "#shared/json.js"; import type { RunMode } from "#shared/run-mode.js"; interface ParsedCreateBody { + agent?: string; callback?: SessionCallback; capabilities?: SessionCapabilities; message: string | UserContent; @@ -39,17 +40,29 @@ interface ParsedCreateBody { /** Replay-stable identity for one authenticated create operation. */ export async function deriveOperationContinuationToken(input: { + readonly agent?: string; readonly auth: SessionAuthContext; readonly operationId: string; }): Promise { - const identity = JSON.stringify([ - "eve:create-session:v1", - input.auth.authenticator, - input.auth.issuer ?? null, - input.auth.principalType, - input.auth.principalId, - input.operationId, - ]); + const identity = + input.agent === undefined + ? JSON.stringify([ + "eve:create-session:v1", + input.auth.authenticator, + input.auth.issuer ?? null, + input.auth.principalType, + input.auth.principalId, + input.operationId, + ]) + : JSON.stringify([ + "eve:create-agent-session:v1", + input.auth.authenticator, + input.auth.issuer ?? null, + input.auth.principalType, + input.auth.principalId, + input.agent, + input.operationId, + ]); const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(identity)); const hex = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join( "", @@ -67,6 +80,9 @@ export function parseCreateBody(payload: Record): ParsedCreateB const message = parseMessageField(payload.message); if (message instanceof Response) return message; + const agent = parseAgentField(payload.agent); + if (agent instanceof Response) return agent; + const context = parseClientContextField(payload.clientContext); if (context instanceof Response) return context; @@ -98,6 +114,7 @@ export function parseCreateBody(payload: Record): ParsedCreateB } const result: ParsedCreateBody = { + agent, callback, capabilities, message, @@ -110,6 +127,7 @@ export function parseCreateBody(payload: Record): ParsedCreateB } interface ParsedSessionMessageBody { + agent?: string; callback?: SessionCallback; message?: string | UserContent; inputResponses?: readonly ValidatedInputResponse[]; @@ -126,6 +144,8 @@ export function parseSessionMessageBody( const message = parseMessageField(payload.message); if (message instanceof Response) return message; + const agent = parseAgentField(payload.agent); + if (agent instanceof Response) return agent; const callback = parseCallbackField(payload.callback); if (callback instanceof Response) return callback; const inputResponses = parseInputResponses(payload.inputResponses); @@ -154,7 +174,14 @@ export function parseSessionMessageBody( ); } - return { callback, message, inputResponses, context, outputSchema, turnPolicy }; + if (agent !== undefined && inputResponses !== undefined) { + return Response.json( + { error: "'agent' cannot be sent alongside 'inputResponses'.", ok: false }, + { status: 400 }, + ); + } + + return { agent, callback, message, inputResponses, context, outputSchema, turnPolicy }; } interface ParsedCancelTurnBody { @@ -346,6 +373,15 @@ function parseModeField(value: unknown): RunMode | Response | undefined { ); } +function parseAgentField(value: unknown): string | Response | undefined { + if (value === undefined) return undefined; + if (typeof value === "string" && value.length > 0) return value; + return Response.json( + { error: "Expected 'agent' to be a non-empty string.", ok: false }, + { status: 400 }, + ); +} + function parseTurnPolicyField(value: unknown): TurnPolicy | Response | undefined { if (value === undefined) return undefined; if (value === "queue" || value === "steer") return value; diff --git a/packages/eve/src/eve-channel/support.ts b/packages/eve/src/eve-channel/support.ts index b9b5a28fb2..8a810c6ff9 100644 --- a/packages/eve/src/eve-channel/support.ts +++ b/packages/eve/src/eve-channel/support.ts @@ -127,6 +127,7 @@ interface OnMessageOutcome { } export async function resolveOnMessage(input: { + readonly agent?: string; readonly auth: SessionAuthContext | null; readonly config: EveChannelInput; readonly message: string | UserContent; @@ -137,10 +138,15 @@ export async function resolveOnMessage(input: { let result: EveMessageResult; try { - const eve: EveHandle = - input.sessionId === undefined - ? { caller: input.auth, request: input.request } - : { caller: input.auth, request: input.request, sessionId: input.sessionId }; + const eve: Omit & { + agent?: string; + sessionId?: string; + } = { + caller: input.auth, + request: input.request, + }; + if (input.agent !== undefined) eve.agent = input.agent; + if (input.sessionId !== undefined) eve.sessionId = input.sessionId; const ctx: EveMessageContext = { eve }; result = await handler(ctx, input.message); if (result === null || result === undefined) { diff --git a/packages/eve/src/eve-channel/types.ts b/packages/eve/src/eve-channel/types.ts index d643f37547..e88b889221 100644 --- a/packages/eve/src/eve-channel/types.ts +++ b/packages/eve/src/eve-channel/types.ts @@ -49,6 +49,8 @@ export type EveChannelCors = boolean | EveChannelCorsOptions; /** Low-level eve HTTP handle exposed to `eveChannel({ onMessage })`. */ export interface EveHandle { + /** Normalized root-relative descendant selected for this request. */ + readonly agent?: string; /** Route-auth result for the request; `onMessage` chooses session auth by returning `{ auth }`. */ readonly caller: SessionAuthContext | null; readonly request: Request; diff --git a/packages/eve/src/execution/durable-session-migrations/turn-workflow-v0-to-v1.ts b/packages/eve/src/execution/durable-session-migrations/turn-workflow-v0-to-v1.ts index df63776311..b925841755 100644 --- a/packages/eve/src/execution/durable-session-migrations/turn-workflow-v0-to-v1.ts +++ b/packages/eve/src/execution/durable-session-migrations/turn-workflow-v0-to-v1.ts @@ -9,11 +9,11 @@ * constructor evolves. */ import type { VersionMigration } from "./chain.js"; -import type { TurnWorkflowDispatchInput, TurnWorkflowInput } from "./turn-workflow.js"; +import type { TurnWorkflowDispatchInput } from "./turn-workflow.js"; export const turnWorkflowInputV0ToV1: VersionMigration = { from: 0, - migrate(prior: unknown): TurnWorkflowInput { + migrate(prior: unknown) { if (!isPreVersionTurnWorkflowInput(prior)) { throw new Error( "turn workflow input: version 0 value is not a recognized pre-version shape.", diff --git a/packages/eve/src/execution/durable-session-migrations/turn-workflow-v1-to-v2.ts b/packages/eve/src/execution/durable-session-migrations/turn-workflow-v1-to-v2.ts new file mode 100644 index 0000000000..39dea89728 --- /dev/null +++ b/packages/eve/src/execution/durable-session-migrations/turn-workflow-v1-to-v2.ts @@ -0,0 +1,28 @@ +import type { VersionMigration } from "#execution/durable-session-migrations/chain.js"; + +/** Version 2 carries the selected direct-agent node through every step in a turn. */ +export const turnWorkflowInputV1ToV2: VersionMigration = { + from: 1, + to: 2, + migrate(prior) { + if ( + typeof prior !== "object" || + prior === null || + !("stepInput" in prior) || + typeof prior.stepInput !== "object" || + prior.stepInput === null + ) { + throw new Error("turn workflow input: version 1 value is not a recognized shape."); + } + const stepInput = prior.stepInput as Record; + return { + ...prior, + stepInput: { + ...stepInput, + agentNodeId: undefined, + defaultBundle: (stepInput.serializedContext as Record)["eve.bundle"], + }, + version: 2, + }; + }, +}; diff --git a/packages/eve/src/execution/durable-session-migrations/turn-workflow.test.ts b/packages/eve/src/execution/durable-session-migrations/turn-workflow.test.ts index 23f73b004d..d3b24498f9 100644 --- a/packages/eve/src/execution/durable-session-migrations/turn-workflow.test.ts +++ b/packages/eve/src/execution/durable-session-migrations/turn-workflow.test.ts @@ -53,6 +53,8 @@ describe("turn workflow wire migrations", () => { driverCapabilities: { cancelledTurnSettle: true, turnInbox: true }, mode: "conversation", stepInput: { + agentNodeId: undefined, + defaultBundle: undefined, input: delivery, parentWritable, serializedContext: { state: "driver" }, @@ -85,6 +87,8 @@ describe("turn workflow wire migrations", () => { completionToken: "turn-token", mode: "conversation", stepInput: { + agentNodeId: undefined, + defaultBundle: undefined, input: delivery, parentWritable, serializedContext: { state: "pre-version" }, diff --git a/packages/eve/src/execution/durable-session-migrations/turn-workflow.ts b/packages/eve/src/execution/durable-session-migrations/turn-workflow.ts index d8119e5a21..a527535be5 100644 --- a/packages/eve/src/execution/durable-session-migrations/turn-workflow.ts +++ b/packages/eve/src/execution/durable-session-migrations/turn-workflow.ts @@ -21,8 +21,9 @@ import type { RunMode } from "#shared/run-mode.js"; import { runMigrationChain, type VersionMigration } from "./chain.js"; import { turnWorkflowInputV0ToV1 } from "./turn-workflow-v0-to-v1.js"; +import { turnWorkflowInputV1ToV2 } from "./turn-workflow-v1-to-v2.js"; -export const TURN_WORKFLOW_INPUT_VERSION = 1; +export const TURN_WORKFLOW_INPUT_VERSION = 2; /** Trusted runtime-action results collected by the parent turn driver. */ interface RuntimeActionResultStepInput { @@ -36,8 +37,12 @@ export type TurnStepPayload = | RuntimeActionResultStepInput; export interface TurnStepInput { + /** Resolved runtime node selected for this logical turn. */ + readonly agentNodeId?: string; /** Cancellation signal forwarded into the turn step. */ readonly abortSignal?: AbortSignal; + /** Serialized session-default bundle restored when the turn returns to the driver. */ + readonly defaultBundle?: unknown; readonly input: TurnStepPayload | undefined; readonly parentWritable: WritableStream; readonly serializedContext: Record; @@ -70,7 +75,10 @@ export interface TurnWorkflowDispatchInput { readonly sessionState: DurableSessionState; } -const turnWorkflowInputMigrations: readonly VersionMigration[] = [turnWorkflowInputV0ToV1]; +const turnWorkflowInputMigrations: readonly VersionMigration[] = [ + turnWorkflowInputV0ToV1, + turnWorkflowInputV1ToV2, +]; export function createTurnWorkflowInput(input: TurnWorkflowDispatchInput): TurnWorkflowInput { return { @@ -79,6 +87,8 @@ export function createTurnWorkflowInput(input: TurnWorkflowDispatchInput): TurnW driverCapabilities: { cancelledTurnSettle: true, turnInbox: true }, mode: input.mode, stepInput: { + agentNodeId: input.delivery.kind === "deliver" ? input.delivery.agentNodeId : undefined, + defaultBundle: input.serializedContext["eve.bundle"], input: input.delivery, parentWritable: input.parentWritable, serializedContext: input.serializedContext, diff --git a/packages/eve/src/execution/durable-session-store.ts b/packages/eve/src/execution/durable-session-store.ts index 08ca4345ca..ffd6d878bf 100644 --- a/packages/eve/src/execution/durable-session-store.ts +++ b/packages/eve/src/execution/durable-session-store.ts @@ -85,6 +85,7 @@ export interface DurableSession { readonly outputSchema?: JsonObject; readonly state?: SessionStateMap; readonly sandboxState?: SandboxState; + readonly sandboxStates?: Readonly>; readonly subagentDepth?: number; readonly workflowMaxSubagents?: number; readonly agent: { diff --git a/packages/eve/src/execution/parked-delivery-wait.test.ts b/packages/eve/src/execution/parked-delivery-wait.test.ts index e768021f25..9281a1381f 100644 --- a/packages/eve/src/execution/parked-delivery-wait.test.ts +++ b/packages/eve/src/execution/parked-delivery-wait.test.ts @@ -150,6 +150,44 @@ describe("nextTurnDelivery", () => { expect(bufferedDeliveries).toHaveLength(1); }); + it("partitions buffered turns by target agent while retaining same-target batching", async () => { + const inbox = createMockInbox([]); + vi.mocked(routeDeliverToChildren).mockImplementation(async (input) => ({ + kind: "continue", + remainder: input.delivery, + serializedContext: {}, + sessionState, + })); + const bufferedDeliveries: DeliverHookPayload[] = [ + { kind: "deliver", payloads: [{ message: "root" }] }, + { + agentNodeId: "node:researcher", + kind: "deliver", + payloads: [{ message: "research one" }], + }, + { + agentNodeId: "node:researcher", + kind: "deliver", + payloads: [{ message: "research two" }], + }, + ]; + + const root = await nextTurnDelivery({ ...waitInput(inbox), bufferedDeliveries }); + const researcher = await nextTurnDelivery({ ...waitInput(inbox), bufferedDeliveries }); + + expect(root).toMatchObject({ + delivery: { payloads: [{ message: "root" }] }, + kind: "turn", + }); + expect(researcher).toMatchObject({ + delivery: { + agentNodeId: "node:researcher", + payloads: [{ message: "research one" }, { message: "research two" }], + }, + kind: "turn", + }); + }); + it("buffers task deliveries until the authorization callback arrives", async () => { const inbox = createMockInbox([messageRead("deferred"), authorizationRead()]); const bufferedDeliveries: DeliverHookPayload[] = []; diff --git a/packages/eve/src/execution/parked-delivery-wait.ts b/packages/eve/src/execution/parked-delivery-wait.ts index 070388f4a8..379bffa14b 100644 --- a/packages/eve/src/execution/parked-delivery-wait.ts +++ b/packages/eve/src/execution/parked-delivery-wait.ts @@ -270,6 +270,7 @@ function takeBufferedTurnDelivery(bufferedDeliveries: DeliverHookPayload[]): Del const next = bufferedDeliveries[0]; if ( next === undefined || + next.agentNodeId !== first.agentNodeId || first.taskDeliveryId !== undefined || next.taskDeliveryId !== undefined || (caller !== undefined && next.caller !== undefined) diff --git a/packages/eve/src/execution/pending-turn-agent.test.ts b/packages/eve/src/execution/pending-turn-agent.test.ts new file mode 100644 index 0000000000..92d3b66ff9 --- /dev/null +++ b/packages/eve/src/execution/pending-turn-agent.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; + +import { PendingTurnAgentNodeIdKey } from "#context/keys.js"; +import { inheritPendingTurnAgent } from "#execution/pending-turn-agent.js"; + +describe("inheritPendingTurnAgent", () => { + const pendingContext = { [PendingTurnAgentNodeIdKey.name]: "node:researcher" }; + + it("resumes the selected agent for input responses", () => { + expect( + inheritPendingTurnAgent( + { + kind: "deliver", + payloads: [{ inputResponses: [{ optionId: "approve", requestId: "req-1" }] }], + }, + pendingContext, + ), + ).toMatchObject({ agentNodeId: "node:researcher" }); + }); + + it("does not apply a pending selector to a new user message", () => { + const delivery = { kind: "deliver" as const, payloads: [{ message: "new turn" }] }; + expect(inheritPendingTurnAgent(delivery, pendingContext)).toBe(delivery); + }); +}); diff --git a/packages/eve/src/execution/pending-turn-agent.ts b/packages/eve/src/execution/pending-turn-agent.ts new file mode 100644 index 0000000000..3f2c70dd1e --- /dev/null +++ b/packages/eve/src/execution/pending-turn-agent.ts @@ -0,0 +1,18 @@ +import type { HookPayload } from "#channel/types.js"; +import { PendingTurnAgentNodeIdKey } from "#context/keys.js"; + +/** Restores a selected turn agent for the response that settles its pending request. */ +export function inheritPendingTurnAgent( + delivery: HookPayload, + serializedContext: Record, +): HookPayload { + if ( + delivery.kind !== "deliver" || + delivery.agentNodeId !== undefined || + delivery.payloads.some((payload) => payload.message !== undefined) + ) { + return delivery; + } + const pendingNodeId = serializedContext[PendingTurnAgentNodeIdKey.name]; + return typeof pendingNodeId === "string" ? { ...delivery, agentNodeId: pendingNodeId } : delivery; +} diff --git a/packages/eve/src/execution/runtime-context.ts b/packages/eve/src/execution/runtime-context.ts index 683e4db4c9..030aac9637 100644 --- a/packages/eve/src/execution/runtime-context.ts +++ b/packages/eve/src/execution/runtime-context.ts @@ -8,6 +8,7 @@ import { ChannelDeliveryKey, ChannelRequestIdKey, ContinuationTokenKey, + DefaultSandboxOwnerNodeIdKey, DynamicSubagentAgentConfigKey, InitiatorAuthKey, ModeKey, @@ -18,6 +19,7 @@ import { } from "#context/keys.js"; import { BundleKey, type CompiledBundle } from "#runtime/sessions/runtime-context-keys.js"; import type { DynamicSubagentAgentConfig } from "#runtime/subagents/dynamic-agent-config.js"; +import { ROOT_RUNTIME_AGENT_NODE_ID } from "#runtime/graph.js"; /** * Builds the bootstrap {@link ContextContainer} for one run. @@ -32,6 +34,13 @@ export function buildRunContext(input: { const auth: SessionAuthContext | null = run.auth; ctx.set(BundleKey, bundle); + ctx.set( + DefaultSandboxOwnerNodeIdKey, + bundle.graph?.root.sandboxRegistry.sandbox?.inheritance?.nodeId ?? + bundle.graph?.root.nodeId ?? + bundle.nodeId ?? + ROOT_RUNTIME_AGENT_NODE_ID, + ); setChannelContext(ctx, run.adapter, { channelName: run.channelName }); if (run.channelMetadata !== undefined) { diff --git a/packages/eve/src/execution/session-command-inbox.test.ts b/packages/eve/src/execution/session-command-inbox.test.ts index e10f7fa3f0..83d2eb1a9a 100644 --- a/packages/eve/src/execution/session-command-inbox.test.ts +++ b/packages/eve/src/execution/session-command-inbox.test.ts @@ -120,7 +120,7 @@ describe("createSessionCommandInbox", () => { ); expect(createHookMock).toHaveBeenCalledOnce(); expect(createHookMock).toHaveBeenCalledWith({ - metadata: { sessionInboxWireVersion: 1 }, + metadata: { sessionInboxWireVersion: 2 }, token: "stable", }); await inbox.dispose(); diff --git a/packages/eve/src/execution/session.ts b/packages/eve/src/execution/session.ts index fa11653af1..b7023c80dd 100644 --- a/packages/eve/src/execution/session.ts +++ b/packages/eve/src/execution/session.ts @@ -210,6 +210,7 @@ export function projectToDurableSession(session: HarnessSession): DurableSession outputSchema?: HarnessSession["outputSchema"]; rootSessionId?: string; sandboxState?: HarnessSession["sandboxState"]; + sandboxStates?: HarnessSession["sandboxStates"]; sessionId: string; state?: HarnessSession["state"]; subagentDepth?: number; @@ -242,6 +243,9 @@ export function projectToDurableSession(session: HarnessSession): DurableSession if (session.sandboxState !== undefined) { durable.sandboxState = session.sandboxState; } + if (session.sandboxStates !== undefined) { + durable.sandboxStates = session.sandboxStates; + } if (session.state !== undefined) { durable.state = session.state; } @@ -299,6 +303,9 @@ export function hydrateDurableSession(input: { if (durable.sandboxState !== undefined) { session.sandboxState = durable.sandboxState; } + if (durable.sandboxStates !== undefined) { + session.sandboxStates = durable.sandboxStates; + } if (durable.state !== undefined) { session.state = durable.state; } diff --git a/packages/eve/src/execution/turn-agent-routing.ts b/packages/eve/src/execution/turn-agent-routing.ts new file mode 100644 index 0000000000..d27942d4d0 --- /dev/null +++ b/packages/eve/src/execution/turn-agent-routing.ts @@ -0,0 +1,61 @@ +import type { ContextContainer } from "#context/container.js"; +import { DefaultSandboxOwnerNodeIdKey, PendingTurnAgentNodeIdKey } from "#context/keys.js"; +import { getPendingAuthorization } from "#harness/authorization.js"; +import { hasPendingInputBatch } from "#harness/input-requests.js"; +import type { HarnessSession } from "#harness/types.js"; +import { getCompiledRuntimeAgentBundle } from "#runtime/sessions/compiled-agent-cache.js"; +import { BundleKey, type CompiledBundle } from "#runtime/sessions/runtime-context-keys.js"; + +/** Resolves the durable session default and the bundle selected for this turn. */ +export async function resolveTurnAgentBundles( + ctx: ContextContainer, + input: { readonly agentNodeId?: string; readonly defaultBundle?: unknown }, +): Promise<{ readonly bundle: CompiledBundle; readonly defaultBundle: CompiledBundle }> { + const defaultBundle = await resolveDefaultBundle(ctx, input.defaultBundle); + ctx.set( + DefaultSandboxOwnerNodeIdKey, + defaultBundle.graph.root.sandboxRegistry.sandbox?.inheritance?.nodeId ?? + defaultBundle.graph.root.nodeId, + ); + const activeBundle = ctx.require(BundleKey); + const bundle = + input.agentNodeId === undefined + ? defaultBundle + : input.agentNodeId === activeBundle.nodeId + ? activeBundle + : await getCompiledRuntimeAgentBundle({ + compiledArtifactsSource: defaultBundle.compiledArtifactsSource, + nodeId: input.agentNodeId, + }); + if (bundle !== defaultBundle) ctx.set(BundleKey, bundle); + return { bundle, defaultBundle }; +} + +/** Persists a selected node only while its turn is awaiting HITL or authorization. */ +export function updatePendingTurnAgent( + ctx: ContextContainer, + agentNodeId: string | undefined, + defaultBundle: CompiledBundle, + session: HarnessSession, +): void { + const pending = + hasPendingInputBatch(session.state) || getPendingAuthorization(session.state) !== undefined; + if (pending && agentNodeId !== undefined && agentNodeId !== defaultBundle.nodeId) { + ctx.set(PendingTurnAgentNodeIdKey, agentNodeId); + } else { + ctx.delete(PendingTurnAgentNodeIdKey); + } +} + +async function resolveDefaultBundle( + ctx: ContextContainer, + serializedDefaultBundle: unknown, +): Promise { + const activeBundle = ctx.require(BundleKey); + if (serializedDefaultBundle === undefined) return activeBundle; + const defaultNodeId = (serializedDefaultBundle as { readonly nodeId?: unknown }).nodeId; + if (activeBundle.nodeId === defaultNodeId) return activeBundle; + const codec = BundleKey.codec; + if (codec === undefined) throw new Error('Context key "eve.bundle" is missing a codec.'); + return await codec.deserialize(serializedDefaultBundle, ctx); +} diff --git a/packages/eve/src/execution/turn-execution-cursor.ts b/packages/eve/src/execution/turn-execution-cursor.ts index bdd082320e..6a355a3b2f 100644 --- a/packages/eve/src/execution/turn-execution-cursor.ts +++ b/packages/eve/src/execution/turn-execution-cursor.ts @@ -33,19 +33,25 @@ type TurnTerminalAction = /** Owns the mutable durable state cursor for one active turn workflow. */ export class TurnExecutionCursor extends SessionStateCursor { + readonly agentNodeId?: string; readonly controlToken: string; + readonly defaultBundle?: unknown; readonly parentWritable: WritableStream; private lastReportedContinuationToken: string; constructor(input: { + readonly agentNodeId?: string; readonly controlToken: string; + readonly defaultBundle?: unknown; readonly parentWritable: WritableStream; readonly serializedContext: Record; readonly sessionState: DurableSessionState; }) { super({ serializedContext: input.serializedContext, sessionState: input.sessionState }); + this.agentNodeId = input.agentNodeId; this.controlToken = input.controlToken; + this.defaultBundle = input.defaultBundle; this.lastReportedContinuationToken = input.sessionState.continuationToken; this.parentWritable = input.parentWritable; } @@ -64,6 +70,8 @@ export class TurnExecutionCursor extends SessionStateCursor { /** Builds the next atomic turn-step input from the cursor's current state. */ createStepInput(input: TurnStepPayload | undefined, abortSignal?: AbortSignal): TurnStepInput { return { + agentNodeId: this.agentNodeId, + defaultBundle: this.defaultBundle, abortSignal, input, parentWritable: this.parentWritable, @@ -82,7 +90,13 @@ export class TurnExecutionCursor extends SessionStateCursor { action: TurnTerminalAction, bufferedDeliveries: readonly DeliverHookPayload[], ): Promise { - this.adoptState(transition); + this.adoptState({ + ...transition, + serializedContext: + transition.serializedContext === undefined || this.defaultBundle === undefined + ? transition.serializedContext + : { ...transition.serializedContext, "eve.bundle": this.defaultBundle }, + }); await this.send({ action: { ...action, diff --git a/packages/eve/src/execution/turn-workflow.ts b/packages/eve/src/execution/turn-workflow.ts index 6d79a7065d..31ef1eb0bd 100644 --- a/packages/eve/src/execution/turn-workflow.ts +++ b/packages/eve/src/execution/turn-workflow.ts @@ -66,7 +66,9 @@ async function runTurnOwnedWorkflow(input: TurnWorkflowInput): Promise { // claiming so conflict replay is consumed by getConflict(), not a later iterator read. const iterator = inbox[Symbol.asyncIterator](); const cursor = new TurnExecutionCursor({ + agentNodeId: input.stepInput.agentNodeId, controlToken: input.completionToken, + defaultBundle: input.stepInput.defaultBundle, parentWritable: input.stepInput.parentWritable, serializedContext: input.stepInput.serializedContext, sessionState: input.stepInput.sessionState, @@ -531,6 +533,8 @@ async function runLegacyTurnWorkflow(input: TurnWorkflowInput): Promise { } currentStepInput = { + agentNodeId: currentStepInput.agentNodeId, + defaultBundle: currentStepInput.defaultBundle, input: undefined, parentWritable: currentStepInput.parentWritable, serializedContext: result.serializedContext, diff --git a/packages/eve/src/execution/wire/__snapshots__/session-inbox-wire.v2.test.ts.snap b/packages/eve/src/execution/wire/__snapshots__/session-inbox-wire.v2.test.ts.snap new file mode 100644 index 0000000000..d84fa92061 --- /dev/null +++ b/packages/eve/src/execution/wire/__snapshots__/session-inbox-wire.v2.test.ts.snap @@ -0,0 +1,3 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`session inbox wire v2 > pins the complete schema byte for byte 1`] = `"{"$schema":"https://json-schema.org/draft/2020-12/schema","oneOf":[{"additionalProperties":false,"properties":{"agentNodeId":{"type":"string"},"auth":{"anyOf":[{"additionalProperties":false,"properties":{"attributes":{"additionalProperties":{"anyOf":[{"type":"string"},{"items":{"type":"string"},"type":"array"}]},"propertyNames":{"type":"string"},"type":"object"},"authenticator":{"type":"string"},"issuer":{"type":"string"},"principalId":{"type":"string"},"principalType":{"type":"string"},"subject":{"type":"string"}},"required":["attributes","authenticator","principalId","principalType"],"type":"object"},{"type":"null"}]},"caller":{"additionalProperties":false,"properties":{"callId":{"type":"string"},"replyTo":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"hook","type":"string"},"token":{"type":"string"}},"required":["kind","token"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"callback","type":"string"},"token":{"type":"string"},"url":{"type":"string"}},"required":["kind","token","url"],"type":"object"}]},"subagentName":{"type":"string"},"taskId":{"type":"string"}},"required":["callId","replyTo","subagentName"],"type":"object"},"deliveryMetadata":{"items":{"additionalProperties":false,"properties":{"channelKind":{"type":"string"},"channelName":{"type":"string"},"deliveryId":{"type":"string"},"payloadIndex":{"maximum":9007199254740991,"minimum":0,"type":"integer"},"requestId":{"type":"string"},"requestTraceContext":{"additionalProperties":false,"properties":{"spanId":{"type":"string"},"traceFlags":{"type":"number"},"traceId":{"type":"string"}},"required":["spanId","traceFlags","traceId"],"type":"object"}},"required":["channelKind","channelName","deliveryId","payloadIndex"],"type":"object"},"type":"array"},"kind":{"const":"deliver","type":"string"},"payload":{"additionalProperties":{},"properties":{"context":{"items":{"type":"string"},"type":"array"},"inputResponses":{"items":{"additionalProperties":false,"properties":{"optionId":{"type":"string"},"requestId":{"type":"string"},"text":{"type":"string"}},"required":["requestId"],"type":"object"},"type":"array"},"message":{"anyOf":[{"type":"string"},{"items":{"oneOf":[{"additionalProperties":false,"properties":{"providerOptions":{"additionalProperties":{},"propertyNames":{"type":"string"},"type":"object"},"text":{"type":"string"},"type":{"const":"text","type":"string"}},"required":["text","type"],"type":"object"},{"additionalProperties":false,"properties":{"image":{},"mediaType":{"type":"string"},"providerOptions":{"additionalProperties":{},"propertyNames":{"type":"string"},"type":"object"},"type":{"const":"image","type":"string"}},"required":["image","type"],"type":"object"},{"additionalProperties":false,"properties":{"data":{},"filename":{"type":"string"},"mediaType":{"type":"string"},"providerOptions":{"additionalProperties":{},"propertyNames":{"type":"string"},"type":"object"},"type":{"const":"file","type":"string"}},"required":["data","mediaType","type"],"type":"object"}]},"type":"array"}]},"outputSchema":{},"task":{"additionalProperties":false,"properties":{"authorizationEvents":{"items":{"additionalProperties":false,"properties":{"hookPayload":{"additionalProperties":false,"properties":{"callId":{"type":"string"},"childSessionId":{"type":"string"},"event":{},"kind":{"const":"subagent-authorization-event","type":"string"},"subagentName":{"type":"string"}},"required":["callId","childSessionId","event","kind","subagentName"],"type":"object"},"taskId":{"type":"string"}},"required":["hookPayload","taskId"],"type":"object"},"type":"array"},"inputRequests":{"items":{"additionalProperties":false,"properties":{"hookPayload":{"additionalProperties":false,"properties":{"callId":{"type":"string"},"childContinuationToken":{"type":"string"},"childSessionId":{"type":"string"},"event":{"additionalProperties":false,"properties":{"requests":{"items":{"additionalProperties":false,"properties":{"action":{"additionalProperties":false,"properties":{"callId":{"type":"string"},"input":{},"kind":{"const":"tool-call","type":"string"},"toolName":{"type":"string"}},"required":["callId","input","kind","toolName"],"type":"object"},"allowFreeform":{"description":"Whether the user may answer with freeform text instead of selecting one of the provided options.","type":"boolean"},"display":{"description":"Rendering hint: the channel uses this to pick a UX treatment.","enum":["confirmation","select","text"],"type":"string"},"kind":{"description":"Framework-owned request source used to resolve, route, and render the response.","enum":["question","session-limit","tool-approval"],"type":"string"},"options":{"description":"Selectable answer options to present to the user.","items":{"additionalProperties":false,"properties":{"description":{"description":"Optional additional context for this option.","type":"string"},"id":{"description":"Stable identifier for the option.","type":"string"},"label":{"description":"User-facing label for the option.","type":"string"},"style":{"description":"Visual treatment hint for the option.","enum":["primary","danger","default"],"type":"string"}},"required":["id","label"],"type":"object"},"type":"array"},"prompt":{"description":"The prompt to present to the user.","type":"string"},"requestId":{"description":"Stable identifier for this request.","type":"string"}},"required":["action","kind","prompt","requestId"],"type":"object"},"type":"array"},"sequence":{"maximum":9007199254740991,"minimum":0,"type":"integer"},"stepIndex":{"maximum":9007199254740991,"minimum":0,"type":"integer"},"turnId":{"type":"string"}},"required":["requests","sequence","stepIndex","turnId"],"type":"object"},"kind":{"const":"subagent-input-request","type":"string"},"subagentName":{"type":"string"}},"required":["callId","childContinuationToken","childSessionId","event","kind","subagentName"],"type":"object"},"taskId":{"type":"string"}},"required":["hookPayload","taskId"],"type":"object"},"type":"array"},"views":{"items":{"oneOf":[{"additionalProperties":false,"properties":{"executor":{"additionalProperties":false,"properties":{"binding":{"additionalProperties":false,"properties":{"data":{"additionalProperties":{},"propertyNames":{"type":"string"},"type":"object"},"kind":{"type":"string"}},"required":["data","kind"],"type":"object"},"childSessionId":{"type":"string"},"childTurnId":{"type":"string"},"lifecycle":{"enum":["parked","terminal"],"type":"string"}},"type":"object"},"metadata":{"anyOf":[{"additionalProperties":false,"properties":{"agentId":{"type":"string"},"kind":{"const":"subagent","type":"string"},"mode":{"enum":["local","remote"],"type":"string"},"name":{"type":"string"}},"required":["agentId","kind","mode","name"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"type":"string"},"name":{"type":"string"}},"required":["kind","name"],"type":"object"}]},"status":{"const":"working","type":"string"},"taskId":{"type":"string"},"usage":{"properties":{"cacheReadTokens":{"maximum":9007199254740991,"minimum":0,"type":"integer"},"cacheWriteTokens":{"maximum":9007199254740991,"minimum":0,"type":"integer"},"inputTokens":{"maximum":9007199254740991,"minimum":0,"type":"integer"},"outputTokens":{"maximum":9007199254740991,"minimum":0,"type":"integer"}},"required":["cacheReadTokens","cacheWriteTokens","inputTokens","outputTokens"],"type":"object"}},"required":["metadata","taskId","status"],"type":"object"},{"additionalProperties":false,"properties":{"executor":{"additionalProperties":false,"properties":{"binding":{"additionalProperties":false,"properties":{"data":{"additionalProperties":{},"propertyNames":{"type":"string"},"type":"object"},"kind":{"type":"string"}},"required":["data","kind"],"type":"object"},"childSessionId":{"type":"string"},"childTurnId":{"type":"string"},"lifecycle":{"enum":["parked","terminal"],"type":"string"}},"type":"object"},"inputRequests":{"items":{},"type":"array"},"metadata":{"anyOf":[{"additionalProperties":false,"properties":{"agentId":{"type":"string"},"kind":{"const":"subagent","type":"string"},"mode":{"enum":["local","remote"],"type":"string"},"name":{"type":"string"}},"required":["agentId","kind","mode","name"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"type":"string"},"name":{"type":"string"}},"required":["kind","name"],"type":"object"}]},"status":{"const":"input_required","type":"string"},"taskId":{"type":"string"},"usage":{"properties":{"cacheReadTokens":{"maximum":9007199254740991,"minimum":0,"type":"integer"},"cacheWriteTokens":{"maximum":9007199254740991,"minimum":0,"type":"integer"},"inputTokens":{"maximum":9007199254740991,"minimum":0,"type":"integer"},"outputTokens":{"maximum":9007199254740991,"minimum":0,"type":"integer"}},"required":["cacheReadTokens","cacheWriteTokens","inputTokens","outputTokens"],"type":"object"}},"required":["metadata","taskId","inputRequests","status"],"type":"object"},{"additionalProperties":false,"properties":{"executor":{"additionalProperties":false,"properties":{"binding":{"additionalProperties":false,"properties":{"data":{"additionalProperties":{},"propertyNames":{"type":"string"},"type":"object"},"kind":{"type":"string"}},"required":["data","kind"],"type":"object"},"childSessionId":{"type":"string"},"childTurnId":{"type":"string"},"lifecycle":{"enum":["parked","terminal"],"type":"string"}},"type":"object"},"lastOutput":{"additionalProperties":false,"properties":{"data":{},"type":{"const":"result","type":"string"}},"required":["data","type"],"type":"object"},"metadata":{"anyOf":[{"additionalProperties":false,"properties":{"agentId":{"type":"string"},"kind":{"const":"subagent","type":"string"},"mode":{"enum":["local","remote"],"type":"string"},"name":{"type":"string"}},"required":["agentId","kind","mode","name"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"type":"string"},"name":{"type":"string"}},"required":["kind","name"],"type":"object"}]},"status":{"const":"completed","type":"string"},"taskId":{"type":"string"},"usage":{"properties":{"cacheReadTokens":{"maximum":9007199254740991,"minimum":0,"type":"integer"},"cacheWriteTokens":{"maximum":9007199254740991,"minimum":0,"type":"integer"},"inputTokens":{"maximum":9007199254740991,"minimum":0,"type":"integer"},"outputTokens":{"maximum":9007199254740991,"minimum":0,"type":"integer"}},"required":["cacheReadTokens","cacheWriteTokens","inputTokens","outputTokens"],"type":"object"}},"required":["metadata","taskId","lastOutput","status"],"type":"object"},{"additionalProperties":false,"properties":{"executor":{"additionalProperties":false,"properties":{"binding":{"additionalProperties":false,"properties":{"data":{"additionalProperties":{},"propertyNames":{"type":"string"},"type":"object"},"kind":{"type":"string"}},"required":["data","kind"],"type":"object"},"childSessionId":{"type":"string"},"childTurnId":{"type":"string"},"lifecycle":{"enum":["parked","terminal"],"type":"string"}},"type":"object"},"lastOutput":{"additionalProperties":false,"properties":{"data":{},"type":{"const":"error","type":"string"}},"required":["data","type"],"type":"object"},"metadata":{"anyOf":[{"additionalProperties":false,"properties":{"agentId":{"type":"string"},"kind":{"const":"subagent","type":"string"},"mode":{"enum":["local","remote"],"type":"string"},"name":{"type":"string"}},"required":["agentId","kind","mode","name"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"type":"string"},"name":{"type":"string"}},"required":["kind","name"],"type":"object"}]},"status":{"const":"failed","type":"string"},"taskId":{"type":"string"},"usage":{"properties":{"cacheReadTokens":{"maximum":9007199254740991,"minimum":0,"type":"integer"},"cacheWriteTokens":{"maximum":9007199254740991,"minimum":0,"type":"integer"},"inputTokens":{"maximum":9007199254740991,"minimum":0,"type":"integer"},"outputTokens":{"maximum":9007199254740991,"minimum":0,"type":"integer"}},"required":["cacheReadTokens","cacheWriteTokens","inputTokens","outputTokens"],"type":"object"}},"required":["metadata","taskId","lastOutput","status"],"type":"object"},{"additionalProperties":false,"properties":{"executor":{"additionalProperties":false,"properties":{"binding":{"additionalProperties":false,"properties":{"data":{"additionalProperties":{},"propertyNames":{"type":"string"},"type":"object"},"kind":{"type":"string"}},"required":["data","kind"],"type":"object"},"childSessionId":{"type":"string"},"childTurnId":{"type":"string"},"lifecycle":{"enum":["parked","terminal"],"type":"string"}},"type":"object"},"metadata":{"anyOf":[{"additionalProperties":false,"properties":{"agentId":{"type":"string"},"kind":{"const":"subagent","type":"string"},"mode":{"enum":["local","remote"],"type":"string"},"name":{"type":"string"}},"required":["agentId","kind","mode","name"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"type":"string"},"name":{"type":"string"}},"required":["kind","name"],"type":"object"}]},"status":{"const":"cancelled","type":"string"},"taskId":{"type":"string"},"usage":{"properties":{"cacheReadTokens":{"maximum":9007199254740991,"minimum":0,"type":"integer"},"cacheWriteTokens":{"maximum":9007199254740991,"minimum":0,"type":"integer"},"inputTokens":{"maximum":9007199254740991,"minimum":0,"type":"integer"},"outputTokens":{"maximum":9007199254740991,"minimum":0,"type":"integer"}},"required":["cacheReadTokens","cacheWriteTokens","inputTokens","outputTokens"],"type":"object"}},"required":["metadata","taskId","status"],"type":"object"}]},"type":"array"}},"type":"object"}},"type":"object"},"payloads":{"items":{"additionalProperties":{},"properties":{"context":{"items":{"type":"string"},"type":"array"},"inputResponses":{"items":{"additionalProperties":false,"properties":{"optionId":{"type":"string"},"requestId":{"type":"string"},"text":{"type":"string"}},"required":["requestId"],"type":"object"},"type":"array"},"message":{"anyOf":[{"type":"string"},{"items":{"oneOf":[{"additionalProperties":false,"properties":{"providerOptions":{"additionalProperties":{},"propertyNames":{"type":"string"},"type":"object"},"text":{"type":"string"},"type":{"const":"text","type":"string"}},"required":["text","type"],"type":"object"},{"additionalProperties":false,"properties":{"image":{},"mediaType":{"type":"string"},"providerOptions":{"additionalProperties":{},"propertyNames":{"type":"string"},"type":"object"},"type":{"const":"image","type":"string"}},"required":["image","type"],"type":"object"},{"additionalProperties":false,"properties":{"data":{},"filename":{"type":"string"},"mediaType":{"type":"string"},"providerOptions":{"additionalProperties":{},"propertyNames":{"type":"string"},"type":"object"},"type":{"const":"file","type":"string"}},"required":["data","mediaType","type"],"type":"object"}]},"type":"array"}]},"outputSchema":{},"task":{"additionalProperties":false,"properties":{"authorizationEvents":{"items":{"additionalProperties":false,"properties":{"hookPayload":{"additionalProperties":false,"properties":{"callId":{"type":"string"},"childSessionId":{"type":"string"},"event":{},"kind":{"const":"subagent-authorization-event","type":"string"},"subagentName":{"type":"string"}},"required":["callId","childSessionId","event","kind","subagentName"],"type":"object"},"taskId":{"type":"string"}},"required":["hookPayload","taskId"],"type":"object"},"type":"array"},"inputRequests":{"items":{"additionalProperties":false,"properties":{"hookPayload":{"additionalProperties":false,"properties":{"callId":{"type":"string"},"childContinuationToken":{"type":"string"},"childSessionId":{"type":"string"},"event":{"additionalProperties":false,"properties":{"requests":{"items":{"additionalProperties":false,"properties":{"action":{"additionalProperties":false,"properties":{"callId":{"type":"string"},"input":{},"kind":{"const":"tool-call","type":"string"},"toolName":{"type":"string"}},"required":["callId","input","kind","toolName"],"type":"object"},"allowFreeform":{"description":"Whether the user may answer with freeform text instead of selecting one of the provided options.","type":"boolean"},"display":{"description":"Rendering hint: the channel uses this to pick a UX treatment.","enum":["confirmation","select","text"],"type":"string"},"kind":{"description":"Framework-owned request source used to resolve, route, and render the response.","enum":["question","session-limit","tool-approval"],"type":"string"},"options":{"description":"Selectable answer options to present to the user.","items":{"additionalProperties":false,"properties":{"description":{"description":"Optional additional context for this option.","type":"string"},"id":{"description":"Stable identifier for the option.","type":"string"},"label":{"description":"User-facing label for the option.","type":"string"},"style":{"description":"Visual treatment hint for the option.","enum":["primary","danger","default"],"type":"string"}},"required":["id","label"],"type":"object"},"type":"array"},"prompt":{"description":"The prompt to present to the user.","type":"string"},"requestId":{"description":"Stable identifier for this request.","type":"string"}},"required":["action","kind","prompt","requestId"],"type":"object"},"type":"array"},"sequence":{"maximum":9007199254740991,"minimum":0,"type":"integer"},"stepIndex":{"maximum":9007199254740991,"minimum":0,"type":"integer"},"turnId":{"type":"string"}},"required":["requests","sequence","stepIndex","turnId"],"type":"object"},"kind":{"const":"subagent-input-request","type":"string"},"subagentName":{"type":"string"}},"required":["callId","childContinuationToken","childSessionId","event","kind","subagentName"],"type":"object"},"taskId":{"type":"string"}},"required":["hookPayload","taskId"],"type":"object"},"type":"array"},"views":{"items":{"oneOf":[{"additionalProperties":false,"properties":{"executor":{"additionalProperties":false,"properties":{"binding":{"additionalProperties":false,"properties":{"data":{"additionalProperties":{},"propertyNames":{"type":"string"},"type":"object"},"kind":{"type":"string"}},"required":["data","kind"],"type":"object"},"childSessionId":{"type":"string"},"childTurnId":{"type":"string"},"lifecycle":{"enum":["parked","terminal"],"type":"string"}},"type":"object"},"metadata":{"anyOf":[{"additionalProperties":false,"properties":{"agentId":{"type":"string"},"kind":{"const":"subagent","type":"string"},"mode":{"enum":["local","remote"],"type":"string"},"name":{"type":"string"}},"required":["agentId","kind","mode","name"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"type":"string"},"name":{"type":"string"}},"required":["kind","name"],"type":"object"}]},"status":{"const":"working","type":"string"},"taskId":{"type":"string"},"usage":{"properties":{"cacheReadTokens":{"maximum":9007199254740991,"minimum":0,"type":"integer"},"cacheWriteTokens":{"maximum":9007199254740991,"minimum":0,"type":"integer"},"inputTokens":{"maximum":9007199254740991,"minimum":0,"type":"integer"},"outputTokens":{"maximum":9007199254740991,"minimum":0,"type":"integer"}},"required":["cacheReadTokens","cacheWriteTokens","inputTokens","outputTokens"],"type":"object"}},"required":["metadata","taskId","status"],"type":"object"},{"additionalProperties":false,"properties":{"executor":{"additionalProperties":false,"properties":{"binding":{"additionalProperties":false,"properties":{"data":{"additionalProperties":{},"propertyNames":{"type":"string"},"type":"object"},"kind":{"type":"string"}},"required":["data","kind"],"type":"object"},"childSessionId":{"type":"string"},"childTurnId":{"type":"string"},"lifecycle":{"enum":["parked","terminal"],"type":"string"}},"type":"object"},"inputRequests":{"items":{},"type":"array"},"metadata":{"anyOf":[{"additionalProperties":false,"properties":{"agentId":{"type":"string"},"kind":{"const":"subagent","type":"string"},"mode":{"enum":["local","remote"],"type":"string"},"name":{"type":"string"}},"required":["agentId","kind","mode","name"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"type":"string"},"name":{"type":"string"}},"required":["kind","name"],"type":"object"}]},"status":{"const":"input_required","type":"string"},"taskId":{"type":"string"},"usage":{"properties":{"cacheReadTokens":{"maximum":9007199254740991,"minimum":0,"type":"integer"},"cacheWriteTokens":{"maximum":9007199254740991,"minimum":0,"type":"integer"},"inputTokens":{"maximum":9007199254740991,"minimum":0,"type":"integer"},"outputTokens":{"maximum":9007199254740991,"minimum":0,"type":"integer"}},"required":["cacheReadTokens","cacheWriteTokens","inputTokens","outputTokens"],"type":"object"}},"required":["metadata","taskId","inputRequests","status"],"type":"object"},{"additionalProperties":false,"properties":{"executor":{"additionalProperties":false,"properties":{"binding":{"additionalProperties":false,"properties":{"data":{"additionalProperties":{},"propertyNames":{"type":"string"},"type":"object"},"kind":{"type":"string"}},"required":["data","kind"],"type":"object"},"childSessionId":{"type":"string"},"childTurnId":{"type":"string"},"lifecycle":{"enum":["parked","terminal"],"type":"string"}},"type":"object"},"lastOutput":{"additionalProperties":false,"properties":{"data":{},"type":{"const":"result","type":"string"}},"required":["data","type"],"type":"object"},"metadata":{"anyOf":[{"additionalProperties":false,"properties":{"agentId":{"type":"string"},"kind":{"const":"subagent","type":"string"},"mode":{"enum":["local","remote"],"type":"string"},"name":{"type":"string"}},"required":["agentId","kind","mode","name"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"type":"string"},"name":{"type":"string"}},"required":["kind","name"],"type":"object"}]},"status":{"const":"completed","type":"string"},"taskId":{"type":"string"},"usage":{"properties":{"cacheReadTokens":{"maximum":9007199254740991,"minimum":0,"type":"integer"},"cacheWriteTokens":{"maximum":9007199254740991,"minimum":0,"type":"integer"},"inputTokens":{"maximum":9007199254740991,"minimum":0,"type":"integer"},"outputTokens":{"maximum":9007199254740991,"minimum":0,"type":"integer"}},"required":["cacheReadTokens","cacheWriteTokens","inputTokens","outputTokens"],"type":"object"}},"required":["metadata","taskId","lastOutput","status"],"type":"object"},{"additionalProperties":false,"properties":{"executor":{"additionalProperties":false,"properties":{"binding":{"additionalProperties":false,"properties":{"data":{"additionalProperties":{},"propertyNames":{"type":"string"},"type":"object"},"kind":{"type":"string"}},"required":["data","kind"],"type":"object"},"childSessionId":{"type":"string"},"childTurnId":{"type":"string"},"lifecycle":{"enum":["parked","terminal"],"type":"string"}},"type":"object"},"lastOutput":{"additionalProperties":false,"properties":{"data":{},"type":{"const":"error","type":"string"}},"required":["data","type"],"type":"object"},"metadata":{"anyOf":[{"additionalProperties":false,"properties":{"agentId":{"type":"string"},"kind":{"const":"subagent","type":"string"},"mode":{"enum":["local","remote"],"type":"string"},"name":{"type":"string"}},"required":["agentId","kind","mode","name"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"type":"string"},"name":{"type":"string"}},"required":["kind","name"],"type":"object"}]},"status":{"const":"failed","type":"string"},"taskId":{"type":"string"},"usage":{"properties":{"cacheReadTokens":{"maximum":9007199254740991,"minimum":0,"type":"integer"},"cacheWriteTokens":{"maximum":9007199254740991,"minimum":0,"type":"integer"},"inputTokens":{"maximum":9007199254740991,"minimum":0,"type":"integer"},"outputTokens":{"maximum":9007199254740991,"minimum":0,"type":"integer"}},"required":["cacheReadTokens","cacheWriteTokens","inputTokens","outputTokens"],"type":"object"}},"required":["metadata","taskId","lastOutput","status"],"type":"object"},{"additionalProperties":false,"properties":{"executor":{"additionalProperties":false,"properties":{"binding":{"additionalProperties":false,"properties":{"data":{"additionalProperties":{},"propertyNames":{"type":"string"},"type":"object"},"kind":{"type":"string"}},"required":["data","kind"],"type":"object"},"childSessionId":{"type":"string"},"childTurnId":{"type":"string"},"lifecycle":{"enum":["parked","terminal"],"type":"string"}},"type":"object"},"metadata":{"anyOf":[{"additionalProperties":false,"properties":{"agentId":{"type":"string"},"kind":{"const":"subagent","type":"string"},"mode":{"enum":["local","remote"],"type":"string"},"name":{"type":"string"}},"required":["agentId","kind","mode","name"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"type":"string"},"name":{"type":"string"}},"required":["kind","name"],"type":"object"}]},"status":{"const":"cancelled","type":"string"},"taskId":{"type":"string"},"usage":{"properties":{"cacheReadTokens":{"maximum":9007199254740991,"minimum":0,"type":"integer"},"cacheWriteTokens":{"maximum":9007199254740991,"minimum":0,"type":"integer"},"inputTokens":{"maximum":9007199254740991,"minimum":0,"type":"integer"},"outputTokens":{"maximum":9007199254740991,"minimum":0,"type":"integer"}},"required":["cacheReadTokens","cacheWriteTokens","inputTokens","outputTokens"],"type":"object"}},"required":["metadata","taskId","status"],"type":"object"}]},"type":"array"}},"type":"object"}},"type":"object"},"type":"array"},"requestId":{"type":"string"},"taskDeliveryId":{"type":"string"},"turnPolicy":{"enum":["queue","steer"],"type":"string"},"version":{"const":2,"type":"number"}},"required":["kind","payloads","version"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"session-timeout","type":"string"},"version":{"const":2,"type":"number"}},"required":["kind","version"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"clear","type":"string"},"version":{"const":2,"type":"number"}},"required":["kind","version"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"compact","type":"string"},"version":{"const":2,"type":"number"}},"required":["kind","version"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"reset","type":"string"},"reason":{"type":"string"},"version":{"const":2,"type":"number"}},"required":["kind","version"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"cancel","type":"string"},"taskId":{"type":"string"},"turnId":{"type":"string"},"version":{"const":2,"type":"number"}},"required":["kind","version"],"type":"object"}]}"`; diff --git a/packages/eve/src/execution/wire/session-inbox-contract.ts b/packages/eve/src/execution/wire/session-inbox-contract.ts index 5b65d4ad96..c8b1e0212f 100644 --- a/packages/eve/src/execution/wire/session-inbox-contract.ts +++ b/packages/eve/src/execution/wire/session-inbox-contract.ts @@ -1,5 +1,5 @@ /** Every explicit session-inbox wire version still supported by producers. */ -export const SESSION_INBOX_WIRE_VERSIONS = [1] as const; +export const SESSION_INBOX_WIRE_VERSIONS = [1, 2] as const; export type SessionInboxWireVersion = (typeof SESSION_INBOX_WIRE_VERSIONS)[number]; diff --git a/packages/eve/src/execution/wire/session-inbox-encoder.ts b/packages/eve/src/execution/wire/session-inbox-encoder.ts index 6ad30c5c7a..6fe46508fd 100644 --- a/packages/eve/src/execution/wire/session-inbox-encoder.ts +++ b/packages/eve/src/execution/wire/session-inbox-encoder.ts @@ -7,6 +7,10 @@ import { encodeSessionCommandV1, type SessionInboxWireV1, } from "#execution/wire/session-inbox-wire.v1.js"; +import { + encodeSessionCommandV2, + type SessionInboxWireV2, +} from "#execution/wire/session-inbox-wire.v2.js"; import { isSessionInboxWireVersion, SessionInboxWireError, @@ -18,17 +22,19 @@ import { encodeSessionCommandV0 } from "#execution/wire/session-inbox-wire.v0.js type SessionInboxCommand = DeliverHookPayload | SessionCommand | SessionTimeoutHookPayload; /** Current wire type consumed after migration. */ -export type SessionInboxWire = SessionInboxWireV1; +export type SessionInboxWire = SessionInboxWireV2; type LegacySessionInboxWireTarget = Extract; type VersionedSessionInboxEncoder = (command: SessionInboxCommand) => unknown; const versionedEncoders = { 1: encodeSessionCommandV1, + 2: encodeSessionCommandV2, } satisfies Record; /** Encodes a command for the selected session-inbox consumer. */ function encode(command: SessionInboxCommand, target: { readonly version: 1 }): SessionInboxWireV1; +function encode(command: SessionInboxCommand, target: { readonly version: 2 }): SessionInboxWireV2; function encode( command: SessionInboxCommand, target: { readonly version: SessionInboxWireVersion }, @@ -40,16 +46,21 @@ function encode( function encode( command: SessionInboxCommand, target: SessionInboxWireTarget, -): SessionInboxWireV1 | Record; +): SessionInboxWireV1 | SessionInboxWireV2 | Record; function encode( command: SessionInboxCommand, target: SessionInboxWireTarget, -): SessionInboxWireV1 | Record { +): SessionInboxWireV1 | SessionInboxWireV2 | Record { + if (command.kind === "send" && command.agentNodeId !== undefined && target.version < 2) { + throw new SessionInboxWireError( + `Cannot route this turn to a selected agent because the target session consumes session inbox wire version ${target.version}. Start a new session on the current deployment or send the turn without an agent selector.`, + ); + } if (target.version === 0) { return encodeSessionCommandV0(encodeSessionCommandV1(command), target.variant); } if (isSessionInboxWireVersion(target.version)) { - return versionedEncoders[target.version](command) as SessionInboxWireV1; + return versionedEncoders[target.version](command); } throw new SessionInboxWireError( `Cannot encode session inbox payload for unknown wire version ${JSON.stringify((target as { version?: unknown }).version)}.`, diff --git a/packages/eve/src/execution/wire/session-inbox-wire-v1-to-v2.ts b/packages/eve/src/execution/wire/session-inbox-wire-v1-to-v2.ts new file mode 100644 index 0000000000..364e864220 --- /dev/null +++ b/packages/eve/src/execution/wire/session-inbox-wire-v1-to-v2.ts @@ -0,0 +1,12 @@ +import type { VersionMigration } from "#execution/durable-session-migrations/chain.js"; +import { sessionInboxWireV1Schema } from "#execution/wire/session-inbox-wire.v1.js"; + +/** Version 2 adds an optional resolved direct-agent node to deliver envelopes. */ +export const sessionInboxWireV1ToV2: VersionMigration = { + from: 1, + to: 2, + migrate(prior) { + const parsed = sessionInboxWireV1Schema.parse(prior); + return { ...parsed, version: 2 }; + }, +}; diff --git a/packages/eve/src/execution/wire/session-inbox-wire.ts b/packages/eve/src/execution/wire/session-inbox-wire.ts index 5146a5f6a5..487e64350d 100644 --- a/packages/eve/src/execution/wire/session-inbox-wire.ts +++ b/packages/eve/src/execution/wire/session-inbox-wire.ts @@ -14,6 +14,7 @@ import { } from "#execution/wire/session-inbox-contract.js"; import type { SessionInboxWire } from "#execution/wire/session-inbox-encoder.js"; import { sessionInboxWireV0Migration } from "#execution/wire/session-inbox-wire.v0.js"; +import { sessionInboxWireV1ToV2 } from "#execution/wire/session-inbox-wire-v1-to-v2.js"; /** * The session inbox wire family: every payload persisted to a session's @@ -38,7 +39,10 @@ export { SessionInboxWireError } from "#execution/wire/session-inbox-contract.js /** Prefixes chain and schema failures alike, so messages read as one voice. */ const WIRE_LABEL = "session inbox payload"; -const sessionInboxMigrations: readonly VersionMigration[] = [sessionInboxWireV0Migration]; +const sessionInboxMigrations: readonly VersionMigration[] = [ + sessionInboxWireV0Migration, + sessionInboxWireV1ToV2, +]; /** * Decodes a persisted inbox payload or throws {@link SessionInboxWireError}. @@ -78,6 +82,7 @@ function normalizeWire(wire: SessionInboxWire): DecodedSessionInbox { switch (wire.kind) { case "deliver": return { + agentNodeId: wire.agentNodeId, auth: wire.auth, caller: wire.caller, deliveryMetadata: wire.deliveryMetadata, diff --git a/packages/eve/src/execution/wire/session-inbox-wire.v1.test.ts b/packages/eve/src/execution/wire/session-inbox-wire.v1.test.ts index d444e974ba..b8d993bda3 100644 --- a/packages/eve/src/execution/wire/session-inbox-wire.v1.test.ts +++ b/packages/eve/src/execution/wire/session-inbox-wire.v1.test.ts @@ -194,7 +194,7 @@ describe("session inbox wire v1", () => { }); it.each([ - ["a future wire version", { kind: "deliver", payloads: [], version: 2 }], + ["a future wire version", { kind: "deliver", payloads: [], version: 3 }], ["a non-numeric version", { kind: "deliver", payloads: [], version: "1" }], ["an unrecognized kind", { kind: "mystery", version: 1 }], ])("rejects %s instead of reinterpreting it", (_name, payload) => { diff --git a/packages/eve/src/execution/wire/session-inbox-wire.v2.test.ts b/packages/eve/src/execution/wire/session-inbox-wire.v2.test.ts new file mode 100644 index 0000000000..637a716338 --- /dev/null +++ b/packages/eve/src/execution/wire/session-inbox-wire.v2.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { z } from "#compiled/zod/index.js"; + +import { sessionInboxWire as sessionInboxWireEncoder } from "#execution/wire/session-inbox-encoder.js"; +import { sessionInboxWire as sessionInboxWireDecoder } from "#execution/wire/session-inbox-wire.js"; +import { sessionInboxWireV2Schema } from "#execution/wire/session-inbox-wire.v2.js"; + +describe("session inbox wire v2", () => { + it("pins the complete schema byte for byte", () => { + expect( + stableStringify( + z.toJSONSchema(sessionInboxWireV2Schema, { io: "input", unrepresentable: "any" }), + ), + ).toMatchSnapshot(); + }); + + it("round-trips a resolved direct-agent target", () => { + const encoded = sessionInboxWireEncoder.encode( + { + agentNodeId: "node:researcher", + kind: "send", + payload: { message: "investigate" }, + }, + { version: 2 }, + ); + + expect(sessionInboxWireV2Schema.safeParse(encoded).success).toBe(true); + expect(sessionInboxWireDecoder.decode(encoded)).toMatchObject({ + agentNodeId: "node:researcher", + kind: "deliver", + payloads: [{ message: "investigate" }], + }); + }); + + it("migrates version-1 deliveries as having no override", () => { + expect( + sessionInboxWireDecoder.decode({ + kind: "deliver", + payloads: [{ message: "legacy" }], + version: 1, + }), + ).toMatchObject({ + kind: "deliver", + payloads: [{ message: "legacy" }], + }); + expect( + sessionInboxWireDecoder.decode({ + kind: "deliver", + payloads: [{ message: "legacy" }], + version: 1, + }), + ).not.toHaveProperty("agentNodeId", expect.any(String)); + }); + + it("rejects targeted sends to consumers that predate direct routing", () => { + expect(() => + sessionInboxWireEncoder.encode( + { + agentNodeId: "node:researcher", + kind: "send", + payload: { message: "investigate" }, + }, + { version: 1 }, + ), + ).toThrow(/target session consumes session inbox wire version 1/); + }); +}); + +function stableStringify(value: unknown): string { + return JSON.stringify(sortKeys(value)); +} + +function sortKeys(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortKeys); + if (typeof value !== "object" || value === null) return value; + const entries = Object.entries(value as Record) + .filter(([, entry]) => entry !== undefined) + .sort(([left], [right]) => left.localeCompare(right)); + return Object.fromEntries(entries.map(([key, entry]) => [key, sortKeys(entry)])); +} diff --git a/packages/eve/src/execution/wire/session-inbox-wire.v2.ts b/packages/eve/src/execution/wire/session-inbox-wire.v2.ts new file mode 100644 index 0000000000..14320416a2 --- /dev/null +++ b/packages/eve/src/execution/wire/session-inbox-wire.v2.ts @@ -0,0 +1,63 @@ +import { z } from "#compiled/zod/index.js"; + +import type { + DeliverHookPayload, + SessionCommand, + SessionTimeoutHookPayload, +} from "#channel/types.js"; +import { coalesceDeliverPayloads } from "#execution/deliver-payloads.js"; +import { SessionInboxWireError } from "#execution/wire/session-inbox-contract.js"; +import { sessionInboxWireV1Schema } from "#execution/wire/session-inbox-wire.v1.js"; +import { formatValidationError } from "#runtime/validation.js"; + +const VERSION = 2; +const version = z.literal(VERSION); +const [deliver, timeout, clear, compact, reset, cancel] = sessionInboxWireV1Schema.options; + +/** The complete schema for persisted session-inbox wire version 2. */ +export const sessionInboxWireV2Schema = z.discriminatedUnion("kind", [ + deliver.omit({ version: true }).extend({ agentNodeId: z.string().optional(), version }).strict(), + timeout.omit({ version: true }).extend({ version }).strict(), + clear.omit({ version: true }).extend({ version }).strict(), + compact.omit({ version: true }).extend({ version }).strict(), + reset.omit({ version: true }).extend({ version }).strict(), + cancel.omit({ version: true }).extend({ version }).strict(), +]); + +export type SessionInboxWireV2 = z.infer; + +/** Builds and validates one complete version-2 wire value. */ +export function encodeSessionCommandV2( + command: DeliverHookPayload | SessionCommand | SessionTimeoutHookPayload, +): SessionInboxWireV2 { + const wire = + command.kind === "send" + ? { + agentNodeId: command.agentNodeId, + auth: command.auth, + caller: command.caller, + deliveryMetadata: + command.delivery === undefined ? undefined : [{ ...command.delivery, payloadIndex: 0 }], + kind: "deliver" as const, + payload: command.payload, + payloads: [command.payload], + requestId: command.requestId, + taskDeliveryId: command.taskDeliveryId, + turnPolicy: command.turnPolicy, + version: VERSION, + } + : command.kind === "deliver" + ? { + ...command, + payload: coalesceDeliverPayloads(command.payloads), + version: VERSION, + } + : { ...command, version: VERSION }; + const parsed = sessionInboxWireV2Schema.safeParse(wire); + if (!parsed.success) { + throw new SessionInboxWireError( + `Produced a session inbox payload that does not match wire version ${VERSION}: ${formatValidationError(parsed.error)}`, + ); + } + return parsed.data; +} diff --git a/packages/eve/src/execution/workflow-entry.ts b/packages/eve/src/execution/workflow-entry.ts index 8f9c76e4c5..7b7c7a52d8 100644 --- a/packages/eve/src/execution/workflow-entry.ts +++ b/packages/eve/src/execution/workflow-entry.ts @@ -26,6 +26,7 @@ import { import type { DurableSessionState } from "#execution/durable-session-store.js"; import type { NextDriverAction } from "#execution/next-driver-action.js"; import { nextTurnDelivery, type NextTurnInstruction } from "#execution/parked-delivery-wait.js"; +import { inheritPendingTurnAgent } from "#execution/pending-turn-agent.js"; import { SessionStateCursor } from "#execution/session-state-cursor.js"; import { cancelDescendantTurnsStep } from "#execution/cancel-descendant-turns-step.js"; import { dispatchAndAwaitTurn } from "#execution/turn-dispatch.js"; @@ -430,6 +431,7 @@ async function runDriverLoop(input: { caller, serializedContext: stateCursor.serializedContext, }); + const routedDelivery = inheritPendingTurnAgent(delivery, serializedContext); const turn = await dispatchAndAwaitTurn({ bufferedDeliveries, bufferedSessionControls, @@ -437,7 +439,7 @@ async function runDriverLoop(input: { capabilities: input.capabilities, commandInbox, controlToken: nextTurnControlToken(), - delivery, + delivery: routedDelivery, mode: input.mode, parentWritable: input.driverWritable, serializedContext, diff --git a/packages/eve/src/execution/workflow-runtime.ts b/packages/eve/src/execution/workflow-runtime.ts index 2ce6a4c559..e92177471e 100644 --- a/packages/eve/src/execution/workflow-runtime.ts +++ b/packages/eve/src/execution/workflow-runtime.ts @@ -136,10 +136,13 @@ export function createWorkflowRuntime(config: { readonly nodeId?: string; }): Runtime { return { - async createSession(input: RunInput): Promise { + async createSession( + input: RunInput, + options?: { readonly agentNodeId?: string }, + ): Promise { const bundle = await getCompiledRuntimeAgentBundle({ compiledArtifactsSource: config.compiledArtifactsSource, - nodeId: config.nodeId, + nodeId: options?.agentNodeId ?? config.nodeId, }); const ctx = buildRunContext({ bundle, diff --git a/packages/eve/src/execution/workflow-steps.ts b/packages/eve/src/execution/workflow-steps.ts index 2dca90c28e..b1a4a4ef42 100644 --- a/packages/eve/src/execution/workflow-steps.ts +++ b/packages/eve/src/execution/workflow-steps.ts @@ -24,13 +24,14 @@ import { CapabilitiesKey, HandleEventKey, ModeKey, + PendingTurnAgentNodeIdKey, SessionDynamicSubagentRuntimeRevisionKey, SessionDynamicToolRuntimeRevisionKey, TasksEnabledKey, TurnTaskDeliveryKey, TurnTaskStateKey, } from "#context/keys.js"; -import { BundleKey, ChannelKey } from "#runtime/sessions/runtime-context-keys.js"; +import { ChannelKey } from "#runtime/sessions/runtime-context-keys.js"; import { deserializeContext, serializeContext } from "#context/serialize.js"; import { emitTurnPreamble, @@ -105,6 +106,7 @@ import { resolveRuntimeCompiledArtifactsVersionedCacheKey } from "#runtime/cache import { createWorkflowRuntime } from "#execution/workflow-runtime.js"; import { TASK_UPDATE_TOOL_NAME } from "#tools/framework/task-contract.js"; import { stageAttachmentsToSandbox } from "#harness/attachment-staging.js"; +import { resolveTurnAgentBundles, updatePendingTurnAgent } from "#execution/turn-agent-routing.js"; const TASK_DONE_WITH_PENDING_INPUT_ERROR_MESSAGE = "Task mode cannot complete while input requests remain pending."; @@ -126,7 +128,7 @@ export async function turnStep(rawInput: TurnStepInput): Promise(items: readonly T[]): const deliveryMetadata = [...(first.deliveryMetadata ?? [])]; for (const item of rest) { + if (item.agentNodeId !== first.agentNodeId) { + throw new Error("Cannot coalesce deliveries targeting different agents."); + } const payloadOffset = payloads.length; if (item.auth !== undefined) { auth = item.auth; diff --git a/packages/eve/src/harness/types.ts b/packages/eve/src/harness/types.ts index 54e0c77ed0..b12b5f896d 100644 --- a/packages/eve/src/harness/types.ts +++ b/packages/eve/src/harness/types.ts @@ -90,6 +90,8 @@ export interface HarnessSession { readonly rootSessionId?: string; readonly sessionId: string; readonly sandboxState?: SandboxState; + /** Sandbox snapshots partitioned by the compiled node that owns each sandbox. */ + readonly sandboxStates?: Readonly>; readonly state?: SessionStateMap; /** * Number of local delegated subagent hops from the root session to this diff --git a/packages/eve/src/internal/nitro/routes/channel-dispatch.ts b/packages/eve/src/internal/nitro/routes/channel-dispatch.ts index d51fab0c53..f54a912f81 100644 --- a/packages/eve/src/internal/nitro/routes/channel-dispatch.ts +++ b/packages/eve/src/internal/nitro/routes/channel-dispatch.ts @@ -16,6 +16,7 @@ import { attachRouteChannelName, attachRemoteAgentStreamHeadersResolver, attachRouteSessionCreator, + attachAgentTargetResolver, } from "#internal/nitro/routes/channel-route-context.js"; import type { NitroArtifactsConfig } from "#internal/nitro/routes/runtime-artifacts.js"; import { traceChannelRequest } from "#internal/nitro/routes/channel-request-instrumentation.js"; @@ -242,13 +243,19 @@ function buildRouteArgs( channelName, metadata: deliverySource, runtime: bundle.runtime, + resolveAgentTarget: bundle.resolveAgentTarget, turnPolicy: channel?.turnPolicy, }); const attachSession = createAttachSessionFn(bundle.runtime, { ...deliverySource, + resolveAgentTarget: bundle.resolveAgentTarget, turnPolicy: channel?.turnPolicy, }); - const to = createCrossChannelToFn(bundle.runtime, toCrossChannelTargets(bundle.channels)); + const to = createCrossChannelToFn( + bundle.runtime, + toCrossChannelTargets(bundle.channels), + bundle.resolveAgentTarget, + ); const args = attachRouteSessionCreator( attachHomeRouteMetadata( @@ -271,19 +278,25 @@ function buildRouteArgs( ), { agentName: bundle.agentName }, ), - async (input) => - await bundle.runtime.createSession({ - ...input, - adapter, - channelName, - continuationToken: - input.continuationToken === undefined - ? undefined - : `${channelName}:${input.continuationToken}`, - delivery: createChannelDeliveryMetadata(deliverySource), - requestId, - }), + async ({ agentNodeId, ...input }) => + await bundle.runtime.createSession( + { + ...input, + adapter, + channelName, + continuationToken: + input.continuationToken === undefined + ? undefined + : `${channelName}:${input.continuationToken}`, + delivery: createChannelDeliveryMetadata(deliverySource), + requestId, + }, + { agentNodeId }, + ), ); + if (bundle.resolveAgentTarget !== undefined) { + attachAgentTargetResolver(args, bundle.resolveAgentTarget); + } if (bundle.resolveRemoteAgentStreamHeaders !== undefined) { attachRemoteAgentStreamHeadersResolver(args, bundle.resolveRemoteAgentStreamHeaders); } diff --git a/packages/eve/src/internal/nitro/routes/channel-route-context.ts b/packages/eve/src/internal/nitro/routes/channel-route-context.ts index 728e8278b0..f9bcc56b89 100644 --- a/packages/eve/src/internal/nitro/routes/channel-route-context.ts +++ b/packages/eve/src/internal/nitro/routes/channel-route-context.ts @@ -1,5 +1,6 @@ import type { RouteHandlerArgs } from "#channel/routes.js"; import type { RunHandle, RunInput } from "#channel/types.js"; +import type { AgentTargetResolver } from "#runtime/agent-target.js"; type AgentInfoRouteResponse = () => Promise; export interface HomeRouteMetadata { @@ -11,7 +12,9 @@ export interface HomeRouteMetadata { * established here is visible to `resolveSession` on the same channel. */ export type RouteSessionCreator = ( - input: Omit, + input: Omit & { + readonly agentNodeId?: string; + }, ) => Promise; export type RemoteAgentStreamHeadersResolver = (input: { @@ -21,6 +24,7 @@ export type RemoteAgentStreamHeadersResolver = (input: { }) => Promise>; const agentInfoRouteResponseKey = "__eveAgentInfoRouteResponse"; +const agentTargetResolverKey = "__eveAgentTargetResolver"; const homeRouteMetadataKey = "__eveHomeRouteMetadata"; const routeChannelNameKey = "__eveRouteChannelName"; const remoteAgentStreamHeadersResolverKey = "__eveRemoteAgentStreamHeadersResolver"; @@ -28,12 +32,27 @@ const routeSessionCreatorKey = "__eveRouteSessionCreator"; type InternalRouteArgs = RouteHandlerArgs & { [agentInfoRouteResponseKey]?: AgentInfoRouteResponse; + [agentTargetResolverKey]?: AgentTargetResolver; [homeRouteMetadataKey]?: HomeRouteMetadata; [routeChannelNameKey]?: string; [remoteAgentStreamHeadersResolverKey]?: RemoteAgentStreamHeadersResolver; [routeSessionCreatorKey]?: RouteSessionCreator; }; +export function attachAgentTargetResolver( + args: TArgs, + resolve: AgentTargetResolver, +): TArgs { + const routeArgs: InternalRouteArgs = args; + routeArgs[agentTargetResolverKey] = resolve; + return args; +} + +export function readAgentTargetResolver(args: RouteHandlerArgs): AgentTargetResolver | undefined { + const routeArgs: InternalRouteArgs = args; + return routeArgs[agentTargetResolverKey]; +} + export function attachRouteChannelName( args: TArgs, channelName: string, diff --git a/packages/eve/src/internal/nitro/routes/runtime-stack.ts b/packages/eve/src/internal/nitro/routes/runtime-stack.ts index 8787046775..cd61bda057 100644 --- a/packages/eve/src/internal/nitro/routes/runtime-stack.ts +++ b/packages/eve/src/internal/nitro/routes/runtime-stack.ts @@ -3,6 +3,7 @@ import { createWorkflowRuntime } from "#execution/workflow-runtime.js"; import { resolveRemoteAgentStreamHeaders } from "#execution/remote-agent-dispatch.js"; import type { RemoteAgentStreamHeadersResolver } from "#internal/nitro/routes/channel-route-context.js"; import { getCompiledRuntimeAgentBundle } from "#runtime/sessions/compiled-agent-cache.js"; +import { resolveAgentTarget, type AgentTargetResolver } from "#runtime/agent-target.js"; import type { ResolvedChannelDefinition } from "#runtime/types.js"; import { type NitroArtifactsConfig, @@ -22,6 +23,7 @@ export interface NitroChannelRuntimeBundle { readonly agentName: string; readonly channels: readonly ResolvedChannelDefinition[]; readonly resolveRemoteAgentStreamHeaders?: RemoteAgentStreamHeadersResolver; + readonly resolveAgentTarget?: AgentTargetResolver; readonly runtime: Runtime; } @@ -46,6 +48,7 @@ export async function resolveNitroChannelRuntimeBundle( channels: bundle.graph.root.channels, resolveRemoteAgentStreamHeaders: async (input) => await resolveRemoteAgentStreamHeaders({ bundle, ...input }), + resolveAgentTarget: (agent) => resolveAgentTarget(bundle.graph, agent), runtime, }; } diff --git a/packages/eve/src/internal/nitro/routes/schedule-task.ts b/packages/eve/src/internal/nitro/routes/schedule-task.ts index 77bf5287c4..a16a0207e8 100644 --- a/packages/eve/src/internal/nitro/routes/schedule-task.ts +++ b/packages/eve/src/internal/nitro/routes/schedule-task.ts @@ -3,6 +3,7 @@ import { createWorkflowRuntime } from "#execution/workflow-runtime.js"; import { loadResolvedModuleExport } from "#runtime/resolve-helpers.js"; import { loadResolvedCompiledScheduleByTaskName } from "#runtime/schedules/resolve-schedule.js"; import { getCompiledRuntimeAgentBundle } from "#runtime/sessions/compiled-agent-cache.js"; +import { resolveAgentTarget } from "#runtime/agent-target.js"; import type { NitroArtifactsConfig } from "#internal/nitro/routes/runtime-artifacts.js"; import { resolveNitroCompiledArtifactsSource } from "#internal/nitro/routes/runtime-artifacts.js"; import type { RuntimeCompiledArtifactsSource } from "#runtime/compiled-artifacts-source.js"; @@ -41,6 +42,7 @@ export async function dispatchScheduleTaskFromArtifacts( const dispatcher = new ScheduleDispatcher({ runtime, channels: bundle.graph.root.channels, + resolveAgentTarget: (agent) => resolveAgentTarget(bundle.graph, agent), }); const dispatchInput: { diff --git a/packages/eve/src/public/channels/index.ts b/packages/eve/src/public/channels/index.ts index ca42631d7e..94276cff00 100644 --- a/packages/eve/src/public/channels/index.ts +++ b/packages/eve/src/public/channels/index.ts @@ -10,6 +10,8 @@ export { DELETE, WS, type AttachSessionFn, + AgentTargetError, + type AgentTargetErrorCode, type CancelTurnResult, type ClearSessionResult, type CompactSessionResult, diff --git a/packages/eve/src/public/definitions/channel.ts b/packages/eve/src/public/definitions/channel.ts index 8b7a7e65e1..257fc79c5e 100644 --- a/packages/eve/src/public/definitions/channel.ts +++ b/packages/eve/src/public/definitions/channel.ts @@ -37,6 +37,8 @@ export type { export type { Session, SessionHandle } from "#channel/session.js"; export type { ChannelAudience, ChannelAudienceMetadata } from "#shared/channel-audience.js"; export type { SessionRespondOptions, SessionSendOptions } from "#channel/session.js"; +export { AgentTargetError } from "#runtime/agent-target.js"; +export type { AgentTargetErrorCode } from "#runtime/agent-target.js"; export type { ChannelFrom, ChannelReceiveContext, diff --git a/packages/eve/src/runtime/agent-target.test.ts b/packages/eve/src/runtime/agent-target.test.ts new file mode 100644 index 0000000000..e9f72e5a8b --- /dev/null +++ b/packages/eve/src/runtime/agent-target.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; + +import type { ResolvedAgentGraphBundle, ResolvedRuntimeAgentNode } from "#runtime/graph.js"; +import { AgentTargetError, resolveAgentTarget } from "#runtime/agent-target.js"; + +describe("resolveAgentTarget", () => { + it("walks nested static local descendants and normalizes outer whitespace", () => { + const critic = createNode("critic"); + const researcher = createNode("researcher", [["critic", staticLocal("critic", critic.nodeId)]]); + const root = createNode("__root__", [ + ["researcher", staticLocal("researcher", researcher.nodeId)], + ]); + const graph = createGraph(root, researcher, critic); + + expect(resolveAgentTarget(graph, " researcher/critic ")).toEqual({ + nodeId: "critic", + path: "researcher/critic", + }); + }); + + it.each([ + ["", "invalid_agent_path", 400], + ["/researcher", "invalid_agent_path", 400], + ["researcher//critic", "invalid_agent_path", 400], + ["researcher/../critic", "invalid_agent_path", 400], + ["missing", "agent_not_found", 404], + ] as const)("rejects %j with a stable typed error", (path, code, status) => { + const graph = createGraph(createNode("__root__")); + + expect(() => resolveAgentTarget(graph, path)).toThrowError( + expect.objectContaining({ code, status }) as AgentTargetError, + ); + }); + + it("rejects remote and dynamic descendants explicitly", () => { + const root = createNode("__root__", [["remote", remote("remote")]], ["dynamic"]); + const graph = createGraph(root); + + for (const path of ["remote", "dynamic"]) { + expect(() => resolveAgentTarget(graph, path)).toThrowError( + expect.objectContaining({ + code: "agent_not_directly_invocable", + status: 400, + }) as AgentTargetError, + ); + } + }); +}); + +function createGraph(...nodes: ResolvedRuntimeAgentNode[]): ResolvedAgentGraphBundle { + const root = nodes[0]!; + return { nodesByNodeId: new Map(nodes.map((node) => [node.nodeId, node])), root }; +} + +function createNode( + nodeId: string, + entries: readonly (readonly [string, unknown])[] = [], + dynamicNames: readonly string[] = [], +): ResolvedRuntimeAgentNode { + return { + nodeId, + subagentRegistry: { + dynamicResolvers: dynamicNames.map((name) => ({ name })), + subagentsByName: new Map(entries), + }, + } as never; +} + +function staticLocal(name: string, nodeId: string) { + return { definition: { description: name, kind: "subagent", name, nodeId } }; +} + +function remote(name: string) { + return { definition: { description: name, kind: "remote", name, nodeId: name } }; +} diff --git a/packages/eve/src/runtime/agent-target.ts b/packages/eve/src/runtime/agent-target.ts new file mode 100644 index 0000000000..27b8f74b09 --- /dev/null +++ b/packages/eve/src/runtime/agent-target.ts @@ -0,0 +1,100 @@ +import type { ResolvedAgentGraphBundle } from "#runtime/graph.js"; + +export type AgentTargetErrorCode = + | "invalid_agent_path" + | "agent_not_found" + | "agent_not_directly_invocable"; + +/** Error raised when a public agent selector cannot resolve to a static local descendant. */ +export class AgentTargetError extends Error { + readonly code: AgentTargetErrorCode; + readonly status: 400 | 404; + + constructor(code: AgentTargetErrorCode, message: string) { + super(message); + this.name = "AgentTargetError"; + this.code = code; + this.status = code === "agent_not_found" ? 404 : 400; + } +} + +/** Validated public selector and its runtime-only compiled node id. */ +export interface ResolvedAgentTarget { + readonly nodeId: string; + readonly path: string; +} + +export type AgentTargetResolver = (agent: string) => ResolvedAgentTarget; + +/** Resolves one root-relative public path through the static local runtime graph. */ +export function resolveAgentTarget( + graph: ResolvedAgentGraphBundle, + requestedPath: string, +): ResolvedAgentTarget { + const path = normalizeAgentTargetPath(requestedPath); + const segments = path.split("/"); + let node = graph.root; + const resolvedSegments: string[] = []; + + for (const segment of segments) { + resolvedSegments.push(segment); + const resolvedPath = resolvedSegments.join("/"); + const registered = node.subagentRegistry.subagentsByName.get(segment); + if (registered === undefined) { + if (node.subagentRegistry.dynamicResolvers.some((resolver) => resolver.name === segment)) { + throw new AgentTargetError( + "agent_not_directly_invocable", + `Agent "${resolvedPath}" is dynamic. Direct invocation only supports an entirely static local path.`, + ); + } + throw new AgentTargetError( + "agent_not_found", + `Agent "${resolvedPath}" was not found. Only statically declared local descendants can be invoked directly.`, + ); + } + + const definition = registered.definition; + if (definition.kind === "remote") { + throw new AgentTargetError( + "agent_not_directly_invocable", + `Agent "${resolvedPath}" is remote. Direct invocation only supports statically declared local descendants.`, + ); + } + if (definition.dynamic !== undefined) { + throw new AgentTargetError( + "agent_not_directly_invocable", + `Agent "${resolvedPath}" is dynamic. Direct invocation only supports an entirely static local path.`, + ); + } + + const child = graph.nodesByNodeId.get(definition.nodeId); + if (child === undefined) { + throw new AgentTargetError( + "agent_not_found", + `Agent "${resolvedPath}" is not available in the compiled runtime graph.`, + ); + } + node = child; + } + + return { nodeId: node.nodeId, path }; +} + +function normalizeAgentTargetPath(requestedPath: string): string { + const path = requestedPath.trim(); + const segments = path.split("/"); + if ( + path.length === 0 || + path.includes("\\") || + segments.some( + (segment) => + segment.length === 0 || segment === "." || segment === ".." || segment !== segment.trim(), + ) + ) { + throw new AgentTargetError( + "invalid_agent_path", + 'Invalid agent path. Use a root-relative "/"-separated path such as "researcher/critic".', + ); + } + return path; +} diff --git a/packages/eve/src/shared/channel-definition.ts b/packages/eve/src/shared/channel-definition.ts index 4660f6ea57..47a46c94d4 100644 --- a/packages/eve/src/shared/channel-definition.ts +++ b/packages/eve/src/shared/channel-definition.ts @@ -37,6 +37,8 @@ export type FetchFileFunction = ( * schedule proactively routes a message to it. */ export interface GenericReceiveInput> { + /** Root-relative static local descendant selected by the sender. */ + readonly agent?: string; readonly message: string | UserContent; readonly target: Readonly; readonly auth: SessionAuthContext | null; diff --git a/packages/eve/test/scenarios/dev-server-harness.ts b/packages/eve/test/scenarios/dev-server-harness.ts index 235f07e74f..bfb57e11d4 100644 --- a/packages/eve/test/scenarios/dev-server-harness.ts +++ b/packages/eve/test/scenarios/dev-server-harness.ts @@ -219,13 +219,20 @@ function spawnEveDev( ): ChildProcessByStdio { const eveBinPath = join(appRoot, "node_modules", "eve", "bin", "eve.js"); const command = options.runtime === "bun" ? "bun" : process.execPath; + const requestedNodeEnv = options.env?.NODE_ENV; + const nodeEnv = + requestedNodeEnv === "development" || + requestedNodeEnv === "production" || + requestedNodeEnv === "test" + ? requestedNodeEnv + : "test"; return spawn(command, [eveBinPath, "dev", "--no-ui", "--host", "127.0.0.1", "--port", "0"], { cwd: appRoot, env: { ...process.env, ...options.env, - NODE_ENV: "test", + NODE_ENV: nodeEnv, }, stdio: ["ignore", "pipe", "pipe"], }); diff --git a/packages/eve/test/scenarios/direct-agent-invocation.scenario.test.ts b/packages/eve/test/scenarios/direct-agent-invocation.scenario.test.ts new file mode 100644 index 0000000000..fb1b080d96 --- /dev/null +++ b/packages/eve/test/scenarios/direct-agent-invocation.scenario.test.ts @@ -0,0 +1,502 @@ +import { describe, expect, it } from "vitest"; + +import { Client } from "../../src/client/client.js"; +import { + type ScenarioAppDescriptor, + useScenarioApp, +} from "../../src/internal/testing/scenario-app.js"; +import { startEveDev, waitForCondition } from "./dev-server-harness.js"; + +const scenarioApp = useScenarioApp(); +const SCENARIO_TIMEOUT_MS = 360_000; + +const DESCRIPTOR: ScenarioAppDescriptor = { + dependencies: { + "@eve/catalog": "file:./catalog", + zod: "4.4.3", + }, + files: { + "catalog/package.json": JSON.stringify({ + name: "@eve/catalog", + version: "0.0.0", + type: "module", + exports: "./index.js", + }), + "catalog/index.js": `export const channelEntries = () => [ + { slug: "eve", surfaces: { scaffoldable: true } }, + { slug: "slack", surfaces: { scaffoldable: true } }, +]; +export const connectionEntries = () => []; +export const connectionProtocols = (connection) => [ + connection.mcp ? "mcp" : null, + connection.openapi ? "openapi" : null, +].filter(Boolean); +`, + "agent/agent.ts": `import { defineAgent } from "eve"; +import { mockModel } from "eve/evals"; + +export default defineAgent({ + model: mockModel((request) => { + const history = request.messages.map((message) => message.text).join("\\n"); + if (request.lastUserMessage?.includes("root first")) return "ROOT_FIRST"; + if (request.lastUserMessage?.includes("root third")) { + return history.includes("RESEARCHER_SAW_ROOT") ? "ROOT_SAW_RESEARCHER" : "ROOT_LOST_RESEARCHER"; + } + if (request.lastUserMessage?.includes("root after hitl")) { + return history.includes("RESEARCHER_HITL_RESUMED") ? "ROOT_AFTER_HITL" : "ROOT_LOST_HITL"; + } + return "ROOT_UNEXPECTED"; + }), + modelContextWindowTokens: 32_000, +}); +`, + "agent/instructions.md": "Run as the root test agent.\n", + "agent/channels/eve.ts": `import { none } from "eve/channels/auth"; +import { eveChannel } from "eve/channels/eve"; + +export default eveChannel({ + auth: none(), + onMessage(ctx) { + return { + auth: ctx.eve.caller, + context: [\`HTTP_AGENT=\${ctx.eve.agent ?? "root"}\`], + }; + }, +}); +`, + "agent/channels/slash.ts": `import { defineChannel, GET, POST } from "eve/channels"; + +const completed = []; + +export default defineChannel({ + routes: [ + POST("/slash", async (request, { from }) => { + const body = await request.json(); + const session = await from(body.threadId).send(body.message, { + agent: body.agent, + auth: null, + }); + return Response.json({ sessionId: session.id }, { status: 202 }); + }), + GET("/slash/events", async () => Response.json(completed)), + ], + events: { + "message.completed"(event, _channel, ctx) { + completed.push({ message: event.message, sessionId: ctx.session.id }); + }, + }, +}); +`, + "agent/channels/queue.ts": `import { defineChannel, GET, POST } from "eve/channels"; + +const events: Array<{ message?: string; sessionId: string; type: string }> = []; + +export default defineChannel({ + turnPolicy: "queue", + routes: [ + POST("/queue", async (request, { from }) => { + const body = await request.json(); + const session = await from(body.threadId).send(body.message, { + agent: body.agent, + auth: null, + }); + return Response.json({ sessionId: session.id }, { status: 202 }); + }), + GET("/queue/events", async () => Response.json(events)), + ], + events: { + "turn.started"(_event, _channel, ctx) { + events.push({ sessionId: ctx.session.id, type: "turn.started" }); + }, + "message.completed"(event, _channel, ctx) { + events.push({ message: event.message, sessionId: ctx.session.id, type: "message.completed" }); + }, + }, +}); +`, + "agent/channels/steer.ts": `import { defineChannel, GET, POST } from "eve/channels"; + +const events: Array<{ message?: string; sessionId: string; type: string }> = []; + +export default defineChannel({ + turnPolicy: "steer", + routes: [ + POST("/steer", async (request, { from }) => { + const body = await request.json(); + const session = await from(body.threadId).send(body.message, { + agent: body.agent, + auth: null, + }); + return Response.json({ sessionId: session.id }, { status: 202 }); + }), + GET("/steer/events", async () => Response.json(events)), + ], + events: { + "turn.started"(_event, _channel, ctx) { + events.push({ sessionId: ctx.session.id, type: "turn.started" }); + }, + "turn.cancelled"(_event, _channel, ctx) { + events.push({ sessionId: ctx.session.id, type: "turn.cancelled" }); + }, + "message.completed"(event, _channel, ctx) { + events.push({ message: event.message, sessionId: ctx.session.id, type: "message.completed" }); + }, + }, +}); +`, + "agent/subagents/researcher/agent.ts": `import { defineAgent } from "eve"; +import { mockModel } from "eve/evals"; + +export default defineAgent({ + description: "Research direct-invocation requests.", + model: mockModel(async (request) => { + const history = request.messages.map((message) => message.text).join("\\n"); + const last = request.lastUserMessage ?? ""; + if (last.includes("research second")) { + return history.includes("ROOT_FIRST") && history.includes("HTTP_AGENT=researcher") + ? "RESEARCHER_SAW_ROOT" + : "RESEARCHER_LOST_ROOT"; + } + if (last.includes("direct create")) { + return history.includes("HTTP_AGENT=researcher") ? "RESEARCHER_DEFAULT" : "RESEARCHER_NO_SELECTOR"; + } + if (last.includes("follow default")) { + return history.includes("RESEARCHER_DEFAULT") ? "RESEARCHER_DEFAULT_FOLLOW" : "RESEARCHER_LOST_DEFAULT"; + } + if (last.includes("surface check")) { + const result = request.toolResults.find((entry) => entry.name === "inspect_surface"); + if (result === undefined) { + const hasInstructions = request.messages.some((message) => + message.role === "system" && message.text.includes("RESEARCHER_INSTRUCTIONS_TOKEN"), + ); + const hasTool = request.tools.some((tool) => tool.name === "inspect_surface"); + return hasInstructions && hasTool + ? { toolCalls: [{ id: "surface-check", input: {}, name: "inspect_surface" }] } + : "RESEARCHER_SURFACE_MISSING"; + } + const output = JSON.stringify(result.output); + return output.includes('"sandboxOwner":"researcher"') && /"hookTurns":[1-9]/.test(output) + ? "RESEARCHER_SURFACE_OK" + : "RESEARCHER_SURFACE_BAD:" + output; + } + if (last.includes("hitl target")) { + const answered = request.toolResults.some((entry) => entry.name === "ask_question"); + return answered + ? "RESEARCHER_HITL_RESUMED" + : { + toolCalls: [{ + id: "researcher-question", + input: { + options: [{ id: "yes", label: "Yes" }, { id: "no", label: "No" }], + prompt: "Resume the researcher turn?", + }, + name: "ask_question", + }], + }; + } + if (last.includes("queue first")) { + await new Promise((resolve) => setTimeout(resolve, 800)); + return "QUEUE_RESEARCHER"; + } + if (last.includes("steer first")) { + await new Promise((resolve) => setTimeout(resolve, 1_500)); + return "STEER_RESEARCHER_SHOULD_CANCEL"; + } + if (last.includes("steer default")) return "STEER_RESEARCHER_DEFAULT"; + if (last.includes("slash research")) return "CHANNEL_RESEARCHER"; + return "RESEARCHER_UNEXPECTED"; + }), + modelContextWindowTokens: 32_000, +}); +`, + "agent/subagents/researcher/instructions.md": + "Run as the researcher test agent. RESEARCHER_INSTRUCTIONS_TOKEN\n", + "agent/subagents/researcher/lib/surface-state.ts": `import { defineState } from "eve/context"; + +export const surfaceState = defineState("researcher.surface", () => ({ turns: 0 })); +`, + "agent/subagents/researcher/hooks/surface.ts": `import { defineHook } from "eve/hooks"; +import { surfaceState } from "../lib/surface-state"; + +export default defineHook({ + events: { + "turn.started"() { + surfaceState.update((state) => ({ turns: state.turns + 1 })); + }, + }, +}); +`, + "agent/subagents/researcher/tools/inspect_surface.ts": `import { defineTool } from "eve/tools"; +import { z } from "zod"; +import { surfaceState } from "../lib/surface-state"; + +export default defineTool({ + description: "Inspect the researcher hook and sandbox.", + inputSchema: z.object({}), + async execute(_input, ctx) { + const sandbox = await ctx.getSandbox(); + const sandboxOwner = await sandbox.readTextFile({ path: "owner.txt" }); + return { hookTurns: surfaceState.get().turns, sandboxOwner: sandboxOwner?.trim() }; + }, +}); +`, + "agent/subagents/researcher/sandbox/sandbox.ts": `import { defineSandbox } from "eve/sandbox"; + +export default defineSandbox({}); +`, + "agent/subagents/researcher/sandbox/workspace/owner.txt": "researcher\n", + "agent/subagents/researcher/subagents/critic/agent.ts": `import { defineAgent } from "eve"; +import { mockModel } from "eve/evals"; + +export default defineAgent({ + description: "Critique direct-invocation requests.", + model: mockModel((request) => { + if (request.lastUserMessage?.includes("queue critic")) return "QUEUE_CRITIC"; + if (request.lastUserMessage?.includes("steer critic")) return "STEER_CRITIC"; + return "CRITIC_DIRECT"; + }), + modelContextWindowTokens: 32_000, +}); +`, + "agent/subagents/researcher/subagents/critic/instructions.md": + "Run as the nested critic test agent.\n", + "agent/subagents/conditional/agent.ts": `import { defineAgent, defineDynamic } from "eve"; + +export default defineDynamic({ + events: { + "session.started": () => defineAgent({ + description: "A dynamic conditional agent.", + model: "openai/gpt-5.4-mini", + }), + }, +}); +`, + "agent/subagents/conditional/instructions.md": "Run conditionally.\n", + "agent/subagents/remote.ts": `import { defineRemoteAgent } from "eve"; + +export default defineRemoteAgent({ + description: "An unreachable remote test agent.", + url: "https://remote.invalid", +}); +`, + }, + installDependencies: true, + name: "direct-agent-invocation", +}; + +describe("direct agent invocation", () => { + it( + "shares history for one-turn overrides, persists targeted defaults, and preserves channel flow", + async () => { + const app = await scenarioApp(DESCRIPTOR); + const server = await startEveDev(app.appRoot, { + env: { EVE_MOCK_AUTHORED_MODELS: "", NODE_ENV: "production" }, + }); + + try { + const client = new Client({ host: server.url }); + const root = await client.sessions.create({ message: "root first" }); + const first = await root.response.result(); + const researcher = await root.session + .send("research second", { agent: "researcher" }) + .then((response) => response.result()); + const backToRoot = await root.session + .send("root third") + .then((response) => response.result()); + + expect(first.message).toBe("ROOT_FIRST"); + expect(researcher.message, JSON.stringify(researcher.events, null, 2)).toBe( + "RESEARCHER_SAW_ROOT", + ); + expect(backToRoot.message).toBe("ROOT_SAW_RESEARCHER"); + expect([first.sessionId, researcher.sessionId, backToRoot.sessionId]).toEqual([ + root.session.state.sessionId, + root.session.state.sessionId, + root.session.state.sessionId, + ]); + expect(researcher.events.some((event) => event.type.startsWith("subagent."))).toBe(false); + + const surface = await root.session + .send("surface check", { agent: "researcher" }) + .then((response) => response.result()); + expect(surface.message).toBe("RESEARCHER_SURFACE_OK"); + + const hitlPending = await root.session + .send("hitl target", { agent: "researcher" }) + .then((response) => response.result()); + expect(hitlPending.status).toBe("waiting"); + const inputRequested = hitlPending.events.find((event) => event.type === "input.requested"); + if (inputRequested?.type !== "input.requested") { + throw new Error("Expected the targeted researcher turn to request input."); + } + const requestId = inputRequested.data.requests[0]?.requestId; + if (requestId === undefined) throw new Error("Expected a researcher input request id."); + const resumed = await root.session + .respond([{ optionId: "yes", requestId }]) + .then((response) => response.result()); + expect(resumed.message).toBe("RESEARCHER_HITL_RESUMED"); + expect((await (await root.session.send("root after hitl")).result()).message).toBe( + "ROOT_AFTER_HITL", + ); + + const direct = await client.sessions.create({ + agent: "researcher", + message: "direct create", + }); + expect((await direct.response.result()).message).toBe("RESEARCHER_DEFAULT"); + expect((await (await direct.session.send("follow default")).result()).message).toBe( + "RESEARCHER_DEFAULT_FOLLOW", + ); + + const nested = await client.sessions.create({ + agent: "researcher/critic", + message: "nested direct", + }); + expect((await nested.response.result()).message).toBe("CRITIC_DIRECT"); + + const slashResponse = await fetch(new URL("/slash", server.url), { + body: JSON.stringify({ + agent: "researcher", + message: "slash research", + threadId: "thread-1", + }), + headers: { "content-type": "application/json" }, + method: "POST", + }); + expect(slashResponse.status).toBe(202); + const slashSessionId = ((await slashResponse.json()) as { sessionId: string }).sessionId; + let slashEvents: Array<{ message: string; sessionId: string }> = []; + await waitForCondition(async () => { + slashEvents = (await fetch(new URL("/slash/events", server.url)).then((response) => + response.json(), + )) as Array<{ message: string; sessionId: string }>; + return slashEvents.some((event) => event.message === "CHANNEL_RESEARCHER"); + }, "Timed out waiting for the targeted channel event."); + expect(slashEvents).toContainEqual({ + message: "CHANNEL_RESEARCHER", + sessionId: slashSessionId, + }); + + const queuedResearcher = await sendRoutedChannel(server.url, "/queue", { + agent: "researcher", + message: "queue first", + threadId: "queue-thread", + }); + const queuedCritic = await sendRoutedChannel(server.url, "/queue", { + agent: "researcher/critic", + message: "queue critic", + threadId: "queue-thread", + }); + expect(queuedCritic).toBe(queuedResearcher); + let queueEvents: RoutedChannelEvent[] = []; + await waitForCondition(async () => { + queueEvents = await readRoutedChannelEvents(server.url, "/queue/events"); + return queueEvents.filter((event) => event.type === "message.completed").length >= 2; + }, "Timed out waiting for the mixed-target queue."); + expect( + queueEvents + .filter((event) => event.type === "message.completed") + .map((event) => event.message), + ).toEqual(["QUEUE_RESEARCHER", "QUEUE_CRITIC"]); + expect(new Set(queueEvents.map((event) => event.sessionId))).toEqual( + new Set([queuedResearcher]), + ); + + const steeredResearcher = await sendRoutedChannel(server.url, "/steer", { + agent: "researcher", + message: "steer first", + threadId: "steer-thread", + }); + await waitForCondition(async () => { + const events = await readRoutedChannelEvents(server.url, "/steer/events"); + return events.some((event) => event.type === "turn.started"); + }, "Timed out waiting for the steer source turn to start."); + const steeredCritic = await sendRoutedChannel(server.url, "/steer", { + agent: "researcher/critic", + message: "steer critic", + threadId: "steer-thread", + }); + expect(steeredCritic).toBe(steeredResearcher); + let steerEvents: RoutedChannelEvent[] = []; + await waitForCondition(async () => { + steerEvents = await readRoutedChannelEvents(server.url, "/steer/events"); + return ( + steerEvents.some((event) => event.type === "turn.cancelled") && + steerEvents.some((event) => event.message === "STEER_CRITIC") + ); + }, "Timed out waiting for the mixed-target steer."); + expect( + steerEvents.some((event) => event.message === "STEER_RESEARCHER_SHOULD_CANCEL"), + ).toBe(false); + const steeredDefault = await sendRoutedChannel(server.url, "/steer", { + message: "steer default", + threadId: "steer-thread", + }); + expect(steeredDefault).toBe(steeredResearcher); + await waitForCondition(async () => { + steerEvents = await readRoutedChannelEvents(server.url, "/steer/events"); + return steerEvents.some((event) => event.message === "STEER_RESEARCHER_DEFAULT"); + }, "Timed out waiting for the post-steer session default."); + expect(new Set(steerEvents.map((event) => event.sessionId))).toEqual( + new Set([steeredResearcher]), + ); + + await expectAgentRejection(server.url, "/researcher", 400, "invalid_agent_path"); + await expectAgentRejection(server.url, "missing", 404, "agent_not_found"); + await expectAgentRejection(server.url, "conditional", 400, "agent_not_directly_invocable"); + await expectAgentRejection(server.url, "remote", 400, "agent_not_directly_invocable"); + } catch (error) { + throw new Error(`stdout:\n${server.stdout()}\n\nstderr:\n${server.stderr()}`, { + cause: error, + }); + } finally { + await server.stop(); + } + }, + SCENARIO_TIMEOUT_MS, + ); +}); + +async function expectAgentRejection( + serverUrl: string, + agent: string, + status: number, + code: string, +): Promise { + const response = await fetch(new URL("/eve/v1/session", serverUrl), { + body: JSON.stringify({ agent, message: "reject me" }), + headers: { "content-type": "application/json" }, + method: "POST", + }); + expect(response.status).toBe(status); + await expect(response.json()).resolves.toMatchObject({ code, ok: false }); +} + +interface RoutedChannelEvent { + readonly message?: string; + readonly sessionId: string; + readonly type: string; +} + +async function sendRoutedChannel( + serverUrl: string, + path: "/queue" | "/steer", + body: { readonly agent?: string; readonly message: string; readonly threadId: string }, +): Promise { + const response = await fetch(new URL(path, serverUrl), { + body: JSON.stringify(body), + headers: { "content-type": "application/json" }, + method: "POST", + }); + expect(response.status).toBe(202); + return ((await response.json()) as { sessionId: string }).sessionId; +} + +async function readRoutedChannelEvents( + serverUrl: string, + path: "/queue/events" | "/steer/events", +): Promise { + return (await fetch(new URL(path, serverUrl)).then((response) => + response.json(), + )) as RoutedChannelEvent[]; +} diff --git a/research/direct-agent-invocation.md b/research/direct-agent-invocation.md new file mode 100644 index 0000000000..ff9aae89d6 --- /dev/null +++ b/research/direct-agent-invocation.md @@ -0,0 +1,87 @@ +--- +issue: https://github.com/vercel/eve/issues/393 +status: implemented +last_updated: "2026-08-26" +--- + +# Direct agent invocation and per-turn routing + +## Summary + +Applications and channels can select a statically declared local descendant without asking the root model to delegate. One root-relative `agent` selector is shared by the eve HTTP API, TypeScript client, fixed sessions, channel sends, and cross-channel sends. + +```ts +await client.sessions.create({ + agent: "researcher", + message: "Investigate this report.", +}); + +await session.send("Audit this turn.", { + agent: "researcher/critic", +}); +``` + +A selector on session creation makes that descendant the session default. A selector on an existing session applies to one turn; the next unqualified message returns to the session default. + +## Public contract + +`agent?: string` is accepted by: + +- `POST /eve/v1/session` and message bodies sent to `POST /eve/v1/session/:sessionId`. +- `client.sessions.create(...)` and `ClientSession.send(...)`. +- Fixed `Session.send(...)` handles. +- `ChannelSendOptions`, proactive channel `receive` input, and cross-channel `to(...).send(...)`. + +Paths use runtime-visible root-relative names. `researcher/critic` walks nested subagent directories; `crm__reviewer/auditor` retains an extension mount namespace. Every segment must resolve through the compiled static-local graph. Malformed, dynamic, remote, and missing targets fail before the turn is accepted. + +`agent` cannot accompany `inputResponses`. HITL responses and authorization callbacks resume the agent that requested them without another selector. + +The eve channel exposes the normalized requested path as `ctx.eve.agent` to `onMessage`. Existing route or platform authentication covers every descendant; direct invocation does not add another allowlist. + +## Session semantics + +A directly selected turn keeps the existing session ID, continuation address, auth, channel state, and model history. The selected node supplies its own model, instructions, tools, skills, hooks, connections, sandbox, and nested subagents. Its assistant and tool history becomes part of the shared session history, so the session default sees it afterward. A one-turn override does not replay the target's `initialMessages`. + +Direct selection is routing, not delegation. It emits the ordinary events for the selected turn through the existing session stream and channel event handlers, with no synthetic `subagent.called` or `subagent.completed`. Nested delegation from the selected node still emits the normal subagent lifecycle events. + +```text +public agent path + │ + ▼ +static graph resolver ──reject──▶ malformed / missing / dynamic / remote + │ nodeId + ▼ +session inbox delivery ──partition by nodeId──▶ turn workflow + │ + load selected bundle │ preserve shared history + ▼ + ordinary session stream +``` + +## Runtime boundary + +One resolver walks `subagentsByName` and returns a normalized path plus an internal node ID. Public boundaries carry the path; durable session commands carry only the node ID. + +Targeted creates construct the existing workflow runtime with that node ID. Existing-session sends encode the node ID in session-inbox wire version 2; version 1 payloads migrate as unqualified deliveries. Buffered messages batch only when they select the same node. Steering retains the selected target on the replacement turn. + +The turn workflow loads the selected compiled bundle and refreshes the active model surface while preserving durable session history. It restores the session's original bundle before returning state to the driver. Pending HITL and authorization state records the selected node until the request settles or the turn is cancelled. + +Sandbox snapshots are stored by resolved sandbox owner. Alternating root and descendant turns therefore preserve independent sandbox state, while a descendant configured with `parent.sandbox` shares its owner's snapshot. + +Root session `operationId` derivation remains byte-for-byte unchanged. A targeted create uses a separate identity domain that includes the normalized path, so the same operation ID cannot alias root and descendant sessions. + +## Error contract + +The shared resolver raises `AgentTargetError` with one stable code: + +| Code | HTTP status | Meaning | +| ------------------------------ | ----------- | ---------------------------------------------- | +| `invalid_agent_path` | `400` | The path is empty, malformed, or not relative. | +| `agent_not_directly_invocable` | `400` | A path segment is dynamic or remote. | +| `agent_not_found` | `404` | A static segment is missing from the graph. | + +eve HTTP routes serialize the code before accepting the turn. Channel APIs throw the same typed error with an actionable message. + +## Verification + +Unit coverage owns parsing, client serialization, resolver failures, operation identity, delivery partitioning, wire migration, pending-agent recovery, and sandbox ownership. Scenario coverage exercises root → researcher → root history, targeted defaults, nested paths, stable session identity, channel event flow, and rejection cases. The `agent-subagents` E2E fixture covers raw HTTP creates and follow-ups, TypeScript client parity, custom channel dispatch, nested paths, and stable failure responses.