sync: update from private repo (d3780b00)
Some checks failed
CI / build-and-test (push) Failing after 6m23s

This commit is contained in:
oss-sync 2026-07-09 00:12:24 +00:00
parent e8d38a104d
commit d41ff0f658
56 changed files with 1687 additions and 117 deletions

View File

@ -20,6 +20,8 @@ jobs:
run: | run: |
npm ci npm ci
npm --prefix ui ci npm --prefix ui ci
- name: Install Playwright Chromium
run: node node_modules/playwright/cli.js install --with-deps chromium
- name: Build - name: Build
run: npm run build:all -- --skip-python run: npm run build:all -- --skip-python
- name: Test - name: Test

View File

@ -6,20 +6,87 @@ follow semantic versioning.
## Unreleased ## Unreleased
### Fixed ## v0.2.0 (2026-07-09)
- Docker: a clean `docker compose up --build` now reliably installs Chromium for
the browser tools. The Playwright browser CLI is invoked directly A month of work since the initial release, centered on shared workspaces,
(`node node_modules/playwright/cli.js install`) to avoid an npm bin-name agent-to-agent (A2A) collaboration, and much deeper visibility into what agents
collision that left a from-scratch build failing with `playwright: not found` are doing.
(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.
### Added ### 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) ## v0.1.0 — Initial public release (2026-06-02)

View File

@ -2,7 +2,19 @@ import { EventEmitter } from 'events';
export interface JobStreamEvent { export interface JobStreamEvent {
type: 'prompt_progress' | 'text' | 'tool_use' | 'tool_use_delta' | 'tool_result' | 'done' 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 // prompt_progress
processed?: number; processed?: number;
total?: number; total?: number;

View File

@ -571,3 +571,82 @@ describe('browser.display_mode migration (captcha_solve rename)', () => {
expect(out.browser?.maxSessions).toBe(3); 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');
});
});

View File

@ -260,7 +260,20 @@ function syncProviderFromLlm(out: Record<string, unknown>, llm: LlmConfig): void
const allExistingMatch = existingWorkers.length > 0 const allExistingMatch = existingWorkers.length > 0
&& existingWorkers.every(w => typeof w.id === 'string' && llmIds.has(w.id)) && existingWorkers.every(w => typeof w.id === 'string' && llmIds.has(w.id))
&& existingWorkers.length === llm.workers.length; && 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 mirroredWorkers: WorkerDef[] = llm.workers.map(w => {
const def: WorkerDef = { const def: WorkerDef = {
@ -273,6 +286,7 @@ function syncProviderFromLlm(out: Record<string, unknown>, llm: LlmConfig): void
if (w.model !== undefined && w.model !== '') def.model = w.model; if (w.model !== undefined && w.model !== '') def.model = w.model;
if (w.apiKey !== undefined) def.apiKey = w.apiKey; if (w.apiKey !== undefined) def.apiKey = w.apiKey;
if (w.vlm !== undefined) def.vlm = w.vlm; if (w.vlm !== undefined) def.vlm = w.vlm;
if (w.returnProgress !== undefined) def.returnProgress = w.returnProgress;
if (w.healthcheckIntervalSeconds !== undefined) { if (w.healthcheckIntervalSeconds !== undefined) {
def.healthcheckIntervalSeconds = w.healthcheckIntervalSeconds; 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.apiKey !== undefined) worker.apiKey = w.apiKey;
if (w.vlm !== undefined) worker.vlm = w.vlm; if (w.vlm !== undefined) worker.vlm = w.vlm;
if (w.returnProgress !== undefined) worker.returnProgress = w.returnProgress;
if (w.healthcheckIntervalSeconds !== undefined) { if (w.healthcheckIntervalSeconds !== undefined) {
worker.healthcheckIntervalSeconds = w.healthcheckIntervalSeconds; worker.healthcheckIntervalSeconds = w.healthcheckIntervalSeconds;
} }
@ -422,6 +437,7 @@ function normalizeLlmWorker(w: Partial<LlmWorkerDef> & Record<string, unknown>):
}; };
if (typeof w.apiKey === 'string') worker.apiKey = w.apiKey; if (typeof w.apiKey === 'string') worker.apiKey = w.apiKey;
if (typeof w.vlm === 'boolean') worker.vlm = w.vlm; if (typeof w.vlm === 'boolean') worker.vlm = w.vlm;
if (typeof w.returnProgress === 'boolean') worker.returnProgress = w.returnProgress;
if (typeof w.healthcheckIntervalSeconds === 'number') { if (typeof w.healthcheckIntervalSeconds === 'number') {
worker.healthcheckIntervalSeconds = w.healthcheckIntervalSeconds; worker.healthcheckIntervalSeconds = w.healthcheckIntervalSeconds;
} }

View File

@ -138,6 +138,13 @@ export interface WorkerDef {
enabled?: boolean; enabled?: boolean;
maxConcurrency?: number; maxConcurrency?: number;
vlm?: boolean; // true: ReadImage uses this worker's own model instead of dedicated VLM endpoint 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[]; roles?: string[];
/** @deprecated Use roles instead. Kept for backward compat shim. */ /** @deprecated Use roles instead. Kept for backward compat shim. */
profiles?: string[]; profiles?: string[];
@ -443,6 +450,8 @@ export interface LlmWorkerDef {
maxConcurrency: number; maxConcurrency: number;
enabled: boolean; enabled: boolean;
vlm?: boolean; vlm?: boolean;
/** llama.cpp の prompt 評価進捗ストリームreturn_progressを要求する。既定 off。 */
returnProgress?: boolean;
healthcheckIntervalSeconds?: number; healthcheckIntervalSeconds?: number;
} }

View File

@ -234,6 +234,13 @@ export async function executeMovement(
missionAware: !!ctx.missionBrief?.read()?.goal, 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 // Traceability T-1: every return path goes through this helper so the
// movement_complete event is emitted exactly once with a uniform shape. // movement_complete event is emitted exactly once with a uniform shape.
const finishMovement = (result: MovementResult): MovementResult => { const finishMovement = (result: MovementResult): MovementResult => {
@ -283,6 +290,7 @@ export async function executeMovement(
promptGuardRatio, promptGuardRatio,
historySummarization: safetyConfig?.historySummarization, historySummarization: safetyConfig?.historySummarization,
runIsolatedLlm, runIsolatedLlm,
onStage: onRecoveryStage,
}); });
if (!promptGuard.ok) { if (!promptGuard.ok) {
logger.warn(`[agent-loop] movement=${movement.name} oversized prompt blocked before send: estimated=${promptGuard.estimatedTokens} limit=${promptGuard.limitTokens}`); 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, messages,
toolsUsed, toolsUsed,
runIsolatedLlm, runIsolatedLlm,
onRecoveryStage,
); );
return finishMovement(result); return finishMovement(result);
} }
@ -302,7 +311,7 @@ export async function executeMovement(
options.conversation?.rewrite(); options.conversation?.rewrite();
} }
const { accumulatedText, pendingToolCalls, hadError, errorMessage, lastUsage } = const { accumulatedText, pendingToolCalls, hadError, errorMessage, errorClass, httpStatus, lastUsage } =
await runLlmIteration({ await runLlmIteration({
client, client,
messages, messages,
@ -334,6 +343,9 @@ export async function executeMovement(
promptGuardRatio, promptGuardRatio,
safetyConfig, safetyConfig,
runIsolatedLlm, runIsolatedLlm,
errorClass,
httpStatus,
onStage: onRecoveryStage,
}); });
if (errorResult) { if (errorResult) {
return finishMovement(errorResult); return finishMovement(errorResult);

View File

@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { ContextManager } from '../context-manager.js'; 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'; import type { Movement } from './types.js';
function mv(defaultNext?: string): Movement { 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); 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']);
});
});

View File

