summaryrefslogtreecommitdiffhomepage
path: root/packages/api
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/api
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/api')
-rw-r--r--packages/api/src/agent-manager.ts141
1 files changed, 105 insertions, 36 deletions
diff --git a/packages/api/src/agent-manager.ts b/packages/api/src/agent-manager.ts
index 92caf81..88503f3 100644
--- a/packages/api/src/agent-manager.ts
+++ b/packages/api/src/agent-manager.ts
@@ -25,9 +25,14 @@ import {
createWriteFileTool,
createYoutubeTranscribeTool,
type DispatchConfig,
+ expandAgentToolNames,
+ GLOBAL_AGENTS_DIR,
+ getAgentDirPaths,
getClaudeAccountsFromDB,
getMessagesForTab,
getSetting,
+ loadAgent,
+ loadAgents,
loadConfig,
loadSkills,
ModelRegistry,
@@ -39,6 +44,7 @@ import {
type SystemChunkKind,
type TabStatusSnapshot,
TaskList,
+ toAvailableAgents,
updateMessage,
validateConfig,
} from "@dispatch/core";
@@ -429,19 +435,30 @@ export class AgentManager {
}
if (allowed.has("summon")) {
const childParentAllowedTools = new Set(toolEntries.map((e) => e.name));
+ const availableAgents = toAvailableAgents(
+ loadAgents(workingDirectory),
+ GLOBAL_AGENTS_DIR,
+ workingDirectory,
+ );
+ const agentDirPaths = getAgentDirPaths(workingDirectory);
toolEntries.push({
name: "summon",
- tool: createSummonTool(workingDirectory, {
- spawn: (opts) =>
- this.spawnChildAgent({
- ...opts,
- parentKeyId: tabAgent.keyId,
- parentModelId: tabAgent.modelId,
- parentAllowedTools: childParentAllowedTools,
- parentTabId: tabId,
- }),
- getResult: (id) => this.getChildResult(id),
- }),
+ tool: createSummonTool(
+ workingDirectory,
+ {
+ spawn: (opts) =>
+ this.spawnChildAgent({
+ ...opts,
+ parentKeyId: tabAgent.keyId,
+ parentModelId: tabAgent.modelId,
+ parentAllowedTools: childParentAllowedTools,
+ parentTabId: tabId,
+ }),
+ getResult: (id) => this.getChildResult(id),
+ },
+ availableAgents,
+ agentDirPaths,
+ ),
});
}
if (allowed.has("retrieve")) {
@@ -489,19 +506,30 @@ export class AgentManager {
if (permSummon) {
// Capture parent's allowed tool names for child permission enforcement
const parentAllowedTools = new Set(toolEntries.map((e) => e.name));
+ const availableAgents = toAvailableAgents(
+ loadAgents(workingDirectory),
+ GLOBAL_AGENTS_DIR,
+ workingDirectory,
+ );
+ const agentDirPaths = getAgentDirPaths(workingDirectory);
toolEntries.push({
name: "summon",
- tool: createSummonTool(workingDirectory, {
- spawn: (opts) =>
- this.spawnChildAgent({
- ...opts,
- parentKeyId: tabAgent.keyId,
- parentModelId: tabAgent.modelId,
- parentAllowedTools,
- parentTabId: tabId,
- }),
- getResult: (id) => this.getChildResult(id),
- }),
+ tool: createSummonTool(
+ workingDirectory,
+ {
+ spawn: (opts) =>
+ this.spawnChildAgent({
+ ...opts,
+ parentKeyId: tabAgent.keyId,
+ parentModelId: tabAgent.modelId,
+ parentAllowedTools,
+ parentTabId: tabId,
+ }),
+ getResult: (id) => this.getChildResult(id),
+ },
+ availableAgents,
+ agentDirPaths,
+ ),
});
toolEntries.push({
name: "retrieve",
@@ -874,6 +902,14 @@ export class AgentManager {
task: string;
tools: string[];
workingDirectory?: string;
+ /**
+ * Optional slug of an `AgentDefinition` to apply. When set, the
+ * definition's `tools`, `models`, and `cwd` take precedence over
+ * the `tools`/`workingDirectory` passed in `options`. Tools are
+ * still intersected with `parentAllowedTools` to prevent a
+ * subagent from gaining capabilities its parent doesn't have.
+ */
+ agentSlug?: string;
parentKeyId?: string | null;
parentModelId?: string | null;
parentAllowedTools?: Set<string>;
@@ -895,12 +931,28 @@ export class AgentManager {
parentEffectiveDir = join(homedir(), parentEffectiveDir.slice(1));
}
+ // Resolve the agent definition (if a slug was supplied) BEFORE
+ // computing the effective working directory and tool whitelist.
+ // The definition's cwd/tools take precedence over the caller's
+ // `workingDirectory`/`tools` parameters, mirroring how a top-level
+ // tab picking the same definition would behave.
+ let agentDef: ReturnType<typeof loadAgent> = null;
+ if (options.agentSlug) {
+ agentDef = loadAgent(options.agentSlug, parentEffectiveDir);
+ if (!agentDef) {
+ throw new Error(
+ `Agent definition not found: "${options.agentSlug}". Inspect the agents directories to see available slugs.`,
+ );
+ }
+ }
+
// Resolve and validate child working directory against parent's effective dir
- let resolvedWorkingDirectory = options.workingDirectory;
- if (options.workingDirectory) {
+ const requestedDir = agentDef?.cwd ?? options.workingDirectory;
+ let resolvedWorkingDirectory = requestedDir;
+ if (requestedDir) {
const { isAbsolute, relative, resolve, join } = await import("node:path");
// Expand ~ in child working directory
- let childDir = options.workingDirectory;
+ let childDir = requestedDir;
if (childDir === "~" || childDir.startsWith("~/")) {
const { homedir } = await import("node:os");
childDir = join(homedir(), childDir.slice(1));
@@ -911,7 +963,7 @@ export class AgentManager {
const isOutside = rel.startsWith("..") || isAbsolute(rel);
if (isOutside) {
throw new Error(
- `Working directory "${options.workingDirectory}" is outside the parent's working directory "${parentDir}".`,
+ `Working directory "${requestedDir}" is outside the parent's working directory "${parentDir}".`,
);
}
// Store the resolved absolute path so downstream code doesn't
@@ -919,25 +971,42 @@ export class AgentManager {
resolvedWorkingDirectory = resolved;
}
- // Intersect requested tools with parent's allowed tools to prevent privilege escalation
- let childTools = options.tools;
+ // Determine the child's tool whitelist. When an agent definition
+ // was supplied, expand its short permission-group names
+ // (read/edit/bash) into concrete tool names. Otherwise use the
+ // `tools` parameter verbatim. Either way, intersect with
+ // parentAllowedTools so a subagent can't gain capabilities the
+ // parent doesn't have — even an agent definition can't escalate.
+ const baseTools = agentDef ? expandAgentToolNames(agentDef.tools) : options.tools;
+ let childTools = baseTools;
if (options.parentAllowedTools) {
- childTools = options.tools.filter((t) => options.parentAllowedTools?.has(t));
+ childTools = baseTools.filter((t) => options.parentAllowedTools?.has(t));
}
// Create the tab agent entry with overrides
const tabAgent = this._getOrCreateTabAgent(tabId);
tabAgent.toolsOverride = childTools;
tabAgent.workingDirectoryOverride = resolvedWorkingDirectory;
- tabAgent.keyId = options.parentKeyId ?? null;
- tabAgent.modelId = options.parentModelId ?? null;
tabAgent.finalOutput = "";
- // Inherit parent's agent fallback models
- if (options.parentTabId) {
- const parentAgent = this.tabAgents.get(options.parentTabId);
- if (parentAgent?.agentModels) {
- tabAgent.agentModels = parentAgent.agentModels;
+ if (agentDef && agentDef.models.length > 0) {
+ // The agent definition specifies its own model fallback chain.
+ // Clear keyId/modelId so the fallback sequence uses the
+ // definition's models (matches how a top-level tab using this
+ // definition would be configured).
+ tabAgent.keyId = null;
+ tabAgent.modelId = null;
+ tabAgent.agentModels = agentDef.models;
+ } else {
+ // No definition (or definition has no models) → inherit from
+ // the parent like before.
+ tabAgent.keyId = options.parentKeyId ?? null;
+ tabAgent.modelId = options.parentModelId ?? null;
+ if (options.parentTabId) {
+ const parentAgent = this.tabAgents.get(options.parentTabId);
+ if (parentAgent?.agentModels) {
+ tabAgent.agentModels = parentAgent.agentModels;
+ }
}
}