/* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-base-to-string, @typescript-eslint/restrict-template-expressions -- Pi SDK types are not fully exported; see upstream Pi SDK for type improvements */ import type { AgentToolResult } from "@earendil-works/pi-coding-agent"; import { defineTool } from "@earendil-works/pi-coding-agent"; import { Text } from "@earendil-works/pi-tui"; import { Type } from "typebox"; import { AgentTypeRegistry } from "#src/config/agent-types"; import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot"; import type { AgentSpawnConfig } from "#src/lifecycle/subagent-manager"; import { spawnBackground } from "#src/tools/background-spawner"; import { runForeground } from "#src/tools/foreground-runner"; import { buildDetails, buildTypeListText, textResult } from "#src/tools/helpers"; import { renderAgentResult } from "#src/tools/result-renderer"; import { type ModelInfo, resolveSpawnConfig } from "#src/tools/spawn-config"; import type { ParentSessionInfo, Subagent } from "#src/types"; import { AgentActivityTracker } from "#src/ui/agent-activity-tracker"; import { type UICtx } from "#src/ui/agent-widget"; import { type AgentDetails, getDisplayName } from "#src/ui/display"; // ---- Shared interfaces (also used by background-spawner and foreground-runner) ---- /** * Narrow read/write interface for the agent-tool's agentActivity access. * The full Map satisfies this structurally — no wrapper needed. */ export interface AgentActivityAccess { get(id: string): AgentActivityTracker | undefined; set(id: string, tracker: AgentActivityTracker): void; delete(id: string): void; } // ---- Deps interfaces ---- /** Narrow manager interface — only the methods the Agent tool calls. */ export interface AgentToolManager { spawn: (snapshot: ParentSnapshot, type: string, prompt: string, opts: AgentSpawnConfig) => string; spawnAndWait: (snapshot: ParentSnapshot, type: string, prompt: string, opts: Omit) => Promise; resume: (id: string, prompt: string, signal: AbortSignal) => Promise; getRecord: (id: string) => Subagent | undefined; } /** Narrow runtime interface — the Agent tool's slice of SubagentRuntime. */ export interface AgentToolRuntime { readonly agentActivity: AgentActivityAccess; buildSnapshot(inheritContext: boolean): ParentSnapshot; getModelInfo(): ModelInfo; getSessionInfo(): { parentSessionFile: string; parentSessionId: string }; } /** * Narrow widget interface the Agent tool drives directly. * Superset of the runner/spawner widget deps plus `setUICtx`. * AgentWidget satisfies it structurally. */ export interface AgentToolWidget { setUICtx(ctx: UICtx): void; ensureTimer(): void; update(): void; markFinished(id: string): void; } /** Narrow settings accessor — only the fields the Agent tool reads. */ export type AgentToolSettings = { readonly defaultMaxTurns: number | undefined; readonly maxConcurrent: number; }; // ---- Class ---- export class AgentTool { private readonly typeListText: string; private readonly availableTypesText: string; constructor( private readonly manager: AgentToolManager, private readonly runtime: AgentToolRuntime, private readonly widget: AgentToolWidget, private readonly settings: AgentToolSettings, private readonly registry: AgentTypeRegistry, private readonly agentDir: string, ) { this.typeListText = buildTypeListText(registry, agentDir); this.availableTypesText = registry.getAvailableTypes().join(", "); } async execute( toolCallId: string, params: Record, signal: AbortSignal | undefined, onUpdate: ((update: AgentToolResult) => void) | undefined, ctx: any, ) { // Ensure we have UI context for widget rendering this.widget.setUICtx(ctx.ui as UICtx); // Reload custom agents so new .pi/agents/*.md files are picked up without restart this.registry.reload(); // ---- Config resolution (pure) ---- const config = resolveSpawnConfig( params, this.registry, this.runtime.getModelInfo(), this.settings, ); if ("error" in config) return textResult(config.error); // ---- Boundary extraction (after config so inheritContext is resolved) ---- const snapshot = this.runtime.buildSnapshot(config.execution.inheritContext); const { parentSessionFile, parentSessionId } = this.runtime.getSessionInfo(); const parentSession: ParentSessionInfo = { parentSessionFile, parentSessionId, toolCallId }; // ---- Resume existing agent ---- if (params.resume) { const existing = this.manager.getRecord(params.resume as string); if (!existing) { return textResult( `Agent not found: "${params.resume}". It may have been cleaned up.`, ); } if (!existing.isSessionReady()) { return textResult( `Agent "${params.resume}" has no active session to resume.`, ); } const record = await this.manager.resume( params.resume as string, params.prompt as string, signal ?? new AbortController().signal, ); if (!record) { return textResult(`Failed to resume agent "${params.resume}".`); } return textResult( record.result?.trim() ?? record.error?.trim() ?? "No output.", buildDetails(config.presentation.detailBase, record), ); } // ---- Background execution ---- if (config.execution.runInBackground) { return spawnBackground( this.manager, this.widget, this.runtime.agentActivity, { config, snapshot, parentSession, settings: this.settings }, ); } // ---- Foreground execution — stream progress via onUpdate ---- return runForeground( this.manager, this.widget, this.runtime.agentActivity, { config, snapshot, parentSession }, signal, onUpdate, ); } toToolDefinition() { const typeListText = this.typeListText; const availableTypesText = this.availableTypesText; const agentDir = this.agentDir; const registry = this.registry; return defineTool({ name: "subagent" as const, label: "Subagent", promptSnippet: "subagent: Launch a specialized agent for complex, multi-step tasks.", description: `Launch a new agent to handle complex, multi-step tasks autonomously. The subagent tool launches specialized agents that autonomously handle complex tasks. Each agent type has specific capabilities and tools available to it. Available agent types: ${typeListText} Guidelines: - For parallel work, use run_in_background: true on each agent. Foreground calls run sequentially — only one executes at a time. - Use Explore for codebase searches and code understanding. - Use Plan for architecture and implementation planning. - Use general-purpose for complex tasks that need file editing. - Provide clear, detailed prompts so the agent can work autonomously. - Subagent results are returned as text — summarize them for the user. - Use run_in_background for work you don't need immediately. You will be notified when it completes. - Use resume with an agent ID to continue a previous agent's work. - Use steer_subagent to send mid-run messages to a running background agent. - Use model to specify a different model only for agents without a model in their agent definition. - If you omit model, the subagent inherits the parent session's current model by default. - Use thinking only for agents without a thinking level in their agent definition. - For Nosh harness specialist agents, keep inherit_context false and pass context as named files in the prompt. `, parameters: Type.Object({ prompt: Type.String({ description: "The task for the agent to perform.", }), description: Type.String({ description: "A short (3-5 word) description of the task (shown in UI).", }), subagent_type: Type.String({ description: `The type of specialized agent to use. Available types: ${availableTypesText}. Custom agents from .pi/agents/.md (project), ${agentDir}/agents/.md (global), or package-registered agent directories are also available.`, }), model: Type.Optional( Type.String({ description: 'Optional model override for agents without a model in frontmatter. Accepts "provider/modelId" or fuzzy name (e.g. "haiku", "sonnet"). Omit to inherit the parent session model.', }), ), thinking: Type.Optional( Type.String({ description: "Thinking level: off, minimal, low, medium, high, xhigh. Used only when the agent definition does not set a thinking level.", }), ), max_turns: Type.Optional( Type.Number({ description: "Maximum number of agentic turns before stopping. Used only when the agent definition does not set max_turns. Omit for unlimited (default).", minimum: 1, }), ), run_in_background: Type.Optional( Type.Boolean({ description: "Set to true to run in background. Returns agent ID immediately. You will be notified when it completes.", }), ), resume: Type.Optional( Type.String({ description: "Optional agent ID to resume from. Continues from previous context.", }), ), inherit_context: Type.Optional( Type.Boolean({ description: "If true, fork parent conversation into the agent. Default: false. Nosh harness specialist agents should keep this false and receive context through named files.", }), ), }), // ---- Custom rendering: inline subagent results ---- renderCall(args: Record, theme: any) { const displayName = args.subagent_type ? getDisplayName(args.subagent_type as string, registry) : "Subagent"; const desc = (args.description as string | undefined) ?? ""; return new Text( "▸ " + theme.fg("toolTitle", theme.bold(displayName)) + (desc ? " " + theme.fg("muted", desc) : ""), 0, 0, ); }, renderResult(result: any, { expanded, isPartial }: any, theme: any) { const details = result.details as AgentDetails | undefined; if (!details) { const text = result.content[0]?.type === "text" ? result.content[0].text : ""; return new Text(text, 0, 0); } const resultText = result.content[0]?.type === "text" ? result.content[0].text : ""; return new Text( renderAgentResult(details, resultText, expanded, isPartial, theme), 0, 0, ); }, execute: ( toolCallId: string, params: Record, signal: AbortSignal | undefined, onUpdate: ((update: AgentToolResult) => void) | undefined, ctx: any, ) => this.execute(toolCallId, params, signal, onUpdate, ctx), }); } }