diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 6f6a47f..fcb6832 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -20,6 +20,8 @@ jobs: run: | npm ci npm --prefix ui ci + - name: Install Playwright Chromium + run: node node_modules/playwright/cli.js install --with-deps chromium - name: Build run: npm run build:all -- --skip-python - name: Test diff --git a/CHANGELOG.md b/CHANGELOG.md index 27d06bc..1531f32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,20 +6,87 @@ follow semantic versioning. ## Unreleased -### Fixed -- Docker: a clean `docker compose up --build` now reliably installs Chromium for - the browser tools. The Playwright browser CLI is invoked directly - (`node node_modules/playwright/cli.js install`) to avoid an npm bin-name - collision that left a from-scratch build failing with `playwright: not found` - (exit 127). -- Docker on Windows (WSL2): a fresh `docker compose up --build` no longer fails - when `.env` is absent — `.env` is now optional. A new `.gitattributes` pins - shell scripts and other build-critical files to LF so a Windows checkout - (CRLF) builds cleanly. +## v0.2.0 (2026-07-09) + +A month of work since the initial release, centered on shared workspaces, +agent-to-agent (A2A) collaboration, and much deeper visibility into what agents +are doing. ### Added -- Docs: Windows/WSL quickstart notes in the README and `docs/docker.md`. The - browser tools run entirely inside the container (no host X server or WSLg). + +- **Shared workspaces** — the workspace is now the single home for everything: + a workspace rail, per-workspace chats with search/filtering, colors and + descriptions, running-count badges, workspace-scoped schedules, reusable + invite links, and credential sharing with scoped grants. The separate + "Tasks" tab is gone. +- **Workspace tool policy** — enable/disable tools per workspace from + Settings → Tools; piece YAML no longer controls tool availability. Agents can + request a missing tool or Python package mid-task and use it as soon as you + approve in chat; workspaces can also pre-install extra Python packages. +- **A2A protocol support** — publish an Agent Card, delegate skills to external + agents with per-delegation resource limits, run long A2A tasks non-blocking, + and list/revoke delegations from Settings (OAuth-based authorization server + underneath). +- **SSH integration** — register SSH connections per workspace, run one-shot + remote operations, or open an interactive console where you and the agent + share one PTY (multiple sessions with tab switching). +- **Delegate observability** — sub-agent (delegate) runs are now visible: + live cards with the currently running tool, result previews and abort + reasons, LLM token counts, child-run rollups, and a live console. LLM + execution itself shows live state, attempt badges, and classified + interruption reasons. +- **Workspace apps** — self-contained HTML mini-apps that read/write workspace + files, generated with starter templates and a self-E2E pass, shareable via + read-only public links. +- **Files tab upgrades** — folder create/rename/move/delete/download, + drag-and-drop organization, touch support, a details view (size, modified + time), file provenance (which task created a file), a protected `readonly/` + area, and automatic recognition of files you drop into the workspace. +- **Richer previews and reading** — Excel/PowerPoint/Word preview, PDF + page-range reads, and a single `Read` tool that auto-detects + .xlsx/.docx/.pdf/.pptx/.msg. +- **X (Twitter) research** — tools restored (new transaction-id scheme), home + timeline support, full text of long-form X Articles, and an "SNS deep sweep" + piece that triages a timeline and deep-dives each post with a clean-context + sub-agent. +- **Cross-task recall** — agents can search other chats in the workspace + (`SearchWorkspaceTasks`), search their own long conversations, and subtasks + can look back at their own transcript; multi-keyword AND search included. +- **Memory & learning** — lessons captured mid-task into persistent memory / + AGENTS.md, a Memory & Learning settings tab with reflection history and + revert, and an extended mission brief that pins user constraints and + decisions. +- **English UI** — recently added screens are fully localized (i18next); + UI is bilingual Japanese/English throughout. + +### Changed + +- Chat screen: taller conversation viewport, a card-style auto-growing + composer, an always-visible context-usage gauge, and tool rows with + execution timestamps. +- Research pieces split large investigations into serial delegate runs to keep + the orchestrator context small. +- Context handling keeps more of the recent conversation verbatim when + compacting and retains longer histories on large-context models. +- Job time limits are configurable from Settings, with clearer hard-kill + reasons (default 180 minutes). + +### Fixed + +- A context-limit livelock that aborted tasks seconds after start with + "max iterations (200) exceeded" while doing no work. +- Docker: clean `docker compose up --build` reliably installs Chromium (npm + bin-name collision), and Windows/WSL2 checkouts build cleanly (`.env` + optional, LF pinned via `.gitattributes`). +- HTTPS redirect kept firing after HSTS was disabled (sticky-HSTS opt-in fix). +- Shift_JIS and other non-UTF-8 Japanese text was misdetected as binary; BOM + handling fixed. +- Raw HTML in chat messages no longer breaks the conversation layout. +- Models that require the system message first no longer error mid-task. +- Skills whose folder name diverged from their display name could not be + deleted or edited. +- Attaching a file with an existing name no longer overwrites it silently — + the old version is evacuated to `old/`. ## v0.1.0 — Initial public release (2026-06-02) diff --git a/src/bridge/job-events.ts b/src/bridge/job-events.ts index aed7556..d5c5d00 100644 --- a/src/bridge/job-events.ts +++ b/src/bridge/job-events.ts @@ -2,7 +2,19 @@ import { EventEmitter } from 'events'; export interface JobStreamEvent { type: 'prompt_progress' | 'text' | 'tool_use' | 'tool_use_delta' | 'tool_result' | 'done' - | 'delegate_lifecycle' | 'delegate_text' | 'delegate_tool'; + | 'delegate_lifecycle' | 'delegate_text' | 'delegate_tool' | 'llm_state'; + // llm_state: LLM 呼び出しのライブ状態(SSE 専用・DB 非永続)。 + // phase: waiting(送信直後) / thinking(reasoning受信中) / retrying(バックオフ待ち) + // / recovering(コンテキスト復旧ステージ実行中) + phase?: 'waiting' | 'thinking' | 'retrying' | 'recovering'; + movement?: string; + iteration?: number; + attempt?: number; + maxAttempts?: number; + reason?: string; + errorClass?: string; + chars?: number; + stage?: string; // prompt_progress processed?: number; total?: number; diff --git a/src/config-normalize.test.ts b/src/config-normalize.test.ts index 7a20b1b..ef4f80e 100644 --- a/src/config-normalize.test.ts +++ b/src/config-normalize.test.ts @@ -571,3 +571,82 @@ describe('browser.display_mode migration (captcha_solve rename)', () => { expect(out.browser?.maxSessions).toBe(3); }); }); + +// 観測性 Phase 2: return_progress (llama.cpp prompt 進捗) は明示フィールド +// ミラーの罠 (#368) にはまりやすいので、v1→v2 / v2→v1 双方向を固定する。 +describe('returnProgress mirroring', () => { + it('v2 llm.workers.returnProgress survives normalization and mirrors into provider.workers', () => { + const out = normalizeConfig({ + configVersion: 2, + llm: { + workers: [{ + id: 'gpu1', + connectionType: 'direct', + endpoint: 'http://x:8080/v1', + model: 'm', + returnProgress: true, + }], + }, + } as never); + expect(out.llm?.workers[0]).toMatchObject({ id: 'gpu1', returnProgress: true }); + expect(out.provider?.workers?.[0]).toMatchObject({ id: 'gpu1', returnProgress: true }); + }); + + it('v1 provider.workers.returnProgress carries into the generated llm block', () => { + const out = normalizeConfig({ + provider: { + baseUrl: 'http://x:11434/v1', + model: 'm', + workers: [{ id: 'gpu1', endpoint: 'http://x:8080/v1', returnProgress: true }], + }, + } as never); + expect(out.llm?.workers[0]).toMatchObject({ id: 'gpu1', returnProgress: true }); + }); + + it('omitted returnProgress stays absent (defaults off)', () => { + const out = normalizeConfig({ + configVersion: 2, + llm: { + workers: [{ id: 'gpu1', connectionType: 'direct', endpoint: 'http://x:8080/v1', model: 'm' }], + }, + } as never); + expect(out.llm?.workers[0]).not.toHaveProperty('returnProgress'); + }); +}); + +// Codex P2: loadConfig が同 id の legacy provider.workers を先に合成する +// 一般的な v2 構成では syncProviderFromLlm が early-return し、returnProgress +// が runtime (provider.workers) に届かなかった。match 経路でも同期する。 +describe('returnProgress sync when provider.workers already match (Codex P2)', () => { + it('copies returnProgress onto pre-existing matching provider.workers rows', () => { + const out = normalizeConfig({ + configVersion: 2, + provider: { + workers: [{ id: 'default', endpoint: 'http://x:8080/v1' }], + }, + llm: { + workers: [{ + id: 'default', + connectionType: 'direct', + endpoint: 'http://x:8080/v1', + model: 'm', + returnProgress: true, + }], + }, + } as never); + expect(out.provider?.workers?.[0]).toMatchObject({ id: 'default', returnProgress: true }); + }); + + it('clears a stale returnProgress when the v2 side turned it off', () => { + const out = normalizeConfig({ + configVersion: 2, + provider: { + workers: [{ id: 'default', endpoint: 'http://x:8080/v1', returnProgress: true }], + }, + llm: { + workers: [{ id: 'default', connectionType: 'direct', endpoint: 'http://x:8080/v1', model: 'm' }], + }, + } as never); + expect(out.provider?.workers?.[0]).not.toHaveProperty('returnProgress'); + }); +}); diff --git a/src/config-normalize.ts b/src/config-normalize.ts index 6eeb344..faab09c 100644 --- a/src/config-normalize.ts +++ b/src/config-normalize.ts @@ -260,7 +260,20 @@ function syncProviderFromLlm(out: Record, llm: LlmConfig): void const allExistingMatch = existingWorkers.length > 0 && existingWorkers.every(w => typeof w.id === 'string' && llmIds.has(w.id)) && existingWorkers.length === llm.workers.length; - if (allExistingMatch) return; + if (allExistingMatch) { + // Even when we keep the v1-populated rows, returnProgress は v2 (llm.workers) + // が UI の書き込み先=真実源。loadConfig が default worker を先に合成する + // 一般的な v2 構成ではこの early-return に入るため、ここで同期しないと + // runtime (provider.workers を読む) に永遠に届かない (Codex P2)。 + const byId = new Map(llm.workers.map(w => [w.id, w])); + for (const existing of existingWorkers) { + const src = typeof existing.id === 'string' ? byId.get(existing.id) : undefined; + if (!src) continue; + if (src.returnProgress !== undefined) existing.returnProgress = src.returnProgress; + else delete existing.returnProgress; + } + return; + } const mirroredWorkers: WorkerDef[] = llm.workers.map(w => { const def: WorkerDef = { @@ -273,6 +286,7 @@ function syncProviderFromLlm(out: Record, llm: LlmConfig): void if (w.model !== undefined && w.model !== '') def.model = w.model; if (w.apiKey !== undefined) def.apiKey = w.apiKey; if (w.vlm !== undefined) def.vlm = w.vlm; + if (w.returnProgress !== undefined) def.returnProgress = w.returnProgress; if (w.healthcheckIntervalSeconds !== undefined) { def.healthcheckIntervalSeconds = w.healthcheckIntervalSeconds; } @@ -375,6 +389,7 @@ function workerFromProvider(w: WorkerDef, providerModel: string | undefined): Ll }; if (w.apiKey !== undefined) worker.apiKey = w.apiKey; if (w.vlm !== undefined) worker.vlm = w.vlm; + if (w.returnProgress !== undefined) worker.returnProgress = w.returnProgress; if (w.healthcheckIntervalSeconds !== undefined) { worker.healthcheckIntervalSeconds = w.healthcheckIntervalSeconds; } @@ -422,6 +437,7 @@ function normalizeLlmWorker(w: Partial & Record): }; if (typeof w.apiKey === 'string') worker.apiKey = w.apiKey; if (typeof w.vlm === 'boolean') worker.vlm = w.vlm; + if (typeof w.returnProgress === 'boolean') worker.returnProgress = w.returnProgress; if (typeof w.healthcheckIntervalSeconds === 'number') { worker.healthcheckIntervalSeconds = w.healthcheckIntervalSeconds; } diff --git a/src/config.ts b/src/config.ts index 5e71fc1..f8712b6 100644 --- a/src/config.ts +++ b/src/config.ts @@ -138,6 +138,13 @@ export interface WorkerDef { enabled?: boolean; maxConcurrency?: number; vlm?: boolean; // true: ReadImage uses this worker's own model instead of dedicated VLM endpoint + /** + * true: リクエストに return_progress を付け、llama.cpp (llama-server) の + * prompt 評価進捗ストリームを受けて UI に「プロンプト処理中 n%」を出す。 + * llama.cpp 系バックエンド専用のオプトイン(未知フィールドを拒否する + * バックエンドがあるため既定 off)。 + */ + returnProgress?: boolean; roles?: string[]; /** @deprecated Use roles instead. Kept for backward compat shim. */ profiles?: string[]; @@ -443,6 +450,8 @@ export interface LlmWorkerDef { maxConcurrency: number; enabled: boolean; vlm?: boolean; + /** llama.cpp の prompt 評価進捗ストリーム(return_progress)を要求する。既定 off。 */ + returnProgress?: boolean; healthcheckIntervalSeconds?: number; } diff --git a/src/engine/agent-loop.ts b/src/engine/agent-loop.ts index 16d3932..73bea5e 100644 --- a/src/engine/agent-loop.ts +++ b/src/engine/agent-loop.ts @@ -234,6 +234,13 @@ export async function executeMovement( missionAware: !!ctx.missionBrief?.read()?.goal, }); + // コンテキスト復旧ステージ(dedup/compact/summarize/force_transition_summary) + // の実況。events.jsonl(事後診断)と callbacks(ライブ SSE)の両方に流す。 + const onRecoveryStage = (stage: string, detail?: string): void => { + movementEvents.emit('context_recovery', { stage, detail }); + callbacks?.onContextRecovery?.({ stage, detail }); + }; + // Traceability T-1: every return path goes through this helper so the // movement_complete event is emitted exactly once with a uniform shape. const finishMovement = (result: MovementResult): MovementResult => { @@ -283,6 +290,7 @@ export async function executeMovement( promptGuardRatio, historySummarization: safetyConfig?.historySummarization, runIsolatedLlm, + onStage: onRecoveryStage, }); if (!promptGuard.ok) { logger.warn(`[agent-loop] movement=${movement.name} oversized prompt blocked before send: estimated=${promptGuard.estimatedTokens} limit=${promptGuard.limitTokens}`); @@ -292,6 +300,7 @@ export async function executeMovement( messages, toolsUsed, runIsolatedLlm, + onRecoveryStage, ); return finishMovement(result); } @@ -302,7 +311,7 @@ export async function executeMovement( options.conversation?.rewrite(); } - const { accumulatedText, pendingToolCalls, hadError, errorMessage, lastUsage } = + const { accumulatedText, pendingToolCalls, hadError, errorMessage, errorClass, httpStatus, lastUsage } = await runLlmIteration({ client, messages, @@ -334,6 +343,9 @@ export async function executeMovement( promptGuardRatio, safetyConfig, runIsolatedLlm, + errorClass, + httpStatus, + onStage: onRecoveryStage, }); if (errorResult) { return finishMovement(errorResult); diff --git a/src/engine/agent-loop/context-control.test.ts b/src/engine/agent-loop/context-control.test.ts index b019d43..b6ea8da 100644 --- a/src/engine/agent-loop/context-control.test.ts +++ b/src/engine/agent-loop/context-control.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { ContextManager } from '../context-manager.js'; -import { applyContextManagerUpdate, buildContextOverflowResult } from './context-control.js'; +import { applyContextManagerUpdate, buildContextOverflowResult, handleLLMError } from './context-control.js'; import type { Movement } from './types.js'; function mv(defaultNext?: string): Movement { @@ -162,3 +162,83 @@ describe('handleLLMError — recovery honors the client-reported safe limit on l expect(typeof toolMsg?.content === 'string' && toolMsg.content.length).toBeLessThan(750_000); }); }); + +// 観測性 Phase 1: 中断メッセージの構造化と復旧ステージ通知。 +describe('handleLLMError — structured abort classification', () => { + it('includes the errorClass and movement in the generic llm_error abort output', async () => { + const res = await handleLLMError('gateway gateway_timeout: upstream aborted', { + movement: mv(undefined), + messages: [], + tools: [], + toolsUsed: [], + promptGuardRatio: 0.8, + runIsolatedLlm: async () => '', + errorClass: 'gateway_timeout', + }); + expect(res?.next).toBe('ABORT'); + expect(res?.abortCode).toBe('llm_error'); + expect(res?.errorClass).toBe('gateway_timeout'); + expect(res?.output).toContain('分類: gateway_timeout'); + expect(res?.output).toContain('movement: execute'); + expect(res?.output).toContain('gateway gateway_timeout: upstream aborted'); + }); + + it('appends the HTTP status to the classification line', async () => { + const res = await handleLLMError('HTTP 500: boom', { + movement: mv(undefined), + messages: [], + tools: [], + toolsUsed: [], + promptGuardRatio: 0.8, + runIsolatedLlm: async () => '', + errorClass: 'http', + httpStatus: 500, + }); + expect(res?.output).toContain('分類: http (HTTP 500)'); + }); + + it('falls back to unknown when no errorClass was captured', async () => { + const res = await handleLLMError('mystery failure', { + movement: mv(undefined), + messages: [], + tools: [], + toolsUsed: [], + promptGuardRatio: 0.8, + runIsolatedLlm: async () => '', + }); + expect(res?.errorClass).toBe('unknown'); + expect(res?.output).toContain('分類: unknown'); + }); +}); + +describe('buildContextOverflowResult — observability', () => { + it('classifies the ABORT output as context_overflow with the movement name', async () => { + const res = await buildContextOverflowResult(mv(undefined), 'guard says too big', [], []); + expect(res.next).toBe('ABORT'); + expect(res.errorClass).toBe('context_overflow'); + expect(res.output).toContain('分類: context_overflow'); + expect(res.output).toContain('movement: execute'); + expect(res.output).toContain('guard says too big'); + }); + + it('emits force_transition_summary stages around the handoff summary call', async () => { + const stages: string[] = []; + const res = await buildContextOverflowResult( + mv('verify'), 'overflow', [{ role: 'user', content: 'hi' }], [], + async () => { throw new Error('blocked'); }, + (stage) => stages.push(stage), + ); + expect(res.next).toBe('verify'); + expect(stages).toEqual(['force_transition_summary', 'force_transition_summary_failed']); + }); + + it('emits only the start stage when the handoff summary succeeds', async () => { + const stages: string[] = []; + await buildContextOverflowResult( + mv('verify'), 'overflow', [{ role: 'user', content: 'hi' }], [], + async () => 'a summary', + (stage) => stages.push(stage), + ); + expect(stages).toEqual(['force_transition_summary']); + }); +}); diff --git a/src/engine/agent-loop/context-control.ts b/src/engine/agent-loop/context-control.ts index 1c16903..1e331d9 100644 --- a/src/engine/agent-loop/context-control.ts +++ b/src/engine/agent-loop/context-control.ts @@ -31,19 +31,32 @@ export async function buildContextOverflowResult( messages: Message[], toolsUsed: string[], runIsolatedLlm?: (messages: Message[]) => Promise, + onStage?: (stage: string, detail?: string) => void, ): Promise { const fallbackNext = resolveContextOverflowNext(movement.defaultNext); if (fallbackNext === 'ABORT') { - return { next: 'ABORT', output: guardMessage, toolsUsed, abortCode: 'context_overflow' }; + const output = [ + 'コンテキスト上限超過のため中断しました(分類: context_overflow / movement: ' + movement.name + ')。', + '', + guardMessage, + ].join('\n'); + return { next: 'ABORT', output, toolsUsed, abortCode: 'context_overflow', errorClass: 'context_overflow' }; } + // 引き継ぎ要約は isolated LLM 呼び出しで、巨大な会話では数分沈黙し得る + // (さらに preflight で block されて失敗もし得る)。ここが observability の穴 + // だったので、開始と失敗を明示的にステージ通知する。 let handoffSummary: string | null = null; if (runIsolatedLlm) { + onStage?.('force_transition_summary'); try { handoffSummary = await summarizeForceTransition(messages, runIsolatedLlm); } catch { handoffSummary = null; } + if (handoffSummary === null) { + onStage?.('force_transition_summary_failed'); + } } const output = handoffSummary ? [ @@ -157,6 +170,12 @@ interface LLMErrorContext { promptGuardRatio: number; safetyConfig?: SafetyConfig; runIsolatedLlm: (messages: Message[]) => Promise; + /** LlmErrorClass from the failed stream (see openai-compat). */ + errorClass?: string; + /** HTTP status when errorClass === 'http'. */ + httpStatus?: number; + /** Recovery-stage observer (see GuardOptions.onStage). */ + onStage?: (stage: string, detail?: string) => void; } const NO_TOOLS_SUPPORT_RE = /does not support tools|tool.*not.*support|tool_use.*not.*support/i; @@ -190,6 +209,7 @@ export async function handleLLMError( promptGuardRatio: impliedRatio, historySummarization, runIsolatedLlm: ctx.runIsolatedLlm, + onStage: ctx.onStage, }); if (recoveredGuard.ok) { const changedAnything = recoveredGuard.deduped || recoveredGuard.compacted || recoveredGuard.summarized; @@ -217,6 +237,7 @@ export async function handleLLMError( ctx.messages, ctx.toolsUsed, ctx.runIsolatedLlm, + ctx.onStage, ); } @@ -231,5 +252,14 @@ export async function handleLLMError( }; } - return { next: 'ABORT', output: `LLM error: ${errorMessage}`, toolsUsed: ctx.toolsUsed, abortCode: 'llm_error' }; + // 構造化された中断メッセージ: ユーザーが「どの種類の失敗か」を + // エラーメッセージ本文の解読なしに判別できるようにする(観測性 Phase 1)。 + const errorClass = ctx.errorClass ?? 'unknown'; + const classLine = ctx.httpStatus !== undefined ? `${errorClass} (HTTP ${ctx.httpStatus})` : errorClass; + const output = [ + `LLM エラーで中断しました(分類: ${classLine} / movement: ${ctx.movement.name})。`, + '', + errorMessage, + ].join('\n'); + return { next: 'ABORT', output, toolsUsed: ctx.toolsUsed, abortCode: 'llm_error', errorClass }; } diff --git a/src/engine/agent-loop/llm-iteration.ts b/src/engine/agent-loop/llm-iteration.ts index 6b69325..29b8ee8 100644 --- a/src/engine/agent-loop/llm-iteration.ts +++ b/src/engine/agent-loop/llm-iteration.ts @@ -64,6 +64,7 @@ export async function runLlmIteration(args: RunLlmIterationArgs): Promise { callbacks?.onBackendResolved?.({ backendId, cacheKey }); }, + onRetry: (info) => { + events.emit('llm_call_retry', { + iteration, + attempt: info.attempt, + maxAttempts: info.maxAttempts, + reason: info.reason.slice(0, 300), + errorClass: info.errorClass, + httpStatus: info.httpStatus, + delayMs: info.delayMs, + }); + callbacks?.onLlmRetry?.(info); + }, + onThinking: (totalChars) => { + callbacks?.onThinking?.({ chars: totalChars }); + }, }, `movement=${movementName} `, { userId }, @@ -101,7 +117,9 @@ export async function runLlmIteration(args: RunLlmIterationArgs): Promise t.function.name).join(',')}]`); diff --git a/src/engine/agent-loop/types.ts b/src/engine/agent-loop/types.ts index 77a15a3..f1ed62a 100644 --- a/src/engine/agent-loop/types.ts +++ b/src/engine/agent-loop/types.ts @@ -46,6 +46,9 @@ export interface MovementResult { // piece-runner が PieceRunResult.abortReason に伝搬する。未指定なら // 'movement_abort'(後方互換)。 abortCode?: string; + // abortCode='llm_error' のとき、失敗の機械可読分類(LlmErrorClass)。 + // 中断メッセージ・LLM 呼び出しログでの診断用。 + errorClass?: string; } export interface ToolResultInfo { @@ -71,6 +74,16 @@ export interface LLMCallInfo { textChars: number; /** True if the stream surfaced an error mid-flight. */ hadError: boolean; + /** Machine-readable failure class when hadError (LlmErrorClass). */ + errorClass?: string; + /** HTTP status when errorClass === 'http'. */ + httpStatus?: number; + /** Client-internal retries performed during this call. */ + retries?: number; + /** Total reasoning_content chars streamed during this call. */ + thinkingChars?: number; + /** Physical backend that served this call (proxy mode only). */ + backendId?: string; } export interface HandoffContext { @@ -98,6 +111,28 @@ export interface AgentLoopCallbacks { */ onPromptProgress?: (progress: { processed: number; total: number; timeMs: number; cache: number }) => void; onLLMCall?: (info: LLMCallInfo) => void; + /** + * LLM 呼び出しの送信直前に発火。「LLM 応答待ち」のライブ表示用。 + * それまでの currentActivity(最後のツール)が LLM 待ち時間中も + * 表示され続ける問題への対処(観測性 Phase 1)。 + */ + onLlmRequestStart?: (info: { movementName: string; iteration: number; messageCount: number }) => void; + /** + * クライアント内部リトライのバックオフ待ち直前に発火。 + * 「リトライ中 2/3 (HTTP 500)」のライブ表示用。attempt は失敗した試行番号。 + */ + onLlmRetry?: (info: { attempt: number; maxAttempts: number; reason: string; errorClass: string; httpStatus?: number; delayMs: number }) => void; + /** + * thinking モデルの reasoning_content 受信中に累積文字数で発火(内容は流れない)。 + * 高頻度なので消費側(worker)で throttle して SSE のみに流すこと。DB には書かない。 + */ + onThinking?: (info: { chars: number }) => void; + /** + * コンテキスト超過リカバリの各ステージ(dedup/compact/summarize/ + * force_transition_summary 等)の開始・失敗を通知。20分沈黙の + * 「実は復旧処理中だった」を可視化する。 + */ + onContextRecovery?: (info: { stage: string; detail?: string }) => void; /** * Fires when a proxy-mode LLM client resolves the physical backend that * handled the call (see OpenAICompatClient + LLMEvent 'backend'). The diff --git a/src/engine/context/prompt-guard.test.ts b/src/engine/context/prompt-guard.test.ts index 4d2f432..572a66f 100644 --- a/src/engine/context/prompt-guard.test.ts +++ b/src/engine/context/prompt-guard.test.ts @@ -349,3 +349,49 @@ describe('guardPromptBeforeSend — client-preflight estimator parity', () => { expect(res.ok).toBe(false); }); }); + +// 観測性 Phase 1: 復旧ステージの実況通知。 +describe('guardPromptBeforeSend onStage', () => { + it('does not fire onStage when the prompt is under budget', async () => { + const cm = new ContextManager({ limitTokens: 100_000 }); + const stages: string[] = []; + const result = await guardPromptBeforeSend( + [{ role: 'user', content: 'short' }], NO_TOOLS, cm, { onStage: (s) => stages.push(s) }, + ); + expect(result.ok).toBe(true); + expect(stages).toEqual([]); + }); + + it('reports dedup when an oversized prompt is recovered by dedup alone', async () => { + const cm = new ContextManager({ limitTokens: 30_000 }); + const big = asciiApproxTokens(14_000); + const messages: Message[] = [ + { role: 'system', content: 'sys' }, + { role: 'user', content: 'task' }, + ...readPair('r1', '/dup.ts', big), + ...readPair('r2', '/dup.ts', big), + ]; + const stages: string[] = []; + const result = await guardPromptBeforeSend(messages, NO_TOOLS, cm, { onStage: (s) => stages.push(s) }); + expect(result.ok).toBe(true); + expect(stages).toEqual(['dedup']); + }); + + it('reports summarize_failed with the skip reason when summarization cannot help', async () => { + // Single giant user message: no turns → summarizeHistory skips. + const cm = new ContextManager({ limitTokens: 30_000 }); + const messages: Message[] = [ + { role: 'system', content: 'sys' }, + { role: 'user', content: asciiApproxTokens(40_000) }, + ]; + const stages: Array<{ stage: string; detail?: string }> = []; + const result = await guardPromptBeforeSend(messages, NO_TOOLS, cm, { + runIsolatedLlm: async () => 'summary', + onStage: (stage, detail) => stages.push({ stage, detail }), + }); + expect(result.ok).toBe(false); + expect(stages.map((s) => s.stage)).toEqual(['dedup', 'compact', 'summarize', 'summarize_failed']); + const failed = stages.find((s) => s.stage === 'summarize_failed'); + expect(failed?.detail).toBeTruthy(); + }); +}); diff --git a/src/engine/context/prompt-guard.ts b/src/engine/context/prompt-guard.ts index 071491a..015ed64 100644 --- a/src/engine/context/prompt-guard.ts +++ b/src/engine/context/prompt-guard.ts @@ -71,6 +71,12 @@ export interface GuardOptions { promptGuardRatio?: number; historySummarization?: HistorySummarizationConfig; runIsolatedLlm?: (messages: Message[]) => Promise; + /** + * リカバリの各ステージの開始/失敗を通知(観測性)。要約は isolated LLM + * 呼び出しを伴い数十秒〜数分沈黙し得るため、UI のライブ表示に使う。 + * stage: 'dedup' | 'compact' | 'summarize' | 'summarize_failed' + */ + onStage?: (stage: string, detail?: string) => void; } export function looksLikeLargeEncodedPayload(text: string): boolean { @@ -190,6 +196,7 @@ export async function guardPromptBeforeSend( return { ok: true, estimatedTokens: estimated, compacted: false, deduped: false, summarized: false }; } + options.onStage?.('dedup'); const dedup = dedupeFileReads(messages); if (dedup.changed) { estimated = estimateMessagesTokens(messages) + fixedTokens; @@ -199,6 +206,7 @@ export async function guardPromptBeforeSend( return { ok: true, estimatedTokens: estimated, compacted: false, deduped: dedup.changed, summarized: false }; } + options.onStage?.('compact'); const compacted = compactOversizedToolResults(messages, fixedTokens, maxPromptTokens); let summarized = false; if (compacted.changed) { @@ -235,6 +243,7 @@ export async function guardPromptBeforeSend( const tailTurns = options.historySummarization?.tailTurns ?? 2; const preserveRecentBudget = options.historySummarization?.preserveRecentBudget ?? Math.min(Math.floor(limitTokens * 0.25), 8_000); + options.onStage?.('summarize'); const summary = await summarizeHistory(messages, { tailTurns, preserveRecentBudget, @@ -255,6 +264,7 @@ export async function guardPromptBeforeSend( } } else { logger.warn(`[prompt-guard] history summarization skipped: ${summary.reason}`); + options.onStage?.('summarize_failed', summary.reason); } } diff --git a/src/engine/llm-stream.test.ts b/src/engine/llm-stream.test.ts index 000a27f..ba89ef1 100644 --- a/src/engine/llm-stream.test.ts +++ b/src/engine/llm-stream.test.ts @@ -176,3 +176,66 @@ describe('consumeLlmStream', () => { expect(result.errorMessage).toContain('idle safety timeout'); }); }); + +describe('consumeLlmStream observability (Phase 1/2)', () => { + it('captures errorClass and httpStatus from error events', async () => { + const client = new FakeClient([[ + { type: 'error', error: 'HTTP 500: boom', errorClass: 'http', httpStatus: 500 }, + ]]); + const result = await consumeLlmStream(client as never, [], NO_TOOLS, undefined, SHORT_TIMEOUT); + expect(result.hadError).toBe(true); + expect(result.errorClass).toBe('http'); + expect(result.httpStatus).toBe(500); + }); + + it('falls back to gatewayErrorType when errorClass is absent (legacy events)', async () => { + const client = new FakeClient([[ + { type: 'error', error: 'gateway gateway_timeout: slow', gatewayErrorType: 'gateway_timeout' }, + ]]); + const result = await consumeLlmStream(client as never, [], NO_TOOLS, undefined, SHORT_TIMEOUT); + expect(result.errorClass).toBe('gateway_timeout'); + }); + + it('counts retries and forwards onRetry callbacks', async () => { + const client = new FakeClient([[ + { type: 'retry', attempt: 1, maxAttempts: 3, reason: 'HTTP 503', errorClass: 'http', httpStatus: 503, delayMs: 100 }, + { type: 'retry', attempt: 2, maxAttempts: 3, reason: 'HTTP 503', errorClass: 'http', httpStatus: 503, delayMs: 200 }, + { type: 'text', text: 'ok' }, + { type: 'done' }, + ]]); + const retries: number[] = []; + const result = await consumeLlmStream(client as never, [], NO_TOOLS, undefined, SHORT_TIMEOUT, { + onRetry: (info) => retries.push(info.attempt), + }); + expect(result.retries).toBe(2); + expect(retries).toEqual([1, 2]); + expect(result.hadError).toBe(false); + expect(result.accumulatedText).toBe('ok'); + }); + + it('accumulates thinking chars and forwards cumulative totals to onThinking', async () => { + const client = new FakeClient([[ + { type: 'thinking', chars: 10 }, + { type: 'thinking', chars: 5 }, + { type: 'text', text: 'answer' }, + { type: 'done' }, + ]]); + const totals: number[] = []; + const result = await consumeLlmStream(client as never, [], NO_TOOLS, undefined, SHORT_TIMEOUT, { + onThinking: (total) => totals.push(total), + }); + expect(result.thinkingChars).toBe(15); + expect(totals).toEqual([10, 15]); + }); + + it('runIsolatedLlm ignores retry and thinking events', async () => { + const client = new FakeClient([[ + { type: 'retry', attempt: 1, maxAttempts: 3, reason: 'HTTP 503', errorClass: 'http', delayMs: 0 }, + { type: 'thinking', chars: 42 }, + { type: 'text', text: 'summary' }, + { type: 'done' }, + ]]); + const out = await runIsolatedLlm(client as never, [{ role: 'user', content: 'hi' }]); + expect(out).toBe('summary'); + }); +}); diff --git a/src/engine/llm-stream.ts b/src/engine/llm-stream.ts index 498958b..a170a8a 100644 --- a/src/engine/llm-stream.ts +++ b/src/engine/llm-stream.ts @@ -5,6 +5,7 @@ import type { OpenAICompatClient, LLMEvent, LlmCallContext, + LlmErrorClass, } from '../llm/openai-compat.js'; import { logger } from '../logger.js'; import { stripThinkingTokens } from './strip-thinking.js'; @@ -68,6 +69,17 @@ export interface ConsumeStreamCallbacks { * cacheKey is non-null only on LiteLLM cache hits (`x-litellm-cache-key`). */ onBackend?: (backendId: string, cacheKey: string | null) => void; + /** + * Fired when the client is about to sleep before an internal retry + * (transient fetch / retryable HTTP / mid-stream read error), so the + * caller can show "retrying 2/3: HTTP 500" instead of silence. + */ + onRetry?: (info: { attempt: number; maxAttempts: number; reason: string; errorClass: LlmErrorClass; httpStatus?: number; delayMs: number }) => void; + /** + * Fired per reasoning_content chunk with the CUMULATIVE thinking char + * count for this call. Content never flows here — liveness only. + */ + onThinking?: (totalChars: number) => void; } export interface ConsumedLLMResponse { @@ -75,6 +87,14 @@ export interface ConsumedLLMResponse { pendingToolCalls: ToolCall[]; hadError: boolean; errorMessage: string; + /** Machine-readable failure class (set when hadError). */ + errorClass?: LlmErrorClass; + /** HTTP status when errorClass === 'http'. */ + httpStatus?: number; + /** Client-internal retries performed during this call. */ + retries: number; + /** Total reasoning_content chars streamed (thinking models; 0 otherwise). */ + thinkingChars: number; lastUsage?: { prompt_tokens: number; completion_tokens: number }; /** * The physical backend id that handled this call, set when the @@ -117,6 +137,8 @@ export async function consumeLlmStream( pendingToolCalls: [], hadError: false, errorMessage: '', + retries: 0, + thinkingChars: 0, }; let streamExhausted = false; @@ -142,6 +164,7 @@ export async function consumeLlmStream( logger.error(`[llm-stream] ${contextLabel}stream safety timeout or error: ${msg}`); accumulator.hadError = true; accumulator.errorMessage = msg; + accumulator.errorClass = msg.includes('idle safety timeout') ? 'idle_timeout' : (accumulator.errorClass ?? 'stream'); try { await Promise.race([ stream.return(undefined as never), @@ -188,7 +211,24 @@ function handleEvent( case 'error': acc.hadError = true; acc.errorMessage = event.error; - logger.error(`[llm-stream] ${contextLabel}LLM error: ${event.error}`); + acc.errorClass = event.errorClass ?? event.gatewayErrorType ?? 'unknown'; + if (event.httpStatus !== undefined) acc.httpStatus = event.httpStatus; + logger.error(`[llm-stream] ${contextLabel}LLM error: ${event.error} (class=${acc.errorClass})`); + return; + case 'retry': + acc.retries += 1; + callbacks.onRetry?.({ + attempt: event.attempt, + maxAttempts: event.maxAttempts, + reason: event.reason, + errorClass: event.errorClass, + httpStatus: event.httpStatus, + delayMs: event.delayMs, + }); + return; + case 'thinking': + acc.thinkingChars += event.chars; + callbacks.onThinking?.(acc.thinkingChars); return; case 'backend': acc.backendId = event.backendId; diff --git a/src/engine/tools/skills.test.ts b/src/engine/tools/skills.test.ts index 6dc1096..4173966 100644 --- a/src/engine/tools/skills.test.ts +++ b/src/engine/tools/skills.test.ts @@ -190,7 +190,8 @@ describe('ReadSkill tool', () => { expect(fs.existsSync(path.join(workspace, 'skills', 'tdd'))).toBe(false); }); - it('keeps the previous copy intact when refreshing fails mid-copy', () => { + // root ignores chmod-based read denial, so this failure injection only holds as non-root. + it.skipIf(process.getuid?.() === 0)('keeps the previous copy intact when refreshing fails mid-copy', () => { const systemDir = makeTempDir(); const userRoot = makeTempDir(); const workspace = makeTempDir(); diff --git a/src/llm/openai-compat.test.ts b/src/llm/openai-compat.test.ts index 90e244d..7b47087 100644 --- a/src/llm/openai-compat.test.ts +++ b/src/llm/openai-compat.test.ts @@ -73,6 +73,7 @@ describe('OpenAICompatClient retry', () => { expect(fetchMock).toHaveBeenCalledTimes(2); expect(events).toEqual([ + { type: 'retry', attempt: 1, maxAttempts: 2, reason: 'HTTP 503', errorClass: 'http', httpStatus: 503, delayMs: 0 }, { type: 'text', text: 'hello' }, { type: 'done', usage: undefined }, ]); @@ -98,6 +99,7 @@ describe('OpenAICompatClient retry', () => { expect(fetchMock).toHaveBeenCalledTimes(2); expect(events).toEqual([ + { type: 'retry', attempt: 1, maxAttempts: 2, reason: 'socket hang up', errorClass: 'connection', delayMs: 0 }, { type: 'text', text: 'ok' }, { type: 'done', usage: undefined }, ]); @@ -118,7 +120,7 @@ describe('OpenAICompatClient retry', () => { expect(fetchMock).toHaveBeenCalledTimes(1); expect(events).toEqual([ - { type: 'error', error: 'HTTP 400: bad request' }, + { type: 'error', error: 'HTTP 400: bad request', errorClass: 'http', httpStatus: 400 }, ]); }); @@ -434,7 +436,7 @@ describe('OpenAICompatClient external AbortSignal', () => { expect(fetchMock).not.toHaveBeenCalled(); expect(events).toEqual([ - { type: 'error', error: 'Request cancelled by caller' }, + { type: 'error', error: 'Request cancelled by caller', errorClass: 'cancelled' }, ]); }); @@ -456,7 +458,7 @@ describe('OpenAICompatClient external AbortSignal', () => { const events = await collectEvents(client, [{ role: 'user', content: 'hi' }]); expect(events).toEqual([ - { type: 'error', error: 'Request timed out (0 minutes)' }, + { type: 'error', error: 'Request timed out (0 minutes)', errorClass: 'idle_timeout' }, ]); }); @@ -477,7 +479,7 @@ describe('OpenAICompatClient external AbortSignal', () => { const events = await collectEvents(client, [{ role: 'user', content: 'hi' }]); expect(events).toEqual([ - { type: 'error', error: 'Request timed out (5 minutes)' }, + { type: 'error', error: 'Request timed out (5 minutes)', errorClass: 'idle_timeout' }, ]); }); }); @@ -874,3 +876,86 @@ describe('OpenAICompatClient hard stream cap', () => { expect(events.some((e) => e.type === 'done')).toBe(false); }); }); + +describe('OpenAICompatClient observability (Phase 1/2)', () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('emits thinking events (char counts only) for reasoning_content chunks', async () => { + const fetchMock = vi.fn().mockResolvedValue(makeSseResponse([ + { choices: [{ delta: { reasoning_content: 'let me think' } }] }, + { choices: [{ delta: { reasoning_content: ' more' } }] }, + { choices: [{ delta: { content: 'answer' } }] }, + '[DONE]', + ])); + vi.stubGlobal('fetch', fetchMock); + + const client = new OpenAICompatClient('http://llm.test/v1', 'test-model'); + const events = await collectEvents(client, [{ role: 'user', content: 'hi' }]); + + expect(events).toEqual([ + { type: 'thinking', chars: 'let me think'.length }, + { type: 'thinking', chars: ' more'.length }, + { type: 'text', text: 'answer' }, + { type: 'done', usage: undefined }, + ]); + }); + + it('classifies gateway sentinel errors into errorClass', async () => { + const fetchMock = vi.fn().mockResolvedValue(makeSseResponse([ + { error: { type: 'gateway_timeout', message: 'upstream aborted' } }, + ])); + vi.stubGlobal('fetch', fetchMock); + + const client = new OpenAICompatClient('http://llm.test/v1', 'test-model'); + const events = await collectEvents(client, [{ role: 'user', content: 'hi' }]); + + expect(events).toEqual([ + { + type: 'error', + error: 'gateway gateway_timeout: upstream aborted', + errorClass: 'gateway_timeout', + gatewayErrorType: 'gateway_timeout', + }, + ]); + }); + + it('classifies the preflight prompt-size block as preflight_block', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + // Tiny context limit so any real message exceeds the guard. + const client = new OpenAICompatClient( + 'http://llm.test/v1', 'test-model', undefined, undefined, undefined, 10, 0.8, + ); + const events = await collectEvents(client, [{ role: 'user', content: 'x'.repeat(4000) }]); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(events).toHaveLength(1); + const err = events[0] as { type: string; errorClass?: string }; + expect(err.type).toBe('error'); + expect(err.errorClass).toBe('preflight_block'); + }); + + it('adds return_progress to the request body only when requestPromptProgress is set', async () => { + const fetchMock = vi.fn().mockResolvedValue(makeSseResponse(['[DONE]'])); + vi.stubGlobal('fetch', fetchMock); + + const withProgress = new OpenAICompatClient( + 'http://llm.test/v1', 'test-model', undefined, undefined, undefined, undefined, undefined, undefined, + { requestPromptProgress: true }, + ); + await collectEvents(withProgress, [{ role: 'user', content: 'hi' }]); + const body1 = JSON.parse((fetchMock.mock.calls[0]![1] as RequestInit).body as string); + expect(body1.return_progress).toBe(true); + + fetchMock.mockClear(); + fetchMock.mockResolvedValue(makeSseResponse(['[DONE]'])); + const without = new OpenAICompatClient('http://llm.test/v1', 'test-model'); + await collectEvents(without, [{ role: 'user', content: 'hi' }]); + const body2 = JSON.parse((fetchMock.mock.calls[0]![1] as RequestInit).body as string); + expect('return_progress' in body2).toBe(false); + }); +}); diff --git a/src/llm/openai-compat.ts b/src/llm/openai-compat.ts index 93066aa..5f8a1b5 100644 --- a/src/llm/openai-compat.ts +++ b/src/llm/openai-compat.ts @@ -38,6 +38,33 @@ export interface ToolDef { }; } +/** + * Machine-readable classification of an LLM request failure. Travels with + * error/retry events so downstream layers (agent-loop abort messages, the + * per-task LLM call log, the UI) never have to string-parse error text. + * - preflight_block: client-side prompt-size guard refused to send + * - cancelled: external AbortSignal (user cancel / job deadline) + * - idle_timeout: no chunk received for timeoutMs + * - hard_cap: total stream duration exceeded maxStreamMs + * - connection: fetch/network failure + * - http: non-2xx response (see httpStatus) + * - stream: SSE read error mid-stream + * - gateway_*: AAO Gateway sentinel errors (see gatewayErrorType docs) + */ +export type LlmErrorClass = + | 'preflight_block' + | 'cancelled' + | 'idle_timeout' + | 'hard_cap' + | 'connection' + | 'http' + | 'stream' + | 'gateway_shutdown' + | 'gateway_timeout' + | 'budget_exhausted' + | 'rate_limited' + | 'unknown'; + export type LLMEvent = | { type: 'text'; text: string } | { type: 'tool_use'; id: string; name: string; input: Record } @@ -63,7 +90,21 @@ export type LLMEvent = * won't help until the period resets. * Unset for generic transport / parse errors. */ - | { type: 'error'; error: string; gatewayErrorType?: 'gateway_shutdown' | 'gateway_timeout' | 'budget_exhausted' | 'rate_limited' } + | { type: 'error'; error: string; errorClass?: LlmErrorClass; httpStatus?: number; gatewayErrorType?: 'gateway_shutdown' | 'gateway_timeout' | 'budget_exhausted' | 'rate_limited' } + /** + * Emitted just before a client-internal retry sleep (transient fetch + * error, retryable HTTP status, or mid-stream read error). Lets the + * caller surface "retrying 2/3: HTTP 500" instead of silence during + * the backoff wait. `attempt` is the attempt that just FAILED. + */ + | { type: 'retry'; attempt: number; maxAttempts: number; reason: string; errorClass: LlmErrorClass; httpStatus?: number; delayMs: number } + /** + * Per-chunk `reasoning_content` size (thinking models). The content + * itself is intentionally NOT forwarded — only char counts, so the UI + * can show "thinking…" liveness without leaking chain-of-thought into + * transcripts. Consumers accumulate. + */ + | { type: 'thinking'; chars: number } /** * Emitted once per request, immediately after response headers arrive, * for proxy-backed clients (LiteLLM Proxy etc.). Carries the physical @@ -288,6 +329,14 @@ export interface OpenAICompatClientOptions { * timeout so every client is bounded even if the caller forgets to set it. */ maxStreamMs?: number; + /** + * When true, add `return_progress: true` to the request body so + * llama.cpp's llama-server streams `prompt_progress` chunks during + * prompt evaluation (surfaced as 'prompt_progress' events). Opt-in + * per worker: non-llama.cpp backends (vLLM, some gateways) may reject + * unknown body fields, so this must never default to on. + */ + requestPromptProgress?: boolean; } /** @@ -335,8 +384,11 @@ export class OpenAICompatClient { // still bounded. `?? ` not `||` so an explicit 0 stays 0 (disabled). this.maxStreamMs = options?.maxStreamMs ?? this.timeoutMs * 2; this.proxy = options?.proxy === true; + this.requestPromptProgress = options?.requestPromptProgress === true; } + private readonly requestPromptProgress: boolean; + private buildAbortErrorMessage(externalSignal?: AbortSignal, hardCapHit = false): string { if (externalSignal?.aborted) { return 'Request cancelled by caller'; @@ -349,6 +401,13 @@ export class OpenAICompatClient { return `Request timed out (${mins} minutes)`; } + /** Classify an AbortError raised inside chat() into its actual trigger. */ + private classifyAbort(externalSignal?: AbortSignal, hardCapHit = false): LlmErrorClass { + if (externalSignal?.aborted) return 'cancelled'; + if (hardCapHit) return 'hard_cap'; + return 'idle_timeout'; + } + /** * Backend the next request should prefer (gateway sticky routing for * KV-cache reuse). Updated by the worker whenever the resolved backend @@ -429,7 +488,7 @@ export class OpenAICompatClient { if (externalSignal.aborted) { clearTimeout(timeoutId); if (hardCapId) clearTimeout(hardCapId); - yield { type: 'error', error: 'Request cancelled by caller' }; + yield { type: 'error', error: 'Request cancelled by caller', errorClass: 'cancelled' }; return; } onExternalAbort = () => controller.abort(); @@ -456,6 +515,11 @@ export class OpenAICompatClient { stream: true, stream_options: { include_usage: true }, }; + if (this.requestPromptProgress) { + // llama.cpp llama-server extension: stream prompt-eval progress + // chunks. Opt-in per worker (see OpenAICompatClientOptions). + body['return_progress'] = true; + } if (this.model) { body['model'] = this.model; } @@ -478,7 +542,7 @@ export class OpenAICompatClient { logPromptBreakdown('blocked', body, messages, tools, estimatedPromptTokens, maxPromptTokens, this.contextLimitTokens, this.onPromptPreflight); const error = buildPromptTooLargeError(estimatedPromptTokens, maxPromptTokens, this.contextLimitTokens, this.promptGuardRatio); logger.warn(`OpenAICompatClient: ${error}`); - yield { type: 'error', error }; + yield { type: 'error', error, errorClass: 'preflight_block' }; return; } logPromptBreakdown('ok', body, messages, tools, estimatedPromptTokens, maxPromptTokens, this.contextLimitTokens, this.onPromptPreflight); @@ -507,22 +571,23 @@ export class OpenAICompatClient { } catch (err) { if ((err as Error)?.name === 'AbortError') { logger.error('OpenAICompatClient: request timed out'); - yield { type: 'error', error: this.buildAbortErrorMessage(externalSignal, hardCapHit) }; + yield { type: 'error', error: this.buildAbortErrorMessage(externalSignal, hardCapHit), errorClass: this.classifyAbort(externalSignal, hardCapHit) }; return; } lastErrorMessage = err instanceof Error ? err.message : String(err); if (!isTransientFetchError(err) || attempt >= maxAttempts) { logger.error(`OpenAICompatClient: fetch failed: ${lastErrorMessage}`); - yield { type: 'error', error: `Connection error: ${lastErrorMessage}` }; + yield { type: 'error', error: `Connection error: ${lastErrorMessage}`, errorClass: 'connection' }; return; } const delayMs = getRetryDelayMs(this.retryConfig, attempt); logger.warn(`OpenAICompatClient: transient fetch error on attempt ${attempt}/${maxAttempts}: ${lastErrorMessage}; retrying in ${delayMs}ms`); + yield { type: 'retry', attempt, maxAttempts, reason: lastErrorMessage, errorClass: 'connection', delayMs }; if (!(await waitForRetry(delayMs, controller.signal))) { logger.error('OpenAICompatClient: request timed out'); - yield { type: 'error', error: this.buildAbortErrorMessage(externalSignal, hardCapHit) }; + yield { type: 'error', error: this.buildAbortErrorMessage(externalSignal, hardCapHit), errorClass: this.classifyAbort(externalSignal, hardCapHit) }; return; } continue; @@ -542,22 +607,23 @@ export class OpenAICompatClient { if (!isRetryableHttpStatus(response.status, this.retryConfig) || attempt >= maxAttempts) { logger.error(`OpenAICompatClient: ${lastErrorMessage}`); - yield { type: 'error', error: lastErrorMessage }; + yield { type: 'error', error: lastErrorMessage, errorClass: 'http', httpStatus: response.status }; return; } const delayMs = getRetryDelayMs(this.retryConfig, attempt); logger.warn(`OpenAICompatClient: retryable HTTP ${response.status} on attempt ${attempt}/${maxAttempts}; retrying in ${delayMs}ms`); + yield { type: 'retry', attempt, maxAttempts, reason: `HTTP ${response.status}`, errorClass: 'http', httpStatus: response.status, delayMs }; if (!(await waitForRetry(delayMs, controller.signal))) { logger.error('OpenAICompatClient: request timed out'); - yield { type: 'error', error: this.buildAbortErrorMessage(externalSignal, hardCapHit) }; + yield { type: 'error', error: this.buildAbortErrorMessage(externalSignal, hardCapHit), errorClass: this.classifyAbort(externalSignal, hardCapHit) }; return; } continue; } if (!response.body) { - yield { type: 'error', error: 'Response body is null' }; + yield { type: 'error', error: 'Response body is null', errorClass: 'connection' }; return; } @@ -656,6 +722,7 @@ export class OpenAICompatClient { yield { type: 'error', error: `gateway ${errObj.type}: ${msg}`, + errorClass: errObj.type as LlmErrorClass, gatewayErrorType: errObj.type as 'gateway_shutdown' | 'gateway_timeout' | 'budget_exhausted' | 'rate_limited', }; return; @@ -697,10 +764,12 @@ export class OpenAICompatClient { const finishReason = choice['finish_reason'] as string | null | undefined; if (delta) { - // reasoning_content (thinking models) — スキップしてログのみ + // reasoning_content (thinking models) — 中身は流さず文字数のみ + // 'thinking' イベントで通知(UI の「思考中」表示用)。 const reasoning = delta['reasoning_content']; if (typeof reasoning === 'string' && reasoning.length > 0) { - logger.debug(`OpenAICompatClient: reasoning_content (${reasoning.length} chars), skipping`); + logger.debug(`OpenAICompatClient: reasoning_content (${reasoning.length} chars)`); + yield { type: 'thinking', chars: reasoning.length }; } // テキストチャンク @@ -764,22 +833,23 @@ export class OpenAICompatClient { const message = err instanceof Error ? err.message : String(err); if ((err as Error)?.name === 'AbortError') { logger.error('OpenAICompatClient: request timed out'); - yield { type: 'error', error: this.buildAbortErrorMessage(externalSignal, hardCapHit) }; + yield { type: 'error', error: this.buildAbortErrorMessage(externalSignal, hardCapHit), errorClass: this.classifyAbort(externalSignal, hardCapHit) }; return; } // 一時的なストリームエラー — 試行回数が残っていればリトライ if (attempt >= maxAttempts) { logger.error(`OpenAICompatClient: stream read error: ${message}`); - yield { type: 'error', error: `Stream error: ${message}` }; + yield { type: 'error', error: `Stream error: ${message}`, errorClass: 'stream' }; return; } const delayMs = getRetryDelayMs(this.retryConfig, attempt); logger.warn(`OpenAICompatClient: stream read error on attempt ${attempt}/${maxAttempts}: ${message}; retrying in ${delayMs}ms`); + yield { type: 'retry', attempt, maxAttempts, reason: message, errorClass: 'stream', delayMs }; if (delayMs > 0 && !(await waitForRetry(delayMs, controller.signal))) { logger.error('OpenAICompatClient: request timed out during retry wait'); - yield { type: 'error', error: this.buildAbortErrorMessage(externalSignal, hardCapHit) }; + yield { type: 'error', error: this.buildAbortErrorMessage(externalSignal, hardCapHit), errorClass: this.classifyAbort(externalSignal, hardCapHit) }; return; } continue; @@ -797,7 +867,7 @@ export class OpenAICompatClient { } // 全試行が失敗した場合 - yield { type: 'error', error: lastErrorMessage || 'Unknown request error' }; + yield { type: 'error', error: lastErrorMessage || 'Unknown request error', errorClass: 'unknown' }; } finally { clearTimeout(timeoutId); if (hardCapId) clearTimeout(hardCapId); diff --git a/src/worker.ts b/src/worker.ts index e3300e0..b04ac7f 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -1427,7 +1427,11 @@ export class Worker { this.contextLimitTokens, this.config.safety?.promptGuardRatio, (line) => reporter.reportPromptPreflight(line), - { proxy: isProxyWorker, maxStreamMs: this.resolveMaxStreamMs() }, + { + proxy: isProxyWorker, + maxStreamMs: this.resolveMaxStreamMs(), + requestPromptProgress: workerDefForLlm.returnProgress === true, + }, ); return { llmClient, isProxyWorker }; } @@ -2492,6 +2496,10 @@ export class Worker { const truncate = (s: string, cap: number): string => s.length > cap ? s.slice(0, cap) + `\n…[truncated ${s.length - cap} bytes]` : s; + // thinking イベントは reasoning チャンクごとに来るため SSE を throttle。 + const THINKING_EMIT_THROTTLE_MS = 2_000; + let lastThinkingEmitAt = 0; + return { onMovementStart: (name) => { movementStartTime = Date.now(); @@ -2538,6 +2546,46 @@ export class Worker { }); } }, + // ── LLM ライブ状態(観測性 Phase 1/2)──────────────────────────── + // 方針: ライブ表示は SSE 専用、DB (currentActivity) には低頻度の + // 節目(送信開始・リトライ・復旧ステージ)だけ書く。thinking は + // 高頻度なので throttle した SSE のみで DB には一切書かない。 + onLlmRequestStart: ({ movementName, iteration }) => { + this.repo.updateJob(jobId, { currentActivity: `LLM: 応答待ち (${movementName})`.slice(0, 200) }); + if (jobEventBus.hasListeners(jobId)) { + jobEventBus.emitJob(jobId, { type: 'llm_state', phase: 'waiting', movement: movementName, iteration }); + } + }, + onLlmRetry: (info) => { + const reasonShort = info.reason.slice(0, 80); + this.repo.updateJob(jobId, { + currentActivity: `LLM: リトライ待ち ${info.attempt}/${info.maxAttempts} (${reasonShort})`.slice(0, 200), + }); + if (jobEventBus.hasListeners(jobId)) { + jobEventBus.emitJob(jobId, { + type: 'llm_state', + phase: 'retrying', + attempt: info.attempt, + maxAttempts: info.maxAttempts, + reason: reasonShort, + errorClass: info.errorClass, + }); + } + }, + onThinking: ({ chars }) => { + const now = Date.now(); + if (now - lastThinkingEmitAt < THINKING_EMIT_THROTTLE_MS) return; + lastThinkingEmitAt = now; + if (jobEventBus.hasListeners(jobId)) { + jobEventBus.emitJob(jobId, { type: 'llm_state', phase: 'thinking', chars }); + } + }, + onContextRecovery: ({ stage, detail }) => { + this.repo.updateJob(jobId, { currentActivity: `LLM: コンテキスト復旧中 (${stage})`.slice(0, 200) }); + if (jobEventBus.hasListeners(jobId)) { + jobEventBus.emitJob(jobId, { type: 'llm_state', phase: 'recovering', stage, reason: detail?.slice(0, 120) }); + } + }, onDelegateLifecycle: (info) => { if (jobEventBus.hasListeners(jobId)) { jobEventBus.emitJob(jobId, { diff --git a/ui/e2e/calendar.spec.ts b/ui/e2e/calendar.spec.ts index 841a6f9..c59855b 100644 --- a/ui/e2e/calendar.spec.ts +++ b/ui/e2e/calendar.spec.ts @@ -56,7 +56,7 @@ async function createAndOpenCaseSpace(page: Page, label: string) { await expect(titleInput).toBeVisible(); const caseTitle = `${label}-${Date.now()}`; await titleInput.fill(caseTitle); - await page.getByTestId('create-space-submit').click(); + await page.getByTestId('space-form-submit').click(); await expect(titleInput).toHaveCount(0); const caseRow = rail diff --git a/ui/e2e/spaces.spec.ts b/ui/e2e/spaces.spec.ts index 2957f8d..7654eb6 100644 --- a/ui/e2e/spaces.spec.ts +++ b/ui/e2e/spaces.spec.ts @@ -59,7 +59,7 @@ async function createAndOpenCaseSpace(page: Page, label: string) { await expect(titleInput).toBeVisible(); const caseTitle = `${label}-${Date.now()}`; await titleInput.fill(caseTitle); - await page.getByTestId('create-space-submit').click(); + await page.getByTestId('space-form-submit').click(); await expect(titleInput).toHaveCount(0); const caseRow = rail @@ -190,7 +190,7 @@ test('spaces foundation: rail, create case space, open detail tabs', async ({ pa const caseTitle = `E2E案件-${Date.now()}`; await titleInput.fill(caseTitle); - await page.getByTestId('create-space-submit').click(); + await page.getByTestId('space-form-submit').click(); // 5. The new case space appears in the rail (and dialog closes). await expect(titleInput).toHaveCount(0); @@ -242,7 +242,7 @@ test('spaces mobile: rail <-> detail switch with back control at 390px', async ( await expect(titleInput).toBeVisible(); const caseTitle = `E2Eモバイル-${Date.now()}`; await titleInput.fill(caseTitle); - await page.getByTestId('create-space-submit').click(); + await page.getByTestId('space-form-submit').click(); await expect(titleInput).toHaveCount(0); // A space is open: detail is now the visible full-width view, rail is hidden, @@ -301,7 +301,7 @@ test('space inline chat: create + open conversation without leaving for Tasks', await expect(titleInput).toBeVisible(); const caseTitle = `E2E会話-${Date.now()}`; await titleInput.fill(caseTitle); - await page.getByTestId('create-space-submit').click(); + await page.getByTestId('space-form-submit').click(); await expect(titleInput).toHaveCount(0); const caseRow = rail @@ -433,7 +433,7 @@ test('space files: icon grid, upload appears as tile, click opens preview modal' await expect(titleInput).toBeVisible(); const caseTitle = `E2Eファイル-${Date.now()}`; await titleInput.fill(caseTitle); - await page.getByTestId('create-space-submit').click(); + await page.getByTestId('space-form-submit').click(); await expect(titleInput).toHaveCount(0); const caseRow = rail @@ -617,7 +617,7 @@ test('space output link: chat output-path link opens preview modal', async ({ pa await expect(titleInput).toBeVisible(); const caseTitle = `E2Eリンク-${Date.now()}`; await titleInput.fill(caseTitle); - await page.getByTestId('create-space-submit').click(); + await page.getByTestId('space-form-submit').click(); await expect(titleInput).toHaveCount(0); const caseRow = rail @@ -724,7 +724,7 @@ test('space chat tabs: single in-place tab bar, overview in place, back to chat' await expect(titleInput).toBeVisible(); const caseTitle = `E2Eタブ-${Date.now()}`; await titleInput.fill(caseTitle); - await page.getByTestId('create-space-submit').click(); + await page.getByTestId('space-form-submit').click(); await expect(titleInput).toHaveCount(0); const caseRow = rail @@ -839,7 +839,7 @@ test('space settings tab: AGENTS.md per-space persistence + MCP/SSH panels rende await expect(titleInput).toBeVisible(); const caseTitle = `E2E設定-${Date.now()}`; await titleInput.fill(caseTitle); - await page.getByTestId('create-space-submit').click(); + await page.getByTestId('space-form-submit').click(); await expect(titleInput).toHaveCount(0); const caseRow = rail @@ -1292,46 +1292,39 @@ test('space chat CONTINUE: button gated on terminal job; opens ContinueWithPiece expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]); }); -// 5. VISIBILITY — the toolbar visibility was removed when the +// scope became fixed, and the static note chip that replaced it was removed in +// PR #780 (the scope is documented in help instead). This test guards that no +// visibility control sneaks back into the conversation toolbar, while the +// action buttons that DO belong there (share / delete) stay present — so the +// assertion can't pass vacuously on a broken/empty toolbar. The auth-mode twin +// of this guard lives in ui/e2e-auth/sharing-scope.auth.spec.ts. (History: +// issue #782 — the old "change to public persists" test referenced the removed +// testid and had been silently dead since the selector was retired.) +test('space chat VISIBILITY: no visibility control (fixed member-only scope)', async ({ page }) => { const fatalErrors = trackFatalErrors(page); - const { detail, spaceId } = await createAndOpenCaseSpace(page, 'E2E可視性'); - const { conversation, taskId } = await startInlineChat( + await createAndOpenCaseSpace(page, 'E2E可視性'); + const { conversation } = await startInlineChat( page, 'E2E: 可視性テスト用チャット。最初のメッセージ。', ); - const visibility = conversation.getByTestId('space-chat-visibility'); - await expect(visibility).toBeVisible(); - await expect(visibility).toHaveValue('private'); + // Positive anchor first: the toolbar actions are rendered, so the negative + // assertions below are checked against a live, non-empty conversation UI. + await expect(conversation.getByTestId('space-chat-share')).toBeVisible(); + await expect(conversation.getByTestId('space-chat-delete')).toBeVisible(); - // Wait for the PATCH so we don't assert before the server commits the change. - // (updateLocalTask uses PATCH /api/local/tasks/:id.) - const patchPromise = page.waitForResponse( - (r) => new RegExp(`/api/local/tasks/${taskId}$`).test(r.url()) && r.request().method() === 'PATCH', - ); - await visibility.selectOption('public'); - const patchRes = await patchPromise; - expect(patchRes.status()).toBe(200); - await expect(visibility).toHaveValue('public'); + // No visibility selector, no visibility note — scope is fixed and implicit. + await expect(conversation.getByTestId('space-chat-visibility')).toHaveCount(0); + await expect(conversation.getByTestId('space-chat-visibility-note')).toHaveCount(0); - // Reload, reopen the same space + chat via URL state (space= selects the space, - // chat= opens the inline conversation), and assert the select retained public - // (proves server-side persistence, not just local component state). - await page.goto(`/ui?page=spaces&space=${spaceId}&chat=${taskId}`); - await expect(detail).toBeVisible(); - const visibilityAfter = page.getByTestId('space-conversation').getByTestId('space-chat-visibility'); - await expect(visibilityAfter).toBeVisible({ timeout: 15_000 }); - await expect(visibilityAfter).toHaveValue('public', { timeout: 15_000 }); - - await expect(page).toHaveURL(/[?&]page=spaces(&|$)/); + // Still on the spaces view with the chat selected. The current URL scheme + // carries space= + chat= params (the legacy page=spaces param is gone; other + // tests in this file still assert it and are stale — see issue #782 follow-up). + await expect(page).toHaveURL(/[?&]space=[^&]+/); + await expect(page).toHaveURL(/[?&]chat=\d+/); expect(page.url()).not.toContain('page=tasks'); expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]); }); diff --git a/ui/src/api/tasks.ts b/ui/src/api/tasks.ts index bce80e5..cf7879a 100644 --- a/ui/src/api/tasks.ts +++ b/ui/src/api/tasks.ts @@ -60,6 +60,11 @@ export interface LocalTask { waitReason?: string | null; currentMovement?: string | null; currentActivity?: string | null; + /** 現在の試行回数 (1 始まり)。>1 は中断→自動再試行が起きた印。 */ + attempt?: number; + maxAttempts?: number; + /** 直近の中断理由コード (context_overflow / llm_error 等)。 */ + abortReason?: string | null; workerId?: string | null; /** * Physical backend id (e.g. LiteLLM deployment) for jobs run through diff --git a/ui/src/components/chat/ChatPane.tsx b/ui/src/components/chat/ChatPane.tsx index f0d4244..fc31421 100644 --- a/ui/src/components/chat/ChatPane.tsx +++ b/ui/src/components/chat/ChatPane.tsx @@ -198,7 +198,7 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ }; const jobStatus = task.latestJob?.status; - const { promptProgress, streamingText, toolCallStream, connected, delegateStreams } = useJobStream(task.id, jobStatus); + const { promptProgress, streamingText, toolCallStream, connected, delegateStreams, llmState } = useJobStream(task.id, jobStatus); // Most-recent content-field tool with decoded content to show live. const liveToolContent = useMemo(() => { @@ -287,6 +287,19 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ

