summaryrefslogtreecommitdiffhomepage
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
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).
-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
-rw-r--r--packages/api/tests/agent-manager.test.ts64
-rw-r--r--packages/api/tests/routes.test.ts33
-rw-r--r--packages/core/src/agent/agent.tsbin57720 -> 57822 bytes
-rw-r--r--packages/core/src/agents/loader.ts8
-rw-r--r--packages/core/src/types/index.ts44
-rw-r--r--packages/core/tests/agent/agent.test.ts21
-rw-r--r--packages/core/tests/agents/loader.test.ts77
-rw-r--r--packages/core/tests/types/reasoning-effort.test.ts48
-rw-r--r--packages/frontend/src/App.svelte11
-rw-r--r--packages/frontend/src/lib/components/AgentBuilder.svelte20
-rw-r--r--packages/frontend/src/lib/components/ModelSelector.svelte39
-rw-r--r--packages/frontend/src/lib/components/SidebarPanel.svelte4
-rw-r--r--packages/frontend/src/lib/tabs.svelte.ts40
16 files changed, 430 insertions, 53 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 } : {}),
diff --git a/packages/api/tests/agent-manager.test.ts b/packages/api/tests/agent-manager.test.ts
index b9b4510..ffd87b5 100644
--- a/packages/api/tests/agent-manager.test.ts
+++ b/packages/api/tests/agent-manager.test.ts
@@ -80,6 +80,13 @@ function resetConstructedAgents(): void {
constructedAgents.length = 0;
}
+// Capture the per-call `run()` options (notably reasoningEffort) so tests can
+// assert the per-model → per-tab → default effort resolution.
+const capturedRunOptions: Array<{ reasoningEffort?: string } | undefined> = [];
+function resetCapturedRunOptions(): void {
+ capturedRunOptions.length = 0;
+}
+
// Configurable settings store so tests can toggle tool permissions
// (perm_send_to_tab / perm_read_tab / ...) and assert which tools the
// constructed Agent receives. Defaults to empty (getSetting → null).
@@ -135,7 +142,7 @@ vi.mock("@dispatch/core", () => ({
constructor(config: { tools?: Array<{ name: string }> }) {
this.toolNames = (config?.tools ?? []).map((t) => t.name);
}
- async *run(message: string): AsyncGenerator<unknown> {
+ async *run(message: string, options?: { reasoningEffort?: string }): AsyncGenerator<unknown> {
// Snapshot the post-construction pre-populated message list
// the first thing `run()` does, before the real `Agent.run`
// would push the current user message at line 546. Tests
@@ -144,6 +151,7 @@ vi.mock("@dispatch/core", () => ({
initialMessages: [...this.messages],
toolNames: [...this.toolNames],
});
+ capturedRunOptions.push(options);
if (runImpl) {
for await (const ev of runImpl(message)) yield ev;
return;
@@ -345,6 +353,11 @@ vi.mock("@dispatch/core", () => ({
getSetting(key: string) {
return fakeSettings.get(key) ?? null;
},
+ isReasoningEffort(value: unknown) {
+ return (
+ typeof value === "string" && ["none", "low", "medium", "high", "xhigh", "max"].includes(value)
+ );
+ },
appendChunks() {
return [];
},
@@ -402,6 +415,7 @@ describe("AgentManager", () => {
beforeEach(() => {
resetFakeMessages();
resetConstructedAgents();
+ resetCapturedRunOptions();
resetFakeTabs();
resetFakeSettings();
setRunImpl(null);
@@ -519,6 +533,54 @@ describe("AgentManager", () => {
expect(listener2).toHaveBeenCalled();
});
+ // ─── per-model reasoning effort precedence ───────────────────────
+
+ describe("reasoning effort precedence (per-model → per-tab → default)", () => {
+ it("uses the per-model effort over the per-tab selector for that fallback entry", async () => {
+ const manager = new AgentManager();
+ // Agent definition supplies a fallback chain where each entry has its
+ // own configured effort; the per-tab selector ("low") must NOT win.
+ await manager.processMessage(
+ "tab-effort-permodel",
+ "go",
+ "key-a",
+ "model-a",
+ "low",
+ undefined,
+ [{ key_id: "key-a", model_id: "model-a", effort: "xhigh" }],
+ );
+ expect(capturedRunOptions.at(-1)?.reasoningEffort).toBe("xhigh");
+ });
+
+ it("falls back to the per-tab selector when the model entry has no effort", async () => {
+ const manager = new AgentManager();
+ await manager.processMessage(
+ "tab-effort-tab",
+ "go",
+ "key-a",
+ "model-a",
+ "medium",
+ undefined,
+ [{ key_id: "key-a", model_id: "model-a" }],
+ );
+ expect(capturedRunOptions.at(-1)?.reasoningEffort).toBe("medium");
+ });
+
+ it("passes no effort (Agent applies its default) when neither is set", async () => {
+ const manager = new AgentManager();
+ await manager.processMessage(
+ "tab-effort-default",
+ "go",
+ "key-a",
+ "model-a",
+ undefined,
+ undefined,
+ [{ key_id: "key-a", model_id: "model-a" }],
+ );
+ expect(capturedRunOptions.at(-1)?.reasoningEffort).toBeUndefined();
+ });
+ });
+
// ─── v6 reasoning-end tests ───────────────────────────────────────
it("reasoning-end event is broadcast to WS listeners", async () => {
diff --git a/packages/api/tests/routes.test.ts b/packages/api/tests/routes.test.ts
index c768cee..3bf446d 100644
--- a/packages/api/tests/routes.test.ts
+++ b/packages/api/tests/routes.test.ts
@@ -169,6 +169,11 @@ vi.mock("@dispatch/core", () => ({
getTab() {
return null;
},
+ isReasoningEffort(value: unknown) {
+ return (
+ typeof value === "string" && ["none", "low", "medium", "high", "xhigh", "max"].includes(value)
+ );
+ },
listOpenTabs() {
return [];
},
@@ -353,6 +358,34 @@ describe("POST /chat", () => {
expect(body).toEqual({ status: "ok" });
});
+ it("accepts xhigh as a valid reasoningEffort", async () => {
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ tabId: "tab-xhigh",
+ message: "hello",
+ reasoningEffort: "xhigh",
+ }),
+ });
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({ status: "ok" });
+ });
+
+ it("tolerates an invalid agentModels effort (sanitized, not rejected)", async () => {
+ const res = await app.request("/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ tabId: "tab-badeffort",
+ message: "hello",
+ agentModels: [{ key_id: "k", model_id: "m", effort: "turbo" }],
+ }),
+ });
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({ status: "ok" });
+ });
+
it("returns 400 with empty message", async () => {
const res = await app.request("/chat", {
method: "POST",
diff --git a/packages/core/src/agent/agent.ts b/packages/core/src/agent/agent.ts
index c6f1322..4bfa7eb 100644
--- a/packages/core/src/agent/agent.ts
+++ b/packages/core/src/agent/agent.ts
Binary files differ
diff --git a/packages/core/src/agents/loader.ts b/packages/core/src/agents/loader.ts
index f4a6c5a..9971803 100644
--- a/packages/core/src/agents/loader.ts
+++ b/packages/core/src/agents/loader.ts
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { parse as parseTOML, stringify as stringifyTOML } from "smol-toml";
-import type { AgentDefinition, AgentModelEntry } from "../types/index.js";
+import { type AgentDefinition, type AgentModelEntry, isReasoningEffort } from "../types/index.js";
// ─── Helpers ─────────────────────────────────────────────────────
@@ -192,6 +192,7 @@ export function saveAgent(agent: AgentDefinition): void {
tomlContent.models = agent.models.map((m) => ({
key_id: m.key_id,
model_id: m.model_id,
+ ...(m.effort ? { effort: m.effort } : {}),
}));
}
@@ -246,9 +247,14 @@ function loadAgentsFromDir(dir: string, scope: string): AgentDefinition[] {
if (Array.isArray(parsed.models)) {
for (const m of parsed.models) {
if (m && typeof m === "object" && "key_id" in m && "model_id" in m) {
+ const rawEffort = (m as Record<string, unknown>).effort;
models.push({
key_id: String((m as Record<string, unknown>).key_id),
model_id: String((m as Record<string, unknown>).model_id),
+ // Only carry `effort` when it's a recognised level; an
+ // unset or invalid value falls back to the per-tab /
+ // default effort at the call site.
+ ...(isReasoningEffort(rawEffort) ? { effort: rawEffort } : {}),
});
}
}
diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts
index ced3dc2..bab17f1 100644
--- a/packages/core/src/types/index.ts
+++ b/packages/core/src/types/index.ts
@@ -308,7 +308,43 @@ export interface ToolDefinition {
// ─── Agent Configuration ─────────────────────────────────────────
-export type ReasoningEffort = "none" | "low" | "medium" | "high" | "max";
+/**
+ * Canonical, ordered list of reasoning-effort levels — the SINGLE SOURCE OF
+ * TRUTH for effort values across the whole codebase (core LLM call site, API
+ * validation, agent TOML persistence, and the frontend UI). Ordered from least
+ * to most effort.
+ *
+ * `none` disables reasoning. `low`/`medium`/`high`/`xhigh` are forwarded
+ * verbatim to providers that accept them (OpenAI-compatible `reasoning_effort`,
+ * Anthropic adaptive `effort`) — `xhigh` is accepted by newer OpenAI reasoning
+ * models. `max` is Dispatch's own top tier, mapped per-provider at the call
+ * site (e.g. classic-thinking Claude budget tokens).
+ */
+export const REASONING_EFFORTS = ["none", "low", "medium", "high", "xhigh", "max"] as const;
+
+export type ReasoningEffort = (typeof REASONING_EFFORTS)[number];
+
+/**
+ * Default effort applied when nothing more specific is configured (no per-model
+ * effort and no per-tab selection). Resolution order is
+ * per-model → per-tab → this default.
+ */
+export const DEFAULT_REASONING_EFFORT: ReasoningEffort = "high";
+
+/** Human-readable labels for each effort level (UI display). */
+export const REASONING_EFFORT_LABELS: Record<ReasoningEffort, string> = {
+ none: "Off",
+ low: "Low",
+ medium: "Medium",
+ high: "High",
+ xhigh: "X-High",
+ max: "Max",
+};
+
+/** Runtime type guard for narrowing an arbitrary value to a `ReasoningEffort`. */
+export function isReasoningEffort(value: unknown): value is ReasoningEffort {
+ return typeof value === "string" && (REASONING_EFFORTS as readonly string[]).includes(value);
+}
export interface AgentConfig {
model: string;
@@ -416,6 +452,12 @@ export interface QueueCallbacks {
export interface AgentModelEntry {
key_id: string;
model_id: string;
+ /**
+ * Per-model/key reasoning effort. When set, overrides the per-tab effort
+ * selector for generations that use this entry (resolution order:
+ * per-model → per-tab → DEFAULT_REASONING_EFFORT). Omitted when unset.
+ */
+ effort?: ReasoningEffort;
}
export interface AgentDefinition {
diff --git a/packages/core/tests/agent/agent.test.ts b/packages/core/tests/agent/agent.test.ts
index 188129c..d8edec7 100644
--- a/packages/core/tests/agent/agent.test.ts
+++ b/packages/core/tests/agent/agent.test.ts
@@ -1514,7 +1514,7 @@ describe("anthropicThinkingProviderOptions — adaptive-thinking model detection
});
it("maps reasoning effort → budgetTokens for enabled (non-adaptive) models", () => {
- const budget = (e: "low" | "medium" | "high" | "max") => {
+ const budget = (e: "low" | "medium" | "high" | "xhigh" | "max") => {
const opts = anthropicThinkingProviderOptions("claude-3-7-sonnet", e) as {
thinking: { type: "enabled"; budgetTokens: number };
};
@@ -1523,6 +1523,25 @@ describe("anthropicThinkingProviderOptions — adaptive-thinking model detection
expect(budget("low")).toBe(2000);
expect(budget("medium")).toBe(5000);
expect(budget("high")).toBe(16000);
+ expect(budget("xhigh")).toBe(24000);
expect(budget("max")).toBe(31999);
});
+
+ it("xhigh budget sits strictly between high and max (ordering invariant)", () => {
+ const budget = (e: "high" | "xhigh" | "max") => {
+ const opts = anthropicThinkingProviderOptions("claude-3-7-sonnet", e) as {
+ thinking: { type: "enabled"; budgetTokens: number };
+ };
+ return opts.thinking.budgetTokens;
+ };
+ expect(budget("high")).toBeLessThan(budget("xhigh"));
+ expect(budget("xhigh")).toBeLessThan(budget("max"));
+ });
+
+ it("forwards xhigh verbatim as the adaptive effort sibling (Opus 4.7+)", () => {
+ expect(anthropicThinkingProviderOptions("claude-opus-4-8", "xhigh")).toEqual({
+ thinking: { type: "adaptive", display: "summarized" },
+ effort: "xhigh",
+ });
+ });
});
diff --git a/packages/core/tests/agents/loader.test.ts b/packages/core/tests/agents/loader.test.ts
index 35ab0cf..92f9877 100644
--- a/packages/core/tests/agents/loader.test.ts
+++ b/packages/core/tests/agents/loader.test.ts
@@ -2,7 +2,12 @@ import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
-import { expandAgentToolNames, getAgentDirPaths, loadAgent } from "../../src/agents/loader.js";
+import {
+ expandAgentToolNames,
+ getAgentDirPaths,
+ loadAgent,
+ saveAgent,
+} from "../../src/agents/loader.js";
describe("expandAgentToolNames", () => {
it("expands 'read' into the granular read tools", () => {
@@ -145,6 +150,76 @@ describe("loadAgent — project-scoped sandbox", () => {
expect(agent?.scope).toBe(tmpProject);
});
+ it("parses a per-model effort when it is a recognised level", () => {
+ writeAgentToml(
+ TEST_SLUG,
+ [
+ 'name = "Fixture"',
+ "skills = []",
+ 'tools = ["read"]',
+ "",
+ "[[models]]",
+ 'key_id = "opencode-1"',
+ 'model_id = "deepseek-v4-flash"',
+ 'effort = "low"',
+ "",
+ "[[models]]",
+ 'key_id = "claude-max"',
+ 'model_id = "claude-opus-4-8"',
+ 'effort = "xhigh"',
+ "",
+ ].join("\n"),
+ );
+
+ const agent = loadAgent(TEST_SLUG, tmpProject);
+ expect(agent?.models).toEqual([
+ { key_id: "opencode-1", model_id: "deepseek-v4-flash", effort: "low" },
+ { key_id: "claude-max", model_id: "claude-opus-4-8", effort: "xhigh" },
+ ]);
+ });
+
+ it("drops an invalid effort value so the call site falls back to the default", () => {
+ writeAgentToml(
+ TEST_SLUG,
+ [
+ 'name = "Fixture"',
+ "skills = []",
+ 'tools = ["read"]',
+ "",
+ "[[models]]",
+ 'key_id = "opencode-1"',
+ 'model_id = "deepseek-v4-flash"',
+ 'effort = "turbo"',
+ "",
+ ].join("\n"),
+ );
+
+ const agent = loadAgent(TEST_SLUG, tmpProject);
+ expect(agent?.models).toEqual([{ key_id: "opencode-1", model_id: "deepseek-v4-flash" }]);
+ expect(agent?.models[0]).not.toHaveProperty("effort");
+ });
+
+ it("round-trips effort through saveAgent → loadAgent", () => {
+ saveAgent({
+ name: "Fixture",
+ description: "",
+ skills: [],
+ tools: ["read"],
+ models: [
+ { key_id: "opencode-1", model_id: "deepseek-v4-flash", effort: "medium" },
+ { key_id: "claude-max", model_id: "claude-opus-4-8" },
+ ],
+ scope: tmpProject,
+ slug: TEST_SLUG,
+ });
+
+ const agent = loadAgent(TEST_SLUG, tmpProject);
+ expect(agent?.models).toEqual([
+ { key_id: "opencode-1", model_id: "deepseek-v4-flash", effort: "medium" },
+ { key_id: "claude-max", model_id: "claude-opus-4-8" },
+ ]);
+ });
+
it("sanitizes the slug so path traversal can't reach outside the agents dir", () => {
// Even if a caller passes something gnarly, the lookup is by
// sanitized slug — no file outside the configured dirs should
diff --git a/packages/core/tests/types/reasoning-effort.test.ts b/packages/core/tests/types/reasoning-effort.test.ts
new file mode 100644
index 0000000..97cd26c
--- /dev/null
+++ b/packages/core/tests/types/reasoning-effort.test.ts
@@ -0,0 +1,48 @@
+import { describe, expect, it } from "vitest";
+import {
+ DEFAULT_REASONING_EFFORT,
+ isReasoningEffort,
+ REASONING_EFFORT_LABELS,
+ REASONING_EFFORTS,
+} from "../../src/types/index.js";
+
+describe("REASONING_EFFORTS — canonical effort list (single source of truth)", () => {
+ it("is ordered least→most and includes xhigh between high and max", () => {
+ expect(REASONING_EFFORTS).toEqual(["none", "low", "medium", "high", "xhigh", "max"]);
+ const hi = REASONING_EFFORTS.indexOf("high");
+ const xhi = REASONING_EFFORTS.indexOf("xhigh");
+ const mx = REASONING_EFFORTS.indexOf("max");
+ expect(hi).toBeLessThan(xhi);
+ expect(xhi).toBeLessThan(mx);
+ });
+
+ it("has a human-readable label for every level (no gaps)", () => {
+ for (const effort of REASONING_EFFORTS) {
+ expect(REASONING_EFFORT_LABELS[effort]).toBeTruthy();
+ }
+ expect(Object.keys(REASONING_EFFORT_LABELS).sort()).toEqual([...REASONING_EFFORTS].sort());
+ });
+
+ it("defaults to high", () => {
+ expect(DEFAULT_REASONING_EFFORT).toBe("high");
+ expect(REASONING_EFFORTS).toContain(DEFAULT_REASONING_EFFORT);
+ });
+});
+
+describe("isReasoningEffort", () => {
+ it("accepts every canonical level", () => {
+ for (const effort of REASONING_EFFORTS) {
+ expect(isReasoningEffort(effort)).toBe(true);
+ }
+ });
+
+ it("rejects unknown strings and non-strings", () => {
+ expect(isReasoningEffort("turbo")).toBe(false);
+ expect(isReasoningEffort("HIGH")).toBe(false);
+ expect(isReasoningEffort("")).toBe(false);
+ expect(isReasoningEffort(undefined)).toBe(false);
+ expect(isReasoningEffort(null)).toBe(false);
+ expect(isReasoningEffort(3)).toBe(false);
+ expect(isReasoningEffort({})).toBe(false);
+ });
+});
diff --git a/packages/frontend/src/App.svelte b/packages/frontend/src/App.svelte
index eaa28e8..3f1c500 100644
--- a/packages/frontend/src/App.svelte
+++ b/packages/frontend/src/App.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+import { DEFAULT_REASONING_EFFORT } from "@dispatch/core/src/types/index.js";
import { onMount } from "svelte";
import AgentBuilder from "./lib/components/AgentBuilder.svelte";
import ChatInput from "./lib/components/ChatInput.svelte";
@@ -141,20 +142,14 @@ onMount(() => {
apiBase={config.apiBase}
activeKeyId={tabStore.activeTab?.keyId ?? null}
activeModelId={tabStore.activeTab?.modelId ?? null}
- reasoningEffort={tabStore.activeTab?.reasoningEffort ?? "max"}
+ reasoningEffort={tabStore.activeTab?.reasoningEffort ?? DEFAULT_REASONING_EFFORT}
activeAgentSlug={tabStore.activeTab?.agentSlug ?? null}
activeTabParentId={tabStore.activeTab?.parentTabId ?? null}
activeAgentModels={tabStore.activeTab?.agentModels ?? null}
workingDirectory={tabStore.activeTab?.workingDirectory ?? null}
onKeyChange={(keyId) => tabStore.setKey(keyId)}
onModelChange={(keyId, modelId) => tabStore.changeModel(keyId, modelId)}
- onReasoningChange={(effort) => {
- const tab = tabStore.activeTab;
- if (tab) {
- // Update reasoning effort for active tab
- tabStore.tabs.find(t => t.id === tab.id)!.reasoningEffort = effort;
- }
- }}
+ onReasoningChange={(effort) => tabStore.setReasoningEffort(effort)}
onAgentChange={(agent) => tabStore.setAgent(agent)}
onWorkingDirectoryChange={(dir) => tabStore.setWorkingDirectory(dir)}
onAddKey={() => { showAddKeyModal = true; addKeyId = ""; addKeyProvider = "anthropic"; addKeyError = null; }}
diff --git a/packages/frontend/src/lib/components/AgentBuilder.svelte b/packages/frontend/src/lib/components/AgentBuilder.svelte
index d8ae530..bbdb83c 100644
--- a/packages/frontend/src/lib/components/AgentBuilder.svelte
+++ b/packages/frontend/src/lib/components/AgentBuilder.svelte
@@ -4,6 +4,11 @@ const modelCache = new Map();
</script>
<script lang="ts">
+ import {
+ DEFAULT_REASONING_EFFORT,
+ REASONING_EFFORTS,
+ REASONING_EFFORT_LABELS,
+ } from "@dispatch/core/src/types/index.js";
import { config } from "../config.js";
import { router } from "../router.svelte.js";
import type { KeyInfo } from "../types.js";
@@ -13,6 +18,7 @@ const modelCache = new Map();
interface AgentModelEntry {
key_id: string;
model_id: string;
+ effort?: string;
}
interface AgentDefinition {
@@ -173,6 +179,10 @@ const modelCache = new Map();
formModels = formModels.filter((_, idx) => idx !== i);
}
+ function setEffortEntry(i: number, effort: string) {
+ formModels = formModels.map((m, idx) => (idx === i ? { ...m, effort } : m));
+ }
+
async function openKeyModal(i: number) {
modelModalIndex = i;
modelModalType = "key";
@@ -546,6 +556,16 @@ const modelCache = new Map();
>
{entry.model_id || "Select Model"}
</button>
+ <select
+ class="select select-bordered select-sm shrink-0 w-28"
+ title="Reasoning effort for this model"
+ value={entry.effort ?? DEFAULT_REASONING_EFFORT}
+ onchange={(e) => setEffortEntry(i, e.currentTarget.value)}
+ >
+ {#each REASONING_EFFORTS as effort}
+ <option value={effort}>{REASONING_EFFORT_LABELS[effort]}</option>
+ {/each}
+ </select>
<button
type="button"
class="btn btn-sm btn-ghost text-error"
diff --git a/packages/frontend/src/lib/components/ModelSelector.svelte b/packages/frontend/src/lib/components/ModelSelector.svelte
index a752f5d..c328511 100644
--- a/packages/frontend/src/lib/components/ModelSelector.svelte
+++ b/packages/frontend/src/lib/components/ModelSelector.svelte
@@ -3,6 +3,12 @@ const modelCache = new Map<string, string[]>();
</script>
<script lang="ts">
+ import {
+ DEFAULT_REASONING_EFFORT,
+ isReasoningEffort,
+ REASONING_EFFORTS,
+ REASONING_EFFORT_LABELS,
+ } from "@dispatch/core/src/types/index.js";
import type { KeyInfo } from "../types.js";
import { config } from "../config.js";
import { router } from "../router.svelte.js";
@@ -14,7 +20,7 @@ const modelCache = new Map<string, string[]>();
description: string;
skills: string[];
tools: string[];
- models: Array<{ key_id: string; model_id: string }>;
+ models: Array<{ key_id: string; model_id: string; effort?: string }>;
cwd?: string;
is_subagent?: boolean;
}
@@ -30,6 +36,15 @@ const modelCache = new Map<string, string[]>();
};
}
+ /**
+ * Human-readable effort label for a (possibly-unset / arbitrary) effort
+ * string. Falls back to the default level's label when unset/invalid so the
+ * displayed badge always reflects what will actually be used.
+ */
+ function effortLabel(effort: string | undefined): string {
+ return REASONING_EFFORT_LABELS[isReasoningEffort(effort) ? effort : DEFAULT_REASONING_EFFORT];
+ }
+
const {
keys = [],
activeKeyId = null,
@@ -37,7 +52,7 @@ const modelCache = new Map<string, string[]>();
reasoningEffort = "max",
activeAgentSlug = null,
activeTabParentId = null as string | null,
- activeAgentModels = null as Array<{ key_id: string; model_id: string }> | null,
+ activeAgentModels = null as Array<{ key_id: string; model_id: string; effort?: string }> | null,
workingDirectory = null,
onKeyChange,
onModelChange,
@@ -51,7 +66,7 @@ const modelCache = new Map<string, string[]>();
reasoningEffort?: string;
activeAgentSlug?: string | null;
activeTabParentId?: string | null;
- activeAgentModels?: Array<{ key_id: string; model_id: string }> | null;
+ activeAgentModels?: Array<{ key_id: string; model_id: string; effort?: string }> | null;
workingDirectory?: string | null;
onKeyChange: (keyId: string) => void;
onModelChange: (keyId: string, modelId: string) => void;
@@ -264,11 +279,9 @@ const modelCache = new Map<string, string[]>();
value={reasoningEffort}
onchange={(e) => onReasoningChange(e.currentTarget.value)}
>
- <option value="none">Off</option>
- <option value="low">Low</option>
- <option value="medium">Medium</option>
- <option value="high">High</option>
- <option value="max">Max</option>
+ {#each REASONING_EFFORTS as effort}
+ <option value={effort}>{REASONING_EFFORT_LABELS[effort]}</option>
+ {/each}
</select>
</div>
{/if}
@@ -312,8 +325,9 @@ const modelCache = new Map<string, string[]>();
</div>
<div class="mt-1 flex flex-col gap-0.5">
{#each subModels as m, i}
- <div class="text-xs font-mono truncate {i === displayIdx ? 'opacity-100 font-semibold' : 'opacity-50'}">
- {i + 1}. {m.key_id} / {m.model_id}
+ <div class="text-xs font-mono truncate flex items-center gap-1 {i === displayIdx ? 'opacity-100 font-semibold' : 'opacity-50'}">
+ <span class="truncate">{i + 1}. {m.key_id} / {m.model_id}</span>
+ <span class="badge badge-xs badge-ghost shrink-0">{effortLabel(m.effort)}</span>
</div>
{/each}
</div>
@@ -373,8 +387,9 @@ const modelCache = new Map<string, string[]>();
{@const displayIdx = sliderDragging !== null ? sliderDragging : (currentIdx >= 0 ? currentIdx : 0)}
{@const displayModel = agent.models[displayIdx]}
<div class="mt-2 pt-2 border-t border-primary-content/20">
- <div class="text-xs font-semibold mb-1 truncate">
- {displayModel ? `${displayModel.key_id} / ${displayModel.model_id}` : `${activeKeyId} / ${activeModelId}`}
+ <div class="text-xs font-semibold mb-1 truncate flex items-center gap-1">
+ <span class="truncate">{displayModel ? `${displayModel.key_id} / ${displayModel.model_id}` : `${activeKeyId} / ${activeModelId}`}</span>
+ <span class="badge badge-xs shrink-0">{effortLabel(displayModel?.effort)}</span>
</div>
<input
type="range"
diff --git a/packages/frontend/src/lib/components/SidebarPanel.svelte b/packages/frontend/src/lib/components/SidebarPanel.svelte
index 491b1bd..3372396 100644
--- a/packages/frontend/src/lib/components/SidebarPanel.svelte
+++ b/packages/frontend/src/lib/components/SidebarPanel.svelte
@@ -34,7 +34,7 @@ const {
reasoningEffort = "max",
activeAgentSlug = null as string | null,
activeTabParentId = null as string | null,
- activeAgentModels = null as Array<{ key_id: string; model_id: string }> | null,
+ activeAgentModels = null as Array<{ key_id: string; model_id: string; effort?: string }> | null,
workingDirectory = null as string | null,
onKeyChange,
onModelChange,
@@ -54,7 +54,7 @@ const {
reasoningEffort?: string;
activeAgentSlug?: string | null;
activeTabParentId?: string | null;
- activeAgentModels?: Array<{ key_id: string; model_id: string }> | null;
+ activeAgentModels?: Array<{ key_id: string; model_id: string; effort?: string }> | null;
workingDirectory?: string | null;
onKeyChange: (keyId: string) => void;
onModelChange: (keyId: string, modelId: string) => void;
diff --git a/packages/frontend/src/lib/tabs.svelte.ts b/packages/frontend/src/lib/tabs.svelte.ts
index 3fd7e5f..d3061c3 100644
--- a/packages/frontend/src/lib/tabs.svelte.ts
+++ b/packages/frontend/src/lib/tabs.svelte.ts
@@ -12,6 +12,12 @@ import {
// source of truth for HISTORY; `groupRowsToMessages` derives render bubbles.
import { groupRowsToMessages, type MessageRow } from "@dispatch/core/src/chunks/transform.js";
import type { ChunkRow } from "@dispatch/core/src/types/index.js";
+import {
+ type AgentModelEntry,
+ DEFAULT_REASONING_EFFORT,
+ isReasoningEffort,
+ type ReasoningEffort,
+} from "@dispatch/core/src/types/index.js";
import { config } from "./config.js";
import { appSettings } from "./settings.svelte.js";
import type {
@@ -154,7 +160,7 @@ export interface Tab {
agentStatus: "idle" | "running" | "error";
keyId: string | null;
modelId: string | null;
- reasoningEffort: string;
+ reasoningEffort: ReasoningEffort;
currentAssistantId: string | null;
tasks: TaskItem[];
injectedSkills: string[];
@@ -162,7 +168,7 @@ export interface Tab {
persistent: boolean;
agentSlug: string | null;
agentScope: string | null;
- agentModels: Array<{ key_id: string; model_id: string }> | null;
+ agentModels: AgentModelEntry[] | null;
workingDirectory: string | null;
queuedMessages: QueuedMessage[];
chunkLimit: number;
@@ -280,7 +286,7 @@ export function createTabStore() {
agentStatus: "idle",
keyId: null,
modelId: null,
- reasoningEffort: "max",
+ reasoningEffort: DEFAULT_REASONING_EFFORT,
currentAssistantId: null,
tasks: [],
injectedSkills: [],
@@ -355,7 +361,7 @@ export function createTabStore() {
agentStatus: "idle",
keyId: tabData.keyId ?? null,
modelId: tabData.modelId ?? null,
- reasoningEffort: "max",
+ reasoningEffort: DEFAULT_REASONING_EFFORT,
currentAssistantId: null,
tasks: [],
injectedSkills: [],
@@ -829,7 +835,7 @@ export function createTabStore() {
agentStatus,
keyId: row.keyId ?? null,
modelId: row.modelId ?? null,
- reasoningEffort: "max",
+ reasoningEffort: DEFAULT_REASONING_EFFORT,
currentAssistantId,
tasks: [],
injectedSkills: [],
@@ -1154,7 +1160,7 @@ export function createTabStore() {
parentTabId: string | null;
agentSlug?: string | null;
workingDirectory: string | null;
- agentModels?: Array<{ key_id: string; model_id: string }> | null;
+ agentModels?: AgentModelEntry[] | null;
};
// Only add if we don't already have this tab
if (!getTabById(newTabEvent.id)) {
@@ -1168,7 +1174,7 @@ export function createTabStore() {
agentStatus: "running",
keyId: newTabEvent.keyId ?? null,
modelId: newTabEvent.modelId ?? null,
- reasoningEffort: "max",
+ reasoningEffort: DEFAULT_REASONING_EFFORT,
currentAssistantId: null,
tasks: [],
injectedSkills: [],
@@ -1382,7 +1388,7 @@ export function createTabStore() {
name: string;
skills: string[];
tools: string[];
- models: Array<{ key_id: string; model_id: string }>;
+ models: AgentModelEntry[];
cwd?: string;
}>;
};
@@ -1451,7 +1457,7 @@ export function createTabStore() {
agents?: Array<{
slug: string;
scope: string;
- models: Array<{ key_id: string; model_id: string }>;
+ models: AgentModelEntry[];
cwd?: string;
}>;
};
@@ -1733,6 +1739,19 @@ export function createTabStore() {
}).catch(() => {});
}
+ /**
+ * Update the per-tab reasoning-effort selector. Ignores unrecognised
+ * values so an out-of-range string from the UI can't corrupt the tab
+ * state. This is the per-tab effort in the per-model → per-tab → default
+ * resolution chain.
+ */
+ function setReasoningEffort(effort: string): void {
+ if (!isReasoningEffort(effort)) return;
+ const tab = getActiveTab();
+ if (!tab) return;
+ updateTab(tab.id, { reasoningEffort: effort });
+ }
+
function setWorkingDirectory(dir: string | null): void {
const tab = getActiveTab();
if (!tab) return;
@@ -1745,7 +1764,7 @@ export function createTabStore() {
scope: string;
skills: string[];
tools: string[];
- models: Array<{ key_id: string; model_id: string }>;
+ models: AgentModelEntry[];
cwd?: string;
} | null,
): void {
@@ -2002,6 +2021,7 @@ export function createTabStore() {
stopGeneration,
changeModel,
setKey,
+ setReasoningEffort,
setAgent,
replyPermission,
copyConversation,