diff --git a/src/agent/factory.ts b/src/agent/factory.ts index 28e836fb72..64559811ca 100644 --- a/src/agent/factory.ts +++ b/src/agent/factory.ts @@ -8,6 +8,7 @@ import type { ResolvedAgentConfig, } from "./types.ts"; import { AgentRuntime } from "./runtime/index.ts"; +import { isRuntimeLocalTool } from "./runtime/local-tool.ts"; import { detectPlatform, validatePlatformCompatibility, @@ -74,6 +75,7 @@ export function agent(config: AgentConfig): Agent { if (config.tools && config.tools !== true) { for (const [name, entry] of Object.entries(config.tools)) { if (!entry || typeof entry !== "object") continue; + if (isRuntimeLocalTool(entry)) continue; const normalizedTool = entry.id === name ? entry : { ...entry, id: name }; registerTool(normalizedTool.id, normalizedTool); diff --git a/src/agent/index.ts b/src/agent/index.ts index e6b1b0a0cb..33c2f3eeae 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -223,6 +223,14 @@ export { isRuntimeAgentMarkdownAgent, } from "./runtime/agent-markdown-adapter.ts"; +export { + AGENT_DELEGATE_TOOL_PREFIX, + buildAgentDelegateTools, + type BuildAgentDelegateToolsInput, + type DelegateAgentResolver, + isProviderSafeDelegateId, +} from "./runtime/agent-delegation.ts"; + export { loadRuntimeAgentMarkdownDefinitionFromFile, type LoadRuntimeAgentMarkdownDefinitionFromFileInput, diff --git a/src/agent/runtime/agent-definition.test.ts b/src/agent/runtime/agent-definition.test.ts index 871260086a..75a31a574e 100644 --- a/src/agent/runtime/agent-definition.test.ts +++ b/src/agent/runtime/agent-definition.test.ts @@ -1,5 +1,5 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals } from "#veryfront/testing/assert.ts"; +import { assertEquals, assertThrows } from "#veryfront/testing/assert.ts"; import { createRuntimeAgentSystemMessages, parseRuntimeAgentMarkdownDefinition, @@ -106,3 +106,78 @@ Deno.test("createRuntimeAgentSystemMessages appends runtime blocks when marker i content: "\nBrowser timezone: UTC\n", }); }); + +Deno.test("parseRuntimeAgentMarkdownDefinition parses delegates frontmatter", () => { + const result = parseRuntimeAgentMarkdownDefinition({ + id: "lead", + content: `--- +name: Lead +delegates: + - writer + - editor +--- +Coordinate the work. +`, + }); + + assertEquals(result.delegates, ["writer", "editor"]); + + const noDelegates = parseRuntimeAgentMarkdownDefinition({ + id: "solo", + content: `--- +name: Solo +--- +Work alone. +`, + }); + + assertEquals(noDelegates.delegates, undefined); +}); + +Deno.test("parseRuntimeAgentMarkdownDefinition ignores empty delegate entries", () => { + const result = parseRuntimeAgentMarkdownDefinition({ + id: "writer", + content: `--- +name: Writer +delegates: ["", " "] +--- +Write copy. +`, + }); + + assertEquals(result.delegates, undefined); +}); + +Deno.test("parseRuntimeAgentMarkdownDefinition rejects self-delegation with a diagnostic", () => { + assertThrows( + () => + parseRuntimeAgentMarkdownDefinition({ + id: "lead", + content: `--- +name: Lead +delegates: [writer, lead] +--- +Coordinate. +`, + }), + Error, + 'Agent "lead" cannot delegate to itself', + ); +}); + +Deno.test("parseRuntimeAgentMarkdownDefinition rejects provider-unsafe delegate ids", () => { + assertThrows( + () => + parseRuntimeAgentMarkdownDefinition({ + id: "lead", + content: `--- +name: Lead +delegates: [data.fetcher] +--- +Coordinate. +`, + }), + Error, + 'produces an invalid tool name "agent_data.fetcher"', + ); +}); diff --git a/src/agent/runtime/agent-definition.ts b/src/agent/runtime/agent-definition.ts index fdcfbc8e54..e3e4dc89bb 100644 --- a/src/agent/runtime/agent-definition.ts +++ b/src/agent/runtime/agent-definition.ts @@ -5,6 +5,7 @@ import type { ChatSystemMessage } from "#veryfront/chat/types.ts"; import { createRuntimePromptBlock } from "./prompt-block.ts"; import { buildRuntimeAvailableSkillsPromptBlock } from "./skill-prompt.ts"; import type { RuntimeSkillDefinition } from "./skill-metadata.ts"; +import { AGENT_DELEGATE_TOOL_PREFIX, isProviderSafeDelegateId } from "./agent-delegation-names.ts"; /** Zod schema for get runtime agent thinking config. */ export const getRuntimeAgentThinkingConfigSchema = defineSchema((v) => @@ -36,6 +37,7 @@ export const getRuntimeAgentMarkdownDefinitionSchema = defineSchema((v) => temperature: v.number().min(0).max(2).optional(), maxSteps: v.number().optional(), providerTools: v.array(v.string().min(1)).optional(), + delegates: v.array(v.string().min(1)).optional(), }) ); @@ -104,6 +106,33 @@ function parseProviderTools(value: unknown): unknown[] | undefined { return value; } +function parseDelegates(value: unknown): string[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const ids = value + .filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0) + .map((entry) => entry.trim()); + return ids.length > 0 ? ids : undefined; +} + +function validateDelegates(agentId: string, delegates: string[] | undefined): void { + if (!delegates) { + return; + } + for (const delegateId of delegates) { + if (delegateId === agentId) { + throw new Error(`Agent "${agentId}" cannot delegate to itself.`); + } + if (!isProviderSafeDelegateId(delegateId)) { + throw new Error( + `Delegate id "${delegateId}" for agent "${agentId}" produces an invalid tool name ` + + `"${AGENT_DELEGATE_TOOL_PREFIX}${delegateId}" (must match [A-Za-z0-9_-], max 64 chars).`, + ); + } + } +} + /** Definition for parse runtime agent markdown. */ export function parseRuntimeAgentMarkdownDefinition( input: ParseRuntimeAgentMarkdownDefinitionInput, @@ -117,6 +146,8 @@ export function parseRuntimeAgentMarkdownDefinition( const temperature = typeof attrs.temperature === "number" ? attrs.temperature : undefined; const maxSteps = typeof attrs["max-steps"] === "number" ? attrs["max-steps"] : undefined; const providerTools = parseProviderTools(attrs["provider-tools"]); + const delegates = parseDelegates(attrs.delegates); + validateDelegates(parsedInput.id, delegates); return getRuntimeAgentMarkdownDefinitionSchema().parse({ id: parsedInput.id, @@ -128,6 +159,7 @@ export function parseRuntimeAgentMarkdownDefinition( ...(temperature === undefined ? {} : { temperature }), ...(maxSteps === undefined ? {} : { maxSteps }), ...(providerTools ? { providerTools } : {}), + ...(delegates === undefined ? {} : { delegates }), }); } diff --git a/src/agent/runtime/agent-delegation-names.ts b/src/agent/runtime/agent-delegation-names.ts new file mode 100644 index 0000000000..6f54fee143 --- /dev/null +++ b/src/agent/runtime/agent-delegation-names.ts @@ -0,0 +1,10 @@ +/** Prefix used for the delegate tool exposed to the coordinator agent. */ +export const AGENT_DELEGATE_TOOL_PREFIX = "agent_"; + +/** Provider tool-call names allow only this charset, max 64 chars. */ +const PROVIDER_TOOL_NAME_REGEX = /^[A-Za-z0-9_-]{1,64}$/; + +/** Whether a delegate id produces a provider-safe `agent_{id}` tool name. */ +export function isProviderSafeDelegateId(delegateId: string): boolean { + return PROVIDER_TOOL_NAME_REGEX.test(`${AGENT_DELEGATE_TOOL_PREFIX}${delegateId}`); +} diff --git a/src/agent/runtime/agent-delegation.test.ts b/src/agent/runtime/agent-delegation.test.ts new file mode 100644 index 0000000000..4e6c7f5eaf --- /dev/null +++ b/src/agent/runtime/agent-delegation.test.ts @@ -0,0 +1,80 @@ +import "#veryfront/schemas/_test-setup.ts"; +import { assertEquals } from "#veryfront/testing/assert.ts"; +import { + AGENT_DELEGATE_TOOL_PREFIX, + buildAgentDelegateTools, + isProviderSafeDelegateId, +} from "./agent-delegation.ts"; +import type { Agent } from "../types.ts"; + +Deno.test("buildAgentDelegateTools exposes one tool per delegate, excluding self and dupes", () => { + const tools = buildAgentDelegateTools({ + delegates: ["writer", "researcher", "writer", "lead", " "], + selfId: "lead", + resolveAgent: () => undefined, + }); + + assertEquals(Object.keys(tools).sort(), [ + `${AGENT_DELEGATE_TOOL_PREFIX}researcher`, + `${AGENT_DELEGATE_TOOL_PREFIX}writer`, + ]); + assertEquals( + tools[`${AGENT_DELEGATE_TOOL_PREFIX}writer`].id, + `${AGENT_DELEGATE_TOOL_PREFIX}writer`, + ); +}); + +Deno.test("buildAgentDelegateTools returns no tools when there are no delegates", () => { + assertEquals(buildAgentDelegateTools({ delegates: [], resolveAgent: () => undefined }), {}); +}); + +Deno.test("buildAgentDelegateTools skips ids that produce provider-unsafe tool names", () => { + const tools = buildAgentDelegateTools({ + delegates: ["data.fetcher", "writer", "über-agent"], + resolveAgent: () => undefined, + }); + + assertEquals(Object.keys(tools), [`${AGENT_DELEGATE_TOOL_PREFIX}writer`]); +}); + +Deno.test("isProviderSafeDelegateId accepts safe ids and rejects unsafe ones", () => { + assertEquals(isProviderSafeDelegateId("writer"), true); + assertEquals(isProviderSafeDelegateId("writer-2_b"), true); + assertEquals(isProviderSafeDelegateId("data.fetcher"), false); + assertEquals(isProviderSafeDelegateId("a".repeat(64)), false); +}); + +Deno.test("delegate tool runs the resolved specialist agent and returns its result", async () => { + const writer = { + id: "writer", + config: {}, + stream: (input: { onFinish?: (response: unknown) => void }) => { + input.onFinish?.({ text: "drafted copy", toolCalls: [], status: "completed" }); + return Promise.resolve({ toDataStreamResponse: () => new Response("") }); + }, + } as unknown as Agent; + + const tools = buildAgentDelegateTools({ + delegates: ["writer"], + resolveAgent: (id) => (id === "writer" ? writer : undefined), + }); + + const result = await tools[`${AGENT_DELEGATE_TOOL_PREFIX}writer`].execute({ input: "Draft it." }); + + assertEquals(result, { text: "drafted copy", toolCalls: 0, status: "completed" }); +}); + +Deno.test("delegate tool reports an error when the target agent is unavailable", async () => { + const tools = buildAgentDelegateTools({ + delegates: ["writer"], + resolveAgent: () => undefined, + }); + + const result = await tools[`${AGENT_DELEGATE_TOOL_PREFIX}writer`].execute({ input: "Draft it." }); + + assertEquals(result, { + text: 'Delegate agent "writer" is not available.', + toolCalls: 0, + status: "error", + }); +}); diff --git a/src/agent/runtime/agent-delegation.ts b/src/agent/runtime/agent-delegation.ts new file mode 100644 index 0000000000..b4008be74c --- /dev/null +++ b/src/agent/runtime/agent-delegation.ts @@ -0,0 +1,83 @@ +import type { Tool } from "../../tool/types.ts"; +import type { Agent } from "../types.ts"; +import { agentAsTool, getAgent } from "../composition/index.ts"; +import { getAgentToolInputSchema } from "../schemas/index.ts"; +import { AGENT_DELEGATE_TOOL_PREFIX, isProviderSafeDelegateId } from "./agent-delegation-names.ts"; +import { markRuntimeLocalTool } from "./local-tool.ts"; + +export { AGENT_DELEGATE_TOOL_PREFIX, isProviderSafeDelegateId }; + +/** Resolves a registered agent by id (defaults to the global registry). */ +export type DelegateAgentResolver = (id: string) => Agent | undefined; + +/** Input payload for build agent delegate tools. */ +export type BuildAgentDelegateToolsInput = { + /** Specialist agent ids this coordinator is allowed to delegate to. */ + delegates: readonly string[]; + /** Id of the delegating agent, excluded to prevent self-delegation. */ + selfId?: string; + /** Override the agent resolver (testing / custom registries). */ + resolveAgent?: DelegateAgentResolver; +}; + +function createLazyDelegateTool( + delegateId: string, + resolveAgent: DelegateAgentResolver, +): Tool { + return markRuntimeLocalTool({ + id: `${AGENT_DELEGATE_TOOL_PREFIX}${delegateId}`, + type: "function", + description: `Delegate a self-contained subtask to the "${delegateId}" specialist agent, ` + + `which runs with its own settings and skills. Provide a complete, standalone instruction.`, + inputSchema: getAgentToolInputSchema(), + execute(input, context) { + const target = resolveAgent(delegateId); + if (!target) { + return Promise.resolve({ + text: `Delegate agent "${delegateId}" is not available.`, + toolCalls: 0, + status: "error", + }); + } + + return agentAsTool(target, `Delegate to ${delegateId}`).execute(input, context); + }, + }); +} + +/** + * Builds the opt-in delegate tools for a coordinator agent. + * + * Each entry in `delegates` becomes an `agent_{id}` tool that runs the named + * specialist agent. Agents are resolved lazily at execution time so discovery + * order does not matter. Self-delegation, duplicates, and ids that would + * produce a provider-unsafe tool name are skipped defensively here; markdown + * parsing rejects the latter two cases up front with an explicit diagnostic. + * Returns an empty map when `delegates` is empty — i.e. an agent with no + * `delegates` runs with no orchestration. + * + * Delegation chains are intentionally not cycle-detected here. Each delegated + * call is a separate agent run with its own maxSteps budget; keep delegate + * graphs acyclic until a runtime chain-depth cap exists. + */ +export function buildAgentDelegateTools( + input: BuildAgentDelegateToolsInput, +): Record { + const resolveAgent = input.resolveAgent ?? getAgent; + const tools: Record = {}; + const seen = new Set(); + + for (const delegateId of input.delegates) { + const id = delegateId.trim(); + if (id.length === 0 || id === input.selfId || seen.has(id)) { + continue; + } + if (!isProviderSafeDelegateId(id)) { + continue; + } + seen.add(id); + tools[`${AGENT_DELEGATE_TOOL_PREFIX}${id}`] = createLazyDelegateTool(id, resolveAgent); + } + + return tools; +} diff --git a/src/agent/runtime/agent-markdown-adapter.test.ts b/src/agent/runtime/agent-markdown-adapter.test.ts index f5dfb3fdbc..325e9e03d9 100644 --- a/src/agent/runtime/agent-markdown-adapter.test.ts +++ b/src/agent/runtime/agent-markdown-adapter.test.ts @@ -1,5 +1,6 @@ import "#veryfront/schemas/_test-setup.ts"; import { assertEquals } from "#veryfront/testing/assert.ts"; +import { toolRegistry } from "#veryfront/tool"; import { createRuntimeAgentFromMarkdownDefinition } from "./agent-markdown-adapter.ts"; Deno.test("createRuntimeAgentFromMarkdownDefinition preserves provider-native tools", () => { @@ -14,3 +15,34 @@ Deno.test("createRuntimeAgentFromMarkdownDefinition preserves provider-native to assertEquals(runtimeAgent.config.providerTools, ["web_search", "web_fetch"]); }); + +Deno.test("createRuntimeAgentFromMarkdownDefinition binds delegate tools from delegates", () => { + toolRegistry.clearAll(); + + const runtimeAgent = createRuntimeAgentFromMarkdownDefinition({ + id: "lead-delegation-test", + name: "Lead", + description: "Coordinates specialists", + instructions: "Break the task down and delegate.", + delegates: ["writer", "researcher"], + }); + + const tools = runtimeAgent.config.tools as Record | undefined; + assertEquals( + Object.keys(tools ?? {}).sort(), + ["agent_researcher", "agent_writer"], + ); + assertEquals(toolRegistry.has("agent_researcher"), false); + assertEquals(toolRegistry.has("agent_writer"), false); +}); + +Deno.test("createRuntimeAgentFromMarkdownDefinition binds no tools without delegates", () => { + const runtimeAgent = createRuntimeAgentFromMarkdownDefinition({ + id: "solo-delegation-test", + name: "Solo", + description: "Independent agent", + instructions: "Work alone.", + }); + + assertEquals(runtimeAgent.config.tools, undefined); +}); diff --git a/src/agent/runtime/agent-markdown-adapter.ts b/src/agent/runtime/agent-markdown-adapter.ts index 777ddbdaca..a1dfb28c5d 100644 --- a/src/agent/runtime/agent-markdown-adapter.ts +++ b/src/agent/runtime/agent-markdown-adapter.ts @@ -1,6 +1,7 @@ import { agent } from "../factory.ts"; import type { Agent } from "../types.ts"; import type { RuntimeAgentMarkdownDefinition } from "./agent-definition.ts"; +import { buildAgentDelegateTools } from "./agent-delegation.ts"; const markdownDefinitionByAgent = new WeakMap(); @@ -8,6 +9,10 @@ const markdownDefinitionByAgent = new WeakMap 0 + ? buildAgentDelegateTools({ delegates: definition.delegates, selfId: definition.id }) + : undefined; + const runtimeAgent = agent({ id: definition.id, name: definition.name, @@ -17,6 +22,7 @@ export function createRuntimeAgentFromMarkdownDefinition( ...(definition.temperature === undefined ? {} : { temperature: definition.temperature }), ...(definition.maxSteps === undefined ? {} : { maxSteps: definition.maxSteps }), ...(definition.providerTools ? { providerTools: definition.providerTools } : {}), + ...(delegateTools && Object.keys(delegateTools).length > 0 ? { tools: delegateTools } : {}), }); markdownDefinitionByAgent.set(runtimeAgent, definition); diff --git a/src/agent/runtime/local-tool.ts b/src/agent/runtime/local-tool.ts new file mode 100644 index 0000000000..fc793c6dba --- /dev/null +++ b/src/agent/runtime/local-tool.ts @@ -0,0 +1,25 @@ +import type { Tool } from "#veryfront/tool"; + +const AGENT_RUNTIME_LOCAL_TOOL = Symbol("veryfront.agent.runtimeLocalTool"); + +type RuntimeLocalTool = Tool & { + [AGENT_RUNTIME_LOCAL_TOOL]?: true; +}; + +/** Mark a framework-created tool as local to one agent runtime. */ +export function markRuntimeLocalTool(tool: Tool): Tool { + Object.defineProperty(tool, AGENT_RUNTIME_LOCAL_TOOL, { + value: true, + enumerable: false, + }); + return tool; +} + +/** Check whether a tool must stay out of the project-wide tool registry. */ +export function isRuntimeLocalTool(value: unknown): boolean { + return Boolean( + value && + typeof value === "object" && + (value as RuntimeLocalTool)[AGENT_RUNTIME_LOCAL_TOOL] === true, + ); +}