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,
+ );
+}