diff options
| author | Adam Malczewski <[email protected]> | 2026-05-22 15:24:13 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-22 15:24:13 +0900 |
| commit | 9ecaabd87c0e51b8a7408dabb0133a9344586859 (patch) | |
| tree | 4b5809ab23948a5e3f558f3aa34c52d5038f4e19 /packages/core/src/agent | |
| parent | 8f1dd855f0c4c877bff8d3a4ba193432b268b1c2 (diff) | |
| download | dispatch-9ecaabd87c0e51b8a7408dabb0133a9344586859.tar.gz dispatch-9ecaabd87c0e51b8a7408dabb0133a9344586859.zip | |
feat: agent builder, CWD support, auto-save, UI polish, unavailable tool handling
- Agent Builder: full CRUD with card grid, drag-and-drop model reorder, edit/delete
- Auto-save on edit with 600ms debounce, AbortController for concurrency, fieldset disabled until name entered
- Agent definitions stored as TOML with cwd field, loaded from global/project dirs
- Working directory: per-tab CWD override in Chat Settings, agent default CWD, auto-create on first message
- CWD validation: check-dir endpoint with ~ expansion, real-time validity indicator
- Subagent CWD validated against parent's effective CWD using path.relative
- Unavailable tool calls: caught gracefully, shown as tool call with error badge, model retries
- UI: tab bar border radius, sidebar border removed, chat input ghost style, scroll-to-bottom rectangle
- Skills dir collapse uses CSS rotation, Model Choice renamed to Chat Settings, System Prompt view removed
- Reusable SkillsBrowser/ToolPermissions with external mode for Agent Builder
- ModelSelector: Agent/Manual toggle, agent list, Agent Settings link
- Page router, skills recursive scanning, bin/up gopass removed, docker volume mounts
Diffstat (limited to 'packages/core/src/agent')
| -rw-r--r-- | packages/core/src/agent/agent.ts | 147 |
1 files changed, 110 insertions, 37 deletions
diff --git a/packages/core/src/agent/agent.ts b/packages/core/src/agent/agent.ts index 006ee1b..c2c5880 100644 --- a/packages/core/src/agent/agent.ts +++ b/packages/core/src/agent/agent.ts @@ -1,11 +1,11 @@ +import { realpathSync } from "node:fs"; +import { dirname, isAbsolute, relative, resolve } from "node:path"; import type { CoreMessage } from "ai"; import { streamText } from "ai"; -import { dirname, isAbsolute, relative, resolve } from "node:path"; -import { realpathSync } from "node:fs"; +import { buildBillingHeaderValue, SYSTEM_IDENTITY } from "../credentials/claude.js"; import { createProvider, prefixToolName, unprefixToolName } from "../llm/provider.js"; import { createToolRegistry } from "../tools/registry.js"; import { analyzeCommand } from "../tools/shell-analyze.js"; -import { buildBillingHeaderValue, SYSTEM_IDENTITY } from "../credentials/claude.js"; import type { AgentConfig, AgentEvent, @@ -21,7 +21,10 @@ function toCoreMessages(messages: ChatMessage[], isAnthropic?: boolean): CoreMes if (msg.role === "user") { result.push({ role: "user", content: msg.content }); } else if (msg.role === "assistant") { - const parts: Array<{ type: "text"; text: string } | { type: "tool-call"; toolCallId: string; toolName: string; args: Record<string, unknown> }> = [{ type: "text", text: msg.content }]; + const parts: Array< + | { type: "text"; text: string } + | { type: "tool-call"; toolCallId: string; toolName: string; args: Record<string, unknown> } + > = [{ type: "text", text: msg.content }]; for (const tc of msg.toolCalls ?? []) { const toolName = isAnthropic ? prefixToolName(tc.name) : tc.name; parts.push({ type: "tool-call", toolCallId: tc.id, toolName, args: tc.arguments }); @@ -29,7 +32,12 @@ function toCoreMessages(messages: ChatMessage[], isAnthropic?: boolean): CoreMes result.push({ role: "assistant", content: parts }); for (const tr of msg.toolResults ?? []) { const toolName = isAnthropic ? prefixToolName(tr.toolName) : tr.toolName; - result.push({ role: "tool", content: [{ type: "tool-result", toolCallId: tr.toolCallId, toolName, result: tr.result }] }); + result.push({ + role: "tool", + content: [ + { type: "tool-result", toolCallId: tr.toolCallId, toolName, result: tr.result }, + ], + }); } } } @@ -76,7 +84,12 @@ export class Agent { const registry = createToolRegistry(this.config.tools); const tool = registry.getTool(tc.name); if (!tool) { - return { toolCallId: tc.id, toolName: tc.name, result: `Unknown tool: ${tc.name}`, isError: true }; + return { + toolCallId: tc.id, + toolName: tc.name, + result: `Unknown tool: ${tc.name}`, + isError: true, + }; } // Permission check for shell commands — only prompt for external directory access. @@ -133,7 +146,8 @@ export class Agent { // Check if outside workspace const rel = relative(this.config.workingDirectory, resolvedPath); - const isOutside = rel.startsWith("../") || rel.startsWith("..\\") || rel === ".." || isAbsolute(rel); + const isOutside = + rel.startsWith("../") || rel.startsWith("..\\") || rel === ".." || isAbsolute(rel); if (isOutside) { const permissionType = @@ -194,7 +208,10 @@ export class Agent { } } - async *run(userMessage: string, options?: { reasoningEffort?: "none" | "low" | "medium" | "high" | "max" }): AsyncGenerator<AgentEvent> { + async *run( + userMessage: string, + options?: { reasoningEffort?: "none" | "low" | "medium" | "high" | "max" }, + ): AsyncGenerator<AgentEvent> { this.status = "running"; yield { type: "status", status: "running" }; @@ -213,8 +230,8 @@ export class Agent { const aiTools = registry.getAISDKTools(); const tools = isAnthropic ? Object.fromEntries( - Object.entries(aiTools).map(([name, tool]) => [prefixToolName(name), tool]), - ) + Object.entries(aiTools).map(([name, tool]) => [prefixToolName(name), tool]), + ) : aiTools; // Build system prompt @@ -246,8 +263,19 @@ export class Agent { }; if (isAnthropic && effort !== "none") { - const budgetTokens = effort === "max" ? 16000 : effort === "high" ? 10000 : effort === "medium" ? 5000 : effort === "low" ? 2000 : 0; - streamOptions.providerOptions = { anthropic: { thinking: { type: "enabled" as const, budgetTokens } } }; + const budgetTokens = + effort === "max" + ? 16000 + : effort === "high" + ? 10000 + : effort === "medium" + ? 5000 + : effort === "low" + ? 2000 + : 0; + streamOptions.providerOptions = { + anthropic: { thinking: { type: "enabled" as const, budgetTokens } }, + }; streamOptions.maxTokens = budgetTokens + 8000; } else if (!isAnthropic && effort !== "none") { streamOptions.providerOptions = { openaiCompatible: { reasoningEffort: effort } }; @@ -258,31 +286,64 @@ export class Agent { let stepText = ""; const stepToolCalls: ToolCall[] = []; - for await (const event of result.fullStream) { - if (event.type === "text-delta") { - stepText += event.textDelta; - finalText += event.textDelta; - yield { type: "text-delta", delta: event.textDelta }; - } else if (event.type === "reasoning") { - yield { type: "reasoning-delta", delta: event.textDelta }; - } else if (event.type === "tool-call") { - const rawName = event.toolName; - const toolName = isAnthropic ? unprefixToolName(rawName) : rawName; - const toolCall: ToolCall = { - id: event.toolCallId, - name: toolName, - arguments: event.args as Record<string, unknown>, - }; - stepToolCalls.push(toolCall); - allToolCalls.push(toolCall); - yield { type: "tool-call", toolCall }; - } else if (event.type === "error") { - const errorMsg = formatError(event.error, this.config); - yield { type: "error", error: errorMsg }; - this.status = "error"; - yield { type: "status", status: "error" }; - return; + try { + for await (const event of result.fullStream) { + if (event.type === "text-delta") { + stepText += event.textDelta; + finalText += event.textDelta; + yield { type: "text-delta", delta: event.textDelta }; + } else if (event.type === "reasoning") { + yield { type: "reasoning-delta", delta: event.textDelta }; + } else if (event.type === "tool-call") { + const rawName = event.toolName; + const toolName = isAnthropic ? unprefixToolName(rawName) : rawName; + const toolCall: ToolCall = { + id: event.toolCallId, + name: toolName, + arguments: event.args as Record<string, unknown>, + }; + stepToolCalls.push(toolCall); + allToolCalls.push(toolCall); + yield { type: "tool-call", toolCall }; + } else if (event.type === "error") { + const errorMsg = formatError(event.error, this.config); + yield { type: "error", error: errorMsg }; + this.status = "error"; + yield { type: "status", status: "error" }; + return; + } } + } catch (streamErr) { + const errMsg = streamErr instanceof Error ? streamErr.message : String(streamErr); + const unavailMatch = errMsg.match( + /tried to call unavailable tool '([^']+)'/i, + ); + if (!unavailMatch) throw streamErr; + + // Model tried to call an unavailable tool. + // Add a synthetic tool call + error result for the bad tool. + const badToolName = unavailMatch[1]; + const fakeId = `unavail_${crypto.randomUUID().slice(0, 8)}`; + const availableTools = Object.keys(aiTools).join(", "); + const errorResult = `Tool "${badToolName}" is not available. Available tools: ${availableTools}. Please use only available tools.`; + + const badToolCall: ToolCall = { + id: fakeId, + name: badToolName, + arguments: {}, + }; + stepToolCalls.push(badToolCall); + allToolCalls.push(badToolCall); + yield { type: "tool-call", toolCall: badToolCall }; + + const badToolResult: ToolResult = { + toolCallId: fakeId, + toolName: badToolName, + result: errorResult, + isError: true, + }; + allToolResults.push(badToolResult); + yield { type: "tool-result", toolResult: badToolResult }; } // No tool calls means the agent is done @@ -304,7 +365,17 @@ export class Agent { // Execute tool calls manually const stepToolResults: ToolResult[] = []; + // Track tool calls that already have results (e.g. synthetic unavailable-tool errors) + const alreadyResolved = new Set(allToolResults.map((r) => r.toolCallId)); + for (const tc of stepToolCalls) { + // Skip execution for tool calls that already have synthetic results + if (alreadyResolved.has(tc.id)) { + const existing = allToolResults.find((r) => r.toolCallId === tc.id); + if (existing) stepToolResults.push(existing); + continue; + } + const shellOutputQueue: Array<{ data: string; stream: "stdout" | "stderr" }> = []; const execPromise = this.executeToolWithStreaming(tc, shellOutputQueue); @@ -321,7 +392,9 @@ export class Agent { } const raceResult = await Promise.race([ execPromise.then((r) => ({ done: true as const, value: r })), - new Promise<{ done: false }>((resolve) => setImmediate(() => resolve({ done: false }))), + new Promise<{ done: false }>((resolve) => + setImmediate(() => resolve({ done: false })), + ), ]); if (raceResult.done) { toolResult = raceResult.value; |
