summaryrefslogtreecommitdiffhomepage
path: root/packages/core/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-28 22:51:47 +0900
committerAdam Malczewski <[email protected]>2026-05-28 22:51:47 +0900
commitd6609efd4e14101e77fb35a98ce597a32816862d (patch)
tree09ea404ce0a780ca6b8c380fdd93ad1ae9960986 /packages/core/src
parent2eeabc95b78f6624c187e1e3892f9413266b4b9a (diff)
downloaddispatch-d6609efd4e14101e77fb35a98ce597a32816862d.tar.gz
dispatch-d6609efd4e14101e77fb35a98ce597a32816862d.zip
fix(core): normalize tool schemas for Anthropic, add toolChoice=auto; feat(summon): agent definition support; docs: cc/ research findings
- registry.ts: add normalizeForAnthropic() to strip , additionalProperties, default, nullable from zodToJsonSchema output so Anthropic doesn't silently reject tool definitions - agent.ts: add toolChoice=auto for Claude OAuth to prevent Opus thinking forever without calling tools - summon.ts: add agentSlug parameter, build agents catalog in description, add toAvailableAgents helper - agent-manager.ts: wire agent definition loading into spawnChildAgent, agent model fallback - loader.ts: export loadAgent, expandAgentToolNames, getAgentDirPaths; add getAgentDirPaths for permission gate - agent.ts: auto-allow read-only tools in agent definition directories - packaging/PKGBUILD: exclude ARM64 prebuilds from x86_64 package - cc/: research findings on Claude Opus tool calling issues - tests: loader tests, summon tool tests
Diffstat (limited to 'packages/core/src')
-rw-r--r--packages/core/src/agent/agent.ts33
-rw-r--r--packages/core/src/agents/index.ts12
-rw-r--r--packages/core/src/agents/loader.ts75
-rw-r--r--packages/core/src/index.ts19
-rw-r--r--packages/core/src/tools/registry.ts39
-rw-r--r--packages/core/src/tools/summon.ts127
6 files changed, 293 insertions, 12 deletions
diff --git a/packages/core/src/agent/agent.ts b/packages/core/src/agent/agent.ts
index bb4ee7d..6139dec 100644
--- a/packages/core/src/agent/agent.ts
+++ b/packages/core/src/agent/agent.ts
@@ -2,6 +2,7 @@ import { dirname } from "node:path";
import type { ProviderOptions } from "@ai-sdk/provider-utils";
import type { ModelMessage, SystemModelMessage } from "ai";
import { streamText } from "ai";
+import { getAgentDirPaths } from "../agents/loader.js";
import { appendEventToChunks } from "../chunks/append.js";
import { buildBillingHeaderValue, SYSTEM_IDENTITY } from "../credentials/claude.js";
import { createProvider, prefixToolName, unprefixToolName } from "../llm/provider.js";
@@ -531,7 +532,27 @@ export class Agent {
const isSpillPath =
resolvedPath === resolvedSpillRoot || resolvedPath.startsWith(`${resolvedSpillRoot}/`);
- if (!isUnderWorkdir && !isSpillPath) {
+ // Agent definitions live in well-known directories
+ // (`~/.config/dispatch/agents/` and
+ // `<workdir>/.dispatch/agents/`). Reading those is a
+ // prerequisite for the summon tool's "specify which subagent"
+ // flow — the LLM needs to inspect the TOML to know what each
+ // agent does. We auto-allow READ-ONLY tools under those paths
+ // without prompting the user. Writes (`write_file`) still go
+ // through the normal external_directory gate so an agent can't
+ // quietly overwrite another agent's definition.
+ const isReadOnlyTool =
+ tc.name === "read_file" || tc.name === "read_file_slice" || tc.name === "list_files";
+ let isAgentsDirReadOnly = false;
+ if (isReadOnlyTool) {
+ const agentDirs = getAgentDirPaths(this.config.workingDirectory);
+ const canonicalAgentDirs = await Promise.all(agentDirs.map((d) => canonicalize(d)));
+ isAgentsDirReadOnly = canonicalAgentDirs.some(
+ (d) => resolvedPath === d || resolvedPath.startsWith(`${d}/`),
+ );
+ }
+
+ if (!isUnderWorkdir && !isSpillPath && !isAgentsDirReadOnly) {
const permissionType =
tc.name === "read_file" ? "read" : tc.name === "write_file" ? "edit" : "list";
@@ -715,6 +736,16 @@ export class Agent {
tools,
};
+ // Encourage tool use on Anthropic. Without an explicit
+ // `toolChoice`, Claude (especially Opus 4.7 with adaptive
+ // thinking) can decide to "think forever" instead of calling
+ // the tools it has been given. `"auto"` keeps Claude free to
+ // answer with text when no tool is needed, while making the
+ // availability of tools an explicit signal in the request.
+ if (isClaudeOAuth) {
+ streamOptions.toolChoice = "auto";
+ }
+
if (isClaudeOAuth && effort !== "none") {
// v6 native support for Opus 4.7 adaptive thinking via
// providerOptions. No more rewriteBodyForOpus47 body-
diff --git a/packages/core/src/agents/index.ts b/packages/core/src/agents/index.ts
index 13f6244..4931162 100644
--- a/packages/core/src/agents/index.ts
+++ b/packages/core/src/agents/index.ts
@@ -1 +1,11 @@
-export { deleteAgent, getAgentDirs, loadAgents, saveAgent } from "./loader.js";
+export {
+ deleteAgent,
+ expandAgentToolNames,
+ GLOBAL_AGENTS_DIR,
+ getAgentDirPaths,
+ getAgentDirs,
+ getProjectAgentsDir,
+ loadAgent,
+ loadAgents,
+ saveAgent,
+} from "./loader.js";
diff --git a/packages/core/src/agents/loader.ts b/packages/core/src/agents/loader.ts
index cf84381..333716e 100644
--- a/packages/core/src/agents/loader.ts
+++ b/packages/core/src/agents/loader.ts
@@ -20,9 +20,9 @@ function sanitizeSlug(slug: string): string {
// ─── Constants ───────────────────────────────────────────────────
-const GLOBAL_AGENTS_DIR = path.join(os.homedir(), ".config", "dispatch", "agents");
+export const GLOBAL_AGENTS_DIR = path.join(os.homedir(), ".config", "dispatch", "agents");
-function getProjectAgentsDir(projectDir: string): string {
+export function getProjectAgentsDir(projectDir: string): string {
return path.join(projectDir, ".dispatch", "agents");
}
@@ -49,6 +49,77 @@ export function getAgentDirs(
}
/**
+ * Return just the absolute filesystem paths of the agent directories.
+ * Used by the agent's permission gate to grant read-only access to
+ * these locations by default (so any agent can list/read agent
+ * definitions without prompting the user).
+ */
+export function getAgentDirPaths(projectDir?: string): string[] {
+ const paths = [GLOBAL_AGENTS_DIR];
+ if (projectDir) paths.push(getProjectAgentsDir(projectDir));
+ return paths;
+}
+
+/**
+ * Load a single agent definition by slug. Searches the project-scoped
+ * directory first (if `projectDir` is provided), then falls back to
+ * the global directory. Returns `null` if no match is found.
+ *
+ * Slug matching is exact and case-sensitive; sanitization mirrors
+ * `saveAgent` to keep loader and writer symmetric.
+ */
+export function loadAgent(slug: string, projectDir?: string): AgentDefinition | null {
+ const safeSlug = sanitizeSlug(slug);
+ const agents = loadAgents(projectDir);
+ return agents.find((a) => a.slug === safeSlug) ?? null;
+}
+
+/**
+ * Translate the short permission-group names used by `AgentDefinition.tools`
+ * (e.g. `"read"`, `"edit"`, `"bash"`) into the concrete tool-implementation
+ * names registered with the agent runtime (e.g. `"read_file"`,
+ * `"list_files"`, `"write_file"`, `"run_shell"`).
+ *
+ * The mapping mirrors the per-permission tool-creation paths in
+ * `AgentManager.getOrCreateAgentForTab` so a subagent summoned with a
+ * given agent definition ends up with the exact same set of registered
+ * tools as a top-level tab using that definition. Tool names that aren't
+ * group aliases (`summon`, `retrieve`, `web_search`, `youtube_transcribe`,
+ * `todo`) are passed through unchanged.
+ *
+ * `"todo"` is auto-included so the summoned agent always has its task list
+ * available, matching the parent-agent path which always registers `todo`.
+ */
+export function expandAgentToolNames(tools: string[]): string[] {
+ const expanded = new Set<string>();
+ for (const t of tools) {
+ switch (t) {
+ case "read":
+ expanded.add("read_file");
+ expanded.add("read_file_slice");
+ expanded.add("list_files");
+ break;
+ case "edit":
+ expanded.add("write_file");
+ break;
+ case "bash":
+ expanded.add("run_shell");
+ break;
+ default:
+ // Pass through tool names that aren't permission-group
+ // aliases (summon, retrieve, web_search, youtube_transcribe,
+ // todo, and the granular file tools themselves if a user
+ // hand-wrote them in a TOML).
+ expanded.add(t);
+ }
+ }
+ // Always include `todo` — every agent should be able to track its work,
+ // and the parent-agent path adds it unconditionally.
+ expanded.add("todo");
+ return Array.from(expanded);
+}
+
+/**
* Ensure the default global agent exists. Creates it if missing.
*/
function ensureDefaultAgent(): void {
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 1453a01..74cb159 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -2,7 +2,17 @@
// Agent & LLM
export { Agent } from "./agent/agent.js";
-export { deleteAgent, getAgentDirs, loadAgents, saveAgent } from "./agents/index.js";
+export {
+ deleteAgent,
+ expandAgentToolNames,
+ GLOBAL_AGENTS_DIR,
+ getAgentDirPaths,
+ getAgentDirs,
+ getProjectAgentsDir,
+ loadAgent,
+ loadAgents,
+ saveAgent,
+} from "./agents/index.js";
// Chunk helpers
export {
appendEventToChunks,
@@ -61,7 +71,12 @@ export { createToolRegistry } from "./tools/registry.js";
export { createRetrieveTool, type RetrieveCallbacks } from "./tools/retrieve.js";
export { BackgroundShellStore, createRunShellTool } from "./tools/run-shell.js";
export { analyzeCommand } from "./tools/shell-analyze.js";
-export { createSummonTool, type SummonCallbacks } from "./tools/summon.js";
+export {
+ type AvailableAgent,
+ createSummonTool,
+ type SummonCallbacks,
+ toAvailableAgents,
+} from "./tools/summon.js";
export { createTaskListTool, TaskList } from "./tools/task-list.js";
export { clearSpillForTab } from "./tools/truncate.js";
export { createWebSearchTool } from "./tools/web-search.js";
diff --git a/packages/core/src/tools/registry.ts b/packages/core/src/tools/registry.ts
index a09535e..ff6f4d1 100644
--- a/packages/core/src/tools/registry.ts
+++ b/packages/core/src/tools/registry.ts
@@ -4,6 +4,41 @@ import { zodToJsonSchema } from "zod-to-json-schema";
import type { ToolDefinition } from "../types/index.js";
/**
+ * Strip JSON Schema fields that Anthropic's API does not accept from a
+ * `zodToJsonSchema()` output. The Anthropic `/messages` API rejects (or
+ * silently ignores) tools whose `input_schema` contains `$schema`,
+ * `additionalProperties`, `default`, or `nullable` — when this happens
+ * Claude never sees the tool and the model "thinks forever" instead of
+ * calling it.
+ *
+ * The stripped fields are also harmless to remove for OpenAI-compatible
+ * endpoints, so we apply this unconditionally.
+ */
+function normalizeForAnthropic(schema: Record<string, unknown>): Record<string, unknown> {
+ delete schema.$schema;
+ delete schema.additionalProperties;
+ delete schema.default;
+ delete schema.nullable;
+
+ const properties = schema.properties;
+ if (properties && typeof properties === "object") {
+ for (const key of Object.keys(properties as Record<string, unknown>)) {
+ const prop = (properties as Record<string, unknown>)[key];
+ if (prop && typeof prop === "object") {
+ normalizeForAnthropic(prop as Record<string, unknown>);
+ }
+ }
+ }
+
+ const items = schema.items;
+ if (items && typeof items === "object") {
+ normalizeForAnthropic(items as Record<string, unknown>);
+ }
+
+ return schema;
+}
+
+/**
* Convert an internal `ToolDefinition` (Zod-parameterised) to an AI SDK v6
* `Tool` object.
*
@@ -14,9 +49,11 @@ import type { ToolDefinition } from "../types/index.js";
* `fullStream` that agent.ts collects and dispatches.
*/
function toAISDKTool(def: ToolDefinition): Tool {
+ const raw = zodToJsonSchema(def.parameters) as Record<string, unknown>;
+ const normalized = normalizeForAnthropic(raw);
return tool({
description: def.description,
- inputSchema: jsonSchema(zodToJsonSchema(def.parameters)),
+ inputSchema: jsonSchema(normalized),
});
}
diff --git a/packages/core/src/tools/summon.ts b/packages/core/src/tools/summon.ts
index 22ab35b..ed4b080 100644
--- a/packages/core/src/tools/summon.ts
+++ b/packages/core/src/tools/summon.ts
@@ -1,17 +1,90 @@
import { z } from "zod";
-import type { ToolDefinition } from "../types/index.js";
+import type { AgentDefinition, ToolDefinition } from "../types/index.js";
export interface SummonCallbacks {
- spawn(options: { task: string; tools: string[]; workingDirectory?: string }): Promise<string>;
+ spawn(options: {
+ task: string;
+ tools: string[];
+ workingDirectory?: string;
+ /**
+ * Optional slug of an `AgentDefinition` (loaded from
+ * `~/.config/dispatch/agents/` or `<projectDir>/.dispatch/agents/`)
+ * to use as the basis for the spawned child. When provided,
+ * the definition's tools, models, and cwd override the
+ * `tools` and `workingDirectory` parameters passed alongside.
+ */
+ agentSlug?: string;
+ }): Promise<string>;
getResult(
agentId: string,
): Promise<{ status: "done"; result: string } | { status: "error"; error: string }>;
}
+/**
+ * Summary of an agent definition surfaced to the calling LLM in the
+ * summon tool's description. The shape is intentionally minimal — full
+ * TOML inspection is done by reading the definition file directly,
+ * which all agents are allowed to do by default.
+ */
+export interface AvailableAgent {
+ slug: string;
+ name: string;
+ description: string;
+ /** Filesystem path of the TOML the agent can read for full details. */
+ path: string;
+}
+
+/**
+ * Build the prose paragraph that lists available agent definitions plus
+ * the disk locations where they live, injected into the summon tool's
+ * description.
+ *
+ * Returns the empty string when no agents are visible — keeps the
+ * description compact for environments where no definitions exist yet.
+ */
+function buildAgentsCatalog(agents: AvailableAgent[], agentDirs: string[]): string {
+ const lines: string[] = [];
+ lines.push("");
+ lines.push("Agent definitions live on disk and can be inspected with read_file/list_files:");
+ for (const d of agentDirs) {
+ lines.push(` - ${d}`);
+ }
+ if (agents.length === 0) {
+ lines.push("");
+ lines.push("No agent definitions are currently defined.");
+ return lines.join("\n");
+ }
+ lines.push("");
+ lines.push("To summon a specific agent, pass its slug as the 'agent' parameter.");
+ lines.push("When 'agent' is set, the child inherits that definition's tools, models,");
+ lines.push("and working directory; the 'tools' parameter is ignored.");
+ lines.push("");
+ lines.push("Available agents:");
+ for (const a of agents) {
+ const desc = a.description ? ` — ${a.description}` : "";
+ lines.push(` - ${a.slug}: ${a.name}${desc}`);
+ }
+ return lines.join("\n");
+}
+
+/**
+ * Factory for the `summon` tool. Accepts a snapshot of agent definitions
+ * available at the time the tool is registered so the LLM's view of
+ * which agents exist matches what `spawnChildAgent` can actually load.
+ *
+ * `agentDirs` is the list of filesystem paths the catalog references in
+ * its description; this is information-only — the runtime resolves
+ * slugs through `loadAgent` independently.
+ */
export function createSummonTool(
defaultWorkingDirectory: string,
callbacks: SummonCallbacks,
+ availableAgents: AvailableAgent[] = [],
+ agentDirs: string[] = [],
): ToolDefinition {
+ const catalog = buildAgentsCatalog(availableAgents, agentDirs);
+ const agentSlugs = availableAgents.map((a) => a.slug);
+
return {
name: "summon",
description: [
@@ -38,7 +111,8 @@ export function createSummonTool(
" - web_search: Search the web",
" - youtube_transcribe: Fetch YouTube video transcripts",
"",
- "If tools is omitted, the child gets read_file, list_files, and todo only (read-only by default).",
+ "If tools is omitted (and no 'agent' is specified), the child gets read_file, list_files, and todo only (read-only by default).",
+ catalog,
].join("\n"),
parameters: z.object({
task: z
@@ -46,6 +120,21 @@ export function createSummonTool(
.describe(
"Detailed instructions for the child agent. Be specific about what it should do and what it should return.",
),
+ agent: z
+ .string()
+ .optional()
+ .describe(
+ [
+ "Slug of an agent definition to use as the basis for the child agent.",
+ "When provided, the child inherits the definition's tools, models, and",
+ "working directory; the 'tools' parameter is ignored. Inspect the agent",
+ "directories listed above to discover which slugs are available and what",
+ "each one does.",
+ agentSlugs.length > 0 ? `Available slugs: ${agentSlugs.join(", ")}.` : "",
+ ]
+ .filter(Boolean)
+ .join(" "),
+ ),
tools: z
.array(
z.enum([
@@ -62,13 +151,13 @@ export function createSummonTool(
)
.optional()
.describe(
- 'Tool names to give the child. Defaults to ["read_file", "list_files", "todo"]. Include "summon" and "retrieve" to allow nesting.',
+ 'Tool names to give the child. Defaults to ["read_file", "list_files", "todo"]. Include "summon" and "retrieve" to allow nesting. Ignored when "agent" is set.',
),
working_directory: z
.string()
.optional()
.describe(
- "Absolute path for the child to work in. Defaults to the current working directory.",
+ "Absolute path for the child to work in. Defaults to the current working directory. When 'agent' is set and its definition has a cwd, that takes precedence.",
),
background: z
.boolean()
@@ -79,6 +168,7 @@ export function createSummonTool(
}),
execute: async (args: Record<string, unknown>): Promise<string> => {
const task = args.task as string;
+ const agentSlug = args.agent as string | undefined;
const tools = (args.tools as string[] | undefined) ?? ["read_file", "list_files", "todo"];
const workingDirectory =
(args.working_directory as string | undefined) ?? defaultWorkingDirectory;
@@ -89,6 +179,7 @@ export function createSummonTool(
task,
tools,
workingDirectory,
+ ...(agentSlug ? { agentSlug } : {}),
});
if (!background) {
@@ -113,3 +204,29 @@ export function createSummonTool(
},
};
}
+
+/**
+ * Build the `AvailableAgent[]` projection from a list of full
+ * `AgentDefinition` records. Each entry's `path` is derived from the
+ * scope+slug so the agent can `read_file(path)` directly.
+ */
+export function toAvailableAgents(
+ defs: AgentDefinition[],
+ globalDir: string,
+ projectDir: string | null,
+): AvailableAgent[] {
+ return defs.map((d) => {
+ const baseDir =
+ d.scope === "global"
+ ? globalDir
+ : projectDir
+ ? `${projectDir.replace(/\/$/, "")}/.dispatch/agents`
+ : globalDir;
+ return {
+ slug: d.slug,
+ name: d.name,
+ description: d.description,
+ path: `${baseDir}/${d.slug}.toml`,
+ };
+ });
+}