{task.title}

#{task.id} · {task.pieceName}
+ {(task.latestJob?.attempt ?? 1) > 1 && jobStatus !== 'succeeded' && ( +
+ + {t('pane.attemptBadge', { attempt: task.latestJob?.attempt, max: task.latestJob?.maxAttempts ?? 3 })} + +
+ )} {isBusy && (
- {t('pane.agentResponding')} + {llmState?.phase === 'thinking' ? t('pane.llmThinking', { chars: (llmState.chars ?? 0).toLocaleString() }) + : llmState?.phase === 'retrying' ? t('pane.llmRetrying', { attempt: llmState.attempt ?? 0, max: llmState.maxAttempts ?? 0, reason: llmState.reason ?? llmState.errorClass ?? '' }) + : llmState?.phase === 'recovering' ? t('pane.llmRecovering', { stage: llmState.stage ?? '' }) + : llmState?.phase === 'waiting' ? t('pane.llmWaiting') + : t('pane.agentResponding')}
)}
diff --git a/ui/src/components/detail/tabs/TraceTab.tsx b/ui/src/components/detail/tabs/TraceTab.tsx index 03df865..4d5e3f5 100644 --- a/ui/src/components/detail/tabs/TraceTab.tsx +++ b/ui/src/components/detail/tabs/TraceTab.tsx @@ -63,11 +63,11 @@ const CATEGORIES: Array<{ id: string; label: string; kinds: string[]; tone: stri { id: 'run', label: 'Run', kinds: ['run_start', 'run_complete'], tone: 'bg-surface-2 text-slate-700 border-hairline' }, { id: 'movement', label: 'Movement', kinds: ['movement_start', 'movement_complete', 'transition', 'complete'], tone: 'bg-blue-50 text-blue-800 border-blue-100 dark:bg-blue-500/15 dark:text-blue-300 dark:border-blue-500/30' }, { id: 'tool', label: 'Tool', kinds: ['tool_call', 'tool_result'], tone: 'bg-canvas text-slate-700 border-hairline' }, - { id: 'llm', label: 'LLM', kinds: ['llm_call_start', 'llm_call_end'], tone: 'bg-indigo-50 text-indigo-800 border-indigo-100 dark:bg-indigo-500/15 dark:text-indigo-300 dark:border-indigo-500/30' }, + { id: 'llm', label: 'LLM', kinds: ['llm_call_start', 'llm_call_end', 'llm_call_retry'], tone: 'bg-indigo-50 text-indigo-800 border-indigo-100 dark:bg-indigo-500/15 dark:text-indigo-300 dark:border-indigo-500/30' }, { id: 'cache', label: 'Cache', kinds: ['cache_set', 'cache_hit', 'cache_invalidate'], tone: 'bg-amber-50 text-amber-800 border-amber-100 dark:bg-amber-500/15 dark:text-amber-300 dark:border-amber-500/30' }, { id: 'memory', label: 'Memory', kinds: ['memory_invalidate', 'memory_update_call', 'memory_handoff_write', 'memory_handoff_read', 'memory_delta_write', 'memory_delta_absorb', 'memory_snapshot_written', 'memory_snapshot_failed'], tone: 'bg-emerald-50 text-emerald-800 border-emerald-100 dark:bg-emerald-500/15 dark:text-emerald-300 dark:border-emerald-500/30' }, { id: 'watchdog', label: 'Watchdog', kinds: ['watchdog_fire', 'followup_detected'], tone: 'bg-red-50 text-red-800 border-red-100 dark:bg-red-500/15 dark:text-red-300 dark:border-red-500/30' }, - { id: 'context', label: 'Context', kinds: ['context_action'], tone: 'bg-violet-50 text-violet-800 border-violet-100 dark:bg-violet-500/15 dark:text-violet-300 dark:border-violet-500/30' }, + { id: 'context', label: 'Context', kinds: ['context_action', 'context_recovery'], tone: 'bg-violet-50 text-violet-800 border-violet-100 dark:bg-violet-500/15 dark:text-violet-300 dark:border-violet-500/30' }, ]; /** diff --git a/ui/src/components/settings/AskSubtasksForm.test.tsx b/ui/src/components/settings/AskSubtasksForm.test.tsx new file mode 100644 index 0000000..5f9a8e7 --- /dev/null +++ b/ui/src/components/settings/AskSubtasksForm.test.tsx @@ -0,0 +1,38 @@ +// @vitest-environment jsdom +/** + * Component tests for AskSubtasksForm — focus on the newly surfaced + * subtasks.spawnStaggerMs field. + */ +import '../../test/dom-setup'; +import { describe, it, expect, vi } from 'vitest'; +import { screen, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithProviders, renderStatefulForm } from '../../test/render-helpers'; +import { AskSubtasksForm } from './AskSubtasksForm'; + +describe('AskSubtasksForm', () => { + it('shows spawnStaggerMs default (1000) in the last number field', () => { + const onChange = vi.fn(); + renderWithProviders(); + const numbers = screen.getAllByRole('spinbutton') as HTMLInputElement[]; + expect(numbers[numbers.length - 1]).toHaveValue(1000); + }); + + it('writes subtasks.spawnStaggerMs as a Number', async () => { + const onChange = vi.fn(); + const { getConfig } = renderStatefulForm(AskSubtasksForm, { subtasks: { spawnStaggerMs: 1000 } }, { onChangeSpy: onChange }); + const numbers = screen.getAllByRole('spinbutton'); + const field = numbers[numbers.length - 1]; + fireEvent.change(field, { target: { value: '250' } }); + expect(getConfig().subtasks.spawnStaggerMs).toBe(250); + expect(onChange).toHaveBeenCalledWith('subtasks.spawnStaggerMs', 250); + }); + + it('serializes spawnStaggerMs as undefined when cleared', async () => { + const onChange = vi.fn(); + renderWithProviders(); + const numbers = screen.getAllByRole('spinbutton'); + await userEvent.clear(numbers[numbers.length - 1]); + expect(onChange).toHaveBeenLastCalledWith('subtasks.spawnStaggerMs', undefined); + }); +}); diff --git a/ui/src/components/settings/AskSubtasksForm.tsx b/ui/src/components/settings/AskSubtasksForm.tsx index 262f2bb..5a54b0b 100644 --- a/ui/src/components/settings/AskSubtasksForm.tsx +++ b/ui/src/components/settings/AskSubtasksForm.tsx @@ -29,6 +29,12 @@ export function AskSubtasksForm({ config, onChange }: SectionFormProps) { onChange('subtasks.maxPerParent', v ? Number(v) : undefined)} /> {t('askSubtasks.maxPerParentHelp')} + +
+ Subtasks: Spawn Stagger (ms) + onChange('subtasks.spawnStaggerMs', v === '' ? undefined : Number(v))} /> + {t('askSubtasks.spawnStaggerHelp')} +
); } diff --git a/ui/src/components/settings/ConfigForm.tsx b/ui/src/components/settings/ConfigForm.tsx index bab2ddf..efe66a2 100644 --- a/ui/src/components/settings/ConfigForm.tsx +++ b/ui/src/components/settings/ConfigForm.tsx @@ -24,6 +24,7 @@ import { MemoryLearningForm } from './MemoryLearningForm'; import { MetricsForm } from './MetricsForm'; import { ServerTlsForm } from './ServerTlsForm'; import { ReflectionForm } from './ReflectionForm'; +import { SkillsQuotaForm } from './SkillsQuotaForm'; import { McpForm } from './McpForm'; import { SshForm } from './SshForm'; import { GatewayServerForm } from './GatewayServerForm'; @@ -242,6 +243,7 @@ function ConfigFormInner({ section }: ConfigFormProps) { case 'context': return ; case 'safety': return ; case 'reflection': return ; + case 'skills-quota': return ; case 'push-notifications': return ; case 'auth': return ; diff --git a/ui/src/components/settings/ExecutionForm.tsx b/ui/src/components/settings/ExecutionForm.tsx index 441b35b..d214223 100644 --- a/ui/src/components/settings/ExecutionForm.tsx +++ b/ui/src/components/settings/ExecutionForm.tsx @@ -65,6 +65,17 @@ export function ExecutionForm({ config, onChange, overriddenByEnv }: SectionForm /> {t('execution.backoffHelp')} + +
+

{t('execution.pythonPanel.title')}

+
+

{t('execution.pythonPanel.intro')}

+

{t('execution.pythonPanel.preinstalled')}

+

{t('execution.pythonPanel.blocked')}

+

{t('execution.pythonPanel.howToAdd')}

+

{t('execution.pythonPanel.roadmap')}

+
+
); } diff --git a/ui/src/components/settings/GatewayServerForm.tsx b/ui/src/components/settings/GatewayServerForm.tsx index 9bf4145..3ed6c02 100644 --- a/ui/src/components/settings/GatewayServerForm.tsx +++ b/ui/src/components/settings/GatewayServerForm.tsx @@ -67,6 +67,7 @@ interface GatewayConfigShape { requestTimeoutSec?: number; upstreamTimeoutSec?: number; shutdownGracefulSec?: number; + internalTeams?: string[]; backends?: GatewayBackend[]; virtualKeys?: unknown[]; } @@ -189,6 +190,7 @@ export function GatewayServerForm({ config, onChange }: SectionFormProps) { const setRequestTimeout = (v: number | undefined) => onChange('gateway.requestTimeoutSec', v); const setUpstreamTimeout = (v: number | undefined) => onChange('gateway.upstreamTimeoutSec', v); const setShutdownGraceful = (v: number | undefined) => onChange('gateway.shutdownGracefulSec', v); + const setInternalTeams = (teams: string[]) => onChange('gateway.internalTeams', teams.length ? teams : undefined); const updateBackend = (i: number, field: keyof GatewayBackend, value: unknown) => { const next = backends.map((b, idx) => (idx === i ? { ...b, [field]: value } : b)); @@ -255,6 +257,20 @@ export function GatewayServerForm({ config, onChange }: SectionFormProps) { +
+ {t('gateway.server.internalTeamsLabel')} + + setInternalTeams( + v.split(',').map(s => s.trim()).filter(s => s.length > 0), + ) + } + placeholder="orchestrator, ops" + /> + {t('gateway.server.internalTeamsHelp')} +
+

Backends

diff --git a/ui/src/components/settings/LlmWorkersForm.test.tsx b/ui/src/components/settings/LlmWorkersForm.test.tsx index 6ac7b47..9e6cbe8 100644 --- a/ui/src/components/settings/LlmWorkersForm.test.tsx +++ b/ui/src/components/settings/LlmWorkersForm.test.tsx @@ -123,4 +123,19 @@ describe('LlmWorkersForm', () => { }); expect(screen.queryByText('llmWorkers.selfLoopWarn')).toBeNull(); }); + + it('serializes llm.maxStreamMinutes as undefined when cleared', async () => { + const { onChange } = render({ llm: { workers: [], maxStreamMinutes: 20 } }); + const field = (screen.getAllByRole('spinbutton') as HTMLInputElement[]).find(n => n.value === '20')!; + await userEvent.clear(field); + expect(onChange).toHaveBeenLastCalledWith('llm.maxStreamMinutes', undefined); + }); + + it('clears a per-worker healthcheckIntervalSeconds to undefined', async () => { + const { onChange } = render({ llm: { workers: [{ id: 'w1', healthcheckIntervalSeconds: 30 }] } }); + const field = (screen.getAllByRole('spinbutton') as HTMLInputElement[]).find(n => n.value === '30')!; + await userEvent.clear(field); + const call = onChange.mock.calls.filter(([p]: [string]) => p === 'llm.workers').at(-1); + expect(call?.[1][0].healthcheckIntervalSeconds).toBeUndefined(); + }); }); diff --git a/ui/src/components/settings/LlmWorkersForm.tsx b/ui/src/components/settings/LlmWorkersForm.tsx index e908139..8fc2be5 100644 --- a/ui/src/components/settings/LlmWorkersForm.tsx +++ b/ui/src/components/settings/LlmWorkersForm.tsx @@ -24,6 +24,9 @@ interface LlmWorker { maxConcurrency?: number; enabled?: boolean; vlm?: boolean; + /** llama.cpp の prompt 評価進捗(return_progress)を要求。llama.cpp 系専用のオプトイン。 */ + returnProgress?: boolean; + healthcheckIntervalSeconds?: number; /** * Phase 1 compat: older `provider.workers[].proxy: true` rows are * mapped to `connectionType: aao_gateway` by the normalizer. We @@ -35,6 +38,7 @@ interface LlmWorker { interface LlmConfigShape { timeoutMinutes?: number; + maxStreamMinutes?: number; retry?: { maxAttempts?: number; backoffMs?: number[]; @@ -285,6 +289,16 @@ export function LlmWorkersForm({ config, onChange, overriddenByEnv }: SectionFor />
+
+ {t('llmWorkers.healthcheckInterval')} + updateWorker(i, { healthcheckIntervalSeconds: v === '' ? undefined : Number(v) })} + /> + {t('llmWorkers.healthcheckIntervalHelp')} +
+
+
@@ -335,6 +361,16 @@ export function LlmWorkersForm({ config, onChange, overriddenByEnv }: SectionFor {t('llmWorkers.timeoutHelp')} +
+ Max Stream (minutes) + onChange('llm.maxStreamMinutes', v === '' ? undefined : Number(v))} + /> + {t('llmWorkers.maxStreamHelp')} +
+

{t('llmWorkers.retryTitle')}

diff --git a/ui/src/components/settings/SafetyForm.test.tsx b/ui/src/components/settings/SafetyForm.test.tsx index 99675b8..7e3c992 100644 --- a/ui/src/components/settings/SafetyForm.test.tsx +++ b/ui/src/components/settings/SafetyForm.test.tsx @@ -11,7 +11,7 @@ */ import '../../test/dom-setup'; import { describe, it, expect, vi } from 'vitest'; -import { screen } from '@testing-library/react'; +import { screen, fireEvent } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { renderWithProviders, renderStatefulForm } from '../../test/render-helpers'; import { SafetyForm } from './SafetyForm'; @@ -80,4 +80,21 @@ describe('SafetyForm', () => { await userEvent.selectOptions(screen.getByRole('combobox'), 'always'); expect(onChange).toHaveBeenCalledWith('safety.bashSandbox', 'always'); }); + + it('writes historySummarization.reserveCapTokens as a Number', async () => { + const { onChange, getConfig } = renderStateful({ safety: { historySummarization: { reserveCapTokens: 32000 } } }); + const numbers = screen.getAllByRole('spinbutton'); + // reserveCapTokens is the last number field (after tailTurns, preserveRecentBudget). + const field = numbers[numbers.length - 1]; + fireEvent.change(field, { target: { value: '48000' } }); + expect(getConfig().safety.historySummarization.reserveCapTokens).toBe(48000); + expect(onChange).toHaveBeenCalledWith('safety.historySummarization.reserveCapTokens', 48000); + }); + + it('serializes reserveCapTokens as undefined when cleared', async () => { + const { onChange } = render({ safety: { historySummarization: { reserveCapTokens: 32000 } } }); + const numbers = screen.getAllByRole('spinbutton'); + await userEvent.clear(numbers[numbers.length - 1]); + expect(onChange).toHaveBeenLastCalledWith('safety.historySummarization.reserveCapTokens', undefined); + }); }); diff --git a/ui/src/components/settings/SafetyForm.tsx b/ui/src/components/settings/SafetyForm.tsx index f11f982..0143032 100644 --- a/ui/src/components/settings/SafetyForm.tsx +++ b/ui/src/components/settings/SafetyForm.tsx @@ -129,6 +129,13 @@ export function SafetyForm({ config, onChange }: SectionFormProps) { onChange={v => onChange('safety.historySummarization.preserveRecentBudget', Number(v))} /> {t('safety.preserveRecentHelp')} + +
+ Reserve Cap (tokens) + onChange('safety.historySummarization.reserveCapTokens', v === '' ? undefined : Number(v))} /> + {t('safety.reserveCapHelp')} +
); } diff --git a/ui/src/components/settings/ServerTlsForm.test.tsx b/ui/src/components/settings/ServerTlsForm.test.tsx index a61280a..109e16a 100644 --- a/ui/src/components/settings/ServerTlsForm.test.tsx +++ b/ui/src/components/settings/ServerTlsForm.test.tsx @@ -80,4 +80,11 @@ describe('ServerTlsForm', () => { await user.click(enableCheckbox); expect(onChange).toHaveBeenCalledWith('server.tls.enabled', true); }); + + it('changes the minimum TLS version select', async () => { + const user = userEvent.setup(); + const onChange = render({ server: { tls: {} } }); + await user.selectOptions(screen.getByRole('combobox'), 'TLSv1.3'); + expect(onChange).toHaveBeenCalledWith('server.tls.minVersion', 'TLSv1.3'); + }); }); diff --git a/ui/src/components/settings/ServerTlsForm.tsx b/ui/src/components/settings/ServerTlsForm.tsx index 464f2dc..55b2f52 100644 --- a/ui/src/components/settings/ServerTlsForm.tsx +++ b/ui/src/components/settings/ServerTlsForm.tsx @@ -11,7 +11,7 @@ import type { SectionFormProps } from './types'; * httpRedirectPort, redirectHost, selfSignedHosts. * * Fields intentionally omitted from the UI (left to config.yaml): - * minVersion, selfSignedDir. The onChange path mechanism in ConfigFormInner + * selfSignedDir. The onChange path mechanism in ConfigFormInner * uses setNestedValue which does a shallow-merge, so unedited fields are * preserved on save automatically. * @@ -111,6 +111,20 @@ export function ServerTlsForm({ config, onChange }: SectionFormProps) { {t('serverTls.hstsHelp')} + {/* Minimum TLS protocol version */} +
+ {t('serverTls.minVersion')} + + {t('serverTls.minVersionHelp')} +
+ {/* HTTP redirect port(s) — accepts a single port or a comma-separated list */}
{t('serverTls.httpRedirectPort')} diff --git a/ui/src/components/settings/SettingsSidebar.test.tsx b/ui/src/components/settings/SettingsSidebar.test.tsx new file mode 100644 index 0000000..bdd891b --- /dev/null +++ b/ui/src/components/settings/SettingsSidebar.test.tsx @@ -0,0 +1,49 @@ +// @vitest-environment jsdom +/** + * Component test for the Settings sidebar search box. + * i18n is not initialized, so t() returns the raw key; we assert on the literal + * English section labels and on the onSelectSection callback. + */ +import '../../test/dom-setup'; +import { describe, it, expect, vi } from 'vitest'; +import { screen, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithProviders } from '../../test/render-helpers'; +import { SettingsSidebar } from './SettingsSidebar'; + +describe('SettingsSidebar search', () => { + it('filters to matching sections and jumps on click', async () => { + const onSelect = vi.fn(); + renderWithProviders(); + + // Before searching, the grouped nav shows the Safety nav button. + expect(screen.getByTestId('settings-nav-safety')).toBeInTheDocument(); + + const box = screen.getByTestId('settings-search'); + await userEvent.type(box, 'deadline'); + + // Results list appears; Safety is a hit (deadline is one of its keywords). + const hit = screen.getByTestId('settings-search-result-safety'); + expect(hit).toBeInTheDocument(); + // Unrelated section is filtered out. + expect(screen.queryByTestId('settings-search-result-branding')).not.toBeInTheDocument(); + + await userEvent.click(hit); + expect(onSelect).toHaveBeenCalledWith('safety'); + }); + + it('shows a no-results message for a non-matching query', async () => { + const onSelect = vi.fn(); + renderWithProviders(); + fireEvent.change(screen.getByTestId('settings-search'), { target: { value: 'zzzznotathing' } }); + expect(screen.getByTestId('settings-search-results').textContent).toContain('search.noResults'); + }); + + it('does not expose admin-only sections to a non-admin search', async () => { + const onSelect = vi.fn(); + renderWithProviders(); + fireEvent.change(screen.getByTestId('settings-search'), { target: { value: 'safety' } }); + // Safety is under an adminOnly group → not searchable for a non-admin. + expect(screen.queryByTestId('settings-search-result-safety')).not.toBeInTheDocument(); + }); +}); diff --git a/ui/src/components/settings/SettingsSidebar.tsx b/ui/src/components/settings/SettingsSidebar.tsx index cd13a3a..6c3fb8c 100644 --- a/ui/src/components/settings/SettingsSidebar.tsx +++ b/ui/src/components/settings/SettingsSidebar.tsx @@ -1,4 +1,6 @@ +import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { buildSettingsSearchIndex, searchSettings } from './settingsSearchIndex'; interface SettingsSidebarProps { activeSection?: string; @@ -66,6 +68,7 @@ export const CONFIG_GROUPS = [ { id: 'context', label: 'Context' }, { id: 'safety', label: 'Safety' }, { id: 'reflection', label: 'Reflection' }, + { id: 'skills-quota', label: 'Skill Quotas' }, ], }, { @@ -134,11 +137,62 @@ export const USER_SECTIONS: string[] = CONFIG_GROUPS export function SettingsSidebar({ activeSection, onSelectSection, isAdmin }: SettingsSidebarProps) { const { t } = useTranslation('settings'); + const [query, setQuery] = useState(''); const visibleGroups = CONFIG_GROUPS.filter(g => isAdmin || !('adminOnly' in g) || !g.adminOnly); + // Only sections the current user can actually open are searchable. + const visibleIds = useMemo( + () => new Set(visibleGroups.flatMap(g => g.sections.map(s => s.id))), + [visibleGroups], + ); + const index = useMemo(() => buildSettingsSearchIndex().filter(e => visibleIds.has(e.sectionId)), [visibleIds]); + const results = useMemo(() => searchSettings(query, index), [query, index]); + const searching = query.trim().length > 0; + const labelFor = (id: string, fallback: string) => { + for (const g of visibleGroups) { + const s = g.sections.find(x => x.id === id); + if (s) return 'labelKey' in s && s.labelKey ? t(s.labelKey) : s.label; + } + return fallback; + }; + return (
- {visibleGroups.map(group => ( + setQuery(e.target.value)} + placeholder={t('search.placeholder')} + aria-label={t('search.placeholder')} + data-testid="settings-search" + className="mb-3 w-full rounded-md border border-hairline bg-surface px-2 py-1.5 text-xs focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent-ring" + /> + + {searching ? ( +
+ {results.length === 0 ? ( +
{t('search.noResults')}
+ ) : ( + results.map(r => { + const active = activeSection === r.sectionId; + return ( + + ); + }) + )} +
+ ) : ( + visibleGroups.map(group => (
{group.label} @@ -154,7 +208,7 @@ export function SettingsSidebar({ activeSection, onSelectSection, isAdmin }: Set ))}
- ))} + )))}
); } diff --git a/ui/src/components/settings/SkillsQuotaForm.test.tsx b/ui/src/components/settings/SkillsQuotaForm.test.tsx new file mode 100644 index 0000000..5533af1 --- /dev/null +++ b/ui/src/components/settings/SkillsQuotaForm.test.tsx @@ -0,0 +1,45 @@ +// @vitest-environment jsdom +/** + * Component tests for SkillsQuotaForm (settings — skill store quotas). + * + * i18n is NOT initialized in the test env, so t() returns the raw key; we + * assert on roles/values, not labels. + */ +import '../../test/dom-setup'; +import { describe, it, expect, vi } from 'vitest'; +import { screen, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithProviders, renderStatefulForm } from '../../test/render-helpers'; +import { SkillsQuotaForm } from './SkillsQuotaForm'; + +describe('SkillsQuotaForm', () => { + it('renders all five quota fields with defaults', () => { + const onChange = vi.fn(); + renderWithProviders(); + const numbers = screen.getAllByRole('spinbutton') as HTMLInputElement[]; + expect(numbers).toHaveLength(5); + // Defaults: 50, 64, 5, 100, 2000 + expect(numbers[0]).toHaveValue(50); + expect(numbers[1]).toHaveValue(64); + expect(numbers[2]).toHaveValue(5); + expect(numbers[3]).toHaveValue(100); + expect(numbers[4]).toHaveValue(2000); + }); + + it('writes skills.maxPerUser as a Number', async () => { + const onChange = vi.fn(); + const { getConfig } = renderStatefulForm(SkillsQuotaForm, { skills: { maxPerUser: 50 } }, { onChangeSpy: onChange }); + const numbers = screen.getAllByRole('spinbutton'); + fireEvent.change(numbers[0], { target: { value: '12' } }); + expect(getConfig().skills.maxPerUser).toBe(12); + expect(onChange).toHaveBeenCalledWith('skills.maxPerUser', 12); + }); + + it('serializes a cleared quota as undefined', async () => { + const onChange = vi.fn(); + renderWithProviders(); + const numbers = screen.getAllByRole('spinbutton'); + await userEvent.clear(numbers[4]); + expect(onChange).toHaveBeenLastCalledWith('skills.maxIndexChars', undefined); + }); +}); diff --git a/ui/src/components/settings/SkillsQuotaForm.tsx b/ui/src/components/settings/SkillsQuotaForm.tsx new file mode 100644 index 0000000..edcd986 --- /dev/null +++ b/ui/src/components/settings/SkillsQuotaForm.tsx @@ -0,0 +1,59 @@ +import { useTranslation } from 'react-i18next'; +import { HelpText } from './HelpText'; +import { FieldLabel, FieldInput } from './formUtils'; +import type { SectionFormProps } from './types'; + +/** + * Skill Quotas — per-user skill store limits (`skills.*`). + * + * These caps bound how many skills a user can install and how large the + * store may grow. They exist in the config schema (SkillsConfig) but were + * previously only editable via config.yaml. (Distinct from the SkillsForm + * panel under User Folder, which is the skill *store* browser/editor.) + */ +export function SkillsQuotaForm({ config, onChange }: SectionFormProps) { + const { t } = useTranslation('settings'); + const skills = config.skills ?? {}; + + return ( +
+

