Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/agent/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
8 changes: 8 additions & 0 deletions src/agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
77 changes: 76 additions & 1 deletion src/agent/runtime/agent-definition.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -106,3 +106,78 @@ Deno.test("createRuntimeAgentSystemMessages appends runtime blocks when marker i
content: "<environment_context>\nBrowser timezone: UTC\n</environment_context>",
});
});

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"',
);
});
32 changes: 32 additions & 0 deletions src/agent/runtime/agent-definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down Expand Up @@ -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(),
})
);

Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -128,6 +159,7 @@ export function parseRuntimeAgentMarkdownDefinition(
...(temperature === undefined ? {} : { temperature }),
...(maxSteps === undefined ? {} : { maxSteps }),
...(providerTools ? { providerTools } : {}),
...(delegates === undefined ? {} : { delegates }),
});
}

Expand Down
10 changes: 10 additions & 0 deletions src/agent/runtime/agent-delegation-names.ts
Original file line number Diff line number Diff line change
@@ -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}`);
}
80 changes: 80 additions & 0 deletions src/agent/runtime/agent-delegation.test.ts
Original file line number Diff line number Diff line change
@@ -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",
});
});
83 changes: 83 additions & 0 deletions src/agent/runtime/agent-delegation.ts
Original file line number Diff line number Diff line change
@@ -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<string, Tool> {
const resolveAgent = input.resolveAgent ?? getAgent;
const tools: Record<string, Tool> = {};
const seen = new Set<string>();

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;
}
Loading