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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/direct-agent-routing.md
Original file line number Diff line number Diff line change
@@ -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.
45 changes: 45 additions & 0 deletions docs/channels/custom.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 /<agent> <message>", { 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`
Expand Down
28 changes: 25 additions & 3 deletions docs/channels/eve.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,34 @@ curl -X POST https://<deployment>/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://<deployment>/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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is non-obvious semantic that is indicative of coupling in the wrong place. It introduces implicit statefulness that it's not clear we want?

  • when you create a session targeting a subagent, will you never want to share with other agents? it seems like yes
  • do you want an a) set of agent capabilities coordinated by an orchestrator or b) a shared session-bound storage that a set of independent agents operate against?


```bash
curl -X POST https://<deployment>/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://<deployment>/eve/v1/session \
-H "Authorization: Bearer <token>" \
Expand All @@ -62,7 +84,7 @@ curl -X POST https://<deployment>/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://<deployment>/eve/v1/session/wrun_A \
Expand Down Expand Up @@ -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: {
Expand All @@ -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

Expand Down
28 changes: 28 additions & 0 deletions docs/guides/client/messages.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
33 changes: 33 additions & 0 deletions docs/subagents/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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://<deployment>" });
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
Expand Down
31 changes: 31 additions & 0 deletions e2e/fixtures/agent-subagents/agent/channels/direct.ts
Original file line number Diff line number Diff line change
@@ -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}`);
},
},
});
Original file line number Diff line number Diff line change
@@ -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",
});
Original file line number Diff line number Diff line change
@@ -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.
159 changes: 159 additions & 0 deletions e2e/fixtures/agent-subagents/evals/direct-invocation.eval.ts
Original file line number Diff line number Diff line change
@@ -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<AcceptedResponse>(
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<AcceptedResponse>(
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<AcceptedResponse>(
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<AcceptedResponse>(
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<T>(
target: EveEvalTargetHandle,
path: string,
body: unknown,
expectedStatus: number,
): Promise<T> {
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<void> {
const response = await postJson<AcceptedResponse>(
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<void> {
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}.`);
}
Loading
Loading