summaryrefslogtreecommitdiffhomepage
path: root/packages/api/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-02 13:02:08 +0900
committerAdam Malczewski <[email protected]>2026-06-02 13:02:08 +0900
commit2503eba38b7885ab92a9c0e4f082323d1b3a8679 (patch)
treecc9ccc2184510d93c601a98ed08a05fdeb146a6f /packages/api/src
parent3f629a8469fe483243671e1ca15582a111e96541 (diff)
downloaddispatch-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.ts20
-rw-r--r--packages/api/src/app.ts39
-rw-r--r--packages/api/src/routes/agents.ts15
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 } : {}),