@ -31,19 +31,32 @@ export async function buildContextOverflowResult(
messages: Message[], messages: Message[],
toolsUsed: string[], toolsUsed: string[],
runIsolatedLlm?: (messages: Message[]) => Promise<string>, runIsolatedLlm?: (messages: Message[]) => Promise<string>,
onStage?: (stage: string, detail?: string) => void,
): Promise<MovementResult> { ): Promise<MovementResult> {
const fallbackNext = resolveContextOverflowNext(movement.defaultNext); const fallbackNext = resolveContextOverflowNext(movement.defaultNext);
if (fallbackNext === 'ABORT') { 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; let handoffSummary: string | null = null;
if (runIsolatedLlm) { if (runIsolatedLlm) {
onStage?.('force_transition_summary');
try { try {
handoffSummary = await summarizeForceTransition(messages, runIsolatedLlm); handoffSummary = await summarizeForceTransition(messages, runIsolatedLlm);
} catch { } catch {
handoffSummary = null; handoffSummary = null;
} }
if (handoffSummary === null) {
onStage?.('force_transition_summary_failed');
}
} }
const output = handoffSummary const output = handoffSummary
? [ ? [
@ -157,6 +170,12 @@ interface LLMErrorContext {
promptGuardRatio: number; promptGuardRatio: number;
safetyConfig?: SafetyConfig; safetyConfig?: SafetyConfig;
runIsolatedLlm: (messages: Message[]) => Promise<string>; runIsolatedLlm: (messages: Message[]) => Promise<string>;
/** 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; 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, promptGuardRatio: impliedRatio,
historySummarization, historySummarization,
runIsolatedLlm: ctx.runIsolatedLlm, runIsolatedLlm: ctx.runIsolatedLlm,
onStage: ctx.onStage,
}); });
if (recoveredGuard.ok) { if (recoveredGuard.ok) {
const changedAnything = recoveredGuard.deduped || recoveredGuard.compacted || recoveredGuard.summarized; const changedAnything = recoveredGuard.deduped || recoveredGuard.compacted || recoveredGuard.summarized;
@ -217,6 +237,7 @@ export async function handleLLMError(
ctx.messages, ctx.messages,
ctx.toolsUsed, ctx.toolsUsed,
ctx.runIsolatedLlm, 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 };
} }

View File

@ -64,6 +64,7 @@ export async function runLlmIteration(args: RunLlmIterationArgs): Promise<LlmIte
iteration, iteration,
messageCount: messages.length, messageCount: messages.length,
}); });
callbacks?.onLlmRequestStart?.({ movementName, iteration, messageCount: messages.length });
const consumed = await consumeLlmStream( const consumed = await consumeLlmStream(
client, client,
messages, messages,
@ -94,6 +95,21 @@ export async function runLlmIteration(args: RunLlmIterationArgs): Promise<LlmIte
onBackend: (backendId, cacheKey) => { onBackend: (backendId, cacheKey) => {
callbacks?.onBackendResolved?.({ backendId, cacheKey }); 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} `, `movement=${movementName} `,
{ userId }, { userId },
@ -101,7 +117,9 @@ export async function runLlmIteration(args: RunLlmIterationArgs): Promise<LlmIte
const llmDurationMs = Date.now() - llmStartedAt; const llmDurationMs = Date.now() - llmStartedAt;
const { accumulatedText, pendingToolCalls, hadError, lastUsage } = consumed; const { accumulatedText, pendingToolCalls, hadError, lastUsage } = consumed;
events.emit('llm_call_end', { // 診断カラム分類・リトライ数・thinking 量・担当バックエンドを1呼び出し
// 1行の llm_call_end に集約する。TraceTab の LLM 呼び出しログの一次データ。
const callInfo = {
iteration, iteration,
durationMs: llmDurationMs, durationMs: llmDurationMs,
promptTokens: lastUsage?.prompt_tokens, promptTokens: lastUsage?.prompt_tokens,
@ -109,17 +127,15 @@ export async function runLlmIteration(args: RunLlmIterationArgs): Promise<LlmIte
toolCalls: pendingToolCalls.length, toolCalls: pendingToolCalls.length,
textChars: accumulatedText.length, textChars: accumulatedText.length,
hadError, hadError,
}); errorClass: consumed.errorClass,
callbacks?.onLLMCall?.({ httpStatus: consumed.httpStatus,
iteration, retries: consumed.retries,
durationMs: llmDurationMs, thinkingChars: consumed.thinkingChars,
promptTokens: lastUsage?.prompt_tokens, backendId: consumed.backendId,
completionTokens: lastUsage?.completion_tokens, };
toolCalls: pendingToolCalls.length, events.emit('llm_call_end', callInfo);
textChars: accumulatedText.length, callbacks?.onLLMCall?.(callInfo);
hadError, logger.info(`[agent-loop] movement=${movementName} LLM stream ended (iteration=${iteration}, hadError=${hadError}${consumed.errorClass ? ` class=${consumed.errorClass}` : ''}, ${llmDurationMs}ms${lastUsage ? ` in=${lastUsage.prompt_tokens} out=${lastUsage.completion_tokens}` : ''})`);
});
logger.info(`[agent-loop] movement=${movementName} LLM stream ended (iteration=${iteration}, hadError=${hadError}, ${llmDurationMs}ms${lastUsage ? ` in=${lastUsage.prompt_tokens} out=${lastUsage.completion_tokens}` : ''})`);
// LLM 応答のサマリーログ // LLM 応答のサマリーログ
logger.info(`[agent-loop] movement=${movementName} response: text=${accumulatedText.length}chars toolCalls=${pendingToolCalls.length} tools=[${pendingToolCalls.map((t) => t.function.name).join(',')}]`); logger.info(`[agent-loop] movement=${movementName} response: text=${accumulatedText.length}chars toolCalls=${pendingToolCalls.length} tools=[${pendingToolCalls.map((t) => t.function.name).join(',')}]`);

View File

@ -46,6 +46,9 @@ export interface MovementResult {
// piece-runner が PieceRunResult.abortReason に伝搬する。未指定なら // piece-runner が PieceRunResult.abortReason に伝搬する。未指定なら
// 'movement_abort'(後方互換)。 // 'movement_abort'(後方互換)。
abortCode?: string; abortCode?: string;
// abortCode='llm_error' のとき、失敗の機械可読分類LlmErrorClass
// 中断メッセージ・LLM 呼び出しログでの診断用。
errorClass?: string;
} }
export interface ToolResultInfo { export interface ToolResultInfo {
@ -71,6 +74,16 @@ export interface LLMCallInfo {
textChars: number; textChars: number;
/** True if the stream surfaced an error mid-flight. */ /** True if the stream surfaced an error mid-flight. */
hadError: boolean; 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 { export interface HandoffContext {
@ -98,6 +111,28 @@ export interface AgentLoopCallbacks {
*/ */
onPromptProgress?: (progress: { processed: number; total: number; timeMs: number; cache: number }) => void; onPromptProgress?: (progress: { processed: number; total: number; timeMs: number; cache: number }) => void;
onLLMCall?: (info: LLMCallInfo) => 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 * Fires when a proxy-mode LLM client resolves the physical backend that
* handled the call (see OpenAICompatClient + LLMEvent 'backend'). The * handled the call (see OpenAICompatClient + LLMEvent 'backend'). The

View File

@ -349,3 +349,49 @@ describe('guardPromptBeforeSend — client-preflight estimator parity', () => {
expect(res.ok).toBe(false); 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();
});
});

View File

@ -71,6 +71,12 @@ export interface GuardOptions {
promptGuardRatio?: number; promptGuardRatio?: number;
historySummarization?: HistorySummarizationConfig; historySummarization?: HistorySummarizationConfig;
runIsolatedLlm?: (messages: Message[]) => Promise<string>; runIsolatedLlm?: (messages: Message[]) => Promise<string>;
/**
* / isolated LLM
* UI 使
* stage: 'dedup' | 'compact' | 'summarize' | 'summarize_failed'
*/
onStage?: (stage: string, detail?: string) => void;
} }
export function looksLikeLargeEncodedPayload(text: string): boolean { 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 }; return { ok: true, estimatedTokens: estimated, compacted: false, deduped: false, summarized: false };
} }
options.onStage?.('dedup');
const dedup = dedupeFileReads(messages); const dedup = dedupeFileReads(messages);
if (dedup.changed) { if (dedup.changed) {
estimated = estimateMessagesTokens(messages) + fixedTokens; estimated = estimateMessagesTokens(messages) + fixedTokens;
@ -199,6 +206,7 @@ export async function guardPromptBeforeSend(
return { ok: true, estimatedTokens: estimated, compacted: false, deduped: dedup.changed, summarized: false }; return { ok: true, estimatedTokens: estimated, compacted: false, deduped: dedup.changed, summarized: false };
} }
options.onStage?.('compact');
const compacted = compactOversizedToolResults(messages, fixedTokens, maxPromptTokens); const compacted = compactOversizedToolResults(messages, fixedTokens, maxPromptTokens);
let summarized = false; let summarized = false;
if (compacted.changed) { if (compacted.changed) {
@ -235,6 +243,7 @@ export async function guardPromptBeforeSend(
const tailTurns = options.historySummarization?.tailTurns ?? 2; const tailTurns = options.historySummarization?.tailTurns ?? 2;
const preserveRecentBudget = options.historySummarization?.preserveRecentBudget const preserveRecentBudget = options.historySummarization?.preserveRecentBudget
?? Math.min(Math.floor(limitTokens * 0.25), 8_000); ?? Math.min(Math.floor(limitTokens * 0.25), 8_000);
options.onStage?.('summarize');
const summary = await summarizeHistory(messages, { const summary = await summarizeHistory(messages, {
tailTurns, tailTurns,
preserveRecentBudget, preserveRecentBudget,
@ -255,6 +264,7 @@ export async function guardPromptBeforeSend(
} }
} else { } else {
logger.warn(`[prompt-guard] history summarization skipped: ${summary.reason}`); logger.warn(`[prompt-guard] history summarization skipped: ${summary.reason}`);
options.onStage?.('summarize_failed', summary.reason);
} }
} }

View File

@ -176,3 +176,66 @@ describe('consumeLlmStream', () => {
expect(result.errorMessage).toContain('idle safety timeout'); 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');
});
});

View File

@ -5,6 +5,7 @@ import type {
OpenAICompatClient, OpenAICompatClient,
LLMEvent, LLMEvent,
LlmCallContext, LlmCallContext,
LlmErrorClass,
} from '../llm/openai-compat.js'; } from '../llm/openai-compat.js';
import { logger } from '../logger.js'; import { logger } from '../logger.js';
import { stripThinkingTokens } from './strip-thinking.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`). * cacheKey is non-null only on LiteLLM cache hits (`x-litellm-cache-key`).
*/ */
onBackend?: (backendId: string, cacheKey: string | null) => void; 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 { export interface ConsumedLLMResponse {
@ -75,6 +87,14 @@ export interface ConsumedLLMResponse {
pendingToolCalls: ToolCall[]; pendingToolCalls: ToolCall[];
hadError: boolean; hadError: boolean;
errorMessage: string; 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 }; lastUsage?: { prompt_tokens: number; completion_tokens: number };
/** /**
* The physical backend id that handled this call, set when the * The physical backend id that handled this call, set when the
@ -117,6 +137,8 @@ export async function consumeLlmStream(
pendingToolCalls: [], pendingToolCalls: [],
hadError: false, hadError: false,
errorMessage: '', errorMessage: '',
retries: 0,
thinkingChars: 0,
}; };
let streamExhausted = false; let streamExhausted = false;
@ -142,6 +164,7 @@ export async function consumeLlmStream(
logger.error(`[llm-stream] ${contextLabel}stream safety timeout or error: ${msg}`); logger.error(`[llm-stream] ${contextLabel}stream safety timeout or error: ${msg}`);
accumulator.hadError = true; accumulator.hadError = true;
accumulator.errorMessage = msg; accumulator.errorMessage = msg;
accumulator.errorClass = msg.includes('idle safety timeout') ? 'idle_timeout' : (accumulator.errorClass ?? 'stream');
try { try {
await Promise.race([ await Promise.race([
stream.return(undefined as never), stream.return(undefined as never),
@ -188,7 +211,24 @@ function handleEvent(
case 'error': case 'error':
acc.hadError = true; acc.hadError = true;
acc.errorMessage = event.error; 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; return;
case 'backend': case 'backend':
acc.backendId = event.backendId; acc.backendId = event.backendId;

View File

@ -190,7 +190,8 @@ describe('ReadSkill tool', () => {
expect(fs.existsSync(path.join(workspace, 'skills', 'tdd'))).toBe(false); 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 systemDir = makeTempDir();
const userRoot = makeTempDir(); const userRoot = makeTempDir();
const workspace = makeTempDir(); const workspace = makeTempDir();

View File

@ -73,6 +73,7 @@ describe('OpenAICompatClient retry', () => {
expect(fetchMock).toHaveBeenCalledTimes(2); expect(fetchMock).toHaveBeenCalledTimes(2);
expect(events).toEqual([ expect(events).toEqual([
{ type: 'retry', attempt: 1, maxAttempts: 2, reason: 'HTTP 503', errorClass: 'http', httpStatus: 503, delayMs: 0 },
{ type: 'text', text: 'hello' }, { type: 'text', text: 'hello' },
{ type: 'done', usage: undefined }, { type: 'done', usage: undefined },
]); ]);
@ -98,6 +99,7 @@ describe('OpenAICompatClient retry', () => {
expect(fetchMock).toHaveBeenCalledTimes(2); expect(fetchMock).toHaveBeenCalledTimes(2);
expect(events).toEqual([ expect(events).toEqual([
{ type: 'retry', attempt: 1, maxAttempts: 2, reason: 'socket hang up', errorClass: 'connection', delayMs: 0 },
{ type: 'text', text: 'ok' }, { type: 'text', text: 'ok' },
{ type: 'done', usage: undefined }, { type: 'done', usage: undefined },
]); ]);
@ -118,7 +120,7 @@ describe('OpenAICompatClient retry', () => {
expect(fetchMock).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledTimes(1);
expect(events).toEqual([ 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(fetchMock).not.toHaveBeenCalled();
expect(events).toEqual([ 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' }]); const events = await collectEvents(client, [{ role: 'user', content: 'hi' }]);
expect(events).toEqual([ 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' }]); const events = await collectEvents(client, [{ role: 'user', content: 'hi' }]);
expect(events).toEqual([ 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); 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);
});
});

View File

@ -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 = export type LLMEvent =
| { type: 'text'; text: string } | { type: 'text'; text: string }
| { type: 'tool_use'; id: string; name: string; input: Record<string, unknown> } | { type: 'tool_use'; id: string; name: string; input: Record<string, unknown> }
@ -63,7 +90,21 @@ export type LLMEvent =
* won't help until the period resets. * won't help until the period resets.
* Unset for generic transport / parse errors. * 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, * Emitted once per request, immediately after response headers arrive,
* for proxy-backed clients (LiteLLM Proxy etc.). Carries the physical * 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. * timeout so every client is bounded even if the caller forgets to set it.
*/ */
maxStreamMs?: number; 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). // still bounded. `?? ` not `||` so an explicit 0 stays 0 (disabled).
this.maxStreamMs = options?.maxStreamMs ?? this.timeoutMs * 2; this.maxStreamMs = options?.maxStreamMs ?? this.timeoutMs * 2;
this.proxy = options?.proxy === true; this.proxy = options?.proxy === true;
this.requestPromptProgress = options?.requestPromptProgress === true;
} }
private readonly requestPromptProgress: boolean;
private buildAbortErrorMessage(externalSignal?: AbortSignal, hardCapHit = false): string { private buildAbortErrorMessage(externalSignal?: AbortSignal, hardCapHit = false): string {
if (externalSignal?.aborted) { if (externalSignal?.aborted) {
return 'Request cancelled by caller'; return 'Request cancelled by caller';
@ -349,6 +401,13 @@ export class OpenAICompatClient {
return `Request timed out (${mins} minutes)`; 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 * Backend the next request should prefer (gateway sticky routing for
* KV-cache reuse). Updated by the worker whenever the resolved backend * KV-cache reuse). Updated by the worker whenever the resolved backend
@ -429,7 +488,7 @@ export class OpenAICompatClient {
if (externalSignal.aborted) { if (externalSignal.aborted) {
clearTimeout(timeoutId); clearTimeout(timeoutId);
if (hardCapId) clearTimeout(hardCapId); if (hardCapId) clearTimeout(hardCapId);
yield { type: 'error', error: 'Request cancelled by caller' }; yield { type: 'error', error: 'Request cancelled by caller', errorClass: 'cancelled' };
return; return;
} }
onExternalAbort = () => controller.abort(); onExternalAbort = () => controller.abort();
@ -456,6 +515,11 @@ export class OpenAICompatClient {
stream: true, stream: true,
stream_options: { include_usage: 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) { if (this.model) {
body['model'] = this.model; body['model'] = this.model;
} }
@ -478,7 +542,7 @@ export class OpenAICompatClient {
logPromptBreakdown('blocked', body, messages, tools, estimatedPromptTokens, maxPromptTokens, this.contextLimitTokens, this.onPromptPreflight); logPromptBreakdown('blocked', body, messages, tools, estimatedPromptTokens, maxPromptTokens, this.contextLimitTokens, this.onPromptPreflight);
const error = buildPromptTooLargeError(estimatedPromptTokens, maxPromptTokens, this.contextLimitTokens, this.promptGuardRatio); const error = buildPromptTooLargeError(estimatedPromptTokens, maxPromptTokens, this.contextLimitTokens, this.promptGuardRatio);
logger.warn(`OpenAICompatClient: ${error}`); logger.warn(`OpenAICompatClient: ${error}`);
yield { type: 'error', error }; yield { type: 'error', error, errorClass: 'preflight_block' };
return; return;
} }
logPromptBreakdown('ok', body, messages, tools, estimatedPromptTokens, maxPromptTokens, this.contextLimitTokens, this.onPromptPreflight); logPromptBreakdown('ok', body, messages, tools, estimatedPromptTokens, maxPromptTokens, this.contextLimitTokens, this.onPromptPreflight);
@ -507,22 +571,23 @@ export class OpenAICompatClient {
} catch (err) { } catch (err) {
if ((err as Error)?.name === 'AbortError') { if ((err as Error)?.name === 'AbortError') {
logger.error('OpenAICompatClient: request timed out'); 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; return;
} }
lastErrorMessage = err instanceof Error ? err.message : String(err); lastErrorMessage = err instanceof Error ? err.message : String(err);
if (!isTransientFetchError(err) || attempt >= maxAttempts) { if (!isTransientFetchError(err) || attempt >= maxAttempts) {
logger.error(`OpenAICompatClient: fetch failed: ${lastErrorMessage}`); logger.error(`OpenAICompatClient: fetch failed: ${lastErrorMessage}`);
yield { type: 'error', error: `Connection error: ${lastErrorMessage}` }; yield { type: 'error', error: `Connection error: ${lastErrorMessage}`, errorClass: 'connection' };
return; return;
} }
const delayMs = getRetryDelayMs(this.retryConfig, attempt); const delayMs = getRetryDelayMs(this.retryConfig, attempt);
logger.warn(`OpenAICompatClient: transient fetch error on attempt ${attempt}/${maxAttempts}: ${lastErrorMessage}; retrying in ${delayMs}ms`); 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))) { if (!(await waitForRetry(delayMs, controller.signal))) {
logger.error('OpenAICompatClient: request timed out'); 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; return;
} }
continue; continue;
@ -542,22 +607,23 @@ export class OpenAICompatClient {
if (!isRetryableHttpStatus(response.status, this.retryConfig) || attempt >= maxAttempts) { if (!isRetryableHttpStatus(response.status, this.retryConfig) || attempt >= maxAttempts) {
logger.error(`OpenAICompatClient: ${lastErrorMessage}`); logger.error(`OpenAICompatClient: ${lastErrorMessage}`);
yield { type: 'error', error: lastErrorMessage }; yield { type: 'error', error: lastErrorMessage, errorClass: 'http', httpStatus: response.status };
return; return;
} }
const delayMs = getRetryDelayMs(this.retryConfig, attempt); const delayMs = getRetryDelayMs(this.retryConfig, attempt);
logger.warn(`OpenAICompatClient: retryable HTTP ${response.status} on attempt ${attempt}/${maxAttempts}; retrying in ${delayMs}ms`); 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))) { if (!(await waitForRetry(delayMs, controller.signal))) {
logger.error('OpenAICompatClient: request timed out'); 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; return;
} }
continue; continue;
} }
if (!response.body) { if (!response.body) {
yield { type: 'error', error: 'Response body is null' }; yield { type: 'error', error: 'Response body is null', errorClass: 'connection' };
return; return;
} }
@ -656,6 +722,7 @@ export class OpenAICompatClient {
yield { yield {
type: 'error', type: 'error',
error: `gateway ${errObj.type}: ${msg}`, error: `gateway ${errObj.type}: ${msg}`,
errorClass: errObj.type as LlmErrorClass,
gatewayErrorType: errObj.type as 'gateway_shutdown' | 'gateway_timeout' | 'budget_exhausted' | 'rate_limited', gatewayErrorType: errObj.type as 'gateway_shutdown' | 'gateway_timeout' | 'budget_exhausted' | 'rate_limited',
}; };
return; return;
@ -697,10 +764,12 @@ export class OpenAICompatClient {
const finishReason = choice['finish_reason'] as string | null | undefined; const finishReason = choice['finish_reason'] as string | null | undefined;
if (delta) { if (delta) {
// reasoning_content (thinking models) — スキップしてログのみ // reasoning_content (thinking models) — 中身は流さず文字数のみ
// 'thinking' イベントで通知UI の「思考中」表示用)。
const reasoning = delta['reasoning_content']; const reasoning = delta['reasoning_content'];
if (typeof reasoning === 'string' && reasoning.length > 0) { 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); const message = err instanceof Error ? err.message : String(err);
if ((err as Error)?.name === 'AbortError') { if ((err as Error)?.name === 'AbortError') {
logger.error('OpenAICompatClient: request timed out'); 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; return;
} }
// 一時的なストリームエラー — 試行回数が残っていればリトライ // 一時的なストリームエラー — 試行回数が残っていればリトライ
if (attempt >= maxAttempts) { if (attempt >= maxAttempts) {
logger.error(`OpenAICompatClient: stream read error: ${message}`); logger.error(`OpenAICompatClient: stream read error: ${message}`);
yield { type: 'error', error: `Stream error: ${message}` }; yield { type: 'error', error: `Stream error: ${message}`, errorClass: 'stream' };
return; return;
} }
const delayMs = getRetryDelayMs(this.retryConfig, attempt); const delayMs = getRetryDelayMs(this.retryConfig, attempt);
logger.warn(`OpenAICompatClient: stream read error on attempt ${attempt}/${maxAttempts}: ${message}; retrying in ${delayMs}ms`); 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))) { if (delayMs > 0 && !(await waitForRetry(delayMs, controller.signal))) {
logger.error('OpenAICompatClient: request timed out during retry wait'); 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; return;
} }
continue; 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 { } finally {
clearTimeout(timeoutId); clearTimeout(timeoutId);
if (hardCapId) clearTimeout(hardCapId); if (hardCapId) clearTimeout(hardCapId);

View File

@ -1427,7 +1427,11 @@ export class Worker {
this.contextLimitTokens, this.contextLimitTokens,
this.config.safety?.promptGuardRatio, this.config.safety?.promptGuardRatio,
(line) => reporter.reportPromptPreflight(line), (line) => reporter.reportPromptPreflight(line),
{ proxy: isProxyWorker, maxStreamMs: this.resolveMaxStreamMs() }, {
proxy: isProxyWorker,
maxStreamMs: this.resolveMaxStreamMs(),
requestPromptProgress: workerDefForLlm.returnProgress === true,
},
); );
return { llmClient, isProxyWorker }; return { llmClient, isProxyWorker };
} }
@ -2492,6 +2496,10 @@ export class Worker {
const truncate = (s: string, cap: number): string => const truncate = (s: string, cap: number): string =>
s.length > cap ? s.slice(0, cap) + `\n…[truncated ${s.length - cap} bytes]` : s; 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 { return {
onMovementStart: (name) => { onMovementStart: (name) => {
movementStartTime = Date.now(); 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) => { onDelegateLifecycle: (info) => {
if (jobEventBus.hasListeners(jobId)) { if (jobEventBus.hasListeners(jobId)) {
jobEventBus.emitJob(jobId, { jobEventBus.emitJob(jobId, {

View File

@ -56,7 +56,7 @@ async function createAndOpenCaseSpace(page: Page, label: string) {
await expect(titleInput).toBeVisible(); await expect(titleInput).toBeVisible();
const caseTitle = `${label}-${Date.now()}`; const caseTitle = `${label}-${Date.now()}`;
await titleInput.fill(caseTitle); await titleInput.fill(caseTitle);
await page.getByTestId('create-space-submit').click(); await page.getByTestId('space-form-submit').click();
await expect(titleInput).toHaveCount(0); await expect(titleInput).toHaveCount(0);
const caseRow = rail const caseRow = rail

View File

@ -59,7 +59,7 @@ async function createAndOpenCaseSpace(page: Page, label: string) {
await expect(titleInput).toBeVisible(); await expect(titleInput).toBeVisible();
const caseTitle = `${label}-${Date.now()}`; const caseTitle = `${label}-${Date.now()}`;
await titleInput.fill(caseTitle); await titleInput.fill(caseTitle);
await page.getByTestId('create-space-submit').click(); await page.getByTestId('space-form-submit').click();
await expect(titleInput).toHaveCount(0); await expect(titleInput).toHaveCount(0);
const caseRow = rail const caseRow = rail
@ -190,7 +190,7 @@ test('spaces foundation: rail, create case space, open detail tabs', async ({ pa
const caseTitle = `E2E案件-${Date.now()}`; const caseTitle = `E2E案件-${Date.now()}`;
await titleInput.fill(caseTitle); 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). // 5. The new case space appears in the rail (and dialog closes).
await expect(titleInput).toHaveCount(0); 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(); await expect(titleInput).toBeVisible();
const caseTitle = `E2Eモバイル-${Date.now()}`; const caseTitle = `E2Eモバイル-${Date.now()}`;
await titleInput.fill(caseTitle); await titleInput.fill(caseTitle);
await page.getByTestId('create-space-submit').click(); await page.getByTestId('space-form-submit').click();
await expect(titleInput).toHaveCount(0); await expect(titleInput).toHaveCount(0);
// A space is open: detail is now the visible full-width view, rail is hidden, // 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(); await expect(titleInput).toBeVisible();
const caseTitle = `E2E会話-${Date.now()}`; const caseTitle = `E2E会話-${Date.now()}`;
await titleInput.fill(caseTitle); await titleInput.fill(caseTitle);
await page.getByTestId('create-space-submit').click(); await page.getByTestId('space-form-submit').click();
await expect(titleInput).toHaveCount(0); await expect(titleInput).toHaveCount(0);
const caseRow = rail const caseRow = rail
@ -433,7 +433,7 @@ test('space files: icon grid, upload appears as tile, click opens preview modal'
await expect(titleInput).toBeVisible(); await expect(titleInput).toBeVisible();
const caseTitle = `E2Eファイル-${Date.now()}`; const caseTitle = `E2Eファイル-${Date.now()}`;
await titleInput.fill(caseTitle); await titleInput.fill(caseTitle);
await page.getByTestId('create-space-submit').click(); await page.getByTestId('space-form-submit').click();
await expect(titleInput).toHaveCount(0); await expect(titleInput).toHaveCount(0);
const caseRow = rail const caseRow = rail
@ -617,7 +617,7 @@ test('space output link: chat output-path link opens preview modal', async ({ pa
await expect(titleInput).toBeVisible(); await expect(titleInput).toBeVisible();
const caseTitle = `E2Eリンク-${Date.now()}`; const caseTitle = `E2Eリンク-${Date.now()}`;
await titleInput.fill(caseTitle); await titleInput.fill(caseTitle);
await page.getByTestId('create-space-submit').click(); await page.getByTestId('space-form-submit').click();
await expect(titleInput).toHaveCount(0); await expect(titleInput).toHaveCount(0);
const caseRow = rail 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(); await expect(titleInput).toBeVisible();
const caseTitle = `E2Eタブ-${Date.now()}`; const caseTitle = `E2Eタブ-${Date.now()}`;
await titleInput.fill(caseTitle); await titleInput.fill(caseTitle);
await page.getByTestId('create-space-submit').click(); await page.getByTestId('space-form-submit').click();
await expect(titleInput).toHaveCount(0); await expect(titleInput).toHaveCount(0);
const caseRow = rail const caseRow = rail
@ -839,7 +839,7 @@ test('space settings tab: AGENTS.md per-space persistence + MCP/SSH panels rende
await expect(titleInput).toBeVisible(); await expect(titleInput).toBeVisible();
const caseTitle = `E2E設定-${Date.now()}`; const caseTitle = `E2E設定-${Date.now()}`;
await titleInput.fill(caseTitle); await titleInput.fill(caseTitle);
await page.getByTestId('create-space-submit').click(); await page.getByTestId('space-form-submit').click();
await expect(titleInput).toHaveCount(0); await expect(titleInput).toHaveCount(0);
const caseRow = rail 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([]); expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
}); });
// 5. VISIBILITY — the toolbar visibility <select> (data-testid="space-chat-visibility") // 5. VISIBILITY — space chats have a FIXED scope: always visible to the space's
// drives updateLocalTask({ visibility }). Changing it to "public" persists; // members, never user-selectable. The visibility <select> was removed when the
// after a full reload + reselecting the chat, the select retains "public". // scope became fixed, and the static note chip that replaced it was removed in
// ("org" is intentionally NOT used here: the server rejects org visibility // PR #780 (the scope is documented in help instead). This test guards that no
// (400) unless the user belongs to the scoped org, and the no-auth synthetic // visibility control sneaks back into the conversation toolbar, while the
// local user has no orgs — so "public" is the env-appropriate persistence // action buttons that DO belong there (share / delete) stay present — so the
// proof. The org branch is covered by backend visibility tests.) // assertion can't pass vacuously on a broken/empty toolbar. The auth-mode twin
test('space chat VISIBILITY: change to public persists across reload', async ({ page }) => { // 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 fatalErrors = trackFatalErrors(page);
const { detail, spaceId } = await createAndOpenCaseSpace(page, 'E2E可視性'); await createAndOpenCaseSpace(page, 'E2E可視性');
const { conversation, taskId } = await startInlineChat( const { conversation } = await startInlineChat(
page, page,
'E2E: 可視性テスト用チャット。最初のメッセージ。', 'E2E: 可視性テスト用チャット。最初のメッセージ。',
); );
const visibility = conversation.getByTestId('space-chat-visibility'); // Positive anchor first: the toolbar actions are rendered, so the negative
await expect(visibility).toBeVisible(); // assertions below are checked against a live, non-empty conversation UI.
await expect(visibility).toHaveValue('private'); 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. // No visibility selector, no visibility note — scope is fixed and implicit.
// (updateLocalTask uses PATCH /api/local/tasks/:id.) await expect(conversation.getByTestId('space-chat-visibility')).toHaveCount(0);
const patchPromise = page.waitForResponse( await expect(conversation.getByTestId('space-chat-visibility-note')).toHaveCount(0);
(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');
// Reload, reopen the same space + chat via URL state (space= selects the space, // Still on the spaces view with the chat selected. The current URL scheme
// chat= opens the inline conversation), and assert the select retained public // carries space= + chat= params (the legacy page=spaces param is gone; other
// (proves server-side persistence, not just local component state). // tests in this file still assert it and are stale — see issue #782 follow-up).
await page.goto(`/ui?page=spaces&space=${spaceId}&chat=${taskId}`); await expect(page).toHaveURL(/[?&]space=[^&]+/);
await expect(detail).toBeVisible(); await expect(page).toHaveURL(/[?&]chat=\d+/);
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(&|$)/);
expect(page.url()).not.toContain('page=tasks'); expect(page.url()).not.toContain('page=tasks');
expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]); expect(fatalErrors, `fatal errors:\n${fatalErrors.join('\n')}`).toEqual([]);
}); });

View File

@ -60,6 +60,11 @@ export interface LocalTask {
waitReason?: string | null; waitReason?: string | null;
currentMovement?: string | null; currentMovement?: string | null;
currentActivity?: string | null; currentActivity?: string | null;
/** 現在の試行回数 (1 始まり)。>1 は中断→自動再試行が起きた印。 */
attempt?: number;
maxAttempts?: number;
/** 直近の中断理由コード (context_overflow / llm_error 等)。 */
abortReason?: string | null;
workerId?: string | null; workerId?: string | null;
/** /**
* Physical backend id (e.g. LiteLLM deployment) for jobs run through * Physical backend id (e.g. LiteLLM deployment) for jobs run through

View File

@ -198,7 +198,7 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
}; };
const jobStatus = task.latestJob?.status; 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. // Most-recent content-field tool with decoded content to show live.
const liveToolContent = useMemo(() => { const liveToolContent = useMemo(() => {
@ -287,6 +287,19 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
<h2 className="min-w-0 truncate text-sm font-semibold text-slate-900">{task.title}</h2> <h2 className="min-w-0 truncate text-sm font-semibold text-slate-900">{task.title}</h2>
<span className="shrink-0 text-[10px] text-slate-400 font-mono tabular-nums">#{task.id} · {task.pieceName}</span> <span className="shrink-0 text-[10px] text-slate-400 font-mono tabular-nums">#{task.id} · {task.pieceName}</span>
<div className="ml-auto flex items-center gap-1.5 flex-shrink-0"> <div className="ml-auto flex items-center gap-1.5 flex-shrink-0">
{(task.latestJob?.attempt ?? 1) > 1 && jobStatus !== 'succeeded' && (
<div
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded border border-amber-200 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/15"
title={task.latestJob?.abortReason
? t('pane.attemptBadgeTitle', { reason: task.latestJob.abortReason })
: undefined}
data-testid="attempt-badge"
>
<span className="text-[10px] font-medium text-amber-700 dark:text-amber-300">
{t('pane.attemptBadge', { attempt: task.latestJob?.attempt, max: task.latestJob?.maxAttempts ?? 3 })}
</span>
</div>
)}
{isBusy && ( {isBusy && (
<div className={`inline-flex items-center gap-1.5 px-1.5 py-0.5 rounded border ${ <div className={`inline-flex items-center gap-1.5 px-1.5 py-0.5 rounded border ${
isWaitingSubtasks isWaitingSubtasks
@ -417,7 +430,11 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /> <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" /> <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg> </svg>
{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')}
</div> </div>
)} )}
</div> </div>

View File

@ -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: '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: '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: '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: '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: '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: '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' },
]; ];
/** /**

View File

@ -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(<AskSubtasksForm config={{ subtasks: {} }} onChange={onChange} overriddenByEnv={{}} />);
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(<AskSubtasksForm config={{ subtasks: { spawnStaggerMs: 1000 } }} onChange={onChange} overriddenByEnv={{}} />);
const numbers = screen.getAllByRole('spinbutton');
await userEvent.clear(numbers[numbers.length - 1]);
expect(onChange).toHaveBeenLastCalledWith('subtasks.spawnStaggerMs', undefined);
});
});

View File

@ -29,6 +29,12 @@ export function AskSubtasksForm({ config, onChange }: SectionFormProps) {
<FieldInput type="number" value={subtasks.maxPerParent ?? ''} onChange={v => onChange('subtasks.maxPerParent', v ? Number(v) : undefined)} /> <FieldInput type="number" value={subtasks.maxPerParent ?? ''} onChange={v => onChange('subtasks.maxPerParent', v ? Number(v) : undefined)} />
<HelpText>{t('askSubtasks.maxPerParentHelp')}</HelpText> <HelpText>{t('askSubtasks.maxPerParentHelp')}</HelpText>
</div> </div>
<div>
<FieldLabel>Subtasks: Spawn Stagger (ms)</FieldLabel>
<FieldInput type="number" value={subtasks.spawnStaggerMs ?? 1000} onChange={v => onChange('subtasks.spawnStaggerMs', v === '' ? undefined : Number(v))} />
<HelpText>{t('askSubtasks.spawnStaggerHelp')}</HelpText>
</div>
</div> </div>
); );
} }

View File

@ -24,6 +24,7 @@ import { MemoryLearningForm } from './MemoryLearningForm';
import { MetricsForm } from './MetricsForm'; import { MetricsForm } from './MetricsForm';
import { ServerTlsForm } from './ServerTlsForm'; import { ServerTlsForm } from './ServerTlsForm';
import { ReflectionForm } from './ReflectionForm'; import { ReflectionForm } from './ReflectionForm';
import { SkillsQuotaForm } from './SkillsQuotaForm';
import { McpForm } from './McpForm'; import { McpForm } from './McpForm';
import { SshForm } from './SshForm'; import { SshForm } from './SshForm';
import { GatewayServerForm } from './GatewayServerForm'; import { GatewayServerForm } from './GatewayServerForm';
@ -242,6 +243,7 @@ function ConfigFormInner({ section }: ConfigFormProps) {
case 'context': return <ContextForm {...formProps} />; case 'context': return <ContextForm {...formProps} />;
case 'safety': return <SafetyForm {...formProps} />; case 'safety': return <SafetyForm {...formProps} />;
case 'reflection': return <ReflectionForm {...formProps} />; case 'reflection': return <ReflectionForm {...formProps} />;
case 'skills-quota': return <SkillsQuotaForm {...formProps} />;
case 'push-notifications': return <PushNotificationsForm {...formProps} />; case 'push-notifications': return <PushNotificationsForm {...formProps} />;
case 'auth': return <AuthForm {...formProps} />; case 'auth': return <AuthForm {...formProps} />;

View File

@ -65,6 +65,17 @@ export function ExecutionForm({ config, onChange, overriddenByEnv }: SectionForm
/> />
<HelpText>{t('execution.backoffHelp')}</HelpText> <HelpText>{t('execution.backoffHelp')}</HelpText>
</div> </div>
<section className="mt-4 pt-3 border-t border-slate-200 space-y-2">
<h3 className="text-sm font-medium text-slate-600">{t('execution.pythonPanel.title')}</h3>
<div className="rounded-md border border-hairline bg-surface px-3 py-2.5 text-xs text-slate-600 dark:text-slate-300 space-y-1.5">
<p>{t('execution.pythonPanel.intro')}</p>
<p>{t('execution.pythonPanel.preinstalled')}</p>
<p>{t('execution.pythonPanel.blocked')}</p>
<p>{t('execution.pythonPanel.howToAdd')}</p>
<p className="text-slate-500 dark:text-slate-400">{t('execution.pythonPanel.roadmap')}</p>
</div>
</section>
</div> </div>
); );
} }

View File

@ -67,6 +67,7 @@ interface GatewayConfigShape {
requestTimeoutSec?: number; requestTimeoutSec?: number;
upstreamTimeoutSec?: number; upstreamTimeoutSec?: number;
shutdownGracefulSec?: number; shutdownGracefulSec?: number;
internalTeams?: string[];
backends?: GatewayBackend[]; backends?: GatewayBackend[];
virtualKeys?: unknown[]; virtualKeys?: unknown[];
} }
@ -189,6 +190,7 @@ export function GatewayServerForm({ config, onChange }: SectionFormProps) {
const setRequestTimeout = (v: number | undefined) => onChange('gateway.requestTimeoutSec', v); const setRequestTimeout = (v: number | undefined) => onChange('gateway.requestTimeoutSec', v);
const setUpstreamTimeout = (v: number | undefined) => onChange('gateway.upstreamTimeoutSec', v); const setUpstreamTimeout = (v: number | undefined) => onChange('gateway.upstreamTimeoutSec', v);
const setShutdownGraceful = (v: number | undefined) => onChange('gateway.shutdownGracefulSec', 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 updateBackend = (i: number, field: keyof GatewayBackend, value: unknown) => {
const next = backends.map((b, idx) => (idx === i ? { ...b, [field]: value } : b)); const next = backends.map((b, idx) => (idx === i ? { ...b, [field]: value } : b));
@ -255,6 +257,20 @@ export function GatewayServerForm({ config, onChange }: SectionFormProps) {
</div> </div>
</div> </div>
<div className="border-t border-hairline pt-3">
<FieldLabel>{t('gateway.server.internalTeamsLabel')}</FieldLabel>
<FieldInput
value={(gw.internalTeams ?? []).join(', ')}
onChange={v =>
setInternalTeams(
v.split(',').map(s => s.trim()).filter(s => s.length > 0),
)
}
placeholder="orchestrator, ops"
/>
<HelpText>{t('gateway.server.internalTeamsHelp')}</HelpText>
</div>
<div className="border-t border-hairline pt-3"> <div className="border-t border-hairline pt-3">
<div className="flex items-center justify-between mb-1.5"> <div className="flex items-center justify-between mb-1.5">
<h3 className="text-sm font-medium text-slate-700">Backends</h3> <h3 className="text-sm font-medium text-slate-700">Backends</h3>

View File

@ -123,4 +123,19 @@ describe('LlmWorkersForm', () => {
}); });
expect(screen.queryByText('llmWorkers.selfLoopWarn')).toBeNull(); 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();
});
}); });

View File

@ -24,6 +24,9 @@ interface LlmWorker {
maxConcurrency?: number; maxConcurrency?: number;
enabled?: boolean; enabled?: boolean;
vlm?: boolean; vlm?: boolean;
/** llama.cpp の prompt 評価進捗return_progressを要求。llama.cpp 系専用のオプトイン。 */
returnProgress?: boolean;
healthcheckIntervalSeconds?: number;
/** /**
* Phase 1 compat: older `provider.workers[].proxy: true` rows are * Phase 1 compat: older `provider.workers[].proxy: true` rows are
* mapped to `connectionType: aao_gateway` by the normalizer. We * mapped to `connectionType: aao_gateway` by the normalizer. We
@ -35,6 +38,7 @@ interface LlmWorker {
interface LlmConfigShape { interface LlmConfigShape {
timeoutMinutes?: number; timeoutMinutes?: number;
maxStreamMinutes?: number;
retry?: { retry?: {
maxAttempts?: number; maxAttempts?: number;
backoffMs?: number[]; backoffMs?: number[];
@ -285,6 +289,16 @@ export function LlmWorkersForm({ config, onChange, overriddenByEnv }: SectionFor
/> />
</div> </div>
<div>
<FieldLabel>{t('llmWorkers.healthcheckInterval')}</FieldLabel>
<FieldInput
type="number"
value={w.healthcheckIntervalSeconds ?? ''}
onChange={v => updateWorker(i, { healthcheckIntervalSeconds: v === '' ? undefined : Number(v) })}
/>
<HelpText>{t('llmWorkers.healthcheckIntervalHelp')}</HelpText>
</div>
<div className="flex items-center gap-5 pt-5 flex-wrap"> <div className="flex items-center gap-5 pt-5 flex-wrap">
<label className="flex items-center gap-2 text-sm text-slate-600 cursor-pointer"> <label className="flex items-center gap-2 text-sm text-slate-600 cursor-pointer">
<input <input
@ -307,6 +321,18 @@ export function LlmWorkersForm({ config, onChange, overriddenByEnv }: SectionFor
/> />
VLM VLM
</label> </label>
<label
className="flex items-center gap-2 text-sm text-slate-600 cursor-pointer"
title={t('llmWorkers.returnProgressTitle')}
>
<input
type="checkbox"
checked={w.returnProgress === true}
onChange={e => updateWorker(i, { returnProgress: e.target.checked || undefined })}
className="rounded"
/>
{t('llmWorkers.returnProgress')}
</label>
</div> </div>
</div> </div>
</div> </div>
@ -335,6 +361,16 @@ export function LlmWorkersForm({ config, onChange, overriddenByEnv }: SectionFor
<HelpText>{t('llmWorkers.timeoutHelp')}</HelpText> <HelpText>{t('llmWorkers.timeoutHelp')}</HelpText>
</div> </div>
<div>
<FieldLabel>Max Stream (minutes)</FieldLabel>
<FieldInput
type="number"
value={llm.maxStreamMinutes ?? ''}
onChange={v => onChange('llm.maxStreamMinutes', v === '' ? undefined : Number(v))}
/>
<HelpText>{t('llmWorkers.maxStreamHelp')}</HelpText>
</div>
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200"> <h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">
{t('llmWorkers.retryTitle')} {t('llmWorkers.retryTitle')}
</h3> </h3>

View File

@ -11,7 +11,7 @@
*/ */
import '../../test/dom-setup'; import '../../test/dom-setup';
import { describe, it, expect, vi } from 'vitest'; 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 userEvent from '@testing-library/user-event';
import { renderWithProviders, renderStatefulForm } from '../../test/render-helpers'; import { renderWithProviders, renderStatefulForm } from '../../test/render-helpers';
import { SafetyForm } from './SafetyForm'; import { SafetyForm } from './SafetyForm';
@ -80,4 +80,21 @@ describe('SafetyForm', () => {
await userEvent.selectOptions(screen.getByRole('combobox'), 'always'); await userEvent.selectOptions(screen.getByRole('combobox'), 'always');
expect(onChange).toHaveBeenCalledWith('safety.bashSandbox', '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);
});
}); });

View File

@ -129,6 +129,13 @@ export function SafetyForm({ config, onChange }: SectionFormProps) {
onChange={v => onChange('safety.historySummarization.preserveRecentBudget', Number(v))} /> onChange={v => onChange('safety.historySummarization.preserveRecentBudget', Number(v))} />
<HelpText>{t('safety.preserveRecentHelp')}</HelpText> <HelpText>{t('safety.preserveRecentHelp')}</HelpText>
</div> </div>
<div>
<FieldLabel>Reserve Cap (tokens)</FieldLabel>
<FieldInput type="number" value={historySummarization.reserveCapTokens ?? 32000}
onChange={v => onChange('safety.historySummarization.reserveCapTokens', v === '' ? undefined : Number(v))} />
<HelpText>{t('safety.reserveCapHelp')}</HelpText>
</div>
</div> </div>
); );
} }

View File

@ -80,4 +80,11 @@ describe('ServerTlsForm', () => {
await user.click(enableCheckbox); await user.click(enableCheckbox);
expect(onChange).toHaveBeenCalledWith('server.tls.enabled', true); 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');
});
}); });

View File

@ -11,7 +11,7 @@ import type { SectionFormProps } from './types';
* httpRedirectPort, redirectHost, selfSignedHosts. * httpRedirectPort, redirectHost, selfSignedHosts.
* *
* Fields intentionally omitted from the UI (left to config.yaml): * 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 * uses setNestedValue which does a shallow-merge, so unedited fields are
* preserved on save automatically. * preserved on save automatically.
* *
@ -111,6 +111,20 @@ export function ServerTlsForm({ config, onChange }: SectionFormProps) {
<HelpText>{t('serverTls.hstsHelp')}</HelpText> <HelpText>{t('serverTls.hstsHelp')}</HelpText>
</div> </div>
{/* Minimum TLS protocol version */}
<div>
<FieldLabel>{t('serverTls.minVersion')}</FieldLabel>
<select
value={tls.minVersion ?? 'TLSv1.2'}
onChange={e => onChange('server.tls.minVersion', e.target.value)}
className="w-full h-8 px-2 text-[13px] border border-hairline rounded-md bg-canvas focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none transition-shadow"
>
<option value="TLSv1.2">TLSv1.2</option>
<option value="TLSv1.3">TLSv1.3</option>
</select>
<HelpText>{t('serverTls.minVersionHelp')}</HelpText>
</div>
{/* HTTP redirect port(s) — accepts a single port or a comma-separated list */} {/* HTTP redirect port(s) — accepts a single port or a comma-separated list */}
<div> <div>
<FieldLabel>{t('serverTls.httpRedirectPort')}</FieldLabel> <FieldLabel>{t('serverTls.httpRedirectPort')}</FieldLabel>

View File

@ -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(<SettingsSidebar isAdmin onSelectSection={onSelect} />);
// 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(<SettingsSidebar isAdmin onSelectSection={onSelect} />);
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(<SettingsSidebar isAdmin={false} onSelectSection={onSelect} />);
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();
});
});

View File

@ -1,4 +1,6 @@
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { buildSettingsSearchIndex, searchSettings } from './settingsSearchIndex';
interface SettingsSidebarProps { interface SettingsSidebarProps {
activeSection?: string; activeSection?: string;
@ -66,6 +68,7 @@ export const CONFIG_GROUPS = [
{ id: 'context', label: 'Context' }, { id: 'context', label: 'Context' },
{ id: 'safety', label: 'Safety' }, { id: 'safety', label: 'Safety' },
{ id: 'reflection', label: 'Reflection' }, { 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) { export function SettingsSidebar({ activeSection, onSelectSection, isAdmin }: SettingsSidebarProps) {
const { t } = useTranslation('settings'); const { t } = useTranslation('settings');
const [query, setQuery] = useState('');
const visibleGroups = CONFIG_GROUPS.filter(g => isAdmin || !('adminOnly' in g) || !g.adminOnly); 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 ( return (
<div className="h-full overflow-y-auto border-r border-hairline bg-canvas p-3"> <div className="h-full overflow-y-auto border-r border-hairline bg-canvas p-3">
{visibleGroups.map(group => ( <input
type="search"
value={query}
onChange={e => 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 ? (
<div data-testid="settings-search-results">
{results.length === 0 ? (
<div className="px-2 py-1 text-xs text-slate-400">{t('search.noResults')}</div>
) : (
results.map(r => {
const active = activeSection === r.sectionId;
return (
<button
key={r.sectionId}
data-testid={`settings-search-result-${r.sectionId}`}
onClick={() => { onSelectSection(r.sectionId); setQuery(''); }}
className={`block w-full text-left px-2 py-1 rounded text-xs mb-0.5 transition-colors ${
active ? 'bg-accent-soft text-accent font-semibold' : 'text-slate-700 hover:bg-surface'
}`}
>
<span>{labelFor(r.sectionId, r.label)}</span>
<span className="ml-1 text-2xs text-slate-400">· {r.group}</span>
</button>
);
})
)}
</div>
) : (
visibleGroups.map(group => (
<div key={group.label} className="mb-3"> <div key={group.label} className="mb-3">
<div className="section-label px-2 py-1"> <div className="section-label px-2 py-1">
{group.label} {group.label}
@ -154,7 +208,7 @@ export function SettingsSidebar({ activeSection, onSelectSection, isAdmin }: Set
</button> </button>
))} ))}
</div> </div>
))} )))}
</div> </div>
); );
} }

