feat(eve): add workflow agent routing (#3485)

Signed-off-by: Casey Gowrie <ctgowrie@gmail.com>
This commit is contained in:
Casey Gowrie
2026-09-18 10:04:35 -04:00
committed by GitHub
parent 505601b3f4
commit 76d4dd84ab
50 changed files with 783 additions and 19 deletions
@@ -0,0 +1,5 @@
---
"eve": patch
---
Workflow tools can read effective declared-subagent descriptions from `ctx.agents`, including subagents hidden from the parent model. Export `agentRouter()` from `eve/tools/agent-router` to route each task across that complete map with JEV and invoke the selected agent.
+1 -1
View File
@@ -141,7 +141,7 @@ export default defineTool({
The choice above is typed as `"billing" | "support"`. Each question appears under
its authored key in `result.answers`. Results also include token usage, warnings,
provider metadata, and response metadata.
provider metadata, and response metadata. To use that choice to delegate while keeping specialist subagents out of the parent model's tools, see [Route to a hidden subagent with JEV](/docs/tools/workflows#route-to-a-hidden-subagent-with-jev).
`evaluate` accepts AI SDK evaluation options, including `maxRetries`, `headers`,
and `providerOptions`. Pass an `abortSignal` to cancel the request. Input and
+3 -1
View File
@@ -56,7 +56,7 @@ export default defineTool({
| `mockModel` | `eve/evals` | Deterministic fixture agent models | [Evals](../evals/overview) |
| `useEveAgent` | `eve/react`, `eve/vue`, `eve/svelte` | frontend | [Frontend](../guides/frontend/overview) |
Tool-wide authoring helpers and types such as `defineTool`, `defineWorkflowTool`, `defineDurableCallback`, `defineDurableSchema`, `defineDynamic`, `disableTool`, and `ToolLabelDefinition` come from `eve/tools`. Capability-specific definitions and helpers use their own subpaths (see [Built-in tools](../concepts/built-in-tools)): reusable definitions such as `bash` and `glob` come from `eve/tools/<name>`, `webSearch` comes from `eve/tools/web_search`, `sleep` comes from `eve/tools/sleep`, and approval policies and types come from `eve/tools/approval`. The route verbs `GET`/`HEAD`/`POST`/`PUT`/`PATCH`/`DELETE`/`OPTIONS`/`WS` plus `disableRoute` come from `eve/channels`, and the channel auth helpers `localDev`/`vercelOidc`/`placeholderAuth` come from `eve/channels/auth`.
Tool-wide authoring helpers and types such as `defineTool`, `defineWorkflowTool`, `defineDurableCallback`, `defineDurableSchema`, `defineDynamic`, `disableTool`, and `ToolLabelDefinition` come from `eve/tools`. Capability-specific definitions and helpers use their own subpaths (see [Built-in tools](../concepts/built-in-tools)): reusable definitions such as `bash` and `glob` come from `eve/tools/<name>`, `webSearch` comes from `eve/tools/web_search`, `agentRouter` comes from `eve/tools/agent-router`, `sleep` comes from `eve/tools/sleep`, and approval policies and types come from `eve/tools/approval`. The route verbs `GET`/`HEAD`/`POST`/`PUT`/`PATCH`/`DELETE`/`OPTIONS`/`WS` plus `disableRoute` come from `eve/channels`, and the channel auth helpers `localDev`/`vercelOidc`/`placeholderAuth` come from `eve/channels/auth`.
`AgentReasoningDefinition` is exported from `eve` for the top-level `defineAgent({ reasoning })` setting. `AgentLimitsDefinition` is exported for `defineAgent({ limits })`. `AgentWorkflowDefinition`, `AgentWorkflowRetentionDefinition`, and `AgentWorkflowWorldDefinition` are exported from `eve` for the `defineAgent({ experimental: { workflow } })` config shape. `WebSearchToolInput` and `WebSearchProvider` are exported from `eve/tools/web_search`.
@@ -108,6 +108,8 @@ import template from "../../prompts/template.txt?raw";
| `ctx.getToken(provider)` | Resolve a bearer token for an inline auth provider such as `connect("...")` |
| `ctx.requireAuth(provider)` | Evict and re-authorize an inline provider, commonly after a downstream `401` |
Authored workflow tools also receive `ctx.agents`, a replay-stable map of effective declared-subagent descriptions, and `ctx.agent(name, input)` for invocation. See [Workflows as tools](../tools/workflows#delegate-work-ctxagent) for the workflow-only context.
## Imports at a glance
| Import | Holds |
+87 -4
View File
@@ -15,7 +15,7 @@ suspend in either execution mode; running in the background does not require a s
Workflow tools use the [Workflow SDK](https://workflow-sdk.dev): `"use workflow"`, `"use step"`, `createHook`,
`createWebhook`, `sleep`, retries, and replay. eve provides `ctx.ask` for questions answered through
the session's channel and `ctx.agent` for durable subagent delegation. Use `yield` to report
the session's channel, `ctx.agents` for effective subagent descriptions, and `ctx.agent` for durable subagent delegation. Use `yield` to report
progress and `await` on a workflow operation to wait durably. Values needed after a durable wait
stay in local variables in the workflow body.
@@ -78,13 +78,13 @@ or days later, the run resumes, deploys, and returns. The model sees one tool re
- Import `createHook`, `createWebhook`, `sleep`, and `FatalError` from `workflow` in the body.
`start`, `getRun`, and `resumeHook` from `workflow/api` belong in steps. Your app does not install
the SDK; for types, new projects list `eve/workflow-modules` in the tsconfig `types`.
- In the body, `ctx` has `session`, `callId`, `toolName`, `abortSignal`, `agent`, and `ask`.
- In the body, `ctx` has `session`, `callId`, `toolName`, `abortSignal`, `agent`, `agents`, and `ask`.
`getToken` and `requireAuth` work inside a `"use step"` helper that receives `ctx` as a direct argument;
they throw in the workflow body. `getSandbox` and `getSkill` remain unavailable.
- The tool's input must be a JSON object. Workflow bodies are for static tools under `agent/tools/`,
not tools returned from `defineDynamic` resolvers.
`ctx.agent` and `ctx.ask` are available only on `WorkflowToolContext`. Ordinary tools, channel
`ctx.agent`, `ctx.agents`, and `ctx.ask` are available only on `WorkflowToolContext`. Ordinary tools, channel
handlers, and schedule handlers do not receive these methods. A shared helper may accept
`WorkflowToolContext` from `eve/tools` and use the context supplied by a workflow tool body.
@@ -302,7 +302,7 @@ and can only show the model's input. Both compose: `approval` before the run, `c
## Delegate work: `ctx.agent`
Workflow tools can call a visible subagent and wait for its result:
Workflow tools can call a subagent and wait for its result:
```ts
const result = await ctx.agent("reviewer", {
@@ -321,6 +321,89 @@ The first argument is the subagent's path-derived name. It can identify a model-
continue an existing child. An inline `outputSchema` requires structured output and determines the
return type, so `result` in the example is typed as `{ findings: string[] }`.
### Route to a hidden subagent with JEV
Use an authored workflow tool when the parent model should decide to delegate, but JEV should choose the specialist. Set `tool: false` on each specialist so the parent model sees only the routing tool. The specialists remain available through `ctx.agents` and callable through `ctx.agent()`:
```ts title="agent/subagents/researcher/agent.ts"
import { defineAgent } from "eve";
export default defineAgent({
description: "Investigation, analysis, and explanation",
model: "anthropic/claude-opus-4.8",
tool: false,
});
```
```ts title="agent/subagents/operator/agent.ts"
import { defineAgent } from "eve";
export default defineAgent({
description: "Execution and operational changes",
model: "openai/gpt-5.6-sol",
tool: false,
});
```
Define the model-visible router under `agent/tools/`. Build the JEV criteria from the subagents' effective descriptions, then pass its typed choice directly to `ctx.agent()`:
```ts title="agent/tools/agent-router.ts"
import { evaluate } from "eve/ai";
import { defineWorkflowTool, type WorkflowToolContext } from "eve/tools";
import { z } from "zod";
async function chooseTarget(task: string, ctx: WorkflowToolContext) {
"use step";
const result = await evaluate({
abortSignal: ctx.abortSignal,
state: { task },
questions: {
route: {
type: "choice",
instructions: "Which specialist should handle this task?",
criteria: {
researcher: ctx.agents.researcher.description,
operator: ctx.agents.operator.description,
},
},
},
});
return result.answers.route.choice;
}
export default defineWorkflowTool({
description: "Route a task to the appropriate specialist.",
inputSchema: z.object({ task: z.string().min(1).max(8000) }),
async execute({ task }, ctx) {
"use workflow";
const target = await chooseTarget(task, ctx);
return ctx.agent(target, { message: task });
},
});
```
The evaluation runs in a step so workflow replay records its result instead of making the routing request again. The parent model receives `agent-router`, but not `researcher` or `operator`. JEV returns the typed `"researcher" | "operator"` choice, and the selected subagent's result becomes the `agent-router` tool result.
`ctx.agents` is a replay-stable metadata snapshot taken when the workflow starts. It includes agents hidden from the parent model with `tool: false` or `disableTool()`, but exposes no model definitions, credentials, or callbacks. Invocation still checks the subagent's availability through `ctx.agent()`.
#### Route across every subagent
Use `agentRouter()` when the router should consider every available declared subagent with the default JEV routing instructions:
```ts title="agent/tools/agent-router.ts"
import { agentRouter } from "eve/tools/agent-router";
export default agentRouter();
```
Its input is `{ message: string, outputSchema?: object }`. With two or more available subagents, `agentRouter()` sends the message and every effective description to JEV, then invokes the selected path-derived name through `ctx.agent()`. It invokes a sole available subagent without evaluation and forwards an optional `outputSchema`. The built-in root-copy `agent` target is not included.
This pattern controls specialist selection, not whether the parent model delegates at all. Route before the parent model runs if every incoming request must go through JEV.
## Report progress: `yield`
A workflow body may be an async generator. Ordinary yields report progress in both execution
@@ -11,12 +11,16 @@ export default defineAgent({
message.includes("E2E_INTERNAL_ROOT_COPY"),
);
if (internalCopy) return "INTERNAL-ROOT-COPY-OK";
if (request.tools.some((tool) => tool.name === "agent")) {
throw new Error("The built-in agent tool was exposed to the model.");
if (request.tools.some((tool) => ["agent", "operator", "researcher"].includes(tool.name))) {
throw new Error("A hidden agent tool was exposed to the model.");
}
const result = request.toolResults.find((entry) => entry.name === "invoke-self");
const inspectAgents = request.userMessages.some((message) =>
message.includes("E2E_INSPECT_WORKFLOW_AGENTS"),
);
const toolName = inspectAgents ? "inspect-agents" : "invoke-self";
const result = request.toolResults.find((entry) => entry.name === toolName);
return result === undefined
? { toolCalls: [{ name: "invoke-self", input: {} }] }
? { toolCalls: [{ name: toolName, input: {} }] }
: JSON.stringify(result.output);
},
}),
@@ -0,0 +1,8 @@
import { e2eSubagentConfig } from "@eve-e2e/config";
import { defineAgent } from "eve";
export default defineAgent({
description: "Execute operational changes to systems and deployments.",
...e2eSubagentConfig({ mock: "AUTO-ROUTER-OPERATOR" }),
tool: false,
});
@@ -0,0 +1 @@
Reply with the exact string `AUTO-ROUTER-OPERATOR` and nothing else.
@@ -0,0 +1,8 @@
import { e2eSubagentConfig } from "@eve-e2e/config";
import { defineAgent } from "eve";
export default defineAgent({
description: "Investigate, analyze, and explain questions without changing systems.",
...e2eSubagentConfig({ mock: "AUTO-ROUTER-RESEARCHER" }),
tool: false,
});
@@ -0,0 +1 @@
Reply with the exact string `AUTO-ROUTER-RESEARCHER` and nothing else.
@@ -0,0 +1,26 @@
import {
defineWorkflowTool,
type WorkflowAgentMetadata,
type WorkflowToolContext,
type WorkflowToolDefinition,
} from "eve/tools";
async function execute(
_input: Record<string, unknown>,
ctx: WorkflowToolContext,
): Promise<Record<string, WorkflowAgentMetadata>> {
"use workflow";
return ctx.agents;
}
const tool: WorkflowToolDefinition<
Record<string, unknown>,
Record<string, WorkflowAgentMetadata>
> = defineWorkflowTool({
description: "Return the workflow's available agent metadata.",
inputSchema: { type: "object", properties: {}, additionalProperties: false },
execute,
});
export default tool;
@@ -0,0 +1,22 @@
import { defineEval } from "eve/evals";
import { equals } from "eve/evals/expect";
export default defineEval({
description: "ctx.agents exposes exact descriptions for hidden declared specialists.",
async test(t) {
const turn = await t.send("E2E_INSPECT_WORKFLOW_AGENTS");
turn.expectOk();
turn.messageIncludes("Investigate, analyze, and explain questions without changing systems.");
turn.messageIncludes("Execute operational changes to systems and deployments.");
await t.require(
Object.keys(JSON.parse(turn.message ?? "{}")).sort(),
equals(["operator", "researcher"]),
);
turn.calledTool("inspect-agents", { count: 1 });
turn.calledSubagent("operator", { count: 0 });
turn.calledSubagent("researcher", { count: 0 });
t.succeeded();
t.noFailedActions();
},
});
+10
View File
@@ -0,0 +1,10 @@
node_modules
.env*
.eve
.vercel
.next
.output
.nitro
dist
.DS_Store
*.tsbuildinfo
+6
View File
@@ -0,0 +1,6 @@
node_modules
.eve
.next
.output
.nitro
dist
+28
View File
@@ -0,0 +1,28 @@
import { e2eAgentConfig } from "@eve-e2e/config";
import { defineAgent } from "eve";
import { mockModel } from "eve/evals";
export default defineAgent({
...e2eAgentConfig(),
model: mockModel({
modelId: "agent-router-parent",
respond(request) {
if (request.tools.some((tool) => tool.name === "worker")) {
throw new Error("The hidden worker was exposed to the parent model.");
}
const result = request.toolResults.find((entry) => entry.name === "agent-router");
return result === undefined
? {
toolCalls: [
{
name: "agent-router",
input: { message: "Return the agent-router marker." },
},
],
}
: JSON.stringify(result.output);
},
}),
modelContextWindowTokens: 1_000_000,
tool: false,
});
@@ -0,0 +1 @@
Use the agent-router tool for every task.
@@ -0,0 +1,9 @@
import { defineAgent } from "eve";
import { mockModel } from "eve/evals";
export default defineAgent({
description: "Handle every delegated task in this fixture.",
model: mockModel({ modelId: "agent-router-worker", respond: "AGENT-ROUTER-WORKER-OK" }),
modelContextWindowTokens: 1_000_000,
tool: false,
});
@@ -0,0 +1 @@
Reply with the exact string `AGENT-ROUTER-WORKER-OK` and nothing else.
@@ -0,0 +1,3 @@
import { agentRouter } from "eve/tools/agent-router";
export default agentRouter();
@@ -0,0 +1,15 @@
import { defineEval } from "eve/evals";
export default defineEval({
description: "agentRouter invokes a sole hidden subagent through the provided workflow tool.",
async test(t) {
const turn = await t.send("Route this task.");
turn.expectOk();
turn.messageIncludes("AGENT-ROUTER-WORKER-OK");
turn.calledTool("agent-router", { count: 1 });
turn.calledSubagent("worker", { count: 1, status: "pending" });
t.succeeded();
t.noFailedActions();
},
});
@@ -0,0 +1,3 @@
import { defineEvalConfig } from "eve/evals";
export default defineEvalConfig({ maxConcurrency: 1 });
+22
View File
@@ -0,0 +1,22 @@
{
"name": "agent-router",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "eve build",
"dev": "eve dev",
"start": "eve start",
"typecheck": "eve build && tsc",
"test:e2e": "eve eval --strict"
},
"dependencies": {
"@eve-e2e/config": "workspace:*",
"@workflow/world-postgres": "catalog:",
"eve": "workspace:*"
},
"devDependencies": {
"@types/node": "catalog:",
"typescript": "catalog:"
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "esnext",
"moduleResolution": "bundler",
"outDir": "dist",
"rootDir": ".",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"noEmit": true,
"types": ["node"]
},
"include": ["agent/**/*.ts", "evals/**/*.ts"]
}
@@ -6,17 +6,23 @@ import {
import { z } from "zod";
type Input = { target: "tool-hidden" | "disabled-hidden" };
type Output = {
description: string;
result: Awaited<ReturnType<WorkflowToolContext["agent"]>>;
};
async function execute({ target }: Input, ctx: WorkflowToolContext) {
"use workflow";
return ctx.agent(target, { message: "Return your fixed marker." });
const description = ctx.agents[target]?.description;
if (description === undefined) {
throw new Error(`Missing workflow metadata for internal subagent ${target}.`);
}
const result = await ctx.agent(target, { message: "Return your fixed marker." });
return { description, result };
}
const tool: WorkflowToolDefinition<
Input,
Awaited<ReturnType<WorkflowToolContext["agent"]>>
> = defineWorkflowTool({
const tool: WorkflowToolDefinition<Input, Output> = defineWorkflowTool({
description: "Invoke an internal specialist selected by the caller.",
inputSchema: z.object({
target: z.enum(["tool-hidden", "disabled-hidden"]),
@@ -8,6 +8,7 @@ export default defineEval({
turn.expectOk();
turn.messageIncludes("TOOL-FALSE-SUBAGENT-OK");
turn.messageIncludes("Internal specialist hidden by its agent definition.");
turn.calledTool("invoke-hidden", { count: 1 });
turn.calledSubagent("tool-hidden", { count: 1, status: "pending" });
turn.calledSubagent("disabled-hidden", { count: 0, status: "pending" });
@@ -0,0 +1,17 @@
import { defineTool } from "#public/tools/index.js";
export default defineTool({
description: "Report a guarded value.",
inputSchema: {
type: "object",
properties: { value: { type: "string" } },
required: ["value"],
additionalProperties: false,
},
approval({ callId, toolInput, toolName }) {
return callId && toolName && toolInput?.value ? "user-approval" : "denied";
},
execute({ value }) {
return value;
},
});
@@ -7,6 +7,11 @@ export {
toolOutputPart,
toolResultFrom,
} from "../../src/public/tools/index.ts";
export {
agentRouter,
type AgentRouterInput,
type AgentRouterTool,
} from "../../src/public/tools/agent-router.ts";
export {
defaultWebSearch,
isWebSearchToolDefinition,
@@ -0,0 +1,26 @@
{
"kind": "eve-extension-capability-contract",
"capability": "tool",
"epoch": 50,
"sha256": "ce1e6ea06485750721d944c1b360375acbe31d9eefcc909ac064bc045f34b275",
"exports": [
"AgentRouterInput",
"AgentRouterTool",
"WorkflowTool",
"WorkflowToolInput",
"WorkflowToolOptions",
"agentRouter",
"defaultWebSearch",
"defineTool",
"defineWorkflowTool",
"disableTool",
"evaluate",
"isDisabledToolSentinel",
"isWebSearchToolDefinition",
"toolOutput",
"toolOutputPart",
"toolResultFrom",
"webSearch",
"workflow"
]
}
+5
View File
@@ -127,6 +127,11 @@
"import": "./dist/src/public/memory/file/vercel.js",
"default": "./dist/src/public/memory/file/vercel.js"
},
"./tools/agent-router": {
"types": "./dist/src/public/tools/agent-router.d.ts",
"import": "./dist/src/public/tools/agent-router.js",
"default": "./dist/src/public/tools/agent-router.js"
},
"./tools/sleep": {
"types": "./dist/src/public/tools/sleep.d.ts",
"import": "./dist/src/public/tools/sleep.js",
@@ -22,9 +22,9 @@ interface ExtensionCapabilityContract {
const EXTENSION_CAPABILITY_CONTRACTS = {
extension: { current: 1, supported: [1], dropped: {} },
tool: {
current: 49,
current: 50,
supported: [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 28, 29, 30, 31, 32, 34, 35, 44, 45, 46, 49,
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 28, 29, 30, 31, 32, 34, 35, 44, 45, 46, 49, 50,
],
dropped: {
14: "TaskExec.delegated was removed; migrate to workflow-backed background tools",
@@ -49,6 +49,8 @@ import { buildSubagentRunInput } from "#subagents/tool.js";
import { resolveEffectiveAgentRuntime } from "#execution/effective-agent-config.js";
import { isTaskControlAction } from "#execution/tasks/parent/dispatch.js";
import type { WorkflowToolRunOwner } from "#execution/tools/workflow/messages.js";
import { resolveWorkflowAgentMetadata } from "#execution/tools/subagent/metadata.js";
import type { WorkflowAgentMetadata } from "#tools/workflow-definition.js";
export type DispatchPlanEntry =
| { readonly kind: "task-control"; readonly action: RuntimeToolCallActionRequest }
@@ -100,6 +102,7 @@ export interface PreparedCoordinationDispatch<PlanEntry = DispatchPlanEntry> {
readonly plan: readonly PlanEntry[];
readonly session: RuntimeSession;
readonly sessionState: DurableSessionState;
readonly workflowAgents: Readonly<Record<string, WorkflowAgentMetadata>>;
}
/**
@@ -238,6 +241,7 @@ export async function prepareActionDispatch<PlanEntry>(input: {
sandboxSessionId,
serializedContext: input.serializedContext,
session,
workflowAgents: resolveWorkflowAgentMetadata(ctx),
};
}
@@ -41,6 +41,7 @@ export async function dispatchCoordinationStep(
for (const entry of prepared.plan) {
if (entry.kind === "workflow-task") {
const started = await startWorkflowTask({
agents: prepared.workflowAgents,
auth: prepared.auth,
batchEvent: batch.event,
initiatorAuth: prepared.initiatorAuth,
@@ -6,6 +6,7 @@ import { runStep } from "#context/run-step.js";
import { buildCallbackContext } from "#context/build-callback-context.js";
import { isAuthorizationSignal } from "#harness/authorization.js";
import { activeTurnId } from "#harness/active-turn-id.js";
import { resolveWorkflowAgentMetadata } from "#execution/tools/subagent/metadata.js";
import { getHarnessEmissionState } from "#harness/emission.js";
import { isTurnCancellation } from "#harness/turn-cancellation.js";
import type { HarnessSession, StepResult } from "#harness/types.js";
@@ -518,6 +519,7 @@ class BackgroundToolExecutionScope implements BackgroundToolExecutor {
parentContinuationToken: sessionCommandHookToken(this.initialSession.sessionId),
taskInboxToken: task.taskInboxToken,
workflow: {
agents: resolveWorkflowAgentMetadata(input.ctx),
callId: taskInput.callId,
executeInput: workflow.executeInput?.(workflowInput),
input: workflowInput,
@@ -0,0 +1,53 @@
import { evaluate } from "#ai/evaluate.js";
import type { JsonValue } from "#shared/json.js";
import type { WorkflowToolContext } from "#tools/workflow-definition.js";
import type { AgentRouterInput } from "#execution/tools/agent-router.js";
/** Routes one task through the complete workflow agent metadata snapshot. */
export async function executeAgentRouterTool(
input: AgentRouterInput,
ctx: WorkflowToolContext,
): Promise<JsonValue> {
"use workflow";
const target = await chooseTarget(input.message, descriptions(ctx), ctx.abortSignal);
return ctx.agent(
target,
input.outputSchema === undefined
? { message: input.message }
: { message: input.message, outputSchema: input.outputSchema },
);
}
async function chooseTarget(
message: string,
criteria: Record<string, string>,
abortSignal: AbortSignal,
): Promise<string> {
"use step";
const names = Object.keys(criteria);
if (names.length === 0) {
throw new Error("agentRouter requires at least one available declared subagent.");
}
if (names.length === 1) return names[0]!;
const result = await evaluate({
abortSignal,
state: { message },
questions: {
route: {
type: "choice",
instructions: "Which subagent should handle this task?",
criteria,
},
},
});
return result.answers.route.choice;
}
function descriptions(ctx: WorkflowToolContext): Record<string, string> {
return Object.fromEntries(
Object.entries(ctx.agents).map(([name, metadata]) => [name, metadata.description]),
);
}
@@ -0,0 +1,20 @@
import { z } from "#compiled/zod/index.js";
import type { JsonObject } from "#shared/json.js";
export { executeAgentRouterTool } from "#execution/tools/agent-router-workflow.js";
export const AGENT_ROUTER_TOOL_DESCRIPTION =
"Route a task to the best available subagent based on each subagent's declared description.";
export interface AgentRouterInput {
readonly message: string;
readonly outputSchema?: JsonObject;
}
export const AGENT_ROUTER_INPUT_SCHEMA: z.ZodType<AgentRouterInput> = z.strictObject({
message: z.string().min(1).describe("The complete task to send to the selected agent."),
outputSchema: z
.record(z.string(), z.json())
.describe("Optional JSON Schema the selected agent's output must match.")
.optional(),
});
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { ContextContainer } from "#context/container.js";
import {
SessionDynamicSubagentSelectionsKey,
TurnDynamicSubagentSelectionsKey,
} from "#context/keys.js";
import { resolveWorkflowAgentMetadata } from "#execution/tools/subagent/metadata.js";
import { BundleKey } from "#runtime/sessions/runtime-context-keys.js";
describe("resolveWorkflowAgentMetadata", () => {
it("includes hidden static subagents without adding self-delegation", () => {
const ctx = context({
nodeId: undefined,
subagentsByName: new Map([
[
"researcher",
{
definition: {
description: "Investigate difficult questions.",
kind: "subagent",
tool: false,
},
},
],
]),
});
expect(resolveWorkflowAgentMetadata(ctx)).toEqual({
researcher: { description: "Investigate difficult questions." },
});
});
it("uses effective dynamic descriptions with turn precedence", () => {
const ctx = context({ nodeId: "subagents/coordinator", subagentsByName: new Map() });
const prepared = { name: "reviewer" };
ctx.set(SessionDynamicSubagentSelectionsKey, {
reviewer: {
agentConfig: { description: "Review generally." },
kind: "subagent",
prepared,
} as never,
});
ctx.set(TurnDynamicSubagentSelectionsKey, {
reviewer: {
kind: "remote",
prepared,
remoteAgent: { description: "Review this tenant." },
} as never,
});
expect(resolveWorkflowAgentMetadata(ctx)).toEqual({
reviewer: { description: "Review this tenant." },
});
});
});
function context(input: {
readonly nodeId: string | undefined;
readonly subagentsByName: ReadonlyMap<string, unknown>;
}): ContextContainer {
const ctx = new ContextContainer();
ctx.set(BundleKey, {
nodeId: input.nodeId,
subagentRegistry: { subagentsByName: input.subagentsByName },
} as never);
return ctx;
}
@@ -0,0 +1,44 @@
import {
SessionDynamicSubagentSelectionsKey,
TurnDynamicSubagentSelectionsKey,
type DurableDynamicSubagentSelection,
} from "#context/keys.js";
import type { ContextReader } from "#context/key.js";
import { BundleKey } from "#runtime/sessions/runtime-context-keys.js";
import type { WorkflowAgentMetadata } from "#tools/workflow-definition.js";
/** Snapshots callable agent metadata for one workflow tool run. */
export function resolveWorkflowAgentMetadata(
ctx: ContextReader,
): Readonly<Record<string, WorkflowAgentMetadata>> {
const bundle = ctx.get(BundleKey);
if (bundle === undefined) return {};
const agents = new Map<string, WorkflowAgentMetadata>();
for (const [name, registered] of bundle.subagentRegistry.subagentsByName ?? []) {
const description = registered.definition.description;
if (description !== undefined) agents.set(name, { description });
}
const selections = effectiveDynamicSelections(ctx);
for (const selection of Object.values(selections)) {
if (selection === null) continue;
agents.set(selection.prepared.name, {
description:
selection.kind === "subagent"
? selection.agentConfig.description
: selection.remoteAgent.description,
});
}
return Object.fromEntries(agents);
}
function effectiveDynamicSelections(
ctx: ContextReader,
): Readonly<Record<string, DurableDynamicSubagentSelection>> {
return {
...ctx.get(SessionDynamicSubagentSelectionsKey),
...ctx.get(TurnDynamicSubagentSelectionsKey),
};
}
@@ -12,12 +12,42 @@ vi.mock("#execution/tools/workflow/ask.js", async (importOriginal) => ({
ask: mocks.ask,
}));
it("defaults agent metadata to an empty registry for older workflow payloads", async () => {
mocks.execute.mockImplementation(async (_input, ctx: WorkflowToolContext) => {
expect(ctx.agents).toEqual({});
return null;
});
await executeWorkflowBody(
{
callId: "legacy-call",
input: {},
session: {
auth: { current: null, initiator: null },
id: "session",
turn: { id: "turn", sequence: 1 },
},
stepIndex: 0,
toolName: "legacy",
workflowId: "workflow//test//legacy",
owner: { inbox: "inbox" },
execution: "blocking",
runId: "run",
},
new AbortController().signal,
);
});
it("binds workflow-only methods to the run context", async () => {
const signal = new AbortController().signal;
const input = {
agents: { reviewer: { description: "Review deployments." } },
callId: "call",
input: {},
session: { id: "session", turn: { id: "turn", sequence: 1 } },
session: {
auth: { current: null, initiator: null },
id: "session",
turn: { id: "turn", sequence: 1 },
},
stepIndex: 0,
toolName: "deploy",
workflowId: "workflow//test//execute",
@@ -33,6 +63,9 @@ it("binds workflow-only methods to the run context", async () => {
mocks.execute.mockImplementation(async (_input, ctx: WorkflowToolContext & ToolContext) => {
expect(readWorkflowToolRunRef(ctx).runId).toBe("run");
expect(ctx.abortSignal).toBe(signal);
expect(ctx.agents).toEqual({ reviewer: { description: "Review deployments." } });
expect(Object.isFrozen(ctx.agents)).toBe(true);
expect(Object.isFrozen(ctx.agents.reviewer)).toBe(true);
const answer = await ctx.ask(question);
const result = await ctx.agent(target, invocation);
expect(mocks.ask).toHaveBeenCalledWith(ctx, question);
@@ -18,6 +18,8 @@ import type { ToolContext } from "#tools/definition.js";
import { createTaskMessage, type TaskExec } from "#tools/task.js";
export interface WorkflowBodyDefinition {
/** Snapshot added for new runs; absent only when resuming an older durable payload. */
readonly agents?: WorkflowToolContext["agents"];
readonly callId: string;
readonly executeInput?: JsonValue;
readonly input: JsonObject;
@@ -140,6 +142,14 @@ function createWorkflowBodyContext(
const ctx: ToolContext & WorkflowToolContext = {
agent: ((target: string, agentInput: AgentInput) =>
agent(ctx, target, agentInput)) as WorkflowToolContext["agent"],
agents: Object.freeze(
Object.fromEntries(
Object.entries(input.agents ?? {}).map(([name, metadata]) => [
name,
Object.freeze({ ...metadata }),
]),
),
),
ask: (request) => ask(ctx, request),
abortSignal: signal,
callId: input.callId,
@@ -13,6 +13,7 @@ vi.mock("#execution/workflow-runtime.js", () => ({
}));
const input: Omit<WorkflowToolRunInput, "hookToken"> = {
agents: { reviewer: { description: "Review deployments." } },
callId: "call-1",
input: { service: "api" },
owner: { inbox: "owner-inbox" },
@@ -33,6 +33,7 @@ export async function startWorkflowToolRun(
/** Starts one durable workflow task and records it on the owning session. */
export async function startWorkflowTask(input: {
readonly agents: WorkflowToolRunInput["agents"];
readonly auth: SessionAuth["current"];
readonly batchEvent: {
readonly sequence: number;
@@ -48,6 +49,7 @@ export async function startWorkflowTask(input: {
const { task, batchEvent, session } = input;
try {
const started = await startWorkflowToolRun({
agents: input.agents,
callId: task.callId,
executeInput: task.executeInput,
input: task.input,
@@ -2,6 +2,7 @@ import type { SessionContext } from "#context/session-context.js";
import type { JsonObject, JsonValue } from "#shared/json.js";
import type { TaskExecutorBinding } from "#tools/task.js";
import type { WorkflowToolRunOwner } from "#execution/tools/workflow/messages.js";
import type { WorkflowAgentMetadata } from "#tools/workflow-definition.js";
export type WorkflowToolRunSessionContext = SessionContext["session"];
@@ -29,6 +30,7 @@ export function readWorkflowToolExecutorAddress(
}
export interface WorkflowToolRunInput {
readonly agents: Readonly<Record<string, WorkflowAgentMetadata>>;
readonly callId: string;
readonly execution?: "background" | "blocking";
readonly executeInput?: JsonValue;
@@ -0,0 +1,5 @@
export {
agentRouter,
type AgentRouterInput,
type AgentRouterTool,
} from "#tools/provided/agent-router.js";
+1
View File
@@ -42,5 +42,6 @@ export {
type WorkflowToolContext,
type WorkflowToolDefinition,
type AgentInput,
type WorkflowAgentMetadata,
} from "#tools/workflow-definition.js";
export type { ToolInputRequest, ToolInputResponse } from "#tools/definition.js";
@@ -0,0 +1,112 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { agentRouter } from "#tools/provided/agent-router.js";
import { executeAgentRouterTool } from "#execution/tools/agent-router.js";
import { evaluate } from "#ai/evaluate.js";
import type { WorkflowToolContext } from "#tools/workflow-definition.js";
vi.mock("#ai/evaluate.js", () => ({ evaluate: vi.fn() }));
describe("agentRouter", () => {
beforeEach(() => vi.clearAllMocks());
it("defines a workflow tool", () => {
const definition = agentRouter();
expect(definition.description).toContain("best available subagent");
expect(definition.execute).toBe(executeAgentRouterTool);
});
it("routes through all workflow agent descriptions", async () => {
vi.mocked(evaluate).mockResolvedValue({
answers: { route: { choice: "operator", type: "choice" } },
} as never);
const agent = vi.fn().mockResolvedValue("operated");
const abortSignal = new AbortController().signal;
const ctx = workflowContext({
abortSignal,
agent,
agents: {
operator: { description: "Execute operational changes." },
researcher: { description: "Investigate and explain." },
},
});
await expect(executeAgentRouterTool({ message: "Deploy the service" }, ctx)).resolves.toBe(
"operated",
);
expect(evaluate).toHaveBeenCalledWith({
abortSignal,
state: { message: "Deploy the service" },
questions: {
route: {
criteria: {
operator: "Execute operational changes.",
researcher: "Investigate and explain.",
},
instructions: "Which subagent should handle this task?",
type: "choice",
},
},
});
expect(agent).toHaveBeenCalledWith("operator", { message: "Deploy the service" });
});
it("invokes the only available agent without evaluation", async () => {
const agent = vi.fn().mockResolvedValue("researched");
const ctx = workflowContext({
agent,
agents: { researcher: { description: "Investigate and explain." } },
});
await expect(executeAgentRouterTool({ message: "Investigate" }, ctx)).resolves.toBe(
"researched",
);
expect(evaluate).not.toHaveBeenCalled();
expect(agent).toHaveBeenCalledWith("researcher", { message: "Investigate" });
});
it("forwards an output schema to the selected agent", async () => {
vi.mocked(evaluate).mockResolvedValue({
answers: { route: { choice: "researcher", type: "choice" } },
} as never);
const agent = vi.fn().mockResolvedValue({ answer: "done" });
const ctx = workflowContext({
agent,
agents: { researcher: { description: "Investigate and explain." } },
});
const outputSchema = {
additionalProperties: false,
properties: { answer: { type: "string" } },
required: ["answer"],
type: "object",
} as const;
await executeAgentRouterTool({ message: "Investigate", outputSchema }, ctx);
expect(agent).toHaveBeenCalledWith("researcher", {
message: "Investigate",
outputSchema,
});
});
it("rejects an empty agent map before evaluation", async () => {
const ctx = { agents: {} } as WorkflowToolContext;
await expect(executeAgentRouterTool({ message: "Route me" }, ctx)).rejects.toThrow(
"agentRouter requires at least one available declared subagent.",
);
expect(evaluate).not.toHaveBeenCalled();
});
});
function workflowContext(
input: Pick<WorkflowToolContext, "agent" | "agents"> &
Partial<Pick<WorkflowToolContext, "abortSignal">>,
): WorkflowToolContext {
return {
abortSignal: input.abortSignal ?? new AbortController().signal,
agent: input.agent,
agents: input.agents,
} as WorkflowToolContext;
}
@@ -0,0 +1,24 @@
import type { JsonValue } from "#shared/json.js";
import {
AGENT_ROUTER_INPUT_SCHEMA,
AGENT_ROUTER_TOOL_DESCRIPTION,
executeAgentRouterTool,
type AgentRouterInput,
} from "#execution/tools/agent-router.js";
import {
defineWorkflowTool,
type BlockingWorkflowToolDefinition,
} from "#tools/workflow-definition.js";
export type { AgentRouterInput };
export type AgentRouterTool = BlockingWorkflowToolDefinition<AgentRouterInput, JsonValue>;
/** Defines a workflow tool that uses JEV to route a task across all available declared subagents. */
export function agentRouter(): AgentRouterTool {
return defineWorkflowTool({
description: AGENT_ROUTER_TOOL_DESCRIPTION,
execute: executeAgentRouterTool,
inputSchema: AGENT_ROUTER_INPUT_SCHEMA,
});
}
@@ -5,6 +5,7 @@ import { defineTool } from "#tools/definition.js";
import {
defineWorkflowTool,
isWorkflowToolDefinition,
type WorkflowAgentMetadata,
type WorkflowToolContext,
} from "#tools/workflow-definition.js";
import { normalizeToolDefinition } from "#internal/authored-definition/schema-backed.js";
@@ -17,6 +18,7 @@ describe("defineWorkflowTool", () => {
async execute(input, ctx) {
expectTypeOf(input).toEqualTypeOf<{ service: string }>();
expectTypeOf(ctx).toEqualTypeOf<WorkflowToolContext>();
expectTypeOf(ctx.agents.researcher).toEqualTypeOf<WorkflowAgentMetadata | undefined>();
const review = ctx.agent("researcher", {
message: "Review the deployment.",
outputSchema: {
@@ -78,6 +80,8 @@ describe("defineWorkflowTool", () => {
async execute(_input, ctx) {
// @ts-expect-error agent is available only on WorkflowToolContext.
void ctx.agent;
// @ts-expect-error agents is available only on WorkflowToolContext.
void ctx.agents;
// @ts-expect-error ask is available only on WorkflowToolContext.
void ctx.ask;
return 1;
@@ -64,6 +64,10 @@ type JsonSchemaOutput<TSchema> = TSchema extends { readonly const: infer TValue
? null
: JsonValue;
export interface WorkflowAgentMetadata {
readonly description: string;
}
interface WorkflowAgent {
<const TOutputSchema extends JsonObject>(
target: string,
@@ -80,8 +84,10 @@ export type WorkflowToolContext = Pick<
ToolContext,
"abortSignal" | "callId" | "session" | "toolName" | "getToken" | "requireAuth"
> & {
/** Invoke a visible subagent by its model-visible name. */
/** Invoke an agent by its path-derived name. */
agent: WorkflowAgent;
/** Metadata for agents callable by this workflow, including hidden agents. */
agents: Readonly<Record<string, WorkflowAgentMetadata>>;
/** Ask the human on the session's channel; awaiting the answer suspends the run. */
ask(request: ToolInputRequest): PromiseLike<ToolInputResponse>;
};
+19
View File
@@ -1082,6 +1082,25 @@ importers:
specifier: 'catalog:'
version: 7.0.2
e2e/fixtures/agent-router:
dependencies:
'@eve-e2e/config':
specifier: workspace:*
version: link:../e2e-config
'@workflow/world-postgres':
specifier: 'catalog:'
version: 5.0.0-beta.42(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(@upstash/redis@1.38.4)(sql.js@1.14.1)(supports-color@10.2.2)(typescript@7.0.2)
eve:
specifier: workspace:*
version: link:../../../packages/eve
devDependencies:
'@types/node':
specifier: 'catalog:'
version: 24.13.3
typescript:
specifier: 'catalog:'
version: 7.0.2
e2e/fixtures/agent-schedules:
dependencies:
'@eve-e2e/config':
+17
View File
@@ -47,3 +47,20 @@ The second form removes the derived `researcher` tool but not the `researcher` c
## Runtime boundary
Each compiled agent node records its selected tools and source-composition decisions. Runtime graph construction derives disabled tool names from that existing composition, always registers every resolved subagent by name and node id, and prepares a subagent model tool only when the child has not set `tool: false` and the parent has not disabled the same-named tool slot. Workflow `ctx.agent()` resolves from the full registry rather than the prepared model-tool list.
## Workflow metadata
A workflow tool receives `ctx.agents`, a replay-stable snapshot of effective declared-subagent descriptions keyed by path-derived name. The snapshot includes model-visible and hidden local, remote, and active dynamic subagents, but not the built-in root-copy `agent` target, model definitions, credentials, or callbacks. Workflow invocation remains separate:
```ts
const target = await chooseTarget(task, {
researcher: ctx.agents.researcher.description,
operator: ctx.agents.operator.description,
});
return ctx.agent(target, { message: task });
```
The owner snapshots metadata when it starts the workflow run. Older in-flight workflow payloads default to an empty metadata registry. `ctx.agent()` still validates availability at invocation time, so a dynamic agent that becomes unavailable after the snapshot cannot be invoked through stale metadata.
The provided `agentRouter()` workflow tool sends its input message and the complete `ctx.agents` description map to the default evaluation model (`typesafe-ai/jev`), then invokes the selected path-derived name. It forwards an optional output schema and rejects an empty declared-subagent map before evaluation. Authors use `defineWorkflowTool` directly when routing requires a subset, custom instructions, or a non-default evaluator.
@@ -24,6 +24,7 @@ export const PUBLIC_SURFACES = [
{
paths: [
"src/public/tools/index.ts",
"src/public/tools/agent-router.ts",
"src/public/tools/web-search.ts",
"src/public/tools/workflow.ts",
"src/public/ai/index.ts",