Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
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",
});
});
82 changes: 82 additions & 0 deletions src/agent/runtime/agent-delegation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
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";

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 {
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;
}
27 changes: 27 additions & 0 deletions src/agent/runtime/agent-markdown-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,30 @@ Deno.test("createRuntimeAgentFromMarkdownDefinition preserves provider-native to

assertEquals(runtimeAgent.config.providerTools, ["web_search", "web_fetch"]);
});

Deno.test("createRuntimeAgentFromMarkdownDefinition binds delegate tools from delegates", () => {
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<string, unknown> | undefined;
assertEquals(
Object.keys(tools ?? {}).sort(),
["agent_researcher", "agent_writer"],
);
});

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