View File

@ -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(<SkillsQuotaForm config={{ skills: {} }} onChange={onChange} overriddenByEnv={{}} />);
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(<SkillsQuotaForm config={{ skills: { maxIndexChars: 2000 } }} onChange={onChange} overriddenByEnv={{}} />);
const numbers = screen.getAllByRole('spinbutton');
await userEvent.clear(numbers[4]);
expect(onChange).toHaveBeenLastCalledWith('skills.maxIndexChars', undefined);
});
});

View File

@ -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 (
<div className="space-y-5">
<h2 className="text-base font-semibold text-slate-800">{t('skillsQuota.title')}</h2>
<HelpText>{t('skillsQuota.intro')}</HelpText>
<div>
<FieldLabel>Max Per User</FieldLabel>
<FieldInput type="number" value={skills.maxPerUser ?? 50}
onChange={v => onChange('skills.maxPerUser', v === '' ? undefined : Number(v))} />
<HelpText>{t('skillsQuota.maxPerUserHelp')}</HelpText>
</div>
<div>
<FieldLabel>Max Skill Size (KB)</FieldLabel>
<FieldInput type="number" value={skills.maxSkillSizeKb ?? 64}
onChange={v => onChange('skills.maxSkillSizeKb', v === '' ? undefined : Number(v))} />
<HelpText>{t('skillsQuota.maxSkillSizeHelp')}</HelpText>
</div>
<div>
<FieldLabel>Max Total Size (MB)</FieldLabel>
<FieldInput type="number" value={skills.maxTotalSizeMb ?? 5}
onChange={v => onChange('skills.maxTotalSizeMb', v === '' ? undefined : Number(v))} />
<HelpText>{t('skillsQuota.maxTotalSizeHelp')}</HelpText>
</div>
<div>
<FieldLabel>Max System Skills</FieldLabel>
<FieldInput type="number" value={skills.maxSystemSkills ?? 100}
onChange={v => onChange('skills.maxSystemSkills', v === '' ? undefined : Number(v))} />
<HelpText>{t('skillsQuota.maxSystemSkillsHelp')}</HelpText>
</div>
<div>
<FieldLabel>Max Index Chars</FieldLabel>
<FieldInput type="number" value={skills.maxIndexChars ?? 2000}
onChange={v => onChange('skills.maxIndexChars', v === '' ? undefined : Number(v))} />
<HelpText>{t('skillsQuota.maxIndexCharsHelp')}</HelpText>
</div>
</div>
);
}

