summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-23 16:59:20 +0900
committerAdam Malczewski <[email protected]>2026-05-23 16:59:20 +0900
commit236beefb708a6cd91b673978ddf4ebf045a9844c (patch)
tree6522c65a0d490b41cc01297f2444f160f8dbb7f8
parent225d3ea65cfc35d211fc66e30cf05cbc693d37e4 (diff)
downloaddispatch-236beefb708a6cd91b673978ddf4ebf045a9844c.tar.gz
dispatch-236beefb708a6cd91b673978ddf4ebf045a9844c.zip
feat: key fallback using agent models[] hierarchy, background tool modes, copy truncation
- Agent rate-limit fallback now iterates through agent's configured models[] in strict order - Frontend sends agentModels with each /chat request; backend uses buildFallbackSequence() - Emits notice event on fallback so chat shows which key failed and what's being tried next - Child agents inherit parent's agentModels for fallback - Added statusCode propagation from AI SDK errors for programmatic 429 detection - Copy button truncates all tool results at 300 chars (was 200 for 4 specific tools) - run_shell, summon, youtube_transcribe: background mode support - summon: blocking mode by default with getResult callback
-rw-r--r--packages/api/src/agent-manager.ts283
-rw-r--r--packages/api/src/app.ts3
-rw-r--r--packages/core/src/agent/agent.ts14
-rw-r--r--packages/core/src/tools/run-shell.ts24
-rw-r--r--packages/core/src/tools/summon.ts27
-rw-r--r--packages/core/src/tools/youtube-transcribe.ts19
-rw-r--r--packages/core/src/types/index.ts3
-rw-r--r--packages/frontend/src/lib/components/Header.svelte2
-rw-r--r--packages/frontend/src/lib/tabs.svelte.ts47
-rw-r--r--packages/frontend/src/lib/types.ts2
10 files changed, 318 insertions, 106 deletions
diff --git a/packages/api/src/agent-manager.ts b/packages/api/src/agent-manager.ts
index d473950..828855c 100644
--- a/packages/api/src/agent-manager.ts
+++ b/packages/api/src/agent-manager.ts
@@ -44,14 +44,15 @@ const TOOL_DESCRIPTIONS: Record<string, string> = {
list_files: "List files and directories",
write_file: "Write content to a file (creates parent directories if needed)",
run_shell:
- "Execute shell commands in the working directory (bash). Returns stdout, stderr, and exit code. Use for running tests, builds, git operations, package management, and other development tasks. Do NOT run destructive or irreversible commands unless the user explicitly requests them.",
+ "Execute shell commands in the working directory (bash). Returns stdout, stderr, and exit code. Set background=true to run in the background and get a job_id for later retrieval. Do NOT run destructive or irreversible commands unless the user explicitly requests them.",
todo: "Manage a todo list for planning and tracking work. Actions: add, update, list, get, remove. Statuses: pending, in_progress, done.",
summon:
- "Spawn a child agent to work on a task independently. Returns an agent_id immediately (non-blocking). Use retrieve to collect the result later.",
+ "Spawn a child agent to work on a task independently. By default blocks until the child finishes. Set background=true to return immediately with an agent_id for later retrieval.",
retrieve:
- "Wait for a child agent to finish and get its result (blocking). Pass the agent_id from summon.",
+ "Wait for a background task to finish and get its result (blocking). Pass the job_id or agent_id.",
web_search: "Search the web and optionally scrape full page content from results.",
- youtube_transcribe: "Fetch the transcript/subtitles for a YouTube video.",
+ youtube_transcribe:
+ "Fetch the transcript/subtitles for a YouTube video. Set background=true to start in the background and get a job_id for later retrieval.",
};
const DEFAULT_SYSTEM_PROMPT =
@@ -121,6 +122,8 @@ interface TabAgent {
modelId: string | null;
taskList: TaskList;
_lastPermKey?: string;
+ /** Ordered key+model fallback hierarchy from the agent definition. */
+ agentModels?: Array<{ key_id: string; model_id: string }>;
/** Abort controller for cancelling a running agent. */
abortController?: AbortController;
/** For child agents: resolves when the agent finishes its task. */
@@ -399,6 +402,7 @@ export class AgentManager {
parentAllowedTools: childParentAllowedTools,
parentTabId: tabId,
}),
+ getResult: (id) => this.getChildResult(id),
}),
});
}
@@ -454,6 +458,7 @@ export class AgentManager {
parentAllowedTools,
parentTabId: tabId,
}),
+ getResult: (id) => this.getChildResult(id),
}),
});
toolEntries.push({
@@ -715,6 +720,14 @@ export class AgentManager {
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;
+ }
+ }
+
// Set up completion tracking
tabAgent.completionPromise = new Promise((resolve) => {
tabAgent.completionResolve = resolve;
@@ -793,6 +806,7 @@ export class AgentManager {
modelId?: string,
reasoningEffort?: "none" | "low" | "medium" | "high" | "max",
workingDirectory?: string,
+ agentModels?: Array<{ key_id: string; model_id: string }>,
): Promise<void> {
const tabAgent = this._getOrCreateTabAgent(tabId);
@@ -809,104 +823,126 @@ export class AgentManager {
tabAgent.status = "running";
this.messageCount += 1;
- let allOutput = "";
- let assistantText = "";
- let assistantThinking = "";
- const assistantToolCalls: Array<{
- id: string;
- name: string;
- arguments: Record<string, unknown>;
- result?: string;
- isError?: boolean;
- }> = [];
- let processError: string | null = null;
+ // Persist user message to DB (once, before any fallback retry)
+ appendMessage(
+ tabId,
+ crypto.randomUUID(),
+ "user",
+ JSON.stringify([{ type: "text", text: message }]),
+ );
- try {
- const agent = await this.getOrCreateAgentForTab(tabId, keyId, modelId);
+ // Store agent models on the tab if provided (defines fallback order)
+ if (agentModels) {
+ tabAgent.agentModels = agentModels;
+ }
+
+ // Build the fallback sequence: the agent's models list in order, or a single manual entry
+ const fallbackSequence = this.buildFallbackSequence(tabAgent, keyId, modelId);
+ const maxFallbackAttempts = fallbackSequence.length;
+
+ let processError: string | null = null;
+ let allOutput = "";
+ let currentKeyId: string | undefined;
+ let currentModelId: string | undefined;
+
+ for (let fallbackIdx = 0; fallbackIdx < maxFallbackAttempts; fallbackIdx++) {
+ const entry = fallbackSequence[fallbackIdx];
+ currentKeyId = entry.key_id;
+ currentModelId = entry.model_id;
+ allOutput = "";
+ let assistantText = "";
+ let assistantThinking = "";
+ const assistantToolCalls: Array<{
+ id: string;
+ name: string;
+ arguments: Record<string, unknown>;
+ result?: string;
+ isError?: boolean;
+ }> = [];
+ let attemptError: string | null = null;
- // Ensure tab exists in DB (frontend may have failed to create it)
try {
- const { getDatabase } = await import("@dispatch/core");
- const db = getDatabase();
- const exists = db.query("SELECT 1 FROM tabs WHERE id = $id").get({ $id: tabId });
- if (!exists) {
- const { createTab } = await import("@dispatch/core");
- createTab(tabId, "New Tab", { keyId: keyId ?? null, modelId: modelId ?? null });
+ const agent = await this.getOrCreateAgentForTab(tabId, currentKeyId, currentModelId);
+
+ // Ensure tab exists in DB (frontend may have failed to create it)
+ try {
+ const { getDatabase } = await import("@dispatch/core");
+ const db = getDatabase();
+ const exists = db.query("SELECT 1 FROM tabs WHERE id = $id").get({ $id: tabId });
+ if (!exists) {
+ const { createTab } = await import("@dispatch/core");
+ createTab(tabId, "New Tab", {
+ keyId: currentKeyId ?? null,
+ modelId: currentModelId ?? null,
+ });
+ }
+ } catch {
+ // Best-effort — if this fails, appendMessage will throw and we'll catch it below
}
- } catch {
- // Best-effort — if this fails, appendMessage will throw and we'll catch it below
- }
-
- // Persist user message to DB
- appendMessage(
- tabId,
- crypto.randomUUID(),
- "user",
- JSON.stringify([{ type: "text", text: message }]),
- );
- for await (const event of agent.run(
- message,
- reasoningEffort ? { reasoningEffort } : undefined,
- )) {
- // Stop processing if the tab was aborted (closed/stopped)
- if (tabAgent.abortController?.signal.aborted) break;
+ for await (const event of agent.run(
+ message,
+ reasoningEffort ? { reasoningEffort } : undefined,
+ )) {
+ // Stop processing if the tab was aborted (closed/stopped)
+ if (tabAgent.abortController?.signal.aborted) break;
- if (event.type === "status") {
- tabAgent.status = event.status;
- }
- this.emit(event, tabId);
-
- // Accumulate content for DB persistence
- if (event.type === "text-delta") {
- assistantText += event.delta;
- allOutput += event.delta;
- } else if (event.type === "reasoning-delta") {
- assistantThinking += event.delta;
- } else if (event.type === "tool-call") {
- assistantToolCalls.push({
- id: event.toolCall.id,
- name: event.toolCall.name,
- arguments: event.toolCall.arguments,
- });
- } else if (event.type === "tool-result") {
- const tc = assistantToolCalls.find((t) => t.id === event.toolResult.toolCallId);
- if (tc) {
- tc.result = event.toolResult.result;
- tc.isError = event.toolResult.isError;
+ if (event.type === "error") {
+ attemptError = event.error;
+ break;
}
- } else if (event.type === "done") {
- // Persist assistant message to DB
- const contentSegments: Array<Record<string, unknown>> = [];
- if (assistantText) contentSegments.push({ type: "text", text: assistantText });
- for (const tc of assistantToolCalls) {
- contentSegments.push({ type: "tool-call", ...tc });
+
+ if (event.type === "status") {
+ tabAgent.status = event.status;
}
- if (contentSegments.length > 0) {
- appendMessage(
- tabId,
- crypto.randomUUID(),
- "assistant",
- JSON.stringify(contentSegments),
- assistantThinking || undefined,
- );
+ this.emit(event, tabId);
+
+ // Accumulate content for DB persistence
+ if (event.type === "text-delta") {
+ assistantText += event.delta;
+ allOutput += event.delta;
+ } else if (event.type === "reasoning-delta") {
+ assistantThinking += event.delta;
+ } else if (event.type === "tool-call") {
+ assistantToolCalls.push({
+ id: event.toolCall.id,
+ name: event.toolCall.name,
+ arguments: event.toolCall.arguments,
+ });
+ } else if (event.type === "tool-result") {
+ const tc = assistantToolCalls.find((t) => t.id === event.toolResult.toolCallId);
+ if (tc) {
+ tc.result = event.toolResult.result;
+ tc.isError = event.toolResult.isError;
+ }
+ } else if (event.type === "done") {
+ // Persist assistant message to DB
+ const contentSegments: Array<Record<string, unknown>> = [];
+ if (assistantText) contentSegments.push({ type: "text", text: assistantText });
+ for (const tc of assistantToolCalls) {
+ contentSegments.push({ type: "tool-call", ...tc });
+ }
+ if (contentSegments.length > 0) {
+ appendMessage(
+ tabId,
+ crypto.randomUUID(),
+ "assistant",
+ JSON.stringify(contentSegments),
+ assistantThinking || undefined,
+ );
+ }
+ // Reset for next turn
+ assistantText = "";
+ assistantThinking = "";
+ assistantToolCalls.length = 0;
}
- // Reset for next turn
- assistantText = "";
- assistantThinking = "";
- assistantToolCalls.length = 0;
}
+ } catch (err) {
+ console.error(`[dispatch] processMessage error for tab ${tabId}:`, err);
+ attemptError = err instanceof Error ? err.message : String(err);
}
- } catch (err) {
- console.error(`[dispatch] processMessage error for tab ${tabId}:`, err);
- const errorMsg = err instanceof Error ? err.message : String(err);
- processError = errorMsg;
- tabAgent.status = "error";
- this.emit({ type: "error", error: errorMsg }, tabId);
- this.emit({ type: "status", status: "error" }, tabId);
- } finally {
- // Flush any accumulated assistant content that wasn't saved by a done event
- // (happens when the agent is aborted mid-stream or throws an error)
+
+ // Flush any accumulated assistant content from this attempt
if (assistantText || assistantToolCalls.length > 0) {
const contentSegments: Array<Record<string, unknown>> = [];
if (assistantText) contentSegments.push({ type: "text", text: assistantText });
@@ -923,14 +959,67 @@ export class AgentManager {
);
}
}
- // Resolve completion promise for child agents
- if (processError === null) {
- tabAgent.finalOutput = allOutput;
- tabAgent.completionResolve?.({ status: "done", result: allOutput || "(no output)" });
- } else {
- tabAgent.completionResolve?.({ status: "error", error: processError });
+
+ // No error — success
+ if (!attemptError) {
+ processError = null;
+ break;
+ }
+
+ // Check if error is retryable (rate limit / exhausted key)
+ const isRetryable =
+ attemptError.includes("status=429") ||
+ attemptError.toLowerCase().includes("rate limit") ||
+ attemptError.toLowerCase().includes("rate_limit");
+
+ if (isRetryable && this.modelRegistry && tabAgent.keyId) {
+ this.modelRegistry.markKeyExhausted(tabAgent.keyId, attemptError);
+
+ // Try the next entry in the agent's fallback sequence
+ const nextIdx = fallbackIdx + 1;
+ if (nextIdx < maxFallbackAttempts) {
+ const nextEntry = fallbackSequence[nextIdx];
+ const fallbackMsg =
+ `Key "${tabAgent.keyId}" rate limited. ` +
+ `Falling back to "${nextEntry.key_id}" (model: ${nextEntry.model_id})...`;
+ console.warn(`[dispatch] ${fallbackMsg}`);
+ this.emit({ type: "notice", message: fallbackMsg }, tabId);
+ tabAgent.agent = null;
+ continue;
+ }
}
+
+ // All fallbacks exhausted or non-retryable error
+ processError = attemptError;
+ tabAgent.status = "error";
+ this.emit({ type: "error", error: attemptError }, tabId);
+ this.emit({ type: "status", status: "error" }, tabId);
+ break;
+ }
+
+ // Resolve completion promise for child agents
+ if (processError === null) {
+ tabAgent.finalOutput = allOutput;
+ tabAgent.completionResolve?.({ status: "done", result: allOutput || "(no output)" });
+ } else {
+ tabAgent.completionResolve?.({ status: "error", error: processError });
+ }
+ }
+
+ private buildFallbackSequence(
+ tabAgent: TabAgent,
+ keyId?: string,
+ modelId?: string,
+ ): Array<{ key_id: string; model_id: string }> {
+ // Agent mode: use the agent's configured fallback hierarchy in strict order
+ const models = tabAgent.agentModels;
+ if (models && models.length > 0) {
+ const startIdx = models.findIndex((m) => m.key_id === keyId && m.model_id === modelId);
+ return startIdx >= 0 ? models.slice(startIdx) : models;
}
+ // Manual mode: no fallback — just the selected key/model pair
+ if (keyId && modelId) return [{ key_id: keyId, model_id: modelId }];
+ return [];
}
queueMessage(tabId: string, message: string, clientId?: string): { messageId: string } {
diff --git a/packages/api/src/app.ts b/packages/api/src/app.ts
index 9ccc911..d7dd0be 100644
--- a/packages/api/src/app.ts
+++ b/packages/api/src/app.ts
@@ -63,6 +63,7 @@ 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 workingDirectory =
typeof body.workingDirectory === "string" ? body.workingDirectory : undefined;
const validEfforts = ["none", "low", "medium", "high", "max"];
@@ -73,7 +74,7 @@ app.post("/chat", async (c) => {
// Non-blocking — let the agent run in the background
agentManager
- .processMessage(tabId, message, keyId, modelId, reasoningEffort, workingDirectory)
+ .processMessage(tabId, message, keyId, modelId, reasoningEffort, workingDirectory, agentModels)
.catch(console.error);
return c.json({ status: "ok" });
diff --git a/packages/core/src/agent/agent.ts b/packages/core/src/agent/agent.ts
index 6f1a5a4..bf9b22f 100644
--- a/packages/core/src/agent/agent.ts
+++ b/packages/core/src/agent/agent.ts
@@ -311,8 +311,15 @@ export class Agent {
allToolCalls.push(toolCall);
yield { type: "tool-call", toolCall };
} else if (event.type === "error") {
+ const errRecord = event.error as unknown as Record<string, unknown>;
+ const statusCode =
+ typeof errRecord.statusCode === "number" ? errRecord.statusCode : undefined;
const errorMsg = formatError(event.error, this.config);
- yield { type: "error", error: errorMsg };
+ yield {
+ type: "error",
+ error: errorMsg,
+ ...(statusCode !== undefined ? { statusCode } : {}),
+ };
this.status = "error";
yield { type: "status", status: "error" };
return;
@@ -471,8 +478,11 @@ export class Agent {
yield { type: "done", message: assistantMessage };
} catch (err) {
+ const errRecord = err as unknown as Record<string, unknown>;
+ const statusCode =
+ typeof errRecord.statusCode === "number" ? errRecord.statusCode : undefined;
const errorMsg = formatError(err, this.config);
- yield { type: "error", error: errorMsg };
+ yield { type: "error", error: errorMsg, ...(statusCode !== undefined ? { statusCode } : {}) };
this.status = "error";
yield { type: "status", status: "error" };
return;
diff --git a/packages/core/src/tools/run-shell.ts b/packages/core/src/tools/run-shell.ts
index 6671c31..ec2db9c 100644
--- a/packages/core/src/tools/run-shell.ts
+++ b/packages/core/src/tools/run-shell.ts
@@ -54,6 +54,12 @@ export function createRunShellTool(
parameters: z.object({
command: z.string().describe("The shell command to execute"),
timeout: z.number().optional().describe("Timeout in milliseconds (default 2 minutes)"),
+ background: z
+ .boolean()
+ .optional()
+ .describe(
+ "If true, the command starts in the background and a job_id is returned immediately. Use the retrieve tool with the job_id to get the result later.",
+ ),
}),
execute: async (
args: Record<string, unknown>,
@@ -61,6 +67,7 @@ export function createRunShellTool(
): Promise<string> => {
const command = args.command as string;
const timeout = (args.timeout as number | undefined) ?? DEFAULT_TIMEOUT;
+ const background = (args.background as boolean | undefined) ?? false;
const [shell, shellArgs] = getShell();
const child = spawn(shell, [...shellArgs, command], {
@@ -99,6 +106,23 @@ export function createRunShellTool(
});
});
+ // If background mode requested, register immediately and return job ID
+ if (background && shellStore) {
+ const jobId = shellStore.register({
+ command,
+ stdout,
+ stderr,
+ completion: completionPromise,
+ });
+ return [
+ `Command started in background.`,
+ `job_id: ${jobId}`,
+ `command: ${command}`,
+ ``,
+ `Use the retrieve tool with this job_id to get the result when ready.`,
+ ].join("\n");
+ }
+
const queueCallbacks = context?.queueCallbacks;
if (queueCallbacks && shellStore) {
diff --git a/packages/core/src/tools/summon.ts b/packages/core/src/tools/summon.ts
index 29f27d1..22ab35b 100644
--- a/packages/core/src/tools/summon.ts
+++ b/packages/core/src/tools/summon.ts
@@ -3,6 +3,9 @@ import type { ToolDefinition } from "../types/index.js";
export interface SummonCallbacks {
spawn(options: { task: string; tools: string[]; workingDirectory?: string }): Promise<string>;
+ getResult(
+ agentId: string,
+ ): Promise<{ status: "done"; result: string } | { status: "error"; error: string }>;
}
export function createSummonTool(
@@ -12,12 +15,15 @@ export function createSummonTool(
return {
name: "summon",
description: [
- "Spawn a new child agent to work on a task independently. Returns immediately with an agent_id — does NOT wait for the child to finish.",
+ "Spawn a new child agent to work on a task independently.",
+ "",
+ "By default, blocks until the child agent finishes and returns the result directly.",
+ "Set background=true to return immediately with an agent_id instead — use retrieve to collect the result later.",
"",
"The child agent runs in its own tab visible to the user. Use the 'retrieve' tool with the returned agent_id to get the result when needed.",
"",
"Pattern for parallel work:",
- " 1. Call summon multiple times to start several agents",
+ " 1. Call summon multiple times with background=true to start several agents",
" 2. Do your own work or wait",
" 3. Call retrieve for each agent_id to collect results",
"",
@@ -64,12 +70,19 @@ export function createSummonTool(
.describe(
"Absolute path for the child to work in. Defaults to the current working directory.",
),
+ background: z
+ .boolean()
+ .optional()
+ .describe(
+ "If true, returns immediately with an agent_id for later retrieval. If false (default), blocks until the child agent finishes and returns the result directly.",
+ ),
}),
execute: async (args: Record<string, unknown>): Promise<string> => {
const task = args.task as string;
const tools = (args.tools as string[] | undefined) ?? ["read_file", "list_files", "todo"];
const workingDirectory =
(args.working_directory as string | undefined) ?? defaultWorkingDirectory;
+ const background = (args.background as boolean | undefined) ?? false;
try {
const agentId = await callbacks.spawn({
@@ -77,6 +90,16 @@ export function createSummonTool(
tools,
workingDirectory,
});
+
+ if (!background) {
+ // Block until the child agent completes
+ const result = await callbacks.getResult(agentId);
+ if (result.status === "done") {
+ return result.result;
+ }
+ return `Error from child agent: ${result.error}`;
+ }
+
return [
`Agent spawned successfully.`,
`agent_id: ${agentId}`,
diff --git a/packages/core/src/tools/youtube-transcribe.ts b/packages/core/src/tools/youtube-transcribe.ts
index ea8ed43..3a26d6f 100644
--- a/packages/core/src/tools/youtube-transcribe.ts
+++ b/packages/core/src/tools/youtube-transcribe.ts
@@ -141,17 +141,36 @@ export function createYoutubeTranscribeTool(
].join("\n"),
parameters: z.object({
url: z.string().describe("The YouTube video URL to fetch the transcript for."),
+ background: z
+ .boolean()
+ .optional()
+ .describe(
+ "If true, the transcription request starts in the background and a job_id is returned immediately. Use the retrieve tool with the job_id to get the transcript later.",
+ ),
}),
execute: async (
args: Record<string, unknown>,
context?: ToolExecuteContext,
): Promise<string> => {
const url = args.url as string;
+ const background = (args.background as boolean | undefined) ?? false;
const queueCallbacks = context?.queueCallbacks;
try {
const pollPromise = pollUntilReady(url);
+ // If background mode requested, register immediately and return job ID
+ if (background && transcriptStore) {
+ const jobId = transcriptStore.register(url, pollPromise);
+ return [
+ `Transcript request started in background.`,
+ `job_id: ${jobId}`,
+ `url: ${url}`,
+ ``,
+ `Use the retrieve tool with this job_id to get the transcript when ready.`,
+ ].join("\n");
+ }
+
if (queueCallbacks && transcriptStore) {
const { promise: queuePromise, cancel: cancelQueueWait } =
queueCallbacks.waitForQueuedMessage();
diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts
index c301822..b17a720 100644
--- a/packages/core/src/types/index.ts
+++ b/packages/core/src/types/index.ts
@@ -36,7 +36,8 @@ export type AgentEvent =
| { type: "tool-call"; toolCall: ToolCall }
| { type: "tool-result"; toolResult: ToolResult }
| { type: "shell-output"; data: string; stream: "stdout" | "stderr" }
- | { type: "error"; error: string }
+ | { type: "error"; error: string; statusCode?: number }
+ | { type: "notice"; message: string }
| { type: "done"; message: ChatMessage }
| { type: "task-list-update"; tasks: TaskItem[] }
| { type: "config-reload" }
diff --git a/packages/frontend/src/lib/components/Header.svelte b/packages/frontend/src/lib/components/Header.svelte
index 06b8ac1..713e916 100644
--- a/packages/frontend/src/lib/components/Header.svelte
+++ b/packages/frontend/src/lib/components/Header.svelte
@@ -26,7 +26,7 @@ async function handleCopy() {
}
</script>
-<header class="navbar bg-base-200 border-b border-base-300 px-4 min-h-14 flex-shrink-0">
+<header class="navbar bg-base-200 px-4 min-h-14 flex-shrink-0">
<div class="navbar-start">
<button class="text-xl font-bold tracking-tight btn btn-ghost px-0" onclick={() => router.navigate("dashboard")}>Dispatch</button>
</div>
diff --git a/packages/frontend/src/lib/tabs.svelte.ts b/packages/frontend/src/lib/tabs.svelte.ts
index c488a92..a48be64 100644
--- a/packages/frontend/src/lib/tabs.svelte.ts
+++ b/packages/frontend/src/lib/tabs.svelte.ts
@@ -50,6 +50,8 @@ export interface Tab {
agentSlug: string | null;
/** Scope of the selected agent */
agentScope: string | null;
+ /** Ordered key+model fallback hierarchy from the selected agent */
+ agentModels: Array<{ key_id: string; model_id: string }> | null;
/** Custom working directory override for this tab */
workingDirectory: string | null;
/** Messages queued to be sent once the agent finishes its current run */
@@ -118,6 +120,7 @@ function createTabStore() {
persistent: true,
agentSlug: null,
agentScope: null,
+ agentModels: null,
workingDirectory: null,
queuedMessages: [],
};
@@ -210,6 +213,7 @@ function createTabStore() {
persistent: true,
agentSlug: null,
agentScope: null,
+ agentModels: null,
workingDirectory: null,
queuedMessages: [],
};
@@ -408,6 +412,25 @@ function createTabStore() {
}
break;
}
+ case "notice": {
+ if (tabId) {
+ const noticeMsg: ChatMessage = {
+ id: generateId(),
+ role: "assistant",
+ content: [{ type: "text", text: event.message }],
+ isStreaming: false,
+ debugInfo: makeDebugInfo({ notice: event.message }),
+ };
+ const tabN = getTabById(tabId);
+ if (tabN) {
+ updateTab(tabId, {
+ messages: [...tabN.messages, noticeMsg],
+ currentAssistantId: null,
+ });
+ }
+ }
+ break;
+ }
case "permission-prompt": {
pendingPermissions = event.pending;
break;
@@ -481,6 +504,7 @@ function createTabStore() {
persistent: newTabEvent.parentTabId == null,
agentSlug: null,
agentScope: null,
+ agentModels: null,
workingDirectory: newTabEvent.workingDirectory ?? null,
queuedMessages: [],
};
@@ -644,6 +668,7 @@ function createTabStore() {
const patch: Partial<Tab> = {
agentSlug: defaultAgent.slug,
agentScope: defaultAgent.scope,
+ agentModels: defaultAgent.models,
workingDirectory: defaultAgent.cwd || null,
};
if (firstModel) {
@@ -805,6 +830,7 @@ function createTabStore() {
message: messageToSend,
...(tab.keyId ? { keyId: tab.keyId } : {}),
...(tab.modelId ? { modelId: tab.modelId } : {}),
+ ...(tab.agentModels ? { agentModels: tab.agentModels } : {}),
reasoningEffort: tab.reasoningEffort,
...(tab.workingDirectory ? { workingDirectory: tab.workingDirectory } : {}),
...(queueId ? { queueId } : {}),
@@ -935,7 +961,12 @@ function createTabStore() {
if (!agent) {
// Switch back to manual mode — clear agent and reset working directory
- updateTab(tab.id, { agentSlug: null, agentScope: null, workingDirectory: null });
+ updateTab(tab.id, {
+ agentSlug: null,
+ agentScope: null,
+ agentModels: null,
+ workingDirectory: null,
+ });
return;
}
@@ -944,6 +975,7 @@ function createTabStore() {
const patch: Partial<Tab> = {
agentSlug: agent.slug,
agentScope: agent.scope,
+ agentModels: agent.models,
workingDirectory: agent.cwd || null,
};
if (firstModel) {
@@ -1039,6 +1071,8 @@ function createTabStore() {
`All tab IDs: ${tabs.map((t) => t.id).join(", ")}`,
"",
];
+ const TOOL_RESULT_MAX = 300;
+
for (const msg of tab.messages) {
const role = msg.role === "user" ? "User" : msg.role === "system" ? "System" : "Assistant";
lines.push(`--- ${role} ---`);
@@ -1047,7 +1081,16 @@ function createTabStore() {
if (seg.type === "text") lines.push(seg.text);
else if (seg.type === "tool-call") {
lines.push(` [Tool: ${seg.name}]`);
- if (seg.result !== undefined) lines.push(` Result: ${seg.result}`);
+ if (seg.result !== undefined) {
+ const result = String(seg.result);
+ if (result.length > TOOL_RESULT_MAX) {
+ lines.push(
+ ` Result: ${result.slice(0, TOOL_RESULT_MAX)}... [truncated, ${result.length} chars total]`,
+ );
+ } else {
+ lines.push(` Result: ${result}`);
+ }
+ }
}
}
lines.push("");
diff --git a/packages/frontend/src/lib/types.ts b/packages/frontend/src/lib/types.ts
index 79b2867..95ffb2b 100644
--- a/packages/frontend/src/lib/types.ts
+++ b/packages/frontend/src/lib/types.ts
@@ -10,6 +10,7 @@ export interface ToolCallDisplay {
export interface DebugInfo {
timestamp: string;
error?: string;
+ notice?: string;
model?: string;
apiBase?: string;
connectionStatus?: string;
@@ -51,6 +52,7 @@ export type AgentEvent =
toolResult: { toolCallId: string; result: string; isError: boolean };
}
| { type: "error"; error: string }
+ | { type: "notice"; message: string }
| { type: "task-list-update"; tasks: TaskItem[] }
| { type: "config-reload" }
| {