mirror of
https://github.com/vercel/eve.git
synced 2026-09-20 05:35:39 +08:00
feat(eve): add workflow agent routing (#3485)
Signed-off-by: Casey Gowrie <ctgowrie@gmail.com>
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
.env*
|
||||
.eve
|
||||
.vercel
|
||||
.next
|
||||
.output
|
||||
.nitro
|
||||
dist
|
||||
.DS_Store
|
||||
*.tsbuildinfo
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
.eve
|
||||
.next
|
||||
.output
|
||||
.nitro
|
||||
dist
|
||||
@@ -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 });
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
Generated
+19
@@ -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':
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user