From 1299ba4a02a9e7f9a6faa3ccad7b1947f9919f23 Mon Sep 17 00:00:00 2001 From: jinlongqi Date: Fri, 3 Jul 2026 22:10:36 +0800 Subject: [PATCH 1/2] fix(ui): use process.exit(0) instead of Ink exit() in handleExit to ensure -p mode auto-exits reliably --- packages/cli/src/ui/views/App.tsx | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/views/App.tsx b/packages/cli/src/ui/views/App.tsx index 3b2886c..ab21052 100644 --- a/packages/cli/src/ui/views/App.tsx +++ b/packages/cli/src/ui/views/App.tsx @@ -315,10 +315,10 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp } sessionManager.dispose(); - exit(); + process.exit(0); }, 0); }, - [exit, sessionManager] + [sessionManager] ); const handlePrompt = useCallback( @@ -414,6 +414,13 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp setRunningProcesses( finalActiveSessionId ? (sessionManager.getSession(finalActiveSessionId)?.processes ?? null) : null ); + // Auto-exit after initial -p/--prompt submission completes, without + // relying on the busy effect which can miss the transition when React + // batches setBusy(true) and setBusy(false) into the same render. + if (initialPromptSubmittedRef.current) { + initialPromptSubmittedRef.current = false; + handleExit({ showCommand: false, showSummary: true }); + } } }, [ @@ -562,6 +569,15 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp void run(); }, [handleSubmit, handleSelectSession, initialPrompt, navigateToSubView, refreshSessionsList, resumeSessionId]); + // Auto-exit after -p/--prompt initial submission completes + const prevBusyRef = useRef(busy); + useEffect(() => { + if (prevBusyRef.current && !busy && initialPromptSubmittedRef.current) { + handleExit({ showCommand: false, showSummary: true }); + } + prevBusyRef.current = busy; + }, [busy, handleExit]); + const handleDeleteSession = useCallback( async (id: string): Promise => { const isActiveSession = sessionManager.getActiveSessionId() === id; From f01ab3264f01b8821d3f2653c4e4bc81515c661b Mon Sep 17 00:00:00 2001 From: jinlongqi Date: Sun, 5 Jul 2026 15:38:57 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E5=AD=90agents?= =?UTF-8?q?=E8=83=BD=E5=8A=9B=EF=BC=8C=E8=87=AA=E5=8A=A8=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E5=AD=90agent=EF=BC=8C=E7=9B=AE=E5=BD=95=E6=A0=BC=E5=BC=8F?= =?UTF-8?q?=E8=A6=81=E6=B1=82.deepcode/[name]/AGENT.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/tests/agent-prompt-extraction.test.ts | 110 +++ .../src/tests/slash-commands-agents.test.ts | 132 ++++ packages/cli/src/tests/slash-commands.test.ts | 1 + .../src/ui/components/MessageView/index.tsx | 43 +- .../src/ui/components/MessageView/utils.ts | 18 + packages/cli/src/ui/core/slash-commands.ts | 22 +- packages/cli/src/ui/views/AgentList.tsx | 185 +++++ packages/cli/src/ui/views/App.tsx | 120 +++- packages/cli/src/ui/views/PromptInput.tsx | 34 +- packages/core/src/agents/agent-registry.ts | 206 ++++++ packages/core/src/agents/delegate-handler.ts | 77 +++ packages/core/src/agents/format-agents.ts | 27 + packages/core/src/agents/index.ts | 25 + packages/core/src/agents/skill-resolver.ts | 107 +++ packages/core/src/agents/sub-agent-session.ts | 292 ++++++++ packages/core/src/index.ts | 22 + packages/core/src/prompt.ts | 37 +- packages/core/src/session.ts | 210 +++++- .../core/src/tests/agent-registry.test.ts | 654 ++++++++++++++++++ .../core/src/tests/delegate-handler.test.ts | 365 ++++++++++ packages/core/src/tests/format-agents.test.ts | 242 +++++++ packages/core/src/tests/prompt-agents.test.ts | 333 +++++++++ .../core/src/tests/skill-resolver.test.ts | 328 +++++++++ .../core/src/tests/sub-agent-session.test.ts | 575 +++++++++++++++ packages/core/src/tools/executor.ts | 32 + 25 files changed, 4186 insertions(+), 11 deletions(-) create mode 100644 packages/cli/src/tests/agent-prompt-extraction.test.ts create mode 100644 packages/cli/src/tests/slash-commands-agents.test.ts create mode 100644 packages/cli/src/ui/views/AgentList.tsx create mode 100644 packages/core/src/agents/agent-registry.ts create mode 100644 packages/core/src/agents/delegate-handler.ts create mode 100644 packages/core/src/agents/format-agents.ts create mode 100644 packages/core/src/agents/index.ts create mode 100644 packages/core/src/agents/skill-resolver.ts create mode 100644 packages/core/src/agents/sub-agent-session.ts create mode 100644 packages/core/src/tests/agent-registry.test.ts create mode 100644 packages/core/src/tests/delegate-handler.test.ts create mode 100644 packages/core/src/tests/format-agents.test.ts create mode 100644 packages/core/src/tests/prompt-agents.test.ts create mode 100644 packages/core/src/tests/skill-resolver.test.ts create mode 100644 packages/core/src/tests/sub-agent-session.test.ts diff --git a/packages/cli/src/tests/agent-prompt-extraction.test.ts b/packages/cli/src/tests/agent-prompt-extraction.test.ts new file mode 100644 index 0000000..b9ed063 --- /dev/null +++ b/packages/cli/src/tests/agent-prompt-extraction.test.ts @@ -0,0 +1,110 @@ +/** + * Property test: Task prompt extraction from agent slash command + * + * **Validates: Requirements 3.3** + * + * Property statement: For any input string of the form `/ ` + * where `` matches a discovered agent, the CLI SHALL extract + * `` (trimmed) as the task prompt passed to the sub-agent session. + * + * This tests the extraction logic used in PromptInput.tsx handleSlashSelection + * for "agent" kind items. + */ + +import { test } from "node:test"; +import assert from "node:assert/strict"; + +// -- Extraction helper mirroring PromptInput.tsx logic -- + +/** + * Extracts the task prompt from a full input string given an agent command prefix. + * This mirrors the logic in PromptInput.tsx handleSlashSelection for "agent" kind: + * + * const fullText = buffer.text.trim(); + * const commandPrefix = `/${item.name}`; + * const taskText = fullText.startsWith(commandPrefix) + * ? fullText.slice(commandPrefix.length).trim() + * : ""; + */ +function extractAgentTaskPrompt(fullText: string, agentName: string): string { + const trimmed = fullText.trim(); + const commandPrefix = `/${agentName}`; + return trimmed.startsWith(commandPrefix) ? trimmed.slice(commandPrefix.length).trim() : ""; +} + +// -- Property tests -- + +test("Property 5: basic task extraction — /agent-name some task text", () => { + const result = extractAgentTaskPrompt("/deploy-assistant fix the build", "deploy-assistant"); + assert.equal(result, "fix the build"); +}); + +test("Property 5: extra spaces in task text are trimmed", () => { + const result = extractAgentTaskPrompt("/ut-agent extra spaces ", "ut-agent"); + assert.equal(result, "extra spaces"); +}); + +test("Property 5: no task text after agent name yields empty string", () => { + const result = extractAgentTaskPrompt("/deploy-assistant", "deploy-assistant"); + assert.equal(result, ""); +}); + +test("Property 5: newlines in task body are preserved", () => { + const result = extractAgentTaskPrompt("/coder multi\nline task", "coder"); + assert.equal(result, "multi\nline task"); +}); + +test("Property 5: leading whitespace in input is trimmed before matching", () => { + const result = extractAgentTaskPrompt(" /eaa leading spaces ", "eaa"); + assert.equal(result, "leading spaces"); +}); + +test("Property 5: non-matching prefix returns empty string", () => { + const result = extractAgentTaskPrompt("/other-agent some text", "deploy-assistant"); + assert.equal(result, ""); +}); + +test("Property 5: agent name with single character works", () => { + const result = extractAgentTaskPrompt("/x do something", "x"); + assert.equal(result, "do something"); +}); + +test("Property 5: agent name as substring of input does not falsely match", () => { + // /deploy is a prefix of /deploy-assistant, but agentName is "deploy-assistant" + const result = extractAgentTaskPrompt("/deploy run tests", "deploy-assistant"); + assert.equal(result, ""); +}); + +test("Property 5: agent name exactly at boundary — task starts immediately after prefix", () => { + // No space between prefix and task — slice still captures it, trim just returns it + const result = extractAgentTaskPrompt("/codertask without space", "coder"); + assert.equal(result, "task without space"); +}); + +test("Property 5: empty input string yields empty string", () => { + const result = extractAgentTaskPrompt("", "deploy-assistant"); + assert.equal(result, ""); +}); + +test("Property 5: whitespace-only input yields empty string", () => { + const result = extractAgentTaskPrompt(" ", "deploy-assistant"); + assert.equal(result, ""); +}); + +test("Property 5: task with special characters is preserved", () => { + const result = extractAgentTaskPrompt( + "/ut-agent generate tests for fn(a: string[], b?: {x: number}): void", + "ut-agent" + ); + assert.equal(result, "generate tests for fn(a: string[], b?: {x: number}): void"); +}); + +test("Property 5: task with unicode characters is preserved", () => { + const result = extractAgentTaskPrompt("/coder 修复登录bug", "coder"); + assert.equal(result, "修复登录bug"); +}); + +test("Property 5: trailing whitespace on input is handled by initial trim", () => { + const result = extractAgentTaskPrompt("/deploy-assistant fix build \n\n", "deploy-assistant"); + assert.equal(result, "fix build"); +}); diff --git a/packages/cli/src/tests/slash-commands-agents.test.ts b/packages/cli/src/tests/slash-commands-agents.test.ts new file mode 100644 index 0000000..d1ebc1d --- /dev/null +++ b/packages/cli/src/tests/slash-commands-agents.test.ts @@ -0,0 +1,132 @@ +/** + * Property test: Slash command generation matches discovered agents + * + * **Validates: Requirements 3.1** + * + * Property statement: For any non-empty set of discovered agents, + * `buildSlashCommands` SHALL produce exactly one slash command item + * of kind "agent" for each discovered agent, with the command name + * matching the agent's name. + */ + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { buildSlashCommands } from "../ui"; +import type { AgentManifest } from "@vegamo/deepcode-core"; +import type { SkillInfo } from "@vegamo/deepcode-core"; + +// -- Test helpers -- + +function makeAgent(overrides: Partial = {}): AgentManifest { + return { + name: overrides.name ?? "test-agent", + description: overrides.description ?? "A test agent", + model: overrides.model ?? "claude", + skills: overrides.skills ?? [], + instructions: overrides.instructions ?? "# Test Agent", + sourcePath: overrides.sourcePath ?? "/fake/path/AGENT.md", + sourceRoot: overrides.sourceRoot ?? "./.deepcode/agents", + }; +} + +const emptySkills: SkillInfo[] = []; + +// -- Property tests -- + +test("Property 4: no agents produces no agent items", () => { + const items = buildSlashCommands(emptySkills, []); + const agentItems = items.filter((i) => i.kind === "agent"); + assert.equal(agentItems.length, 0); +}); + +test("Property 4: single agent produces exactly one agent item with matching name", () => { + const agents: AgentManifest[] = [makeAgent({ name: "deploy-assistant", description: "Deploys to test environment" })]; + + const items = buildSlashCommands(emptySkills, agents); + const agentItems = items.filter((i) => i.kind === "agent"); + + assert.equal(agentItems.length, 1); + assert.equal(agentItems[0].name, "deploy-assistant"); + assert.equal(agentItems[0].kind, "agent"); +}); + +test("Property 4: multiple agents produce one item each with correct names", () => { + const agents: AgentManifest[] = [ + makeAgent({ name: "deploy-assistant", description: "Deploys to test environment" }), + makeAgent({ name: "ut-agent", description: "Unit test generation" }), + makeAgent({ name: "code-review", description: "Reviews code changes" }), + ]; + + const items = buildSlashCommands(emptySkills, agents); + const agentItems = items.filter((i) => i.kind === "agent"); + + assert.equal(agentItems.length, 3); + assert.deepEqual( + agentItems.map((i) => i.name), + ["deploy-assistant", "ut-agent", "code-review"] + ); +}); + +test("Property 4: agent items appear before skill items and built-in items", () => { + const skills: SkillInfo[] = [ + { name: "testing-skill", path: "/skills/testing-skill/SKILL.md", description: "A skill" }, + ]; + const agents: AgentManifest[] = [makeAgent({ name: "deploy-assistant", description: "Deploy agent" })]; + + const items = buildSlashCommands(skills, agents); + + // Find positions + const agentIndex = items.findIndex((i) => i.kind === "agent"); + const skillIndex = items.findIndex((i) => i.kind === "skill"); + const builtinIndex = items.findIndex((i) => i.kind !== "agent" && i.kind !== "skill"); + + assert.ok(agentIndex < skillIndex, "Agent items should appear before skill items"); + assert.ok(agentIndex < builtinIndex, "Agent items should appear before built-in items"); +}); + +test("Property 4: each agent item has kind 'agent' and label matching /", () => { + const agents: AgentManifest[] = [ + makeAgent({ name: "deploy-assistant", description: "Deploys to test environment" }), + makeAgent({ name: "eaa", description: "Accessibility helper" }), + ]; + + const items = buildSlashCommands(emptySkills, agents); + const agentItems = items.filter((i) => i.kind === "agent"); + + for (const agent of agents) { + const item = agentItems.find((i) => i.name === agent.name); + assert.ok(item, `Expected to find item for agent "${agent.name}"`); + assert.equal(item.kind, "agent"); + assert.equal(item.label, `/${agent.name}`); + assert.equal(item.description, agent.description); + } +}); + +test("Property 4: agent count matches exactly — no extra agent items", () => { + const agents: AgentManifest[] = [ + makeAgent({ name: "agent-a", description: "First" }), + makeAgent({ name: "agent-b", description: "Second" }), + makeAgent({ name: "agent-c", description: "Third" }), + makeAgent({ name: "agent-d", description: "Fourth" }), + makeAgent({ name: "agent-e", description: "Fifth" }), + ]; + + const items = buildSlashCommands(emptySkills, agents); + const agentItems = items.filter((i) => i.kind === "agent"); + + assert.equal(agentItems.length, agents.length); + for (const agent of agents) { + const matching = agentItems.filter((i) => i.name === agent.name); + assert.equal(matching.length, 1, `Expected exactly one item for agent "${agent.name}"`); + } +}); + +test("Property 4: agent with empty description gets '(no description)'", () => { + const agents: AgentManifest[] = [makeAgent({ name: "no-desc-agent", description: "" })]; + + const items = buildSlashCommands(emptySkills, agents); + const agentItems = items.filter((i) => i.kind === "agent"); + + assert.equal(agentItems.length, 1); + assert.equal(agentItems[0].description, "(no description)"); +}); diff --git a/packages/cli/src/tests/slash-commands.test.ts b/packages/cli/src/tests/slash-commands.test.ts index 420e5a4..a968251 100644 --- a/packages/cli/src/tests/slash-commands.test.ts +++ b/packages/cli/src/tests/slash-commands.test.ts @@ -20,6 +20,7 @@ test("buildSlashCommands prefixes skills before built-ins", () => { assert.equal(items[0].name, "skill-writer"); const builtinNames = items.filter((i) => i.kind !== "skill").map((i) => i.name); assert.deepEqual(builtinNames, [ + "agents", "skills", "model", "new", diff --git a/packages/cli/src/ui/components/MessageView/index.tsx b/packages/cli/src/ui/components/MessageView/index.tsx index 12b8229..9b23b97 100644 --- a/packages/cli/src/ui/components/MessageView/index.tsx +++ b/packages/cli/src/ui/components/MessageView/index.tsx @@ -11,6 +11,7 @@ import { } from "./utils"; import type { DiffPreviewLine, MessageViewProps } from "./types"; import { RawMode, useRawModeContext } from "../../contexts"; +import type { SessionMessage } from "@vegamo/deepcode-core"; const PROMPT_ECHO_PREFIX_WIDTH = 2; const PROMPT_ECHO_MARGIN_LEFT = 1; @@ -125,6 +126,9 @@ export function MessageView({ message, collapsed, width = 80 }: MessageViewProps ); } + if (message.meta?.agentName) { + return ; + } return null; } @@ -167,7 +171,7 @@ function StatusLine({ params, width, }: { - bulletColor: "gray" | "green" | "red"; + bulletColor: "gray" | "green" | "red" | "magenta" | "yellow"; name: string; params: string; width: number; @@ -218,6 +222,43 @@ function DiffPreview({ lines }: { lines: DiffPreviewLine[] }): React.ReactElemen ); } +/** + * Renders a persisted sub-agent progress message (tagged with meta.agentName) + * distinctly from the main agent's own tool/thinking lines, so it's clear in + * the transcript which lines came from a delegated sub-agent. + */ +function SubAgentActivityLine({ message, width }: { message: SessionMessage; width: number }): React.ReactElement { + const agentName = message.meta?.agentName ?? "agent"; + const status = message.meta?.subAgentStatus; + + if (status) { + const bulletColor = status === "error" ? "red" : status === "model_fallback" ? "yellow" : "magenta"; + return ( + + + + ); + } + + // Sub-agent tool_result message: same visual shape as the main agent's + // own tool status line, but prefixed with the sub-agent's name. + const summary = buildToolSummary(message); + const diffLines = getToolDiffPreviewLines(summary); + const planLines = getUpdatePlanPreviewLines(summary); + return ( + + + {diffLines.length > 0 ? : null} + {planLines.length > 0 ? : null} + + ); +} + function PlanPreview({ lines }: { lines: string[] }): React.ReactElement { return ( diff --git a/packages/cli/src/ui/components/MessageView/utils.ts b/packages/cli/src/ui/components/MessageView/utils.ts index 4b6158d..a4135ad 100644 --- a/packages/cli/src/ui/components/MessageView/utils.ts +++ b/packages/cli/src/ui/components/MessageView/utils.ts @@ -271,12 +271,30 @@ export function renderMessageToStdout(message: SessionMessage, mode: RawMode): s if (message.meta?.isSummary) { return chalk.dim.italic("(conversation summary inserted)"); } + if (message.meta?.agentName) { + return renderSubAgentMessageToStdout(message); + } return ""; } return ""; } +/** Renders a persisted sub-agent progress message for Raw-mode scrollback output. */ +function renderSubAgentMessageToStdout(message: SessionMessage): string { + const agentName = message.meta?.agentName ?? "agent"; + if (message.meta?.subAgentStatus) { + return chalk(`✧ [${agentName}] ${message.content ?? ""}`); + } + + const summary = buildToolSummary(message); + const params = formatToolStatusParams(summary); + const statusLine = chalk(`✧ [${agentName}] ${formatStatusName(summary.name)}${params ? ` ${params}` : ""}`); + const metaResultMd = typeof message.meta?.resultMd === "string" ? message.meta.resultMd.trim() : ""; + const result = metaResultMd ? `\n${chalk.dim(" └ Result")}\n${metaResultMd}` : ""; + return `${statusLine}${result}`; +} + export function getUpdatePlanPreviewLines(summary: ToolSummary): string[] { if (!summary.ok || summary.name !== "UpdatePlan") { return []; diff --git a/packages/cli/src/ui/core/slash-commands.ts b/packages/cli/src/ui/core/slash-commands.ts index ba5ae6e..9922d90 100644 --- a/packages/cli/src/ui/core/slash-commands.ts +++ b/packages/cli/src/ui/core/slash-commands.ts @@ -1,6 +1,8 @@ -import type { SkillInfo } from "@vegamo/deepcode-core"; +import type { AgentManifest, SkillInfo } from "@vegamo/deepcode-core"; export type SlashCommandKind = + | "agent" + | "agents" | "skill" | "skills" | "model" @@ -23,6 +25,12 @@ export type SlashCommandItem = { }; export const BUILTIN_SLASH_COMMANDS: SlashCommandItem[] = [ + { + kind: "agents", + name: "agents", + label: "/agents", + description: "List available sub-agents", + }, { kind: "skills", name: "skills", @@ -86,7 +94,14 @@ export const BUILTIN_SLASH_COMMANDS: SlashCommandItem[] = [ }, ]; -export function buildSlashCommands(skills: SkillInfo[]): SlashCommandItem[] { +export function buildSlashCommands(skills: SkillInfo[], agents: AgentManifest[] = []): SlashCommandItem[] { + const agentItems: SlashCommandItem[] = agents.map((agent) => ({ + kind: "agent", + name: agent.name, + label: `/${agent.name}`, + description: agent.description || "(no description)", + })); + const skillItems: SlashCommandItem[] = skills.map((skill) => ({ kind: "skill", name: skill.name, @@ -94,7 +109,8 @@ export function buildSlashCommands(skills: SkillInfo[]): SlashCommandItem[] { description: skill.description || "(no description)", skill, })); - return [...skillItems, ...BUILTIN_SLASH_COMMANDS]; + + return [...agentItems, ...skillItems, ...BUILTIN_SLASH_COMMANDS]; } export function filterSlashCommands(items: SlashCommandItem[], token: string): SlashCommandItem[] { diff --git a/packages/cli/src/ui/views/AgentList.tsx b/packages/cli/src/ui/views/AgentList.tsx new file mode 100644 index 0000000..cff981b --- /dev/null +++ b/packages/cli/src/ui/views/AgentList.tsx @@ -0,0 +1,185 @@ +import React, { useState, useMemo } from "react"; +import { Box, Text, useInput, useWindowSize } from "ink"; +import type { AgentManifest } from "@vegamo/deepcode-core"; + +type Props = { + agents: AgentManifest[]; + onCancel: () => void; +}; + +export function AgentList({ agents, onCancel }: Props): React.ReactElement { + const { columns, rows } = useWindowSize(); + + useInput((input, key) => { + if (key.escape || (key.ctrl && (input === "c" || input === "C"))) { + onCancel(); + } + }); + + if (agents.length === 0) { + return ( + + + + Available Sub-Agents + + 0 agents + + + No sub-agents discovered. + To add agents, create an AGENT.md file in: + .deepcode/agents/{""}/AGENT.md + .agents/{""}/AGENT.md + + Esc to close + + ); + } + + return ; +} + +function AgentListView({ + agents, + onCancel, + rows, + columns, +}: { + agents: AgentManifest[]; + onCancel: () => void; + rows: number; + columns: number; +}): React.ReactElement { + const [selectedIndex, setSelectedIndex] = useState(0); + const [scrollOffset, setScrollOffset] = useState(0); + const agentCount = agents.length; + + const maxVisible = useMemo(() => { + const reservedLines = 8; + const availableLines = Math.max(0, Math.min(rows, 30) - reservedLines); + // Each agent entry takes up to 4 lines (name + description + model/source + gap) + return Math.max(1, Math.floor(availableLines / 4)); + }, [rows]); + + const safeIndex = useMemo(() => { + if (agentCount === 0) return 0; + return Math.max(0, Math.min(selectedIndex, agentCount - 1)); + }, [selectedIndex, agentCount]); + + React.useEffect(() => { + if (safeIndex < scrollOffset) { + setScrollOffset(safeIndex); + } else if (safeIndex >= scrollOffset + maxVisible) { + setScrollOffset(safeIndex - maxVisible + 1); + } + }, [safeIndex, scrollOffset, maxVisible]); + + const visibleAgents = useMemo(() => { + return agents.slice(scrollOffset, scrollOffset + maxVisible); + }, [agents, scrollOffset, maxVisible]); + + useInput((input, key) => { + if (key.escape || (key.ctrl && (input === "c" || input === "C"))) { + onCancel(); + return; + } + if (agentCount === 0) return; + if (key.upArrow) { + setSelectedIndex(Math.max(0, selectedIndex - 1)); + return; + } + if (key.downArrow) { + setSelectedIndex(Math.min(agentCount - 1, selectedIndex + 1)); + return; + } + if (key.pageUp) { + setSelectedIndex(Math.max(0, selectedIndex - maxVisible)); + return; + } + if (key.pageDown) { + setSelectedIndex(Math.min(agentCount - 1, selectedIndex + maxVisible)); + return; + } + }); + + return ( + + + {/* Header */} + + + Available Sub-Agents + + + ({agentCount} agent{agentCount !== 1 ? "s" : ""}) + + + {/* Agent list */} + + {visibleAgents.map((agent, i) => { + const actualIndex = scrollOffset + i; + const isSelected = actualIndex === safeIndex; + return ; + })} + {scrollOffset > 0 || scrollOffset + maxVisible < agentCount ? ( + + {scrollOffset > 0 ? ... {scrollOffset} agents above. : null} + {scrollOffset + maxVisible < agentCount ? ( + ... {agentCount - scrollOffset - maxVisible} agents below. + ) : null} + + ) : null} + + {/* Footer */} + + {agentCount > maxVisible ? "↑/↓ navigate · " : ""}Esc to close + + + + ); +} + +function AgentRow({ agent, selected }: { agent: AgentManifest; selected: boolean }): React.ReactElement { + return ( + + + + {selected ? "> " : " "} + {agent.name} + + + {agent.description ? ( + + + {agent.description} + + + ) : null} + + + model: {agent.model} + + source: {agent.sourceRoot} + {agent.skills.length > 0 ? skills: {agent.skills.join(", ")} : null} + + + ); +} diff --git a/packages/cli/src/ui/views/App.tsx b/packages/cli/src/ui/views/App.tsx index ab21052..c6fb856 100644 --- a/packages/cli/src/ui/views/App.tsx +++ b/packages/cli/src/ui/views/App.tsx @@ -13,6 +13,7 @@ import { findExpandedThinkingId } from "../core/thinking-state"; import { WelcomeScreen } from "./WelcomeScreen"; import { AskUserQuestionPrompt } from "./AskUserQuestionPrompt"; import { McpStatusList } from "./McpStatusList"; +import { AgentList } from "./AgentList"; import { ProcessStdoutView } from "./ProcessStdoutView"; import { type AskUserQuestionAnswers, @@ -37,12 +38,14 @@ import type { SessionInfo } from "../statusline"; import { isCollapsedThinking } from "../core/thinking-state"; import { ANSI_CLEAR_SCREEN } from "../constants"; import type { + AgentManifest, LlmStreamProgress, MessageMeta, SessionEntry, SessionMessage, SessionStatus, SkillInfo, + SubAgentProgressEvent, UndoTarget, UserPromptContent, } from "@vegamo/deepcode-core"; @@ -50,7 +53,38 @@ import { SessionManager } from "@vegamo/deepcode-core"; import { getCompactPromptTokenThreshold } from "@vegamo/deepcode-core"; import { writeStdout, writeStdoutLine } from "../../utils/stdio-helpers"; -type View = "chat" | "session-list" | "undo" | "mcp-status"; +type View = "chat" | "session-list" | "undo" | "mcp-status" | "agent-list"; + +/** + * Formats a sub-agent progress event into a human-readable status line. + * Shared between the manual `/agent` command path and sub-agents dispatched + * autonomously by the main model via the DelegateToAgent tool, so both show + * consistent real-time feedback instead of a bare spinner. + */ +function formatSubAgentProgress(event: SubAgentProgressEvent): string { + switch (event.type) { + case "start": + return `[${event.agentName}] 启动中... (model: ${event.model})`; + case "thinking": + return `[${event.agentName}] 思考中...`; + case "tool_call": + return `[${event.agentName}] ⚡ ${event.toolName}: ${truncateForStatusLine(event.toolInput)}`; + case "tool_result": + return `[${event.agentName}] ${event.ok ? "✓" : "✗"} ${event.toolName} 完成`; + case "model_fallback": + return `[${event.agentName}] 模型 ${event.fromModel} 不可用,切换到 ${event.toModel}`; + case "complete": + return `[${event.agentName}] 执行完成`; + case "error": + return `[${event.agentName}] 错误: ${event.message}`; + default: + return ""; + } +} + +function truncateForStatusLine(value: string, maxLength = 100): string { + return value.length > maxLength ? `${value.slice(0, maxLength)}...` : value; +} const STATUS_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; @@ -110,6 +144,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp const [view, setView] = useState("chat"); const [busy, setBusy] = useState(false); const [skills, setSkills] = useState([]); + const [agents, setAgents] = useState([]); const [messages, setMessages] = useState([]); const [sessions, setSessions] = useState([]); const [undoTargets, setUndoTargets] = useState([]); @@ -132,6 +167,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp const [resolvedSettings, setResolvedSettings] = useState(() => resolveCurrentSettings(projectRoot)); const [nowTick, setNowTick] = useState(0); const [mcpStatuses, setMcpStatuses] = useState>([]); + const [agentList, setAgentList] = useState([]); const [showProcessStdout, setShowProcessStdout] = useState(false); rawModeRef.current = mode; @@ -180,6 +216,9 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp const available = MAX_STDOUT_BUFFER - current.length; buf.set(pid, current + text.slice(0, available)); }, + onSubAgentProgress: (event) => { + setStatusLine(formatSubAgentProgress(event)); + }, }); }, [projectRoot]); @@ -241,6 +280,11 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp [sessionManager] ); + const refreshAgents = useCallback((): void => { + const registry = sessionManager.getAgentRegistry(); + setAgents(registry.listAgents()); + }, [sessionManager]); + /** * Reset the app to the welcome screen. */ @@ -264,7 +308,8 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp useEffect(() => { refreshSessionsList(); void refreshSkills(); - }, [refreshSessionsList, refreshSkills]); + refreshAgents(); + }, [refreshSessionsList, refreshSkills, refreshAgents]); // Eagerly create the OpenAI client on mount so the TCP+TLS connection // warmup (fire-and-forget inside createOpenAIClient) starts before the @@ -361,6 +406,74 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp navigateToSubView("mcp-status"); return; } + if (submission.command === "agents") { + const registry = sessionManager.getAgentRegistry(); + setAgentList(registry.listAgents()); + navigateToSubView("agent-list"); + return; + } + if (submission.command === "agent") { + const agentName = submission.agentName; + if (!agentName) { + setErrorLine("No agent name specified."); + return; + } + const registry = sessionManager.getAgentRegistry(); + const manifest = registry.getAgent(agentName); + if (!manifest) { + const available = registry + .listAgents() + .map((a) => a.name) + .join(", "); + setErrorLine( + `Agent "${agentName}" not found.${available ? ` Available agents: ${available}` : " No agents discovered."}` + ); + return; + } + const taskText = submission.text || ""; + if (!taskText.trim()) { + setErrorLine(`No task provided for agent "${agentName}". Usage: /${agentName} `); + return; + } + + // Display user message and set busy state + setMessages((prev) => [...prev, buildSyntheticUserMessage(`/${agentName} ${taskText}`, 0)]); + setBusy(true); + setErrorLine(null); + + try { + const result = await sessionManager.invokeAgent(agentName, taskText, undefined, (event) => { + setStatusLine(formatSubAgentProgress(event)); + }); + if (result.ok) { + const now = new Date().toISOString(); + setMessages((prev) => [ + ...prev, + { + id: `local-${Math.random().toString(36).slice(2)}`, + sessionId: "local", + role: "assistant", + content: result.response, + contentParams: null, + messageParams: null, + compacted: false, + visible: true, + createTime: now, + updateTime: now, + meta: { agentName }, + }, + ]); + } else { + setErrorLine(result.error ?? `Agent "${agentName}" execution failed.`); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setErrorLine(`Agent "${agentName}" error: ${message}`); + } finally { + setBusy(false); + } + return; + } const prompt: UserPromptContent = { text: submission.text, @@ -965,6 +1078,8 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp void sessionManager.reconnectMcpServer(name, latest.mcpServers?.[name]); }} /> + ) : view === "agent-list" ? ( + setView("chat")} /> ) : shouldShowQuestionPrompt && pendingQuestion && !busy ? ( buildSlashCommands(skills), [skills]); + const slashItems = React.useMemo(() => buildSlashCommands(skills, agents), [skills, agents]); const slashToken = getCurrentSlashToken(buffer); const slashMenu = React.useMemo( () => @@ -713,6 +716,31 @@ export const PromptInput = React.memo(function PromptInput({ resetPromptInput(); return; } + if (item.kind === "agent") { + const fullText = buffer.text.trim(); + const commandPrefix = `/${item.name}`; + const hasFullCommand = fullText.startsWith(commandPrefix); + const taskText = hasFullCommand ? fullText.slice(commandPrefix.length).trim() : ""; + + if (!taskText) { + // Selected from the dropdown before typing a task, or pressed Enter with + // no task yet — complete the buffer to "/agent-name " so the user can + // type the task text and press Enter again to actually invoke the agent. + const completed = `${commandPrefix} `; + updateBuffer(() => ({ text: completed, cursor: completed.length })); + clearUndoRedoStacks(); + return; + } + + onSubmit({ text: taskText, imageUrls: [], command: "agent", agentName: item.name }); + resetPromptInput(); + return; + } + if (item.kind === "agents") { + onSubmit({ text: "/agents", imageUrls: [], command: "agents" }); + resetPromptInput(); + return; + } if (item.kind === "exit") { onSubmit({ text: "/exit", imageUrls: [], command: "exit" }); setBuffer(EMPTY_BUFFER); diff --git a/packages/core/src/agents/agent-registry.ts b/packages/core/src/agents/agent-registry.ts new file mode 100644 index 0000000..3076755 --- /dev/null +++ b/packages/core/src/agents/agent-registry.ts @@ -0,0 +1,206 @@ +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; +import matter from "gray-matter"; + +export type AgentManifest = { + name: string; + description: string; + model: string; + skills: string[]; + instructions: string; + sourcePath: string; + sourceRoot: string; +}; + +export type AgentScanRoot = { + root: string; + displayRoot: string; +}; + +export class AgentRegistry { + private agents = new Map(); + private readonly projectRoot: string; + + constructor(projectRoot: string) { + this.projectRoot = projectRoot; + } + + /** + * Scan all agent roots in priority order. + * First-found wins for deduplication by agent name. + */ + scan(): void { + this.agents.clear(); + const roots = this.getScanRoots(); + + for (const { root, displayRoot } of roots) { + if (!fs.existsSync(root)) { + continue; + } + + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(root, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of entries) { + if (!entry.isDirectory() && !entry.isSymbolicLink()) { + continue; + } + + const agentDir = path.join(root, entry.name); + const manifestPath = path.join(agentDir, "AGENT.md"); + + if (!fs.existsSync(manifestPath)) { + continue; + } + + const manifest = this.parseManifest(manifestPath, entry.name, displayRoot); + if (manifest && !this.agents.has(manifest.name)) { + this.agents.set(manifest.name, manifest); + } + } + } + } + + listAgents(): AgentManifest[] { + return Array.from(this.agents.values()); + } + + getAgent(name: string): AgentManifest | undefined { + return this.agents.get(name); + } + + hasAgents(): boolean { + return this.agents.size > 0; + } + + private getScanRoots(): AgentScanRoot[] { + const homeDir = os.homedir(); + return [ + { root: path.join(this.projectRoot, ".deepcode", "agents"), displayRoot: "./.deepcode/agents" }, + { root: path.join(homeDir, ".deepcode", "agents"), displayRoot: "~/.deepcode/agents" }, + ]; + } + + private parseManifest(manifestPath: string, dirName: string, sourceRoot: string): AgentManifest | null { + try { + const raw = fs.readFileSync(manifestPath, "utf8"); + const parsed = matter(raw); + const data = parsed.data as Record; + // A document is treated as YAML frontmatter if it actually starts with + // a frontmatter delimiter (gray-matter's own detection), even if the + // frontmatter block is empty (e.g. "---\n---\n"). Plain-markdown files + // that don't start with "---" fall back to parsePlainMetadata below. + const hasFrontmatter = matter.test(raw); + + if (hasFrontmatter) { + return { + name: typeof data.name === "string" ? data.name : dirName, + description: typeof data.description === "string" ? data.description : "", + model: typeof data.model === "string" ? data.model : "default", + skills: Array.isArray(data.skills) ? data.skills.filter((s): s is string => typeof s === "string") : [], + instructions: parsed.content.trim(), + sourcePath: manifestPath, + sourceRoot, + }; + } + + const plain = parsePlainMetadata(raw); + return { + name: plain.name ?? dirName, + description: plain.description, + model: plain.model, + skills: plain.skills, + instructions: plain.instructions, + sourcePath: manifestPath, + sourceRoot, + }; + } catch { + // Invalid YAML frontmatter — skip with warning + return null; + } + } +} + +/** + * Fallback parser for AGENT.md files that don't use YAML frontmatter. + * Extracts metadata from plain-markdown conventions: + * - `# ` heading + * - `> ` blockquote or `**Description**: ` bold-label line + * - `**Model**: ` bold-label line + * - `**Skills**: ` bold-label line + * - Instructions are everything after the first bare `---` line following metadata. + */ +function parsePlainMetadata(raw: string): { + name: string | null; + description: string; + model: string; + skills: string[]; + instructions: string; +} { + const lines = raw.split(/\r?\n/); + let description = ""; + let model = "default"; + let skills: string[] = []; + let name: string | null = null; + let foundMetadata = false; + let bodyStartIndex = -1; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]!.trim(); + + if (name === null) { + const headingMatch = line.match(/^#\s+(.+)$/); + if (headingMatch) { + name = headingMatch[1]!.trim(); + continue; + } + } + + const descBoldMatch = line.match(/^-?\s*\*\*Description\*\*\s*:\s*(.+)$/i); + if (descBoldMatch) { + description = descBoldMatch[1]!.trim(); + foundMetadata = true; + continue; + } + + if (!description) { + const blockquoteMatch = line.match(/^>\s*(.+)$/); + if (blockquoteMatch) { + description = blockquoteMatch[1]!.trim(); + foundMetadata = true; + continue; + } + } + + const modelMatch = line.match(/^-?\s*\*\*Model\*\*\s*:\s*(.+)$/i); + if (modelMatch) { + model = modelMatch[1]!.trim(); + foundMetadata = true; + continue; + } + + const skillsMatch = line.match(/^-?\s*\*\*Skills\*\*\s*:\s*(.+)$/i); + if (skillsMatch) { + skills = skillsMatch[1]! + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + foundMetadata = true; + continue; + } + + if (foundMetadata && line === "---") { + bodyStartIndex = i + 1; + break; + } + } + + const instructions = bodyStartIndex >= 0 ? lines.slice(bodyStartIndex).join("\n").trim() : raw.trim(); + + return { name, description, model, skills, instructions }; +} diff --git a/packages/core/src/agents/delegate-handler.ts b/packages/core/src/agents/delegate-handler.ts new file mode 100644 index 0000000..cfc2cee --- /dev/null +++ b/packages/core/src/agents/delegate-handler.ts @@ -0,0 +1,77 @@ +import type { AgentRegistry } from "./agent-registry"; +import { SubAgentSession } from "./sub-agent-session"; +import type { SubAgentProgressCallback } from "./sub-agent-session"; +import { resolveAgentSkills } from "./skill-resolver"; +import type { CreateOpenAIClient } from "../common/tool-types"; +import type { ToolExecutor } from "../tools/executor"; + +export type DelegateToAgentArgs = { + agent_name: string; + task: string; +}; + +export type DelegateToAgentResult = { + ok: boolean; + output?: string; + error?: string; +}; + +export type DelegateHandlerOptions = { + agentRegistry: AgentRegistry; + projectRoot: string; + parentModel: string; + createOpenAIClient: CreateOpenAIClient; + toolExecutor: ToolExecutor; + globalSkillRoots: string[]; + onProgress?: SubAgentProgressCallback; +}; + +export function createDelegateToAgentHandler(options: DelegateHandlerOptions) { + return async function handleDelegateToAgent(args: Record): Promise { + const agentName = typeof args.agent_name === "string" ? args.agent_name : ""; + const task = typeof args.task === "string" ? args.task : ""; + + if (!agentName) { + return { ok: false, error: "Missing required parameter: agent_name" }; + } + + const manifest = options.agentRegistry.getAgent(agentName); + if (!manifest) { + const available = options.agentRegistry + .listAgents() + .map((a) => a.name) + .join(", "); + return { ok: false, error: `Agent "${agentName}" not found. Available agents: ${available}` }; + } + + if (!task) { + return { ok: false, error: "Missing required parameter: task" }; + } + + // Resolve skills for the agent + const resolvedSkills = resolveAgentSkills({ + manifest, + globalSkillRoots: options.globalSkillRoots, + }); + const resolvedSkillPrompts = resolvedSkills.map((s) => s.content); + + const session = new SubAgentSession({ + manifest, + task, + projectRoot: options.projectRoot, + parentModel: options.parentModel, + createOpenAIClient: options.createOpenAIClient, + resolvedSkillPrompts, + toolExecutor: options.toolExecutor, + onProgress: options.onProgress, + }); + + const result = await session.execute(); + + if (!result.ok) { + return { ok: false, error: result.error ?? "Sub-agent execution failed" }; + } + + return { ok: true, output: result.response }; + }; +} diff --git a/packages/core/src/agents/format-agents.ts b/packages/core/src/agents/format-agents.ts new file mode 100644 index 0000000..a414869 --- /dev/null +++ b/packages/core/src/agents/format-agents.ts @@ -0,0 +1,27 @@ +import type { AgentManifest } from "./agent-registry"; + +/** + * Formats the discovered agent list for display in the CLI. + * Used by the `/agents` command handler to show agent metadata. + */ +export function formatAgentsList(agents: AgentManifest[]): string { + if (agents.length === 0) { + return "No sub-agents discovered.\n\nTo add agents, create an AGENT.md file in:\n- .deepcode/agents//AGENT.md\n- .agents//AGENT.md"; + } + + const lines: string[] = ["## Available Sub-Agents\n"]; + for (const agent of agents) { + lines.push(`### ${agent.name}`); + if (agent.description) { + lines.push(`- **Description**: ${agent.description}`); + } + lines.push(`- **Model**: ${agent.model}`); + lines.push(`- **Source**: ${agent.sourceRoot}`); + if (agent.skills.length > 0) { + lines.push(`- **Skills**: ${agent.skills.join(", ")}`); + } + lines.push(""); + } + + return lines.join("\n"); +} diff --git a/packages/core/src/agents/index.ts b/packages/core/src/agents/index.ts new file mode 100644 index 0000000..6ed366e --- /dev/null +++ b/packages/core/src/agents/index.ts @@ -0,0 +1,25 @@ +// Agent subsystem — discovery, delegation, and isolated execution. + +// AgentRegistry +export type { AgentManifest, AgentScanRoot } from "./agent-registry"; +export { AgentRegistry } from "./agent-registry"; + +// SubAgentSession +export type { + SubAgentOptions, + SubAgentResult, + SubAgentProgressEvent, + SubAgentProgressCallback, +} from "./sub-agent-session"; +export { SubAgentSession } from "./sub-agent-session"; + +// DelegateToAgent tool handler +export type { DelegateToAgentArgs, DelegateToAgentResult, DelegateHandlerOptions } from "./delegate-handler"; +export { createDelegateToAgentHandler } from "./delegate-handler"; + +// Skill resolver +export type { ResolvedSkill, SkillResolverOptions } from "./skill-resolver"; +export { resolveAgentSkills } from "./skill-resolver"; + +// Format helpers +export { formatAgentsList } from "./format-agents"; diff --git a/packages/core/src/agents/skill-resolver.ts b/packages/core/src/agents/skill-resolver.ts new file mode 100644 index 0000000..c9c08d0 --- /dev/null +++ b/packages/core/src/agents/skill-resolver.ts @@ -0,0 +1,107 @@ +import * as fs from "fs"; +import * as path from "path"; +import matter from "gray-matter"; +import type { AgentManifest } from "./agent-registry"; + +export type ResolvedSkill = { + name: string; + content: string; + path: string; +}; + +export type SkillResolverOptions = { + manifest: AgentManifest; + globalSkillRoots: string[]; +}; + +/** + * Resolves skills for a sub-agent. + * Priority: agent-local skills/ dir > global skill roots. + * If manifest.skills lists specific names, only those are loaded. + * If manifest.skills is empty, all local skills are loaded. + */ +export function resolveAgentSkills(options: SkillResolverOptions): ResolvedSkill[] { + const { manifest, globalSkillRoots } = options; + const agentDir = path.dirname(manifest.sourcePath); + const localSkillsDir = path.join(agentDir, "skills"); + + const resolved = new Map(); + + // Determine which skills to load + const targetNames = manifest.skills.length > 0 ? manifest.skills : null; + + // Scan local skills directory first (highest priority) + if (fs.existsSync(localSkillsDir)) { + const localSkills = scanSkillDirectory(localSkillsDir); + for (const skill of localSkills) { + if (targetNames === null || targetNames.includes(skill.name)) { + resolved.set(skill.name, skill); + } + } + } + + // If specific skills are requested and not all found locally, + // search global roots + if (targetNames !== null) { + for (const skillName of targetNames) { + if (resolved.has(skillName)) { + continue; + } + + let found = false; + for (const globalRoot of globalSkillRoots) { + const skillPath = path.join(globalRoot, skillName, "SKILL.md"); + if (fs.existsSync(skillPath)) { + const skill = readSkillFile(skillPath, skillName); + if (skill) { + resolved.set(skillName, skill); + found = true; + break; + } + } + } + + if (!found) { + console.warn(`[skill-resolver] Skill "${skillName}" not found in any scan root`); + } + } + } + + return Array.from(resolved.values()); +} + +function scanSkillDirectory(dir: string): ResolvedSkill[] { + const skills: ResolvedSkill[] = []; + try { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory() && !entry.isSymbolicLink()) { + continue; + } + const skillPath = path.join(dir, entry.name, "SKILL.md"); + if (fs.existsSync(skillPath)) { + const skill = readSkillFile(skillPath, entry.name); + if (skill) { + skills.push(skill); + } + } + } + } catch { + // skip unreadable directories + } + return skills; +} + +function readSkillFile(skillPath: string, name: string): ResolvedSkill | null { + try { + const raw = fs.readFileSync(skillPath, "utf8"); + const parsed = matter(raw); + return { + name: typeof parsed.data?.name === "string" ? parsed.data.name : name, + content: raw, + path: skillPath, + }; + } catch { + return null; + } +} diff --git a/packages/core/src/agents/sub-agent-session.ts b/packages/core/src/agents/sub-agent-session.ts new file mode 100644 index 0000000..e64ff3f --- /dev/null +++ b/packages/core/src/agents/sub-agent-session.ts @@ -0,0 +1,292 @@ +import type { ChatCompletionMessageParam } from "openai/resources/chat/completions"; +import type { AgentManifest } from "./agent-registry"; +import type { CreateOpenAIClient } from "../common/tool-types"; +import type { ToolExecutor } from "../tools/executor"; +import { getTools } from "../prompt"; + +/** + * Sub-agent 执行进度事件 + * + * Note: `toolInput` / `output` carry the full, untruncated content. Callers + * that only need a short label for a status line (e.g. the CLI's spinner + * text) are responsible for truncating themselves. This keeps the full + * fidelity available for callers that need to persist a durable history + * of what the sub-agent actually did (e.g. session transcript storage). + */ +export type SubAgentProgressEvent = + | { type: "start"; agentName: string; model: string } + | { type: "thinking"; agentName: string } + | { type: "tool_call"; agentName: string; toolCallId: string; toolName: string; toolInput: string } + | { + type: "tool_result"; + agentName: string; + toolCallId: string; + toolName: string; + toolInput: string; + output: string; + ok: boolean; + } + | { type: "model_fallback"; agentName: string; fromModel: string; toModel: string } + | { type: "complete"; agentName: string; response: string } + | { type: "error"; agentName: string; message: string }; + +export type SubAgentProgressCallback = (event: SubAgentProgressEvent) => void; + +export type SubAgentOptions = { + manifest: AgentManifest; + task: string; + projectRoot: string; + parentModel: string; + createOpenAIClient: CreateOpenAIClient; + resolvedSkillPrompts: string[]; + toolExecutor: ToolExecutor; + /** 进度回调,用于实时通知 UI 层 sub-agent 执行状态 */ + onProgress?: SubAgentProgressCallback; + /** 单次 API 调用超时(毫秒),默认 120000 (2分钟) */ + apiTimeout?: number; +}; + +export type SubAgentResult = { + ok: boolean; + response: string; + error?: string; +}; + +const MAX_TOOL_ROUNDS = 50; +const DEFAULT_API_TIMEOUT = 120_000; // 2 minutes + +export const SUB_AGENT_NON_INTERACTIVE_NOTE = `## Execution Context + +You are running as a sub-agent in a single, non-interactive turn. You do NOT have access to an AskUserQuestion tool and cannot pause to ask the user clarifying questions mid-task. If required information is missing or ambiguous: +- Make the most reasonable assumption based on the task description and available context, clearly stating the assumption you made in your final response, OR +- If you truly cannot proceed safely without more information, do not attempt any side-effecting actions (e.g. do not deploy, do not modify files) — instead, end your response by clearly listing the specific questions you need answered, so the user can provide that information in a follow-up request. + +Do not attempt to call a tool that asks the user a question — no such tool is available to you in this context.`; + +export class SubAgentSession { + private readonly options: SubAgentOptions; + + constructor(options: SubAgentOptions) { + this.options = options; + } + + /** + * Returns the effective model for this sub-agent. + * Uses manifest model unless it's "default" or empty. + */ + getModel(): string { + const { manifest, parentModel } = this.options; + if (!manifest.model || manifest.model === "default") { + return parentModel; + } + return manifest.model; + } + + /** + * Builds the system prompt from manifest instructions + resolved skills. + */ + buildSystemPrompt(): string { + const parts: string[] = []; + if (this.options.manifest.instructions) { + parts.push(this.options.manifest.instructions); + } + for (const skillPrompt of this.options.resolvedSkillPrompts) { + parts.push(skillPrompt); + } + parts.push(SUB_AGENT_NON_INTERACTIVE_NOTE); + return parts.join("\n\n"); + } + + private emit(event: SubAgentProgressEvent): void { + this.options.onProgress?.(event); + } + + /** + * Execute the sub-agent's task in an isolated LLM loop. + * Creates fresh message history, runs tool loop, returns final response. + */ + async execute(signal?: AbortSignal): Promise { + const agentName = this.options.manifest.name; + try { + const model = this.getModel(); + const systemPrompt = this.buildSystemPrompt(); + + if (!systemPrompt) { + return { + ok: false, + response: "", + error: "Sub-agent has no instructions (empty system prompt)", + }; + } + + this.emit({ type: "start", agentName, model }); + + try { + const response = await this.runIsolatedLoop(model, systemPrompt, signal); + this.emit({ type: "complete", agentName, response }); + return { ok: true, response }; + } catch (error) { + // 如果模型不支持(400 错误),自动回退到父模型重试 + const message = error instanceof Error ? error.message : String(error); + const { parentModel } = this.options; + if (message.includes("400") && model !== parentModel) { + this.emit({ type: "model_fallback", agentName, fromModel: model, toModel: parentModel }); + const response = await this.runIsolatedLoop(parentModel, systemPrompt, signal); + this.emit({ type: "complete", agentName, response }); + return { ok: true, response }; + } + throw error; + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.emit({ type: "error", agentName, message }); + return { ok: false, response: "", error: `Sub-agent execution failed: ${message}` }; + } + } + + private async runIsolatedLoop(model: string, systemPrompt: string, signal?: AbortSignal): Promise { + const { createOpenAIClient, task, toolExecutor } = this.options; + const agentName = this.options.manifest.name; + const apiTimeout = this.options.apiTimeout ?? DEFAULT_API_TIMEOUT; + + const { client } = createOpenAIClient(); + if (!client) { + throw new Error("Failed to create OpenAI client for sub-agent"); + } + + // Fresh message history — completely isolated from parent + const messages: ChatCompletionMessageParam[] = [ + { role: "system", content: systemPrompt }, + { role: "user", content: task }, + ]; + + // Get tool definitions (the same built-in tools), excluding AskUserQuestion: + // sub-agent sessions are single-shot and non-interactive, so there is no + // mechanism to pause and route a question to a real user for an answer. + const tools = getTools().filter((tool) => tool.function.name !== "AskUserQuestion"); + + // Generate a unique session ID for tool execution isolation + const sessionId = `sub-agent-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + for (let round = 0; round < MAX_TOOL_ROUNDS; round++) { + if (signal?.aborted) { + throw new Error("Sub-agent execution was aborted"); + } + + this.emit({ type: "thinking", agentName }); + + // 创建带超时的 AbortSignal + const timeoutController = new AbortController(); + const timeoutId = setTimeout(() => timeoutController.abort(), apiTimeout); + const mergedSignal = signal ? combineSignals(signal, timeoutController.signal) : timeoutController.signal; + + let response: { + choices?: Array<{ + message?: { + role?: string; + content?: string | null; + tool_calls?: Array<{ + id: string; + type: "function"; + function: { name: string; arguments: string }; + }>; + }; + finish_reason?: string; + }>; + }; + + try { + response = await ( + client.chat.completions.create as unknown as ( + body: Record, + options?: Record + ) => Promise + )( + { + model, + messages, + tools: tools.length > 0 ? tools : undefined, + stream: false, + }, + { signal: mergedSignal } + ); + } catch (err: unknown) { + if (err instanceof Error && err.name === "AbortError") { + if (signal?.aborted) { + throw new Error("Sub-agent execution was aborted"); + } + throw new Error(`Sub-agent API call timed out after ${apiTimeout / 1000}s`); + } + throw err; + } finally { + clearTimeout(timeoutId); + } + + const choice = response?.choices?.[0]; + if (!choice?.message) { + throw new Error("No response from LLM"); + } + + const assistantMessage = choice.message; + const toolCalls = assistantMessage.tool_calls; + + // Append the assistant message to conversation history + messages.push(assistantMessage as ChatCompletionMessageParam); + + // If no tool calls, the assistant is done — return its text content + if (!toolCalls || toolCalls.length === 0) { + return assistantMessage.content ?? ""; + } + + // Execute each tool call and append results + for (const toolCall of toolCalls) { + this.emit({ + type: "tool_call", + agentName, + toolCallId: toolCall.id, + toolName: toolCall.function.name, + toolInput: toolCall.function.arguments, + }); + } + + const executions = await toolExecutor.executeToolCalls(sessionId, toolCalls); + + for (const execution of executions) { + const toolCall = toolCalls.find((tc) => tc.id === execution.toolCallId); + const toolName = toolCall?.function.name ?? "unknown"; + this.emit({ + type: "tool_result", + agentName, + toolCallId: execution.toolCallId, + toolName, + toolInput: toolCall?.function.arguments ?? "", + output: execution.content, + ok: execution.result.ok, + }); + + messages.push({ + role: "tool", + tool_call_id: execution.toolCallId, + content: execution.content, + } as ChatCompletionMessageParam); + } + } + + throw new Error(`Sub-agent exceeded maximum tool call rounds (${MAX_TOOL_ROUNDS})`); + } +} + +/** + * 合并多个 AbortSignal + */ +function combineSignals(...signals: AbortSignal[]): AbortSignal { + const controller = new AbortController(); + for (const signal of signals) { + if (signal.aborted) { + controller.abort(signal.reason); + return controller.signal; + } + signal.addEventListener("abort", () => controller.abort(signal.reason), { once: true }); + } + return controller.signal; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 832d244..c9b166a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -133,3 +133,25 @@ export type { // State types export type { FileState, FileSnippet, FileLineEnding } from "./common/state"; export type { FileReadMetadata } from "./common/file-utils"; + +// Agents +export { + AgentRegistry, + SubAgentSession, + resolveAgentSkills, + createDelegateToAgentHandler, + formatAgentsList, +} from "./agents"; +export type { + AgentManifest, + AgentScanRoot, + SubAgentOptions, + SubAgentResult, + SubAgentProgressEvent, + SubAgentProgressCallback, + ResolvedSkill, + SkillResolverOptions, + DelegateToAgentArgs, + DelegateToAgentResult, + DelegateHandlerOptions, +} from "./agents"; diff --git a/packages/core/src/prompt.ts b/packages/core/src/prompt.ts index dce3494..6e7f241 100644 --- a/packages/core/src/prompt.ts +++ b/packages/core/src/prompt.ts @@ -8,6 +8,7 @@ import { fileURLToPath } from "url"; import type { SessionMessage } from "./session"; import { findGitBashPath, resolveShellPath } from "./common/shell-utils"; import { supportsMultimodal } from "./common/model-capabilities"; +import type { AgentRegistry } from "./agents/agent-registry"; const COMPACT_PROMPT_BASE = `Your task is to create a detailed summary of the conversation so far, paying close attention to the user's explicit requests and your previous actions. This summary should be thorough in capturing technical details, code patterns, and architectural decisions that would be essential for continuing development work without losing context. @@ -448,7 +449,11 @@ export type ToolDefinition = { }; }; -export function getTools(_options: PromptToolOptions = {}, externalTools: ToolDefinition[] = []): ToolDefinition[] { +export function getTools( + _options: PromptToolOptions = {}, + externalTools: ToolDefinition[] = [], + agentRegistry?: AgentRegistry +): ToolDefinition[] { const tools: ToolDefinition[] = [ { type: "function", @@ -688,6 +693,36 @@ export function getTools(_options: PromptToolOptions = {}, externalTools: ToolDe }, }); + // Conditionally add DelegateToAgent when agents are discovered + if (agentRegistry?.hasAgents()) { + const agents = agentRegistry.listAgents(); + const agentList = agents.map((a) => `- ${a.name}: ${a.description || "(no description)"}`).join("\n"); + + tools.push({ + type: "function", + function: { + name: "DelegateToAgent", + description: `Delegate a task to a specialized sub-agent that runs in an isolated session. The sub-agent has access to the same tools and project context. Available agents:\n${agentList}`, + parameters: { + type: "object", + properties: { + agent_name: { + type: "string", + description: "The name of the sub-agent to invoke.", + }, + task: { + type: "string", + description: + "The task description to send to the sub-agent as its user prompt. Be specific and include relevant context.", + }, + }, + required: ["agent_name", "task"], + additionalProperties: false, + }, + }, + }); + } + for (const tool of externalTools) { tools.push(tool); } diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 4483c67..dc842a9 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -19,6 +19,10 @@ import { getTools, type ToolDefinition, } from "./prompt"; +import { AgentRegistry } from "./agents/agent-registry"; +import { SubAgentSession } from "./agents/sub-agent-session"; +import type { SubAgentResult, SubAgentProgressCallback, SubAgentProgressEvent } from "./agents/sub-agent-session"; +import { resolveAgentSkills } from "./agents/skill-resolver"; import { ToolExecutor, type CreateOpenAIClient, @@ -252,6 +256,19 @@ export type MessageMeta = { skill?: SkillInfo; permissions?: MessageToolPermission[]; userPrompt?: UserPromptContent; + /** + * Name of the sub-agent that produced this message. Present on messages + * generated while a sub-agent (invoked via DelegateToAgent) is executing, + * so the UI can visually distinguish sub-agent activity from the main + * agent's own messages. + */ + agentName?: string; + /** + * Lifecycle marker for a sub-agent-authored system message. Only set on + * role "system" messages tagged with `agentName`; used by the UI to pick + * an icon/style without re-parsing message content. + */ + subAgentStatus?: "start" | "complete" | "error" | "model_fallback"; }; export type SessionMessage = { @@ -308,6 +325,13 @@ type SessionManagerOptions = { onLlmStreamProgress?: (progress: LlmStreamProgress) => void; onMcpStatusChanged?: () => void; onProcessStdout?: (pid: number, chunk: string) => void; + /** + * Fired whenever a sub-agent invoked via the DelegateToAgent tool (i.e. + * dispatched autonomously by the main model, not via a manual /agent + * command) reports progress. Lets the CLI show real-time feedback + * instead of a plain spinner while the sub-agent is running. + */ + onSubAgentProgress?: (event: SubAgentProgressEvent) => void; }; export type LlmStreamProgress = { @@ -334,6 +358,7 @@ export class SessionManager { private readonly onLlmStreamProgress?: (progress: LlmStreamProgress) => void; private readonly onMcpStatusChanged?: () => void; private readonly onProcessStdout?: (pid: number, chunk: string) => void; + private readonly onSubAgentProgress?: (event: SubAgentProgressEvent) => void; private activeSessionId: string | null = null; private activePromptController: AbortController | null = null; private readonly sessionControllers = new Map(); @@ -343,6 +368,7 @@ export class SessionManager { private readonly mcpManager = new McpManager(); private mcpToolDefinitions: ToolDefinition[] = []; private readonly messageConverter: OpenAIMessageConverter; + private readonly agentRegistry: AgentRegistry; constructor(options: SessionManagerOptions) { this.projectRoot = options.projectRoot; @@ -353,11 +379,26 @@ export class SessionManager { this.onLlmStreamProgress = options.onLlmStreamProgress; this.onMcpStatusChanged = options.onMcpStatusChanged; this.onProcessStdout = options.onProcessStdout; + this.onSubAgentProgress = options.onSubAgentProgress; this.toolExecutor = new ToolExecutor(this.projectRoot, this.createOpenAIClient, this.mcpManager); this.mcpManager.prepare(this.getResolvedSettings().mcpServers); this.messageConverter = new OpenAIMessageConverter({ renderInitPrompt: () => this.renderInitCommandPrompt(), }); + this.agentRegistry = new AgentRegistry(this.projectRoot); + this.agentRegistry.scan(); + if (this.agentRegistry.hasAgents()) { + const model = this.getResolvedSettings().model; + this.toolExecutor.registerAgentHandler( + this.agentRegistry, + model, + this.getSkillScanRoots().map((r) => r.root), + (sessionId, event) => { + this.persistSubAgentProgressEvent(sessionId, event); + this.onSubAgentProgress?.(event); + } + ); + } } /** @@ -388,6 +429,74 @@ export class SessionManager { return this.mcpManager.getStatus(); } + getAgentRegistry(): AgentRegistry { + return this.agentRegistry; + } + + /** + * Invoke a sub-agent by name with the given task text. + * Returns the SubAgentResult containing the response or error. + * + * When `sessionId` refers to an existing session, the sub-agent's + * execution trace is persisted to that session's history (same as when + * a sub-agent is dispatched autonomously via DelegateToAgent), so it + * survives /resume and is distinguishable from the main agent's messages. + */ + async invokeAgent( + agentName: string, + task: string, + signal?: AbortSignal, + onProgress?: SubAgentProgressCallback, + sessionId?: string + ): Promise { + const manifest = this.agentRegistry.getAgent(agentName); + if (!manifest) { + const available = this.agentRegistry + .listAgents() + .map((a) => a.name) + .join(", "); + return { + ok: false, + response: "", + error: `Agent "${agentName}" not found. Available agents: ${available || "(none)"}`, + }; + } + + if (!task.trim()) { + return { + ok: false, + response: "", + error: `No task provided for agent "${agentName}". Usage: /${agentName} `, + }; + } + + const skillRoots = this.getSkillScanRoots().map((r) => r.root); + const resolvedSkills = resolveAgentSkills({ + manifest, + globalSkillRoots: skillRoots, + }); + const resolvedSkillPrompts = resolvedSkills.map((s) => s.content); + + const parentModel = this.getResolvedSettings().model; + const session = new SubAgentSession({ + manifest, + task, + projectRoot: this.projectRoot, + parentModel, + createOpenAIClient: this.createOpenAIClient, + resolvedSkillPrompts, + toolExecutor: this.toolExecutor, + onProgress: (event) => { + if (sessionId) { + this.persistSubAgentProgressEvent(sessionId, event); + } + onProgress?.(event); + }, + }); + + return session.execute(signal); + } + async reconnectMcpServer(name: string, config?: McpServerConfig): Promise { await this.mcpManager.reconnect(name, config); this.mcpToolDefinitions = this.mcpManager.getMcpToolDefinitions(); @@ -1374,7 +1483,7 @@ ${agentInstructions} model, ...(temperature !== undefined ? { temperature } : {}), messages, - tools: getTools(this.getPromptToolOptions(), this.mcpToolDefinitions), + tools: getTools(this.getPromptToolOptions(), this.mcpToolDefinitions, this.agentRegistry), ...thinkingOptions, }, { signal: sessionController.signal }, @@ -2246,6 +2355,105 @@ ${agentInstructions} }); } + /** + * Persists a sub-agent progress event as a durable SessionMessage so the + * sub-agent's own execution trace (its tool calls, thinking, errors) is + * kept in session history — surviving /resume — instead of only ever + * appearing as a transient status-line spinner. + * + * Persisted messages are: + * - role "system" (never "tool"): this keeps them out of + * rebuildSessionStateFromHistory's tool-result scan (which only + * inspects role "tool") so a sub-agent's file reads/edits never + * leak into the parent session's file-state/snippet cache, and out of + * OpenAIMessageConverter's tool-call pairing logic. + * - compacted: true: excluded from the messages sent back to the main + * model (the sub-agent's own tool-call loop is not part of the + * parent's conversation with the LLM — only its final text response, + * which is already delivered as the DelegateToAgent tool result, is). + * - tagged with meta.agentName so the CLI can render them distinctly + * from the main agent's own messages. + */ + private persistSubAgentProgressEvent(sessionId: string, event: SubAgentProgressEvent): void { + // "thinking" carries no unique content (just an "still working" pulse) + // and fires on every round-trip; persisting it would only add noise. + if (event.type === "thinking" || event.type === "tool_call") { + return; + } + if (!this.getSession(sessionId)) { + return; + } + + const message = this.buildSubAgentMessage(sessionId, event); + if (!message) { + return; + } + this.appendSessionMessage(sessionId, message); + this.onAssistantMessage(message, true); + } + + private buildSubAgentMessage(sessionId: string, event: SubAgentProgressEvent): SessionMessage | null { + const now = new Date().toISOString(); + const base = { + id: crypto.randomUUID(), + sessionId, + contentParams: null, + messageParams: null, + compacted: true, + createTime: now, + updateTime: now, + } as const; + + switch (event.type) { + case "start": + return { + ...base, + role: "system", + content: `Delegating to sub-agent "${event.agentName}" (model: ${event.model})`, + visible: true, + meta: { agentName: event.agentName, subAgentStatus: "start" }, + }; + case "tool_result": { + const toolFunction = { name: event.toolName, arguments: event.toolInput }; + return { + ...base, + role: "system", + content: event.output, + visible: !this.isInvisibleExecution(event.output), + meta: { + agentName: event.agentName, + function: toolFunction, + paramsMd: this.buildToolParamsSnippet(toolFunction), + resultMd: this.buildToolResultSnippet(event.output), + }, + }; + } + case "model_fallback": + return { + ...base, + role: "system", + content: `模型 "${event.fromModel}" 不可用,回退到 "${event.toModel}"`, + visible: true, + meta: { agentName: event.agentName, subAgentStatus: "model_fallback" }, + }; + case "error": + return { + ...base, + role: "system", + content: event.message, + visible: true, + meta: { agentName: event.agentName, subAgentStatus: "error" }, + }; + case "complete": + // The sub-agent's final text is already delivered to the parent + // conversation as the DelegateToAgent tool result — no need to + // duplicate it here as a separate system message. + return null; + default: + return null; + } + } + private buildToolMessage( sessionId: string, toolCallId: string, diff --git a/packages/core/src/tests/agent-registry.test.ts b/packages/core/src/tests/agent-registry.test.ts new file mode 100644 index 0000000..7309029 --- /dev/null +++ b/packages/core/src/tests/agent-registry.test.ts @@ -0,0 +1,654 @@ +import { afterEach, describe, test } from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { AgentRegistry } from "../agents/agent-registry"; + +const tempDirs: string[] = []; +const originalHome = process.env.HOME; +const originalUserProfile = process.env.USERPROFILE; + +/** Set homedir in a cross-platform way (HOME on Unix, USERPROFILE on Windows). */ +function setHomeDir(dir: string): void { + process.env.HOME = dir; + if (process.platform === "win32") { + process.env.USERPROFILE = dir; + } +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + if (originalUserProfile === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = originalUserProfile; + } +}); + +function createTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +/** + * Helper to create an AGENT.md file in a given scan root under a named subdirectory. + */ +function createAgentMd(scanRoot: string, dirName: string, content: string): string { + const agentDir = path.join(scanRoot, dirName); + fs.mkdirSync(agentDir, { recursive: true }); + const manifestPath = path.join(agentDir, "AGENT.md"); + fs.writeFileSync(manifestPath, content, "utf8"); + return manifestPath; +} + +/** + * Generates a valid AGENT.md content string with all fields populated. + */ +function makeAgentMd(opts: { + name?: string; + description?: string; + model?: string; + skills?: string[]; + body?: string; +}): string { + const frontmatterParts: string[] = []; + if (opts.name !== undefined) frontmatterParts.push(`name: ${opts.name}`); + if (opts.description !== undefined) frontmatterParts.push(`description: ${opts.description}`); + if (opts.model !== undefined) frontmatterParts.push(`model: ${opts.model}`); + if (opts.skills !== undefined) { + frontmatterParts.push(`skills:`); + for (const skill of opts.skills) { + frontmatterParts.push(` - ${skill}`); + } + } + + const frontmatter = frontmatterParts.length > 0 ? `---\n${frontmatterParts.join("\n")}\n---\n` : `---\n---\n`; + + return `${frontmatter}\n${opts.body ?? "# Default Instructions\n\nYou are a helpful agent."}`; +} + +// ============================================================================= +// Property 1: Agent discovery respects priority ordering and deduplication +// ============================================================================= +// **Validates: Requirements 1.1, 1.2, 1.3** +// +// For any set of agent directories across scan roots where multiple roots +// contain agents with the same name, listAgents() returns exactly one entry +// per unique agent name from the highest-priority scan root. + +describe("Property 1: Agent discovery respects priority ordering and deduplication", () => { + test("higher-priority scan root wins when same agent name exists in multiple roots", () => { + const projectRoot = createTempDir("deepcode-agent-priority-"); + const home = createTempDir("deepcode-agent-priority-home-"); + setHomeDir(home); + + // Priority order: project .deepcode/agents > home ~/.deepcode/agents + const highPriority = path.join(projectRoot, ".deepcode", "agents"); + const lowPriority = path.join(home, ".deepcode", "agents"); + + // Create agent "test-agent" in both roots with different descriptions + createAgentMd( + highPriority, + "test-agent", + makeAgentMd({ + name: "test-agent", + description: "From high priority root", + body: "# High Priority", + }) + ); + createAgentMd( + lowPriority, + "test-agent", + makeAgentMd({ + name: "test-agent", + description: "From low priority root", + body: "# Low Priority", + }) + ); + + const registry = new AgentRegistry(projectRoot); + registry.scan(); + const agents = registry.listAgents(); + + // Exactly one entry per unique agent name + const testAgents = agents.filter((a) => a.name === "test-agent"); + assert.equal(testAgents.length, 1); + + // The entry is from the highest-priority root + assert.equal(testAgents[0]!.description, "From high priority root"); + assert.equal(testAgents[0]!.sourceRoot, "./.deepcode/agents"); + }); + + test("deduplication uses frontmatter name field, not directory name", () => { + const projectRoot = createTempDir("deepcode-agent-dedup-name-"); + + const highPriority = path.join(projectRoot, ".deepcode", "agents"); + const lowPriority = path.join(projectRoot, ".agents", "agents"); + + // Different directory names, same frontmatter name + createAgentMd( + highPriority, + "dir-a", + makeAgentMd({ + name: "shared-name", + description: "High priority version", + }) + ); + createAgentMd( + lowPriority, + "dir-b", + makeAgentMd({ + name: "shared-name", + description: "Low priority version", + }) + ); + + const registry = new AgentRegistry(projectRoot); + registry.scan(); + const agents = registry.listAgents(); + + const shared = agents.filter((a) => a.name === "shared-name"); + assert.equal(shared.length, 1); + assert.equal(shared[0]!.description, "High priority version"); + }); + + test("unique agents from multiple roots are all included", () => { + const projectRoot = createTempDir("deepcode-agent-multi-roots-"); + + const root1 = path.join(projectRoot, ".deepcode", "agents"); + const root2 = path.join(projectRoot, ".agents", "agents"); + + createAgentMd( + root1, + "agent-alpha", + makeAgentMd({ + name: "agent-alpha", + description: "Alpha agent", + }) + ); + createAgentMd( + root2, + "agent-beta", + makeAgentMd({ + name: "agent-beta", + description: "Beta agent", + }) + ); + + const registry = new AgentRegistry(projectRoot); + registry.scan(); + const agents = registry.listAgents(); + const names = agents.map((a) => a.name); + + assert.equal(names.includes("agent-alpha"), true); + assert.equal(names.includes("agent-beta"), true); + assert.equal(agents.length, 2); + }); + + test("non-existent scan roots are skipped without error", () => { + const projectRoot = createTempDir("deepcode-agent-missing-roots-"); + // No directories created — all scan roots are missing + + const registry = new AgentRegistry(projectRoot); + registry.scan(); + const agents = registry.listAgents(); + + assert.equal(agents.length, 0); + }); + + test("directories without AGENT.md are skipped", () => { + const projectRoot = createTempDir("deepcode-agent-no-manifest-"); + const root = path.join(projectRoot, ".deepcode", "agents"); + + // Create directory without AGENT.md + fs.mkdirSync(path.join(root, "no-manifest"), { recursive: true }); + // Create valid one for comparison + createAgentMd(root, "valid-agent", makeAgentMd({ name: "valid-agent" })); + + const registry = new AgentRegistry(projectRoot); + registry.scan(); + const agents = registry.listAgents(); + + assert.equal(agents.length, 1); + assert.equal(agents[0]!.name, "valid-agent"); + }); + + test("invalid YAML frontmatter is skipped silently", () => { + const projectRoot = createTempDir("deepcode-agent-invalid-yaml-"); + const root = path.join(projectRoot, ".deepcode", "agents"); + + // Invalid YAML - unclosed bracket + createAgentMd(root, "bad-agent", "---\nname: [unclosed\n---\n# Bad Agent\n"); + // Valid agent for comparison + createAgentMd(root, "good-agent", makeAgentMd({ name: "good-agent" })); + + const registry = new AgentRegistry(projectRoot); + registry.scan(); + const agents = registry.listAgents(); + + const names = agents.map((a) => a.name); + assert.equal(names.includes("bad-agent"), false); + assert.equal(names.includes("good-agent"), true); + }); + + test("multiple agents with unique names across many roots are all discovered", () => { + const projectRoot = createTempDir("deepcode-agent-many-"); + + const root1 = path.join(projectRoot, ".deepcode", "agents"); + const root2 = path.join(projectRoot, ".agents", "agents"); + + // Create several agents across roots + const agentNames = ["analyzer", "formatter", "deployer", "reviewer"]; + for (let i = 0; i < agentNames.length; i++) { + const root = i % 2 === 0 ? root1 : root2; + createAgentMd( + root, + agentNames[i]!, + makeAgentMd({ + name: agentNames[i], + description: `${agentNames[i]} description`, + }) + ); + } + + const registry = new AgentRegistry(projectRoot); + registry.scan(); + const agents = registry.listAgents(); + const names = agents.map((a) => a.name).sort(); + + assert.deepEqual(names, [...agentNames].sort()); + }); + + test("scan clears previous state before re-scanning", () => { + const projectRoot = createTempDir("deepcode-agent-rescan-"); + const root = path.join(projectRoot, ".deepcode", "agents"); + + createAgentMd(root, "first-agent", makeAgentMd({ name: "first-agent" })); + + const registry = new AgentRegistry(projectRoot); + registry.scan(); + assert.equal(registry.listAgents().length, 1); + + // Remove the agent and add a different one + fs.rmSync(path.join(root, "first-agent"), { recursive: true }); + createAgentMd(root, "second-agent", makeAgentMd({ name: "second-agent" })); + + registry.scan(); + const agents = registry.listAgents(); + assert.equal(agents.length, 1); + assert.equal(agents[0]!.name, "second-agent"); + }); +}); + +// ============================================================================= +// Property 2: Manifest parsing extracts all fields and body content +// ============================================================================= +// **Validates: Requirements 2.1, 2.2, 2.5** +// +// For any valid AGENT.md file containing YAML frontmatter and a markdown body, +// parsing produces an AgentManifest with correct name (frontmatter or directory), +// instructions (body), description, model, skills (or defaults). + +describe("Property 2: Manifest parsing extracts all fields and body content", () => { + test("all frontmatter fields are correctly extracted", () => { + const projectRoot = createTempDir("deepcode-agent-parse-all-"); + const root = path.join(projectRoot, ".deepcode", "agents"); + + createAgentMd( + root, + "full-agent", + makeAgentMd({ + name: "full-agent", + description: "A fully configured agent", + model: "claude-sonnet", + skills: ["testing", "deployment"], + body: "# Full Agent\n\nYou handle everything.", + }) + ); + + const registry = new AgentRegistry(projectRoot); + registry.scan(); + const agent = registry.getAgent("full-agent"); + + assert.ok(agent); + assert.equal(agent.name, "full-agent"); + assert.equal(agent.description, "A fully configured agent"); + assert.equal(agent.model, "claude-sonnet"); + assert.deepEqual(agent.skills, ["testing", "deployment"]); + assert.equal(agent.instructions, "# Full Agent\n\nYou handle everything."); + assert.equal(agent.sourceRoot, "./.deepcode/agents"); + }); + + test("name defaults to directory name when frontmatter name is missing", () => { + const projectRoot = createTempDir("deepcode-agent-parse-no-name-"); + const root = path.join(projectRoot, ".deepcode", "agents"); + + createAgentMd( + root, + "my-dir-name", + makeAgentMd({ + description: "No name field", + body: "# Instructions", + }) + ); + + const registry = new AgentRegistry(projectRoot); + registry.scan(); + const agent = registry.getAgent("my-dir-name"); + + assert.ok(agent); + assert.equal(agent.name, "my-dir-name"); + }); + + test("description defaults to empty string when missing", () => { + const projectRoot = createTempDir("deepcode-agent-parse-no-desc-"); + const root = path.join(projectRoot, ".deepcode", "agents"); + + createAgentMd( + root, + "no-desc-agent", + makeAgentMd({ + name: "no-desc-agent", + body: "# No description", + }) + ); + + const registry = new AgentRegistry(projectRoot); + registry.scan(); + const agent = registry.getAgent("no-desc-agent"); + + assert.ok(agent); + assert.equal(agent.description, ""); + }); + + test("model defaults to 'default' when missing", () => { + const projectRoot = createTempDir("deepcode-agent-parse-no-model-"); + const root = path.join(projectRoot, ".deepcode", "agents"); + + createAgentMd( + root, + "no-model-agent", + makeAgentMd({ + name: "no-model-agent", + body: "# No model", + }) + ); + + const registry = new AgentRegistry(projectRoot); + registry.scan(); + const agent = registry.getAgent("no-model-agent"); + + assert.ok(agent); + assert.equal(agent.model, "default"); + }); + + test("skills defaults to empty array when missing", () => { + const projectRoot = createTempDir("deepcode-agent-parse-no-skills-"); + const root = path.join(projectRoot, ".deepcode", "agents"); + + createAgentMd( + root, + "no-skills-agent", + makeAgentMd({ + name: "no-skills-agent", + body: "# No skills", + }) + ); + + const registry = new AgentRegistry(projectRoot); + registry.scan(); + const agent = registry.getAgent("no-skills-agent"); + + assert.ok(agent); + assert.deepEqual(agent.skills, []); + }); + + test("markdown body (instructions) is extracted correctly with trimming", () => { + const projectRoot = createTempDir("deepcode-agent-parse-body-"); + const root = path.join(projectRoot, ".deepcode", "agents"); + + const body = + "# Complex Agent\n\nYou do many things.\n\n## Section 1\n\nDetails here.\n\n## Section 2\n\nMore details."; + createAgentMd( + root, + "body-agent", + makeAgentMd({ + name: "body-agent", + body, + }) + ); + + const registry = new AgentRegistry(projectRoot); + registry.scan(); + const agent = registry.getAgent("body-agent"); + + assert.ok(agent); + assert.equal(agent.instructions, body); + }); + + test("sourcePath points to the actual AGENT.md file", () => { + const projectRoot = createTempDir("deepcode-agent-parse-path-"); + const root = path.join(projectRoot, ".deepcode", "agents"); + + const manifestPath = createAgentMd( + root, + "path-agent", + makeAgentMd({ + name: "path-agent", + }) + ); + + const registry = new AgentRegistry(projectRoot); + registry.scan(); + const agent = registry.getAgent("path-agent"); + + assert.ok(agent); + assert.equal(agent.sourcePath, manifestPath); + }); + + test("skills array filters out non-string values", () => { + const projectRoot = createTempDir("deepcode-agent-parse-bad-skills-"); + const root = path.join(projectRoot, ".deepcode", "agents"); + + // Manually write YAML with mixed types in skills + const content = `--- +name: mixed-skills-agent +skills: + - valid-skill + - 123 + - another-skill + - true +--- + +# Mixed Skills Agent +`; + createAgentMd(root, "mixed-skills-agent", content); + + const registry = new AgentRegistry(projectRoot); + registry.scan(); + const agent = registry.getAgent("mixed-skills-agent"); + + assert.ok(agent); + // Only string values should be included + assert.deepEqual(agent.skills, ["valid-skill", "another-skill"]); + }); + + test("empty frontmatter uses all defaults with directory name", () => { + const projectRoot = createTempDir("deepcode-agent-parse-empty-fm-"); + const root = path.join(projectRoot, ".deepcode", "agents"); + + createAgentMd(root, "empty-fm-agent", "---\n---\n\n# Empty Frontmatter Agent\n\nInstructions here."); + + const registry = new AgentRegistry(projectRoot); + registry.scan(); + const agent = registry.getAgent("empty-fm-agent"); + + assert.ok(agent); + assert.equal(agent.name, "empty-fm-agent"); + assert.equal(agent.description, ""); + assert.equal(agent.model, "default"); + assert.deepEqual(agent.skills, []); + assert.equal(agent.instructions, "# Empty Frontmatter Agent\n\nInstructions here."); + }); + + test("hasAgents returns true when agents exist, false otherwise", () => { + const projectRoot = createTempDir("deepcode-agent-has-agents-"); + const root = path.join(projectRoot, ".deepcode", "agents"); + + const registry = new AgentRegistry(projectRoot); + registry.scan(); + assert.equal(registry.hasAgents(), false); + + createAgentMd(root, "some-agent", makeAgentMd({ name: "some-agent" })); + registry.scan(); + assert.equal(registry.hasAgents(), true); + }); + + test("getAgent returns undefined for non-existent agent", () => { + const projectRoot = createTempDir("deepcode-agent-get-missing-"); + const root = path.join(projectRoot, ".deepcode", "agents"); + + createAgentMd(root, "existing-agent", makeAgentMd({ name: "existing-agent" })); + + const registry = new AgentRegistry(projectRoot); + registry.scan(); + + assert.equal(registry.getAgent("non-existent"), undefined); + assert.ok(registry.getAgent("existing-agent")); + }); +}); + +// ============================================================================= +// Plain markdown AGENT.md fallback parsing (no YAML frontmatter) +// ============================================================================= +// Real-world AGENT.md files sometimes express metadata using plain markdown +// conventions (H1 heading, blockquote description, bold-label lines) instead +// of YAML frontmatter. gray-matter's matter() only recognizes frontmatter +// blocks that start the document with "---", so these files must fall back +// to a plain-text metadata scan. + +describe("Plain markdown AGENT.md fallback parsing (no YAML frontmatter)", () => { + test("extracts name, description, model, and instructions from blockquote-style metadata", () => { + const projectRoot = createTempDir("deepcode-agent-plain-blockquote-"); + const root = path.join(projectRoot, ".deepcode", "agents"); + + const content = `# deploy-assistant + +> 根据代码分支自动识别 Web 或 CRN 工程类型,调用对应工具(captain mcp / npx mcd-cli)发布测试环境,拒绝生产环境发布。 + +**Model**: deepseek + +--- + +你是一个专注于测试环境发布的智能助手。 +根据代码分支自动识别工程类型并发布。 +`; + + createAgentMd(root, "deploy-assistant", content); + + const registry = new AgentRegistry(projectRoot); + registry.scan(); + const agent = registry.getAgent("deploy-assistant"); + + assert.ok(agent); + assert.equal(agent.name, "deploy-assistant"); + assert.equal( + agent.description, + "根据代码分支自动识别 Web 或 CRN 工程类型,调用对应工具(captain mcp / npx mcd-cli)发布测试环境,拒绝生产环境发布。" + ); + assert.equal(agent.model, "deepseek"); + assert.equal(agent.instructions, "你是一个专注于测试环境发布的智能助手。\n根据代码分支自动识别工程类型并发布。"); + }); + + test("extracts description and skills from bullet-style bold-label lines", () => { + const projectRoot = createTempDir("deepcode-agent-plain-bullets-"); + const root = path.join(projectRoot, ".deepcode", "agents"); + + const content = `# bullet-agent + +- **Description**: A bullet-style described agent. +- **Model**: claude +- **Skills**: a, b, c + +--- + +You are a bullet-style agent. +`; + + createAgentMd(root, "bullet-agent", content); + + const registry = new AgentRegistry(projectRoot); + registry.scan(); + const agent = registry.getAgent("bullet-agent"); + + assert.ok(agent); + assert.equal(agent.name, "bullet-agent"); + assert.equal(agent.description, "A bullet-style described agent."); + assert.equal(agent.model, "claude"); + assert.deepEqual(agent.skills, ["a", "b", "c"]); + assert.equal(agent.instructions, "You are a bullet-style agent."); + }); + + test("falls back to full trimmed raw content when no separator follows metadata", () => { + const projectRoot = createTempDir("deepcode-agent-plain-no-sep-"); + const root = path.join(projectRoot, ".deepcode", "agents"); + + const content = `# no-separator-agent + +> This agent has no separator line. + +**Model**: gpt-4 + +You are a helpful agent without a separator. +`; + + createAgentMd(root, "no-separator-agent", content); + + const registry = new AgentRegistry(projectRoot); + registry.scan(); + const agent = registry.getAgent("no-separator-agent"); + + assert.ok(agent); + assert.equal(agent.name, "no-separator-agent"); + assert.equal(agent.description, "This agent has no separator line."); + assert.equal(agent.model, "gpt-4"); + assert.equal(agent.instructions, content.trim()); + }); + + test("regression: YAML frontmatter AGENT.md files still parse exactly as before", () => { + const projectRoot = createTempDir("deepcode-agent-plain-regression-"); + const root = path.join(projectRoot, ".deepcode", "agents"); + + createAgentMd( + root, + "frontmatter-agent", + makeAgentMd({ + name: "frontmatter-agent", + description: "A frontmatter-described agent", + model: "claude-sonnet", + skills: ["testing"], + body: "# Frontmatter Agent\n\nYou use YAML frontmatter.", + }) + ); + + const registry = new AgentRegistry(projectRoot); + registry.scan(); + const agent = registry.getAgent("frontmatter-agent"); + + assert.ok(agent); + assert.equal(agent.name, "frontmatter-agent"); + assert.equal(agent.description, "A frontmatter-described agent"); + assert.equal(agent.model, "claude-sonnet"); + assert.deepEqual(agent.skills, ["testing"]); + assert.equal(agent.instructions, "# Frontmatter Agent\n\nYou use YAML frontmatter."); + }); +}); diff --git a/packages/core/src/tests/delegate-handler.test.ts b/packages/core/src/tests/delegate-handler.test.ts new file mode 100644 index 0000000..72874d8 --- /dev/null +++ b/packages/core/src/tests/delegate-handler.test.ts @@ -0,0 +1,365 @@ +import { afterEach, describe, test } from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { AgentRegistry } from "../agents/agent-registry"; +import { createDelegateToAgentHandler } from "../agents/delegate-handler"; +import type { DelegateHandlerOptions } from "../agents/delegate-handler"; +import type { CreateOpenAIClient } from "../common/tool-types"; +import type { ToolExecutor } from "../tools/executor"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +function createTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +/** + * Helper to create an AGENT.md file in a given scan root under a named subdirectory. + */ +function createAgentMd(scanRoot: string, dirName: string, content: string): string { + const agentDir = path.join(scanRoot, dirName); + fs.mkdirSync(agentDir, { recursive: true }); + const manifestPath = path.join(agentDir, "AGENT.md"); + fs.writeFileSync(manifestPath, content, "utf8"); + return manifestPath; +} + +/** + * Generates a valid AGENT.md content string. + */ +function makeAgentMd(opts: { + name?: string; + description?: string; + model?: string; + skills?: string[]; + body?: string; +}): string { + const frontmatterParts: string[] = []; + if (opts.name !== undefined) frontmatterParts.push(`name: ${opts.name}`); + if (opts.description !== undefined) frontmatterParts.push(`description: "${opts.description}"`); + if (opts.model !== undefined) frontmatterParts.push(`model: ${opts.model}`); + if (opts.skills !== undefined) { + frontmatterParts.push(`skills:`); + for (const skill of opts.skills) { + frontmatterParts.push(` - ${skill}`); + } + } + + const frontmatter = frontmatterParts.length > 0 ? `---\n${frontmatterParts.join("\n")}\n---\n` : `---\n---\n`; + + return `${frontmatter}\n${opts.body ?? "# Default Instructions\n\nYou are a helpful agent."}`; +} + +/** + * Creates a mock CreateOpenAIClient that returns a simple text response without tool calls. + */ +function makeMockCreateOpenAIClient(responseText = "Sub-agent task completed."): CreateOpenAIClient { + return () => ({ + client: { + chat: { + completions: { + create: async () => ({ + choices: [ + { + message: { + role: "assistant", + content: responseText, + tool_calls: undefined, + }, + finish_reason: "stop", + }, + ], + }), + }, + }, + } as unknown, + model: "test-model", + baseURL: undefined, + thinkingEnabled: false, + debugLogEnabled: false, + }); +} + +/** + * Creates a mock ToolExecutor. + */ +function makeMockToolExecutor(): ToolExecutor { + return { + executeToolCalls: async (_sessionId: string, toolCalls: unknown[]) => { + const calls = toolCalls as Array<{ id: string; function: { name: string; arguments: string } }>; + return calls.map((tc) => ({ + toolCallId: tc.id, + content: JSON.stringify({ ok: true, name: tc.function.name, output: "done" }), + result: { ok: true, name: tc.function.name, output: "done" }, + })); + }, + } as unknown as ToolExecutor; +} + +/** + * Sets up a registry with multiple agents and returns the handler options. + */ +function setupHandlerWithAgents(agentConfigs: Array<{ name: string; description?: string; body?: string }>): { + handler: ReturnType; + options: DelegateHandlerOptions; +} { + const projectRoot = createTempDir("deepcode-delegate-handler-"); + const agentsRoot = path.join(projectRoot, ".deepcode", "agents"); + + for (const config of agentConfigs) { + createAgentMd( + agentsRoot, + config.name, + makeAgentMd({ + name: config.name, + description: config.description ?? `${config.name} agent`, + body: config.body ?? `# ${config.name}\n\nYou are the ${config.name} agent.`, + }) + ); + } + + const registry = new AgentRegistry(projectRoot); + registry.scan(); + + const options: DelegateHandlerOptions = { + agentRegistry: registry, + projectRoot, + parentModel: "parent-model", + createOpenAIClient: makeMockCreateOpenAIClient(), + toolExecutor: makeMockToolExecutor(), + globalSkillRoots: [], + }; + + const handler = createDelegateToAgentHandler(options); + return { handler, options }; +} + +// ============================================================================= +// Property 8: Agent name validation in DelegateToAgent handler +// ============================================================================= +// **Validates: Requirements 4.4, 4.5** +// +// For any `agent_name` string passed to the DelegateToAgent handler: +// if it matches a discovered agent name, execution SHALL proceed to spawn +// a sub-agent session; if it does not match, the handler SHALL return an +// error whose message contains all available agent names. + +describe("Property 8: Agent name validation in DelegateToAgent handler", () => { + test("unknown agent_name returns error listing all available agents", async () => { + const { handler } = setupHandlerWithAgents([ + { name: "alpha-agent", description: "Alpha" }, + { name: "beta-agent", description: "Beta" }, + { name: "gamma-agent", description: "Gamma" }, + ]); + + const result = await handler({ agent_name: "nonexistent-agent", task: "do something" }); + + assert.equal(result.ok, false); + assert.ok(result.error, "Should have an error message"); + // Error should mention the unknown agent name + assert.ok(result.error!.includes("nonexistent-agent"), "Error should reference the unknown agent name"); + // Error should list all available agents + assert.ok(result.error!.includes("alpha-agent"), "Error should list available agent: alpha-agent"); + assert.ok(result.error!.includes("beta-agent"), "Error should list available agent: beta-agent"); + assert.ok(result.error!.includes("gamma-agent"), "Error should list available agent: gamma-agent"); + }); + + test("empty agent_name returns 'Missing required parameter' error", async () => { + const { handler } = setupHandlerWithAgents([{ name: "test-agent" }]); + + const result = await handler({ agent_name: "", task: "do something" }); + + assert.equal(result.ok, false); + assert.ok(result.error, "Should have an error message"); + assert.ok( + result.error!.includes("Missing required parameter"), + `Error should indicate missing parameter, got: ${result.error}` + ); + }); + + test("missing agent_name field returns 'Missing required parameter' error", async () => { + const { handler } = setupHandlerWithAgents([{ name: "test-agent" }]); + + const result = await handler({ task: "do something" }); + + assert.equal(result.ok, false); + assert.ok(result.error, "Should have an error message"); + assert.ok( + result.error!.includes("Missing required parameter"), + `Error should indicate missing parameter, got: ${result.error}` + ); + }); + + test("empty task returns 'Missing required parameter' error", async () => { + const { handler } = setupHandlerWithAgents([{ name: "test-agent" }]); + + const result = await handler({ agent_name: "test-agent", task: "" }); + + assert.equal(result.ok, false); + assert.ok(result.error, "Should have an error message"); + assert.ok( + result.error!.includes("Missing required parameter"), + `Error should indicate missing parameter, got: ${result.error}` + ); + }); + + test("missing task field returns 'Missing required parameter' error", async () => { + const { handler } = setupHandlerWithAgents([{ name: "test-agent" }]); + + const result = await handler({ agent_name: "test-agent" }); + + assert.equal(result.ok, false); + assert.ok(result.error, "Should have an error message"); + assert.ok( + result.error!.includes("Missing required parameter"), + `Error should indicate missing parameter, got: ${result.error}` + ); + }); + + test("valid agent_name + task proceeds and returns successful result", async () => { + const projectRoot = createTempDir("deepcode-delegate-valid-"); + const agentsRoot = path.join(projectRoot, ".deepcode", "agents"); + + createAgentMd( + agentsRoot, + "my-agent", + makeAgentMd({ + name: "my-agent", + description: "A test agent", + body: "# My Agent\n\nYou help with testing.", + }) + ); + + const registry = new AgentRegistry(projectRoot); + registry.scan(); + + const options: DelegateHandlerOptions = { + agentRegistry: registry, + projectRoot, + parentModel: "parent-model", + createOpenAIClient: makeMockCreateOpenAIClient("Task completed successfully."), + toolExecutor: makeMockToolExecutor(), + globalSkillRoots: [], + }; + + const handler = createDelegateToAgentHandler(options); + const result = await handler({ agent_name: "my-agent", task: "Write a unit test" }); + + assert.equal(result.ok, true); + assert.equal(result.output, "Task completed successfully."); + assert.equal(result.error, undefined); + }); + + test("valid agent_name but sub-agent error returns ok=false with error", async () => { + const projectRoot = createTempDir("deepcode-delegate-error-"); + const agentsRoot = path.join(projectRoot, ".deepcode", "agents"); + + createAgentMd( + agentsRoot, + "failing-agent", + makeAgentMd({ + name: "failing-agent", + description: "An agent that fails", + body: "# Failing Agent\n\nYou always fail.", + }) + ); + + const registry = new AgentRegistry(projectRoot); + registry.scan(); + + // Mock client that throws an error + const failingClient: CreateOpenAIClient = () => ({ + client: { + chat: { + completions: { + create: async () => { + throw new Error("LLM service unavailable"); + }, + }, + }, + } as unknown, + model: "test-model", + baseURL: undefined, + thinkingEnabled: false, + debugLogEnabled: false, + }); + + const options: DelegateHandlerOptions = { + agentRegistry: registry, + projectRoot, + parentModel: "parent-model", + createOpenAIClient: failingClient, + toolExecutor: makeMockToolExecutor(), + globalSkillRoots: [], + }; + + const handler = createDelegateToAgentHandler(options); + const result = await handler({ agent_name: "failing-agent", task: "Do something" }); + + assert.equal(result.ok, false); + assert.ok(result.error, "Should have an error message"); + assert.ok( + result.error!.includes("LLM service unavailable") || result.error!.includes("failed"), + `Error should describe the failure, got: ${result.error}` + ); + }); + + test("error for unknown agent includes ALL available agent names (property guarantee)", async () => { + // Use many agents to ensure all are listed + const agentNames = ["agent-a", "agent-b", "agent-c", "agent-d", "agent-e"]; + const { handler } = setupHandlerWithAgents( + agentNames.map((name) => ({ name, description: `Description for ${name}` })) + ); + + const result = await handler({ agent_name: "unknown-xyz", task: "test task" }); + + assert.equal(result.ok, false); + assert.ok(result.error, "Should have an error message"); + + // Verify EVERY agent name appears in the error + for (const name of agentNames) { + assert.ok(result.error!.includes(name), `Error should list available agent "${name}", got: ${result.error}`); + } + }); + + test("non-string agent_name is treated as empty/missing", async () => { + const { handler } = setupHandlerWithAgents([{ name: "test-agent" }]); + + // Pass non-string values for agent_name + const result1 = await handler({ agent_name: 123, task: "do something" }); + assert.equal(result1.ok, false); + assert.ok(result1.error!.includes("Missing required parameter")); + + const result2 = await handler({ agent_name: null, task: "do something" }); + assert.equal(result2.ok, false); + assert.ok(result2.error!.includes("Missing required parameter")); + + const result3 = await handler({ agent_name: undefined, task: "do something" }); + assert.equal(result3.ok, false); + assert.ok(result3.error!.includes("Missing required parameter")); + }); + + test("agent_name matching is exact (case-sensitive)", async () => { + const { handler } = setupHandlerWithAgents([{ name: "MyAgent" }]); + + // Exact match succeeds + const resultExact = await handler({ agent_name: "MyAgent", task: "test" }); + assert.equal(resultExact.ok, true); + + // Case mismatch fails + const resultWrongCase = await handler({ agent_name: "myagent", task: "test" }); + assert.equal(resultWrongCase.ok, false); + assert.ok(resultWrongCase.error!.includes("not found")); + assert.ok(resultWrongCase.error!.includes("MyAgent")); + }); +}); diff --git a/packages/core/src/tests/format-agents.test.ts b/packages/core/src/tests/format-agents.test.ts new file mode 100644 index 0000000..a624891 --- /dev/null +++ b/packages/core/src/tests/format-agents.test.ts @@ -0,0 +1,242 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { formatAgentsList } from "../agents/format-agents"; +import type { AgentManifest } from "../agents/agent-registry"; + +describe("formatAgentsList", () => { + test("returns empty-state message when no agents are discovered", () => { + const result = formatAgentsList([]); + assert.ok(result.includes("No sub-agents discovered")); + assert.ok(result.includes(".deepcode/agents")); + assert.ok(result.includes(".agents")); + }); + + test("includes agent name, description, model, and sourceRoot for each agent", () => { + const agents: AgentManifest[] = [ + { + name: "deploy-assistant", + description: "Auto-deploy to test environments", + model: "deepseek", + skills: [], + instructions: "Deploy instructions...", + sourcePath: "/project/.deepcode/agents/deploy-assistant/AGENT.md", + sourceRoot: "./.deepcode/agents", + }, + { + name: "ut-agent", + description: "Unit test generation", + model: "claude", + skills: ["testing-patterns"], + instructions: "UT instructions...", + sourcePath: "/project/.agents/ut-agent/AGENT.md", + sourceRoot: "./.agents", + }, + ]; + + const result = formatAgentsList(agents); + + // Check header + assert.ok(result.includes("Available Sub-Agents")); + + // Check first agent + assert.ok(result.includes("### deploy-assistant")); + assert.ok(result.includes("Auto-deploy to test environments")); + assert.ok(result.includes("**Model**: deepseek")); + assert.ok(result.includes("**Source**: ./.deepcode/agents")); + + // Check second agent + assert.ok(result.includes("### ut-agent")); + assert.ok(result.includes("Unit test generation")); + assert.ok(result.includes("**Model**: claude")); + assert.ok(result.includes("**Source**: ./.agents")); + assert.ok(result.includes("**Skills**: testing-patterns")); + }); + + test("omits description line when description is empty", () => { + const agents: AgentManifest[] = [ + { + name: "simple-agent", + description: "", + model: "default", + skills: [], + instructions: "", + sourcePath: "/path/AGENT.md", + sourceRoot: "./.agents", + }, + ]; + + const result = formatAgentsList(agents); + assert.ok(result.includes("### simple-agent")); + assert.ok(result.includes("**Model**: default")); + assert.ok(!result.includes("**Description**")); + }); + + test("omits skills line when skills array is empty", () => { + const agents: AgentManifest[] = [ + { + name: "no-skills-agent", + description: "An agent without skills", + model: "claude", + skills: [], + instructions: "", + sourcePath: "/path/AGENT.md", + sourceRoot: "./.deepcode/agents", + }, + ]; + + const result = formatAgentsList(agents); + assert.ok(!result.includes("**Skills**")); + }); + + test("lists multiple skills separated by commas", () => { + const agents: AgentManifest[] = [ + { + name: "multi-skill", + description: "Agent with skills", + model: "claude", + skills: ["skill-a", "skill-b", "skill-c"], + instructions: "", + sourcePath: "/path/AGENT.md", + sourceRoot: "./.agents", + }, + ]; + + const result = formatAgentsList(agents); + assert.ok(result.includes("**Skills**: skill-a, skill-b, skill-c")); + }); +}); + +describe("Property 11: Agent listing includes complete metadata", () => { + /** + * **Validates: Requirements 7.2** + * + * For any set of discovered agents, the `/agents` command output SHALL include + * each agent's name, description, model preference, and source scan root path. + */ + + function makeAgent(overrides: Partial = {}): AgentManifest { + return { + name: overrides.name ?? "test-agent", + description: overrides.description ?? "A test agent", + model: overrides.model ?? "claude", + skills: overrides.skills ?? [], + instructions: overrides.instructions ?? "Do things", + sourcePath: overrides.sourcePath ?? "/project/.agents/test-agent/AGENT.md", + sourceRoot: overrides.sourceRoot ?? "./.agents", + }; + } + + test("for any number of agents (1, 2, 5), output contains EVERY agent's name", () => { + const agentCounts = [1, 2, 5]; + + for (const count of agentCounts) { + const agents: AgentManifest[] = Array.from({ length: count }, (_, i) => + makeAgent({ name: `agent-${i}`, description: `Description ${i}` }) + ); + + const result = formatAgentsList(agents); + + for (const agent of agents) { + assert.ok( + result.includes(agent.name), + `Output should include agent name "${agent.name}" when ${count} agents are present` + ); + } + } + }); + + test("for any agent with a non-empty description, output contains that description", () => { + const descriptions = [ + "Auto-deploy to test environments", + "Unit test generation specialist", + "Code review and refactoring assistant", + "A simple helper", + "Multi-word description with special chars: @#$%", + ]; + + for (const desc of descriptions) { + const agents = [makeAgent({ name: "desc-agent", description: desc })]; + const result = formatAgentsList(agents); + + assert.ok(result.includes(desc), `Output should include description "${desc}"`); + } + }); + + test("for any agent, output contains the model preference", () => { + const models = ["claude", "deepseek", "default", "gpt-4o", "claude-haiku"]; + + for (const model of models) { + const agents = [makeAgent({ name: "model-agent", model })]; + const result = formatAgentsList(agents); + + assert.ok(result.includes(model), `Output should include model preference "${model}"`); + } + }); + + test("for any agent, output contains the source scan root path", () => { + const sourceRoots = ["./.deepcode/agents", "./.agents", "~/.deepcode/agents", "~/.agents"]; + + for (const sourceRoot of sourceRoots) { + const agents = [makeAgent({ name: "source-agent", sourceRoot })]; + const result = formatAgentsList(agents); + + assert.ok(result.includes(sourceRoot), `Output should include source root path "${sourceRoot}"`); + } + }); + + test("for agents with skills, the skills are listed in the output", () => { + const skillSets = [["testing-patterns"], ["skill-a", "skill-b"], ["deploy", "lint", "format"]]; + + for (const skills of skillSets) { + const agents = [makeAgent({ name: "skilled-agent", skills })]; + const result = formatAgentsList(agents); + + for (const skill of skills) { + assert.ok( + result.includes(skill), + `Output should include skill "${skill}" from skills list [${skills.join(", ")}]` + ); + } + } + }); + + test("combined: multiple agents each have all metadata fields present", () => { + const agents: AgentManifest[] = [ + makeAgent({ + name: "deploy-bot", + description: "Deploys services", + model: "deepseek", + skills: ["docker", "k8s"], + sourceRoot: "./.deepcode/agents", + }), + makeAgent({ + name: "test-writer", + description: "Writes unit tests", + model: "claude", + skills: ["jest-patterns"], + sourceRoot: "./.agents", + }), + makeAgent({ + name: "reviewer", + description: "Reviews code changes", + model: "gpt-4o", + skills: [], + sourceRoot: "~/.deepcode/agents", + }), + ]; + + const result = formatAgentsList(agents); + + for (const agent of agents) { + assert.ok(result.includes(agent.name), `Missing name: ${agent.name}`); + if (agent.description) { + assert.ok(result.includes(agent.description), `Missing description for ${agent.name}`); + } + assert.ok(result.includes(agent.model), `Missing model for ${agent.name}`); + assert.ok(result.includes(agent.sourceRoot), `Missing sourceRoot for ${agent.name}`); + for (const skill of agent.skills) { + assert.ok(result.includes(skill), `Missing skill "${skill}" for ${agent.name}`); + } + } + }); +}); diff --git a/packages/core/src/tests/prompt-agents.test.ts b/packages/core/src/tests/prompt-agents.test.ts new file mode 100644 index 0000000..8fa7366 --- /dev/null +++ b/packages/core/src/tests/prompt-agents.test.ts @@ -0,0 +1,333 @@ +import { afterEach, describe, test } from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { getTools } from "../prompt"; +import { AgentRegistry } from "../agents/agent-registry"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +function createTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +function createAgentMd(scanRoot: string, dirName: string, content: string): string { + const agentDir = path.join(scanRoot, dirName); + fs.mkdirSync(agentDir, { recursive: true }); + const manifestPath = path.join(agentDir, "AGENT.md"); + fs.writeFileSync(manifestPath, content, "utf8"); + return manifestPath; +} + +function makeAgentMd(opts: { + name?: string; + description?: string; + model?: string; + skills?: string[]; + body?: string; +}): string { + const frontmatterParts: string[] = []; + if (opts.name !== undefined) frontmatterParts.push(`name: ${opts.name}`); + if (opts.description !== undefined) frontmatterParts.push(`description: ${opts.description}`); + if (opts.model !== undefined) frontmatterParts.push(`model: ${opts.model}`); + if (opts.skills !== undefined) { + frontmatterParts.push(`skills:`); + for (const skill of opts.skills) { + frontmatterParts.push(` - ${skill}`); + } + } + + const frontmatter = frontmatterParts.length > 0 ? `---\n${frontmatterParts.join("\n")}\n---\n` : `---\n---\n`; + + return `${frontmatter}\n${opts.body ?? "# Default Instructions\n\nYou are a helpful agent."}`; +} + +// ============================================================================= +// Property 6: DelegateToAgent tool presence correlates with agent discovery +// ============================================================================= +// **Validates: Requirements 4.1, 4.3** +// +// For any call to getTools() with an AgentRegistry argument, the returned tool +// list SHALL contain a DelegateToAgent tool definition if and only if +// agentRegistry.hasAgents() returns true. + +describe("Property 6: DelegateToAgent tool presence correlates with agent discovery", () => { + test("DelegateToAgent is present when registry has agents", () => { + const projectRoot = createTempDir("deepcode-prompt-agents-present-"); + const root = path.join(projectRoot, ".deepcode", "agents"); + + createAgentMd( + root, + "test-agent", + makeAgentMd({ + name: "test-agent", + description: "A test agent", + }) + ); + + const registry = new AgentRegistry(projectRoot); + registry.scan(); + assert.equal(registry.hasAgents(), true); + + const tools = getTools({}, [], registry); + const delegateTool = tools.find((t) => t.function.name === "DelegateToAgent"); + + assert.ok(delegateTool, "DelegateToAgent tool should be present when agents exist"); + }); + + test("DelegateToAgent is NOT present when no registry is provided", () => { + const tools = getTools({}, []); + const delegateTool = tools.find((t) => t.function.name === "DelegateToAgent"); + + assert.equal(delegateTool, undefined, "DelegateToAgent should not be present without a registry"); + }); + + test("DelegateToAgent is NOT present when registry has no agents", () => { + const projectRoot = createTempDir("deepcode-prompt-agents-empty-"); + // No agent directories created — registry will be empty + + const registry = new AgentRegistry(projectRoot); + registry.scan(); + assert.equal(registry.hasAgents(), false); + + const tools = getTools({}, [], registry); + const delegateTool = tools.find((t) => t.function.name === "DelegateToAgent"); + + assert.equal(delegateTool, undefined, "DelegateToAgent should not be present when registry is empty"); + }); + + test("DelegateToAgent presence is consistent with hasAgents() for multiple agents", () => { + const projectRoot = createTempDir("deepcode-prompt-agents-multi-"); + const root = path.join(projectRoot, ".deepcode", "agents"); + + createAgentMd(root, "agent-a", makeAgentMd({ name: "agent-a", description: "Alpha" })); + createAgentMd(root, "agent-b", makeAgentMd({ name: "agent-b", description: "Beta" })); + createAgentMd(root, "agent-c", makeAgentMd({ name: "agent-c", description: "Gamma" })); + + const registry = new AgentRegistry(projectRoot); + registry.scan(); + assert.equal(registry.hasAgents(), true); + + const tools = getTools({}, [], registry); + const delegateTool = tools.find((t) => t.function.name === "DelegateToAgent"); + + assert.ok(delegateTool, "DelegateToAgent tool should be present when multiple agents exist"); + }); + + test("DelegateToAgent tool parameters include agent_name and task as required", () => { + const projectRoot = createTempDir("deepcode-prompt-agents-params-"); + const root = path.join(projectRoot, ".deepcode", "agents"); + + createAgentMd( + root, + "param-agent", + makeAgentMd({ + name: "param-agent", + description: "Parameter test", + }) + ); + + const registry = new AgentRegistry(projectRoot); + registry.scan(); + + const tools = getTools({}, [], registry); + const delegateTool = tools.find((t) => t.function.name === "DelegateToAgent"); + + assert.ok(delegateTool); + const params = delegateTool.function.parameters; + assert.equal(params.type, "object"); + assert.ok(params.properties.agent_name, "agent_name property should exist"); + assert.ok(params.properties.task, "task property should exist"); + assert.ok(params.required?.includes("agent_name"), "agent_name should be required"); + assert.ok(params.required?.includes("task"), "task should be required"); + }); + + test("other built-in tools remain unaffected by agent registry presence", () => { + const projectRoot = createTempDir("deepcode-prompt-agents-other-tools-"); + const root = path.join(projectRoot, ".deepcode", "agents"); + + createAgentMd(root, "some-agent", makeAgentMd({ name: "some-agent" })); + + const registry = new AgentRegistry(projectRoot); + registry.scan(); + + const toolsWithAgents = getTools({}, [], registry); + const toolsWithout = getTools({}, []); + + // All tools that exist without agents should still be present with agents + const namesWithout = toolsWithout.map((t) => t.function.name); + for (const name of namesWithout) { + const found = toolsWithAgents.find((t) => t.function.name === name); + assert.ok(found, `Tool "${name}" should still be present when agents exist`); + } + }); +}); + +// ============================================================================= +// Property 7: DelegateToAgent description contains all agent metadata +// ============================================================================= +// **Validates: Requirements 4.3** +// +// For any non-empty set of discovered agents, the DelegateToAgent tool's +// description field SHALL contain the name and description of every +// discovered agent. + +describe("Property 7: DelegateToAgent description contains all agent metadata", () => { + test("description contains single agent name and description", () => { + const projectRoot = createTempDir("deepcode-prompt-agents-desc-single-"); + const root = path.join(projectRoot, ".deepcode", "agents"); + + createAgentMd( + root, + "code-reviewer", + makeAgentMd({ + name: "code-reviewer", + description: "Reviews code for quality and correctness", + }) + ); + + const registry = new AgentRegistry(projectRoot); + registry.scan(); + + const tools = getTools({}, [], registry); + const delegateTool = tools.find((t) => t.function.name === "DelegateToAgent"); + + assert.ok(delegateTool); + const description = delegateTool.function.description; + assert.ok(description.includes("code-reviewer"), "Description should contain agent name"); + assert.ok( + description.includes("Reviews code for quality and correctness"), + "Description should contain agent description" + ); + }); + + test("description contains ALL agent names and descriptions for multiple agents", () => { + const projectRoot = createTempDir("deepcode-prompt-agents-desc-multi-"); + const root = path.join(projectRoot, ".deepcode", "agents"); + + const agents = [ + { name: "test-writer", description: "Generates unit tests" }, + { name: "doc-generator", description: "Creates documentation" }, + { name: "refactorer", description: "Refactors code for clarity" }, + ]; + + for (const agent of agents) { + createAgentMd(root, agent.name, makeAgentMd(agent)); + } + + const registry = new AgentRegistry(projectRoot); + registry.scan(); + + const tools = getTools({}, [], registry); + const delegateTool = tools.find((t) => t.function.name === "DelegateToAgent"); + + assert.ok(delegateTool); + const description = delegateTool.function.description; + + for (const agent of agents) { + assert.ok(description.includes(agent.name), `Description should contain agent name "${agent.name}"`); + assert.ok( + description.includes(agent.description), + `Description should contain description for "${agent.name}": "${agent.description}"` + ); + } + }); + + test("description handles agents with empty descriptions gracefully", () => { + const projectRoot = createTempDir("deepcode-prompt-agents-desc-empty-"); + const root = path.join(projectRoot, ".deepcode", "agents"); + + createAgentMd( + root, + "no-desc-agent", + makeAgentMd({ + name: "no-desc-agent", + // No description — defaults to "" + }) + ); + + const registry = new AgentRegistry(projectRoot); + registry.scan(); + + const tools = getTools({}, [], registry); + const delegateTool = tools.find((t) => t.function.name === "DelegateToAgent"); + + assert.ok(delegateTool); + const description = delegateTool.function.description; + assert.ok( + description.includes("no-desc-agent"), + "Description should still contain the agent name even without a description" + ); + }); + + test("description includes agents from multiple scan roots", () => { + const projectRoot = createTempDir("deepcode-prompt-agents-desc-roots-"); + const root1 = path.join(projectRoot, ".deepcode", "agents"); + const root2 = path.join(projectRoot, ".agents", "agents"); + + createAgentMd( + root1, + "primary-agent", + makeAgentMd({ + name: "primary-agent", + description: "From primary root", + }) + ); + createAgentMd( + root2, + "secondary-agent", + makeAgentMd({ + name: "secondary-agent", + description: "From secondary root", + }) + ); + + const registry = new AgentRegistry(projectRoot); + registry.scan(); + + const tools = getTools({}, [], registry); + const delegateTool = tools.find((t) => t.function.name === "DelegateToAgent"); + + assert.ok(delegateTool); + const description = delegateTool.function.description; + + assert.ok(description.includes("primary-agent"), "Should include primary-agent"); + assert.ok(description.includes("From primary root"), "Should include primary-agent description"); + assert.ok(description.includes("secondary-agent"), "Should include secondary-agent"); + assert.ok(description.includes("From secondary root"), "Should include secondary-agent description"); + }); + + test("description reflects agents with special characters in name/description", () => { + const projectRoot = createTempDir("deepcode-prompt-agents-desc-special-"); + const root = path.join(projectRoot, ".deepcode", "agents"); + + createAgentMd( + root, + "my-agent-2", + makeAgentMd({ + name: "my-agent-2", + description: "Handles tasks with numbers (v2.0) & symbols", + }) + ); + + const registry = new AgentRegistry(projectRoot); + registry.scan(); + + const tools = getTools({}, [], registry); + const delegateTool = tools.find((t) => t.function.name === "DelegateToAgent"); + + assert.ok(delegateTool); + const description = delegateTool.function.description; + assert.ok(description.includes("my-agent-2")); + assert.ok(description.includes("Handles tasks with numbers (v2.0) & symbols")); + }); +}); diff --git a/packages/core/src/tests/skill-resolver.test.ts b/packages/core/src/tests/skill-resolver.test.ts new file mode 100644 index 0000000..ee38b57 --- /dev/null +++ b/packages/core/src/tests/skill-resolver.test.ts @@ -0,0 +1,328 @@ +import { afterEach, describe, test } from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { resolveAgentSkills } from "../agents/skill-resolver"; +import type { AgentManifest } from "../agents/agent-registry"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +function createTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +/** + * Helper to create a SKILL.md file in a skills directory. + */ +function createSkillMd( + skillsRoot: string, + skillName: string, + opts: { name?: string; description?: string; body?: string } = {} +): string { + const skillDir = path.join(skillsRoot, skillName); + fs.mkdirSync(skillDir, { recursive: true }); + const skillPath = path.join(skillDir, "SKILL.md"); + const frontmatter = [ + "---", + `name: ${opts.name ?? skillName}`, + `description: ${opts.description ?? `${skillName} skill`}`, + "---", + ].join("\n"); + const content = `${frontmatter}\n\n${opts.body ?? `# ${skillName}\n\nSkill instructions for ${skillName}.`}`; + fs.writeFileSync(skillPath, content, "utf8"); + return skillPath; +} + +/** + * Helper to create a minimal AgentManifest pointing to a temp directory. + */ +function makeManifest(opts: { agentDir: string; skills?: string[] }): AgentManifest { + const sourcePath = path.join(opts.agentDir, "AGENT.md"); + // Write a minimal AGENT.md so the directory structure is valid + fs.mkdirSync(opts.agentDir, { recursive: true }); + fs.writeFileSync(sourcePath, "---\nname: test-agent\n---\n\n# Test Agent\n", "utf8"); + return { + name: "test-agent", + description: "A test agent", + model: "default", + skills: opts.skills ?? [], + instructions: "# Test Agent", + sourcePath, + sourceRoot: "./.deepcode/agents", + }; +} + +// ============================================================================= +// Property 10: Skill resolution respects locality and deduplication +// ============================================================================= +// **Validates: Requirements 6.1, 6.2, 6.3, 6.4** +// +// For any agent manifest with a `skills` field listing N skill names and a local +// `skills/` directory containing some of those skills, `resolveAgentSkills` SHALL: +// (a) prefer local skills over global skills with the same name +// (b) load only the skills listed in the `skills` field +// (c) when the `skills` field is absent/empty, load all skills found in the +// local `skills/` directory + +describe("Property 10: Skill resolution respects locality and deduplication", () => { + test("(a) local skills take priority over same-named global skills", () => { + const baseDir = createTempDir("skill-resolver-local-priority-"); + const agentDir = path.join(baseDir, "agent"); + const localSkillsDir = path.join(agentDir, "skills"); + const globalRoot = path.join(baseDir, "global-skills"); + + // Create same skill in both local and global with different content + createSkillMd(localSkillsDir, "shared-skill", { + name: "shared-skill", + description: "Local version", + body: "# Local Shared Skill", + }); + createSkillMd(globalRoot, "shared-skill", { + name: "shared-skill", + description: "Global version", + body: "# Global Shared Skill", + }); + + const manifest = makeManifest({ agentDir, skills: ["shared-skill"] }); + const resolved = resolveAgentSkills({ + manifest, + globalSkillRoots: [globalRoot], + }); + + assert.equal(resolved.length, 1); + assert.equal(resolved[0]!.name, "shared-skill"); + // Content should come from local, not global + assert.ok(resolved[0]!.content.includes("Local version")); + assert.ok(resolved[0]!.path.includes(localSkillsDir)); + }); + + test("(b) when manifest.skills lists specific names, only those are loaded", () => { + const baseDir = createTempDir("skill-resolver-specific-"); + const agentDir = path.join(baseDir, "agent"); + const localSkillsDir = path.join(agentDir, "skills"); + + // Create multiple skills locally + createSkillMd(localSkillsDir, "skill-a", { name: "skill-a" }); + createSkillMd(localSkillsDir, "skill-b", { name: "skill-b" }); + createSkillMd(localSkillsDir, "skill-c", { name: "skill-c" }); + + // Manifest only requests skill-a and skill-c + const manifest = makeManifest({ agentDir, skills: ["skill-a", "skill-c"] }); + const resolved = resolveAgentSkills({ + manifest, + globalSkillRoots: [], + }); + + const names = resolved.map((s) => s.name).sort(); + assert.deepEqual(names, ["skill-a", "skill-c"]); + // skill-b should NOT be loaded + assert.equal( + resolved.find((s) => s.name === "skill-b"), + undefined + ); + }); + + test("(c) when manifest.skills is empty, all local skills are loaded", () => { + const baseDir = createTempDir("skill-resolver-all-local-"); + const agentDir = path.join(baseDir, "agent"); + const localSkillsDir = path.join(agentDir, "skills"); + + // Create several skills locally + createSkillMd(localSkillsDir, "alpha", { name: "alpha" }); + createSkillMd(localSkillsDir, "beta", { name: "beta" }); + createSkillMd(localSkillsDir, "gamma", { name: "gamma" }); + + // Empty skills array = load all local + const manifest = makeManifest({ agentDir, skills: [] }); + const resolved = resolveAgentSkills({ + manifest, + globalSkillRoots: [], + }); + + const names = resolved.map((s) => s.name).sort(); + assert.deepEqual(names, ["alpha", "beta", "gamma"]); + }); + + test("skills not found anywhere log a warning but don't fail", () => { + const baseDir = createTempDir("skill-resolver-missing-"); + const agentDir = path.join(baseDir, "agent"); + const localSkillsDir = path.join(agentDir, "skills"); + const globalRoot = path.join(baseDir, "global-skills"); + + // Only create one skill locally + createSkillMd(localSkillsDir, "existing-skill", { name: "existing-skill" }); + // Create empty global root + fs.mkdirSync(globalRoot, { recursive: true }); + + // Request a skill that doesn't exist anywhere + const manifest = makeManifest({ + agentDir, + skills: ["existing-skill", "nonexistent-skill"], + }); + const resolved = resolveAgentSkills({ + manifest, + globalSkillRoots: [globalRoot], + }); + + // Should still resolve the existing skill without throwing + assert.equal(resolved.length, 1); + assert.equal(resolved[0]!.name, "existing-skill"); + }); + + test("global roots are searched when local skills are missing", () => { + const baseDir = createTempDir("skill-resolver-global-fallback-"); + const agentDir = path.join(baseDir, "agent"); + const globalRoot1 = path.join(baseDir, "global-1"); + const globalRoot2 = path.join(baseDir, "global-2"); + + // No local skills directory + // Create skill only in global root + createSkillMd(globalRoot1, "global-only-skill", { + name: "global-only-skill", + description: "From global root 1", + }); + createSkillMd(globalRoot2, "another-global", { + name: "another-global", + description: "From global root 2", + }); + + const manifest = makeManifest({ + agentDir, + skills: ["global-only-skill", "another-global"], + }); + const resolved = resolveAgentSkills({ + manifest, + globalSkillRoots: [globalRoot1, globalRoot2], + }); + + const names = resolved.map((s) => s.name).sort(); + assert.deepEqual(names, ["another-global", "global-only-skill"]); + // Verify they come from global roots + assert.ok(resolved.find((s) => s.name === "global-only-skill")!.path.includes(globalRoot1)); + assert.ok(resolved.find((s) => s.name === "another-global")!.path.includes(globalRoot2)); + }); + + test("local skill wins deduplication even when skill exists in multiple global roots", () => { + const baseDir = createTempDir("skill-resolver-dedup-multi-"); + const agentDir = path.join(baseDir, "agent"); + const localSkillsDir = path.join(agentDir, "skills"); + const globalRoot1 = path.join(baseDir, "global-1"); + const globalRoot2 = path.join(baseDir, "global-2"); + + // Same skill in local, global-1, and global-2 + createSkillMd(localSkillsDir, "common-skill", { + name: "common-skill", + description: "Local version", + body: "# Local", + }); + createSkillMd(globalRoot1, "common-skill", { + name: "common-skill", + description: "Global-1 version", + body: "# Global 1", + }); + createSkillMd(globalRoot2, "common-skill", { + name: "common-skill", + description: "Global-2 version", + body: "# Global 2", + }); + + const manifest = makeManifest({ agentDir, skills: ["common-skill"] }); + const resolved = resolveAgentSkills({ + manifest, + globalSkillRoots: [globalRoot1, globalRoot2], + }); + + assert.equal(resolved.length, 1); + assert.ok(resolved[0]!.content.includes("Local version")); + assert.ok(resolved[0]!.path.includes(localSkillsDir)); + }); + + test("first global root wins when skill not found locally", () => { + const baseDir = createTempDir("skill-resolver-global-priority-"); + const agentDir = path.join(baseDir, "agent"); + const globalRoot1 = path.join(baseDir, "global-1"); + const globalRoot2 = path.join(baseDir, "global-2"); + + // No local skills dir + // Same skill in both global roots with different content + createSkillMd(globalRoot1, "dup-skill", { + name: "dup-skill", + description: "From first global root", + }); + createSkillMd(globalRoot2, "dup-skill", { + name: "dup-skill", + description: "From second global root", + }); + + const manifest = makeManifest({ agentDir, skills: ["dup-skill"] }); + const resolved = resolveAgentSkills({ + manifest, + globalSkillRoots: [globalRoot1, globalRoot2], + }); + + assert.equal(resolved.length, 1); + assert.ok(resolved[0]!.content.includes("From first global root")); + assert.ok(resolved[0]!.path.includes(globalRoot1)); + }); + + test("skill name is derived from frontmatter name field when present", () => { + const baseDir = createTempDir("skill-resolver-frontmatter-name-"); + const agentDir = path.join(baseDir, "agent"); + const localSkillsDir = path.join(agentDir, "skills"); + + // Directory name differs from frontmatter name + createSkillMd(localSkillsDir, "dir-name-skill", { + name: "actual-name", + description: "Has frontmatter name", + }); + + // When skills field is empty, all local skills are loaded by directory scan + const manifest = makeManifest({ agentDir, skills: [] }); + const resolved = resolveAgentSkills({ + manifest, + globalSkillRoots: [], + }); + + // The resolved skill name comes from frontmatter + assert.equal(resolved.length, 1); + assert.equal(resolved[0]!.name, "actual-name"); + }); + + test("empty local skills directory with empty skills field results in no skills", () => { + const baseDir = createTempDir("skill-resolver-empty-"); + const agentDir = path.join(baseDir, "agent"); + const localSkillsDir = path.join(agentDir, "skills"); + fs.mkdirSync(localSkillsDir, { recursive: true }); + + const manifest = makeManifest({ agentDir, skills: [] }); + const resolved = resolveAgentSkills({ + manifest, + globalSkillRoots: [], + }); + + assert.equal(resolved.length, 0); + }); + + test("no local skills directory with empty skills field results in no skills", () => { + const baseDir = createTempDir("skill-resolver-no-local-dir-"); + const agentDir = path.join(baseDir, "agent"); + // Don't create the skills/ subdirectory + + const manifest = makeManifest({ agentDir, skills: [] }); + const resolved = resolveAgentSkills({ + manifest, + globalSkillRoots: [], + }); + + assert.equal(resolved.length, 0); + }); +}); diff --git a/packages/core/src/tests/sub-agent-session.test.ts b/packages/core/src/tests/sub-agent-session.test.ts new file mode 100644 index 0000000..5e45834 --- /dev/null +++ b/packages/core/src/tests/sub-agent-session.test.ts @@ -0,0 +1,575 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { SubAgentSession, SUB_AGENT_NON_INTERACTIVE_NOTE } from "../agents/sub-agent-session"; +import type { SubAgentOptions } from "../agents/sub-agent-session"; +import type { AgentManifest } from "../agents/agent-registry"; +import type { CreateOpenAIClient } from "../common/tool-types"; +import type { ToolExecutor } from "../tools/executor"; + +// ============================================================================= +// Property 9: Sub-agent session isolation +// ============================================================================= +// **Validates: Requirements 5.1, 5.6** +// +// For any sub-agent execution, the sub-agent's LLM context SHALL contain zero +// messages from the parent session's history, and upon completion, only the final +// assistant response text SHALL be returned to the parent — no intermediate +// messages, tool call records, or internal state SHALL leak. + +describe("Property 9: Sub-agent session isolation", () => { + /** + * Helper: Create a mock manifest for testing. + */ + function makeManifest(overrides: Partial = {}): AgentManifest { + return { + name: "test-agent", + description: "A test sub-agent", + model: "test-model", + skills: [], + instructions: "You are a helpful test agent.", + sourcePath: "/fake/path/AGENT.md", + sourceRoot: "./.deepcode/agents", + ...overrides, + }; + } + + /** + * Helper: Create a mock ToolExecutor that records calls and returns canned results. + */ + function makeMockToolExecutor(results: Array<{ toolCallId: string; content: string }> = []): ToolExecutor { + return { + executeToolCalls: async (_sessionId: string, toolCalls: unknown[]) => { + // Return canned results matching the tool call IDs + const calls = toolCalls as Array<{ id: string; function: { name: string; arguments: string } }>; + return calls.map((tc, i) => ({ + toolCallId: tc.id, + content: results[i]?.content ?? JSON.stringify({ ok: true, name: tc.function.name, output: "done" }), + result: { ok: true, name: tc.function.name, output: "done" }, + })); + }, + } as unknown as ToolExecutor; + } + + /** + * Scenario 1: Simple response (no tool calls). + * Verifies that the LLM receives only a system message and a user message — + * zero messages from any parent session history. + */ + test("sub-agent LLM context starts fresh with only system + user messages (no parent history)", async () => { + const capturedMessages: Array> = []; + + const mockCreateOpenAIClient: CreateOpenAIClient = () => ({ + client: { + chat: { + completions: { + create: async (body: Record) => { + // Capture the messages sent to the LLM + capturedMessages.push( + (body.messages as Array<{ role: string; content?: string | null }>).map((m) => ({ + role: m.role, + content: m.content ?? null, + })) + ); + // Return a simple text response (no tool calls) + return { + choices: [ + { + message: { + role: "assistant", + content: "Final response from sub-agent", + tool_calls: undefined, + }, + finish_reason: "stop", + }, + ], + }; + }, + }, + }, + } as unknown, + model: "test-model", + baseURL: undefined, + thinkingEnabled: false, + debugLogEnabled: false, + }); + + const manifest = makeManifest(); + const toolExecutor = makeMockToolExecutor(); + + const session = new SubAgentSession({ + manifest, + task: "Do something specific", + projectRoot: "/fake/project", + parentModel: "parent-model", + createOpenAIClient: mockCreateOpenAIClient, + resolvedSkillPrompts: [], + toolExecutor, + } as SubAgentOptions); + + const result = await session.execute(); + + // Verify successful execution + assert.equal(result.ok, true); + + // Verify that exactly one LLM call was made + assert.equal(capturedMessages.length, 1); + + // Verify the messages sent to LLM contain ONLY system + user (no parent history) + const messages = capturedMessages[0]!; + assert.equal(messages.length, 2, "Sub-agent should start with exactly 2 messages: system + user"); + assert.equal(messages[0]!.role, "system"); + assert.equal(messages[0]!.content, `${manifest.instructions}\n\n${SUB_AGENT_NON_INTERACTIVE_NOTE}`); + assert.equal(messages[1]!.role, "user"); + assert.equal(messages[1]!.content, "Do something specific"); + }); + + /** + * Scenario 2: Multi-round with tool calls. + * Verifies that intermediate tool calls and results stay internal + * and only the final text response is returned to the parent. + */ + test("only final assistant text is returned; intermediate tool calls do not leak to parent", async () => { + let callCount = 0; + const capturedMessages: Array> = []; + + const mockCreateOpenAIClient: CreateOpenAIClient = () => ({ + client: { + chat: { + completions: { + create: async (body: Record) => { + callCount++; + // Capture messages for inspection + capturedMessages.push( + (body.messages as Array<{ role: string; content?: string | null; tool_calls?: unknown }>).map((m) => ({ + role: m.role, + content: m.content ?? null, + tool_calls: (m as { tool_calls?: unknown }).tool_calls, + })) + ); + + if (callCount === 1) { + // First call: return a tool call (simulating intermediate work) + return { + choices: [ + { + message: { + role: "assistant", + content: null, + tool_calls: [ + { + id: "tool-call-001", + type: "function", + function: { + name: "bash", + arguments: JSON.stringify({ command: "echo hello" }), + }, + }, + ], + }, + finish_reason: "tool_calls", + }, + ], + }; + } + + // Second call: final text response (no more tool calls) + return { + choices: [ + { + message: { + role: "assistant", + content: "The task is complete. Here is the result.", + tool_calls: undefined, + }, + finish_reason: "stop", + }, + ], + }; + }, + }, + }, + } as unknown, + model: "test-model", + baseURL: undefined, + thinkingEnabled: false, + debugLogEnabled: false, + }); + + const manifest = makeManifest(); + const toolExecutor = makeMockToolExecutor([ + { toolCallId: "tool-call-001", content: JSON.stringify({ ok: true, name: "bash", output: "hello\n" }) }, + ]); + + const session = new SubAgentSession({ + manifest, + task: "Run a command and tell me the result", + projectRoot: "/fake/project", + parentModel: "parent-model", + createOpenAIClient: mockCreateOpenAIClient, + resolvedSkillPrompts: [], + toolExecutor, + } as SubAgentOptions); + + const result = await session.execute(); + + // Verify successful execution + assert.equal(result.ok, true); + + // The returned response is ONLY the final text — no tool call records, no intermediate messages + assert.equal(result.response, "The task is complete. Here is the result."); + + // The result should not contain any trace of intermediate tool calls + assert.ok(!result.response.includes("tool-call-001"), "Tool call ID should not leak"); + assert.ok(!result.response.includes("echo hello"), "Tool arguments should not leak"); + assert.ok(!result.response.includes("hello\\n"), "Tool output should not leak"); + + // Verify the LLM was called twice (tool call round + final response) + assert.equal(callCount, 2); + + // On the second LLM call, messages include the tool call exchange + // but this is internal to the session — it does NOT appear in the result + const secondCallMessages = capturedMessages[1]!; + // Second call should have: system, user, assistant (with tool_calls), tool (result) + assert.equal(secondCallMessages.length, 4); + assert.equal(secondCallMessages[0]!.role, "system"); + assert.equal(secondCallMessages[1]!.role, "user"); + assert.equal(secondCallMessages[2]!.role, "assistant"); + assert.equal(secondCallMessages[3]!.role, "tool"); + }); + + /** + * Scenario 3: Multiple tool call rounds. + * Verifies that even with many intermediate rounds, the parent only + * receives the final text response — no internal state leaks. + */ + test("multiple tool call rounds produce only final text in result (no state leakage)", async () => { + let callCount = 0; + + const mockCreateOpenAIClient: CreateOpenAIClient = () => ({ + client: { + chat: { + completions: { + create: async () => { + callCount++; + + if (callCount <= 3) { + // Three rounds of tool calls + return { + choices: [ + { + message: { + role: "assistant", + content: null, + tool_calls: [ + { + id: `tool-call-${callCount}`, + type: "function", + function: { + name: "read", + arguments: JSON.stringify({ file_path: `/file-${callCount}.txt` }), + }, + }, + ], + }, + finish_reason: "tool_calls", + }, + ], + }; + } + + // Final response after 3 tool rounds + return { + choices: [ + { + message: { + role: "assistant", + content: "All files have been read successfully.", + tool_calls: undefined, + }, + finish_reason: "stop", + }, + ], + }; + }, + }, + }, + } as unknown, + model: "test-model", + baseURL: undefined, + thinkingEnabled: false, + debugLogEnabled: false, + }); + + const manifest = makeManifest(); + const toolExecutor = makeMockToolExecutor(); + + const session = new SubAgentSession({ + manifest, + task: "Read multiple files", + projectRoot: "/fake/project", + parentModel: "parent-model", + createOpenAIClient: mockCreateOpenAIClient, + resolvedSkillPrompts: [], + toolExecutor, + } as SubAgentOptions); + + const result = await session.execute(); + + assert.equal(result.ok, true); + // Only the final text is returned + assert.equal(result.response, "All files have been read successfully."); + + // Verify no internal state leaked + assert.ok(!result.response.includes("tool-call-"), "No tool call IDs in response"); + assert.ok(!result.response.includes("file_path"), "No tool arguments in response"); + + // 3 tool rounds + 1 final response = 4 LLM calls + assert.equal(callCount, 4); + + // SubAgentResult only has ok, response, and optionally error + const keys = Object.keys(result); + assert.ok(keys.includes("ok")); + assert.ok(keys.includes("response")); + // No fields like "messages", "toolCalls", "history" should exist + assert.ok(!keys.includes("messages"), "No messages field should leak"); + assert.ok(!keys.includes("toolCalls"), "No toolCalls field should leak"); + assert.ok(!keys.includes("history"), "No history field should leak"); + }); + + /** + * Scenario 4: Error case — sub-agent still isolates internal state on failure. + * Even when an error occurs, only the error message is returned, not internal context. + */ + test("on error, only error description is returned — no internal messages leak", async () => { + const mockCreateOpenAIClient: CreateOpenAIClient = () => ({ + client: { + chat: { + completions: { + create: async () => { + throw new Error("Network timeout connecting to LLM"); + }, + }, + }, + } as unknown, + model: "test-model", + baseURL: undefined, + thinkingEnabled: false, + debugLogEnabled: false, + }); + + const manifest = makeManifest(); + const toolExecutor = makeMockToolExecutor(); + + const session = new SubAgentSession({ + manifest, + task: "Do something that will fail", + projectRoot: "/fake/project", + parentModel: "parent-model", + createOpenAIClient: mockCreateOpenAIClient, + resolvedSkillPrompts: [], + toolExecutor, + } as SubAgentOptions); + + const result = await session.execute(); + + assert.equal(result.ok, false); + assert.equal(result.response, ""); + // Error should describe what happened, but not leak messages or context + assert.ok(result.error?.includes("Network timeout"), "Error should describe the failure"); + // No internal state + const keys = Object.keys(result); + assert.ok(!keys.includes("messages"), "No messages field should leak on error"); + assert.ok(!keys.includes("history"), "No history field should leak on error"); + }); + + /** + * Scenario 5: Verify session messages never include simulated "parent" messages. + * Even if we construct a sub-agent after a parent has many messages, + * the sub-agent's first LLM call still only has system + user. + */ + test("sub-agent context is completely independent of any parent message history", async () => { + let capturedFirstCallMessages: Array<{ role: string }> = []; + + const mockCreateOpenAIClient: CreateOpenAIClient = () => ({ + client: { + chat: { + completions: { + create: async (body: Record) => { + if (capturedFirstCallMessages.length === 0) { + capturedFirstCallMessages = (body.messages as Array<{ role: string }>).map((m) => ({ + role: m.role, + })); + } + return { + choices: [ + { + message: { + role: "assistant", + content: "Done", + tool_calls: undefined, + }, + finish_reason: "stop", + }, + ], + }; + }, + }, + }, + } as unknown, + model: "test-model", + baseURL: undefined, + thinkingEnabled: false, + debugLogEnabled: false, + }); + + const manifest = makeManifest({ instructions: "Sub-agent system prompt" }); + const toolExecutor = makeMockToolExecutor(); + + // Simulate: parent session has had extensive conversation history + // (in reality, the SubAgentSession constructor doesn't receive parent messages at all) + const session = new SubAgentSession({ + manifest, + task: "A fresh task for the sub-agent", + projectRoot: "/fake/project", + parentModel: "parent-model", + createOpenAIClient: mockCreateOpenAIClient, + resolvedSkillPrompts: ["Extra skill instructions here."], + toolExecutor, + } as SubAgentOptions); + + const result = await session.execute(); + + assert.equal(result.ok, true); + assert.equal(result.response, "Done"); + + // The first LLM call should have exactly 2 messages: system + user + assert.equal(capturedFirstCallMessages.length, 2); + assert.equal(capturedFirstCallMessages[0]!.role, "system"); + assert.equal(capturedFirstCallMessages[1]!.role, "user"); + + // No "assistant" or "tool" messages from a parent session + const hasParentMessages = capturedFirstCallMessages.some((m) => m.role === "assistant" || m.role === "tool"); + assert.equal(hasParentMessages, false, "No parent assistant/tool messages should be in sub-agent context"); + }); +}); + +// ============================================================================= +// AskUserQuestion exclusion and non-interactive guidance +// ============================================================================= +// Sub-agent sessions are single-shot and non-interactive: there is no mechanism +// to pause execution and route a question to a real user. The AskUserQuestion +// tool must not be offered to the sub-agent LLM, and the system prompt must +// explain this limitation so the sub-agent states assumptions/open questions +// in its final text response instead. + +describe("Sub-agent non-interactive constraints", () => { + function makeManifest(overrides: Partial = {}): AgentManifest { + return { + name: "test-agent", + description: "A test sub-agent", + model: "test-model", + skills: [], + instructions: "You are a helpful test agent.", + sourcePath: "/fake/path/AGENT.md", + sourceRoot: "./.deepcode/agents", + ...overrides, + }; + } + + function makeMockToolExecutor(): ToolExecutor { + return { + executeToolCalls: async (_sessionId: string, toolCalls: unknown[]) => { + const calls = toolCalls as Array<{ id: string; function: { name: string; arguments: string } }>; + return calls.map((tc) => ({ + toolCallId: tc.id, + content: JSON.stringify({ ok: true, name: tc.function.name, output: "done" }), + result: { ok: true, name: tc.function.name, output: "done" }, + })); + }, + } as unknown as ToolExecutor; + } + + test("buildSystemPrompt() always appends the non-interactive note", () => { + const manifest = makeManifest(); + const session = new SubAgentSession({ + manifest, + task: "irrelevant", + projectRoot: "/fake/project", + parentModel: "parent-model", + createOpenAIClient: (() => ({})) as unknown as CreateOpenAIClient, + resolvedSkillPrompts: [], + toolExecutor: makeMockToolExecutor(), + } as SubAgentOptions); + + const prompt = session.buildSystemPrompt(); + assert.equal(prompt, `${manifest.instructions}\n\n${SUB_AGENT_NON_INTERACTIVE_NOTE}`); + assert.ok(prompt.includes("non-interactive")); + }); + + test("buildSystemPrompt() with no instructions and no skills returns only the non-interactive note", () => { + const manifest = makeManifest({ instructions: "" }); + const session = new SubAgentSession({ + manifest, + task: "irrelevant", + projectRoot: "/fake/project", + parentModel: "parent-model", + createOpenAIClient: (() => ({})) as unknown as CreateOpenAIClient, + resolvedSkillPrompts: [], + toolExecutor: makeMockToolExecutor(), + } as SubAgentOptions); + + const prompt = session.buildSystemPrompt(); + assert.equal(prompt, SUB_AGENT_NON_INTERACTIVE_NOTE); + }); + + test("AskUserQuestion tool is excluded from the tools list passed to the sub-agent LLM", async () => { + let capturedTools: Array<{ function: { name: string } }> | undefined; + + const mockCreateOpenAIClient: CreateOpenAIClient = () => ({ + client: { + chat: { + completions: { + create: async (body: Record) => { + capturedTools = body.tools as Array<{ function: { name: string } }> | undefined; + return { + choices: [ + { + message: { + role: "assistant", + content: "Done", + tool_calls: undefined, + }, + finish_reason: "stop", + }, + ], + }; + }, + }, + }, + } as unknown, + model: "test-model", + baseURL: undefined, + thinkingEnabled: false, + debugLogEnabled: false, + }); + + const manifest = makeManifest(); + const session = new SubAgentSession({ + manifest, + task: "Do something", + projectRoot: "/fake/project", + parentModel: "parent-model", + createOpenAIClient: mockCreateOpenAIClient, + resolvedSkillPrompts: [], + toolExecutor: makeMockToolExecutor(), + } as SubAgentOptions); + + const result = await session.execute(); + + assert.equal(result.ok, true); + assert.ok(capturedTools, "Tools should have been passed to the LLM call"); + const toolNames = capturedTools!.map((t) => t.function.name); + assert.ok(!toolNames.includes("AskUserQuestion"), "AskUserQuestion must not be offered to sub-agent LLM calls"); + assert.ok(toolNames.length > 0, "Other built-in tools should still be present"); + }); +}); diff --git a/packages/core/src/tools/executor.ts b/packages/core/src/tools/executor.ts index 6af57c4..962541e 100644 --- a/packages/core/src/tools/executor.ts +++ b/packages/core/src/tools/executor.ts @@ -5,6 +5,9 @@ import { handleReadTool } from "./read-handler"; import { handleUpdatePlanTool } from "./update-plan-handler"; import { handleWebSearchTool } from "./web-search-handler"; import { handleWriteTool } from "./write-handler"; +import { createDelegateToAgentHandler } from "../agents/delegate-handler"; +import type { AgentRegistry } from "../agents/agent-registry"; +import type { SubAgentProgressEvent } from "../agents/sub-agent-session"; import type { McpManager } from "../mcp/mcp-manager"; import type { CreateOpenAIClient, @@ -76,6 +79,35 @@ export class ToolExecutor { return executions; } + registerAgentHandler( + agentRegistry: AgentRegistry, + parentModel: string, + globalSkillRoots: string[] = [], + onProgress?: (sessionId: string, event: SubAgentProgressEvent) => void + ): void { + // Built per tool-call (not once at registration) so each invocation's + // progress events can be tagged with the sessionId that triggered it — + // ToolExecutionContext.sessionId is only known at call time. + this.toolHandlers.set("DelegateToAgent", async (args, context) => { + const handler = createDelegateToAgentHandler({ + agentRegistry, + projectRoot: this.projectRoot, + parentModel, + createOpenAIClient: this.createOpenAIClient!, + toolExecutor: this, + globalSkillRoots, + onProgress: onProgress ? (event) => onProgress(context.sessionId, event) : undefined, + }); + const result = await handler(args); + return { + ok: result.ok, + name: "DelegateToAgent", + output: result.output, + error: result.error, + }; + }); + } + private registerToolHandlers(): void { this.toolHandlers.set("bash", handleBashTool); this.toolHandlers.set("read", handleReadTool);