diff options
| author | Adam Malczewski <[email protected]> | 2026-06-02 13:02:08 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-02 13:02:08 +0900 |
| commit | 2503eba38b7885ab92a9c0e4f082323d1b3a8679 (patch) | |
| tree | cc9ccc2184510d93c601a98ed08a05fdeb146a6f /packages/api/src | |
| parent | 3f629a8469fe483243671e1ca15582a111e96541 (diff) | |
| download | dispatch-2503eba38b7885ab92a9c0e4f082323d1b3a8679.tar.gz dispatch-2503eba38b7885ab92a9c0e4f082323d1b3a8679.zip | |
feat(agents): per-model reasoning effort level
Add a per-model/key reasoning effort setting to agent definitions,
surfaced and editable in the Agent Settings page and displayed at a
glance in the model selector views.
- core: single source of truth for effort levels (REASONING_EFFORTS,
DEFAULT_REASONING_EFFORT='high', labels, isReasoningEffort guard);
add 'xhigh' level; AgentModelEntry.effort; xhigh budget=24000 for
classic-thinking Claude; default floor 'high'. Persist/parse effort
in the agent TOML loader.
- api: thread effort through the fallback chain with per-model -> per-tab
-> default precedence; validate /chat + agentModels effort from the
canonical list.
- frontend: effort <select> per model row in AgentBuilder; effort badges
in ModelSelector (agent + subagent chains); Thinking dropdown sourced
from canonical list; per-tab default raised to 'high'.
- tests: +15 (loader round-trip, agent xhigh budget, canonical list +
guard, api precedence, route validation).
Diffstat (limited to 'packages/api/src')
| -rw-r--r-- | packages/api/src/agent-manager.ts | 20 | ||||
| -rw-r--r-- | packages/api/src/app.ts | 39 | ||||
| -rw-r--r-- | packages/api/src/routes/agents.ts | 15 |
3 files changed, 58 insertions, 16 deletions
diff --git a/packages/api/src/agent-manager.ts b/packages/api/src/agent-manager.ts index 5a0ffdf..d03e696 100644 --- a/packages/api/src/agent-manager.ts +++ b/packages/api/src/agent-manager.ts @@ -1,6 +1,7 @@ import { Agent, type AgentEvent, + type AgentModelEntry, type AgentSkillMapping, type AgentStatus, appendChunks, @@ -42,6 +43,7 @@ import { loadSkills, ModelRegistry, type QueuedMessage, + type ReasoningEffort, refreshAccountCredentials, refreshAccountCredentialsAsync, resolveApiKey, @@ -172,7 +174,7 @@ interface TabAgent { taskList: TaskList; _lastPermKey?: string; /** Ordered key+model fallback hierarchy from the agent definition. */ - agentModels?: Array<{ key_id: string; model_id: string }>; + agentModels?: AgentModelEntry[]; /** Abort controller for cancelling a running agent. */ abortController?: AbortController; /** For child agents: resolves when the agent finishes its task. */ @@ -1333,8 +1335,8 @@ export class AgentManager { opts: { keyId?: string; modelId?: string; - agentModels?: Array<{ key_id: string; model_id: string }>; - reasoningEffort?: "none" | "low" | "medium" | "high" | "max"; + agentModels?: AgentModelEntry[]; + reasoningEffort?: ReasoningEffort; workingDirectory?: string; queueId?: string; /** @@ -1419,9 +1421,9 @@ export class AgentManager { message: string, keyId?: string, modelId?: string, - reasoningEffort?: "none" | "low" | "medium" | "high" | "max", + reasoningEffort?: ReasoningEffort, workingDirectory?: string, - agentModels?: Array<{ key_id: string; model_id: string }>, + agentModels?: AgentModelEntry[], ): Promise<void> { const tabAgent = this._getOrCreateTabAgent(tabId); @@ -1471,6 +1473,10 @@ export class AgentManager { // to the tabAgent's stored defaults via the `?? tabAgent.keyId` chain. currentKeyId = entry.key_id || undefined; currentModelId = entry.model_id || undefined; + // Effort precedence: per-model (agent definition) → per-tab selector + // (the `reasoningEffort` arg) → the Agent's own DEFAULT_REASONING_EFFORT + // floor (applied inside `agent.run`). + const effortForEntry = entry.effort ?? reasoningEffort; allOutput = ""; // Single ordered chunk list accumulating this attempt's assistant @@ -1514,7 +1520,7 @@ export class AgentManager { } for await (const event of agent.run(message, { - ...(reasoningEffort ? { reasoningEffort } : {}), + ...(effortForEntry ? { reasoningEffort: effortForEntry } : {}), abortSignal: tabAgent.abortController?.signal, })) { // Stop processing if the tab was aborted (closed/stopped). @@ -1703,7 +1709,7 @@ export class AgentManager { tabAgent: TabAgent, keyId?: string, modelId?: string, - ): Array<{ key_id: string; model_id: string }> { + ): AgentModelEntry[] { // Agent mode: use the agent's configured fallback hierarchy in strict order const models = tabAgent.agentModels; if (models && models.length > 0) { diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts index 0dabb0d..84afd2a 100644 --- a/packages/api/src/app.ts +++ b/packages/api/src/app.ts @@ -1,4 +1,9 @@ -import { getTab, NotificationDispatcher } from "@dispatch/core"; +import { + type AgentModelEntry, + getTab, + isReasoningEffort, + NotificationDispatcher, +} from "@dispatch/core"; import { Hono } from "hono"; import { cors } from "hono/cors"; import { AgentManager } from "./agent-manager.js"; @@ -10,6 +15,28 @@ import { notificationsRoutes } from "./routes/notifications.js"; import { skillsRoutes } from "./routes/skills.js"; import { tabsRoutes } from "./routes/tabs.js"; +/** + * Validate and normalise the `agentModels` fallback chain coming from the + * frontend. Each entry must carry string `key_id`/`model_id`; an `effort` is + * kept only when it's a recognised level (otherwise dropped so the per-tab / + * default effort applies). Returns `undefined` when the input isn't an array. + */ +function sanitizeAgentModels(raw: unknown): AgentModelEntry[] | undefined { + if (!Array.isArray(raw)) return undefined; + const out: AgentModelEntry[] = []; + for (const m of raw) { + if (!m || typeof m !== "object") continue; + const entry = m as Record<string, unknown>; + if (typeof entry.key_id !== "string" || typeof entry.model_id !== "string") continue; + out.push({ + key_id: entry.key_id, + model_id: entry.model_id, + ...(isReasoningEffort(entry.effort) ? { effort: entry.effort } : {}), + }); + } + return out; +} + export const permissionManager = new PermissionManager(); export const agentManager = new AgentManager(permissionManager); @@ -86,15 +113,13 @@ app.post("/chat", async (c) => { const keyId = typeof body.keyId === "string" ? body.keyId : undefined; const modelId = typeof body.modelId === "string" ? body.modelId : undefined; - const agentModels = Array.isArray(body.agentModels) ? body.agentModels : undefined; + const agentModels = sanitizeAgentModels(body.agentModels); const workingDirectory = typeof body.workingDirectory === "string" ? body.workingDirectory : undefined; const queueId = typeof body.queueId === "string" ? body.queueId : undefined; - const validEfforts = ["none", "low", "medium", "high", "max"]; - const reasoningEffort = - typeof body.reasoningEffort === "string" && validEfforts.includes(body.reasoningEffort) - ? (body.reasoningEffort as "none" | "low" | "medium" | "high" | "max") - : undefined; + const reasoningEffort = isReasoningEffort(body.reasoningEffort) + ? body.reasoningEffort + : undefined; // Single routing decision (queue if busy, new turn if idle) shared with the // `send_to_tab` tool via `AgentManager.deliverMessage`. Non-blocking — a diff --git a/packages/api/src/routes/agents.ts b/packages/api/src/routes/agents.ts index 1627674..10ca714 100644 --- a/packages/api/src/routes/agents.ts +++ b/packages/api/src/routes/agents.ts @@ -2,7 +2,13 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import type { AgentDefinition } from "@dispatch/core"; -import { deleteAgent, getAgentDirs, loadAgents, saveAgent } from "@dispatch/core"; +import { + deleteAgent, + getAgentDirs, + isReasoningEffort, + loadAgents, + saveAgent, +} from "@dispatch/core"; import { Hono } from "hono"; const SAFE_SLUG_RE = /^[a-zA-Z0-9_-]+$/; @@ -52,7 +58,12 @@ agentsRoutes.post("/", async (c) => { description: body.description || "", skills: body.skills || [], tools: body.tools || [], - models: body.models || [], + models: (body.models || []).map((m) => ({ + key_id: m.key_id, + model_id: m.model_id, + // Keep `effort` only when it's a recognised level; drop anything else. + ...(isReasoningEffort(m.effort) ? { effort: m.effort } : {}), + })), scope: body.scope, slug: body.slug, ...(body.cwd ? { cwd: body.cwd } : {}), |
