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 | |
| 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).
| -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 | ||||
| -rw-r--r-- | packages/api/tests/agent-manager.test.ts | 64 | ||||
| -rw-r--r-- | packages/api/tests/routes.test.ts | 33 | ||||
| -rw-r--r-- | packages/core/src/agent/agent.ts | bin | 57720 -> 57822 bytes | |||
| -rw-r--r-- | packages/core/src/agents/loader.ts | 8 | ||||
| -rw-r--r-- | packages/core/src/types/index.ts | 44 | ||||
| -rw-r--r-- | packages/core/tests/agent/agent.test.ts | 21 | ||||
| -rw-r--r-- | packages/core/tests/agents/loader.test.ts | 77 | ||||
| -rw-r--r-- | packages/core/tests/types/reasoning-effort.test.ts | 48 | ||||
| -rw-r--r-- | packages/frontend/src/App.svelte | 11 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/AgentBuilder.svelte | 20 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/ModelSelector.svelte | 39 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/SidebarPanel.svelte | 4 | ||||
| -rw-r--r-- | packages/frontend/src/lib/tabs.svelte.ts | 40 |
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 Binary files differindex c6f1322..4bfa7eb 100644 --- a/packages/core/src/agent/agent.ts +++ b/packages/core/src/agent/agent.ts 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, |
