diff --git a/AGENTS.md b/AGENTS.md index 530c783..4263b49 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,8 @@ This repo implements the `subagent` pi extension in `index.ts`. `~/.pi/subagent-sessions//_.jsonl`. - Omitting `sessionId` creates a new UUID-backed session. - Passing `sessionId` resumes the same agent/cwd child session. +- A prompt's `.md` body **replaces** pi's default system prompt (passed via `--system-prompt`). It is the entire system prompt; do not write it as an appendage. Pi still tacks on AGENTS.md/skills/date/cwd. +- Retries reuse the same session and append a new user message nudging the child to finalize. The system prompt is stable across attempts (cache-friendly). ## Validation diff --git a/index.ts b/index.ts index c994c2b..dd32039 100644 --- a/index.ts +++ b/index.ts @@ -1,18 +1,17 @@ // Subagent Extension - Registers a tool for delegating work to prompt-defined // subagents with constrained tool permissions. import { randomUUID } from "node:crypto"; -import * as fs from "node:fs"; import * as path from "node:path"; import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { Type } from "typebox"; -import { FINALIZE_TOOL_NAME, MAX_FINALIZE_RETRIES } from "./src/constants.ts"; -import { formatPromptList, formatSubagentContent } from "./src/format.ts"; +import { FINALIZE_TOOL_NAME } from "./src/constants.ts"; +import { formatPromptList, toToolResult } from "./src/format.ts"; import { discoverPrompts } from "./src/prompts.ts"; import { renderSubagentCall, renderSubagentResult } from "./src/render.ts"; -import { runAgent } from "./src/runner.ts"; -import { getSubagentSessionPath } from "./src/session.ts"; -import { FinalizeStatus, type SubagentResult } from "./src/types.ts"; -import { resolveTools, SubagentParams } from "./src/tools.ts"; +import { runAgentWithRetries } from "./src/runner.ts"; +import { SubagentParams } from "./src/tools.ts"; +import { FinalizeStatus } from "./src/types.ts"; +import { validateAgent } from "./src/validate.ts"; export default function (pi: ExtensionAPI) { if (process.env.PI_SUBAGENT_CHILD === "1") { @@ -49,164 +48,33 @@ export default function (pi: ExtensionAPI) { "Delegate tasks to subagents by name. Subagent prompts live in prompts/*.md and define approved_tools/denied_tools.", parameters: SubagentParams, - async execute(_toolCallId, params, signal, _onUpdate, ctx) { - const prompts = discoverPrompts(); - const agent = prompts.find((prompt) => prompt.name === params.agent); - if (!agent) { + async execute(_toolCallId, params, signal, onUpdate, ctx) { + // Validate Agent + const validation = validateAgent( + params.agent, + discoverPrompts(), + pi.getActiveTools(), + ); + if (!validation.ok) { return { - content: [ - { - type: "text", - text: `Unknown subagent: ${params.agent}. Available: ${formatPromptList(prompts)}`, - }, - ], - details: { available: prompts.map((prompt) => prompt.name) }, + content: [{ type: "text", text: validation.text }], + details: validation.details, isError: true, }; } - if (agent.approvedTools.length > 0 && agent.deniedTools.length > 0) { - return { - content: [ - { - type: "text", - text: - `Invalid subagent config for ${agent.name}: define either approved_tools/allowed_tools ` + - "or denied_tools, not both.", - }, - ], - details: { agent: agent.name, filePath: agent.filePath }, - isError: true, - }; - } + // Run Subagent + const result = await runAgentWithRetries({ + cwd: path.resolve(ctx.cwd, params.cwd ?? "."), + agent: validation.agent, + task: params.task, + tools: validation.tools, + sessionId: params.sessionId ?? randomUUID(), + signal, + onUpdate, + }); - const activeTools = pi.getActiveTools(); - const tools = resolveTools(agent, activeTools); - if (tools.length === 0) { - return { - content: [ - { - type: "text", - text: `Subagent ${agent.name} has no approved tools after applying denied_tools.`, - }, - ], - details: { - agent: agent.name, - approvedTools: agent.approvedTools, - deniedTools: agent.deniedTools, - }, - isError: true, - }; - } - - const cwd = path.resolve(ctx.cwd, params.cwd ?? "."); - const sessionId = params.sessionId ?? randomUUID(); - const sessionPath = getSubagentSessionPath(cwd, agent.name, sessionId); - await fs.promises.mkdir(path.dirname(sessionPath), { recursive: true }); - - let result: SubagentResult | null = null; - for ( - let retryCount = 0; - retryCount <= MAX_FINALIZE_RETRIES; - retryCount += 1 - ) { - const task = - retryCount === 0 - ? `Task: ${params.task}` - : [ - "Your previous response did not finalize correctly.", - `If you are finished, call ${FINALIZE_TOOL_NAME}.`, - "If you are not finished, continue the original task using available tools as needed.", - `Original task: ${params.task}`, - ].join("\n\n"); - - result = await runAgent( - cwd, - agent, - task, - tools, - sessionId, - sessionPath, - signal, - _onUpdate, - ); - - if (result.finalized) break; - if (result.exitCode !== 0 || result.error) break; - } - - if (!result) { - return { - content: [ - { - type: "text", - text: formatSubagentContent( - FinalizeStatus.ERROR, - sessionId, - undefined, - "Subagent did not run.", - ), - }, - ], - details: { sessionId }, - isError: true, - }; - } - - if (!result.finalized) { - const fallback = - result.error || - result.stderr || - `Subagent did not call ${FINALIZE_TOOL_NAME}.`; - return { - content: [ - { - type: "text", - text: formatSubagentContent( - FinalizeStatus.ERROR, - sessionId, - undefined, - fallback, - ), - }, - ], - details: { sessionId }, - isError: true, - }; - } - - if (result.finalized.status === FinalizeStatus.ERROR) { - return { - content: [ - { - type: "text", - text: formatSubagentContent( - FinalizeStatus.ERROR, - sessionId, - result.finalized.result, - result.finalized.error ?? "Subagent failed.", - ), - }, - ], - details: { sessionId, finalized: result.finalized }, - isError: true, - }; - } - - return { - content: [ - { - type: "text", - text: formatSubagentContent( - FinalizeStatus.SUCCESS, - sessionId, - result.finalized.result, - ), - }, - ], - details: { sessionId, finalized: result.finalized }, - isError: false, - }; + return toToolResult(result); }, renderCall(args, theme) { diff --git a/src/format.ts b/src/format.ts index a4802a9..a235d17 100644 --- a/src/format.ts +++ b/src/format.ts @@ -1,6 +1,72 @@ +import { FINALIZE_TOOL_NAME } from "./constants.ts"; import type { PromptConfig, SubagentResult } from "./types.ts"; import { FinalizeStatus } from "./types.ts"; +// To Tool Result - Translate a completed SubagentResult into the tool response +// shape. Three outcomes: missing finalize, finalize error, finalize success. +export function toToolResult(result: SubagentResult): { + content: { type: "text"; text: string }[]; + details: Record; + isError: boolean; +} { + const { sessionId, finalized } = result; + + if (!finalized) { + const fallback = + result.error || + result.stderr || + `Subagent did not call ${FINALIZE_TOOL_NAME}.`; + return { + content: [ + { + type: "text", + text: formatSubagentContent( + FinalizeStatus.ERROR, + sessionId, + undefined, + fallback, + ), + }, + ], + details: { sessionId }, + isError: true, + }; + } + + if (finalized.status === FinalizeStatus.ERROR) { + return { + content: [ + { + type: "text", + text: formatSubagentContent( + FinalizeStatus.ERROR, + sessionId, + finalized.result, + finalized.error ?? "Subagent failed.", + ), + }, + ], + details: { sessionId, finalized }, + isError: true, + }; + } + + return { + content: [ + { + type: "text", + text: formatSubagentContent( + FinalizeStatus.SUCCESS, + sessionId, + finalized.result, + ), + }, + ], + details: { sessionId, finalized }, + isError: false, + }; +} + // Format Tool Content - Some clients hide structured details from the model. export function formatSubagentContent( status: FinalizeStatus, @@ -51,6 +117,10 @@ export function formatStatusText(result: SubagentResult): string { `Tool Calls: ${result.status.toolCallCount} total, ${result.status.activeToolCalls} active`, ]; + if (result.status.attempt > 1) { + lines.push(`Attempt: ${result.status.attempt}/${result.status.maxAttempts}`); + } + if (result.status.recentToolCalls.length > 0) { lines.push("", "Tool Calls - Last 3:"); for (const call of result.status.recentToolCalls.slice(0, 3)) { diff --git a/src/render.ts b/src/render.ts index bf022b6..5ab358e 100644 --- a/src/render.ts +++ b/src/render.ts @@ -37,11 +37,15 @@ export function renderSubagentSummary( const latestText = latest ? ` · ${latest.toolName}${latest.summary ? ` ${latest.summary}` : ""}` : ""; + const attemptText = + result.status.attempt > 1 + ? ` · try ${result.status.attempt}/${result.status.maxAttempts}` + : ""; const text = theme.fg(statusColor, statusText) + theme.fg( "muted", - ` · ${result.status.toolCallCount} total, ${result.status.activeToolCalls} active${latestText}`, + ` · ${result.status.toolCallCount} total, ${result.status.activeToolCalls} active${latestText}${attemptText}`, ); return new Text(text, 0, 0); } @@ -65,6 +69,15 @@ export function renderSubagentStatus( )}`, ]; + if (result.status.attempt > 1) { + lines.push( + `${theme.fg("dim", "Attempt:")} ${theme.fg( + "warning", + `${result.status.attempt}/${result.status.maxAttempts}`, + )}`, + ); + } + if (result.status.recentToolCalls.length > 0) { lines.push("", theme.fg("dim", "Tool Calls - Last 3:")); for (const call of result.status.recentToolCalls.slice(0, 3)) { diff --git a/src/runner.ts b/src/runner.ts index 8a55174..1f84370 100644 --- a/src/runner.ts +++ b/src/runner.ts @@ -3,9 +3,14 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { withFileMutationQueue } from "@mariozechner/pi-coding-agent"; -import { EXTENSION_ENTRY, FINALIZE_TOOL_NAME } from "./constants.ts"; +import { + EXTENSION_ENTRY, + FINALIZE_TOOL_NAME, + MAX_FINALIZE_RETRIES, +} from "./constants.ts"; import { formatStatusText, formatToolArgs } from "./format.ts"; import { buildSubagentPrompt } from "./prompts.ts"; +import { getSubagentSessionPath } from "./session.ts"; import type { PromptConfig, SubagentResult, @@ -13,6 +18,21 @@ import type { } from "./types.ts"; import { validateFinalizePayload } from "./tools.ts"; +export interface RunAgentOptions { + cwd: string; + agent: PromptConfig; + task: string; + tools: string[]; + sessionId: string; + signal?: AbortSignal; + onUpdate?: OnUpdate; +} + +type OnUpdate = (partial: { + content: { type: "text"; text: string }[]; + details: SubagentResult; +}) => void; + // Write Prompt - pi accepts appended system prompts via file path. async function writePromptToTempFile( agentName: string, @@ -44,23 +64,22 @@ function getPiInvocation(args: string[]): { command: string; args: string[] } { return { command: "pi", args }; } -// Run Agent - Spawn an isolated pi process in JSON mode and collect final text. -export async function runAgent( - cwd: string, +// Run State - Mutable bookkeeping for a single subagent run. +interface RunState { + result: SubagentResult; + activeToolIds: Set; + emitUpdate: () => void; +} + +function createRunState( agent: PromptConfig, task: string, tools: string[], sessionId: string, - sessionPath: string, - signal?: AbortSignal, - onUpdate?: (partial: { - content: { type: "text"; text: string }[]; - details: SubagentResult; - }) => void, -): Promise { - let tmpDir: string | null = null; - let tmpPromptPath: string | null = null; - + attempt: number, + maxAttempts: number, + onUpdate?: OnUpdate, +): RunState { const result: SubagentResult = { agent: agent.name, task, @@ -74,11 +93,11 @@ export async function runAgent( toolCallCount: 0, activeToolCalls: 0, recentToolCalls: [], + attempt, + maxAttempts, }, }; - const activeToolIds = new Set(); - const emitUpdate = () => { onUpdate?.({ content: [{ type: "text", text: formatStatusText(result) }], @@ -92,154 +111,242 @@ export async function runAgent( }); }; - const rememberToolCall = (activity: SubagentToolActivity) => { - const existing = result.status.recentToolCalls.findIndex( - (call) => call.id === activity.id, - ); - if (existing >= 0) result.status.recentToolCalls.splice(existing, 1); - result.status.recentToolCalls.unshift(activity); - result.status.recentToolCalls = result.status.recentToolCalls.slice(0, 3); - }; + return { result, activeToolIds: new Set(), emitUpdate }; +} - try { - const args = [ - "--mode", - "json", - "-p", - "--session", - sessionPath, - "--extension", - EXTENSION_ENTRY, - ]; - args.push("--tools", tools.join(",")); +function rememberToolCall(state: RunState, activity: SubagentToolActivity) { + const calls = state.result.status.recentToolCalls; + const existing = calls.findIndex((call) => call.id === activity.id); + if (existing >= 0) calls.splice(existing, 1); + calls.unshift(activity); + state.result.status.recentToolCalls = calls.slice(0, 3); +} - const prompt = buildSubagentPrompt(agent); - if (prompt) { - const tmp = await writePromptToTempFile(agent.name, prompt); - tmpDir = tmp.dir; - tmpPromptPath = tmp.filePath; - args.push("--append-system-prompt", tmpPromptPath); - } +// Handle Event - Apply one parsed JSON event from the child to run state. +function handleEvent(state: RunState, event: any) { + const { result, activeToolIds, emitUpdate } = state; - args.push(task); + if (event.type === "message_start" && event.message?.role === "assistant") { + result.status.state = "thinking"; emitUpdate(); + return; + } - const exitCode = await new Promise((resolve) => { - const invocation = getPiInvocation(args); - const proc = spawn(invocation.command, invocation.args, { - cwd, - env: { ...process.env, PI_SUBAGENT_CHILD: "1" }, - shell: false, - stdio: ["ignore", "pipe", "pipe"], - }); + if (event.type === "tool_execution_start") { + const id = String(event.toolCallId ?? result.status.toolCallCount + 1); + const toolName = String(event.toolName ?? "tool"); + if (toolName === FINALIZE_TOOL_NAME) { + result.finalized = validateFinalizePayload(event.args) ?? undefined; + } + activeToolIds.add(id); + result.status.state = "running"; + result.status.toolCallCount += 1; + result.status.activeToolCalls = activeToolIds.size; + rememberToolCall(state, { + id, + toolName, + summary: formatToolArgs(event.args), + status: "running", + }); + emitUpdate(); + return; + } - let buffer = ""; - let aborted = false; + if (event.type === "tool_execution_end") { + const id = String(event.toolCallId ?? ""); + if (id) activeToolIds.delete(id); + result.status.activeToolCalls = activeToolIds.size; + const previous = result.status.recentToolCalls.find( + (call) => call.id === id, + ); + rememberToolCall(state, { + id, + toolName: String(event.toolName ?? previous?.toolName ?? "tool"), + summary: previous?.summary ?? "", + status: event.isError ? "error" : "done", + }); + emitUpdate(); + return; + } - const processLine = (line: string) => { - if (!line.trim()) return; - try { - const event = JSON.parse(line); - if ( - event.type === "message_start" && - event.message?.role === "assistant" - ) { - result.status.state = "thinking"; - emitUpdate(); - } - if (event.type === "tool_execution_start") { - const id = String( - event.toolCallId ?? result.status.toolCallCount + 1, - ); - const toolName = String(event.toolName ?? "tool"); - if (toolName === FINALIZE_TOOL_NAME) { - result.finalized = - validateFinalizePayload(event.args) ?? undefined; - } - activeToolIds.add(id); - result.status.state = "running"; - result.status.toolCallCount += 1; - result.status.activeToolCalls = activeToolIds.size; - rememberToolCall({ - id, - toolName, - summary: formatToolArgs(event.args), - status: "running", - }); - emitUpdate(); - } - if (event.type === "tool_execution_end") { - const id = String(event.toolCallId ?? ""); - if (id) activeToolIds.delete(id); - result.status.activeToolCalls = activeToolIds.size; - const previous = result.status.recentToolCalls.find( - (call) => call.id === id, - ); - rememberToolCall({ - id, - toolName: String(event.toolName ?? previous?.toolName ?? "tool"), - summary: previous?.summary ?? "", - status: event.isError ? "error" : "done", - }); - emitUpdate(); - } - if ( - event.type === "message_end" && - event.message?.role === "assistant" - ) { - for (const part of event.message.content ?? []) { - if (part.type === "text") result.output = part.text; - } - if (event.message.stopReason === "error") { - result.error = - event.message.errorMessage ?? "Subagent stopped with an error."; - } - emitUpdate(); - } - } catch { - // Ignore Non-JSON Output. - } - }; + if (event.type === "message_end" && event.message?.role === "assistant") { + for (const part of event.message.content ?? []) { + if (part.type === "text") result.output = part.text; + } + if (event.message.stopReason === "error") { + result.error = + event.message.errorMessage ?? "Subagent stopped with an error."; + } + emitUpdate(); + } +} - proc.stdout.on("data", (data) => { - buffer += data.toString(); - const lines = buffer.split("\n"); - buffer = lines.pop() ?? ""; - for (const line of lines) processLine(line); - }); +// Spawn Pi - Run the child process, stream stdout lines, capture stderr. +function spawnPi(opts: { + command: string; + args: string[]; + cwd: string; + signal?: AbortSignal; + onLine: (line: string) => void; + onStderr: (chunk: string) => void; + onError: (message: string) => void; +}): Promise { + return new Promise((resolve) => { + const proc = spawn(opts.command, opts.args, { + cwd: opts.cwd, + env: { ...process.env, PI_SUBAGENT_CHILD: "1" }, + shell: false, + stdio: ["ignore", "pipe", "pipe"], + }); - proc.stderr.on("data", (data) => { - result.stderr += data.toString(); - }); + let buffer = ""; + let aborted = false; - proc.on("close", (code) => { - if (buffer.trim()) processLine(buffer); - resolve(aborted ? 130 : (code ?? 0)); - }); - - proc.on("error", (error) => { - result.error = error.message; - resolve(1); - }); - - if (signal) { - const killProc = () => { - aborted = true; - proc.kill("SIGTERM"); - setTimeout(() => { - if (!proc.killed) proc.kill("SIGKILL"); - }, 5000); - }; - if (signal.aborted) killProc(); - else signal.addEventListener("abort", killProc, { once: true }); + proc.stdout.on("data", (data) => { + buffer += data.toString(); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + for (const line of lines) { + if (line.trim()) opts.onLine(line); } }); - result.exitCode = exitCode; - result.status.state = "done"; - result.status.activeToolCalls = 0; - if (exitCode === 130) result.error = "Subagent was aborted."; - return result; + proc.stderr.on("data", (data) => opts.onStderr(data.toString())); + + proc.on("close", (code) => { + if (buffer.trim()) opts.onLine(buffer); + resolve(aborted ? 130 : (code ?? 0)); + }); + + proc.on("error", (error) => { + opts.onError(error.message); + resolve(1); + }); + + if (opts.signal) { + const killProc = () => { + aborted = true; + proc.kill("SIGTERM"); + setTimeout(() => { + if (!proc.killed) proc.kill("SIGKILL"); + }, 5000); + }; + if (opts.signal.aborted) killProc(); + else opts.signal.addEventListener("abort", killProc, { once: true }); + } + }); +} + +// Run Agent With Retries - Public entry. Sets up the sticky session once, +// then loops runOnce until the child finalizes or fails hard. +export async function runAgentWithRetries( + opts: RunAgentOptions, +): Promise { + const sessionPath = getSubagentSessionPath( + opts.cwd, + opts.agent.name, + opts.sessionId, + ); + await fs.promises.mkdir(path.dirname(sessionPath), { recursive: true }); + + const maxAttempts = MAX_FINALIZE_RETRIES + 1; + let result!: SubagentResult; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + result = await runOnce(opts, sessionPath, attempt, maxAttempts); + + // Break Conditions - finalize wins; otherwise any process failure aborts retry. + if (result.finalized) break; + if (result.exitCode !== 0 || result.error) break; + } + return result; +} + +// Build Child Prompt - First attempt sends the task verbatim; later attempts +// nudge the child to finalize without altering the user-facing task string. +function buildChildPrompt(task: string, attempt: number): string { + if (attempt === 1) return `Task: ${task}`; + return [ + "Your previous response did not finalize correctly.", + `If you are finished, call ${FINALIZE_TOOL_NAME}.`, + "If you are not finished, continue the original task using available tools as needed.", + `Original task: ${task}`, + ].join("\n\n"); +} + +// Run Once - Spawn an isolated pi process in JSON mode and collect final text. +async function runOnce( + opts: RunAgentOptions, + sessionPath: string, + attempt: number, + maxAttempts: number, +): Promise { + const { cwd, agent, task, tools, sessionId, signal, onUpdate } = opts; + const state = createRunState( + agent, + task, + tools, + sessionId, + attempt, + maxAttempts, + onUpdate, + ); + + // Build CLI Args + const args = [ + "--mode", + "json", + "-p", + "--session", + sessionPath, + "--extension", + EXTENSION_ENTRY, + "--tools", + tools.join(","), + ]; + + // Append System Prompt - Written to a temp file scoped to this run. + let tmpDir: string | null = null; + let tmpPromptPath: string | null = null; + const prompt = buildSubagentPrompt(agent); + if (prompt) { + const tmp = await writePromptToTempFile(agent.name, prompt); + tmpDir = tmp.dir; + tmpPromptPath = tmp.filePath; + // System Prompt - Replace pi's default so the subagent .md body is the + // authoritative prompt (no conflicting "You are..." identity from pi). + args.push("--system-prompt", tmpPromptPath); + } + args.push(buildChildPrompt(task, attempt)); + + try { + state.emitUpdate(); + + const invocation = getPiInvocation(args); + const exitCode = await spawnPi({ + command: invocation.command, + args: invocation.args, + cwd, + signal, + onLine: (line) => { + try { + handleEvent(state, JSON.parse(line)); + } catch { + // Ignore Non-JSON Output. + } + }, + onStderr: (chunk) => { + state.result.stderr += chunk; + }, + onError: (message) => { + state.result.error = message; + }, + }); + + state.result.exitCode = exitCode; + state.result.status.state = "done"; + state.result.status.activeToolCalls = 0; + if (exitCode === 130) state.result.error = "Subagent was aborted."; + return state.result; } finally { if (tmpPromptPath) await fs.promises.rm(tmpPromptPath, { force: true }); if (tmpDir) await fs.promises.rm(tmpDir, { force: true, recursive: true }); diff --git a/src/types.ts b/src/types.ts index 491f3a0..fc1b069 100644 --- a/src/types.ts +++ b/src/types.ts @@ -19,6 +19,8 @@ export interface SubagentStatus { toolCallCount: number; activeToolCalls: number; recentToolCalls: SubagentToolActivity[]; + attempt: number; + maxAttempts: number; } export enum FinalizeStatus { diff --git a/src/validate.ts b/src/validate.ts new file mode 100644 index 0000000..65b96a8 --- /dev/null +++ b/src/validate.ts @@ -0,0 +1,50 @@ +import { formatPromptList } from "./format.ts"; +import { resolveTools } from "./tools.ts"; +import type { PromptConfig } from "./types.ts"; + +export type AgentValidation = + | { ok: true; agent: PromptConfig; tools: string[] } + | { ok: false; text: string; details: Record }; + +// Validate Agent - Resolve the agent by name, enforce config invariants, and +// compute the effective tool list. Returns a flat error payload on failure so +// the caller can shape it into a tool result without re-deriving any of this. +export function validateAgent( + agentName: string, + prompts: PromptConfig[], + activeTools: string[], +): AgentValidation { + const agent = prompts.find((prompt) => prompt.name === agentName); + if (!agent) { + return { + ok: false, + text: `Unknown subagent: ${agentName}. Available: ${formatPromptList(prompts)}`, + details: { available: prompts.map((prompt) => prompt.name) }, + }; + } + + if (agent.approvedTools.length > 0 && agent.deniedTools.length > 0) { + return { + ok: false, + text: + `Invalid subagent config for ${agent.name}: define either approved_tools/allowed_tools ` + + "or denied_tools, not both.", + details: { agent: agent.name, filePath: agent.filePath }, + }; + } + + const tools = resolveTools(agent, activeTools); + if (tools.length === 0) { + return { + ok: false, + text: `Subagent ${agent.name} has no approved tools after applying denied_tools.`, + details: { + agent: agent.name, + approvedTools: agent.approvedTools, + deniedTools: agent.deniedTools, + }, + }; + } + + return { ok: true, agent, tools }; +}