/**
* Agent Tools — task, team_create, task_output, task_stop, config, ask_user, lsp, skill
*
* Extracted from local-tools.ts for single-responsibility.
*/
import { readFileSync, existsSync } from "fs";
import { dirname, join } from "path";
import { homedir } from "os";
import {
runSubagent,
runSubagentBackground,
type SubagentType,
type ParentTraceContext,
} from "../subagent.js";
import {
createTurnContext,
getTurnNumber,
} from "../telemetry.js";
import {
runAgentTeam,
type TeamConfig,
} from "../team-lead.js";
import { readAgentOutput, stopBackgroundAgent } from "../background-processes.js";
import { readProcessOutput, killProcess } from "../background-processes.js";
import { getGlobalEmitter } from "../agent-events.js";
import { executeLSP } from "../lsp-manager.js";
import { debugLog } from "../debug-log.js";
import { setPermissionMode, getPermissionMode } from "../permission-modes.js";
import { getModel, setModel } from "../model-manager.js";
import { ToolResult } from "../../../shared/types.js";
// ============================================================================
// CONFIG TOOL
// ============================================================================
export function configTool(input: Record<string, unknown>): ToolResult {
const setting = input.setting as string;
const value = input.value as string | undefined;
switch (setting) {
case "model": {
if (!value) return { success: true, output: `Current model: ${getModel()}` };
const result = setModel(value);
if (!result.success) {
return { success: false, output: result.error || `Unknown model: ${value}` };
}
return { success: true, output: `Model set to: ${result.model}` };
}
case "mode":
case "permission_mode": {
if (!value) return { success: true, output: `Current mode: ${getPermissionMode()}` };
if (!["default", "plan", "yolo"].includes(value)) {
return { success: false, output: `Invalid mode: ${value}. Use: default, plan, yolo` };
}
const result = setPermissionMode(value as "default" | "plan" | "yolo");
return { success: result.success, output: result.message };
}
case "memory": {
if (!value) {
const memPath = join(homedir(), ".swagmanager", "memory", "MEMORY.md");
if (!existsSync(memPath)) return { success: true, output: "No memory file found." };
const content = readFileSync(memPath, "utf-8");
return { success: true, output: `Memory (${content.length} chars):\n${content.slice(0, 2000)}` };
}
return { success: false, output: "Use read_file/write_file to edit memory directly." };
}
default:
return { success: false, output: `Unknown setting: ${setting}. Available: model, mode, memory` };
}
}
// ============================================================================
// ASK USER
// ============================================================================
export function askUser(input: Record<string, unknown>): ToolResult {
const question = input.question as string;
const options = input.options as Array<{ label: string; description: string }>;
if (!question) return { success: false, output: "question is required" };
if (!options || options.length < 2) return { success: false, output: "at least 2 options required" };
// Emit the question via the global event emitter for the UI to render
const emitter = getGlobalEmitter();
if (emitter) {
emitter.emit("ask_user", { question, options });
}
// Format question as text for the model to see in the response
const optionLines = options.map((o, i) => ` ${i + 1}. **${o.label}** — ${o.description}`).join("\n");
return {
success: true,
output: `Question presented to user:\n${question}\n\nOptions:\n${optionLines}\n\n(Waiting for user response...)`,
};
}
// ============================================================================
// LSP TOOL
// ============================================================================
export async function lspTool(input: Record<string, unknown>): Promise<ToolResult> {
const operation = input.operation as string;
if (!operation) return { success: false, output: "operation is required" };
return await executeLSP(operation, input);
}
// ============================================================================
// SKILL TOOL
// ============================================================================
export function skillTool(input: Record<string, unknown>): ToolResult {
const skillName = input.skill as string;
if (!skillName) return { success: false, output: "skill name is required" };
const args = ((input.args as string) || "").split(/\s+/).filter(Boolean);
// Resolution order:
// 1. .whale/commands/{skill}.md (project-local)
// 2. ~/.swagmanager/commands/{skill}.md (user global)
// 3. Built-in skills bundled with package
const localPath = join(process.cwd(), ".whale", "commands", `${skillName}.md`);
const globalPath = join(homedir(), ".swagmanager", "commands", `${skillName}.md`);
// Built-in skills: check both dist/ and src/ locations
const thisFileDir = dirname(new URL(import.meta.url).pathname);
const builtinPaths = [
join(thisFileDir, "..", "builtin-skills", `${skillName}.md`), // dist/cli/services/tools/../builtin-skills/
join(thisFileDir, "..", "..", "..", "..", "src", "cli", "services", "builtin-skills", `${skillName}.md`), // src/ from dist/
];
let template: string | null = null;
let source = "";
// Check local -> global -> builtin (multiple paths)
const candidates: Array<[string, string]> = [
[localPath, "local"],
[globalPath, "global"],
...builtinPaths.map(p => [p, "builtin"] as [string, string]),
];
for (const [path, src] of candidates) {
if (existsSync(path)) {
try {
let content = readFileSync(path, "utf-8");
// Strip frontmatter
if (content.startsWith("---")) {
const endIdx = content.indexOf("---", 3);
if (endIdx !== -1) {
content = content.slice(endIdx + 3).trim();
}
}
template = content;
source = src;
break;
} catch { /* skip */ }
}
}
if (!template) {
return {
success: false,
output: `Skill not found: ${skillName}. Available locations:\n .whale/commands/${skillName}.md\n ~/.swagmanager/commands/${skillName}.md\n\nBuilt-in skills: commit, review, review-pr`,
};
}
// Expand arguments ($1, $2, $ARGS)
let expanded = template;
for (let i = 0; i < args.length; i++) {
expanded = expanded.replace(new RegExp(`\\$${i + 1}`, "g"), args[i]);
}
expanded = expanded.replace(/\$ARGS/g, args.join(" "));
expanded = expanded.replace(/\$\d+/g, ""); // Clean up unused
return {
success: true,
output: `[Skill: ${skillName} (${source})]\n\n${expanded.trim()}`,
};
}
// ============================================================================
// TASK TOOL — subagent execution
// ============================================================================
/** Create parent trace context for subagent hierarchy */
function getParentTraceContext(): ParentTraceContext {
const ctx = createTurnContext();
return {
traceId: ctx.traceId!,
spanId: ctx.spanId!,
conversationId: ctx.conversationId,
turnNumber: getTurnNumber(),
userId: ctx.userId,
userEmail: ctx.userEmail,
};
}
export async function taskTool(input: Record<string, unknown>): Promise<ToolResult> {
const prompt = input.prompt as string;
const subagent_type = input.subagent_type as SubagentType;
const model = (input.model as "sonnet" | "opus" | "haiku") || "haiku";
const runInBackground = input.run_in_background as boolean | undefined;
const maxTurns = input.max_turns as number | undefined;
const agentName = input.name as string | undefined;
const teamName = input.team_name as string | undefined;
const mode = input.mode as string | undefined;
if (!prompt) return { success: false, output: "prompt is required" };
if (!subagent_type) return { success: false, output: "subagent_type is required" };
// Apply permission mode for subagent if specified
if (mode) {
setPermissionMode(mode as "default" | "plan" | "yolo");
// Note: mode resets are handled by subagent isolation
}
debugLog("tools", `task: ${agentName || subagent_type}`, { model, maxTurns, teamName, mode });
try {
// Background mode: start agent, return output file path immediately
if (runInBackground) {
const { agentId, outputFile } = await runSubagentBackground({
prompt,
subagent_type,
model,
max_turns: maxTurns,
name: agentName,
run_in_background: true,
parentTraceContext: getParentTraceContext(),
});
return {
success: true,
output: `Background agent started.\n agent_id: ${agentId}\n output_file: ${outputFile}\n\nUse task_output with task_id="${agentId}" to check progress.`,
};
}
// Foreground mode: run agent synchronously
const result = await runSubagent({
prompt,
subagent_type,
model,
max_turns: maxTurns,
name: agentName,
parentTraceContext: getParentTraceContext(),
});
return {
success: result.success,
output: result.output,
};
} catch (err: any) {
return {
success: false,
output: `Task failed: ${err.message || err}`,
};
}
}
// ============================================================================
// TASK OUTPUT / TASK STOP
// ============================================================================
export async function taskOutput(input: Record<string, unknown>): Promise<ToolResult> {
const taskId = input.task_id as string;
const block = (input.block as boolean) ?? true;
const timeout = Math.min((input.timeout as number) || 30000, 120000);
if (!taskId) return { success: false, output: "task_id is required" };
// Check if it's a background agent (agent-xxx prefix)
if (taskId.startsWith("agent-")) {
const agentResult = readAgentOutput(taskId);
if (!agentResult) return { success: false, output: `Agent not found: ${taskId}. Use list_shells to see available tasks.` };
// If blocking and still running, poll until done or timeout
if (block && agentResult.status === "running") {
const start = Date.now();
while (Date.now() - start < timeout) {
await new Promise(r => setTimeout(r, 1000));
const updated = readAgentOutput(taskId);
if (updated && updated.status !== "running") {
return { success: true, output: `[${updated.status}]\n${updated.output}` };
}
}
const final = readAgentOutput(taskId);
return { success: true, output: `[${final?.status || "running"} — timed out waiting]\n${final?.output || ""}` };
}
return { success: true, output: `[${agentResult.status}]\n${agentResult.output}` };
}
// Fall back to shell process output (bash_output behavior)
const result = readProcessOutput(taskId, {});
if ("error" in result) return { success: false, output: result.error };
const statusIcon = result.status === "running" ? "\u25CF" : result.status === "completed" ? "\u2713" : "\u2715";
const lines: string[] = [];
lines.push(`${statusIcon} Task ${taskId} — ${result.status}`);
if (result.exitCode !== undefined) lines.push(` Exit code: ${result.exitCode}`);
if (result.newOutput) { lines.push(` Output:`); lines.push(result.newOutput); }
if (result.newErrors) { lines.push(` Errors:`); lines.push(result.newErrors); }
if (!result.newOutput && !result.newErrors) lines.push(" (no new output since last check)");
return { success: true, output: lines.join("\n") };
}
export function taskStop(input: Record<string, unknown>): ToolResult {
const taskId = input.task_id as string;
if (!taskId) return { success: false, output: "task_id is required" };
// Check if it's a background agent
if (taskId.startsWith("agent-")) {
const result = stopBackgroundAgent(taskId);
return { success: result.success, output: result.message };
}
// Fall back to shell kill
const result = killProcess(taskId);
return { success: result.success, output: result.message };
}
// ============================================================================
// TEAM CREATE
// ============================================================================
export async function teamCreateTool(input: Record<string, unknown>): Promise<ToolResult> {
const name = input.name as string;
const teammateCount = input.teammate_count as number;
const model = (input.model as "sonnet" | "opus" | "haiku") || "sonnet";
const tasksInput = input.tasks as Array<{
description: string;
files?: string[];
dependencies?: string[];
}>;
if (!name) return { success: false, output: "name is required" };
if (!teammateCount || teammateCount < 1) {
return { success: false, output: "teammate_count must be at least 1" };
}
if (!tasksInput || tasksInput.length === 0) {
return { success: false, output: "tasks array is required and must not be empty" };
}
// Validate task count vs teammate count
if (tasksInput.length < teammateCount) {
return {
success: false,
output: `Not enough tasks (${tasksInput.length}) for ${teammateCount} teammates. Add more tasks or reduce teammates.`,
};
}
const config: TeamConfig = {
name,
teammateCount,
model,
tasks: tasksInput,
};
try {
const result = await runAgentTeam(config);
// Build summary output
const lines: string[] = [
`## Team: ${name}`,
`Status: ${result.success ? "SUCCESS" : "PARTIAL"}`,
`Duration: ${(result.durationMs / 1000).toFixed(1)}s`,
`Tokens: ${result.tokensUsed.input} in, ${result.tokensUsed.output} out`,
"",
"### Task Results",
];
for (const task of result.taskResults) {
const icon = task.status === "completed" ? "[done]" : "[fail]";
lines.push(`${icon} ${task.description}`);
if (task.result) {
lines.push(` ${task.result.slice(0, 200)}${task.result.length > 200 ? "..." : ""}`);
}
}
return {
success: result.success,
output: lines.join("\n"),
};
} catch (err: any) {
return {
success: false,
output: `Team failed: ${err.message || err}`,
};
}
}