View File

@ -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(<ToolsMediaForm config={{ tools: {} }} onChange={onChange} overriddenByEnv={{}} />);
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));
});
});

View File

@ -116,6 +116,12 @@ export function ToolsMediaForm({ config, onChange }: SectionFormProps) {
onChange={v => onChange('tools.officePptxMaxUncompressedMb', Number(v))} /> onChange={v => onChange('tools.officePptxMaxUncompressedMb', Number(v))} />
<HelpText>{t('tools.office.pptxUncompressedHelp')}</HelpText> <HelpText>{t('tools.office.pptxUncompressedHelp')}</HelpText>
</div> </div>
<div>
<FieldLabel>{t('tools.office.msgLabel')}</FieldLabel>
<FieldInput type="number" value={tools.officeMsgMaxSizeMb ?? 25}
onChange={v => onChange('tools.officeMsgMaxSizeMb', Number(v))} />
<HelpText>{t('tools.office.msgHelp')}</HelpText>
</div>
</section> </section>
<section className="space-y-5 pt-2 border-t border-hairline"> <section className="space-y-5 pt-2 border-t border-hairline">

View File

@ -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);
}
});
});

View File

@ -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<string, string> = {
// 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));
});
}

View File

@ -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 — 名前が食い違ったスキルを削除・編集できない問題を修正 ## 2026-07-08 — 名前が食い違ったスキルを削除・編集できない問題を修正
スキルの表示名SKILL.md の frontmatter `name:`と保存フォルダ名が食い違っていると、一覧には表示されるのに削除・編集だけが「Skill not found」で失敗し続ける問題を修正しました。表示名から実体を探すようになったため、既存の食い違いスキルもそのまま削除・編集できます。あわせて、InstallSkill やスキル作成時に frontmatter の `name:` と指定名の一致を必須にして、新たな食い違いが生まれないようにしました。削除・保存に失敗したときのエラーはボタンのすぐ近くに表示されます。 スキルの表示名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回超過」で突然中断される問題を修正 ## 2026-07-08 — タスクが開始直後に「200回超過」で突然中断される問題を修正
会話がコンテキスト上限のごく近くまで膨らんだとき、まれにエージェントが「同じリクエストの送信 → 送信前ブロック」を1回あたり十数ミリ秒で延々と繰り返し、実際には何も作業しないまま数分で「max iterations (200) 超過」として中断されることがありました。空回りはツールを使わないため会話タブには何も表示されず、タスクが始まった直後に突然打ち切られたように見えていました。原因はコンテキストサイズの見積もりが送信前チェックとガードで 128 トークンだけずれていたことで、見積もりを統一し、さらに「何も削れないのに再送する」経路自体を塞ぎました。今後この状況では空回りせず、コンテキスト超過として即座に次のステップへの強制遷移(または中断)になります。あわせて、これまで無制限だった Grep の結果出力にも Read や Bash と同じ自動切り詰めを入れ、巨大な検索結果が一撃で会話を埋める事故を防ぎます。 会話がコンテキスト上限のごく近くまで膨らんだとき、まれにエージェントが「同じリクエストの送信 → 送信前ブロック」を1回あたり十数ミリ秒で延々と繰り返し、実際には何も作業しないまま数分で「max iterations (200) 超過」として中断されることがありました。空回りはツールを使わないため会話タブには何も表示されず、タスクが始まった直後に突然打ち切られたように見えていました。原因はコンテキストサイズの見積もりが送信前チェックとガードで 128 トークンだけずれていたことで、見積もりを統一し、さらに「何も削れないのに再送する」経路自体を塞ぎました。今後この状況では空回りせず、コンテキスト超過として即座に次のステップへの強制遷移(または中断)になります。あわせて、これまで無制限だった Grep の結果出力にも Read や Bash と同じ自動切り詰めを入れ、巨大な検索結果が一撃で会話を埋める事故を防ぎます。
## 2026-07-08 — 「いま何を待っているか」が見えるようになりましたLLM 実行の可視化)
エージェントが LLM の応答を待っている間、チャットが無言になる問題を解消しました。チャット下部のスピナーに「LLM の応答を待っています…」「思考中…文字」「LLM リトライ待ち 2/3HTTP 500」「コンテキストを整理中…」など、いま実際に起きていることが表示されます。あわせて、①中断メッセージの 1 行目に失敗の分類context_overflow / gateway_timeout など)と発生ステップが明記されるようになり、②自動再試行中はヘッダーに「試行 2/3」バッジが出るようになり、③トレースタブの LLM 行に所要時間・リトライ回数・思考量・担当バックエンド・エラー分類が記録されるようになりました。llama.cppllama-server利用時は、設定 → LLM Workers の「プロンプト処理進捗」を有効にすると長いプロンプト処理中に進捗バーも出ます。詳しくは[実行中のタスクを見る・介入する](03-running.md)と[トラブルシューティング](08-troubleshooting.md)へ。
## 2026-07-08 — 会話画面を縦に広く、入力欄をカード型に刷新 ## 2026-07-08 — 会話画面を縦に広く、入力欄をカード型に刷新
会話画面の縦方向の使える範囲を広げました。ヘッダー部分の行数を減らすため、公開範囲の表示ワークスペース内のチャットは常にメンバーに公開されるという前提は撤去し、継続・共有・削除ボタンはタブ行の右端にまとめています。入力欄はカード型のコンポーザーに刷新し、書いた内容に応じて高さが自動で伸びます1〜8 行程度)。コンテキストの残量ゲージはツールバー右下に常時表示し、使用率が 70% を超えるとパーセント表示も出るようになりました。 会話画面の縦方向の使える範囲を広げました。ヘッダー部分の行数を減らすため、公開範囲の表示ワークスペース内のチャットは常にメンバーに公開されるという前提は撤去し、継続・共有・削除ボタンはタブ行の右端にまとめています。入力欄はカード型のコンポーザーに刷新し、書いた内容に応じて高さが自動で伸びます1〜8 行程度)。コンテキストの残量ゲージはツールバー右下に常時表示し、使用率が 70% を超えるとパーセント表示も出るようになりました。
@ -118,7 +130,6 @@ A2A 連携を通じて接続する外部エージェントに、委任ごとの
## 2026-07-03 — Shift_JIS などの日本語テキストが「バイナリ」と誤判定されて読めない不具合を修正 ## 2026-07-03 — Shift_JIS などの日本語テキストが「バイナリ」と誤判定されて読めない不具合を修正
Windows で保存した日本語の `.txt` や、Excel から出した Shift_JISCP932の CSV を Read で開こうとすると、中身は読めるテキストなのに「バイナリなので開けません」と拒否されていました。文字コードの判定が UTF-8 しか想定しておらず、UTF-8 として解釈できないものをすべてバイナリと見なしていたためです。Shift_JIS・EUC-JP などを自動で判別し、UTF-8 に変換して読めるようにしました。Edit で書き換えた場合は元の文字コードのまま保存します。あわせて Grep が画像などのバイナリファイルを拾ってしまい、検索結果にバイナリの断片が紛れ込む問題も直しました(対象フォルダにバイナリがあっても自動でスキップします)(→[ツール](./16-tools.md))。 Windows で保存した日本語の `.txt` や、Excel から出した Shift_JISCP932の CSV を Read で開こうとすると、中身は読めるテキストなのに「バイナリなので開けません」と拒否されていました。文字コードの判定が UTF-8 しか想定しておらず、UTF-8 として解釈できないものをすべてバイナリと見なしていたためです。Shift_JIS・EUC-JP などを自動で判別し、UTF-8 に変換して読めるようにしました。Edit で書き換えた場合は元の文字コードのまま保存します。あわせて Grep が画像などのバイナリファイルを拾ってしまい、検索結果にバイナリの断片が紛れ込む問題も直しました(対象フォルダにバイナリがあっても自動でスキップします)(→[ツール](./16-tools.md))。
## 2026-07-03 — ワークスペースに登録した SSH 接続でコンソールが開けない不具合を修正 ## 2026-07-03 — ワークスペースに登録した SSH 接続でコンソールが開けない不具合を修正
ワークスペースに登録した SSH 接続に対して**コンソール(対話シェル)を開こうとすると、接続が正しく登録されているのに `access denied (space_mismatch)` で弾かれる**不具合を修正しました。原因はコンソールを開く経路が、そのタスクの所属ワークスペース(スペース)を権限判定に渡していなかったことです。一発実行の `SshExec` は影響を受けていませんでしたが、コンソール経路(エージェントの `SshConsoleEnsure`、および「コンソール」タブから手動で開く操作・再接続)はすべて対象でした。個人ワークスペースのタスクでも正しく開けるようにしています。あわせて、公開・組織共有などで**タスクは見えてもそのワークスペースのメンバーではない人**が、コンソールからそのワークスペースの SSH 接続を使えてしまわないよう、実行時にメンバーであることを確認するようにしました(→[SSH 接続](./14-ssh.md))。 ワークスペースに登録した SSH 接続に対して**コンソール(対話シェル)を開こうとすると、接続が正しく登録されているのに `access denied (space_mismatch)` で弾かれる**不具合を修正しました。原因はコンソールを開く経路が、そのタスクの所属ワークスペース(スペース)を権限判定に渡していなかったことです。一発実行の `SshExec` は影響を受けていませんでしたが、コンソール経路(エージェントの `SshConsoleEnsure`、および「コンソール」タブから手動で開く操作・再接続)はすべて対象でした。個人ワークスペースのタスクでも正しく開けるようにしています。あわせて、公開・組織共有などで**タスクは見えてもそのワークスペースのメンバーではない人**が、コンソールからそのワークスペースの SSH 接続を使えてしまわないよう、実行時にメンバーであることを確認するようにしました(→[SSH 接続](./14-ssh.md))。