{t('skillsQuota.title')}

+ {t('skillsQuota.intro')} + +
+ Max Per User + onChange('skills.maxPerUser', v === '' ? undefined : Number(v))} /> + {t('skillsQuota.maxPerUserHelp')} +
+ +
+ Max Skill Size (KB) + onChange('skills.maxSkillSizeKb', v === '' ? undefined : Number(v))} /> + {t('skillsQuota.maxSkillSizeHelp')} +
+ +
+ Max Total Size (MB) + onChange('skills.maxTotalSizeMb', v === '' ? undefined : Number(v))} /> + {t('skillsQuota.maxTotalSizeHelp')} +
+ +
+ Max System Skills + onChange('skills.maxSystemSkills', v === '' ? undefined : Number(v))} /> + {t('skillsQuota.maxSystemSkillsHelp')} +
+ +
+ Max Index Chars + onChange('skills.maxIndexChars', v === '' ? undefined : Number(v))} /> + {t('skillsQuota.maxIndexCharsHelp')} +
+
+ ); +} diff --git a/ui/src/components/settings/ToolsMediaForm.test.tsx b/ui/src/components/settings/ToolsMediaForm.test.tsx new file mode 100644 index 0000000..0e174f9 --- /dev/null +++ b/ui/src/components/settings/ToolsMediaForm.test.tsx @@ -0,0 +1,31 @@ +// @vitest-environment jsdom +/** + * Component tests for ToolsMediaForm — focus on the newly surfaced + * tools.officeMsgMaxSizeMb field. + */ +import '../../test/dom-setup'; +import { describe, it, expect, vi } from 'vitest'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithProviders, renderStatefulForm } from '../../test/render-helpers'; +import { ToolsMediaForm } from './ToolsMediaForm'; + +describe('ToolsMediaForm', () => { + it('shows officeMsgMaxSizeMb default (25)', () => { + const onChange = vi.fn(); + renderWithProviders(); + const numbers = screen.getAllByRole('spinbutton') as HTMLInputElement[]; + // The .msg field sits just before the Uploads field; assert the value is present. + expect(numbers.some(n => n.value === '25')).toBe(true); + }); + + it('writes tools.officeMsgMaxSizeMb as a Number', async () => { + const onChange = vi.fn(); + renderStatefulForm(ToolsMediaForm, { tools: { officeMsgMaxSizeMb: 25 } }, { onChangeSpy: onChange }); + const numbers = screen.getAllByRole('spinbutton') as HTMLInputElement[]; + const msgField = numbers.find(n => n.value === '25')!; + await userEvent.clear(msgField); + await userEvent.type(msgField, '40'); + expect(onChange).toHaveBeenCalledWith('tools.officeMsgMaxSizeMb', expect.any(Number)); + }); +}); diff --git a/ui/src/components/settings/ToolsMediaForm.tsx b/ui/src/components/settings/ToolsMediaForm.tsx index b378665..ae8a2d6 100644 --- a/ui/src/components/settings/ToolsMediaForm.tsx +++ b/ui/src/components/settings/ToolsMediaForm.tsx @@ -116,6 +116,12 @@ export function ToolsMediaForm({ config, onChange }: SectionFormProps) { onChange={v => onChange('tools.officePptxMaxUncompressedMb', Number(v))} /> {t('tools.office.pptxUncompressedHelp')}
+
+ {t('tools.office.msgLabel')} + onChange('tools.officeMsgMaxSizeMb', Number(v))} /> + {t('tools.office.msgHelp')} +
diff --git a/ui/src/components/settings/settingsSearchIndex.test.ts b/ui/src/components/settings/settingsSearchIndex.test.ts new file mode 100644 index 0000000..5461beb --- /dev/null +++ b/ui/src/components/settings/settingsSearchIndex.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'vitest'; +import { CONFIG_GROUPS } from './SettingsSidebar'; +import { buildSettingsSearchIndex, searchSettings } from './settingsSearchIndex'; + +describe('searchSettings', () => { + it('finds a section by a config-key keyword buried in a form', () => { + expect(searchSettings('reserve_cap').map(r => r.sectionId)).toContain('safety'); + expect(searchSettings('deadline').map(r => r.sectionId)).toContain('safety'); + expect(searchSettings('max_stream_minutes').map(r => r.sectionId)).toContain('llm-workers'); + }); + + it('finds a section by concept in English or Japanese', () => { + expect(searchSettings('python').map(r => r.sectionId)).toContain('execution'); + expect(searchSettings('tls').map(r => r.sectionId)).toContain('server-tls'); + expect(searchSettings('証明書').map(r => r.sectionId)).toContain('server-tls'); + expect(searchSettings('デッドライン').map(r => r.sectionId)).toContain('safety'); + }); + + it('is AND across whitespace-separated terms', () => { + const r = searchSettings('browser timeout'); + expect(r.map(x => x.sectionId)).toContain('tools-browser'); + // "browser timeout" should not match, say, safety (has neither pair). + expect(r.every(x => `${x.label} ${x.keywords}`.toLowerCase().includes('browser'))).toBe(true); + }); + + it('is case-insensitive and returns nothing for an empty query', () => { + expect(searchSettings('SAFETY').map(r => r.sectionId)).toContain('safety'); + expect(searchSettings(' ')).toEqual([]); + expect(searchSettings('')).toEqual([]); + }); + + it('can be scoped to a caller-supplied (e.g. non-admin) index subset', () => { + const onlyPrefs = buildSettingsSearchIndex().filter(e => e.sectionId === 'preferences'); + // "safety" is admin-only → absent from a preferences-only index. + expect(searchSettings('safety', onlyPrefs)).toEqual([]); + expect(searchSettings('preferences', onlyPrefs).map(r => r.sectionId)).toEqual(['preferences']); + }); +}); + +describe('index integrity (drift guard)', () => { + const allIds = CONFIG_GROUPS.flatMap(g => g.sections.map(s => s.id)); + + it('covers every sidebar section (no section left unsearchable)', () => { + const index = buildSettingsSearchIndex(); + const indexedIds = new Set(index.map(e => e.sectionId)); + for (const id of allIds) expect(indexedIds.has(id)).toBe(true); + }); + + it('every indexed section has non-empty keywords', () => { + for (const e of buildSettingsSearchIndex()) { + expect(e.keywords.trim().length, `section ${e.sectionId} has no keywords`).toBeGreaterThan(0); + } + }); +}); diff --git a/ui/src/components/settings/settingsSearchIndex.ts b/ui/src/components/settings/settingsSearchIndex.ts new file mode 100644 index 0000000..dece9de --- /dev/null +++ b/ui/src/components/settings/settingsSearchIndex.ts @@ -0,0 +1,99 @@ +/** + * settingsSearchIndex.ts — a lightweight static manifest for searching the + * admin Settings screen. + * + * The Settings sidebar has ~25 sections spread across 8 groups; the specific + * knob you want (e.g. "deadline", "reserve_cap_tokens", "min TLS version") is + * often buried in a form whose section name does not mention it. This manifest + * maps hand-authored keywords → sectionId so a search box can jump straight to + * the right section. + * + * Deliberately NOT auto-generated from the forms: keeping it a small explicit + * list is lower-risk (no form refactor) and lets us add synonyms / Japanese + * terms. A test asserts every entry points at a real section id and every + * admin section is covered, so the manifest cannot silently drift. + */ + +import { CONFIG_GROUPS } from './SettingsSidebar'; + +export interface SettingsSearchEntry { + sectionId: string; + /** English display label (from the sidebar). */ + label: string; + /** Group the section lives under (e.g. "Agent Runtime"). */ + group: string; + /** Free-text keywords: config keys, concepts, and Japanese synonyms. */ + keywords: string; +} + +/** sectionId → extra searchable keywords (config keys, concepts, JP terms). */ +const KEYWORDS: Record = { + // Preference + preferences: 'preferences default visibility 公開範囲 個人設定 デフォルト', + notifications: 'notifications browser web push 通知 ブラウザ通知 購読', + pets: 'pets mascot chat backend worker ペット マスコット', + 'memory-learning': 'reflection history memory learning revert 学習 メモリ 履歴 リフレクション', + 'a2a-delegations': 'a2a delegation token revoke 委任 取り消し 外部エージェント', + // System + branding: 'branding app name logo favicon accent color テーマ ロゴ アプリ名 色', + 'paths-storage': 'storage paths worktree upload dir data directory 保存先 ディレクトリ アップロード上限 worktree_dir', + execution: 'execution concurrency max_movements retry backoff python packages 並列度 リトライ 実行 サンドボックス python', + auth: 'authentication login google gitea oauth local password provider 認証 ログイン', + organizations: 'organizations org members visibility 組織 メンバー', + 'push-notifications': 'web push vapid server 配信 サーバー鍵', + // LLM + 'llm-workers': 'llm workers model endpoint api_key concurrency timeout max_stream_minutes healthcheck ワーカー モデル 接続 タイムアウト', + 'gateway-server': 'gateway openai compatible virtual keys internal_teams listen port ゲートウェイ 仮想キー', + 'llm-metrics': 'metrics prometheus exporter gateway 計測 メトリクス', + // Agent Runtime + 'ask-subtasks': 'ask limit subtasks spawn_stagger_ms max_per_parent サブタスク 質問上限 間引き', + context: 'context threshold warn prompt force_transition token limit コンテキスト 閾値 使用率', + safety: 'safety max_iterations max_revisits deadline max_job_minutes grace bash sandbox network reserve_cap_tokens history summarization 自爆防止 デッドライン 反復 サンドボックス ネットワーク', + reflection: 'reflection learning cooldown budget auto apply 自動学習 クールダウン', + 'skills-quota': 'skills quota max per user size limit maxPerUser maxSkillSizeKb maxTotalSizeMb maxSystemSkills maxIndexChars スキル クォータ 上限 個数 サイズ', + // Tools + 'tools-web': 'web search websearch webfetch search_filter 検索 ウェブ', + 'tools-browser': 'browser playwright browseweb timeout channel ブラウザ タイムアウト', + 'tools-media': 'media vision ocr audio office excel docx pdf pptx msg size 画像 音声 文書 ファイル上限', + 'tools-external': 'external api keys x twitter maps amazon youtube 外部サービス キー', + 'search-filter': 'search filter domain allow deny websearch ドメイン 許可 除外', + // MCP & Connections + mcp: 'mcp model context protocol runtime quota サーバー クォータ', + // SSH + ssh: 'ssh remote connection grant audit master key 接続 監査 鍵ローテーション', + // Network + 'server-tls': 'https tls certificate self signed hsts redirect min_version 証明書 リダイレクト 暗号化', +}; + +/** Build the flat search index from the sidebar's canonical section list. */ +export function buildSettingsSearchIndex(): SettingsSearchEntry[] { + const entries: SettingsSearchEntry[] = []; + for (const group of CONFIG_GROUPS) { + for (const s of group.sections) { + entries.push({ + sectionId: s.id, + label: s.label, + group: group.label, + keywords: KEYWORDS[s.id] ?? '', + }); + } + } + return entries; +} + +/** + * Case-insensitive AND search: every whitespace-separated term must appear in + * the section's label, group, id, or keywords. Empty query → no results (the + * caller shows the normal grouped nav instead). + */ +export function searchSettings( + query: string, + index: SettingsSearchEntry[] = buildSettingsSearchIndex(), +): SettingsSearchEntry[] { + const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean); + if (terms.length === 0) return []; + return index.filter((e) => { + const haystack = `${e.label} ${e.group} ${e.sectionId} ${e.keywords}`.toLowerCase(); + return terms.every((t) => haystack.includes(t)); + }); +} diff --git a/ui/src/content/help/00-changelog.md b/ui/src/content/help/00-changelog.md index e1d3721..4b6ccf8 100644 --- a/ui/src/content/help/00-changelog.md +++ b/ui/src/content/help/00-changelog.md @@ -12,14 +12,26 @@ MAESTRO に入った、ユーザーに関係する主な変更を新しい順に > 機能に変更があるたび、このページを更新していきます。日付は変更が本番に入ったおおよその時期です。 +## 2026-07-09 — 設定画面に「スキルのクォータ」を追加し、これまで隠れていた設定を編集可能に + +これまで `config.yaml` を直接いじらないと変えられなかった項目を、設定画面から編集できるようにしました。Agent Runtime に新しく「スキルのクォータ」セクションが増え、1 ユーザーあたりのスキル数やサイズ上限を調整できます。あわせて、LLM の暴走を止める総時間の上限(Max Stream)、サブタスク発火の間引き遅延、履歴要約の headroom 上限、Outlook メール(.msg)の読み込みサイズ上限、TLS の最小バージョン、Gateway の内部チーム、ワーカーのヘルスチェック間隔も、それぞれの設定セクションから変更できます。さらに Execution 画面に「Python サンドボックスのパッケージ」案内を追加し、どのパッケージが使えるか・どう増やすかを確認できるようにしました(→[設定](./17-settings.md))。 + ## 2026-07-08 — 名前が食い違ったスキルを削除・編集できない問題を修正 スキルの表示名(SKILL.md の frontmatter `name:`)と保存フォルダ名が食い違っていると、一覧には表示されるのに削除・編集だけが「Skill not found」で失敗し続ける問題を修正しました。表示名から実体を探すようになったため、既存の食い違いスキルもそのまま削除・編集できます。あわせて、InstallSkill やスキル作成時に frontmatter の `name:` と指定名の一致を必須にして、新たな食い違いが生まれないようにしました。削除・保存に失敗したときのエラーはボタンのすぐ近くに表示されます。 +## 2026-07-08 — 設定画面にセクション検索を追加 + +システム設定(admin)の左サイドバー上部に検索ボックスを追加しました。探している設定のキーワードを入れると、該当セクションだけに絞り込めます。項目名だけでなく、`config.yaml` のキー名や概念でも引けます(例:「デッドライン」「reserve_cap」→ Safety、「python」→ Execution、「TLS」「証明書」→ HTTPS / TLS)。複数語のスペース区切りは AND 検索、クリックでそのセクションが開きます。表示されるのは自分が開ける権限のあるセクションだけです(→[システム設定](./17-settings.md))。 + ## 2026-07-08 — タスクが開始直後に「200回超過」で突然中断される問題を修正 会話がコンテキスト上限のごく近くまで膨らんだとき、まれにエージェントが「同じリクエストの送信 → 送信前ブロック」を1回あたり十数ミリ秒で延々と繰り返し、実際には何も作業しないまま数分で「max iterations (200) 超過」として中断されることがありました。空回りはツールを使わないため会話タブには何も表示されず、タスクが始まった直後に突然打ち切られたように見えていました。原因はコンテキストサイズの見積もりが送信前チェックとガードで 128 トークンだけずれていたことで、見積もりを統一し、さらに「何も削れないのに再送する」経路自体を塞ぎました。今後この状況では空回りせず、コンテキスト超過として即座に次のステップへの強制遷移(または中断)になります。あわせて、これまで無制限だった Grep の結果出力にも Read や Bash と同じ自動切り詰めを入れ、巨大な検索結果が一撃で会話を埋める事故を防ぎます。 +## 2026-07-08 — 「いま何を待っているか」が見えるようになりました(LLM 実行の可視化) + +エージェントが LLM の応答を待っている間、チャットが無言になる問題を解消しました。チャット下部のスピナーに「LLM の応答を待っています…」「思考中…(〇〇文字)」「LLM リトライ待ち 2/3(HTTP 500)」「コンテキストを整理中…」など、いま実際に起きていることが表示されます。あわせて、①中断メッセージの 1 行目に失敗の分類(context_overflow / gateway_timeout など)と発生ステップが明記されるようになり、②自動再試行中はヘッダーに「試行 2/3」バッジが出るようになり、③トレースタブの LLM 行に所要時間・リトライ回数・思考量・担当バックエンド・エラー分類が記録されるようになりました。llama.cpp(llama-server)利用時は、設定 → LLM Workers の「プロンプト処理進捗」を有効にすると長いプロンプト処理中に進捗バーも出ます。詳しくは[実行中のタスクを見る・介入する](03-running.md)と[トラブルシューティング](08-troubleshooting.md)へ。 + ## 2026-07-08 — 会話画面を縦に広く、入力欄をカード型に刷新 会話画面の縦方向の使える範囲を広げました。ヘッダー部分の行数を減らすため、公開範囲の表示(ワークスペース内のチャットは常にメンバーに公開されるという前提)は撤去し、継続・共有・削除ボタンはタブ行の右端にまとめています。入力欄はカード型のコンポーザーに刷新し、書いた内容に応じて高さが自動で伸びます(1〜8 行程度)。コンテキストの残量ゲージはツールバー右下に常時表示し、使用率が 70% を超えるとパーセント表示も出るようになりました。 @@ -118,7 +130,6 @@ A2A 連携を通じて接続する外部エージェントに、委任ごとの ## 2026-07-03 — Shift_JIS などの日本語テキストが「バイナリ」と誤判定されて読めない不具合を修正 Windows で保存した日本語の `.txt` や、Excel から出した Shift_JIS(CP932)の CSV を Read で開こうとすると、中身は読めるテキストなのに「バイナリなので開けません」と拒否されていました。文字コードの判定が UTF-8 しか想定しておらず、UTF-8 として解釈できないものをすべてバイナリと見なしていたためです。Shift_JIS・EUC-JP などを自動で判別し、UTF-8 に変換して読めるようにしました。Edit で書き換えた場合は元の文字コードのまま保存します。あわせて Grep が画像などのバイナリファイルを拾ってしまい、検索結果にバイナリの断片が紛れ込む問題も直しました(対象フォルダにバイナリがあっても自動でスキップします)(→[ツール](./16-tools.md))。 - ## 2026-07-03 — ワークスペースに登録した SSH 接続でコンソールが開けない不具合を修正 ワークスペースに登録した SSH 接続に対して**コンソール(対話シェル)を開こうとすると、接続が正しく登録されているのに `access denied (space_mismatch)` で弾かれる**不具合を修正しました。原因はコンソールを開く経路が、そのタスクの所属ワークスペース(スペース)を権限判定に渡していなかったことです。一発実行の `SshExec` は影響を受けていませんでしたが、コンソール経路(エージェントの `SshConsoleEnsure`、および「コンソール」タブから手動で開く操作・再接続)はすべて対象でした。個人ワークスペースのタスクでも正しく開けるようにしています。あわせて、公開・組織共有などで**タスクは見えてもそのワークスペースのメンバーではない人**が、コンソールからそのワークスペースの SSH 接続を使えてしまわないよう、実行時にメンバーであることを確認するようにしました(→[SSH 接続](./14-ssh.md))。 diff --git a/ui/src/content/help/03-running.md b/ui/src/content/help/03-running.md index 13b42b3..e402f25 100644 --- a/ui/src/content/help/03-running.md +++ b/ui/src/content/help/03-running.md @@ -20,6 +20,19 @@ keywords: [チャット, ストリーミング, ツールコール, 割り込み 実行中はヘッダーに `running`(サブタスク待ちのときは `subtasks`)のバッジが点滅します。 +## 「いま何を待っているか」のライブ表示 + +画面に何も流れていない時間も、エージェントが止まっているとは限りません。LLM の応答待ちの間、チャット下部のスピナーに現在の状態が具体的に表示されます。 + +- **LLM の応答を待っています…**: リクエストを送信して最初のトークンを待っている状態 +- **思考中…(〇〇文字)**: thinking 対応モデルが内部推論を出力中。文字数が増えていれば正常に動いています +- **LLM リトライ待ち 2/3(HTTP 500)**: バックエンドが一時エラーを返し、自動リトライの待機中 +- **コンテキストを整理中…**: 会話履歴が上限に近づき、圧縮や要約を実行中 + +同じ内容はタスク一覧の「現在の作業」欄にも反映されます。中断後に自動再試行が走っている場合は、ヘッダーに **試行 2/3** のバッジが出ます(ホバーで前回の中断理由を表示)。 + +また llama.cpp(llama-server)のバックエンドでは、設定 → LLM Workers の「プロンプト処理進捗」を有効にすると、長いプロンプトの処理中に進捗バー(`Processing 〇〇%`)が出るようになります。 + ## 進捗タブで振り返る 詳細パネル上部のタブ(概要 / 進捗 / ファイル / トレース、ブラウザ・SSH は接続中のみ)のうち、**進捗** タブに時系列のビューがあります。各ステップで何のツールをどんな引数で呼び、何を生成したかを上から下へ追え、その中の **タイムライン** で movement ごとにまとまった流れも確認できます(別タブではなく、進捗タブ内のセクションです)。 diff --git a/ui/src/content/help/08-troubleshooting.md b/ui/src/content/help/08-troubleshooting.md index 2253902..c8d1afa 100644 --- a/ui/src/content/help/08-troubleshooting.md +++ b/ui/src/content/help/08-troubleshooting.md @@ -26,7 +26,7 @@ keywords: [トラブル, エラー, 失敗, waiting, スタック, 再試行] ## タスクが失敗した(failed)/ 再試行について -タスクは失敗しても、設定された最大試行回数まで自動で再キューされて再実行されます。再実行時には前回の失敗内容が引き継ぎコンテキストとして渡され、同じ作業の重複を避けようとします。 +タスクは失敗しても、設定された最大試行回数まで自動で再キューされて再実行されます。再実行時には前回の失敗内容が引き継ぎコンテキストとして渡され、同じ作業の重複を避けようとします。再試行中はチャットのヘッダーに **試行 2/3** のバッジが表示され、ホバーすると前回の中断理由が確認できます。 **対処**: @@ -34,6 +34,17 @@ keywords: [トラブル, エラー, 失敗, waiting, スタック, 再試行] - 入力ファイルや依頼文に問題があれば修正して新しいタスクを作る - 最大試行回数を使い切っても失敗し続ける場合は、依頼を分割するか、より能力の高いモデルでの実行を管理者に相談する +## 「⚠️ 中断」メッセージの読み方 + +LLM 起因で中断したタスクの中断メッセージには、1 行目に失敗の分類が入ります。 + +- **分類: context_overflow** — 会話がモデルのコンテキスト上限を超えた。依頼を分割するか、大きなファイルの全文読み込みを避ける指示を足す +- **分類: idle_timeout / hard_cap** — LLM バックエンドが規定時間内に応答を返さなかった(idle_timeout)か、1 回の応答が長すぎて上限で打ち切られた(hard_cap)。バックエンドの負荷や暴走生成のサイン +- **分類: gateway_timeout / gateway_shutdown** — LLM ゲートウェイ(配車側)がバックエンドとの通信を打ち切った。特定バックエンドの不調が疑われる +- **分類: http (HTTP 5xx) / connection** — バックエンドがエラーを返した、または接続できなかった + +どの LLM 呼び出しで何が起きたかの詳細は、詳細パネルの **トレース** タブで確認できます。各呼び出しの所要時間・トークン数・リトライ回数・担当バックエンド・エラー分類が 1 行ずつ記録されています。 + ## エージェントが「ログインを要求された」と返す タスク中のブラウザ操作(BrowseWeb)で、保存済みのログイン情報が期限切れになっているとこのメッセージが返ります。 diff --git a/ui/src/content/help/17-settings.md b/ui/src/content/help/17-settings.md index e124548..4d3fa4b 100644 --- a/ui/src/content/help/17-settings.md +++ b/ui/src/content/help/17-settings.md @@ -18,6 +18,10 @@ YAML キーは **スネークケース** (`max_concurrency`)、コード内は * - **右フォーム** — 選択したセクションの設定項目。ほとんどはその場で編集するインラインフォーム - `User` グループ以外は **admin 専用**。一般ユーザーには表示されない (`adminOnly`) +## 設定を検索する + +サイドバー上部の検索ボックスに、探している設定のキーワードを入れると、該当するセクションだけが一覧に絞り込まれます。項目名だけでなく、その設定の中身(`config.yaml` のキー名や概念)でも引けます。たとえば「デッドライン」「reserve_cap」で **Safety**、「python」で **Execution**、「TLS」「証明書」で **HTTPS / TLS** に飛べます。複数の語をスペースで区切ると、そのすべてを含むセクションに絞り込みます(AND 検索)。結果をクリックするとそのセクションが開きます。表示されるのは、あなたが開ける権限のあるセクションだけです。 + ## セクション一覧 サイドバーのグループとセクションは以下の通りです。 @@ -41,7 +45,7 @@ YAML キーは **スネークケース** (`max_concurrency`)、コード内は * |-----------|------| | Branding | アプリ名・ロゴ・アクセント色などの見た目 | | Paths & Storage | `storage.*` の作業ディレクトリ・ワークスペース保存先・アップロード上限 | -| Execution | `concurrency` (全 worker 合計の並列度)・`max_movements`・`retry.*` | +| Execution | `concurrency` (全 worker 合計の並列度)・`max_movements`・`retry.*`。末尾に **Python サンドボックスで使えるパッケージ**の案内(読み取り専用)あり | | Authentication | ログイン方式(Google / Gitea OAuth・ローカル認証)の有効化と設定 | | 🏢 Organizations | ローカル組織の作成・メンバー管理(`org` 公開範囲の単位) | | Web Push (Server) | Web Push 配信のサーバー側設定(VAPID キーなど) | @@ -60,10 +64,11 @@ Gateway の運用は [LLM Gateway 連携](#llm-gateway) を参照。 | セクション | 内容 | |-----------|------| -| Ask / Subtasks | ASK 上限・サブタスクの制御 | +| Ask / Subtasks | ASK 上限・サブタスクの制御(サブタスク発火の間引き `spawn_stagger_ms` を含む) | | Context | コンテキスト使用率の警告閾値 (warn / prompt / force_transition) | -| Safety | `max_iterations`・`max_revisits`・**実行デッドライン(Max Job Minutes / Deadline Grace)**・history 要約などの自爆防止 | +| Safety | `max_iterations`・`max_revisits`・**実行デッドライン(Max Job Minutes / Deadline Grace)**・history 要約(headroom 上限 `reserve_cap_tokens` を含む)などの自爆防止 | | Reflection | タスク完了後の自動学習。詳細は [Reflection の調整](#reflection) | +| スキルのクォータ | 1 ユーザーあたりのスキル数・スキルサイズ・合計サイズなどの上限(`skills.*`) | ### Tools グループ (admin) diff --git a/ui/src/hooks/useJobStream.test.ts b/ui/src/hooks/useJobStream.test.ts index af97093..d758323 100644 --- a/ui/src/hooks/useJobStream.test.ts +++ b/ui/src/hooks/useJobStream.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { reduceDelegateStreams } from './useJobStream'; +import { reduceDelegateStreams, reduceLlmState } from './useJobStream'; describe('reduceDelegateStreams – originJobId contract', () => { it('lifecycle の originJobId を run に紐づける', () => { @@ -65,3 +65,34 @@ describe('reduceDelegateStreams – originJobId contract', () => { expect(s['r3'].originJobId).toBe('sub2'); }); }); + +// 観測性 Phase 1: SSE llm_state → LlmStateEntry 変換。 +describe('reduceLlmState', () => { + it('maps a waiting event with movement/iteration metadata', () => { + const entry = reduceLlmState({ type: 'llm_state', phase: 'waiting', movement: 'dig' }, 1000); + expect(entry).toMatchObject({ phase: 'waiting', movement: 'dig', receivedAt: 1000 }); + }); + + it('maps retrying with attempt info', () => { + const entry = reduceLlmState( + { type: 'llm_state', phase: 'retrying', attempt: 2, maxAttempts: 3, reason: 'HTTP 500', errorClass: 'http' }, + 2000, + ); + expect(entry).toMatchObject({ phase: 'retrying', attempt: 2, maxAttempts: 3, reason: 'HTTP 500' }); + }); + + it('maps thinking with cumulative chars', () => { + const entry = reduceLlmState({ type: 'llm_state', phase: 'thinking', chars: 4200 }, 0); + expect(entry).toMatchObject({ phase: 'thinking', chars: 4200 }); + }); + + it('maps recovering with the stage name', () => { + const entry = reduceLlmState({ type: 'llm_state', phase: 'recovering', stage: 'force_transition_summary' }, 0); + expect(entry).toMatchObject({ phase: 'recovering', stage: 'force_transition_summary' }); + }); + + it('returns null for unknown phases and non-llm_state events', () => { + expect(reduceLlmState({ type: 'llm_state', phase: 'bogus' }, 0)).toBeNull(); + expect(reduceLlmState({ type: 'text_delta' }, 0)).toBeNull(); + }); +}); diff --git a/ui/src/hooks/useJobStream.ts b/ui/src/hooks/useJobStream.ts index b7696fd..a90a570 100644 --- a/ui/src/hooks/useJobStream.ts +++ b/ui/src/hooks/useJobStream.ts @@ -25,12 +25,31 @@ export interface DelegateStreamEntry { originJobId?: string | null; } +/** + * LLM 呼び出しのライブ状態(SSE llm_state イベント由来・揮発)。 + * 「20分無応答」の間に実際は何をしているか(応答待ち/思考中/リトライ/ + * コンテキスト復旧)をチャットのステータス行に出すためのもの。 + */ +export interface LlmStateEntry { + phase: 'waiting' | 'thinking' | 'retrying' | 'recovering'; + movement?: string; + attempt?: number; + maxAttempts?: number; + reason?: string; + errorClass?: string; + chars?: number; + stage?: string; + /** クライアント側で受信した時刻 (ms)。経過時間表示用。 */ + receivedAt: number; +} + export interface JobStreamState { promptProgress: PromptProgressState | null; streamingText: string; toolCallStream: Record; connected: boolean; delegateStreams: Record; + llmState: LlmStateEntry | null; } function emptyEntry(delegateRunId: string): DelegateStreamEntry { @@ -40,6 +59,29 @@ function emptyEntry(delegateRunId: string): DelegateStreamEntry { }; } +/** SSE llm_state イベントを LlmStateEntry へ変換する純関数。不正 phase は null。 */ +export function reduceLlmState( + data: { type: string; phase?: string; movement?: string; attempt?: number; + maxAttempts?: number; reason?: string; errorClass?: string; chars?: number; stage?: string }, + now: number, +): LlmStateEntry | null { + if (data.type !== 'llm_state') return null; + if (data.phase !== 'waiting' && data.phase !== 'thinking' && data.phase !== 'retrying' && data.phase !== 'recovering') { + return null; + } + return { + phase: data.phase, + movement: data.movement, + attempt: data.attempt, + maxAttempts: data.maxAttempts, + reason: data.reason, + errorClass: data.errorClass, + chars: data.chars, + stage: data.stage, + receivedAt: now, + }; +} + /** SSE 1 イベントを delegateStreams マップへ畳み込む純関数。 */ export function reduceDelegateStreams( prev: Record, @@ -75,6 +117,7 @@ export function useJobStream(taskId: number | null, jobStatus: string | null | u const [toolCallStream, setToolCallStream] = useState>({}); const [connected, setConnected] = useState(false); const [delegateStreams, setDelegateStreams] = useState>({}); + const [llmState, setLlmState] = useState(null); const esRef = useRef(null); const isActive = jobStatus === 'running' || jobStatus === 'dispatching'; @@ -94,6 +137,7 @@ export function useJobStream(taskId: number | null, jobStatus: string | null | u setStreamingText(''); setToolCallStream({}); setDelegateStreams({}); + setLlmState(null); return; } @@ -115,12 +159,21 @@ export function useJobStream(taskId: number | null, jobStatus: string | null | u timeMs: data.timeMs ?? 0, }); break; + case 'llm_state': + // 直前の prompt_progress を残すと、ChatPane の表示優先順位 + // (promptProgress > llmState) で新しいライブ状態が古い進捗バーに + // 隠れる (Codex P2)。状態遷移が届いた時点で進捗は陳腐化している。 + setPromptProgress(null); + setLlmState(reduceLlmState(data, Date.now())); + break; case 'text_delta': setPromptProgress(null); + setLlmState(null); setStreamingText(prev => prev + (data.text ?? '')); break; case 'tool_use_delta': setPromptProgress(null); + setLlmState(null); setToolCallStream(prev => { const callId = data.callId ?? ''; const existing = prev[callId]; @@ -138,6 +191,7 @@ export function useJobStream(taskId: number | null, jobStatus: string | null | u case 'tool_use': setStreamingText(''); setPromptProgress(null); + setLlmState(null); setToolCallStream(prev => { if (!data.callId || !(data.callId in prev)) return prev; const next = { ...prev }; @@ -155,6 +209,7 @@ export function useJobStream(taskId: number | null, jobStatus: string | null | u setPromptProgress(null); setToolCallStream({}); setDelegateStreams({}); + setLlmState(null); cleanup(); break; } @@ -169,5 +224,5 @@ export function useJobStream(taskId: number | null, jobStatus: string | null | u return cleanup; }, [taskId, isActive, cleanup]); - return { promptProgress, streamingText, toolCallStream, connected, delegateStreams }; + return { promptProgress, streamingText, toolCallStream, connected, delegateStreams, llmState }; } diff --git a/ui/src/i18n/locales/en/chat.json b/ui/src/i18n/locales/en/chat.json index 6e70402..6973487 100644 --- a/ui/src/i18n/locales/en/chat.json +++ b/ui/src/i18n/locales/en/chat.json @@ -22,6 +22,12 @@ "generating": "{{name}} generating…", "processing": "Processing", "agentResponding": "Agent is generating a response...", + "llmWaiting": "Waiting for the LLM to respond…", + "llmThinking": "Thinking… ({{chars}} chars)", + "llmRetrying": "LLM retry {{attempt}}/{{max}} ({{reason}})", + "llmRecovering": "Compacting context… ({{stage}})", + "attemptBadge": "Attempt {{attempt}}/{{max}}", + "attemptBadgeTitle": "Previous abort reason: {{reason}}", "sendFailed": "Failed to send", "interjectHint": "Agent running — send a message to give it instructions", "agentRunningWait": "The agent is running your task. Please wait a moment.", diff --git a/ui/src/i18n/locales/en/settings.json b/ui/src/i18n/locales/en/settings.json index 0e8981e..8ea40e6 100644 --- a/ui/src/i18n/locales/en/settings.json +++ b/ui/src/i18n/locales/en/settings.json @@ -61,7 +61,9 @@ "pptxLabel": "ReadPPTX max size", "pptxHelp": "Maximum .pptx file size accepted by ReadPPTX (default: 50 MB)", "pptxUncompressedLabel": "ReadPPTX uncompressed size limit", - "pptxUncompressedHelp": "Total size limit after unzipping a PPTX (for ZIP-bomb detection, default: 200 MB)" + "pptxUncompressedHelp": "Total size limit after unzipping a PPTX (for ZIP-bomb detection, default: 200 MB)", + "msgLabel": "ReadMsg max size", + "msgHelp": "Maximum .msg (Outlook email) file size accepted by ReadMsg (default: 25 MB)" }, "uploads": { "sectionHelp": "Request body limit for the upload API from the UI (MB)", @@ -254,6 +256,8 @@ "enable": "Enable Gateway", "listenPortHelp": "Not used in same-process mode: it shares the same port as the worker UI ({{port}}). Only effective when started as a separate process with AAO_MODE=gateway.", "separateDeploy": "Separate-process deploy:", + "internalTeamsLabel": "Internal teams", + "internalTeamsHelp": "Comma-separated team names treated as internal: their traffic is exempt from usage metering/billing. This server-side list is authoritative (never a client-supplied header).", "backendsHelp1": "Routing targets such as llama-server / Ollama / vLLM. The Gateway routes to the least-busy backend among those serving the role the worker sends (a backend with no roles set serves all roles). It falls back to an exact request.model = id/model match only when no backend serves the role.", "backendsHelp2": "api_key storage format: the value entered in the form is saved to config.yaml in plain text. A ${VAR}-style env-var reference is saved as a literal string on form save, so edit config.yaml directly if you want to pass it via env.", "backendsEmpty": "No backends registered. Add at least one.", @@ -524,11 +528,16 @@ "apiKeyDirectHelp": "Set only when Bearer auth is required. Leave empty for standalone Ollama.", "modelHelp": "If the endpoint serves /models, candidates appear in the dropdown. If not (auth required, behind a proxy, etc.), type it in directly.", "maxConcurrency": "Max concurrency", + "healthcheckInterval": "Healthcheck interval (sec)", + "healthcheckIntervalHelp": "How often to health-check this worker (seconds). Leave blank for the default.", "enabled": "Enabled", "vlmTitle": "When the model supports VLM, ReadImage uses the worker's own model", + "returnProgress": "Prompt progress", + "returnProgressTitle": "llama.cpp (llama-server) only: streams prompt-eval progress so the chat shows \"processing prompt n%\". Leave off for other backends — they may reject the extra request field", "addWorker": "+ Add worker", "globalTitle": "Global LLM Settings", "timeoutHelp": "Timeout for an LLM request (minutes). Default: 10", + "maxStreamHelp": "Hard wall-clock ceiling for a single LLM call including retries (minutes). Unlike Timeout it never resets on new chunks, so a runaway generation is still aborted. Blank = 2× Timeout; 0 disables (not recommended).", "retryTitle": "Retry (per-call HTTP)", "maxAttemptsHelp": "Maximum attempts for a single LLM API call", "backoffHelp": "Wait time between retries (ms). Consumed in array order.", @@ -581,7 +590,8 @@ "historyEnable": "Enable automatic history summarization", "historyEnableHelp": "Automatically summarize old conversation history to save context. Default: enabled", "tailTurnsHelp": "Number of recent assistant+tool turns to always keep. Default: 2", - "preserveRecentHelp": "Token budget of recent messages preserved without summarization. Default: 8000" + "preserveRecentHelp": "Token budget of recent messages preserved without summarization. Default: 8000", + "reserveCapHelp": "Upper bound on the headroom reserved above the prompt before compact-and-continue fires. Lets large-context models actually use their headroom. Default: 32000" }, "mcp": { "intro": "Settings for connecting to and running external MCP (Model Context Protocol) servers. To add a target server, specify its MCP server URL from a task or from settings.", @@ -677,7 +687,15 @@ "concurrencyHelp": "Number of jobs that can run concurrently", "maxMovementsHelp": "Maximum number of movements per job", "maxAttemptsHelp": "Maximum retry attempts on job failure. Default: 3", - "backoffHelp": "Retry interval (seconds). Comma-separated. Default: 60, 300, 900" + "backoffHelp": "Retry interval (seconds). Comma-separated. Default: 60, 300, 900", + "pythonPanel": { + "title": "Python sandbox packages", + "intro": "Agent python runs inside the Bash sandbox. Only pre-baked packages can be imported.", + "preinstalled": "Pre-installed examples: pandas, numpy, openpyxl, python-docx, pymupdf. The authoritative list is runtime/python-requirements.txt.", + "blocked": "pip install inside the sandbox is currently blocked (the sandbox has its network cut off).", + "howToAdd": "To add a package, an admin edits runtime/python-requirements.txt and re-provisions the host (scripts/prebake-python.sh).", + "roadmap": "Using pip directly inside the sandbox is planned for a future release." + } }, "context": { "limitPlaceholder": "auto (fetched from the Ollama API)", @@ -739,7 +757,17 @@ "askSubtasks": { "askMaxHelp": "Limit of ASKs (questions to the user) per job", "maxDepthHelp": "Maximum nesting depth for subtasks", - "maxPerParentHelp": "Maximum number of subtasks a single parent job can spawn. Default: 10" + "maxPerParentHelp": "Maximum number of subtasks a single parent job can spawn. Default: 10", + "spawnStaggerHelp": "Delay between consecutive SpawnSubTask enqueues (ms), so a burst doesn't monopolize GPU priority. 0 disables. Default: 1000" + }, + "skillsQuota": { + "title": "Skill Quotas", + "intro": "Per-user limits on the skill store: how many skills a user may install and how large the store can grow.", + "maxPerUserHelp": "Maximum number of skills per user. Default: 50", + "maxSkillSizeHelp": "Maximum size of a single skill (KB). Default: 64", + "maxTotalSizeHelp": "Maximum total size of all a user's skills (MB). Default: 5", + "maxSystemSkillsHelp": "Maximum number of system skills. Default: 100", + "maxIndexCharsHelp": "Maximum characters in the skill index. Default: 2000" }, "namespaceEditor": { "addAriaDisabled": "Adding new entries is disabled", @@ -776,6 +804,12 @@ "hsts": "Send HSTS (advanced — off by default)", "hstsHelp": "Turn on only when you serve a real certificate. HSTS pins browsers to HTTPS and cannot be undone once sent (with a self-signed cert it is pure footgun). While off, the server sends a clearing signal (max-age=0) so a browser pinned by an earlier build recovers. If you were stuck being redirected to HTTPS, leave this off and load the app once over HTTPS to clear the pin. Requires a restart to apply.", "selfSignedHosts": "Additional certificate hostnames", + "minVersion": "Minimum TLS version", + "minVersionHelp": "Lowest TLS protocol version the server will negotiate. Requires a restart to apply.", "restartBanner": "Changes to HTTPS settings require a server restart to take effect." + }, + "search": { + "placeholder": "Search settings… (e.g. deadline, TLS, python)", + "noResults": "No matching settings" } } diff --git a/ui/src/i18n/locales/ja/chat.json b/ui/src/i18n/locales/ja/chat.json index 4d17ef1..ab1ecbb 100644 --- a/ui/src/i18n/locales/ja/chat.json +++ b/ui/src/i18n/locales/ja/chat.json @@ -22,6 +22,12 @@ "generating": "{{name}} 生成中…", "processing": "処理中…", "agentResponding": "エージェントが応答を生成中...", + "llmWaiting": "LLM の応答を待っています…", + "llmThinking": "思考中… ({{chars}} 文字)", + "llmRetrying": "LLM リトライ待ち {{attempt}}/{{max}} ({{reason}})", + "llmRecovering": "コンテキストを整理中… ({{stage}})", + "attemptBadge": "試行 {{attempt}}/{{max}}", + "attemptBadgeTitle": "前回の中断理由: {{reason}}", "sendFailed": "送信に失敗しました", "interjectHint": "エージェント実行中 — メッセージで指示を送れます", "agentRunningWait": "エージェントがタスクを実行中です。少々お待ちください。", diff --git a/ui/src/i18n/locales/ja/settings.json b/ui/src/i18n/locales/ja/settings.json index 573e70d..fc68bcb 100644 --- a/ui/src/i18n/locales/ja/settings.json +++ b/ui/src/i18n/locales/ja/settings.json @@ -61,7 +61,9 @@ "pptxLabel": "ReadPPTX 最大サイズ", "pptxHelp": "ReadPPTX が受け付ける .pptx ファイルの最大サイズ(デフォルト: 50 MB)", "pptxUncompressedLabel": "ReadPPTX 展開後サイズ上限", - "pptxUncompressedHelp": "PPTX の ZIP 展開後の合計サイズ上限(ZIP bomb 検知用、デフォルト: 200 MB)" + "pptxUncompressedHelp": "PPTX の ZIP 展開後の合計サイズ上限(ZIP bomb 検知用、デフォルト: 200 MB)", + "msgLabel": "ReadMsg 最大サイズ", + "msgHelp": "ReadMsg が受け付ける .msg(Outlook メール)の最大ファイルサイズ(デフォルト: 25 MB)" }, "uploads": { "sectionHelp": "UI からのアップロード API のリクエスト body 上限 (MB)", @@ -254,6 +256,8 @@ "enable": "Enable Gateway", "listenPortHelp": "同 process 時はこの値は使われません: worker UI と同じポート ({{port}}) を共有します。AAO_MODE=gateway で別 process 起動した場合のみ有効。", "separateDeploy": "別 process deploy:", + "internalTeamsLabel": "内部チーム", + "internalTeamsHelp": "内部として扱うチーム名(カンマ区切り)。ここに載るチームの通信は usage の課金・メータリングから除外される。この一覧はサーバー側で権威を持ち、クライアント指定のヘッダは使わない。", "backendsHelp1": "ルーティング先の llama-server / Ollama / vLLM など。Gateway は worker が送る role を担う backend のうち最も空いているものに割り振ります (roles 未設定の backend は全 role 対応)。role を担う backend が無い場合のみ request.model = id/model の厳密一致にフォールバックします。", "backendsHelp2": "api_key の保存形式: フォームで入力した値は config.yaml に平文で保存されます。${VAR} 形式の env var 参照はフォーム保存時に literal 文字列として保存されるため、env 経由で渡したい場合は config.yaml を直接編集してください。", "backendsEmpty": "backend が未登録です。最低 1 つ追加してください。", @@ -524,11 +528,16 @@ "apiKeyDirectHelp": "Bearer 認証が必要な場合のみ設定。Ollama 単体なら空のままで OK。", "modelHelp": "endpoint が /models を返せば dropdown に候補が出ます。出ない場合 (auth が必要、proxy 越し等) は直接入力してください。", "maxConcurrency": "最大同時実行数", + "healthcheckInterval": "ヘルスチェック間隔(秒)", + "healthcheckIntervalHelp": "このワーカーのヘルスチェック間隔(秒)。空欄で既定値。", "enabled": "有効", "vlmTitle": "VLM 対応モデルの場合、ReadImage が worker 自身のモデルを使用", + "returnProgress": "プロンプト処理進捗", + "returnProgressTitle": "llama.cpp (llama-server) 専用: プロンプト評価の進捗をストリームし、チャットに「プロンプト処理中 n%」を表示します。他のバックエンドではリクエストが拒否され得るためオフのままにしてください", "addWorker": "+ Worker を追加", "globalTitle": "Global LLM Settings", "timeoutHelp": "LLM リクエストのタイムアウト (分)。デフォルト: 10", + "maxStreamHelp": "1 回の LLM 呼び出しの総時間ハード上限(分、リトライ込み)。Timeout と違いチャンク受信でリセットしないので、止まらない暴走生成も打ち切れる。空欄で Timeout×2、0 で無効(非推奨)。", "retryTitle": "Retry (per-call HTTP)", "maxAttemptsHelp": "1 回の LLM API 呼び出しでの最大試行回数", "backoffHelp": "各リトライ間の待機時間 (ms)。配列順に消費されます。", @@ -581,7 +590,8 @@ "historyEnable": "履歴の自動要約を有効化", "historyEnableHelp": "古い会話履歴を自動で要約して context を節約。デフォルト: 有効", "tailTurnsHelp": "常に保持する直近の assistant+tool ターン数。デフォルト: 2", - "preserveRecentHelp": "要約せず温存する直近メッセージのトークン予算。デフォルト: 8000" + "preserveRecentHelp": "要約せず温存する直近メッセージのトークン予算。デフォルト: 8000", + "reserveCapHelp": "compact-and-continue 発火前に prompt の上へ確保する headroom の上限。大コンテキストモデルが headroom を使い切れるようにする。デフォルト: 32000" }, "mcp": { "intro": "外部 MCP (Model Context Protocol) サーバーの接続・実行に関する設定です。接続先サーバーを追加する場合は、各タスクまたは設定から MCP サーバー URL を指定してください。", @@ -677,7 +687,15 @@ "concurrencyHelp": "同時実行可能なジョブ数", "maxMovementsHelp": "1ジョブあたりの最大 movement 数", "maxAttemptsHelp": "ジョブ失敗時の最大リトライ回数。デフォルト: 3", - "backoffHelp": "リトライ間隔(秒)。カンマ区切り。デフォルト: 60, 300, 900" + "backoffHelp": "リトライ間隔(秒)。カンマ区切り。デフォルト: 60, 300, 900", + "pythonPanel": { + "title": "Python サンドボックスのパッケージ", + "intro": "エージェントの python は Bash サンドボックス内で動きます。import できるのは事前ベイク済みのパッケージだけです。", + "preinstalled": "事前インストール例: pandas, numpy, openpyxl, python-docx, pymupdf。権威ある一覧は runtime/python-requirements.txt です。", + "blocked": "サンドボックス内の pip install は現在ブロックされています(サンドボックスはネットワークが遮断されているため)。", + "howToAdd": "パッケージを増やすには、管理者が runtime/python-requirements.txt に追記し、ホストを再プロビジョニングします(scripts/prebake-python.sh)。", + "roadmap": "サンドボックス内で pip を直接使える機能は、今後のリリースで追加予定です。" + } }, "context": { "limitPlaceholder": "auto (Ollama API から取得)", @@ -739,7 +757,17 @@ "askSubtasks": { "askMaxHelp": "1 Job あたりの ASK(ユーザーへの質問)上限", "maxDepthHelp": "サブタスクのネスト最大深度", - "maxPerParentHelp": "1 つの親ジョブが spawn できるサブタスクの最大数。デフォルト: 10" + "maxPerParentHelp": "1 つの親ジョブが spawn できるサブタスクの最大数。デフォルト: 10", + "spawnStaggerHelp": "SpawnSubTask を連続発火するときの間引き遅延(ms)。バースト時に GPU 優先度を独占しないため。0 で無効。デフォルト: 1000" + }, + "skillsQuota": { + "title": "スキルのクォータ", + "intro": "スキルストアのユーザー単位の上限。1 ユーザーが入れられるスキル数とストアの最大サイズを制限します。", + "maxPerUserHelp": "ユーザーあたりの最大スキル数。デフォルト: 50", + "maxSkillSizeHelp": "1 スキルの最大サイズ(KB)。デフォルト: 64", + "maxTotalSizeHelp": "1 ユーザーの全スキル合計の最大サイズ(MB)。デフォルト: 5", + "maxSystemSkillsHelp": "システムスキルの最大数。デフォルト: 100", + "maxIndexCharsHelp": "スキルインデックスの最大文字数。デフォルト: 2000" }, "namespaceEditor": { "addAriaDisabled": "新規追加は無効化されています", @@ -776,6 +804,12 @@ "hsts": "HSTS を送出(上級者向け・既定オフ)", "hstsHelp": "正式な証明書を導入している場合のみオンにしてください。HSTS はブラウザを HTTPS に固定し、一度送ると後からオフにしても解除できません(自己署名では害だけが残ります)。オフのままなら、サーバーは過去に固定されたブラウザを解除するための信号(max-age=0)を送ります。以前 HTTPS にずっとリダイレクトされていた場合は、このスイッチをオフのまま一度 HTTPS でアプリを開けば固定が解除されます。反映には再起動が必要です。", "selfSignedHosts": "証明書の追加ホスト名", + "minVersion": "最小 TLS バージョン", + "minVersionHelp": "サーバーがネゴシエートする最小の TLS プロトコルバージョン。反映には再起動が必要です。", "restartBanner": "HTTPS 設定の変更を反映するには、サーバーの再起動が必要です。" + }, + "search": { + "placeholder": "設定を検索…(例: デッドライン, TLS, python)", + "noResults": "一致する設定はありません" } } diff --git a/ui/src/lib/traceEvent.test.ts b/ui/src/lib/traceEvent.test.ts index 7c5f95a..8139d10 100644 --- a/ui/src/lib/traceEvent.test.ts +++ b/ui/src/lib/traceEvent.test.ts @@ -103,3 +103,54 @@ describe('summarizeTraceEvent', () => { expect(result).toBe('piece=chat'); }); }); + +// 観測性 Phase 1: LLM 呼び出しログの診断カラム。 +describe('summarizeTraceEvent — llm observability', () => { + it('llm_call_end includes error class, retries, thinking chars and backend', () => { + const line = summarizeTraceEvent({ + kind: 'llm_call_end', + payload: { + durationMs: 1200, promptTokens: 100, completionTokens: 5, toolCalls: 0, textChars: 0, + hadError: true, errorClass: 'gateway_timeout', retries: 2, thinkingChars: 4200, backendId: 'backend-2', + }, + }); + expect(line).toContain('⚠ gateway_timeout'); + expect(line).toContain('retries=2'); + expect(line).toContain('think=4200c'); + expect(line).toContain('@backend-2'); + }); + + it('llm_call_end appends HTTP status for http errors', () => { + const line = summarizeTraceEvent({ + kind: 'llm_call_end', + payload: { durationMs: 10, toolCalls: 0, textChars: 0, hadError: true, errorClass: 'http', httpStatus: 500 }, + }); + expect(line).toContain('⚠ http HTTP 500'); + }); + + it('llm_call_end stays compact on the happy path', () => { + const line = summarizeTraceEvent({ + kind: 'llm_call_end', + payload: { durationMs: 800, promptTokens: 10, completionTokens: 2, toolCalls: 1, textChars: 0, hadError: false, retries: 0, thinkingChars: 0 }, + }); + expect(line).not.toContain('retries='); + expect(line).not.toContain('think='); + expect(line).not.toContain('⚠'); + }); + + it('summarizes llm_call_retry rows', () => { + const line = summarizeTraceEvent({ + kind: 'llm_call_retry', + payload: { attempt: 1, maxAttempts: 3, errorClass: 'http', httpStatus: 503, delayMs: 2000 }, + }); + expect(line).toBe('retry 1/3 http HTTP 503 wait=2.0s'); + }); + + it('summarizes context_recovery rows with truncated detail', () => { + const line = summarizeTraceEvent({ + kind: 'context_recovery', + payload: { stage: 'summarize_failed', detail: 'no middle turns to summarize' }, + }); + expect(line).toBe('summarize_failed: no middle turns to summarize'); + }); +}); diff --git a/ui/src/lib/traceEvent.ts b/ui/src/lib/traceEvent.ts index 67f3fbd..4d4fdf7 100644 --- a/ui/src/lib/traceEvent.ts +++ b/ui/src/lib/traceEvent.ts @@ -51,8 +51,18 @@ export function summarizeTraceEvent(event: TraceEventShape): string { : (p.textChars as number) > 0 ? ` text=${p.textChars}c` : ''; - return `${fmtDuration(Number(p.durationMs ?? 0))}${tokens}${shape}${p.hadError ? ' ⚠' : ''}`; + const thinking = (p.thinkingChars as number) > 0 ? ` think=${p.thinkingChars}c` : ''; + const retries = (p.retries as number) > 0 ? ` retries=${p.retries}` : ''; + const backend = typeof p.backendId === 'string' && p.backendId ? ` @${p.backendId}` : ''; + const error = p.hadError + ? ` ⚠ ${String(p.errorClass ?? 'error')}${typeof p.httpStatus === 'number' ? ` HTTP ${p.httpStatus}` : ''}` + : ''; + return `${fmtDuration(Number(p.durationMs ?? 0))}${tokens}${shape}${thinking}${retries}${backend}${error}`; } + case 'llm_call_retry': + return `retry ${p.attempt ?? '?'}/${p.maxAttempts ?? '?'} ${String(p.errorClass ?? '?')}${typeof p.httpStatus === 'number' ? ` HTTP ${p.httpStatus}` : ''} wait=${fmtDuration(Number(p.delayMs ?? 0))}`; + case 'context_recovery': + return `${String(p.stage ?? '?')}${p.detail ? `: ${String(p.detail).slice(0, 120)}` : ''}`; case 'cache_set': return `${String(p.tool ?? '?')} (${String(p.volatility ?? '?')})`; case 'cache_hit': diff --git a/ui/src/lib/urlState.ts b/ui/src/lib/urlState.ts index 90b7250..7ec0503 100644 --- a/ui/src/lib/urlState.ts +++ b/ui/src/lib/urlState.ts @@ -27,6 +27,7 @@ const SETTINGS_SECTIONS = [ 'context', 'safety', 'reflection', + 'skills-quota', // Tools group (sub-sections) 'tools-web', 'tools-browser',