From 76d4dd84ab047023d4b7b492b373a73848918196 Mon Sep 17 00:00:00 2001 From: Casey Gowrie Date: Fri, 18 Sep 2026 10:04:35 -0400 Subject: [PATCH] feat(eve): add workflow agent routing (#3485) Signed-off-by: Casey Gowrie --- .changeset/add-workflow-agent-metadata.md | 5 + docs/guides/evaluate.md | 2 +- docs/reference/typescript-api.md | 4 +- docs/tools/workflows.mdx | 91 +++++++++++++- .../agent-agent-tool-controls/agent/agent.ts | 12 +- .../agent/subagents/operator/agent.ts | 8 ++ .../agent/subagents/operator/instructions.md | 1 + .../agent/subagents/researcher/agent.ts | 8 ++ .../subagents/researcher/instructions.md | 1 + .../agent/tools/inspect-agents.ts | 26 ++++ .../evals/workflow-agent-metadata.eval.ts | 22 ++++ e2e/fixtures/agent-router/.gitignore | 10 ++ e2e/fixtures/agent-router/.vercelignore | 6 + e2e/fixtures/agent-router/agent/agent.ts | 28 +++++ .../agent-router/agent/instructions.md | 1 + .../agent/subagents/worker/agent.ts | 9 ++ .../agent/subagents/worker/instructions.md | 1 + .../agent-router/agent/tools/agent-router.ts | 3 + .../agent-router/evals/agent-router.eval.ts | 15 +++ .../agent-router/evals/evals.config.ts | 3 + e2e/fixtures/agent-router/package.json | 22 ++++ e2e/fixtures/agent-router/tsconfig.json | 17 +++ .../agent/tools/invoke-hidden.ts | 16 ++- .../evals/hidden-subagents.eval.ts | 1 + .../compatibility/tool/v49.ts | 17 +++ .../extension-contracts/entrypoints/tool.ts | 5 + .../extension-contracts/reports/tool/v50.json | 26 ++++ packages/eve/package.json | 5 + .../src/compiler/extension-compatibility.ts | 4 +- .../execution/coordination-dispatch-shared.ts | 4 + .../execution/coordination-dispatch-step.ts | 1 + .../execution/tasks/parent/tool-execution.ts | 2 + .../execution/tools/agent-router-workflow.ts | 53 +++++++++ .../eve/src/execution/tools/agent-router.ts | 20 ++++ .../execution/tools/subagent/metadata.test.ts | 68 +++++++++++ .../src/execution/tools/subagent/metadata.ts | 44 +++++++ .../src/execution/tools/workflow/body.test.ts | 35 +++++- .../eve/src/execution/tools/workflow/body.ts | 10 ++ .../execution/tools/workflow/start.test.ts | 1 + .../eve/src/execution/tools/workflow/start.ts | 2 + .../eve/src/execution/tools/workflow/types.ts | 2 + packages/eve/src/public/tools/agent-router.ts | 5 + packages/eve/src/public/tools/index.ts | 1 + .../src/tools/provided/agent-router.test.ts | 112 ++++++++++++++++++ .../eve/src/tools/provided/agent-router.ts | 24 ++++ .../eve/src/tools/workflow-definition.test.ts | 4 + packages/eve/src/tools/workflow-definition.ts | 8 +- pnpm-lock.yaml | 19 +++ research/subagent-tool-projection.md | 17 +++ scripts/extension-contracts/configuration.mjs | 1 + 50 files changed, 783 insertions(+), 19 deletions(-) create mode 100644 .changeset/add-workflow-agent-metadata.md create mode 100644 e2e/fixtures/agent-agent-tool-controls/agent/subagents/operator/agent.ts create mode 100644 e2e/fixtures/agent-agent-tool-controls/agent/subagents/operator/instructions.md create mode 100644 e2e/fixtures/agent-agent-tool-controls/agent/subagents/researcher/agent.ts create mode 100644 e2e/fixtures/agent-agent-tool-controls/agent/subagents/researcher/instructions.md create mode 100644 e2e/fixtures/agent-agent-tool-controls/agent/tools/inspect-agents.ts create mode 100644 e2e/fixtures/agent-agent-tool-controls/evals/workflow-agent-metadata.eval.ts create mode 100644 e2e/fixtures/agent-router/.gitignore create mode 100644 e2e/fixtures/agent-router/.vercelignore create mode 100644 e2e/fixtures/agent-router/agent/agent.ts create mode 100644 e2e/fixtures/agent-router/agent/instructions.md create mode 100644 e2e/fixtures/agent-router/agent/subagents/worker/agent.ts create mode 100644 e2e/fixtures/agent-router/agent/subagents/worker/instructions.md create mode 100644 e2e/fixtures/agent-router/agent/tools/agent-router.ts create mode 100644 e2e/fixtures/agent-router/evals/agent-router.eval.ts create mode 100644 e2e/fixtures/agent-router/evals/evals.config.ts create mode 100644 e2e/fixtures/agent-router/package.json create mode 100644 e2e/fixtures/agent-router/tsconfig.json create mode 100644 packages/eve/extension-contracts/compatibility/tool/v49.ts create mode 100644 packages/eve/extension-contracts/reports/tool/v50.json create mode 100644 packages/eve/src/execution/tools/agent-router-workflow.ts create mode 100644 packages/eve/src/execution/tools/agent-router.ts create mode 100644 packages/eve/src/execution/tools/subagent/metadata.test.ts create mode 100644 packages/eve/src/execution/tools/subagent/metadata.ts create mode 100644 packages/eve/src/public/tools/agent-router.ts create mode 100644 packages/eve/src/tools/provided/agent-router.test.ts create mode 100644 packages/eve/src/tools/provided/agent-router.ts diff --git a/.changeset/add-workflow-agent-metadata.md b/.changeset/add-workflow-agent-metadata.md new file mode 100644 index 000000000..40eb44376 --- /dev/null +++ b/.changeset/add-workflow-agent-metadata.md @@ -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. diff --git a/docs/guides/evaluate.md b/docs/guides/evaluate.md index 182a2c475..9be5603bc 100644 --- a/docs/guides/evaluate.md +++ b/docs/guides/evaluate.md @@ -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 diff --git a/docs/reference/typescript-api.md b/docs/reference/typescript-api.md index 090b4d100..c412c4532 100644 --- a/docs/reference/typescript-api.md +++ b/docs/reference/typescript-api.md @@ -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/`, `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/`, `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 | diff --git a/docs/tools/workflows.mdx b/docs/tools/workflows.mdx index 4c9fc345c..7ae160a92 100644 --- a/docs/tools/workflows.mdx +++ b/docs/tools/workflows.mdx @@ -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 diff --git a/e2e/fixtures/agent-agent-tool-controls/agent/agent.ts b/e2e/fixtures/agent-agent-tool-controls/agent/agent.ts index 67ed92ff6..3279ccc2c 100644 --- a/e2e/fixtures/agent-agent-tool-controls/agent/agent.ts +++ b/e2e/fixtures/agent-agent-tool-controls/agent/agent.ts @@ -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); }, }), diff --git a/e2e/fixtures/agent-agent-tool-controls/agent/subagents/operator/agent.ts b/e2e/fixtures/agent-agent-tool-controls/agent/subagents/operator/agent.ts new file mode 100644 index 000000000..c5522db8e --- /dev/null +++ b/e2e/fixtures/agent-agent-tool-controls/agent/subagents/operator/agent.ts @@ -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, +}); diff --git a/e2e/fixtures/agent-agent-tool-controls/agent/subagents/operator/instructions.md b/e2e/fixtures/agent-agent-tool-controls/agent/subagents/operator/instructions.md new file mode 100644 index 000000000..42ea7dd5e --- /dev/null +++ b/e2e/fixtures/agent-agent-tool-controls/agent/subagents/operator/instructions.md @@ -0,0 +1 @@ +Reply with the exact string `AUTO-ROUTER-OPERATOR` and nothing else. diff --git a/e2e/fixtures/agent-agent-tool-controls/agent/subagents/researcher/agent.ts b/e2e/fixtures/agent-agent-tool-controls/agent/subagents/researcher/agent.ts new file mode 100644 index 000000000..1d1ab5421 --- /dev/null +++ b/e2e/fixtures/agent-agent-tool-controls/agent/subagents/researcher/agent.ts @@ -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, +}); diff --git a/e2e/fixtures/agent-agent-tool-controls/agent/subagents/researcher/instructions.md b/e2e/fixtures/agent-agent-tool-controls/agent/subagents/researcher/instructions.md new file mode 100644 index 000000000..df18d1a5f --- /dev/null +++ b/e2e/fixtures/agent-agent-tool-controls/agent/subagents/researcher/instructions.md @@ -0,0 +1 @@ +Reply with the exact string `AUTO-ROUTER-RESEARCHER` and nothing else. diff --git a/e2e/fixtures/agent-agent-tool-controls/agent/tools/inspect-agents.ts b/e2e/fixtures/agent-agent-tool-controls/agent/tools/inspect-agents.ts new file mode 100644 index 000000000..e1c18eda7 --- /dev/null +++ b/e2e/fixtures/agent-agent-tool-controls/agent/tools/inspect-agents.ts @@ -0,0 +1,26 @@ +import { + defineWorkflowTool, + type WorkflowAgentMetadata, + type WorkflowToolContext, + type WorkflowToolDefinition, +} from "eve/tools"; + +async function execute( + _input: Record, + ctx: WorkflowToolContext, +): Promise> { + "use workflow"; + + return ctx.agents; +} + +const tool: WorkflowToolDefinition< + Record, + Record +> = defineWorkflowTool({ + description: "Return the workflow's available agent metadata.", + inputSchema: { type: "object", properties: {}, additionalProperties: false }, + execute, +}); + +export default tool; diff --git a/e2e/fixtures/agent-agent-tool-controls/evals/workflow-agent-metadata.eval.ts b/e2e/fixtures/agent-agent-tool-controls/evals/workflow-agent-metadata.eval.ts new file mode 100644 index 000000000..622e1271d --- /dev/null +++ b/e2e/fixtures/agent-agent-tool-controls/evals/workflow-agent-metadata.eval.ts @@ -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(); + }, +}); diff --git a/e2e/fixtures/agent-router/.gitignore b/e2e/fixtures/agent-router/.gitignore new file mode 100644 index 000000000..eb095f0f8 --- /dev/null +++ b/e2e/fixtures/agent-router/.gitignore @@ -0,0 +1,10 @@ +node_modules +.env* +.eve +.vercel +.next +.output +.nitro +dist +.DS_Store +*.tsbuildinfo diff --git a/e2e/fixtures/agent-router/.vercelignore b/e2e/fixtures/agent-router/.vercelignore new file mode 100644 index 000000000..357664320 --- /dev/null +++ b/e2e/fixtures/agent-router/.vercelignore @@ -0,0 +1,6 @@ +node_modules +.eve +.next +.output +.nitro +dist diff --git a/e2e/fixtures/agent-router/agent/agent.ts b/e2e/fixtures/agent-router/agent/agent.ts new file mode 100644 index 000000000..4660e5bd9 --- /dev/null +++ b/e2e/fixtures/agent-router/agent/agent.ts @@ -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, +}); diff --git a/e2e/fixtures/agent-router/agent/instructions.md b/e2e/fixtures/agent-router/agent/instructions.md new file mode 100644 index 000000000..35c7ede8a --- /dev/null +++ b/e2e/fixtures/agent-router/agent/instructions.md @@ -0,0 +1 @@ +Use the agent-router tool for every task. diff --git a/e2e/fixtures/agent-router/agent/subagents/worker/agent.ts b/e2e/fixtures/agent-router/agent/subagents/worker/agent.ts new file mode 100644 index 000000000..07f670a6a --- /dev/null +++ b/e2e/fixtures/agent-router/agent/subagents/worker/agent.ts @@ -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, +}); diff --git a/e2e/fixtures/agent-router/agent/subagents/worker/instructions.md b/e2e/fixtures/agent-router/agent/subagents/worker/instructions.md new file mode 100644 index 000000000..4c8dac607 --- /dev/null +++ b/e2e/fixtures/agent-router/agent/subagents/worker/instructions.md @@ -0,0 +1 @@ +Reply with the exact string `AGENT-ROUTER-WORKER-OK` and nothing else. diff --git a/e2e/fixtures/agent-router/agent/tools/agent-router.ts b/e2e/fixtures/agent-router/agent/tools/agent-router.ts new file mode 100644 index 000000000..2b2a06664 --- /dev/null +++ b/e2e/fixtures/agent-router/agent/tools/agent-router.ts @@ -0,0 +1,3 @@ +import { agentRouter } from "eve/tools/agent-router"; + +export default agentRouter(); diff --git a/e2e/fixtures/agent-router/evals/agent-router.eval.ts b/e2e/fixtures/agent-router/evals/agent-router.eval.ts new file mode 100644 index 000000000..ce692d824 --- /dev/null +++ b/e2e/fixtures/agent-router/evals/agent-router.eval.ts @@ -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(); + }, +}); diff --git a/e2e/fixtures/agent-router/evals/evals.config.ts b/e2e/fixtures/agent-router/evals/evals.config.ts new file mode 100644 index 000000000..93fa84714 --- /dev/null +++ b/e2e/fixtures/agent-router/evals/evals.config.ts @@ -0,0 +1,3 @@ +import { defineEvalConfig } from "eve/evals"; + +export default defineEvalConfig({ maxConcurrency: 1 }); diff --git a/e2e/fixtures/agent-router/package.json b/e2e/fixtures/agent-router/package.json new file mode 100644 index 000000000..12ec77f92 --- /dev/null +++ b/e2e/fixtures/agent-router/package.json @@ -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:" + } +} diff --git a/e2e/fixtures/agent-router/tsconfig.json b/e2e/fixtures/agent-router/tsconfig.json new file mode 100644 index 000000000..85e3eceb8 --- /dev/null +++ b/e2e/fixtures/agent-router/tsconfig.json @@ -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"] +} diff --git a/e2e/fixtures/agent-subagents/agent/tools/invoke-hidden.ts b/e2e/fixtures/agent-subagents/agent/tools/invoke-hidden.ts index c984910e0..517761aeb 100644 --- a/e2e/fixtures/agent-subagents/agent/tools/invoke-hidden.ts +++ b/e2e/fixtures/agent-subagents/agent/tools/invoke-hidden.ts @@ -6,17 +6,23 @@ import { import { z } from "zod"; type Input = { target: "tool-hidden" | "disabled-hidden" }; +type Output = { + description: string; + result: Awaited>; +}; 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> -> = defineWorkflowTool({ +const tool: WorkflowToolDefinition = defineWorkflowTool({ description: "Invoke an internal specialist selected by the caller.", inputSchema: z.object({ target: z.enum(["tool-hidden", "disabled-hidden"]), diff --git a/e2e/fixtures/agent-subagents/evals/hidden-subagents.eval.ts b/e2e/fixtures/agent-subagents/evals/hidden-subagents.eval.ts index 88b89c33a..ccaf70605 100644 --- a/e2e/fixtures/agent-subagents/evals/hidden-subagents.eval.ts +++ b/e2e/fixtures/agent-subagents/evals/hidden-subagents.eval.ts @@ -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" }); diff --git a/packages/eve/extension-contracts/compatibility/tool/v49.ts b/packages/eve/extension-contracts/compatibility/tool/v49.ts new file mode 100644 index 000000000..79e42d299 --- /dev/null +++ b/packages/eve/extension-contracts/compatibility/tool/v49.ts @@ -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; + }, +}); diff --git a/packages/eve/extension-contracts/entrypoints/tool.ts b/packages/eve/extension-contracts/entrypoints/tool.ts index dca259bc9..a9f50687e 100644 --- a/packages/eve/extension-contracts/entrypoints/tool.ts +++ b/packages/eve/extension-contracts/entrypoints/tool.ts @@ -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, diff --git a/packages/eve/extension-contracts/reports/tool/v50.json b/packages/eve/extension-contracts/reports/tool/v50.json new file mode 100644 index 000000000..f1b987e62 --- /dev/null +++ b/packages/eve/extension-contracts/reports/tool/v50.json @@ -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" + ] +} diff --git a/packages/eve/package.json b/packages/eve/package.json index 0fc46efc4..fa7982354 100644 --- a/packages/eve/package.json +++ b/packages/eve/package.json @@ -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", diff --git a/packages/eve/src/compiler/extension-compatibility.ts b/packages/eve/src/compiler/extension-compatibility.ts index 5b651f941..613453feb 100644 --- a/packages/eve/src/compiler/extension-compatibility.ts +++ b/packages/eve/src/compiler/extension-compatibility.ts @@ -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", diff --git a/packages/eve/src/execution/coordination-dispatch-shared.ts b/packages/eve/src/execution/coordination-dispatch-shared.ts index 4f86e707d..cdc2141ae 100644 --- a/packages/eve/src/execution/coordination-dispatch-shared.ts +++ b/packages/eve/src/execution/coordination-dispatch-shared.ts @@ -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 { readonly plan: readonly PlanEntry[]; readonly session: RuntimeSession; readonly sessionState: DurableSessionState; + readonly workflowAgents: Readonly>; } /** @@ -238,6 +241,7 @@ export async function prepareActionDispatch(input: { sandboxSessionId, serializedContext: input.serializedContext, session, + workflowAgents: resolveWorkflowAgentMetadata(ctx), }; } diff --git a/packages/eve/src/execution/coordination-dispatch-step.ts b/packages/eve/src/execution/coordination-dispatch-step.ts index f4a216861..87abc89df 100644 --- a/packages/eve/src/execution/coordination-dispatch-step.ts +++ b/packages/eve/src/execution/coordination-dispatch-step.ts @@ -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, diff --git a/packages/eve/src/execution/tasks/parent/tool-execution.ts b/packages/eve/src/execution/tasks/parent/tool-execution.ts index f02c51e04..e0880a3b6 100644 --- a/packages/eve/src/execution/tasks/parent/tool-execution.ts +++ b/packages/eve/src/execution/tasks/parent/tool-execution.ts @@ -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, diff --git a/packages/eve/src/execution/tools/agent-router-workflow.ts b/packages/eve/src/execution/tools/agent-router-workflow.ts new file mode 100644 index 000000000..196623975 --- /dev/null +++ b/packages/eve/src/execution/tools/agent-router-workflow.ts @@ -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 { + "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, + abortSignal: AbortSignal, +): Promise { + "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 { + return Object.fromEntries( + Object.entries(ctx.agents).map(([name, metadata]) => [name, metadata.description]), + ); +} diff --git a/packages/eve/src/execution/tools/agent-router.ts b/packages/eve/src/execution/tools/agent-router.ts new file mode 100644 index 000000000..d3926f1a7 --- /dev/null +++ b/packages/eve/src/execution/tools/agent-router.ts @@ -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 = 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(), +}); diff --git a/packages/eve/src/execution/tools/subagent/metadata.test.ts b/packages/eve/src/execution/tools/subagent/metadata.test.ts new file mode 100644 index 000000000..3933f5f4d --- /dev/null +++ b/packages/eve/src/execution/tools/subagent/metadata.test.ts @@ -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; +}): ContextContainer { + const ctx = new ContextContainer(); + ctx.set(BundleKey, { + nodeId: input.nodeId, + subagentRegistry: { subagentsByName: input.subagentsByName }, + } as never); + return ctx; +} diff --git a/packages/eve/src/execution/tools/subagent/metadata.ts b/packages/eve/src/execution/tools/subagent/metadata.ts new file mode 100644 index 000000000..9d8c55485 --- /dev/null +++ b/packages/eve/src/execution/tools/subagent/metadata.ts @@ -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> { + const bundle = ctx.get(BundleKey); + if (bundle === undefined) return {}; + const agents = new Map(); + + 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> { + return { + ...ctx.get(SessionDynamicSubagentSelectionsKey), + ...ctx.get(TurnDynamicSubagentSelectionsKey), + }; +} diff --git a/packages/eve/src/execution/tools/workflow/body.test.ts b/packages/eve/src/execution/tools/workflow/body.test.ts index 1700cea13..6024881ff 100644 --- a/packages/eve/src/execution/tools/workflow/body.test.ts +++ b/packages/eve/src/execution/tools/workflow/body.test.ts @@ -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); diff --git a/packages/eve/src/execution/tools/workflow/body.ts b/packages/eve/src/execution/tools/workflow/body.ts index 6c0fff26a..b51a22b45 100644 --- a/packages/eve/src/execution/tools/workflow/body.ts +++ b/packages/eve/src/execution/tools/workflow/body.ts @@ -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, diff --git a/packages/eve/src/execution/tools/workflow/start.test.ts b/packages/eve/src/execution/tools/workflow/start.test.ts index 65c017599..dbef0fde1 100644 --- a/packages/eve/src/execution/tools/workflow/start.test.ts +++ b/packages/eve/src/execution/tools/workflow/start.test.ts @@ -13,6 +13,7 @@ vi.mock("#execution/workflow-runtime.js", () => ({ })); const input: Omit = { + agents: { reviewer: { description: "Review deployments." } }, callId: "call-1", input: { service: "api" }, owner: { inbox: "owner-inbox" }, diff --git a/packages/eve/src/execution/tools/workflow/start.ts b/packages/eve/src/execution/tools/workflow/start.ts index d4ca99998..436c76c7a 100644 --- a/packages/eve/src/execution/tools/workflow/start.ts +++ b/packages/eve/src/execution/tools/workflow/start.ts @@ -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, diff --git a/packages/eve/src/execution/tools/workflow/types.ts b/packages/eve/src/execution/tools/workflow/types.ts index e68a68346..cc67e39db 100644 --- a/packages/eve/src/execution/tools/workflow/types.ts +++ b/packages/eve/src/execution/tools/workflow/types.ts @@ -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>; readonly callId: string; readonly execution?: "background" | "blocking"; readonly executeInput?: JsonValue; diff --git a/packages/eve/src/public/tools/agent-router.ts b/packages/eve/src/public/tools/agent-router.ts new file mode 100644 index 000000000..fe391650e --- /dev/null +++ b/packages/eve/src/public/tools/agent-router.ts @@ -0,0 +1,5 @@ +export { + agentRouter, + type AgentRouterInput, + type AgentRouterTool, +} from "#tools/provided/agent-router.js"; diff --git a/packages/eve/src/public/tools/index.ts b/packages/eve/src/public/tools/index.ts index 41e739e2a..400c6cc8b 100644 --- a/packages/eve/src/public/tools/index.ts +++ b/packages/eve/src/public/tools/index.ts @@ -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"; diff --git a/packages/eve/src/tools/provided/agent-router.test.ts b/packages/eve/src/tools/provided/agent-router.test.ts new file mode 100644 index 000000000..b9cb4cef2 --- /dev/null +++ b/packages/eve/src/tools/provided/agent-router.test.ts @@ -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 & + Partial>, +): WorkflowToolContext { + return { + abortSignal: input.abortSignal ?? new AbortController().signal, + agent: input.agent, + agents: input.agents, + } as WorkflowToolContext; +} diff --git a/packages/eve/src/tools/provided/agent-router.ts b/packages/eve/src/tools/provided/agent-router.ts new file mode 100644 index 000000000..5c7acdf55 --- /dev/null +++ b/packages/eve/src/tools/provided/agent-router.ts @@ -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; + +/** 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, + }); +} diff --git a/packages/eve/src/tools/workflow-definition.test.ts b/packages/eve/src/tools/workflow-definition.test.ts index 1cb60d464..5afc2e27b 100644 --- a/packages/eve/src/tools/workflow-definition.test.ts +++ b/packages/eve/src/tools/workflow-definition.test.ts @@ -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(); + expectTypeOf(ctx.agents.researcher).toEqualTypeOf(); 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; diff --git a/packages/eve/src/tools/workflow-definition.ts b/packages/eve/src/tools/workflow-definition.ts index d45b03a6e..96a7da4d8 100644 --- a/packages/eve/src/tools/workflow-definition.ts +++ b/packages/eve/src/tools/workflow-definition.ts @@ -64,6 +64,10 @@ type JsonSchemaOutput = TSchema extends { readonly const: infer TValue ? null : JsonValue; +export interface WorkflowAgentMetadata { + readonly description: string; +} + interface WorkflowAgent { ( 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>; /** Ask the human on the session's channel; awaiting the answer suspends the run. */ ask(request: ToolInputRequest): PromiseLike; }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b6cfd43c9..f0773e1dd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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': diff --git a/research/subagent-tool-projection.md b/research/subagent-tool-projection.md index 2d5144fbf..d148b205e 100644 --- a/research/subagent-tool-projection.md +++ b/research/subagent-tool-projection.md @@ -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. diff --git a/scripts/extension-contracts/configuration.mjs b/scripts/extension-contracts/configuration.mjs index 33f898525..9f2c4f9c3 100644 --- a/scripts/extension-contracts/configuration.mjs +++ b/scripts/extension-contracts/configuration.mjs @@ -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",