View File

@ -20,6 +20,19 @@ keywords: [チャット, ストリーミング, ツールコール, 割り込み
実行中はヘッダーに `running`(サブタスク待ちのときは `subtasks`)のバッジが点滅します。 実行中はヘッダーに `running`(サブタスク待ちのときは `subtasks`)のバッジが点滅します。
## 「いま何を待っているか」のライブ表示
画面に何も流れていない時間も、エージェントが止まっているとは限りません。LLM の応答待ちの間、チャット下部のスピナーに現在の状態が具体的に表示されます。
- **LLM の応答を待っています…**: リクエストを送信して最初のトークンを待っている状態
- **思考中…(〇〇文字)**: thinking 対応モデルが内部推論を出力中。文字数が増えていれば正常に動いています
- **LLM リトライ待ち 2/3HTTP 500**: バックエンドが一時エラーを返し、自動リトライの待機中
- **コンテキストを整理中…**: 会話履歴が上限に近づき、圧縮や要約を実行中
同じ内容はタスク一覧の「現在の作業」欄にも反映されます。中断後に自動再試行が走っている場合は、ヘッダーに **試行 2/3** のバッジが出ます(ホバーで前回の中断理由を表示)。
また llama.cppllama-serverのバックエンドでは、設定 → LLM Workers の「プロンプト処理進捗」を有効にすると、長いプロンプトの処理中に進捗バー(`Processing %`)が出るようになります。
## 進捗タブで振り返る ## 進捗タブで振り返る
詳細パネル上部のタブ(概要 / 進捗 / ファイル / トレース、ブラウザ・SSH は接続中のみ)のうち、**進捗** タブに時系列のビューがあります。各ステップで何のツールをどんな引数で呼び、何を生成したかを上から下へ追え、その中の **タイムライン** で movement ごとにまとまった流れも確認できます(別タブではなく、進捗タブ内のセクションです)。 詳細パネル上部のタブ(概要 / 進捗 / ファイル / トレース、ブラウザ・SSH は接続中のみ)のうち、**進捗** タブに時系列のビューがあります。各ステップで何のツールをどんな引数で呼び、何を生成したかを上から下へ追え、その中の **タイムライン** で movement ごとにまとまった流れも確認できます(別タブではなく、進捗タブ内のセクションです)。

View File

@ -26,7 +26,7 @@ keywords: [トラブル, エラー, 失敗, waiting, スタック, 再試行]
## タスクが失敗したfailed/ 再試行について ## タスクが失敗した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で、保存済みのログイン情報が期限切れになっているとこのメッセージが返ります。 タスク中のブラウザ操作BrowseWebで、保存済みのログイン情報が期限切れになっているとこのメッセージが返ります。

View File

@ -18,6 +18,10 @@ YAML キーは **スネークケース** (`max_concurrency`)、コード内は *
- **右フォーム** — 選択したセクションの設定項目。ほとんどはその場で編集するインラインフォーム - **右フォーム** — 選択したセクションの設定項目。ほとんどはその場で編集するインラインフォーム
- `User` グループ以外は **admin 専用**。一般ユーザーには表示されない (`adminOnly`) - `User` グループ以外は **admin 専用**。一般ユーザーには表示されない (`adminOnly`)
## 設定を検索する
サイドバー上部の検索ボックスに、探している設定のキーワードを入れると、該当するセクションだけが一覧に絞り込まれます。項目名だけでなく、その設定の中身(`config.yaml` のキー名や概念でも引けます。たとえば「デッドライン」「reserve_cap」で **Safety**、「python」で **Execution**、「TLS」「証明書」で **HTTPS / TLS** に飛べます。複数の語をスペースで区切ると、そのすべてを含むセクションに絞り込みますAND 検索)。結果をクリックするとそのセクションが開きます。表示されるのは、あなたが開ける権限のあるセクションだけです。
## セクション一覧 ## セクション一覧
サイドバーのグループとセクションは以下の通りです。 サイドバーのグループとセクションは以下の通りです。
@ -41,7 +45,7 @@ YAML キーは **スネークケース** (`max_concurrency`)、コード内は *
|-----------|------| |-----------|------|
| Branding | アプリ名・ロゴ・アクセント色などの見た目 | | Branding | アプリ名・ロゴ・アクセント色などの見た目 |
| Paths & Storage | `storage.*` の作業ディレクトリ・ワークスペース保存先・アップロード上限 | | Paths & Storage | `storage.*` の作業ディレクトリ・ワークスペース保存先・アップロード上限 |
| Execution | `concurrency` (全 worker 合計の並列度)・`max_movements``retry.*` | | Execution | `concurrency` (全 worker 合計の並列度)・`max_movements``retry.*`。末尾に **Python サンドボックスで使えるパッケージ**の案内(読み取り専用)あり |
| Authentication | ログイン方式Google / Gitea OAuth・ローカル認証の有効化と設定 | | Authentication | ログイン方式Google / Gitea OAuth・ローカル認証の有効化と設定 |
| 🏢 Organizations | ローカル組織の作成・メンバー管理(`org` 公開範囲の単位) | | 🏢 Organizations | ローカル組織の作成・メンバー管理(`org` 公開範囲の単位) |
| Web Push (Server) | Web Push 配信のサーバー側設定VAPID キーなど) | | 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) | | 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) | | Reflection | タスク完了後の自動学習。詳細は [Reflection の調整](#reflection) |
| スキルのクォータ | 1 ユーザーあたりのスキル数・スキルサイズ・合計サイズなどの上限(`skills.*` |
### Tools グループ (admin) ### Tools グループ (admin)

View File

@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { reduceDelegateStreams } from './useJobStream'; import { reduceDelegateStreams, reduceLlmState } from './useJobStream';
describe('reduceDelegateStreams originJobId contract', () => { describe('reduceDelegateStreams originJobId contract', () => {
it('lifecycle の originJobId を run に紐づける', () => { it('lifecycle の originJobId を run に紐づける', () => {
@ -65,3 +65,34 @@ describe('reduceDelegateStreams originJobId contract', () => {
expect(s['r3'].originJobId).toBe('sub2'); 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();
});
});

View File

@ -25,12 +25,31 @@ export interface DelegateStreamEntry {
originJobId?: string | null; 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 { export interface JobStreamState {
promptProgress: PromptProgressState | null; promptProgress: PromptProgressState | null;
streamingText: string; streamingText: string;
toolCallStream: Record<string, ToolCallStreamEntry>; toolCallStream: Record<string, ToolCallStreamEntry>;
connected: boolean; connected: boolean;
delegateStreams: Record<string, DelegateStreamEntry>; delegateStreams: Record<string, DelegateStreamEntry>;
llmState: LlmStateEntry | null;
} }
function emptyEntry(delegateRunId: string): DelegateStreamEntry { 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 マップへ畳み込む純関数。 */ /** SSE 1 イベントを delegateStreams マップへ畳み込む純関数。 */
export function reduceDelegateStreams( export function reduceDelegateStreams(
prev: Record<string, DelegateStreamEntry>, prev: Record<string, DelegateStreamEntry>,
@ -75,6 +117,7 @@ export function useJobStream(taskId: number | null, jobStatus: string | null | u
const [toolCallStream, setToolCallStream] = useState<Record<string, ToolCallStreamEntry>>({}); const [toolCallStream, setToolCallStream] = useState<Record<string, ToolCallStreamEntry>>({});
const [connected, setConnected] = useState(false); const [connected, setConnected] = useState(false);
const [delegateStreams, setDelegateStreams] = useState<Record<string, DelegateStreamEntry>>({}); const [delegateStreams, setDelegateStreams] = useState<Record<string, DelegateStreamEntry>>({});
const [llmState, setLlmState] = useState<LlmStateEntry | null>(null);
const esRef = useRef<EventSource | null>(null); const esRef = useRef<EventSource | null>(null);
const isActive = jobStatus === 'running' || jobStatus === 'dispatching'; const isActive = jobStatus === 'running' || jobStatus === 'dispatching';
@ -94,6 +137,7 @@ export function useJobStream(taskId: number | null, jobStatus: string | null | u
setStreamingText(''); setStreamingText('');
setToolCallStream({}); setToolCallStream({});
setDelegateStreams({}); setDelegateStreams({});
setLlmState(null);
return; return;
} }
@ -115,12 +159,21 @@ export function useJobStream(taskId: number | null, jobStatus: string | null | u
timeMs: data.timeMs ?? 0, timeMs: data.timeMs ?? 0,
}); });
break; break;
case 'llm_state':
// 直前の prompt_progress を残すと、ChatPane の表示優先順位
// (promptProgress > llmState) で新しいライブ状態が古い進捗バーに
// 隠れる (Codex P2)。状態遷移が届いた時点で進捗は陳腐化している。
setPromptProgress(null);
setLlmState(reduceLlmState(data, Date.now()));
break;
case 'text_delta': case 'text_delta':
setPromptProgress(null); setPromptProgress(null);
setLlmState(null);
setStreamingText(prev => prev + (data.text ?? '')); setStreamingText(prev => prev + (data.text ?? ''));
break; break;
case 'tool_use_delta': case 'tool_use_delta':
setPromptProgress(null); setPromptProgress(null);
setLlmState(null);
setToolCallStream(prev => { setToolCallStream(prev => {
const callId = data.callId ?? ''; const callId = data.callId ?? '';
const existing = prev[callId]; const existing = prev[callId];
@ -138,6 +191,7 @@ export function useJobStream(taskId: number | null, jobStatus: string | null | u
case 'tool_use': case 'tool_use':
setStreamingText(''); setStreamingText('');
setPromptProgress(null); setPromptProgress(null);
setLlmState(null);
setToolCallStream(prev => { setToolCallStream(prev => {
if (!data.callId || !(data.callId in prev)) return prev; if (!data.callId || !(data.callId in prev)) return prev;
const next = { ...prev }; const next = { ...prev };
@ -155,6 +209,7 @@ export function useJobStream(taskId: number | null, jobStatus: string | null | u
setPromptProgress(null); setPromptProgress(null);
setToolCallStream({}); setToolCallStream({});
setDelegateStreams({}); setDelegateStreams({});
setLlmState(null);
cleanup(); cleanup();
break; break;
} }
@ -169,5 +224,5 @@ export function useJobStream(taskId: number | null, jobStatus: string | null | u
return cleanup; return cleanup;
}, [taskId, isActive, cleanup]); }, [taskId, isActive, cleanup]);
return { promptProgress, streamingText, toolCallStream, connected, delegateStreams }; return { promptProgress, streamingText, toolCallStream, connected, delegateStreams, llmState };
} }

View File

@ -22,6 +22,12 @@
"generating": "{{name}} generating…", "generating": "{{name}} generating…",
"processing": "Processing", "processing": "Processing",
"agentResponding": "Agent is generating a response...", "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", "sendFailed": "Failed to send",
"interjectHint": "Agent running — send a message to give it instructions", "interjectHint": "Agent running — send a message to give it instructions",
"agentRunningWait": "The agent is running your task. Please wait a moment.", "agentRunningWait": "The agent is running your task. Please wait a moment.",

View File

@ -61,7 +61,9 @@
"pptxLabel": "ReadPPTX max size", "pptxLabel": "ReadPPTX max size",
"pptxHelp": "Maximum .pptx file size accepted by ReadPPTX (default: 50 MB)", "pptxHelp": "Maximum .pptx file size accepted by ReadPPTX (default: 50 MB)",
"pptxUncompressedLabel": "ReadPPTX uncompressed size limit", "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": { "uploads": {
"sectionHelp": "Request body limit for the upload API from the UI (MB)", "sectionHelp": "Request body limit for the upload API from the UI (MB)",
@ -254,6 +256,8 @@
"enable": "Enable Gateway", "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.", "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:", "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.", "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.", "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.", "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.", "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.", "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", "maxConcurrency": "Max concurrency",
"healthcheckInterval": "Healthcheck interval (sec)",
"healthcheckIntervalHelp": "How often to health-check this worker (seconds). Leave blank for the default.",
"enabled": "Enabled", "enabled": "Enabled",
"vlmTitle": "When the model supports VLM, ReadImage uses the worker's own model", "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", "addWorker": "+ Add worker",
"globalTitle": "Global LLM Settings", "globalTitle": "Global LLM Settings",
"timeoutHelp": "Timeout for an LLM request (minutes). Default: 10", "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)", "retryTitle": "Retry (per-call HTTP)",
"maxAttemptsHelp": "Maximum attempts for a single LLM API call", "maxAttemptsHelp": "Maximum attempts for a single LLM API call",
"backoffHelp": "Wait time between retries (ms). Consumed in array order.", "backoffHelp": "Wait time between retries (ms). Consumed in array order.",
@ -581,7 +590,8 @@
"historyEnable": "Enable automatic history summarization", "historyEnable": "Enable automatic history summarization",
"historyEnableHelp": "Automatically summarize old conversation history to save context. Default: enabled", "historyEnableHelp": "Automatically summarize old conversation history to save context. Default: enabled",
"tailTurnsHelp": "Number of recent assistant+tool turns to always keep. Default: 2", "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": { "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.", "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", "concurrencyHelp": "Number of jobs that can run concurrently",
"maxMovementsHelp": "Maximum number of movements per job", "maxMovementsHelp": "Maximum number of movements per job",
"maxAttemptsHelp": "Maximum retry attempts on job failure. Default: 3", "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": { "context": {
"limitPlaceholder": "auto (fetched from the Ollama API)", "limitPlaceholder": "auto (fetched from the Ollama API)",
@ -739,7 +757,17 @@
"askSubtasks": { "askSubtasks": {
"askMaxHelp": "Limit of ASKs (questions to the user) per job", "askMaxHelp": "Limit of ASKs (questions to the user) per job",
"maxDepthHelp": "Maximum nesting depth for subtasks", "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": { "namespaceEditor": {
"addAriaDisabled": "Adding new entries is disabled", "addAriaDisabled": "Adding new entries is disabled",
@ -776,6 +804,12 @@
"hsts": "Send HSTS (advanced — off by default)", "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.", "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", "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." "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"
} }
} }

View File

@ -22,6 +22,12 @@
"generating": "{{name}} 生成中…", "generating": "{{name}} 生成中…",
"processing": "処理中…", "processing": "処理中…",
"agentResponding": "エージェントが応答を生成中...", "agentResponding": "エージェントが応答を生成中...",
"llmWaiting": "LLM の応答を待っています…",
"llmThinking": "思考中… ({{chars}} 文字)",
"llmRetrying": "LLM リトライ待ち {{attempt}}/{{max}} ({{reason}})",
"llmRecovering": "コンテキストを整理中… ({{stage}})",
"attemptBadge": "試行 {{attempt}}/{{max}}",
"attemptBadgeTitle": "前回の中断理由: {{reason}}",
"sendFailed": "送信に失敗しました", "sendFailed": "送信に失敗しました",
"interjectHint": "エージェント実行中 — メッセージで指示を送れます", "interjectHint": "エージェント実行中 — メッセージで指示を送れます",
"agentRunningWait": "エージェントがタスクを実行中です。少々お待ちください。", "agentRunningWait": "エージェントがタスクを実行中です。少々お待ちください。",

View File

@ -61,7 +61,9 @@
"pptxLabel": "ReadPPTX 最大サイズ", "pptxLabel": "ReadPPTX 最大サイズ",
"pptxHelp": "ReadPPTX が受け付ける .pptx ファイルの最大サイズ(デフォルト: 50 MB", "pptxHelp": "ReadPPTX が受け付ける .pptx ファイルの最大サイズ(デフォルト: 50 MB",
"pptxUncompressedLabel": "ReadPPTX 展開後サイズ上限", "pptxUncompressedLabel": "ReadPPTX 展開後サイズ上限",
"pptxUncompressedHelp": "PPTX の ZIP 展開後の合計サイズ上限ZIP bomb 検知用、デフォルト: 200 MB" "pptxUncompressedHelp": "PPTX の ZIP 展開後の合計サイズ上限ZIP bomb 検知用、デフォルト: 200 MB",
"msgLabel": "ReadMsg 最大サイズ",
"msgHelp": "ReadMsg が受け付ける .msgOutlook メール)の最大ファイルサイズ(デフォルト: 25 MB"
}, },
"uploads": { "uploads": {
"sectionHelp": "UI からのアップロード API のリクエスト body 上限 (MB)", "sectionHelp": "UI からのアップロード API のリクエスト body 上限 (MB)",
@ -254,6 +256,8 @@
"enable": "Enable Gateway", "enable": "Enable Gateway",
"listenPortHelp": "同 process 時はこの値は使われません: worker UI と同じポート ({{port}}) を共有します。AAO_MODE=gateway で別 process 起動した場合のみ有効。", "listenPortHelp": "同 process 時はこの値は使われません: worker UI と同じポート ({{port}}) を共有します。AAO_MODE=gateway で別 process 起動した場合のみ有効。",
"separateDeploy": "別 process deploy:", "separateDeploy": "別 process deploy:",
"internalTeamsLabel": "内部チーム",
"internalTeamsHelp": "内部として扱うチーム名(カンマ区切り)。ここに載るチームの通信は usage の課金・メータリングから除外される。この一覧はサーバー側で権威を持ち、クライアント指定のヘッダは使わない。",
"backendsHelp1": "ルーティング先の llama-server / Ollama / vLLM など。Gateway は worker が送る role を担う backend のうち最も空いているものに割り振ります (roles 未設定の backend は全 role 対応)。role を担う backend が無い場合のみ request.model = id/model の厳密一致にフォールバックします。", "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 を直接編集してください。", "backendsHelp2": "api_key の保存形式: フォームで入力した値は config.yaml に平文で保存されます。${VAR} 形式の env var 参照はフォーム保存時に literal 文字列として保存されるため、env 経由で渡したい場合は config.yaml を直接編集してください。",
"backendsEmpty": "backend が未登録です。最低 1 つ追加してください。", "backendsEmpty": "backend が未登録です。最低 1 つ追加してください。",
@ -524,11 +528,16 @@
"apiKeyDirectHelp": "Bearer 認証が必要な場合のみ設定。Ollama 単体なら空のままで OK。", "apiKeyDirectHelp": "Bearer 認証が必要な場合のみ設定。Ollama 単体なら空のままで OK。",
"modelHelp": "endpoint が /models を返せば dropdown に候補が出ます。出ない場合 (auth が必要、proxy 越し等) は直接入力してください。", "modelHelp": "endpoint が /models を返せば dropdown に候補が出ます。出ない場合 (auth が必要、proxy 越し等) は直接入力してください。",
"maxConcurrency": "最大同時実行数", "maxConcurrency": "最大同時実行数",
"healthcheckInterval": "ヘルスチェック間隔(秒)",
"healthcheckIntervalHelp": "このワーカーのヘルスチェック間隔(秒)。空欄で既定値。",
"enabled": "有効", "enabled": "有効",
"vlmTitle": "VLM 対応モデルの場合、ReadImage が worker 自身のモデルを使用", "vlmTitle": "VLM 対応モデルの場合、ReadImage が worker 自身のモデルを使用",
"returnProgress": "プロンプト処理進捗",
"returnProgressTitle": "llama.cpp (llama-server) 専用: プロンプト評価の進捗をストリームし、チャットに「プロンプト処理中 n%」を表示します。他のバックエンドではリクエストが拒否され得るためオフのままにしてください",
"addWorker": "+ Worker を追加", "addWorker": "+ Worker を追加",
"globalTitle": "Global LLM Settings", "globalTitle": "Global LLM Settings",
"timeoutHelp": "LLM リクエストのタイムアウト (分)。デフォルト: 10", "timeoutHelp": "LLM リクエストのタイムアウト (分)。デフォルト: 10",
"maxStreamHelp": "1 回の LLM 呼び出しの総時間ハード上限分、リトライ込み。Timeout と違いチャンク受信でリセットしないので、止まらない暴走生成も打ち切れる。空欄で Timeout×2、0 で無効(非推奨)。",
"retryTitle": "Retry (per-call HTTP)", "retryTitle": "Retry (per-call HTTP)",
"maxAttemptsHelp": "1 回の LLM API 呼び出しでの最大試行回数", "maxAttemptsHelp": "1 回の LLM API 呼び出しでの最大試行回数",
"backoffHelp": "各リトライ間の待機時間 (ms)。配列順に消費されます。", "backoffHelp": "各リトライ間の待機時間 (ms)。配列順に消費されます。",
@ -581,7 +590,8 @@
"historyEnable": "履歴の自動要約を有効化", "historyEnable": "履歴の自動要約を有効化",
"historyEnableHelp": "古い会話履歴を自動で要約して context を節約。デフォルト: 有効", "historyEnableHelp": "古い会話履歴を自動で要約して context を節約。デフォルト: 有効",
"tailTurnsHelp": "常に保持する直近の assistant+tool ターン数。デフォルト: 2", "tailTurnsHelp": "常に保持する直近の assistant+tool ターン数。デフォルト: 2",
"preserveRecentHelp": "要約せず温存する直近メッセージのトークン予算。デフォルト: 8000" "preserveRecentHelp": "要約せず温存する直近メッセージのトークン予算。デフォルト: 8000",
"reserveCapHelp": "compact-and-continue 発火前に prompt の上へ確保する headroom の上限。大コンテキストモデルが headroom を使い切れるようにする。デフォルト: 32000"
}, },
"mcp": { "mcp": {
"intro": "外部 MCP (Model Context Protocol) サーバーの接続・実行に関する設定です。接続先サーバーを追加する場合は、各タスクまたは設定から MCP サーバー URL を指定してください。", "intro": "外部 MCP (Model Context Protocol) サーバーの接続・実行に関する設定です。接続先サーバーを追加する場合は、各タスクまたは設定から MCP サーバー URL を指定してください。",
@ -677,7 +687,15 @@
"concurrencyHelp": "同時実行可能なジョブ数", "concurrencyHelp": "同時実行可能なジョブ数",
"maxMovementsHelp": "1ジョブあたりの最大 movement 数", "maxMovementsHelp": "1ジョブあたりの最大 movement 数",
"maxAttemptsHelp": "ジョブ失敗時の最大リトライ回数。デフォルト: 3", "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": { "context": {
"limitPlaceholder": "auto (Ollama API から取得)", "limitPlaceholder": "auto (Ollama API から取得)",
@ -739,7 +757,17 @@
"askSubtasks": { "askSubtasks": {
"askMaxHelp": "1 Job あたりの ASKユーザーへの質問上限", "askMaxHelp": "1 Job あたりの ASKユーザーへの質問上限",
"maxDepthHelp": "サブタスクのネスト最大深度", "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": { "namespaceEditor": {
"addAriaDisabled": "新規追加は無効化されています", "addAriaDisabled": "新規追加は無効化されています",
@ -776,6 +804,12 @@
"hsts": "HSTS を送出(上級者向け・既定オフ)", "hsts": "HSTS を送出(上級者向け・既定オフ)",
"hstsHelp": "正式な証明書を導入している場合のみオンにしてください。HSTS はブラウザを HTTPS に固定し、一度送ると後からオフにしても解除できません自己署名では害だけが残ります。オフのままなら、サーバーは過去に固定されたブラウザを解除するための信号max-age=0を送ります。以前 HTTPS にずっとリダイレクトされていた場合は、このスイッチをオフのまま一度 HTTPS でアプリを開けば固定が解除されます。反映には再起動が必要です。", "hstsHelp": "正式な証明書を導入している場合のみオンにしてください。HSTS はブラウザを HTTPS に固定し、一度送ると後からオフにしても解除できません自己署名では害だけが残ります。オフのままなら、サーバーは過去に固定されたブラウザを解除するための信号max-age=0を送ります。以前 HTTPS にずっとリダイレクトされていた場合は、このスイッチをオフのまま一度 HTTPS でアプリを開けば固定が解除されます。反映には再起動が必要です。",
"selfSignedHosts": "証明書の追加ホスト名", "selfSignedHosts": "証明書の追加ホスト名",
"minVersion": "最小 TLS バージョン",
"minVersionHelp": "サーバーがネゴシエートする最小の TLS プロトコルバージョン。反映には再起動が必要です。",
"restartBanner": "HTTPS 設定の変更を反映するには、サーバーの再起動が必要です。" "restartBanner": "HTTPS 設定の変更を反映するには、サーバーの再起動が必要です。"
},
"search": {
"placeholder": "設定を検索…(例: デッドライン, TLS, python",
"noResults": "一致する設定はありません"
} }
} }

View File

@ -103,3 +103,54 @@ describe('summarizeTraceEvent', () => {
expect(result).toBe('piece=chat'); 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');
});
});

View File

@ -51,8 +51,18 @@ export function summarizeTraceEvent(event: TraceEventShape): string {
: (p.textChars as number) > 0 : (p.textChars as number) > 0
? ` text=${p.textChars}c` ? ` 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': case 'cache_set':
return `${String(p.tool ?? '?')} (${String(p.volatility ?? '?')})`; return `${String(p.tool ?? '?')} (${String(p.volatility ?? '?')})`;
case 'cache_hit': case 'cache_hit':

View File

@ -27,6 +27,7 @@ const SETTINGS_SECTIONS = [
'context', 'context',
'safety', 'safety',
'reflection', 'reflection',
'skills-quota',
// Tools group (sub-sections) // Tools group (sub-sections)
'tools-web', 'tools-web',
'tools-browser', 'tools-browser',