From bdff8573b686773fc5d82ab71eb047e8cb7a48c8 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 24 Jul 2026 12:44:00 +0800 Subject: [PATCH 001/319] ci: run coverage on in-house vm-backup pool Coverage does not gate merges, so move it off the metered dsh-enterprise-ubuntu-24-04-32core-test pool onto the in-house self-hosted pool (vm-backup label, 64-core). Also switch the pnpm store cache path to ~ so it resolves under both /home/runner (hosted) and self-hosted home directories. Verified on the self-hosted pool: the full coverage job (including prepare-ci-bubblewrap and the exhaustive suite) completed green in ~5 min. --- .github/workflows/ci.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2eceefa114..a2e70cab6f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,7 +77,9 @@ jobs: node-24-coverage: if: github.event_name == 'pull_request' - runs-on: dsh-enterprise-ubuntu-24-04-32core-test + # Coverage does not gate merges, so it runs on the in-house pool + # (self-hosted, 64-core) instead of the metered enterprise pool. + runs-on: [self-hosted, linux, x64, vm-backup] name: node 24 / coverage env: DSH_COVERAGE_MAX_WORKERS: '24' @@ -89,7 +91,8 @@ jobs: - uses: actions/cache/restore@v4 with: - path: /home/runner/.local/share/pnpm/store/v11 + # ~ resolves on both hosted (/home/runner) and self-hosted homes + path: ~/.local/share/pnpm/store/v11 key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- From 81890d7a994ab791c7db8bc93667caf21fc38f45 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 24 Jul 2026 16:37:59 +0800 Subject: [PATCH 002/319] =?UTF-8?q?ci:=20address=20review=20=E2=80=94=20sa?= =?UTF-8?q?me-repo=20guard,=20keep=20cache=20path=20identical?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Restrict node-24-coverage to same-repo PRs so fork-originated code can never reach the self-hosted runner (defense in depth; the repo is private with forking disabled today). - Revert the pnpm cache path to the literal /home/runner/... save-side path: actions/cache hashes the path into the cache version, so the ~ variant could never match the cache saved by the master lane. On self-hosted the persistent local pnpm store covers warm installs. - Drop the incorrect 'does not gate merges' claim: node-24-coverage is needed by all-checks-passed. Pool capacity notes moved into comments. --- .github/workflows/ci.yml | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a2e70cab6f..dd60593a85 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,9 +76,14 @@ jobs: compression-level: 0 node-24-coverage: - if: github.event_name == 'pull_request' - # Coverage does not gate merges, so it runs on the in-house pool - # (self-hosted, 64-core) instead of the metered enterprise pool. + # Same-repo PRs only: this lane runs on an in-house self-hosted runner, + # so fork-originated code must never land here. The repo is currently + # private with forking disabled; this guard keeps that invariant explicit + # if either setting ever changes. + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + # Runs on the in-house pool (self-hosted, 64-core) instead of the metered + # enterprise pool. The pool holds 4 always-on instances plus 4 registered + # spares; the runner service is systemd-managed and self-healing. runs-on: [self-hosted, linux, x64, vm-backup] name: node 24 / coverage env: @@ -91,8 +96,12 @@ jobs: - uses: actions/cache/restore@v4 with: - # ~ resolves on both hosted (/home/runner) and self-hosted homes - path: ~/.local/share/pnpm/store/v11 + # Path must stay byte-identical to the save-side path in the master + # lane: actions/cache hashes the literal path into the cache version, + # so any variation (e.g. ~) would never match the saved cache. On + # self-hosted this restore simply misses and the persistent local + # pnpm store covers warm installs instead. + path: /home/runner/.local/share/pnpm/store/v11 key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} restore-keys: | ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- From a0c269b0fbc4ee1226e57308303bd8c47f814646 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 22 Jul 2026 13:02:44 +0800 Subject: [PATCH 003/319] feat(gui): fold todo/write into ConversationSnapshot.todos Session consumes the todo/write session event as a per-event side effect (last write wins), rebuilds it on window replay/paging/resync, and exposes snapshot.todos. TodoItem re-exported through the runtime surface. --- packages/client/runtime/src/client/index.ts | 2 +- .../runtime/src/client/sessions/conversation.ts | 5 +++++ .../runtime/src/client/sessions/session.ts | 10 +++++++++- packages/client/runtime/tests/event-script.ts | 2 ++ packages/client/runtime/tests/session.spec.ts | 17 +++++++++++++++++ .../tests/gate-branch-tails.spec.tsx | 2 +- .../tests/skeleton-branches.spec.tsx | 2 +- 7 files changed, 36 insertions(+), 4 deletions(-) diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 33097d4573..645575a893 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -33,7 +33,7 @@ export type { export type { AssistantBlock, AssistantMessageNode, ContextMessageNode, ConversationNode, ConversationSnapshot, RunningToolCall, SteeringMessageNode, - ToolResultNode, UnknownSurfaceNode, UserMessageNode, + TodoItem, ToolResultNode, UnknownSurfaceNode, UserMessageNode, } from './sessions/conversation.ts' // PendingWait is a value export: tests construct fixture waits directly. export { PendingWait } from './sessions/pending.ts' diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 08b80f2a26..b77a6fdcac 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -4,9 +4,12 @@ // string here (narrow to real brands when convenient). import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +import type { TodoItem } from '@deepseek-ai/dsh-session/types' import type { RpcError, SessionId, ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client' import type { PendingInteraction } from './pending.ts' +export type { TodoItem } + /** Assistant content blocks sorted by what the UI cares about * (text body / collapsible reasoning / tool-call card head / other fallback). */ export type AssistantBlock = @@ -174,4 +177,6 @@ export interface ConversationSnapshot { loadingOlder: boolean promptError: PromptError | null lastAgentError: string | null + /** Latest `todo/write` whole-list snapshot in the window (last write wins); empty = no plan. */ + todos: readonly TodoItem[] } diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 6b773e0903..304b2e6e02 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -4,7 +4,7 @@ // subscribe/getSnapshot. import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' -import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import type { SessionEvent, TodoItem } from '@deepseek-ai/dsh-session/types' import type { HistoryEntry, IApiClient, MuxFrame, RpcError, RpcId, RpcResult, SessionId, ToolEventView, @@ -65,6 +65,8 @@ export class Session implements ObservableSnapshot { private pendingCache: { rev: number; value: PendingInteraction[] } | null = null private frozenRev = 0 private nodesCache: { folded: readonly ConversationNode[]; frozenRev: number; value: readonly ConversationNode[] } | null = null + /** Latest todo/write whole-list snapshot in the window (last write wins on replay). */ + private todos: readonly TodoItem[] = [] private running = false private removed = false private promptError: PromptError | null = null @@ -444,6 +446,10 @@ export class Session implements ObservableSnapshot { if (this.openCalls.delete(String(event.data.callId))) this.callsRev++ return } + case 'todo/write': { + this.todos = event.data.todos + return + } case 'turn/end': { // Aborted turns never finalize. The accumulated partial is VALUE, not residue: freeze it // into an interrupted terminal node (pulse stops, text survives) instead of deleting it. @@ -495,6 +501,7 @@ export class Session implements ObservableSnapshot { this.callsRev++ this.frozenNodes = [] this.frozenRev++ + this.todos = [] for (let i = 0; i < this.events.length; i++) { const event = this.events[i] /* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */ @@ -542,6 +549,7 @@ export class Session implements ObservableSnapshot { loadingOlder: this.loadingOlder, promptError: this.promptError, lastAgentError: this.lastAgentError, + todos: this.todos, } } } diff --git a/packages/client/runtime/tests/event-script.ts b/packages/client/runtime/tests/event-script.ts index b567800c9b..8b3b6f59ee 100644 --- a/packages/client/runtime/tests/event-script.ts +++ b/packages/client/runtime/tests/event-script.ts @@ -30,6 +30,8 @@ export const ev = { at(seq, { type: 'step/end', data: { turn, step } }), turnEnd: (seq: number, turn: number, reason: 'completed' | 'cancelled' = 'completed'): SessionEvent => at(seq, { type: 'turn/end', data: { turn, reason: { kind: reason } } }), + todoWrite: (seq: number, todos: { content: string; status: 'pending' | 'in_progress' | 'completed' }[]): SessionEvent => + at(seq, { type: 'todo/write', data: { todos } }), } /** One complete plain turn (turn/start → user → step → assistant → turn/end), 6 events from startSeq. */ diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index b980a674fe..786bb9793b 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -153,6 +153,23 @@ describe('live event path', () => { }) }) + it('folds todo/write into snapshot.todos last-write-wins, live and on window replay', async () => { + const listA = [{ content: '搭骨架', status: 'completed' as const }, { content: '写组件', status: 'in_progress' as const }] + const listB = [{ content: '搭骨架', status: 'completed' as const }, { content: '写组件', status: 'completed' as const }] + const { session } = await opened() + expect(session.getSnapshot().todos).toEqual([]) + const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) } + feed(ev.todoWrite(6, listA)) + expect(session.getSnapshot().todos).toEqual(listA) + feed(ev.todoWrite(7, listB)) + expect(session.getSnapshot().todos).toEqual(listB) + // Window replay converges on the same last snapshot (history contains both writes). + const replayed = makeSession() + replayed.api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ev.todoWrite(6, listA), ev.todoWrite(7, listB)]) + await replayed.session.open() + expect(replayed.session.getSnapshot().todos).toEqual(listB) + }) + it('repairs a seq gap by repulling the tail page instead of appending a hole', async () => { const { api, session } = await opened(plainTurn(0, 0, 'a', 'b')) // tail seq = 5 const repaired = [...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')] diff --git a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx index c59b1c7527..62c4213a6d 100644 --- a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx @@ -25,7 +25,7 @@ function snapshotBase(): ConversationSnapshot { return { sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], pending: [], running: false, removed: false, openState: 'open', openError: null, - hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null, + hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null, todos: [], } as ConversationSnapshot } diff --git a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx index 4eb70ea39f..c3461cc0c3 100644 --- a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx @@ -29,7 +29,7 @@ function snapshotBase(): ConversationSnapshot { return { sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], pending: [], running: false, removed: false, openState: 'open', openError: null, - hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null, + hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null, todos: [], } as ConversationSnapshot } From 63109dab66ad93ec4e274e15624fa3ebf9b603f7 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 22 Jul 2026 13:02:53 +0800 Subject: [PATCH 004/319] =?UTF-8?q?feat(gui):=20todo=20display=20=E2=80=94?= =?UTF-8?q?=20TodoPanel=20plan=20strip=20+=20todo=5Fwrite=20toolview=20row?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TodoPanel pins above the composer (776px card axis), hidden while empty, collapsible with the active item as the collapsed hint; status glyphs mirror the TUI plan panel. todo_write rows render a plan-flavored summary (counts + active item) via the toolview registry, generic fallback on malformed args. Existing fake snapshots gain the required todos field. --- .../ui-conversation/src/client/apply.ts | 4 + .../src/client/skeleton/ConversationRoot.tsx | 3 + .../src/client/skeleton/TodoPanel.module.css | 111 +++++++++++++++ .../src/client/skeleton/TodoPanel.tsx | 59 ++++++++ .../src/client/toolviews/todo-row.module.css | 42 ++++++ .../src/client/toolviews/todo-row.tsx | 71 ++++++++++ .../ui-conversation/tests/chat-apply.spec.tsx | 6 +- .../tests/chat-stats-bash-sample.spec.tsx | 2 +- .../tests/chat-toolview-slot.spec.tsx | 2 +- .../ui-conversation/tests/chat-view.spec.tsx | 2 +- .../ui-conversation/tests/skeleton.spec.tsx | 3 +- .../ui-conversation/tests/todo-panel.spec.tsx | 128 ++++++++++++++++++ .../client/ui-trajectory/tests/views.spec.tsx | 1 + 13 files changed, 427 insertions(+), 7 deletions(-) create mode 100644 packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css create mode 100644 packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx create mode 100644 packages/client/ui-conversation/src/client/toolviews/todo-row.module.css create mode 100644 packages/client/ui-conversation/src/client/toolviews/todo-row.tsx create mode 100644 packages/client/ui-conversation/tests/todo-panel.spec.tsx diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 372eb36c80..70ec78722c 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -21,6 +21,7 @@ import { createChatStore } from './stores.ts' import { ConversationService } from './service.ts' import { ChatView } from './chat/ChatView.tsx' import { bashToolviewSample } from './toolviews/bash-sample.tsx' +import { todoToolview } from './toolviews/todo-row.tsx' import { ConversationRoot } from './skeleton/ConversationRoot.tsx' import { DetailsPanel } from './skeleton/DetailsPanel.tsx' import { EmptyState } from './skeleton/EmptyState.tsx' @@ -140,6 +141,9 @@ export function apply(ctx: Context): void { // The bash sample rides that exact seam, in third-party posture. ctx.plugin(bashToolviewSample) + // The todo_write row rides the same seam (a product registration, not a sample). + ctx.plugin(todoToolview) + slots.register({ name: 'details', store: chatStore, diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index 59346a557b..ca7fc526e4 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -15,6 +15,7 @@ import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/d import type { ConversationSlotProps } from '../contract/slots.ts' import { InputBar } from './InputBar.tsx' import type { InputBarError } from './InputBar.tsx' +import { TodoPanel } from './TodoPanel.tsx' import css from './ConversationRoot.module.css' /** Full props = the automatic shares & injected share — composed by reference @@ -123,6 +124,8 @@ export function ConversationRoot({ {active !== undefined && renderSlot('conversation.view', {}, { only: active.id })} + + {renderSlotChain('conversation.composer', { interactions: pending }, { fallback: composerBar })} ) diff --git a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css new file mode 100644 index 0000000000..17c9c890a7 --- /dev/null +++ b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css @@ -0,0 +1,111 @@ +/* Plan strip pinned above the composer: bordered card on the composer card's + axis (776px column inside 32px side padding). Colors resolve through + --dsw-alias-* tokens only; the active row rides the business blue, done + rows fade to tertiary. */ + +.root { + flex: none; + overflow: hidden; + margin: 8px auto 0; + width: calc(100% - 64px); + max-width: 776px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 12px; + background: var(--dsw-alias-bg-base); +} + +.header { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 8px 12px; + border: none; + background: transparent; + text-align: left; + cursor: pointer; +} + +.header:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.title { + font-size: 13px; + line-height: 16px; + font-weight: 510; + color: var(--dsw-alias-label-primary); +} + +.progress { + font-size: 12px; + line-height: 16px; + color: var(--dsw-alias-label-tertiary); +} + +.activeHint { + flex: 1; + min-width: 0; + overflow: hidden; + font-size: 12px; + line-height: 16px; + color: var(--dsw-alias-label-secondary); + text-overflow: ellipsis; + white-space: nowrap; +} + +.chevron { + display: grid; + flex: none; + place-items: center; + margin-left: auto; + color: var(--dsw-alias-label-secondary); +} + +.list { + margin: 0; + padding: 0 12px 8px; + list-style: none; + max-height: 180px; + overflow-y: auto; +} + +.item { + display: flex; + align-items: baseline; + gap: 8px; + padding: 2px 0; + font-size: 13px; + line-height: 20px; + color: var(--dsw-alias-label-secondary); +} + +.glyph { + flex: none; + width: 14px; + text-align: center; + color: var(--dsw-alias-label-tertiary); +} + +.item[data-status='completed'] .content { + color: var(--dsw-alias-label-tertiary); + text-decoration: line-through; +} + +.item[data-status='completed'] .glyph { + color: var(--dsw-alias-state-success-primary); +} + +.item[data-status='in_progress'] .content { + font-weight: 510; + color: var(--dsw-alias-label-primary); +} + +.item[data-status='in_progress'] .glyph { + color: var(--dsw-alias-state-business-primary); +} + +.content { + min-width: 0; + overflow-wrap: anywhere; +} diff --git a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx new file mode 100644 index 0000000000..efaa2599b8 --- /dev/null +++ b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx @@ -0,0 +1,59 @@ +// TodoPanel: persistent plan strip pinned above the composer (the web +// counterpart of the TUI plan panel; ACP maps the same event to its native +// plan). Renders the latest todo/write whole-list snapshot off the session +// snapshot — no data of its own, hidden while the list is empty. Zero +// framework imports: useSession arrives via props from ConversationRoot. + +import { useState } from 'react' +import type { TodoItem } from '@deepseek-ai/dsh-client-runtime/client' +import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots' +import { IconChevronDownOutline14, IconChevronUpOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' +import css from './TodoPanel.module.css' + +export interface TodoPanelProps { + useSession: UseSession +} + +/** Status glyphs mirror the TUI plan panel (✓ done / ● active / ○ pending). */ +const STATUS_GLYPHS: Record = { + completed: '✓', in_progress: '●', pending: '○', +} + +export function TodoPanel({ useSession }: TodoPanelProps) { + const todos = useSession(s => (s as { todos: readonly TodoItem[] }).todos) + const [collapsed, setCollapsed] = useState(false) + if (todos.length === 0) return null + + const done = todos.filter(t => t.status === 'completed').length + const active = todos.find(t => t.status === 'in_progress') + + return ( +
+ + {!collapsed && ( +
    + {todos.map(item => ( +
  • + {STATUS_GLYPHS[item.status]} + {item.content} +
  • + ))} +
+ )} +
+ ) +} diff --git a/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css b/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css new file mode 100644 index 0000000000..ff4068d49c --- /dev/null +++ b/packages/client/ui-conversation/src/client/toolviews/todo-row.module.css @@ -0,0 +1,42 @@ +/* todo_write plan-update row: title + progress summary on one line. */ + +.row { + display: flex; + align-items: center; + gap: 8px; + height: 24px; + min-width: 0; + cursor: pointer; + border-radius: 6px; + font-size: 13px; +} + +.row:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.badge { + flex: none; + color: var(--dsw-alias-state-business-primary); +} + +.title { + flex: none; + font-weight: 510; + color: var(--dsw-alias-label-primary); +} + +.summary { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--dsw-alias-label-secondary); +} + +.err { + flex: none; + color: var(--dsw-alias-state-error-primary); + font-size: 11px; +} diff --git a/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx b/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx new file mode 100644 index 0000000000..390361d20b --- /dev/null +++ b/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx @@ -0,0 +1,71 @@ +// todo_write toolview: plan-flavored summary row replacing the generic +// "Tool call" card, registered into the keyed 'conversation.chat.toolview' +// hole like the bash sample (a product registration, not a sample). The row +// summarizes the written list (counts + active item) from the call args; the +// durable list itself renders in the TodoPanel above the composer, so the +// row stays one line. + +import type { Context } from 'cordis' +import type { ToolRowProps } from '../contract/slots.ts' +import { toolRowModel } from '../contract/tool-call-model.ts' +import css from './todo-row.module.css' + +/** One parsed args item, shape-checked (model JSON: any field may be missing or mistyped). */ +interface TodoWriteItem { content?: unknown; status?: unknown } + +function isItem(value: unknown): value is TodoWriteItem { + return typeof value === 'object' && value !== null +} + +function summarize(argsRaw: string): string | null { + let parsed: unknown + try { + parsed = JSON.parse(argsRaw) + } catch { + // Mid-stream truncation or malformed model JSON: fall back to the generic summary. + return null + } + // Valid JSON with an invalid shape (null root, non-array todos, null items — + // a rejected tool/call retains such args verbatim): same generic fallback. + if (typeof parsed !== 'object' || parsed === null) return null + const todos = (parsed as { todos?: unknown }).todos + if (!Array.isArray(todos) || !todos.every(isItem)) return null + const done = todos.filter(t => t.status === 'completed').length + const active = todos.find(t => t.status === 'in_progress') + const head = `${done}/${todos.length} 已完成` + return typeof active?.content === 'string' && active.content !== '' + ? `${head} · ${active.content}` + : head +} + +/** One-line plan update row (click opens the raw args in details). */ +export function TodoRow({ toolName, block, openDetails }: ToolRowProps) { + const model = toolRowModel(toolName, block) + const argsRaw = ('kind' in block ? block.call?.argsRaw : block.argsRaw) ?? '' + const summary = summarize(argsRaw) ?? model.summary + return ( +
+ + 更新任务清单 + {summary} + {model.state === 'error' && failed} +
+ ) +} + +/** + * The todo row as a plain registrant plugin, riding the same load-order seam + * as the bash sample: `inject: ['conversation']` guarantees the chat entry + * (and with it the 'conversation.chat.toolview' declaration) is on the ledger. + */ +export const todoToolview = { + name: 'todo-toolview', + inject: ['slots', 'conversation'], + /** + * Register the todo row into the chat view's keyed toolview hole. + * @param ctx - registrant context (disposal rides ctx.effect inside slots.register). + */ + apply(ctx: Context): void { + ctx.slots.register({ name: 'conversation.chat.toolview', key: 'todo_write' }, TodoRow) + }, +} diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index adb9271c71..bcd5cec2ed 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -103,13 +103,13 @@ describe('apply wiring', () => { expect(empty?.store).toBeUndefined() }) - it('mounts the bash sample as a keyed entry through the load-order seam', async () => { + it('mounts the bash sample and the todo row as keyed entries through the load-order seam', async () => { const b = await bench() await b.fiber.await() - // The sample plugin's inject: ['slots', 'conversation'] resolved — the + // Both registrant plugins' inject: ['slots', 'conversation'] resolved — the // service being present implies the chat entry declared the hole first. const entries = b.slots.entries('conversation.chat.toolview') - expect(entries.map((e) => e.options.key)).toEqual(['bash']) + expect(entries.map((e) => e.options.key)).toEqual(['bash', 'todo_write']) }) it('plugin fiber disposal collects every registration (unload cascade, ring and hole included)', async () => { diff --git a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx index 4d3383b2d1..6efb63fa00 100644 --- a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx @@ -28,7 +28,7 @@ function snapshotBase(): ConversationSnapshot { return { sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], pending: [], running: false, removed: false, openState: 'open', openError: null, - hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null, + hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null, todos: [], } } diff --git a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx index 5d2b3408a2..73e9478431 100644 --- a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx @@ -41,7 +41,7 @@ function snapshotWith(nodes: ToolResultNode[]): ConversationSnapshot { return { sessionId: SID, nodes, foldDegraded: false, partial: null, runningCalls: [], pending: [], running: false, removed: false, openState: 'open', openError: null, - hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null, + hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null, todos: [], } as ConversationSnapshot } diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 3f1db55199..8930f3171d 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -30,7 +30,7 @@ function snapshotBase(): ConversationSnapshot { return { sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], pending: [], running: false, removed: false, openState: 'open', openError: null, - hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null, + hasMore: false, loadingOlder: false, promptError: null, lastAgentError: null, todos: [], } } diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index a598803a25..a3ffd3cb11 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -59,11 +59,12 @@ interface FakeSnapshot { removed: boolean promptError: { op: 'send' | 'stop'; error: { message: string; code: string } } | null pending: readonly PendingInteraction[] + todos: readonly { content: string; status: 'pending' | 'in_progress' | 'completed' }[] } function fakeSession(init: Partial = {}) { const store = createSnapshotStore({ - nodes: [], runningCalls: [], running: false, removed: false, promptError: null, pending: [], ...init, + nodes: [], runningCalls: [], running: false, removed: false, promptError: null, pending: [], todos: [], ...init, }) return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession } } diff --git a/packages/client/ui-conversation/tests/todo-panel.spec.tsx b/packages/client/ui-conversation/tests/todo-panel.spec.tsx new file mode 100644 index 0000000000..761cb4dfd3 --- /dev/null +++ b/packages/client/ui-conversation/tests/todo-panel.spec.tsx @@ -0,0 +1,128 @@ +// @vitest-environment jsdom +/** + * Todo display acceptance: the TodoPanel plan strip (empty-hidden, status + * rows, collapse with active hint) and the todo_write toolview row (progress + * summary from args, generic fallback on malformed JSON, error badge). + */ +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { hookOf } from './hook.ts' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { TodoItem, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' +import type { UseSession } from '@deepseek-ai/dsh-client-ui-slots' +import type { ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +// Export discipline: packages/client/AGENTS.md. +import { TodoRow, todoToolview } from '../src/client/toolviews/todo-row.tsx' +import { TodoPanel } from '../src/client/skeleton/TodoPanel.tsx' + +afterEach(cleanup) + +function sessionWith(todos: readonly TodoItem[]) { + const store = createSnapshotStore<{ todos: readonly TodoItem[] }>({ todos }) + return { store, useSession: hookOf(store) as unknown as UseSession } +} + +const LIST: TodoItem[] = [ + { content: '搭骨架', status: 'completed' }, + { content: '写组件', status: 'in_progress' }, + { content: '补测试', status: 'pending' }, +] + +describe('TodoPanel', () => { + it('renders nothing while the list is empty, appears when todos land', () => { + const { store, useSession } = sessionWith([]) + render() + expect(screen.queryByTestId('todo-panel')).toBeNull() + act(() => { store.set({ todos: LIST }) }) + expect(screen.getByTestId('todo-panel')).toBeTruthy() + }) + + it('shows progress, one row per item with its status, and strikes done items', () => { + const { useSession } = sessionWith(LIST) + render() + expect(screen.getByText('1/3')).toBeTruthy() + const items = screen.getAllByRole('listitem') + expect(items.map(li => li.getAttribute('data-status'))).toEqual(['completed', 'in_progress', 'pending']) + expect(screen.getByText('搭骨架')).toBeTruthy() + expect(screen.getByText('写组件')).toBeTruthy() + }) + + it('collapse hides the list and surfaces the active item in the header; expand restores', () => { + const { useSession } = sessionWith(LIST) + render() + const header = screen.getByRole('button', { expanded: true }) + fireEvent.click(header) + expect(screen.queryByRole('list')).toBeNull() + // Collapsed header carries the in-progress content as the one-line hint. + expect(screen.getByText('写组件')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { expanded: false })) + expect(screen.getAllByRole('listitem')).toHaveLength(3) + }) + + it('collapsed header omits the hint when nothing is in progress', () => { + const { useSession } = sessionWith([{ content: '都完了', status: 'completed' }]) + render() + fireEvent.click(screen.getByRole('button', { expanded: true })) + expect(screen.queryByText('都完了')).toBeNull() + expect(screen.getByText('1/1')).toBeTruthy() + }) +}) + +const resultNode = (argsRaw: string, over?: Partial): ToolResultNode => ({ + kind: 'tool-result', seq: 10, callId: 'c1', + call: { name: 'todo_write', argsRaw }, + content: [], isError: false, callView: null, resultView: null, ...over, +}) + +function rowProps(block: unknown, openDetails = vi.fn()): ToolRowProps { + return { + callId: 'c1', toolName: 'todo_write', block, + openDetails, + sessionId: 's1', + useSessions: () => undefined, + } as unknown as ToolRowProps +} + +describe('TodoRow', () => { + const ARGS = JSON.stringify({ todos: LIST }) + + it('summarizes counts and the active item from the call args', () => { + render() + expect(screen.getByText('更新任务清单')).toBeTruthy() + expect(screen.getByText('1/3 已完成 · 写组件')).toBeTruthy() + }) + + it('omits the active clause when no item is in progress and reads running-call args', () => { + const args = JSON.stringify({ todos: [{ content: 'x', status: 'completed' }] }) + render() + expect(screen.getByText('1/1 已完成')).toBeTruthy() + }) + + it('falls back to the generic summary on malformed args and flags errors', () => { + render() + expect(screen.getByText('failed')).toBeTruthy() + // Generic others summary: " · ". + expect(screen.getByText('todo_write · not json')).toBeTruthy() + }) + + it('falls back when parsed args carry no todos array, and click opens details', () => { + const openDetails = vi.fn() + render() + expect(screen.getByText('todo_write · {"other":1}')).toBeTruthy() + fireEvent.click(screen.getByText('更新任务清单')) + expect(openDetails).toHaveBeenCalledTimes(1) + }) + + it('window-truncated result (call head lost) falls back to the callId summary', () => { + render() + expect(screen.getByText('todo_write · c1')).toBeTruthy() + }) + + it('todoToolview is a plain registrant riding the conversation load-order seam', () => { + expect(todoToolview.name).toBe('todo-toolview') + expect(todoToolview.inject).toEqual(['slots', 'conversation']) + const register = vi.fn() + todoToolview.apply({ slots: { register } } as never) + expect(register).toHaveBeenCalledWith({ name: 'conversation.chat.toolview', key: 'todo_write' }, TodoRow) + }) +}) diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index 4818e67db5..ec37507443 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -109,6 +109,7 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES const sessionSnapshot = createSnapshotStore({ running: false, removed: false, promptError: null, nodes, partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'], + todos: [] as ConversationSnapshot['todos'], }) const useSession = bindSnapshotSelector(sessionSnapshot) as unknown as UseSession const chat = createChatStore().create() From de4f818c80b11dc56128087344d18b97f2e3d7ca Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 22 Jul 2026 13:15:51 +0800 Subject: [PATCH 005/319] test(gui): todo display fixture sample + browser acceptance script fx-alpha gains turn 63: a todo_write call/result pair plus the todo/write snapshot event, feeding both the TodoRow toolview and the TodoPanel strip in ?fixture mode. verify-todo-display.mjs drives chromium through panel visibility, content, row summary, details linkage, collapse and dark. --- .../client/connection/src/client/fixture.ts | 12 ++ scripts/verify-todo-display.mjs | 107 ++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 scripts/verify-todo-display.mjs diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index e1e21dfd78..82b3925aac 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -124,6 +124,18 @@ function buildAlphaLog(): SessionEvent[] { toolTurn(61, 'fx-write', '{"path":"notes/demo.txt","content":"hello fixture\\n"}', 'wrote notes/demo.txt') toolTurn(62, 'edit', '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}', '已编辑') toolTurn(63, 'write', '{"file_path":"notes/new-demo.txt","content":"hello fixture\\n"}', '已写入') + // Turn 64: todo_write sample — the TodoRow toolview in the flow plus the + // todo/write snapshot event feeding the TodoPanel plan strip. + const fixtureTodos = [ + { content: '梳理需求', status: 'completed' }, + { content: '实现 fixture 样本', status: 'in_progress' }, + { content: '浏览器验收', status: 'pending' }, + ] + const todoArgs = JSON.stringify({ todos: fixtureTodos }) + toolTurn(64, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.') + // The tool appends the snapshot event inside its own turn; splice it before the trailing turn/end. + events.splice(events.length - 1, 0, { type: 'todo/write', time: time += 800, data: { todos: fixtureTodos } }) + events.forEach((e, i) => { e.seq = i }) return events as unknown as SessionEvent[] } diff --git a/scripts/verify-todo-display.mjs b/scripts/verify-todo-display.mjs new file mode 100644 index 0000000000..3e8f42e5fe --- /dev/null +++ b/scripts/verify-todo-display.mjs @@ -0,0 +1,107 @@ +// Manual acceptance probe: boot the real shell + 8 bundles in ?fixture mode, +// open fx-alpha, assert the TodoPanel strip and the todo_write row render. +import { existsSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { createRequire } from 'node:module' +import { startWebServer } from '@deepseek-ai/dsh-host-webserver' + +// playwright is a dependency of apps/web (the browser test owner), not the root. +const { chromium } = createRequire(new URL('../apps/web/package.json', import.meta.url)).call(undefined, 'playwright') + +const root = fileURLToPath(new URL('..', import.meta.url)) +const bundle = (dir) => `${root}packages/client/${dir}/lib/client.js` +const PLUGINS = [ + { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true }, + { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', inject: [], immediately: true }, + { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', inject: ['@deepseek-ai/dsh-client-runtime'] }, + { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, + { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, + { id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', inject: ['@deepseek-ai/dsh-client-ui-conversation'] }, +] +for (const p of PLUGINS) if (!existsSync(bundle(p.dir))) throw new Error(`bundle missing: ${p.dir}`) + +const rows = PLUGINS.map(p => ({ + id: p.id, url: `/plugins/${p.id}/client.js?rev=verify`, rev: 'verify', + ...(p.inject.length > 0 ? { inject: p.inject } : {}), + ...(p.immediately ? { immediately: true } : {}), +})) +const graph = { rev: 'verify', entries: rows } +const byId = new Map(PLUGINS.map(p => [p.id, bundle(p.dir)])) +const port = 34567 +const errors = [] +const server = await startWebServer({ + host: '127.0.0.1', + port, + distIndex: `${root}apps/web/dist/index.html`, + apiHandler: { fetch: () => Promise.resolve(new Response('fixture mode must not call /api', { status: 500 })) }, + webPlugins: { graph: () => graph, clientPath: (id) => byId.get(id), onRebuilt: () => () => undefined }, +}, (err) => errors.push(`server: ${String(err)}`)) + +const browser = await chromium.launch() +const page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) +page.on('pageerror', e => errors.push(String(e))) +await page.goto(`http://127.0.0.1:${port}/?fixture`, { waitUntil: 'load' }) +try { + await page.waitForSelector('[class*="frame"]', { timeout: 15000 }) +} catch (e) { + console.error('BODY:', (await page.evaluate(() => document.body.innerText)).slice(0, 400)) + console.error('STATUS:', await page.evaluate(() => JSON.stringify(globalThis + +.__DSH_LOADER_STATUS__ ?? 'n/a'))) + console.error('BOOT:', await page.evaluate(() => JSON.stringify(window.__DSH_BOOT__))) + const reqs = await page.evaluate(() => performance.getEntriesByType('resource').map(r => `${r.name.split('/').slice(-2).join('/')}=${r.responseStatus ?? '?'}`)) + console.error('RES:', reqs.join(' ')) + console.error('ERRORS:', errors.join(' ;; ')) + throw e +} + +// Open fx-alpha: expand the workspace group, then click the newest session row. +await page.locator('[role="treeitem"]').first().click() +// fx-alpha is the newest (running) session — the first option row. +const sessionRow = page.locator('[role="treeitem"][aria-selected]').first() +await sessionRow.waitFor({ timeout: 5000 }) +await sessionRow.click() +await page.waitForSelector('[data-testid="todo-panel"]', { timeout: 15000 }) +console.log('✓ TodoPanel visible') + +const panelText = await page.locator('[data-testid="todo-panel"]').innerText() +for (const expected of ['Plan', '1/3', '梳理需求', '实现 fixture 样本', '浏览器验收']) { + if (!panelText.includes(expected)) throw new Error(`TodoPanel missing "${expected}"; got: ${panelText}`) +} +console.log('✓ TodoPanel content: counts + all three items') + +await page.screenshot({ path: `${root}.artifacts/todo-01-panel.png` }) + +// The todo_write row in the flow (turn 63 sample, already at the bottom). +const row = page.locator('[data-sample="todo-row"]') +await row.waitFor({ timeout: 10000 }) +const rowText = await row.innerText() +if (!rowText.includes('更新任务清单') || !rowText.includes('1/3 已完成')) throw new Error(`TodoRow wrong: ${rowText}`) +console.log('✓ TodoRow renders plan summary:', rowText.replace(/\n/g, ' ')) +await page.screenshot({ path: `${root}.artifacts/todo-02-row.png` }) + +// Row click opens details with the raw args. +await row.click() +await page.waitForSelector('text=Input', { timeout: 5000 }) +console.log('✓ TodoRow click opens details') +await page.screenshot({ path: `${root}.artifacts/todo-03-details.png` }) + +// Collapse: list hides, active item hint appears in the header. +await page.locator('[data-testid="todo-panel"] button').first().click() +const collapsed = await page.locator('[data-testid="todo-panel"]').innerText() +if (collapsed.includes('梳理需求')) throw new Error('collapse failed: list still visible') +if (!collapsed.includes('实现 fixture 样本')) throw new Error('collapsed hint missing the active item') +console.log('✓ Collapse hides list, shows active hint') +await page.screenshot({ path: `${root}.artifacts/todo-04-collapsed.png` }) + +// Dark theme spot check. +await page.evaluate(() => document.body.setAttribute('data-ds-dark-theme', '')) +await page.screenshot({ path: `${root}.artifacts/todo-05-dark.png` }) +console.log('✓ Dark screenshot taken') + +if (errors.length > 0) throw new Error(`page errors: ${errors.join('; ')}`) +console.log('✓ No page errors — todo display acceptance PASSED') +await browser.close() +await server.close() From 0a3c7f2b39af0865cf647108e0ea449f806a9922 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 23 Jul 2026 13:54:33 +0800 Subject: [PATCH 006/319] =?UTF-8?q?docs(agents):=20web=20todo=20display=20?= =?UTF-8?q?Agent=20Note=20=E2=80=94=20side-effect=20channel=20+=20two=20su?= =?UTF-8?q?rfaces?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-07-23-web-todo-display.i18n.yaml | 6 ++++ .../feature/2026-07-23-web-todo-display.md | 35 +++++++++++++++++++ .../feature/2026-07-23-web-todo-display.zh.md | 35 +++++++++++++++++++ 3 files changed, 76 insertions(+) create mode 100644 .agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-23-web-todo-display.md create mode 100644 .agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml new file mode 100644 index 0000000000..d8a6a517bf --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-23-web-todo-display.md: 6b6af215016b2f73baa088653fcf133dbbfd3449 +2026-07-23-web-todo-display.zh.md: a06083190ba7b9b030a48aa7dc2c3177853f3cee diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md new file mode 100644 index 0000000000..6b6af21501 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md @@ -0,0 +1,35 @@ +# Agent Note: Web todo display — snapshot side-effect channel + two render surfaces + +Status: implemented + +English | [中文](2026-07-23-web-todo-display.zh.md) + +## Problem + +`todo_write` appends `todo/write` whole-list snapshots to the session log; the TUI renders a persistent plan panel and the ACP bridge maps the event to native `plan` updates. The web client dropped the event entirely: the host mux stream already forwards every session event, but `todo/write` is not a surface type (it never folds into `ConversationSnapshot.nodes`), and no side-effect branch accumulated it — the browser had no consumption point and no display surface. + +## Decision + +Consume `todo/write` as a Session side effect, not a surface node, and render it on two surfaces matching the split the TUI and ACP already draw. + +### Side-effect channel, converging with window replay + +`applyEventSideEffects` gains a `todo/write` case (whole list, last write wins) and `rebuildDerivedFromWindow` resets it, so the live path and every window rebuild (paging, reconnect stitch, resync) converge on the same latest snapshot — the same shape partial/openCalls already use. `ConversationSnapshot.todos` is the read surface. This follows the event's own contract ("log-only UI state; never derived history"): surfacing each write as a conversation node would render superseded lists as if they were still standing. + +### TodoPanel: the durable list as a persistent strip + +The skeleton pins the panel between the view area and the composer (the composer-card axis), hidden while empty, collapsible with the in-progress item as the collapsed one-line hint; ✓/●/○ glyphs mirror the TUI plan panel. It reads `snapshot.todos` via the framework `useSession` hook — no store, no service, no ctx. It lives inside `ConversationRoot` rather than the details column or its own slot: the details slot is single-occupant and selection-driven (a different lifetime than an always-on strip), and the slot table reserves no plan seat. The component is props-complete and framework-free, so a later relocation to a dedicated slot touches nothing inside it. + +### TodoRow: the per-call row through the toolview registry + +The dedicated `todo_write` chat row registers through the named `ctx.toolviews` registry from `apply` (the cross-domain assembly point, the same posture as the bash samples but a product registration). The summary derives from call args (`N/M done · active item`); unparseable args fall back to the generic row summary; clicking opens the details column with the raw args. No `ToolEventView` is added for todo — presentation is client-owned, and the durable list renders from the session event, not the tool card. + +## Alternatives considered + +- **Fold todo writes into `nodes` as surface entries** — replayed windows would render every superseded list; the event is deliberately not a surface type. +- **Details column or a dedicated slot for the panel** — the details slot is single-occupant and selection-driven; a new slot key needs a slot-table seat that design has not assigned. The panel is framework-free, so the relocation stays cheap if one lands. +- **Host-computed view (a todo `ToolEventView`)** — presentation belongs to the client; the wire already carries the whole snapshot in the event payload. + +## Consequences + +Replay correctness is owned by one code path: any future change to window rebuild keeps todos consistent for free, and the fixture (fx-alpha turn 63) plus `scripts/verify-todo-display.mjs` pin the full chain (panel visibility, row summary, details linkage, collapse, dark theme) in a real chromium. `todos` is a required `ConversationSnapshot` field, so scripted fakes in specs must carry it. The ACP bridge's todo → `plan` mapping and the TUI panel are untouched; the web surfaces render the same event with no new wire vocabulary. diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md new file mode 100644 index 0000000000..a06083190b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md @@ -0,0 +1,35 @@ +# Agent Note: Web todo 展示——快照副作用通道 + 两个渲染面 + +Status: implemented + +[English](2026-07-23-web-todo-display.md) | 中文 + +## Problem + +`todo_write` 把 `todo/write` 的整份列表快照追加进会话日志;TUI 渲染一块常驻的 plan 面板,ACP 桥接把该事件映射为原生 `plan` 更新。Web 客户端把这个事件整个丢弃了:host mux 流本已转发每一个会话事件,但 `todo/write` 不是 surface 类型(它从不 fold 进 `ConversationSnapshot.nodes`),也没有任何副作用分支累积它——浏览器既无消费点,也无展示面。 + +## Decision + +把 `todo/write` 当作 Session 副作用消费,而非 surface 节点,并在两个面上渲染它,这两个面正对应 TUI 与 ACP 已经绘制的那套划分。 + +### 副作用通道,与窗口回放收敛 + +`applyEventSideEffects` 新增一个 `todo/write` 分支(整份列表,后写覆盖先写),`rebuildDerivedFromWindow` 将其重置,于是实时路径与每一次窗口重建(分页、重连缝合、resync)都收敛到同一份最新快照——partial/openCalls 已在用的正是这个形态。`ConversationSnapshot.todos` 是读取面。这遵循事件自身的契约(「仅日志 UI 状态,绝非派生历史」):把每次写入作为对话节点呈现,会让已被取代的列表看起来仍然有效。 + +### TodoPanel:长驻列表作为一条常驻横条 + +骨架把面板钉在视图区与 composer 之间(composer-card 轴),空列表时隐藏,可折叠——折叠态以进行中项作为单行提示;✓/●/○ 字形与 TUI plan 面板一致。它经框架 `useSession` hook 读取 `snapshot.todos`——无 store、无 service、无 ctx。它住在 `ConversationRoot` 之内,而非 details 列或自建 slot:details slot 单占用且由选中驱动(生命周期不同于一条常开横条),且 slot 表没有为 plan 预留席位。组件 props 完备且框架无关,因此日后迁往专用 slot 不触及其内部任何东西。 + +### TodoRow:经 toolview 注册表的逐调用行 + +专用的 `todo_write` 对话行经具名的 `ctx.toolviews` 注册表在 `apply` 中注册(跨域装配点,与 bash 样例同一姿态,但属产品级注册)。摘要由调用 args 推导(`N/M done · active item`);无法解析的 args 回退到通用行摘要;点击会以原始 args 打开 details 列。todo 不新增任何 `ToolEventView`——呈现归客户端所有,常驻列表从会话事件渲染,而非工具卡。 + +## Alternatives considered + +- **把 todo 写入作为 surface 条目折叠进 `nodes`**——回放的窗口会渲染每一份已被取代的列表;该事件被刻意设计成非 surface 类型。 +- **面板放进 details 列或专用 slot**——details slot 单占用且由选中驱动;新增一个 slot 键需要一个 slot 表席位,而设计尚未分配。面板框架无关,所以真要迁移,代价依然很低。 +- **host 计算的视图(一个 todo `ToolEventView`)**——呈现属于客户端;协议已在事件载荷里携带整份快照。 + +## Consequences + +回放正确性由一条代码路径掌管:未来对窗口重建的任何改动都免费保持 todos 一致;fx-alpha 第 63 轮的 fixture(测试前置数据)加 `scripts/verify-todo-display.mjs` 在真实 chromium 里钉住整条链(面板可见性、行摘要、详情联动、折叠、深色主题)。`todos` 是 `ConversationSnapshot` 的必填字段,所以 spec 里脚本化的 fake 必须带上它。ACP 桥接的 todo → `plan` 映射与 TUI 面板均未受改动;Web 各面渲染同一个事件,不引入任何新的协议词汇。 From 2d95a60ac9a012a9e01a86aca6b52adcedb36c21 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 23 Jul 2026 14:10:00 +0800 Subject: [PATCH 007/319] fix(tool-todo): reject unknown item keys instead of silently dropping them An item carrying keys beyond content/status (ids, children, priority) was flattened to {content, status} on append, so the logged snapshot diverged from what the model believed it wrote (model-visible must equal logged). Reject loudly; the isError result lets the model self-correct. --- packages/todo/tool-todo/README.md | 2 +- packages/todo/tool-todo/src/index.ts | 11 +++++++++-- packages/todo/tool-todo/tests/tool-todo.spec.ts | 1 + 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/todo/tool-todo/README.md b/packages/todo/tool-todo/README.md index febb2c9ce0..2e06c7dde5 100644 --- a/packages/todo/tool-todo/README.md +++ b/packages/todo/tool-todo/README.md @@ -14,7 +14,7 @@ The list belongs to the ONE agent session that called the tool. There is no suba ## Validation -Beyond the schema's type/required/enum checks, `execute` rejects an empty or duplicate `content` and more than one `in_progress` task (a coherent plan has at most one task active). Ordering and the discipline of keeping the list current are left to the model via the tool description. +Beyond the schema's type/required/enum checks, `execute` rejects an empty or duplicate `content`, more than one `in_progress` task (a coherent plan has at most one task active), and any item key beyond `content`/`status` — an extended item shape (ids, nesting) fails loud instead of silently flattening, keeping the logged snapshot equal to what the model believes it wrote. Ordering and the discipline of keeping the list current are left to the model via the tool description. ## Rendering diff --git a/packages/todo/tool-todo/src/index.ts b/packages/todo/tool-todo/src/index.ts index 1da9914ac9..5297103e77 100644 --- a/packages/todo/tool-todo/src/index.ts +++ b/packages/todo/tool-todo/src/index.ts @@ -28,14 +28,21 @@ const DESCRIPTION = /** * Validate the value constraints the ParameterSchemaSpec can't express and build the canonical {@link - * TodoItem}[]: trimmed non-empty unique content and at most one in-progress item. The registry - * has already enforced the status enum; the cast below records that guarantee. + * TodoItem}[]: known keys only, trimmed non-empty unique content, and at most one in-progress + * item. The registry has already enforced the status enum; the cast below records that + * guarantee. Unknown keys are rejected rather than dropped — the logged snapshot must equal + * what the model believes it wrote (model-visible ⟺ logged), so a nested/extended item shape + * fails loud instead of silently flattening. */ function toTodoList(raw: { content: string; status: string }[]): TodoItem[] { const todos: TodoItem[] = [] const seen = new Set() let inProgress = 0 for (const item of raw) { + const unknown = Object.keys(item).filter(key => key !== 'content' && key !== 'status') + if (unknown.length > 0) { + throw new Error(`invalid todo: unknown key(s) ${unknown.map(k => JSON.stringify(k)).join(', ')} — each item is exactly { content, status }`) + } const content = item.content.trim() if (content.length === 0) { throw new Error('invalid todo: `content` must be a non-empty string') diff --git a/packages/todo/tool-todo/tests/tool-todo.spec.ts b/packages/todo/tool-todo/tests/tool-todo.spec.ts index 79758cb3ea..f83b202d32 100644 --- a/packages/todo/tool-todo/tests/tool-todo.spec.ts +++ b/packages/todo/tool-todo/tests/tool-todo.spec.ts @@ -126,6 +126,7 @@ describe('dsh-tool-todo', () => { { label: 'empty content', todos: [{ content: ' ', status: 'pending' }], fragment: 'non-empty' }, { label: 'duplicate content', todos: [{ content: 'dup', status: 'pending' }, { content: 'dup', status: 'completed' }], fragment: 'duplicate' }, { label: 'two in_progress', todos: [{ content: 'a', status: 'in_progress' }, { content: 'b', status: 'in_progress' }], fragment: 'in_progress' }, + { label: 'unknown item keys', todos: [{ content: 'a', status: 'pending', children: [] }], fragment: 'unknown key' }, ])('rejects $label as an isError result', async ({ todos, fragment }) => { const ctx = await setup() const result = await callTodo(ctx, { todos }) From 02abe3c821b811582f9cf4b572234d997fe297c3 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 23 Jul 2026 15:56:28 +0800 Subject: [PATCH 008/319] fix(gui): todo-row guards valid-JSON invalid-shape args before dereferencing null roots, non-object roots, and null array items (retained verbatim on a rejected tool/call) now take the documented generic-summary fallback instead of throwing into the row error boundary. --- .../client/ui-conversation/tests/todo-panel.spec.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/client/ui-conversation/tests/todo-panel.spec.tsx b/packages/client/ui-conversation/tests/todo-panel.spec.tsx index 761cb4dfd3..d3c5b93ecf 100644 --- a/packages/client/ui-conversation/tests/todo-panel.spec.tsx +++ b/packages/client/ui-conversation/tests/todo-panel.spec.tsx @@ -113,6 +113,16 @@ describe('TodoRow', () => { expect(openDetails).toHaveBeenCalledTimes(1) }) + it.each([ + { label: 'null root', argsRaw: 'null' }, + { label: 'non-object root', argsRaw: '42' }, + { label: 'null items', argsRaw: '{"todos":[null]}' }, + ])('falls back to the generic summary on valid JSON with an invalid shape ($label)', ({ argsRaw }) => { + render() + // No throw, and the generic others summary carries the raw args verbatim. + expect(screen.getByText(`todo_write · ${argsRaw}`)).toBeTruthy() + }) + it('window-truncated result (call head lost) falls back to the callId summary', () => { render() expect(screen.getByText('todo_write · c1')).toBeTruthy() From f2a9c09429d4826d05d751c9d69add16a26bf7d4 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 23 Jul 2026 15:56:28 +0800 Subject: [PATCH 009/319] fix(gui): fixture emits todo/write at the real tool boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tool appends the snapshot mid-execution, between tool/call and tool/result; the fixture spliced it after step/end with a post-turn timestamp, so acceptance never exercised the production ordering. A spec pins call → snapshot → result with monotonic times. --- packages/client/connection/src/client/fixture.ts | 8 ++++++-- packages/client/connection/tests/fixture.spec.ts | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 82b3925aac..062a39a2cd 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -133,8 +133,12 @@ function buildAlphaLog(): SessionEvent[] { ] const todoArgs = JSON.stringify({ todos: fixtureTodos }) toolTurn(64, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.') - // The tool appends the snapshot event inside its own turn; splice it before the trailing turn/end. - events.splice(events.length - 1, 0, { type: 'todo/write', time: time += 800, data: { todos: fixtureTodos } }) + // The real tool appends the snapshot mid-execution — between tool/call and + // tool/result — so the fixture reproduces that exact ordering (the last + // toolTurn events run ... tool/call, tool/result, step/end, turn/end). + const callIndex = events.length - 4 + const callTime = events[callIndex]?.time as number + events.splice(callIndex + 1, 0, { type: 'todo/write', time: callTime + 400, data: { todos: fixtureTodos } }) events.forEach((e, i) => { e.seq = i }) return events as unknown as SessionEvent[] } diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index ac32955c36..ac363334bd 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -71,6 +71,21 @@ describe('createFixtureApi', () => { expect(empty.result.value).toEqual({ events: [], hasMore: false }) }) + it('emits the todo/write snapshot at the real tool boundary: between tool/call and tool/result, timestamps monotonic', async () => { + const api = createFixtureApi() + const tail = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 10 })) + if (!tail.result.ok) throw new Error('history failed') + const events = tail.result.value.events.map(e => e.event) + const todoAt = events.findIndex(e => e.type === 'todo/write') + expect(todoAt).toBeGreaterThan(0) + // Production ordering (the tool appends mid-execution): call → snapshot → result. + expect(events[todoAt - 1]?.type).toBe('tool/call') + expect(events[todoAt + 1]?.type).toBe('tool/result') + const times = events.slice(todoAt - 1, todoAt + 2).map(e => e.time) + expect(times[0]).toBeLessThanOrEqual(times[1] ?? 0) + expect(times[1]).toBeLessThanOrEqual(times[2] ?? 0) + }) + it('create adds a session and pushes host/session-added to open host streams', async () => { const api = createFixtureApi() const abort = new AbortController() From 3b1ad3ee6c37332f5cb7ddad21f4169220b754d3 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 23 Jul 2026 15:56:28 +0800 Subject: [PATCH 010/319] test(gui): keyless assembled todo-display pass in the fixture smoke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight real bundles through the DI chain in ?fixture mode: plan strip content, dedicated row summary + details linkage, collapse hint, zero page errors — the CI-gated assembled-surface coverage for the todo display. --- apps/web/tests/smoke-fixture.e2e.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/apps/web/tests/smoke-fixture.e2e.ts b/apps/web/tests/smoke-fixture.e2e.ts index 0726d14c8b..3b997442f5 100644 --- a/apps/web/tests/smoke-fixture.e2e.ts +++ b/apps/web/tests/smoke-fixture.e2e.ts @@ -285,6 +285,28 @@ describe('web boot chain success pass (keyless, nine real bundles, ?fixture)', ( expect(await restoredInput.getAttribute('placeholder')).toBe('回复生成中,可停止后再输入') }) + it('renders the todo plan strip and the dedicated todo_write row off the session events', async () => { + onTestFailed(() => saveFailureShot(page, 'smoke-todo-display')) + // The question pass above already opened the fixture session; the strip + // reads the todo/write snapshot (tail-page projection + window replay). + const panel = page.locator('[data-testid="todo-panel"]') + await panel.waitFor({ timeout: 15_000 }) + const text = await panel.innerText() + for (const expected of ['Plan', '1/3', '梳理需求', '实现 fixture 样本', '浏览器验收']) { + expect(text).toContain(expected) + } + // The dedicated row renders through the keyed toolview hole with the plan summary. + const row = page.locator('[data-sample="todo-row"]') + await row.scrollIntoViewIfNeeded() + expect(await row.innerText()).toContain('1/3 已完成') + // Collapse hides the list and surfaces the active item as the header hint. + await panel.locator('button').first().click() + const collapsed = await panel.innerText() + expect(collapsed).not.toContain('梳理需求') + expect(collapsed).toContain('实现 fixture 样本') + await panel.locator('button').first().click() // restore for later passes + }) + it('stayed clean: no page errors across the whole load chain', () => { expect(pageErrors).toEqual([]) }) From 356be9710ad03fef76b2952108f0bb7df0d9f32f Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 23 Jul 2026 15:56:28 +0800 Subject: [PATCH 011/319] docs(gui): record the window-scoped todos gap and the web consumer runtime README documents ConversationSnapshot.todos and its window-scoped limitation; the todo tool README and Agent Note name the web client among the event consumers; the web display note records the cold-load gap and fix directions (bilingual pair re-recorded). --- .../notes/implemented/feature/2026-06-29-todo-write-tool.md | 4 ++-- .../implemented/feature/2026-07-23-web-todo-display.i18n.yaml | 4 ++-- .../notes/implemented/feature/2026-07-23-web-todo-display.md | 2 +- .../implemented/feature/2026-07-23-web-todo-display.zh.md | 2 +- packages/client/runtime/README.md | 3 ++- packages/todo/tool-todo/README.md | 2 +- 6 files changed, 9 insertions(+), 8 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.md b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.md index bf5fceaafd..aa281a22a2 100644 --- a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.md +++ b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.md @@ -10,7 +10,7 @@ The harness gives the model bash and subagent tools but no way to record a struc ## Decision -Add a model-facing `todo_write(todos: [{ content, status }])` tool whose whole-list state lives on the event-sourced session log as a new `todo/write` `SessionEventMap` variant. Both the stdio UI and the ACP bridge render off the existing `session/event` — the ACP bridge maps the list to a `plan` sessionUpdate. +Add a model-facing `todo_write(todos: [{ content, status }])` tool whose whole-list state lives on the event-sourced session log as a new `todo/write` `SessionEventMap` variant. Every UI renders off the existing `session/event`: the stdio/TUI front doors show a persistent plan, the ACP bridge maps the list to a `plan` sessionUpdate, and the web client projects it into `ConversationSnapshot.todos` ([web todo display](2026-07-23-web-todo-display.md)). ### Whole-list replace, three-state status @@ -18,7 +18,7 @@ The model sends the ENTIRE list every call; the new list replaces the old (last- ### State on the session log, not a service -The list is appended as a `todo/write` event carrying the full `{ todos }` snapshot. The harness is event-sourced — the LLM history, tool calls, and turn structure all live on the log — so the todo list lives there too. This buys durability, replay, and `session/load` reconstruction for free: a reopened session re-derives the current list (the last `todo/write`) and the ACP bridge re-emits the `plan` on load, with no separate persistence backend, no in-memory service to rehydrate, and no extra wiring. An in-memory `ctx.todos` service would have had to reinvent all of that. +The list is appended as a `todo/write` event carrying the full `{ todos }` snapshot. The harness is event-sourced — the LLM history, tool calls, and turn structure all live on the log — so the todo list lives there too. This buys durability, replay, and `session/load` reconstruction for free: a reopened session re-derives the current list (the last `todo/write`) and the ACP bridge re-emits the `plan` on load, with no separate persistence backend, no in-memory service to rehydrate, and no extra wiring. An in-memory `ctx.todos` service would have had to reinvent all of that. (Full-log consumers get this reconstruction outright; the web client's paged window currently sees only the tail page — the gap and its fix directions are recorded in the [web todo display note](2026-07-23-web-todo-display.md).) ### NOT a surface event diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml index d8a6a517bf..1bfe2b59f3 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-23-web-todo-display.md: 6b6af215016b2f73baa088653fcf133dbbfd3449 -2026-07-23-web-todo-display.zh.md: a06083190ba7b9b030a48aa7dc2c3177853f3cee +2026-07-23-web-todo-display.md: 003ab29546e8098331496641a28d095a1dd95fed +2026-07-23-web-todo-display.zh.md: 200fd1d44dca7c5bcaa4f47ac31e26cb1f0f8d3d diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md index 6b6af21501..003ab29546 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md @@ -32,4 +32,4 @@ The dedicated `todo_write` chat row registers through the named `ctx.toolviews` ## Consequences -Replay correctness is owned by one code path: any future change to window rebuild keeps todos consistent for free, and the fixture (fx-alpha turn 63) plus `scripts/verify-todo-display.mjs` pin the full chain (panel visibility, row summary, details linkage, collapse, dark theme) in a real chromium. `todos` is a required `ConversationSnapshot` field, so scripted fakes in specs must carry it. The ACP bridge's todo → `plan` mapping and the TUI panel are untouched; the web surfaces render the same event with no new wire vocabulary. +Replay correctness is owned by one code path: any future change to window rebuild keeps todos consistent for free, and the fixture (fx-alpha turn 63) plus `scripts/verify-todo-display.mjs` pin the full chain (panel visibility, row summary, details linkage, collapse, dark theme) in a real chromium. `todos` is a required `ConversationSnapshot` field, so scripted fakes in specs must carry it. The ACP bridge's todo → `plan` mapping and the TUI panel are untouched; the web surfaces render the same event with no new wire vocabulary. Known gap: the projection is window-scoped — reopening a session whose last `todo/write` precedes the tail history page shows an empty plan until the user pages back to it; restoring the tool note's cold-load reconstruction promise needs the current projection independent of the display window (host-attached on the history response, or a dedicated read). diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md index a06083190b..200fd1d44d 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md @@ -32,4 +32,4 @@ Status: implemented ## Consequences -回放正确性由一条代码路径掌管:未来对窗口重建的任何改动都免费保持 todos 一致;fx-alpha 第 63 轮的 fixture(测试前置数据)加 `scripts/verify-todo-display.mjs` 在真实 chromium 里钉住整条链(面板可见性、行摘要、详情联动、折叠、深色主题)。`todos` 是 `ConversationSnapshot` 的必填字段,所以 spec 里脚本化的 fake 必须带上它。ACP 桥接的 todo → `plan` 映射与 TUI 面板均未受改动;Web 各面渲染同一个事件,不引入任何新的协议词汇。 +回放正确性由一条代码路径掌管:未来对窗口重建的任何改动都免费保持 todos 一致;fx-alpha 第 63 轮的 fixture(测试前置数据)加 `scripts/verify-todo-display.mjs` 在真实 chromium 里钉住整条链(面板可见性、行摘要、详情联动、折叠、深色主题)。`todos` 是 `ConversationSnapshot` 的必填字段,所以 spec 里脚本化的 fake 必须带上它。ACP 桥接的 todo → `plan` 映射与 TUI 面板均未受改动;Web 各面渲染同一个事件,不引入任何新的协议词汇。已知缺口:该投影以窗口为界——重新打开一个最后一次 `todo/write` 落在尾页之前的会话时,计划面板为空,直到用户翻页翻到它;要兑现工具 Note 里冷加载重建的承诺,需要一份独立于显示窗口的当前投影(history 响应由 host 附带,或提供专门的读取口)。 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 4fd5d15905..a9d03279f1 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-client-runtime -Client cordis boot + core services: SlotsService (Service wrapper over SlotCore + 'slots/changed' bridge), SessionsService (list store projection, scope tree, bindings, ancestry), the Session object layer (exported as a type; instances are owned and handed out by SessionsService — the manager/paging internals stay package-internal, tests reach them via src), ClientLoader (`./loader` subpath, statically held by the shell). Contract: api-contracts v3 §4. +Client cordis boot + core services: SlotsService (Service wrapper over SlotCore + 'slots/changed' bridge), SessionsService (list store projection, scope tree, bindings, ancestry), the Session object layer (exported as a type; instances are owned and handed out by SessionsService — the manager/paging internals stay package-internal, tests reach them via src), ClientLoader (`./loader` subpath, statically held by the shell). Contract: api-contracts v3 §4. `ConversationSnapshot` carries `todos` — the latest `todo/write` whole-list snapshot in the window, consumed as a per-event side effect (last write wins) and reset on every window rebuild. ## Session title projection @@ -19,3 +19,4 @@ None; this package neither assembles nor sends a provider request. - **`loader.unload` is a stub (throws not-implemented)** — the full chain (fiber dispose → registration cascade → style removal) lands with the HMR project. - **Scope teardown is stage-driven, single-occupant today** — the staged session follows `list.current` exactly (staging is the open signal: the event window opens ⟺ the session is on stage); a removed-while-staged session's scope survives frozen until the stage moves on, not until true observer count reaches zero. Resolution (`cell()`/`binding()`/`scope()`) is pure addressing, render-safe. The staged state can widen to a multi-pane list when concurrent panes land. - **Value imports of this package from plugin bundles must use the `/client` subpath** — the bare package name is not in the loader externals table and inlines a second module instance, whose private scope-tag Symbol never matches (the empty-state P0 postmortem). +- **`todos` is window-scoped** — the projection scans only the paged display window, so reopening a session whose last `todo/write` precedes the tail page shows an empty plan until the user pages back to it. Restoring the tool's cold-load reconstruction promise needs the current projection independent of the window (host-attached on history, or a dedicated read). diff --git a/packages/todo/tool-todo/README.md b/packages/todo/tool-todo/README.md index 2e06c7dde5..d7479a3bb2 100644 --- a/packages/todo/tool-todo/README.md +++ b/packages/todo/tool-todo/README.md @@ -18,7 +18,7 @@ Beyond the schema's type/required/enum checks, `execute` rejects an empty or dup ## Rendering -The canonical result is `{ todos, counts: { pending, inProgress, completed } }`; its Native renderer returns the compact update acknowledgement. The tool also writes the full `todo/write` session event. UIs subscribe to `session/event` and render that durable list themselves: the [TUI app](../../examples/tui-demo) shows a persistent plan, and the [ACP bridge](../../ui/acp) maps the list to a `plan` sessionUpdate (synthesizing the `priority` ACP requires). +The canonical result is `{ todos, counts: { pending, inProgress, completed } }`; its Native renderer returns the compact update acknowledgement. The tool also writes the full `todo/write` session event. UIs subscribe to `session/event` and render that durable list themselves: the [TUI app](../../examples/tui-demo) shows a persistent plan, the [ACP bridge](../../ui/acp) maps the list to a `plan` sessionUpdate (synthesizing the `priority` ACP requires), and the [web client](../../client/ui-conversation) renders a plan strip plus a dedicated tool row off `ConversationSnapshot.todos` ([Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-web-todo-display.md)). ## Export shape From 1687c2c15c8f09d6ac74a04e824bc5718e3be388 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 23 Jul 2026 16:40:48 +0800 Subject: [PATCH 012/319] fix(gui): tail history page carries the full-log todo projection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client's todos projection derived only from the paged display window, so reopening a session whose last todo/write preceded the tail page showed an empty plan until the user paged back — session-level state cannot be reconstructed from an arbitrary window. The host owns the full log, so the tail history response now attaches todos (latest todo/write backscan, the same posture as the view pairing); installWindow seeds it, window rebuilds preserve it, and any in-window or live write keeps overwriting it. The fixture mirrors the host; docs and both Agent Notes record the mechanism. --- .../feature/2026-06-29-todo-write-tool.md | 2 +- .../2026-07-23-web-todo-display.i18n.yaml | 4 +-- .../feature/2026-07-23-web-todo-display.md | 2 +- .../feature/2026-07-23-web-todo-display.zh.md | 2 +- .../client/connection/src/client/fixture.ts | 15 +++++++-- packages/client/runtime/README.md | 4 +-- .../runtime/src/client/sessions/session.ts | 16 ++++++--- packages/client/runtime/tests/fake-api.ts | 2 +- packages/client/runtime/tests/session.spec.ts | 23 +++++++++++-- packages/host/apiproxy/src/api/sessions.ts | 8 +++-- packages/host/runtime/src/api-proxy.ts | 17 ++++++++-- .../host/runtime/tests/api-proxy-view.spec.ts | 33 +++++++++++++++++++ 12 files changed, 107 insertions(+), 21 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.md b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.md index aa281a22a2..8f9cf7fd9c 100644 --- a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.md +++ b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.md @@ -18,7 +18,7 @@ The model sends the ENTIRE list every call; the new list replaces the old (last- ### State on the session log, not a service -The list is appended as a `todo/write` event carrying the full `{ todos }` snapshot. The harness is event-sourced — the LLM history, tool calls, and turn structure all live on the log — so the todo list lives there too. This buys durability, replay, and `session/load` reconstruction for free: a reopened session re-derives the current list (the last `todo/write`) and the ACP bridge re-emits the `plan` on load, with no separate persistence backend, no in-memory service to rehydrate, and no extra wiring. An in-memory `ctx.todos` service would have had to reinvent all of that. (Full-log consumers get this reconstruction outright; the web client's paged window currently sees only the tail page — the gap and its fix directions are recorded in the [web todo display note](2026-07-23-web-todo-display.md).) +The list is appended as a `todo/write` event carrying the full `{ todos }` snapshot. The harness is event-sourced — the LLM history, tool calls, and turn structure all live on the log — so the todo list lives there too. This buys durability, replay, and `session/load` reconstruction for free: a reopened session re-derives the current list (the last `todo/write`) and the ACP bridge re-emits the `plan` on load, with no separate persistence backend, no in-memory service to rehydrate, and no extra wiring. An in-memory `ctx.todos` service would have had to reinvent all of that. (Full-log consumers get this reconstruction outright; the web client's paged window gets it from the tail history page's host-computed projection — see the [web todo display note](2026-07-23-web-todo-display.md).) ### NOT a surface event diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml index 1bfe2b59f3..e3c6868165 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-23-web-todo-display.md: 003ab29546e8098331496641a28d095a1dd95fed -2026-07-23-web-todo-display.zh.md: 200fd1d44dca7c5bcaa4f47ac31e26cb1f0f8d3d +2026-07-23-web-todo-display.md: a5b45f288518cd53594aea724fb8eba532a1142d +2026-07-23-web-todo-display.zh.md: d960a5ab402d9f1c53ddecc7838b19da0743ef3b diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md index 003ab29546..a5b45f2885 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md @@ -32,4 +32,4 @@ The dedicated `todo_write` chat row registers through the named `ctx.toolviews` ## Consequences -Replay correctness is owned by one code path: any future change to window rebuild keeps todos consistent for free, and the fixture (fx-alpha turn 63) plus `scripts/verify-todo-display.mjs` pin the full chain (panel visibility, row summary, details linkage, collapse, dark theme) in a real chromium. `todos` is a required `ConversationSnapshot` field, so scripted fakes in specs must carry it. The ACP bridge's todo → `plan` mapping and the TUI panel are untouched; the web surfaces render the same event with no new wire vocabulary. Known gap: the projection is window-scoped — reopening a session whose last `todo/write` precedes the tail history page shows an empty plan until the user pages back to it; restoring the tool note's cold-load reconstruction promise needs the current projection independent of the display window (host-attached on the history response, or a dedicated read). +Replay correctness is owned by one code path: any future change to window rebuild keeps todos consistent for free, and the fixture (fx-alpha turn 63) plus `scripts/verify-todo-display.mjs` pin the full chain (panel visibility, row summary, details linkage, collapse, dark theme) in a real chromium. `todos` is a required `ConversationSnapshot` field, so scripted fakes in specs must carry it. The ACP bridge's todo → `plan` mapping and the TUI panel are untouched; the web surfaces render the same event with no new wire vocabulary. Cold-load reconstruction is host-backed: the tail history page carries `todos` — the full-log latest `todo/write`, computed independently of the page window (the same backscan posture the view pairing uses) — so a reopened session restores the plan even when the last write precedes the window; the seeded value is preserved across window rebuilds and overwritten by any later write. diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md index 200fd1d44d..d960a5ab40 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md @@ -32,4 +32,4 @@ Status: implemented ## Consequences -回放正确性由一条代码路径掌管:未来对窗口重建的任何改动都免费保持 todos 一致;fx-alpha 第 63 轮的 fixture(测试前置数据)加 `scripts/verify-todo-display.mjs` 在真实 chromium 里钉住整条链(面板可见性、行摘要、详情联动、折叠、深色主题)。`todos` 是 `ConversationSnapshot` 的必填字段,所以 spec 里脚本化的 fake 必须带上它。ACP 桥接的 todo → `plan` 映射与 TUI 面板均未受改动;Web 各面渲染同一个事件,不引入任何新的协议词汇。已知缺口:该投影以窗口为界——重新打开一个最后一次 `todo/write` 落在尾页之前的会话时,计划面板为空,直到用户翻页翻到它;要兑现工具 Note 里冷加载重建的承诺,需要一份独立于显示窗口的当前投影(history 响应由 host 附带,或提供专门的读取口)。 +回放正确性由一条代码路径掌管:未来对窗口重建的任何改动都免费保持 todos 一致;fx-alpha 第 63 轮的 fixture(测试前置数据)加 `scripts/verify-todo-display.mjs` 在真实 chromium 里钉住整条链(面板可见性、行摘要、详情联动、折叠、深色主题)。`todos` 是 `ConversationSnapshot` 的必填字段,所以 spec 里脚本化的 fake 必须带上它。ACP 桥接的 todo → `plan` 映射与 TUI 面板均未受改动;Web 各面渲染同一个事件,不引入任何新的协议词汇。冷加载重建由 host 兜底:history 尾页附带 `todos`——全量 log 上最新一次 `todo/write` 的投影,独立于分页窗口计算(与 view 配对同一种 backscan 姿势)——因此重开会话时即使最后一次写入落在窗口之前,计划也照常恢复;播种值跨窗口重建保留,之后的任何写入照常覆盖。 diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 062a39a2cd..3a84961677 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -6,7 +6,7 @@ // approval/question requests exercise replay and composer takeover with stable rpcIds. import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' -import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' +import type { SessionEvent, SessionId, TodoItem } from '@deepseek-ai/dsh-session/types' import type { ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt, RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary, @@ -254,6 +254,15 @@ function pageOf( return { events, hasMore: start > 0 } } +/** Current todo projection over the full log (host parallel: latest todo/write, last write wins). */ +function backscanTodos(log: readonly SessionEvent[]): TodoItem[] | undefined { + for (let i = log.length - 1; i >= 0; i--) { + const event = log[i] + if (event !== undefined && event.type === 'todo/write') return event.data.todos + } + return undefined +} + interface StreamConn { push(envelope: RpcRequest): void } @@ -492,12 +501,14 @@ export function createFixtureApi(): ApiProxy { const log = logs.get(request.payload.sessionId) ?? [] // Snapshot at request time, deliver after the transit delay (mirrors a real host under latency). const page = pageOf(log, request.payload.beforeSeq, request.payload.maxMessages ?? 50) + // Tail page carries the session-level todo projection (host parallel: full-log backscan). + const todos = request.payload.beforeSeq === undefined ? backscanTodos(log) : undefined const doomed = failNextHistory failNextHistory = false const delay = historyDelayMs if (delay > 0) await new Promise(resolve => setTimeout(resolve, delay)) if (doomed) throw new Error('fixture: simulated history transport failure') - return ok(request, page) + return ok(request, { ...page, ...todos === undefined ? {} : { todos } }) }, prompt: (request) => { const { sessionId: id, mode, content } = request.payload diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index a9d03279f1..72c4f1f059 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-client-runtime -Client cordis boot + core services: SlotsService (Service wrapper over SlotCore + 'slots/changed' bridge), SessionsService (list store projection, scope tree, bindings, ancestry), the Session object layer (exported as a type; instances are owned and handed out by SessionsService — the manager/paging internals stay package-internal, tests reach them via src), ClientLoader (`./loader` subpath, statically held by the shell). Contract: api-contracts v3 §4. `ConversationSnapshot` carries `todos` — the latest `todo/write` whole-list snapshot in the window, consumed as a per-event side effect (last write wins) and reset on every window rebuild. +Client cordis boot + core services: SlotsService (Service wrapper over SlotCore + 'slots/changed' bridge), SessionsService (list store projection, scope tree, bindings, ancestry), the Session object layer (exported as a type; instances are owned and handed out by SessionsService — the manager/paging internals stay package-internal, tests reach them via src), ClientLoader (`./loader` subpath, statically held by the shell). Contract: api-contracts v3 §4. `ConversationSnapshot` carries `todos` — the session-level todo projection: seeded from the tail history page's full-log projection (`history` response `todos`), overwritten by every in-window or live `todo/write` (last write wins), and preserved across window rebuilds. ## Session title projection @@ -19,4 +19,4 @@ None; this package neither assembles nor sends a provider request. - **`loader.unload` is a stub (throws not-implemented)** — the full chain (fiber dispose → registration cascade → style removal) lands with the HMR project. - **Scope teardown is stage-driven, single-occupant today** — the staged session follows `list.current` exactly (staging is the open signal: the event window opens ⟺ the session is on stage); a removed-while-staged session's scope survives frozen until the stage moves on, not until true observer count reaches zero. Resolution (`cell()`/`binding()`/`scope()`) is pure addressing, render-safe. The staged state can widen to a multi-pane list when concurrent panes land. - **Value imports of this package from plugin bundles must use the `/client` subpath** — the bare package name is not in the loader externals table and inlines a second module instance, whose private scope-tag Symbol never matches (the empty-state P0 postmortem). -- **`todos` is window-scoped** — the projection scans only the paged display window, so reopening a session whose last `todo/write` precedes the tail page shows an empty plan until the user pages back to it. Restoring the tool's cold-load reconstruction promise needs the current projection independent of the window (host-attached on history, or a dedicated read). +- **`SessionSummary.title` is a display projection** — the wire summary carries no title yet; the cwd basename stands in, then the raw id. diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 304b2e6e02..aff1bc1b0f 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -327,13 +327,13 @@ export class Session implements ObservableSnapshot { this.openError = result.error return } - this.installWindow(result.value.events, result.value.hasMore) + this.installWindow(result.value.events, result.value.hasMore, result.value.todos) // Gap detection (§D.3-4): baseline past the window tail and liveBuffer did not cover it -> pull the tail page once more. const tailSeq = this.windowTailSeq() if (this.subscribedLastSeq !== null && tailSeq !== null && this.subscribedLastSeq > tailSeq) { result = (await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })).result if (generation !== this.openGeneration) return - if (result.ok) this.installWindow(result.value.events, result.value.hasMore) + if (result.ok) this.installWindow(result.value.events, result.value.hasMore, result.value.todos) } this.openState = 'open' } catch (error) { @@ -351,11 +351,15 @@ export class Session implements ObservableSnapshot { * Stitching MUST NOT route through acceptLiveEvent: openState is still 'loading' here * (doOpen flips it after install), so recursing would push every buffered event straight * back into liveBuffer where nothing ever drains it — a silent drop loop (audit S1). */ - private installWindow(entries: HistoryEntry[], hasMore: boolean): void { + private installWindow(entries: HistoryEntry[], hasMore: boolean, todos?: readonly TodoItem[]): void { this.events = entries.map(e => e.event) this.views = entries.map(e => e.view) this.baseSeq = this.events[0]?.seq ?? 0 this.hasMore = hasMore + // Session-level projection from the tail page (full-log latest todo/write, + // independent of the window); an in-window write below re-derives the same + // value, and later live events keep overwriting it. + if (todos !== undefined) this.todos = todos this.foldAdapter.reset(this.events, this.baseSeq, this.views) this.rebuildDerivedFromWindow() const buffered = this.liveBuffer @@ -494,14 +498,16 @@ export class Session implements ObservableSnapshot { /** Re-derive state (partial/openCalls/frozenNodes) from raw window events after a rebuild — keeps * paging/stitching consistent, and makes the live freeze and the history replay converge on the - * same interrupted nodes (chunks are logged, so the replayed sweep re-freezes identical text). */ + * same interrupted nodes (chunks are logged, so the replayed sweep re-freezes identical text). + * todos is deliberately NOT reset: it is session-level (seeded by the tail page's full-log + * projection, not derivable from an arbitrary window). The window always extends to the log + * tail, so an in-window todo/write can only overwrite it with the same latest value. */ private rebuildDerivedFromWindow(): void { this.partial = null this.openCalls.clear() this.callsRev++ this.frozenNodes = [] this.frozenRev++ - this.todos = [] for (let i = 0; i < this.events.length; i++) { const event = this.events[i] /* v8 ignore next -- dense-array guard: i stays within events.length, so the undefined arm needs a sparse array no caller builds. */ diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index 25f12c2b70..67907443f0 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -47,7 +47,7 @@ export class FakeApiClient implements IApiClient { onList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ items: [] })) onCreate: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId })) onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) - => Promise> = + => Promise> = () => Promise.resolve(ok({ events: [], hasMore: false })) onPrompt: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index 786bb9793b..990e14d1b4 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -22,9 +22,9 @@ function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session: return { api, session: new Session(SID, api) } } -function histResponse(events: SessionEvent[], hasMore = false) { +function histResponse(events: SessionEvent[], hasMore = false, todos?: { content: string; status: 'pending' | 'in_progress' | 'completed' }[]) { // history now returns HistoryEntry[] ({event, view?}); these tests are view-less. - return Promise.resolve(ok({ events: entries(events) as never[], hasMore })) + return Promise.resolve(ok({ events: entries(events) as never[], hasMore, ...todos === undefined ? {} : { todos } })) } describe('open', () => { @@ -170,6 +170,25 @@ describe('live event path', () => { expect(replayed.session.getSnapshot().todos).toEqual(listB) }) + it('seeds todos from the tail page projection when the last write precedes the window', async () => { + const list = [{ content: '窗口外的计划', status: 'in_progress' as const }] + // Cold open: the page window carries NO todo/write; the projection rides the response. + const { api, session } = makeSession() + api.onHistory = () => histResponse(plainTurn(100, 9, '问', '答'), true, list) + await session.open() + expect(session.getSnapshot().todos).toEqual(list) + // Paging an older window in must not clear the session-level projection. + api.onHistory = () => histResponse(plainTurn(94, 8, '旧问', '旧答'), false) + await session.loadOlder() + expect(session.getSnapshot().todos).toEqual(list) + // A later live write still overrides the seeded projection. + session.handleMuxEnvelope('r' as never, { + type: 'session/event', sessionId: SID, + event: ev.todoWrite(106, [{ content: '新计划', status: 'pending' as const }]), + }) + expect(session.getSnapshot().todos).toEqual([{ content: '新计划', status: 'pending' }]) + }) + it('repairs a seq gap by repulling the tail page instead of appending a hole', async () => { const { api, session } = await opened(plainTurn(0, 0, 'a', 'b')) // tail seq = 5 const repaired = [...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')] diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index 393303f817..ee97405063 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -5,7 +5,7 @@ */ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' -import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' +import type { SessionEvent, SessionId, TodoItem } from '@deepseek-ai/dsh-session/types' import type { RpcId, RpcRequest, RpcResponse } from './rpc.ts' import type { ToolEventView } from './events.ts' @@ -60,9 +60,13 @@ export interface SessionsApi { * Each entry pairs the raw SessionEvent with the host-computed view (tool events whose * presenter produced one, evaluated against the registry at pagination time); the client * rebuilds the surface from the events with the shared fold. + * The tail page (beforeSeq absent) also carries `todos` — the session's current todo + * projection (latest `todo/write` over the FULL log, independent of the page window) — + * so a paged client restores the plan without walking history; absent when the session + * never wrote one. Older pages omit it (the projection is session-level, not per-page). */ history(request: RpcRequest<{ sessionId: SessionId; beforeSeq?: number; maxMessages?: number }>): - Promise> + Promise> /** Sends a message. content is core's ContentBlock[] verbatim; mode maps 1:1 — queue→send, steer→steer. */ prompt(request: RpcRequest<{ sessionId: SessionId; mode: 'queue' | 'steer'; content: ContentBlock[] }>): diff --git a/packages/host/runtime/src/api-proxy.ts b/packages/host/runtime/src/api-proxy.ts index acf77751af..1b794c0c4b 100644 --- a/packages/host/runtime/src/api-proxy.ts +++ b/packages/host/runtime/src/api-proxy.ts @@ -8,7 +8,7 @@ import { mkdir, stat } from 'node:fs/promises' import type { Context } from 'cordis' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' -import type { JsonValue, Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import type { JsonValue, Session, SessionEvent, SessionHeader, SessionId, TodoItem } from '@deepseek-ai/dsh-session' import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import { foldSessionTitle } from '@deepseek-ai/dsh-session-title' import type { @@ -265,6 +265,15 @@ function backscanArgs(events: readonly SessionEvent[], callId: string): { name: return undefined } +/** Current todo projection: the latest `todo/write` over the full log (whole-list replace ⇒ last write wins); undefined when none. */ +function backscanTodos(events: readonly SessionEvent[]): TodoItem[] | undefined { + for (let i = events.length - 1; i >= 0; i--) { + const event = events[i] + if (event !== undefined && event.type === 'todo/write') return event.data.todos + } + return undefined +} + /** * Thrown by the cold-resume path when the id names no servable session * (absent from the store, or a pre-project legacy log without a cwd). @@ -435,7 +444,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId)) return { event, ...view === undefined ? {} : { view } } }) - return ok(request, { events: entries, hasMore: page.hasMore }) + // Tail page carries the session-level todo projection over the FULL + // log (the page window may not contain the last todo/write; a paged + // client cannot reconstruct session-level state from it). + const todos = beforeSeq === undefined ? backscanTodos(found.agent.session.events) : undefined + return ok(request, { events: entries, hasMore: page.hasMore, ...todos === undefined ? {} : { todos } }) }, async prompt(request) { diff --git a/packages/host/runtime/tests/api-proxy-view.spec.ts b/packages/host/runtime/tests/api-proxy-view.spec.ts index a7dcdc73c5..41f064a06f 100644 --- a/packages/host/runtime/tests/api-proxy-view.spec.ts +++ b/packages/host/runtime/tests/api-proxy-view.spec.ts @@ -154,6 +154,39 @@ describe('mux live view computation', () => { expect('view' in (byKey.get('tool/result:h-plain') ?? {})).toBe(false) }) + it('tail page carries the full-log todo projection; older pages and todo-less sessions omit it', async () => { + const { ctx } = await harness() + const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) + const session = ctx.sessions.create() + ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) + // Superseded write early in the log, latest write later; enough messages to page. + session.append('todo/write', { todos: [{ content: 'old', status: 'pending' }] }) + for (let turn = 0; turn < 6; turn++) { + session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('user/message', { content: [{ type: 'text', text: `q${turn}` }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + session.append('assistant/message', { turn, step: 0, content: [{ type: 'text', text: `a${turn}` }], provenance: { provider: 'p', model: 'm' } }, { surfaceOp: 'append' }) + session.append('turn/end', { turn, reason: { kind: 'completed' } }) + } + session.append('todo/write', { todos: [{ content: 'current', status: 'in_progress' }] }) + + // Tail page limited to 2 messages: the latest todo/write may or may not sit + // in the window — the projection must come from the FULL log either way. + const tail = await api.sessions.history({ rpcId: RpcId('t-todos'), payload: { sessionId: session.id, maxMessages: 2 } }) + if (!tail.result.ok) throw new Error('history failed') + expect(tail.result.value.todos).toEqual([{ content: 'current', status: 'in_progress' }]) + // An older page omits the projection (session-level, tail-page-only). + const boundary = tail.result.value.events[0]?.event.seq ?? 0 + const older = await api.sessions.history({ rpcId: RpcId('t-todos-2'), payload: { sessionId: session.id, beforeSeq: boundary, maxMessages: 2 } }) + if (!older.result.ok) throw new Error('older failed') + expect('todos' in older.result.value).toBe(false) + // A session with no todo/write anywhere omits the field. + const bare = ctx.sessions.create() + ctx.agents.register({ id: bare.id, session: bare, status: 'idle', ctx } as Agent) + const bareTail = await api.sessions.history({ rpcId: RpcId('t-todos-3'), payload: { sessionId: bare.id } }) + if (!bareTail.result.ok) throw new Error('bare failed') + expect('todos' in bareTail.result.value).toBe(false) + }) + it('drops a disposed session from the live open-call table (result after dispose gets no view)', async () => { const { ctx } = await harness() const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' }) From beb191d87fe16b5ad69e6848f4b88d51c2e7089d Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 24 Jul 2026 11:47:29 +0800 Subject: [PATCH 013/319] fix(gui): admit todos in the history wire schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sessionHistoryValueSchema declared only events/hasMore, so the fetch carrier's Zod parse stripped the tail page's todos projection — the in-process and fixture paths carried it while a real WebApiClient lost it. The fetch-carrier spec pins the field through the wire round trip. --- packages/host/apiproxy/src/api/sessions.schema.ts | 7 +++++++ packages/host/apiproxy/tests/fetch-carrier.spec.ts | 12 ++++++++++++ 2 files changed, 19 insertions(+) diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index 3edf0e6014..bb77383388 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -78,10 +78,17 @@ export const historyEntrySchema = z.object({ view: toolEventViewSchema.optional(), }) satisfies z.ZodType> +/** One todo item of the tail page's session-level projection (the todo/write payload shape). */ +export const todoItemSchema = z.object({ + content: z.string(), + status: z.union([z.literal('pending'), z.literal('in_progress'), z.literal('completed')]), +}) + /** session.history response value. */ export const sessionHistoryValueSchema = z.object({ events: z.array(historyEntrySchema), hasMore: z.boolean(), + todos: z.array(todoItemSchema).optional(), }) satisfies z.ZodType>> /** ContentBlock passthrough: core is merge-extensible — the type discriminant envelope is strict, the rest stays wide. */ diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index d097daecef..3c2260a2ae 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -25,6 +25,12 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra return { rpcId: request.rpcId, result: { ok: true, value: { sessionId: 's-new' as never } } } }, async history(request) { + if (request.payload.sessionId === ('with-todos' as never)) { + return { + rpcId: request.rpcId, + result: { ok: true, value: { events: [], hasMore: false, todos: [{ content: 'current', status: 'in_progress' as const }] } }, + } + } return { rpcId: request.rpcId, result: { ok: false, error: { code: 'session-not-found', message: 'nope', details: { sessionId: request.payload.sessionId } } }, @@ -69,6 +75,12 @@ describe('unary round trip (handler ⇄ client, no network)', () => { expect(response.rpcId).toMatch(/[0-9a-f-]{36}/) }) + it('carries the tail-page todos projection through the wire schema (Zod must not strip it)', async () => { + const response = await client().sessions.history({ sessionId: 'with-todos' as never }) + expect(response.result.ok).toBe(true) + if (response.result.ok) expect(response.result.value.todos).toEqual([{ content: 'current', status: 'in_progress' }]) + }) + it('carries a business error as 200 + error result', async () => { const response = await client().sessions.history({ sessionId: 'missing' as never }) expect(response.result.ok).toBe(false) From c1ae98940cd4536d9c0ac00ddebac76de3f7f3db Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 24 Jul 2026 11:47:29 +0800 Subject: [PATCH 014/319] fix(gui): gap repair adopts the repull response's todos projection repairGap installed the repulled window without the response projection; a todo/write missed during the gap and already outside the new tail page kept the stale list. The spec pins adoption through the repair path. --- .../client/runtime/src/client/sessions/session.ts | 2 +- packages/client/runtime/tests/session.spec.ts | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index aff1bc1b0f..abe2a4b30c 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -410,7 +410,7 @@ export class Session implements ObservableSnapshot { const { result } = await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES }) // Failure or superseded by a full resync: drop — the resync path rebuilds and clears the buffer itself. if (result.ok && generation === this.openGeneration && this.openState === 'open') { - this.installWindow(result.value.events, result.value.hasMore) + this.installWindow(result.value.events, result.value.hasMore, result.value.todos) } } catch (error) { console.error('[web-runtime] gap repair failed:', error) diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index 990e14d1b4..8940e7fe78 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -202,6 +202,21 @@ describe('live event path', () => { const seqs = session.getSnapshot().nodes.map(n => n.seq) expect(seqs).toEqual([1, 3, 7, 9]) // both turns' user/assistant, no hole, no duplicate 9 }) + + it('gap repair adopts the repull response projection (a missed todo/write outside the new tail page)', async () => { + const { api, session } = await opened(plainTurn(0, 0, 'a', 'b')) // tail seq = 5 + expect(session.getSnapshot().todos).toEqual([]) + // The missed range contained a todo/write that the repulled page no longer + // covers; the response's session-level projection is the only carrier. + const current = [{ content: '断线期间写的', status: 'in_progress' as const }] + api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...plainTurn(8, 1, 'c', 'd')], false, current) + session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event: ev.assistant(11, 1, 'd') }) + await vi.waitFor(() => { + expect(api.callsOf('session.history').length).toBe(2) + }) + await Promise.resolve() + expect(session.getSnapshot().todos).toEqual(current) + }) }) describe('paging', () => { From ba75229638ba659117e3b395c4399f07da5914b9 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 24 Jul 2026 22:47:01 +0800 Subject: [PATCH 015/319] fix(tool-todo): declare the unknown-key rejection in the item schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit additionalProperties stays true in the published schema while execute rejected extra keys, so generated typings and validation disagreed with runtime behavior. The item schema now declares additionalProperties: false — the registry's arg validation rejects extra keys with a path-qualified violation before execute runs — and the redundant manual check is dropped (tool catalog regenerated). --- docs/tool-catalog.md | 2 +- packages/todo/tool-todo/src/index.ts | 16 ++++++---------- packages/todo/tool-todo/tests/tool-todo.spec.ts | 2 +- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index c10b0c90a7..864b5378d8 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -898,7 +898,7 @@ Record and update a structured task list for the current work. Send the ENTIRE l "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", - "additionalProperties": true, + "additionalProperties": false, "properties": { "content": { "type": "string", diff --git a/packages/todo/tool-todo/src/index.ts b/packages/todo/tool-todo/src/index.ts index 5297103e77..66b0a8ab12 100644 --- a/packages/todo/tool-todo/src/index.ts +++ b/packages/todo/tool-todo/src/index.ts @@ -28,21 +28,17 @@ const DESCRIPTION = /** * Validate the value constraints the ParameterSchemaSpec can't express and build the canonical {@link - * TodoItem}[]: known keys only, trimmed non-empty unique content, and at most one in-progress - * item. The registry has already enforced the status enum; the cast below records that - * guarantee. Unknown keys are rejected rather than dropped — the logged snapshot must equal - * what the model believes it wrote (model-visible ⟺ logged), so a nested/extended item shape - * fails loud instead of silently flattening. + * TodoItem}[]: trimmed non-empty unique content and at most one in-progress item. The registry + * has already enforced the status enum and rejected unknown item keys (`additionalProperties: + * false` — the logged snapshot must equal what the model believes it wrote, so a nested/extended + * item shape fails loud at the schema boundary instead of silently flattening); the cast below + * records that guarantee. */ function toTodoList(raw: { content: string; status: string }[]): TodoItem[] { const todos: TodoItem[] = [] const seen = new Set() let inProgress = 0 for (const item of raw) { - const unknown = Object.keys(item).filter(key => key !== 'content' && key !== 'status') - if (unknown.length > 0) { - throw new Error(`invalid todo: unknown key(s) ${unknown.map(k => JSON.stringify(k)).join(', ')} — each item is exactly { content, status }`) - } const content = item.content.trim() if (content.length === 0) { throw new Error('invalid todo: `content` must be a non-empty string') @@ -73,7 +69,7 @@ export function apply(ctx: Context): void { description: 'The COMPLETE task list, replacing any previous list.', items: { type: 'object', - additionalProperties: true, + additionalProperties: false, properties: { content: { type: 'string', required: true, description: 'What the task is — a short imperative line.' }, status: { diff --git a/packages/todo/tool-todo/tests/tool-todo.spec.ts b/packages/todo/tool-todo/tests/tool-todo.spec.ts index f83b202d32..2883cfdc02 100644 --- a/packages/todo/tool-todo/tests/tool-todo.spec.ts +++ b/packages/todo/tool-todo/tests/tool-todo.spec.ts @@ -126,7 +126,7 @@ describe('dsh-tool-todo', () => { { label: 'empty content', todos: [{ content: ' ', status: 'pending' }], fragment: 'non-empty' }, { label: 'duplicate content', todos: [{ content: 'dup', status: 'pending' }, { content: 'dup', status: 'completed' }], fragment: 'duplicate' }, { label: 'two in_progress', todos: [{ content: 'a', status: 'in_progress' }, { content: 'b', status: 'in_progress' }], fragment: 'in_progress' }, - { label: 'unknown item keys', todos: [{ content: 'a', status: 'pending', children: [] }], fragment: 'unknown key' }, + { label: 'unknown item keys', todos: [{ content: 'a', status: 'pending', children: [] }], fragment: 'not a declared property' }, ])('rejects $label as an isError result', async ({ todos, fragment }) => { const ctx = await setup() const result = await callTodo(ctx, { todos }) From 729dd3e1b75db4fab4e5f71641f7d9b7633adc13 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 24 Jul 2026 22:47:01 +0800 Subject: [PATCH 016/319] docs(agents): correct the web todo note to the shipped mechanisms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The projection sentence prescribed resetting todos on window rebuild — the implementation deliberately preserves the tail-page seed and lets only in-window/live writes overwrite. The toolview section named a nonexistent ctx.toolviews registry — the shipped seam is the keyed conversation.chat.toolview slot via ctx.slots.register. Both sides of the bilingual pair re-recorded. --- .../feature/2026-07-23-web-todo-display.i18n.yaml | 4 ++-- .../implemented/feature/2026-07-23-web-todo-display.md | 6 +++--- .../implemented/feature/2026-07-23-web-todo-display.zh.md | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml index e3c6868165..86de69efbf 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-23-web-todo-display.md: a5b45f288518cd53594aea724fb8eba532a1142d -2026-07-23-web-todo-display.zh.md: d960a5ab402d9f1c53ddecc7838b19da0743ef3b +2026-07-23-web-todo-display.md: 15368ee44c3a3aee1f1854964b9f3575d1b2fa92 +2026-07-23-web-todo-display.zh.md: c9ac6cda23721296a9cae5431adac4182feab72c diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md index a5b45f2885..15368ee44c 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.md @@ -14,15 +14,15 @@ Consume `todo/write` as a Session side effect, not a surface node, and render it ### Side-effect channel, converging with window replay -`applyEventSideEffects` gains a `todo/write` case (whole list, last write wins) and `rebuildDerivedFromWindow` resets it, so the live path and every window rebuild (paging, reconnect stitch, resync) converge on the same latest snapshot — the same shape partial/openCalls already use. `ConversationSnapshot.todos` is the read surface. This follows the event's own contract ("log-only UI state; never derived history"): surfacing each write as a conversation node would render superseded lists as if they were still standing. +`applyEventSideEffects` gains a `todo/write` case (whole list, last write wins). Unlike partial/openCalls, `rebuildDerivedFromWindow` deliberately does NOT reset it: the value is session-level — seeded by the tail history page's full-log projection — and an arbitrary window may not contain the latest write, so rebuilds (paging, reconnect stitch, resync) preserve it and only an in-window or live write overwrites it. `ConversationSnapshot.todos` is the read surface. This follows the event's own contract ("log-only UI state; never derived history"): surfacing each write as a conversation node would render superseded lists as if they were still standing. ### TodoPanel: the durable list as a persistent strip The skeleton pins the panel between the view area and the composer (the composer-card axis), hidden while empty, collapsible with the in-progress item as the collapsed one-line hint; ✓/●/○ glyphs mirror the TUI plan panel. It reads `snapshot.todos` via the framework `useSession` hook — no store, no service, no ctx. It lives inside `ConversationRoot` rather than the details column or its own slot: the details slot is single-occupant and selection-driven (a different lifetime than an always-on strip), and the slot table reserves no plan seat. The component is props-complete and framework-free, so a later relocation to a dedicated slot touches nothing inside it. -### TodoRow: the per-call row through the toolview registry +### TodoRow: the per-call row through the keyed toolview slot -The dedicated `todo_write` chat row registers through the named `ctx.toolviews` registry from `apply` (the cross-domain assembly point, the same posture as the bash samples but a product registration). The summary derives from call args (`N/M done · active item`); unparseable args fall back to the generic row summary; clicking opens the details column with the raw args. No `ToolEventView` is added for todo — presentation is client-owned, and the durable list renders from the session event, not the tool card. +The dedicated `todo_write` chat row is a plain registrant plugin (`todoToolview`, mounted from `apply`) that registers into the keyed `conversation.chat.toolview` slot via `ctx.slots.register` — the same seam and load-order posture as the bash sample (`inject: ['slots', 'conversation']`), but a product registration. The summary derives from call args (`N/M done · active item`); unparseable args fall back to the generic row summary; clicking opens the details column with the raw args. No `ToolEventView` is added for todo — presentation is client-owned, and the durable list renders from the session event, not the tool card. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md index d960a5ab40..c9ac6cda23 100644 --- a/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-web-todo-display.zh.md @@ -14,15 +14,15 @@ Status: implemented ### 副作用通道,与窗口回放收敛 -`applyEventSideEffects` 新增一个 `todo/write` 分支(整份列表,后写覆盖先写),`rebuildDerivedFromWindow` 将其重置,于是实时路径与每一次窗口重建(分页、重连缝合、resync)都收敛到同一份最新快照——partial/openCalls 已在用的正是这个形态。`ConversationSnapshot.todos` 是读取面。这遵循事件自身的契约(「仅日志 UI 状态,绝非派生历史」):把每次写入作为对话节点呈现,会让已被取代的列表看起来仍然有效。 +`applyEventSideEffects` 新增一个 `todo/write` 分支(整份列表,后写覆盖先写)。与 partial/openCalls 不同,`rebuildDerivedFromWindow` 刻意不重置它:该值是会话级的——由尾页 history 携带的全量 log 投影播种——而任意窗口未必包含最近一次写入,因此窗口重建(分页、重连缝合、resync)保留它,只有窗口内或实时的写入才会覆盖。`ConversationSnapshot.todos` 是读取面。这遵循事件自身的契约(「仅日志 UI 状态,绝非派生历史」):把每次写入作为对话节点呈现,会让已被取代的列表看起来仍然有效。 ### TodoPanel:长驻列表作为一条常驻横条 骨架把面板钉在视图区与 composer 之间(composer-card 轴),空列表时隐藏,可折叠——折叠态以进行中项作为单行提示;✓/●/○ 字形与 TUI plan 面板一致。它经框架 `useSession` hook 读取 `snapshot.todos`——无 store、无 service、无 ctx。它住在 `ConversationRoot` 之内,而非 details 列或自建 slot:details slot 单占用且由选中驱动(生命周期不同于一条常开横条),且 slot 表没有为 plan 预留席位。组件 props 完备且框架无关,因此日后迁往专用 slot 不触及其内部任何东西。 -### TodoRow:经 toolview 注册表的逐调用行 +### TodoRow:经 keyed toolview slot 的逐调用行 -专用的 `todo_write` 对话行经具名的 `ctx.toolviews` 注册表在 `apply` 中注册(跨域装配点,与 bash 样例同一姿态,但属产品级注册)。摘要由调用 args 推导(`N/M done · active item`);无法解析的 args 回退到通用行摘要;点击会以原始 args 打开 details 列。todo 不新增任何 `ToolEventView`——呈现归客户端所有,常驻列表从会话事件渲染,而非工具卡。 +专用的 `todo_write` 对话行是一个普通注册者插件(`todoToolview`,由 `apply` 挂载),经 `ctx.slots.register` 注册进 keyed 的 `conversation.chat.toolview` slot——与 bash 样例同一接缝、同一载序姿态(`inject: ['slots', 'conversation']`),但属产品级注册。摘要由调用 args 推导(`N/M done · active item`);无法解析的 args 回退到通用行摘要;点击会以原始 args 打开 details 列。todo 不新增任何 `ToolEventView`——呈现归客户端所有,常驻列表从会话事件渲染,而非工具卡。 ## Alternatives considered From 5818fd62242f8799484fbf166c11f1fc8434bf48 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 24 Jul 2026 23:00:56 +0800 Subject: [PATCH 017/319] =?UTF-8?q?ci:=20address=20second=20review=20round?= =?UTF-8?q?=20=E2=80=94=20dependabot=20lane,=20drop=20dead=20restore,=20up?= =?UTF-8?q?date=20topology=20note?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Route untrusted PRs (forks + Dependabot, same author test as e2e.yml) back to the hosted enterprise pool via a runs-on expression: Dependabot PRs are same-repo, so the previous head.repo guard admitted dependency-supplied code onto the persistent self-hosted VM. A single job with pool selection keeps all-checks-passed free of skips. - Drop the pnpm-store cache restore from this lane: on self-hosted the hosted-path cache actually HIT (Linux key) and spent ~52 s pulling 181 MB into a path pnpm never reads; the persistent local store already serves warm installs in seconds. - Update the larger-hosted-runners Agent Note (en/zh + i18n pairing record) so the decision record describes the shipped topology: coverage on the in-house vm-backup pool for trusted PRs, hosted Ubuntu 24.04 32-core retained for untrusted PRs. --- ...ence-based-larger-hosted-runners.i18n.yaml | 4 +- ...22-evidence-based-larger-hosted-runners.md | 2 +- ...evidence-based-larger-hosted-runners.zh.md | 2 +- .github/workflows/ci.yml | 39 +++++++++---------- 4 files changed, 23 insertions(+), 24 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml index 9d87cb9ad3..360395102e 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-22-evidence-based-larger-hosted-runners.md: aaeab4ed9ae9687598f9f1d4a862120405697672 -2026-07-22-evidence-based-larger-hosted-runners.zh.md: 72b69c85908990a9f35b60f4c0a2ce213f9c8134 +2026-07-22-evidence-based-larger-hosted-runners.md: c3e6344ae61669da4810090e558589875ca7536e +2026-07-22-evidence-based-larger-hosted-runners.zh.md: e5b322673b7a1eb004eb15b3784d21f500e83719 diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md index aaeab4ed9a..c3e6344ae6 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md @@ -12,7 +12,7 @@ Larger runners make it possible to pay setup once and parallelize inside the rep ## Decision -The enterprise keeps repo-restricted x64 larger-runner pools for Ubuntu and Windows. Ordinary pull requests name three 32-core pools directly: Ubuntu 24.04 for exhaustive coverage, Ubuntu latest for the remaining primary Node 24 inventory, and Windows 2025 for blocking Windows contracts. Public IPs are disabled, and workflow concurrency remains bounded because an autoscaling ceiling neither allocates idle machines nor makes repository work scale without limit. +The enterprise keeps repo-restricted x64 larger-runner pools for Ubuntu and Windows. Ordinary pull requests name two 32-core hosted pools directly: Ubuntu latest for the remaining primary Node 24 inventory and Windows 2025 for blocking Windows contracts. Exhaustive coverage moved off the metered Ubuntu 24.04 32-core pool onto the in-house self-hosted pool (`vm-backup` label: a 64-core VM running four always-on systemd-managed runner instances plus four registered spares) for trusted same-repo PRs; untrusted PRs — forks and Dependabot — keep coverage on the hosted Ubuntu 24.04 32-core pool so dependency-supplied code never reaches the persistent VM. Public IPs are disabled, and workflow concurrency remains bounded because an autoscaling ceiling neither allocates idle machines nor makes repository work scale without limit. The required primary path depends on those enterprise pools. Standard GitHub-hosted jobs retain the Node 22.19, Node 26, and Python SDK compatibility contracts, while the [portable recovery boundary](2026-07-23-portable-required-pull-request-ci.md) and [serial reference](2026-07-21-serial-cross-platform-ci-reference.md) keep complete standard-runner evidence available on `master`. `suite=larger-runner-benchmark` compares isolated critical lanes across provisioned sizes, and `suite=consolidated-runner-benchmark` compares whole aggregates. Each benchmark reports its observed processor and memory capacity before running repository work. diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md index 72b69c8590..e5b322673b 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -企业保留仅限本仓库使用的 Ubuntu 和 Windows x64 大型运行器池。普通拉取请求直接指定 3 个 32 核运行器池:Ubuntu 24.04 用于完整覆盖率,Ubuntu latest 用于其余主 Node 24 清单,Windows 2025 用于阻塞性 Windows 契约。公网 IP 已禁用;工作流并发仍设有边界,因为自动扩缩容上限既不会分配闲置机器,也不意味着仓库工作可以无限扩展。 +企业保留仅限本仓库使用的 Ubuntu 和 Windows x64 大型运行器池。普通拉取请求直接指定 2 个 32 核托管运行器池:Ubuntu latest 用于其余主 Node 24 清单,Windows 2025 用于阻塞性 Windows 契约。完整覆盖率已从计费的 Ubuntu 24.04 32 核池迁移至公司自有的自托管池(`vm-backup` 标签:一台 64 核虚拟机,运行 4 个常驻的 systemd 管理运行器实例,另有 4 个已注册备用位),仅面向可信的同仓库拉取请求;不可信的拉取请求——fork 与 Dependabot——的覆盖率仍在托管的 Ubuntu 24.04 32 核池上运行,确保依赖方提供的代码永远不会进入持久化虚拟机。公网 IP 已禁用;工作流并发仍设有边界,因为自动扩缩容上限既不会分配闲置机器,也不意味着仓库工作可以无限扩展。 必需主路径依赖这些企业级运行器池。GitHub 标准托管作业保留 Node 22.19、Node 26 和 Python SDK 兼容性契约,而[可移植恢复边界](2026-07-23-portable-required-pull-request-ci.md)与[串行参考流程](2026-07-21-serial-cross-platform-ci-reference.md)则在 `master` 上持续提供完整的标准运行器证据。`suite=larger-runner-benchmark` 比较已预配规格上相互独立的关键通道,`suite=consolidated-runner-benchmark` 则比较完整聚合流程。每项基准测试都会先报告实测的处理器和内存容量,再运行仓库工作。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd60593a85..dc45bc9a7a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,15 +76,19 @@ jobs: compression-level: 0 node-24-coverage: - # Same-repo PRs only: this lane runs on an in-house self-hosted runner, - # so fork-originated code must never land here. The repo is currently - # private with forking disabled; this guard keeps that invariant explicit - # if either setting ever changes. - if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository - # Runs on the in-house pool (self-hosted, 64-core) instead of the metered - # enterprise pool. The pool holds 4 always-on instances plus 4 registered - # spares; the runner service is systemd-managed and self-healing. - runs-on: [self-hosted, linux, x64, vm-backup] + if: github.event_name == 'pull_request' + # Trusted same-repo PRs run on the in-house pool (self-hosted, 64-core; + # 4 always-on systemd-managed instances plus 4 registered spares) instead + # of the metered enterprise pool. Untrusted PRs — forks and Dependabot + # (same-repo but dependency-supplied code; same author test as e2e.yml) — + # stay on the hosted enterprise pool so no untrusted code reaches the + # persistent self-hosted VM. Selecting the pool via runs-on keeps this a + # single job, so the all-checks-passed aggregate never sees a skip. + runs-on: >- + ${{ (github.event.pull_request.head.repo.full_name != github.repository + || github.event.pull_request.user.login == 'dependabot[bot]') + && 'dsh-enterprise-ubuntu-24-04-32core-test' + || fromJSON('["self-hosted", "linux", "x64", "vm-backup"]') }} name: node 24 / coverage env: DSH_COVERAGE_MAX_WORKERS: '24' @@ -94,17 +98,12 @@ jobs: with: persist-credentials: false - - uses: actions/cache/restore@v4 - with: - # Path must stay byte-identical to the save-side path in the master - # lane: actions/cache hashes the literal path into the cache version, - # so any variation (e.g. ~) would never match the saved cache. On - # self-hosted this restore simply misses and the persistent local - # pnpm store covers warm installs instead. - path: /home/runner/.local/share/pnpm/store/v11 - key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- + # No pnpm-store cache restore in this lane: on the self-hosted pool + # pnpm's persistent store lives outside /home/runner, so restoring the + # hosted cache here downloads ~180 MB into a path pnpm never reads + # (measured: 52 s restore, then a 2.8 s install straight from the + # persistent store). The rare hosted (untrusted-PR) run just does a + # cold install. - uses: actions/setup-node@v6 with: From 62bfc6b4fb49b41c403a6556a18050702cea2fc9 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 24 Jul 2026 23:10:19 +0800 Subject: [PATCH 018/319] docs(gui): bring the todo tool note's Chinese side along master gave 2026-06-29-todo-write-tool a Chinese counterpart; the English side's web-consumer sentences now translate across (in-body links keep their .md targets per the pairing contract) and the pair is re-recorded. The todo-panel fake gains the time/callTime fields ToolResultNode now requires. --- .../implemented/feature/2026-06-29-todo-write-tool.i18n.yaml | 4 ++-- .../implemented/feature/2026-06-29-todo-write-tool.zh.md | 4 ++-- packages/client/ui-conversation/tests/todo-panel.spec.tsx | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.i18n.yaml b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.i18n.yaml index e6fc4aba97..afec670de8 100644 --- a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-06-29-todo-write-tool.md: bf5fceaafd475224914212beb460fdbc42e3dd68 -2026-06-29-todo-write-tool.zh.md: 3afc03a393284e2a9c2d6e234bc95f0faebbfb48 +2026-06-29-todo-write-tool.md: 8f9cf7fd9c102871566bded4db6fddf47519f130 +2026-06-29-todo-write-tool.zh.md: 7abfe1b0f3433d047c72e1e8a17527cb4b10fdd9 diff --git a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.zh.md b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.zh.md index 3afc03a393..7abfe1b0f3 100644 --- a/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.zh.md +++ b/.agents/notes/implemented/feature/2026-06-29-todo-write-tool.zh.md @@ -10,7 +10,7 @@ harness 为模型提供了 bash 和 subagent 工具,却没有办法记录结 ## 决策 -新增一个面向模型的 `todo_write(todos: [{ content, status }])` 工具,其整列表状态作为新的 `todo/write` `SessionEventMap` 变体存储在事件溯源的会话日志上。stdio UI 和 ACP bridge 均从现有的 `session/event` 渲染;ACP bridge 将列表映射为 `plan` sessionUpdate。 +新增一个面向模型的 `todo_write(todos: [{ content, status }])` 工具,其整列表状态作为新的 `todo/write` `SessionEventMap` 变体存储在事件溯源的会话日志上。每个 UI 都从现有的 `session/event` 渲染:stdio/TUI 前门展示常驻计划,ACP bridge 将列表映射为 `plan` sessionUpdate,web 客户端将其投影进 `ConversationSnapshot.todos`([web todo 展示](2026-07-23-web-todo-display.md))。 ### 整列表替换,三态 status @@ -18,7 +18,7 @@ harness 为模型提供了 bash 和 subagent 工具,却没有办法记录结 ### 状态在会话日志上,而非服务 -列表作为 `todo/write` 事件追加到日志,携带完整的 `{ todos }` 快照。harness 是事件溯源的——LLM(大语言模型)历史、工具调用和轮次结构都在日志上——所以 todo 列表也在那里。这免费获得了持久性、回放和 `session/load` 重建:重新打开的会话从最后一条 `todo/write` 重新推导当前列表,ACP bridge 在加载时重新发出 `plan`,无需独立的持久化后端、无需重新注水的内存服务、无需额外接线。一个内存中的 `ctx.todos` 服务需要重新发明以上所有。 +列表作为 `todo/write` 事件追加到日志,携带完整的 `{ todos }` 快照。harness 是事件溯源的——LLM(大语言模型)历史、工具调用和轮次结构都在日志上——所以 todo 列表也在那里。这免费获得了持久性、回放和 `session/load` 重建:重新打开的会话从最后一条 `todo/write` 重新推导当前列表,ACP bridge 在加载时重新发出 `plan`,无需独立的持久化后端、无需重新注水的内存服务、无需额外接线。一个内存中的 `ctx.todos` 服务需要重新发明以上所有。(全量 log 消费者直接获得这份重建;web 客户端的分页窗口则从尾页 history 携带的 host 计算投影获得——见 [web todo 展示 Note](2026-07-23-web-todo-display.md)。) ### 不是 surface 事件 diff --git a/packages/client/ui-conversation/tests/todo-panel.spec.tsx b/packages/client/ui-conversation/tests/todo-panel.spec.tsx index d3c5b93ecf..196077e6a4 100644 --- a/packages/client/ui-conversation/tests/todo-panel.spec.tsx +++ b/packages/client/ui-conversation/tests/todo-panel.spec.tsx @@ -69,7 +69,7 @@ describe('TodoPanel', () => { }) const resultNode = (argsRaw: string, over?: Partial): ToolResultNode => ({ - kind: 'tool-result', seq: 10, callId: 'c1', + kind: 'tool-result', seq: 10, time: 2_000, callTime: 1_000, callId: 'c1', call: { name: 'todo_write', argsRaw }, content: [], isError: false, callView: null, resultView: null, ...over, }) From e532c9ccc245a2360df74bb6d4795ea1f3c13162 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 24 Jul 2026 23:12:20 +0800 Subject: [PATCH 019/319] ci: restore pnpm cache on the hosted leg only Keep the cache restore for the ephemeral hosted (untrusted-PR) leg where it is a genuine speedup, gated by the same expression as the runs-on pool selector; the self-hosted leg skips it and installs from the persistent local store. --- .github/workflows/ci.yml | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc45bc9a7a..d0d51fde9b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,12 +98,21 @@ jobs: with: persist-credentials: false - # No pnpm-store cache restore in this lane: on the self-hosted pool - # pnpm's persistent store lives outside /home/runner, so restoring the - # hosted cache here downloads ~180 MB into a path pnpm never reads - # (measured: 52 s restore, then a 2.8 s install straight from the - # persistent store). The rare hosted (untrusted-PR) run just does a - # cold install. + # Restore the pnpm-store cache only on the hosted (untrusted-PR) leg, + # where the VM is ephemeral and the same-region download is fast. On + # the self-hosted leg pnpm's persistent store lives outside + # /home/runner, so this restore would spend ~52 s pulling ~180 MB into + # a path pnpm never reads (measured; install then took 2.8 s straight + # from the persistent store). Condition mirrors the runs-on selector. + - uses: actions/cache/restore@v4 + if: >- + github.event.pull_request.head.repo.full_name != github.repository + || github.event.pull_request.user.login == 'dependabot[bot]' + with: + path: /home/runner/.local/share/pnpm/store/v11 + key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- - uses: actions/setup-node@v6 with: From 8d53d44b6055ce37aecdd22be1eb9d1429a96cae Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 24 Jul 2026 23:49:58 +0800 Subject: [PATCH 020/319] docs(ci): reconcile every present-tense topology description with the coverage lane move MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sweep all remaining sources that still described coverage as an enterprise 32-core job: the ci.yml jobs preamble, the three-job paragraph of the larger-hosted-runners note, and the required-pool sentence of the portable-recovery note — English and Chinese sides of both notes, with their i18n pairing records re-recorded. --- ...026-07-22-evidence-based-larger-hosted-runners.i18n.yaml | 4 ++-- .../2026-07-22-evidence-based-larger-hosted-runners.md | 2 +- .../2026-07-22-evidence-based-larger-hosted-runners.zh.md | 2 +- .../2026-07-23-portable-required-pull-request-ci.i18n.yaml | 4 ++-- .../process/2026-07-23-portable-required-pull-request-ci.md | 2 +- .../2026-07-23-portable-required-pull-request-ci.zh.md | 2 +- .github/workflows/ci.yml | 6 ++++-- 7 files changed, 12 insertions(+), 10 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml index 360395102e..4d781caa54 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-22-evidence-based-larger-hosted-runners.md: c3e6344ae61669da4810090e558589875ca7536e -2026-07-22-evidence-based-larger-hosted-runners.zh.md: e5b322673b7a1eb004eb15b3784d21f500e83719 +2026-07-22-evidence-based-larger-hosted-runners.md: 88b9e6d83777172d8afb6a391512e5f293b81171 +2026-07-22-evidence-based-larger-hosted-runners.zh.md: b1105f00cd08b1af633d258ea4ff28a835ce6074 diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md index c3e6344ae6..88b9e6d837 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md @@ -18,7 +18,7 @@ The required primary path depends on those enterprise pools. Standard GitHub-hos The former gate-level and coarse primary shard jobs are absent from the workflow. Their static, lint, coverage, snapshot, and scenario shard selectors are also absent from the repository, so an unused diagnostic path cannot preserve a second CI architecture. -Linux primary work uses three independent 32-core jobs. Coverage runs alone with its own worker bound, and the static scheduler runs alone so its result has no post-build consumer tail. After static gates finish, that job publishes its emitted `apps/*/lib`, `packages/*/*/lib`, and `vendor/*/lib` tree as a run-scoped artifact. The third job restores that exact tree, then starts lint, Node 24 runtime compatibility, build-backed snapshots, and all artifact consumers without repeating the build. Generated NodeNext consumer directories are excluded from ESLint discovery because the artifact check removes them while these processes overlap. The pnpm store and ESLint cache are restored without putting cache uploads on the pull-request critical path. Performance reports use each job's `startedAt` to `completedAt` interval; runner queue delay is capacity evidence, not repository execution time. +Linux primary work uses three independent jobs: static gates and the consumer tail on hosted 32-core pools, and coverage on the in-house self-hosted 64-core pool for trusted PRs (hosted 32-core for untrusted ones). Coverage runs alone with its own worker bound, and the static scheduler runs alone so its result has no post-build consumer tail. After static gates finish, that job publishes its emitted `apps/*/lib`, `packages/*/*/lib`, and `vendor/*/lib` tree as a run-scoped artifact. The third job restores that exact tree, then starts lint, Node 24 runtime compatibility, build-backed snapshots, and all artifact consumers without repeating the build. Generated NodeNext consumer directories are excluded from ESLint discovery because the artifact check removes them while these processes overlap. The pnpm store and ESLint cache are restored without putting cache uploads on the pull-request critical path. Performance reports use each job's `startedAt` to `completedAt` interval; runner queue delay is capacity evidence, not repository execution time. Windows shares one 32-core setup across the blocking build and production site plus observational built-artifact contracts. Linux owns the duplicate lint, coverage, and snapshot inventories because running those observational copies on Windows extends the paid critical path without adding a blocking platform claim. diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md index e5b322673b..b1105f00cd 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -18,7 +18,7 @@ Status: implemented 原有的门禁级和粗粒度主流程分片作业已从工作流中移除。相应的静态、lint、覆盖率、快照和场景分片选择器也已从仓库中移除,因此未使用的诊断路径无法继续维系第二套 CI 架构。 -Linux 主流程使用 3 个相互独立的 32 核作业。覆盖率单独运行,并设有自己的工作线程上限;静态调度器也单独运行,因此构建后的消费方不会拖延其结果。静态门禁完成后,该作业将其生成的 `apps/*/lib`、`packages/*/*/lib` 和 `vendor/*/lib` 目录树作为仅供本次运行使用的产物发布。第三个作业恢复完全相同的目录树,再让 lint、Node 24 运行时兼容性、依赖构建产物的快照和所有产物消费方基于构建完成后的工作树启动,而不重复构建。生成的 NodeNext 消费方目录不会纳入 ESLint 的文件发现范围,因为这些进程重叠执行时,产物检查会删除这些目录。pnpm store 和 ESLint 缓存会得到恢复,但缓存上传不会进入拉取请求关键路径。性能报告采用每个作业从 `startedAt` 到 `completedAt` 的区间;运行器排队延迟是容量证据,而非仓库执行时间。 +Linux 主流程使用 3 个相互独立的作业:静态门禁与消费方尾部作业运行在托管 32 核池上,覆盖率对可信拉取请求运行在公司自有的自托管 64 核池上(不可信请求仍用托管 32 核池)。覆盖率单独运行,并设有自己的工作线程上限;静态调度器也单独运行,因此构建后的消费方不会拖延其结果。静态门禁完成后,该作业将其生成的 `apps/*/lib`、`packages/*/*/lib` 和 `vendor/*/lib` 目录树作为仅供本次运行使用的产物发布。第三个作业恢复完全相同的目录树,再让 lint、Node 24 运行时兼容性、依赖构建产物的快照和所有产物消费方基于构建完成后的工作树启动,而不重复构建。生成的 NodeNext 消费方目录不会纳入 ESLint 的文件发现范围,因为这些进程重叠执行时,产物检查会删除这些目录。pnpm store 和 ESLint 缓存会得到恢复,但缓存上传不会进入拉取请求关键路径。性能报告采用每个作业从 `startedAt` 到 `completedAt` 的区间;运行器排队延迟是容量证据,而非仓库执行时间。 Windows 以一次 32 核环境设置同时承载阻塞性构建、生产网站和观测性的构建产物契约。重复的 lint、覆盖率和快照清单由 Linux 承担,因为在 Windows 上运行这些观测性副本会延长付费关键路径,却不会新增任何阻塞性平台契约。 diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml index f8b54b0ec5..ed97fe08a7 100644 --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-23-portable-required-pull-request-ci.md: 9cf8d97016300c5258c075879176aa6abd64e59e -2026-07-23-portable-required-pull-request-ci.zh.md: c6839a133d0c3fe7a699362f6168e17d827a5b61 +2026-07-23-portable-required-pull-request-ci.md: 29b2cfa3f431a4a8be4aaa685b16cffdb4bf2593 +2026-07-23-portable-required-pull-request-ci.zh.md: 8b6d067637ce83c09529977f463f16dfa4af5a8b diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md index 9cf8d97016..29b2cfa3f4 100644 --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.md @@ -12,7 +12,7 @@ Billing health, a runner definition's `Ready` state, and a large autoscaling cei ## Decision -[CI](../../../../.github/workflows/ci.yml) runs the required primary Node 24 and Windows jobs on repo-restricted enterprise 32-core pools. Standard `ubuntu-latest` jobs retain Node 22.19, Node 26, and Python SDK compatibility, and `master` runs complete serial Linux, macOS, and Windows references. Those standard-hosted jobs keep the portable execution boundary observable without duplicating the primary inventory on every pull request. +[CI](../../../../.github/workflows/ci.yml) runs the required primary Node 24 and Windows jobs on repo-restricted enterprise 32-core pools, except exhaustive coverage, which runs on the in-house self-hosted 64-core pool for trusted same-repo pull requests (hosted 32-core for forks and Dependabot). Standard `ubuntu-latest` jobs retain Node 22.19, Node 26, and Python SDK compatibility, and `master` runs complete serial Linux, macOS, and Windows references. Those standard-hosted jobs keep the portable execution boundary observable without duplicating the primary inventory on every pull request. The two Linux primary jobs, Node compatibility, Python SDK, and `windows node 24 / complete` remain dependencies of `all checks passed`; branch protection continues to require `e2e` and `all checks passed`. There is no automatic fallback when an enterprise label cannot allocate: the standard jobs continue to report their own contracts, but they cannot manufacture the missing required result. diff --git a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md index c6839a133d..8b6d067637 100644 --- a/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-07-23-portable-required-pull-request-ci.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -[CI](../../../../.github/workflows/ci.yml) 在仅限本仓库使用的企业级 32 核运行器池上运行必需的主 Node 24 作业和 Windows 作业。标准 `ubuntu-latest` 作业保留 Node 22.19、Node 26 和 Python SDK 兼容性,`master` 则运行完整的 Linux、macOS 和 Windows 串行参考流程。这些标准托管作业让可移植执行边界保持可观测,而不必在每个拉取请求中重复主清单。 +[CI](../../../../.github/workflows/ci.yml) 在仅限本仓库使用的企业级 32 核运行器池上运行必需的主 Node 24 作业和 Windows 作业;唯一例外是完整覆盖率——可信的同仓库拉取请求在公司自有的自托管 64 核池上运行(fork 与 Dependabot 仍用托管 32 核池)。标准 `ubuntu-latest` 作业保留 Node 22.19、Node 26 和 Python SDK 兼容性,`master` 则运行完整的 Linux、macOS 和 Windows 串行参考流程。这些标准托管作业让可移植执行边界保持可观测,而不必在每个拉取请求中重复主清单。 两项 Linux 主作业、Node 兼容性、Python SDK 和 `windows node 24 / complete` 继续作为 `all checks passed` 的依赖项;分支保护继续要求 `e2e` 和 `all checks passed`。企业级运行器标签无法分配运行器时没有自动后备机制:标准作业会继续报告各自的契约,但无法产出缺失的必需结果。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0d51fde9b..a21086754b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,8 +27,10 @@ env: jobs: - # Three enterprise jobs isolate coverage, static analysis, and the - # build-backed consumer tail. The static job publishes its exact build so + # Three independent Linux jobs isolate coverage, static analysis, and the + # build-backed consumer tail: static and consumers on hosted enterprise + # 32-core pools; coverage on the in-house self-hosted pool for trusted PRs + # (hosted for forks/Dependabot). The static job publishes its exact build so # consumers do not repeat the longest part of their critical path. node-24: if: github.event_name == 'pull_request' From 34eef10f045cb8874db6426e3847465c6409afd0 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sat, 25 Jul 2026 00:00:50 +0800 Subject: [PATCH 021/319] test(snapshot): re-record tool schemas for the todo item key tightening additionalProperties: false on the todo_write item schema is model-visible (tool schemas ride the request header and the code-mode prompt types), so the pinned ACP/headless expected outputs re-record. Keyless refresh; the two locally-failing scenarios are this machine's known environment issues (HOME-symlink cwd normalization, SQLite ExperimentalWarning), not the diff. --- .../snapshots/advanced-toolchain/system-prompt.expected.md | 2 +- .../snapshots/advanced-toolchain/tool-schemas.expected.json | 2 +- .../tests/snapshots/both-mode-turn/system-prompt.expected.md | 2 +- .../tests/snapshots/both-mode-turn/tool-schemas.expected.json | 2 +- .../tests/snapshots/code-mode-turn/system-prompt.expected.md | 2 +- .../code-mode-workspace-context/system-prompt.expected.md | 2 +- .../tests/snapshots/lsp-definition/tool-schemas.expected.json | 2 +- .../snapshots/model-switching/tool-schemas.expected.json | 4 ++-- .../snapshots/permission-switching/tool-schemas.expected.json | 4 ++-- .../tests/snapshots/plan-mode/tool-schemas.expected.json | 4 ++-- .../tests/snapshots/pty-tools/tool-schemas.expected.json | 2 +- .../tests/snapshots/skill-load/tool-schemas.expected.json | 2 +- .../tests/snapshots/text-turn/tool-schemas.expected.json | 2 +- .../snapshots/workspace-context/tool-schemas.expected.json | 2 +- .../tests/snapshots/advanced-toolchain/session.1.jsonl | 2 +- .../tests/snapshots/advanced-toolchain/session.2.jsonl | 2 +- 16 files changed, 19 insertions(+), 19 deletions(-) diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md index 73c31413bf..672948ecbe 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md @@ -188,7 +188,7 @@ interface ToolArgsMap { content: string; /** pending (not started) | in_progress (now) | completed (done). */ status: "pending" | "in_progress" | "completed"; - } & Record)[]; + })[]; } & Record; /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ update_goal: { diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json index 6b50a5d220..e5173eb3f9 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json @@ -440,7 +440,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", - "additionalProperties": true, + "additionalProperties": false, "properties": { "content": { "type": "string", diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md index 8744029272..9556f9eec5 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md @@ -171,7 +171,7 @@ interface ToolArgsMap { content: string; /** pending (not started) | in_progress (now) | completed (done). */ status: "pending" | "in_progress" | "completed"; - } & Record)[]; + })[]; } & Record; /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ update_goal: { diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json index 7ceeec4042..a0bd131348 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json @@ -383,7 +383,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", - "additionalProperties": true, + "additionalProperties": false, "properties": { "content": { "type": "string", diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md index 8744029272..9556f9eec5 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md @@ -171,7 +171,7 @@ interface ToolArgsMap { content: string; /** pending (not started) | in_progress (now) | completed (done). */ status: "pending" | "in_progress" | "completed"; - } & Record)[]; + })[]; } & Record; /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ update_goal: { diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md index 8744029272..9556f9eec5 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md @@ -171,7 +171,7 @@ interface ToolArgsMap { content: string; /** pending (not started) | in_progress (now) | completed (done). */ status: "pending" | "in_progress" | "completed"; - } & Record)[]; + })[]; } & Record; /** Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason. */ update_goal: { diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json index b42a434388..c78fe8c63f 100644 --- a/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json @@ -404,7 +404,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", - "additionalProperties": true, + "additionalProperties": false, "properties": { "content": { "type": "string", diff --git a/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json index ef40784fa5..d4abb8f48b 100644 --- a/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json @@ -367,7 +367,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", - "additionalProperties": true, + "additionalProperties": false, "properties": { "content": { "type": "string", @@ -917,7 +917,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", - "additionalProperties": true, + "additionalProperties": false, "properties": { "content": { "type": "string", diff --git a/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json index ef40784fa5..d4abb8f48b 100644 --- a/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json @@ -367,7 +367,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", - "additionalProperties": true, + "additionalProperties": false, "properties": { "content": { "type": "string", @@ -917,7 +917,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", - "additionalProperties": true, + "additionalProperties": false, "properties": { "content": { "type": "string", diff --git a/examples/acp-agent/tests/snapshots/plan-mode/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/plan-mode/tool-schemas.expected.json index ef40784fa5..d4abb8f48b 100644 --- a/examples/acp-agent/tests/snapshots/plan-mode/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/plan-mode/tool-schemas.expected.json @@ -367,7 +367,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", - "additionalProperties": true, + "additionalProperties": false, "properties": { "content": { "type": "string", @@ -917,7 +917,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", - "additionalProperties": true, + "additionalProperties": false, "properties": { "content": { "type": "string", diff --git a/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json index 529b1419da..b98e501452 100644 --- a/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json @@ -496,7 +496,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", - "additionalProperties": true, + "additionalProperties": false, "properties": { "content": { "type": "string", diff --git a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json index b01e7683d1..9ce90ffd83 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json @@ -367,7 +367,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", - "additionalProperties": true, + "additionalProperties": false, "properties": { "content": { "type": "string", diff --git a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json index b01e7683d1..9ce90ffd83 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json @@ -367,7 +367,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", - "additionalProperties": true, + "additionalProperties": false, "properties": { "content": { "type": "string", diff --git a/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json index b01e7683d1..9ce90ffd83 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json @@ -367,7 +367,7 @@ "description": "The COMPLETE task list, replacing any previous list.", "items": { "type": "object", - "additionalProperties": true, + "additionalProperties": false, "properties": { "content": { "type": "string", diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl index 045b9bb736..6cae860e36 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1783957884563,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884563,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884564,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884564,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Calls execute sequentially, even under `Promise.all`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"dynamic\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** Body of an async JS function; must `return` the plugin to mount. */\n code: string;\n } & Record;\n /** Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop). */\n cordis_unmount: {\n /** The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\"). */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n } & Record)[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":true,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":1783957884564,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Calls execute sequentially, even under `Promise.all`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"dynamic\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** Body of an async JS function; must `return` the plugin to mount. */\n code: string;\n } & Record;\n /** Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop). */\n cordis_unmount: {\n /** The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\"). */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":1783950001005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":6,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} {"type":"assistant/chunk","seq":7,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl index 8193973bee..c00a4119c7 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -3,7 +3,7 @@ {"type":"user/message","seq":1,"time":1783957884700,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1783957884700,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1783957884700,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":1783957884701,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Calls execute sequentially, even under `Promise.all`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"dynamic\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** Body of an async JS function; must `return` the plugin to mount. */\n code: string;\n } & Record;\n /** Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop). */\n cordis_unmount: {\n /** The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\"). */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n } & Record)[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":true,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":4,"time":1783957884701,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is /tmp/advanced-headless.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Calls execute sequentially, even under `Promise.all`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"dynamic\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** Body of an async JS function; must `return` the plugin to mount. */\n code: string;\n } & Record;\n /** Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop). */\n cordis_unmount: {\n /** The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\"). */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background task and return its id; collect with task_output or stop with task_kill. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background task and return its id; collect with task_output or stop with task_kill."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":5,"time":1783950002005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":6,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} {"type":"assistant/chunk","seq":7,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} From 1f094ae0762a36e103448aa82a35812ced721256 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sat, 25 Jul 2026 00:42:40 +0800 Subject: [PATCH 022/319] fix(gui): todo row keeps running/stopped execution states visible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The row rendered a status only for error, so a call cancelled before tool/result read as a completed plan update even though no todo/write occurred. Non-ok states now ride the generic row's StateDot semantics (ongoing dot while running, warning dot + 已中断 marker when interrupted); the ok badge stays for settled successful updates. --- .../src/client/toolviews/todo-row.tsx | 12 +++++++++--- .../ui-conversation/tests/todo-panel.spec.tsx | 15 ++++++++++++++- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx b/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx index 390361d20b..665fc4dfab 100644 --- a/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx +++ b/packages/client/ui-conversation/src/client/toolviews/todo-row.tsx @@ -6,6 +6,7 @@ // row stays one line. import type { Context } from 'cordis' +import { StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import type { ToolRowProps } from '../contract/slots.ts' import { toolRowModel } from '../contract/tool-call-model.ts' import css from './todo-row.module.css' @@ -38,17 +39,22 @@ function summarize(argsRaw: string): string | null { : head } -/** One-line plan update row (click opens the raw args in details). */ +/** One-line plan update row (click opens the raw args in details). Non-ok + * execution states keep the generic row's dot semantics — a cancelled call + * wrote no todo/write, so it must not read as a completed update. */ export function TodoRow({ toolName, block, openDetails }: ToolRowProps) { const model = toolRowModel(toolName, block) const argsRaw = ('kind' in block ? block.call?.argsRaw : block.argsRaw) ?? '' const summary = summarize(argsRaw) ?? model.summary return ( -
- +
+ {model.state === 'ok' + ? + : } 更新任务清单 {summary} {model.state === 'error' && failed} + {model.state === 'stopped' && 已中断}
) } diff --git a/packages/client/ui-conversation/tests/todo-panel.spec.tsx b/packages/client/ui-conversation/tests/todo-panel.spec.tsx index 196077e6a4..8971bba2cd 100644 --- a/packages/client/ui-conversation/tests/todo-panel.spec.tsx +++ b/packages/client/ui-conversation/tests/todo-panel.spec.tsx @@ -94,10 +94,23 @@ describe('TodoRow', () => { it('omits the active clause when no item is in progress and reads running-call args', () => { const args = JSON.stringify({ todos: [{ content: 'x', status: 'completed' }] }) - render() + render() expect(screen.getByText('1/1 已完成')).toBeTruthy() }) + it('keeps the non-ok execution states visible: running dot, interrupted marker', () => { + // A running call (no result yet) shows the ongoing dot, never the ok badge. + const args = JSON.stringify({ todos: LIST }) + const running = render() + expect(running.container.querySelector('[data-state="running"]')).not.toBeNull() + expect(running.container.querySelector('[data-state="running"] svg')).not.toBeNull() + running.unmount() + // A cancelled call wrote no todo/write: the row must not read as a completed update. + const stopped = render() + expect(stopped.container.querySelector('[data-state="stopped"]')).not.toBeNull() + expect(stopped.getByText('已中断')).toBeTruthy() + }) + it('falls back to the generic summary on malformed args and flags errors', () => { render() expect(screen.getByText('failed')).toBeTruthy() From 1a5d892ec53beb5f1b7212decfc2a10bd9ea2741 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sat, 25 Jul 2026 00:45:31 +0800 Subject: [PATCH 023/319] ci: halve coverage workers on the shared self-hosted leg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hosted 32-core runner is exclusive to one job, but the vm-backup pool shares one 64-core VM across four runner instances; concurrent PRs could stack 4×24 = 96 Vitest workers and re-trigger the documented aggregate-contention failures in the timing-sensitive process suites. Bound the self-hosted leg at 12 workers per job (48 host-wide fully loaded) and keep 24 on the hosted leg, selected by the same expression as the pool. --- .github/workflows/ci.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a21086754b..dc5ad98ec4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,7 +93,16 @@ jobs: || fromJSON('["self-hosted", "linux", "x64", "vm-backup"]') }} name: node 24 / coverage env: - DSH_COVERAGE_MAX_WORKERS: '24' + # Worker bound is per-leg: the hosted 32-core runner is exclusive to + # one job, but the self-hosted pool shares one 64-core VM across four + # runner instances, so concurrent PRs would otherwise stack up to + # 4×24 = 96 workers and re-trigger the aggregate-contention failures + # documented for the timing-sensitive process suites. 12 per job caps + # the shared host at 48 workers even fully loaded. + DSH_COVERAGE_MAX_WORKERS: >- + ${{ (github.event.pull_request.head.repo.full_name != github.repository + || github.event.pull_request.user.login == 'dependabot[bot]') + && '24' || '12' }} DSH_GATE_CONCURRENCY: '8' steps: - uses: actions/checkout@v6 From f09539581d33a5110c97d81cfe2778c74337690e Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sat, 25 Jul 2026 00:54:35 +0800 Subject: [PATCH 024/319] docs(ci): record disabled forking as an explicit precondition of the self-hosted lane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pool selector is defense-in-depth only — pull_request executes the PR's own workflow definition, so YAML cannot enforce runner trust. Make the actual enforcement boundary explicit in the decision record: org-side disabled forking (the public release is an isolated read-only mirror under a separate org), with migration to a repo-restricted org-level runner group with base-branch workflow pinning as a hard gate before forking could ever be enabled. --- .../2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml | 4 ++-- .../2026-07-22-evidence-based-larger-hosted-runners.md | 2 +- .../2026-07-22-evidence-based-larger-hosted-runners.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml index 4d781caa54..ea3a57e072 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-22-evidence-based-larger-hosted-runners.md: 88b9e6d83777172d8afb6a391512e5f293b81171 -2026-07-22-evidence-based-larger-hosted-runners.zh.md: b1105f00cd08b1af633d258ea4ff28a835ce6074 +2026-07-22-evidence-based-larger-hosted-runners.md: 497c6f297d79245fb40cd30457e4b1d1e36db651 +2026-07-22-evidence-based-larger-hosted-runners.zh.md: bcb0c6e9f11081b2cff696a9b6b425a40ee4aeb4 diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md index 88b9e6d837..497c6f297d 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md @@ -12,7 +12,7 @@ Larger runners make it possible to pay setup once and parallelize inside the rep ## Decision -The enterprise keeps repo-restricted x64 larger-runner pools for Ubuntu and Windows. Ordinary pull requests name two 32-core hosted pools directly: Ubuntu latest for the remaining primary Node 24 inventory and Windows 2025 for blocking Windows contracts. Exhaustive coverage moved off the metered Ubuntu 24.04 32-core pool onto the in-house self-hosted pool (`vm-backup` label: a 64-core VM running four always-on systemd-managed runner instances plus four registered spares) for trusted same-repo PRs; untrusted PRs — forks and Dependabot — keep coverage on the hosted Ubuntu 24.04 32-core pool so dependency-supplied code never reaches the persistent VM. Public IPs are disabled, and workflow concurrency remains bounded because an autoscaling ceiling neither allocates idle machines nor makes repository work scale without limit. +The enterprise keeps repo-restricted x64 larger-runner pools for Ubuntu and Windows. Ordinary pull requests name two 32-core hosted pools directly: Ubuntu latest for the remaining primary Node 24 inventory and Windows 2025 for blocking Windows contracts. Exhaustive coverage moved off the metered Ubuntu 24.04 32-core pool onto the in-house self-hosted pool (`vm-backup` label: a 64-core VM running four always-on systemd-managed runner instances plus four registered spares) for trusted same-repo PRs; untrusted PRs — forks and Dependabot — keep coverage on the hosted Ubuntu 24.04 32-core pool so dependency-supplied code never reaches the persistent VM. **Precondition: repository forking stays disabled.** The workflow's pool selector is defense-in-depth only — `pull_request` executes the PR's own workflow definition, so YAML cannot enforce runner trust against a fork that edits it. Disabled forking (org-side, not PR-editable) is the enforcement boundary; the planned public release is an isolated read-only mirror under a separate org, preserving this. Before forking is ever enabled, the runners must first move into an org-level runner group restricted to this repository with base-branch workflow pinning — that migration is the gate, not a follow-up. Public IPs are disabled, and workflow concurrency remains bounded because an autoscaling ceiling neither allocates idle machines nor makes repository work scale without limit. The required primary path depends on those enterprise pools. Standard GitHub-hosted jobs retain the Node 22.19, Node 26, and Python SDK compatibility contracts, while the [portable recovery boundary](2026-07-23-portable-required-pull-request-ci.md) and [serial reference](2026-07-21-serial-cross-platform-ci-reference.md) keep complete standard-runner evidence available on `master`. `suite=larger-runner-benchmark` compares isolated critical lanes across provisioned sizes, and `suite=consolidated-runner-benchmark` compares whole aggregates. Each benchmark reports its observed processor and memory capacity before running repository work. diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md index b1105f00cd..bcb0c6e9f1 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -企业保留仅限本仓库使用的 Ubuntu 和 Windows x64 大型运行器池。普通拉取请求直接指定 2 个 32 核托管运行器池:Ubuntu latest 用于其余主 Node 24 清单,Windows 2025 用于阻塞性 Windows 契约。完整覆盖率已从计费的 Ubuntu 24.04 32 核池迁移至公司自有的自托管池(`vm-backup` 标签:一台 64 核虚拟机,运行 4 个常驻的 systemd 管理运行器实例,另有 4 个已注册备用位),仅面向可信的同仓库拉取请求;不可信的拉取请求——fork 与 Dependabot——的覆盖率仍在托管的 Ubuntu 24.04 32 核池上运行,确保依赖方提供的代码永远不会进入持久化虚拟机。公网 IP 已禁用;工作流并发仍设有边界,因为自动扩缩容上限既不会分配闲置机器,也不意味着仓库工作可以无限扩展。 +企业保留仅限本仓库使用的 Ubuntu 和 Windows x64 大型运行器池。普通拉取请求直接指定 2 个 32 核托管运行器池:Ubuntu latest 用于其余主 Node 24 清单,Windows 2025 用于阻塞性 Windows 契约。完整覆盖率已从计费的 Ubuntu 24.04 32 核池迁移至公司自有的自托管池(`vm-backup` 标签:一台 64 核虚拟机,运行 4 个常驻的 systemd 管理运行器实例,另有 4 个已注册备用位),仅面向可信的同仓库拉取请求;不可信的拉取请求——fork 与 Dependabot——的覆盖率仍在托管的 Ubuntu 24.04 32 核池上运行,确保依赖方提供的代码永远不会进入持久化虚拟机。**前置条件:仓库必须保持禁用 fork。**工作流中的运行器池选择表达式仅是纵深防御——`pull_request` 执行的是拉取请求自带的工作流定义,因此 YAML 无法对能修改它的 fork 实施运行器信任约束。真正的强制边界是组织侧(拉取请求无法修改)的 fork 禁用设置;规划中的开源发布采用独立组织下的只读镜像仓库,正是为了保持这一边界。将来若要启用 fork,必须先把运行器迁入组织级 runner group(限定本仓库并绑定基线分支工作流)——该迁移是启用 fork 的先决门槛,而非事后跟进项。公网 IP 已禁用;工作流并发仍设有边界,因为自动扩缩容上限既不会分配闲置机器,也不意味着仓库工作可以无限扩展。 必需主路径依赖这些企业级运行器池。GitHub 标准托管作业保留 Node 22.19、Node 26 和 Python SDK 兼容性契约,而[可移植恢复边界](2026-07-23-portable-required-pull-request-ci.md)与[串行参考流程](2026-07-21-serial-cross-platform-ci-reference.md)则在 `master` 上持续提供完整的标准运行器证据。`suite=larger-runner-benchmark` 比较已预配规格上相互独立的关键通道,`suite=consolidated-runner-benchmark` 则比较完整聚合流程。每项基准测试都会先报告实测的处理器和内存容量,再运行仓库工作。 From cf2e18411244f837999bcf934ba383a21ec8661d Mon Sep 17 00:00:00 2001 From: kingwl Date: Thu, 23 Jul 2026 03:06:57 +0800 Subject: [PATCH 025/319] feat(telemetry): session-telemetry seam with mandatory redaction + OTel backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revive the reviewed session-telemetry packages from the closed session-telemetry-otlp-rfc branch (PR #222/#231) on current master, renamed to @deepseek-ai/dsh-session-telemetry{,-otel} (the SDK component-telemetry package holds the dsh-telemetry name). Delta over the branch version: every record now passes a telemetry/redact waterfall between projection and emit() — the innermost next() applies a non-configurable conservative credential-shape rule set, listeners stack stricter rules, a throwing rule withholds the record fail-closed, and the canonical log is never rewritten. This answers the export-side concern that closed PR #222; the boundary axiom (our aspect ends at emit(); delivery is the reporting SDK's) is unchanged, and the runtime-telemetry RFC's outbox / readCommitted lane is recorded as deferred in the Agent Note. Covered by seam/redact/OTel-wire unit tiers (100% per-file) and a keyless Loader-composition e2e that boots the examples fixture against a mock OTLP collector and pins redaction on the wire plus the untouched canonical log. --- ...3-session-telemetry-otel-revival.i18n.yaml | 6 + ...26-07-23-session-telemetry-otel-revival.md | 33 ++ ...07-23-session-telemetry-otel-revival.zh.md | 33 ++ docs/capability-seams.md | 6 + docs/config-catalog.md | 32 ++ docs/cordis-catalog/events.md | 27 ++ docs/cordis-catalog/services.md | 23 ++ docs/event-producer-consumer.md | 9 +- docs/module-graph.md | 13 + .../tests/fixtures/telemetry-otel-driver.ts | 43 +++ .../tests/fixtures/telemetry-otel.cordis.yml | 23 ++ examples/package.json | 1 + knip.json | 11 + packages/README.md | 7 +- .../cordis/tool-cordis/src/api-catalog.ts | 33 ++ packages/telemetry/README.md | 8 + .../session-telemetry-otel/README.md | 39 +++ .../session-telemetry-otel/package.json | 52 +++ .../session-telemetry-otel/src/index.ts | 168 ++++++++++ .../session-telemetry-otel/src/invariant.ts | 32 ++ .../tests/loader-composition.e2e.ts | 98 ++++++ .../session-telemetry-otel/tests/otel.e2e.ts | 25 ++ .../session-telemetry-otel/tests/otel.spec.ts | 166 ++++++++++ .../session-telemetry-otel/tsconfig.json | 33 ++ .../telemetry/session-telemetry/README.md | 40 +++ .../telemetry/session-telemetry/package.json | 41 +++ .../session-telemetry/src/coordinator.ts | 244 ++++++++++++++ .../telemetry/session-telemetry/src/index.ts | 145 ++++++++ .../session-telemetry/src/invariant.ts | 32 ++ .../telemetry/session-telemetry/src/redact.ts | 77 +++++ .../session-telemetry/tests/redact.spec.ts | 157 +++++++++ .../session-telemetry/tests/telemetry.spec.ts | 313 ++++++++++++++++++ .../telemetry/session-telemetry/tsconfig.json | 27 ++ pnpm-lock.yaml | 200 +++++++++++ scripts/gen-cordis-catalog.ts | 1 + scripts/gen-doc-graphs.ts | 10 + .../verify-package-readme-model-experience.ts | 2 + tsconfig.base.json | 2 + tsconfig.host.json | 2 + 39 files changed, 2207 insertions(+), 7 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md create mode 100644 .agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md create mode 100644 examples/headless-agent/tests/fixtures/telemetry-otel-driver.ts create mode 100644 examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml create mode 100644 packages/telemetry/README.md create mode 100644 packages/telemetry/session-telemetry-otel/README.md create mode 100644 packages/telemetry/session-telemetry-otel/package.json create mode 100644 packages/telemetry/session-telemetry-otel/src/index.ts create mode 100644 packages/telemetry/session-telemetry-otel/src/invariant.ts create mode 100644 packages/telemetry/session-telemetry-otel/tests/loader-composition.e2e.ts create mode 100644 packages/telemetry/session-telemetry-otel/tests/otel.e2e.ts create mode 100644 packages/telemetry/session-telemetry-otel/tests/otel.spec.ts create mode 100644 packages/telemetry/session-telemetry-otel/tsconfig.json create mode 100644 packages/telemetry/session-telemetry/README.md create mode 100644 packages/telemetry/session-telemetry/package.json create mode 100644 packages/telemetry/session-telemetry/src/coordinator.ts create mode 100644 packages/telemetry/session-telemetry/src/index.ts create mode 100644 packages/telemetry/session-telemetry/src/invariant.ts create mode 100644 packages/telemetry/session-telemetry/src/redact.ts create mode 100644 packages/telemetry/session-telemetry/tests/redact.spec.ts create mode 100644 packages/telemetry/session-telemetry/tests/telemetry.spec.ts create mode 100644 packages/telemetry/session-telemetry/tsconfig.json diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml new file mode 100644 index 0000000000..31a5914d0d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-23-session-telemetry-otel-revival.md: 1150d363e9db98a39e1188b86dc41fa291edba41 +2026-07-23-session-telemetry-otel-revival.zh.md: 76749a59c38ef2b8c5b3f09960ca500c25d8e1a8 diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md new file mode 100644 index 0000000000..1150d363e9 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md @@ -0,0 +1,33 @@ +# Agent Note: Session telemetry seam with mandatory redaction and the OTel backend + +Status: implemented + +English | [中文](2026-07-23-session-telemetry-otel-revival.zh.md) + +## Problem + +Every deployment that wants harness sessions in an observability stack must hand-roll a session-log consumer: subscription, lifecycle handoff, and — hardest — redaction, since the raw log carries file contents and command output that may embed credentials. A telemetry seam and OTel backend shipped once on the `session-telemetry-otlp-rfc` branch (PR #222/#231) but never reached master: the proposal exported raw session events verbatim, which legal review declined. The capture-side design (backend contract, coordinator, handoff cursor, chunk projection) was sound and reviewed; the export-side stance was the blocker. + +## Decision + +`packages/telemetry/` revives the two reviewed packages under the SDK stance — the harness provides the capability, the deployment configures where records go, and nothing crosses the seam unredacted: + +- **`@deepseek-ai/dsh-session-telemetry`** — the seam. `TelemetryBackend` (`emit`/`flush?`/`shutdown`), the service-registered `Telemetry` form, and `TelemetryCoordinator` owning capture: adoption with cursor read-back, the per-append firehose (project → `structuredClone` → redact → `emit`, zero I/O), the fixed first-chunk-per-(turn, step) projection, the `agent/error` relay, and dispose-time `shutdown` records. +- **The `telemetry/redact` waterfall** — the delta over the branch version. Every record passes it before reaching any backend; the innermost `next()` applies a conservative built-in rule set (credential shapes: API keys, GitHub/Slack tokens, AWS/Google keys, JWTs, PEM blocks, URL userinfo), deployments stack stricter rules as listeners, and a throwing rule withholds the record fail-closed. The pattern list is a security invariant, deliberately not configurable. Redaction applies to the exported copy only; the canonical log is never rewritten. +- **`@deepseek-ai/dsh-session-telemetry-otel`** — the reference backend: OTel JS SDK log pipeline (`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP exporter), configured verbatim through `exporter`/`processor` passthroughs. `exporter.url` is required and validated at load; unmounted or unconfigured, nothing leaves the process. + +The boundary axiom holds: the harness's aspect ends at `emit()`. Batching, retry, queueing, and loss policy are the reporting SDK's, configured through passthroughs — delivery is best-effort (at-most-once across a crash), which the READMEs state plainly. + +## Alternatives considered + +**Implement the runtime-telemetry RFC's outbox (durable spool, per-sink cursors, at-least-once, a `readCommitted` persistence-seam method).** Deferred, not rejected: the SDK stance makes delivery semantics the reporting SDK's territory, and the OTel SDK's own batch pipeline is the honest default. The outbox is a pure additive layer (the `emit()` contract does not move); revive it when a deployment states a crash-loss requirement telemetry must satisfy. + +**Export without built-in redaction, delegating to receiver-side collector processors.** Rejected — this is what legal declined. Receiver-side redaction ships the secret first and scrubs it second; the seam must scrub before bytes leave the process, and a waterfall makes the redaction point auditable and stackable. + +**A configurable pattern list for the default rules.** Rejected: deployment-varying tunables belong in config, but a security invariant does not — weakening the floor should require code, not YAML. Stricter rules stack as `telemetry/redact` listeners. + +**Map onto OTel spans (GenAI semantic conventions) instead of logs.** Rejected for this revival: the branch implementation's log mapping is reviewed and shipped-shaped; the span model is lossy for forkable, interruptible sessions and belongs to a future consumer with real span queries to serve. + +## Consequences + +A deployment adds one `cordis.yml` entry with an OTLP endpoint and gets its session stream in any OTel-compatible stack; removing the entry is the opt-out, with no residual state. Credential-shaped substrings never leave the process even on a rule-free deployment, at the cost of a synchronous per-record scrub on the capture path (string-regex over lossless-JSON bodies — bounded by event size, no I/O). Exported bodies can differ from canonical log bytes wherever the placeholder landed, so receivers must not treat telemetry as a byte-exact replica; the log remains the source of truth. Crash durability is explicitly out of scope until the outbox decision above is revisited. diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md new file mode 100644 index 0000000000..76749a59c3 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md @@ -0,0 +1,33 @@ +# Agent Note: Session telemetry seam with mandatory redaction and the OTel backend + +Status: implemented + +[English](2026-07-23-session-telemetry-otel-revival.md) | 中文 + +## Problem + +每个想把 harness 会话接入可观测性体系的部署方都得手写一套会话日志消费端:订阅、生命周期交接、以及最难的脱敏——原始日志携带文件内容与命令输出,可能内嵌凭据。遥测 seam 和 OTel backend 曾在 `session-telemetry-otlp-rfc` 分支(PR #222/#231)上完成过一版,但从未进入 master:该提案将原始会话事件原样导出,法务评审未予通过。捕获侧设计(backend 契约、coordinator、handoff 游标、chunk 投影)本身合理且经过评审;导出侧的立场才是阻塞点。 + +## Decision + +`packages/telemetry/` 以 SDK 立场复活这两个经过评审的包——harness 提供能力,部署方配置上报去向,且任何数据未经脱敏不得跨越 seam: + +- **`@deepseek-ai/dsh-session-telemetry`** —— seam 本体。`TelemetryBackend`(`emit`/`flush?`/`shutdown`)、服务注册形态的 `Telemetry`、以及拥有捕获侧的 `TelemetryCoordinator`:带游标回读的收养、逐 append 的 firehose(投影 → `structuredClone` → 脱敏 → `emit`,零 I/O)、固定的每 (turn, step) 首 chunk 投影、`agent/error` 转发、以及 dispose 时的 `shutdown` 记录。 +- **`telemetry/redact` waterfall** —— 相对分支版本的增量。每条记录抵达任何 backend 前必经此处;最内层 `next()` 应用保守的内置规则集(凭据形状:API key、GitHub/Slack token、AWS/Google key、JWT、PEM 块、URL userinfo),部署方以监听器堆叠更严规则,抛异常的规则将该记录 fail-closed 扣下。模式列表是安全不变量,刻意不可配置。脱敏只作用于导出副本;canonical log 永不改写。 +- **`@deepseek-ai/dsh-session-telemetry-otel`** —— 参考 backend:OTel JS SDK 日志管线(`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP exporter),经 `exporter`/`processor` passthrough 原样配置。`exporter.url` 必填且加载时校验;未挂载或未配置时,任何数据都不会离开进程。 + +边界公理保持不变:harness 的职责止于 `emit()`。批处理、重试、排队与丢失策略属于 reporting SDK,经 passthrough 配置——投递是尽力而为(崩溃时至多一次),README 对此如实陈述。 + +## Alternatives considered + +**实现 runtime-telemetry RFC 的 outbox(落盘 spool、每 sink 游标、at-least-once、persistence seam 的 `readCommitted` 方法)。** 推迟而非否决:SDK 立场使投递语义归属 reporting SDK,OTel SDK 自身的批处理管线是诚实的默认。outbox 是纯增量层(`emit()` 契约不动);待某个部署提出遥测必须满足的崩溃丢失要求时再复活。 + +**不带内置脱敏直接导出,交给接收端 collector processor。** 否决——这正是法务否掉的方案。接收端脱敏是先把秘密发出去再擦除;seam 必须在字节离开进程前擦除,且 waterfall 使脱敏点可审计、可堆叠。 + +**默认规则的模式列表做成可配置。** 否决:随部署变化的调优项应进 config,但安全不变量不应——削弱底线应当需要改代码而非改 YAML。更严格的规则以 `telemetry/redact` 监听器堆叠。 + +**映射到 OTel span(GenAI 语义约定)而非日志。** 本次复活否决:分支实现的日志映射已经过评审、形态可交付;span 模型对可 fork、可中断的会话有损,留给将来真正有 span 查询需求的消费者。 + +## Consequences + +部署方在 `cordis.yml` 加一个带 OTLP endpoint 的条目即可把会话流接入任何 OTel 兼容体系;删除条目即退出,无残留状态。即使部署方未配置任何规则,凭据形状的子串也绝不离开进程,代价是捕获路径上每条记录一次同步擦除(对 lossless-JSON body 做字符串正则——受事件大小约束,无 I/O)。导出的 body 在占位符落点处可能与 canonical log 字节不同,接收端不得把遥测当作字节精确副本;日志仍是唯一事实源。崩溃持久性在上述 outbox 决定重启前明确不在范围内。 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index a27d325e31..25406c8c9b 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -36,6 +36,9 @@ flowchart LR pkg_hooks_claude["hooks-claude"] pkg_hooks_codex["hooks-codex"] pkg_acp["acp"] + pkg_session_telemetry["session-telemetry"] + svc_telemetry["ctx.telemetry
Session telemetry seam"] + pkg_session_telemetry_otel["session-telemetry-otel"] svc_sessionQuery["ctx.sessionQuery
Session reads, traces, filters, and search"] pkg_session_reference["session-reference"] svc_sessionReferences["ctx.sessionReferences
Cross-session snapshot preparation"] @@ -159,6 +162,8 @@ flowchart LR pkg_session_query --> svc_sessionQuery pkg_session_query_sqlite --> svc_sessionQuery pkg_session_reference --> svc_sessionReferences + pkg_session_telemetry --> svc_telemetry + pkg_session_telemetry_otel --> svc_telemetry pkg_session_title --> svc_sessionTitle pkg_session_title_all_messages_llm --> svc_sessionTitle pkg_session_title_first_message_llm --> svc_sessionTitle @@ -276,6 +281,7 @@ flowchart LR | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`cli-demo`](../packages/examples/cli-demo), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. | | `ctx.invariants` | `core` | [`invariants`](../packages/support/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. | | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`acp`](../packages/ui/acp), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | +| `ctx.telemetry` | `seam` | [`session-telemetry`](../packages/telemetry/session-telemetry) | [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | - | - | The seam captures, redacts, and hands session records to one backend; nothing else consumes the service — its output leaves the process. | | `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | [`session-reference`](../packages/context/session-reference) | - | The interface supplies exact reads, filters, and traces; its concrete backend adds full-text reconciliation, ranking, snippets, and cursor generations on the same service. | | `ctx.sessionReferences` | `core` | [`session-reference`](../packages/context/session-reference) | - | [`tui`](../packages/ui/tui), [`acp`](../packages/ui/acp) | - | Projects bounded current-surface conversation snapshots into durable untrusted message context; host adapters own mention syntax. | | `ctx.sessionTitle` | `seam` | [`session-title`](../packages/session-title/session-title) | [`session-title-first-message-llm`](../packages/session-title/session-title-first-message-llm), [`session-title-all-messages-llm`](../packages/session-title/session-title-all-messages-llm) | - | - | Owns the deterministic fallback, latest-title fold, and sole optional asynchronous provider registration. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 49a0a1510f..2f55f60bec 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1047,6 +1047,37 @@ export interface Config { Source: [`packages/context/session-reference/src/config.ts:11`](../packages/context/session-reference/src/config.ts) +## `@deepseek-ai/dsh-session-telemetry-otel` + +Requires: `sessions` + +```ts config-catalog +/** + * Plugin configuration: two verbatim SDK option shapes plus nothing else. + * `exporter.url` is the one field this package validates itself — required, + * no default, must parse as an `http(s)` URL — because a missing endpoint + * must fail at plugin load, not at first export. + */ +export interface Config { + /** Passed verbatim to the SDK's OTLP/HTTP log exporter. */ + exporter?: { + /** Full logs endpoint (e.g. `https://collector.example.com/v1/logs`). Required; validated at plugin load. */ + url?: string + /** Extra request headers (auth etc.); owned and sent by the SDK exporter. */ + headers?: Record + } + /** + * Passed verbatim to `BatchLogRecordProcessor` (minus the exporter slot, + * which this plugin fills); the SDK owns and documents these knobs. + */ + processor?: Omit +} +``` + +Depends on: `BatchLogRecordProcessorOptions` (`@opentelemetry/sdk-logs`) + +Source: [`packages/telemetry/session-telemetry-otel/src/index.ts:39`](../packages/telemetry/session-telemetry-otel/src/index.ts) + ## `@deepseek-ai/dsh-session-title` Requires: `sessions` @@ -1958,6 +1989,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-retention` ([`packages/util/retention/src/index.ts`](../packages/util/retention/src/index.ts)) - `@deepseek-ai/dsh-scope` ([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts)) - `@deepseek-ai/dsh-scripts` ([`packages/sdk/scripts/src/index.ts`](../packages/sdk/scripts/src/index.ts)) +- `@deepseek-ai/dsh-session-telemetry` ([`packages/telemetry/session-telemetry/src/index.ts`](../packages/telemetry/session-telemetry/src/index.ts)) - `@deepseek-ai/dsh-session-title-llm` ([`packages/session-title/session-title-llm/src/index.ts`](../packages/session-title/session-title-llm/src/index.ts)) - `@deepseek-ai/dsh-subagent-inprocess` ([`packages/subagent/subagent-inprocess/src/index.ts`](../packages/subagent/subagent-inprocess/src/index.ts)) - `@deepseek-ai/dsh-subagent-subprocess` ([`packages/subagent/subagent-subprocess/src/index.ts`](../packages/subagent/subagent-subprocess/src/index.ts)) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index ed9bebdc92..4ed5de022b 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -803,6 +803,33 @@ Emitted when any prompt provider changes. This registry notification is unfilter Source: [`packages/core/system-prompt/src/index.ts:35`](../../packages/core/system-prompt/src/index.ts) +## `telemetry/*` + +### `telemetry/redact` — waterfall + +Redact one outbound record before it reaches the backend. The innermost `next()` applies the seam's conservative default rule set (credential-shape scrubbing); listeners stack stricter rules by transforming its return value, and returning without `next()` replaces the default — the exported record is then only as clean as the replacing rule. Dispatched synchronously on the capture hot path inside the coordinator's containment: a throwing listener withholds that one record (fail-closed) and never reaches the agent loop. Redaction applies to the exported copy only; the canonical session log is never rewritten. + +```ts cordis-catalog +/** + * Redact one outbound record before it reaches the backend. The innermost + * `next()` applies the seam's conservative default rule set + * (credential-shape scrubbing); listeners stack stricter rules by + * transforming its return value, and returning without `next()` replaces + * the default — the exported record is then only as clean as the + * replacing rule. Dispatched synchronously on the capture hot path inside + * the coordinator's containment: a throwing listener withholds that one + * record (fail-closed) and never reaches the agent loop. Redaction + * applies to the exported copy only; the canonical session log is never + * rewritten. + * @param record - the candidate record, already the coordinator's own deep + * copy; listeners return a (possibly new) record and must not mutate it. + * @mode waterfall + */ +'telemetry/redact'(record: TelemetryRecord, next: () => TelemetryRecord): TelemetryRecord +``` + +Source: [`packages/telemetry/session-telemetry/src/index.ts:39`](../../packages/telemetry/session-telemetry/src/index.ts) + ## `tools/*` ### `tools/change` — emit diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 0d6cc97086..22120b2f8e 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1533,6 +1533,29 @@ Types: [Agent](../core-data-structures/core.md) · [TaskDoneListener](../core-da Source: [`packages/tasks/tasks/src/index.ts:77`](../../packages/tasks/tasks/src/index.ts) +## `ctx.telemetry` — `Telemetry` (abstract seam) + +The backend contract in its loadable form: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis' standard behavior. A backend composes a TelemetryCoordinator in its constructor to install the capture side. + +```ts cordis-catalog +/** + * See {@link TelemetryBackend.emit} — the seam declaration is the contract's one home. + * @param record - the logical record to report; owned by the backend after the call. + */ +abstract emit(record: TelemetryRecord): void + +/** See {@link TelemetryBackend.flush}. */ +flush?(): void + +/** + * See {@link TelemetryBackend.shutdown}. + * @returns resolves when the backend's pipeline has quiesced. + */ +abstract shutdown(): Promise +``` + +Source: [`packages/telemetry/session-telemetry/src/index.ts:123`](../../packages/telemetry/session-telemetry/src/index.ts) + ## `ctx.tokenMeter` — `TokenMeterService` Replay owner for one service-wide estimator and isolated per-session folds. diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index b36d390f55..30380261fe 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -11,7 +11,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:350`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `agent/created` | `emit` | [`packages/core/agent/src/types.ts:285`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | | `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:294`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:498`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`tui`](../packages/ui/tui) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:498`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), `runtime`, [`session-telemetry`](../packages/telemetry/session-telemetry), [`tui`](../packages/ui/tui) | | `agent/inbox/dequeue` | `emit` | [`packages/core/agent/src/types.ts:326`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | | `agent/inbox/discard` | `emit` | [`packages/core/agent/src/types.ts:340`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent) | | `agent/inbox/enqueue` | `emit` | [`packages/core/agent/src/types.ts:316`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | @@ -33,16 +33,17 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:167`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:52`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`user-approval`](../packages/ui/user-approval) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`user-approval`](../packages/ui/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:89`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `runtime`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | -| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:111`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:111`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:139`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:113`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:119`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:130`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | +| `telemetry/redact` | `waterfall` | [`packages/telemetry/session-telemetry/src/index.ts:39`](../packages/telemetry/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/telemetry/session-telemetry) (`waterfall`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:143`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | | `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:113`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) | | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:125`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search), [`workspace-context`](../packages/context/workspace-context) | diff --git a/docs/module-graph.md b/docs/module-graph.md index 212fd723a7..a66c5edc0a 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -199,6 +199,10 @@ flowchart TD pkg_tasks["tasks"] pkg_tool_tasks["tool-tasks"] end + subgraph group_telemetry["packages/telemetry"] + pkg_session_telemetry["session-telemetry"] + pkg_session_telemetry_otel["session-telemetry-otel"] + end subgraph group_workflow["packages/workflow"] pkg_tool_ralph["tool-ralph"] pkg_tool_workflow["tool-workflow"] @@ -408,6 +412,9 @@ flowchart TD pkg_tasks --> pkg_invariants pkg_tasks --> pkg_session pkg_tasks --> pkg_timeout + pkg_session_telemetry --> pkg_agent + pkg_session_telemetry --> pkg_invariants + pkg_session_telemetry --> pkg_session pkg_workflow --> pkg_agent pkg_workflow --> pkg_brand pkg_workflow --> pkg_invariants @@ -472,6 +479,10 @@ flowchart TD pkg_pty_local --> pkg_sandbox pkg_pty_local --> pkg_sandbox_policy pkg_pty_local --> pkg_session + pkg_session_telemetry_otel --> pkg_invariants + pkg_session_telemetry_otel --> pkg_llm + pkg_session_telemetry_otel --> pkg_session + pkg_session_telemetry_otel --> pkg_session_telemetry pkg_agent_loop --> pkg_agent pkg_agent_loop --> pkg_invariants pkg_agent_loop --> pkg_llm @@ -858,6 +869,7 @@ flowchart TD | [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants) | | [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | +| [`session-telemetry`](../packages/telemetry/session-telemetry) | `telemetry` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/ui/user-approval) | | [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/ui/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | @@ -870,6 +882,7 @@ flowchart TD | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session) | +| [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | `telemetry` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/telemetry/session-telemetry) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | diff --git a/examples/headless-agent/tests/fixtures/telemetry-otel-driver.ts b/examples/headless-agent/tests/fixtures/telemetry-otel-driver.ts new file mode 100644 index 0000000000..02be1a9011 --- /dev/null +++ b/examples/headless-agent/tests/fixtures/telemetry-otel-driver.ts @@ -0,0 +1,43 @@ +#!/usr/bin/env node +/** + * Test driver: start a mock OTLP/HTTP collector, boot the telemetry Loader + * composition against it, run one turn whose prompt carries a fixture + * credential, then persist everything the collector captured to + * `./otlp-captures.json` for the e2e's inspect step. + */ + +import { writeFile } from 'node:fs/promises' +import { createServer } from 'node:http' +import { once } from 'node:events' +import { boot, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' +import { runOneShot } from '@deepseek-ai/dsh-cli-demo/src/cli.ts' + +const configPath = process.argv[2] +if (configPath === undefined) throw new Error('telemetry-otel driver requires a config path') + +const captures: unknown[] = [] +const server = createServer((request, response) => { + const chunks: Buffer[] = [] + request.on('data', chunk => chunks.push(chunk as Buffer)) + request.on('end', () => { + captures.push(JSON.parse(Buffer.concat(chunks).toString())) + response.writeHead(200, { 'content-type': 'application/json' }).end('{}') + }) +}) +server.listen(0, '127.0.0.1') +await once(server, 'listening') +const address = server.address() +if (address === null || typeof address === 'string') throw new Error('collector has no port') +process.env.DSH_TELEMETRY_E2E_URL = `http://127.0.0.1:${address.port}/v1/logs` + +const ctx = await boot('telemetry-otel-e2e', resolveConfigPath(configPath, undefined)) +try { + // The fixture credential rides the model-visible user message; the exported + // copy must scrub it while the canonical log keeps the original bytes. + await runOneShot(ctx, { task: 'prove telemetry with key sk-e2efixture1234567890' }) +} finally { + await ctx.fiber.dispose() +} +await writeFile('./otlp-captures.json', JSON.stringify(captures)) +server.close() +server.closeAllConnections() diff --git a/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml b/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml new file mode 100644 index 0000000000..defde98ff5 --- /dev/null +++ b/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml @@ -0,0 +1,23 @@ +# Test-only composition: session-telemetry-otel through the real Loader/app +# path, exporting to the mock OTLP collector the driver starts (url via env). +- id: cli-mock-llm + name: './cli-mock-llm.ts' + +- id: bash + name: '@deepseek-ai/dsh-bash-local' + +- id: telemetry-otel + name: '@deepseek-ai/dsh-session-telemetry-otel' + config: + exporter: + url: !!js process.env.DSH_TELEMETRY_E2E_URL + +- id: cli-agent + name: '@deepseek-ai/dsh-cli-demo' + config: + provider: cli-mock + model: cli-mock + persona: 'Test the session-telemetry-otel plugin.' + persistenceRoot: './.sessions' + persistenceCompression: 'none' + workspaceContext: false diff --git a/examples/package.json b/examples/package.json index bd87263340..133196aa96 100644 --- a/examples/package.json +++ b/examples/package.json @@ -41,6 +41,7 @@ "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:*", "@deepseek-ai/dsh-session-query": "workspace:*", "@deepseek-ai/dsh-session-query-sqlite": "workspace:*", + "@deepseek-ai/dsh-session-telemetry-otel": "workspace:*", "@deepseek-ai/dsh-spill-local": "workspace:*", "@deepseek-ai/dsh-spill-policy": "workspace:*", "@deepseek-ai/dsh-tui-demo": "workspace:*", diff --git a/knip.json b/knip.json index 59658e1df6..728ded39b7 100644 --- a/knip.json +++ b/knip.json @@ -31,6 +31,7 @@ "headless-agent/tests/fixtures/goal-domain/seed-goal.ts", "headless-agent/tests/fixtures/time-context-driver.ts", "headless-agent/tests/fixtures/time-context-mock-llm.ts", + "headless-agent/tests/fixtures/telemetry-otel-driver.ts", "acp-agent/tests/snapshots/lsp-definition/workspace/subject.ts", "tui-agent/tests/fixtures/tui-scripted-llm.ts", "acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts", @@ -188,6 +189,16 @@ "tests/**/*.ts" ] }, + "packages/telemetry/session-telemetry-otel": { + "entry": [ + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] + }, "packages/util/brand": { "project": [ "src/**/*.ts" diff --git a/packages/README.md b/packages/README.md index 4e18d7b5f8..93e06631d2 100644 --- a/packages/README.md +++ b/packages/README.md @@ -18,7 +18,7 @@ Packages live at `packages///`; groups are containers, while names r | [`fs/`](fs/README.md) | Filesystem capability family: seam, local impl, model-facing file tools, bash-backed discovery tools | Product — stable surface | | [`lsp/`](lsp/README.md) | LSP capability family: seam, generic stdio provider, and the `lsp` tool | Product — stable surface | | [`skill/`](skill/README.md) | Skill capability family: the provider registry, local provider, and model-facing catalog/loader | Product — stable surface | -| [`compact/`](compact/README.md) | Compaction capability family: the abstract seam + a basic backend (tool deferred) | Product — stable surface | +| [`compact/`](compact/README.md) | Compaction capability family: abstract seam + basic backend (tool deferred) | Product — stable surface | | [`context/`](context/README.md) | Model-visible request context, including workspace instructions and time context | Product — stable surface | | [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry seam and the model-facing delegation tool | Product — stable surface | | [`tasks/`](tasks/README.md) | Generic background-task runtime and model-facing `task_*` control tools | Product — stable surface | @@ -32,8 +32,9 @@ Packages live at `packages///`; groups are containers, while names r | [`cordis/`](cordis/README.md) | Self-referential runtime toolset: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface | | [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface | | [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface | -| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, event relationships, semantic filtering, and SQLite full-text search | Product — stable surface | +| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, event relationships, semantic filtering, SQLite full-text search | Product — stable surface | | [`session-title/`](session-title/README.md) | Log-backed session titles: fallback service, shared LLM policy, and opt-in providers | Product — stable surface | +| [`telemetry/`](telemetry/README.md) | Session reporting: capture/redact seam, OTel backend | Product — stable surface | | [`sdk/`](sdk/README.md) | Project SDK tooling | Product — stable surface | | [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, JSON-RPC SDK server, user-approval/user-interaction seams, ask-user tool | Product — stable surface | | [`examples/`](examples/README.md) | Demo bundles (agent-spine + TUI/one-shot CLI/ACP/JSON-RPC bins) the leaves load | Support — example infra | @@ -46,6 +47,6 @@ Groups distinguish product API from support infrastructure. New packages join an The dependency graph is generated: [docs/module-graph.md](../docs/module-graph.md) (`pnpm run gen-module-graph`, freshness-gated in CI). -**Extension plugins depend on interfaces, never the concrete loop.** `dsh-agent-loop` is swappable; UI, hook, and tool plugins use `dsh-agent`. Composition bundles, including `dsh-agent-spine-demo`, may depend on spine plugins. Capabilities split into interface / implementation / consumer packages; see [capability seams](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md). +**Extension plugins depend on interfaces, never the concrete loop.** `dsh-agent-loop` is swappable; UI, hook, and tool plugins use `dsh-agent`. Composition bundles, including `dsh-agent-spine-demo`, may depend on spine plugins. Capabilities split into interface/implementation/consumer packages; see [capability seams](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md). Package READMEs cover purpose, APIs, extension points, and [Model Experience](../docs/cookbook/adding-a-package.md#4-write-the-package-readme) unless on the model-agnostic [omission allowlist](../scripts/verify-package-readme-model-experience.ts). They also carry `## Known Limitations and Deferred Work` or use its [allowlist](../scripts/verify-package-readme-limitations.ts). diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index c3e4e1858b..aee1ac9ae8 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -724,6 +724,24 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'telemetry', + summary: 'The backend contract in its loadable form: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis\' standard behavior.', + methods: [ + { + signature: 'abstract emit(record: TelemetryRecord): void', + jsDoc: '/**\n * See {@link TelemetryBackend.emit} — the seam declaration is the contract\'s one home.\n * @param record - the logical record to report; owned by the backend after the call.\n */', + }, + { + signature: 'flush?(): void', + jsDoc: '/** See {@link TelemetryBackend.flush}. */', + }, + { + signature: 'abstract shutdown(): Promise', + jsDoc: '/**\n * See {@link TelemetryBackend.shutdown}.\n * @returns resolves when the backend\'s pipeline has quiesced.\n */', + }, + ], + }, { key: 'tokenMeter', summary: 'Replay owner for one service-wide estimator and isolated per-session folds.', @@ -1102,6 +1120,13 @@ export const EVENT_API: readonly EventApiEntry[] = [ jsDoc: '/**\n * Emitted when any prompt provider changes. This registry notification is\n * unfiltered because a global change affects every scope.\n * @mode emit\n */', summary: 'Emitted when any prompt provider changes.', }, + { + name: 'telemetry/redact', + mode: 'waterfall', + signature: '\'telemetry/redact\'(record: TelemetryRecord, next: () => TelemetryRecord): TelemetryRecord', + jsDoc: '/**\n * Redact one outbound record before it reaches the backend. The innermost\n * `next()` applies the seam\'s conservative default rule set\n * (credential-shape scrubbing); listeners stack stricter rules by\n * transforming its return value, and returning without `next()` replaces\n * the default — the exported record is then only as clean as the\n * replacing rule. Dispatched synchronously on the capture hot path inside\n * the coordinator\'s containment: a throwing listener withholds that one\n * record (fail-closed) and never reaches the agent loop. Redaction\n * applies to the exported copy only; the canonical session log is never\n * rewritten.\n * @param record - the candidate record, already the coordinator\'s own deep\n * copy; listeners return a (possibly new) record and must not mutate it.\n * @mode waterfall\n */', + summary: 'Redact one outbound record before it reaches the backend.', + }, { name: 'tools/change', mode: 'emit', @@ -2087,6 +2112,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'TaskStatus', declaration: 'export type TaskStatus = \'running\' | \'stopping\' | \'completed\' | \'killed\' | \'failed\';', }, + { + name: 'TelemetryRecord', + declaration: 'export interface TelemetryRecord {\n channel: \'ledger\' | \'ops\';\n time: number;\n severity: TelemetrySeverity;\n attributes: Record;\n body: unknown;\n}', + }, + { + name: 'TelemetrySeverity', + declaration: 'export type TelemetrySeverity = \'info\' | \'warn\' | \'error\';', + }, { name: 'TerminalCallView', declaration: 'export interface TerminalCallView {\n card: \'terminal\';\n title: string;\n description?: string;\n cwd?: string;\n}', diff --git a/packages/telemetry/README.md b/packages/telemetry/README.md new file mode 100644 index 0000000000..a5869077c2 --- /dev/null +++ b/packages/telemetry/README.md @@ -0,0 +1,8 @@ +# telemetry/ + +Outbound session reporting: the telemetry seam plus its OpenTelemetry backend. The design — the boundary axiom (the harness's aspect ends at `emit()`; delivery is the reporting SDK's), the mandatory `telemetry/redact` waterfall, the fixed chunk projection, the handoff cursor, and the operational-record channel — is pinned in [the revival Agent Note](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md). + +| Package | Role | +|---|---| +| [`@deepseek-ai/dsh-session-telemetry`](session-telemetry/) | The seam: capture points, projection, redaction, handoff cursor, ops signals, and the minimal backend contract (`emit`/`flush?`/`shutdown`). | +| [`@deepseek-ai/dsh-session-telemetry-otel`](session-telemetry-otel/) | The backend a deployment loads: the OTel JS SDK's log pipeline (`LoggerProvider` + `BatchLogRecordProcessor` + OTLP/HTTP exporter), configured verbatim through passthroughs. | diff --git a/packages/telemetry/session-telemetry-otel/README.md b/packages/telemetry/session-telemetry-otel/README.md new file mode 100644 index 0000000000..26edc62645 --- /dev/null +++ b/packages/telemetry/session-telemetry-otel/README.md @@ -0,0 +1,39 @@ +# @deepseek-ai/dsh-session-telemetry-otel + +The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — the only entry a deployment loads. It composes the OTel JS SDK as-is (`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP log exporter) and maps each record the seam hands over onto `logger.emit()`, under two instrumentation scopes: ledger records on `@deepseek-ai/dsh-session-telemetry-otel`, operational records on `@deepseek-ai/dsh-session-telemetry-otel/ops`. Resource identity (`service.name`/`service.version`) comes from `dsh-llm`'s `APP_IDENTITY`, the same source the attribution headers use. + +## Config + +```yaml +- id: telemetry-otel + name: '@deepseek-ai/dsh-session-telemetry-otel' + config: + exporter: # passed verbatim to the SDK's OTLP/HTTP log exporter + url: https://collector.example.com/v1/logs + headers: + authorization: !!js `Bearer ${process.env.OTLP_TOKEN}` + processor: {} # optional; passed verbatim to BatchLogRecordProcessor +``` + +`exporter.url` is the one field this package validates itself — required, no default, must parse as `http(s)` — so a missing endpoint fails at plugin load. Everything else is the SDK's option shape, owned and documented by the SDK; batching, retry, queue bounds, and loss policy under sustained failure are its documented behavior, tuned through the `processor` passthrough. Removing this block from `cordis.yml` is the opt-out: no residual state, no `enabled` flag. + +## What leaves the machine + +Records carry the seam's REDACTED copy of `event.data` — user and assistant message content, tool arguments and results (command output, file contents), the full system prompt and tool schemas (`request/header`), todo text, compaction summaries, hook `stderrSummary`, and the session `cwd` (a local path) — after the seam's `telemetry/redact` waterfall has scrubbed credential-shaped substrings (see [the seam README](../session-telemetry/README.md#the-redact-waterfall)). Provider credentials never appear regardless: adapter API keys are constructor parameters, not session events, so they are structurally absent from the log and therefore from telemetry. A deployment with stricter requirements stacks `telemetry/redact` listeners or opts out structurally. + +## Field mapping + +Seam record → SDK log record: `time` → `timestamp`/`observedTimestamp`; `severity` → `severityNumber`/`severityText` (INFO 9 / WARN 13 / ERROR 17); `body` → the structured log body; `attributes` verbatim. Receivers dedupe on `(session.id, event.seq)`, alert on severity, and detect crashes by `shutdown`-record staleness (a session with activity, no `shutdown` ops record, gone stale ended uncleanly). + +## Model Experience + +None, as the backend only forwards the seam's redacted records into the OTel SDK pipeline; it never contributes to a model request. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + +- **Upstream experimental tree** — `@opentelemetry/sdk-logs` is still published from the upstream experimental tree; SDK API churn lands here and only here — the seam contract does not move. +- **Live-collector smoke is opt-in** — the e2e smoke (`tests/otel.e2e.ts`) self-skips without `$DSH_OTLP_E2E_ENDPOINT`; the keyless Loader-composition e2e (`tests/loader-composition.e2e.ts`) covers the wire shape against a mock collector on every run. diff --git a/packages/telemetry/session-telemetry-otel/package.json b/packages/telemetry/session-telemetry-otel/package.json new file mode 100644 index 0000000000..706112d1fe --- /dev/null +++ b/packages/telemetry/session-telemetry-otel/package.json @@ -0,0 +1,52 @@ +{ + "name": "@deepseek-ai/dsh-session-telemetry-otel", + "description": "OpenTelemetry backend for the DeepSeek Harness telemetry seam: hands captured session records to the OTel JS SDK's log pipeline", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "dependencies": { + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/api-logs": "^0.220.0", + "@opentelemetry/exporter-logs-otlp-http": "^0.220.0", + "@opentelemetry/resources": "^2.9.0", + "@opentelemetry/sdk-logs": "^0.220.0", + "schemastery": "^3.18.0" + }, + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-session-telemetry": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-telemetry": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/telemetry/session-telemetry-otel/src/index.ts b/packages/telemetry/session-telemetry-otel/src/index.ts new file mode 100644 index 0000000000..4738cdd932 --- /dev/null +++ b/packages/telemetry/session-telemetry-otel/src/index.ts @@ -0,0 +1,168 @@ +/** + * OpenTelemetry backend for the DeepSeek Harness telemetry seam. + * + * Composes the OTel JS SDK as-is — a `LoggerProvider` with a + * `BatchLogRecordProcessor` and an OTLP/HTTP log exporter — and maps each + * record handed over by the seam onto `logger.emit()`. Per the seam's + * boundary axiom, everything downstream of that call (batching, retry, + * queueing, loss policy) is the SDK's documented behavior, configured + * verbatim through the `exporter`/`processor` passthroughs; this package + * adds no knobs of its own on top of them. + * + * @module @deepseek-ai/dsh-session-telemetry-otel + */ + +import { createRequire } from 'node:module' +import z from 'schemastery' +import type { Context } from 'cordis' +import { Telemetry, TelemetryCoordinator, type TelemetryRecord, type TelemetrySeverity } from '@deepseek-ai/dsh-session-telemetry' +import { APP_IDENTITY } from '@deepseek-ai/dsh-llm' +import { + BatchLogRecordProcessor, + LoggerProvider, + type BatchLogRecordProcessorOptions, +} from '@opentelemetry/sdk-logs' +import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http' +import { SeverityNumber, type AnyValue, type Logger } from '@opentelemetry/api-logs' +import { resourceFromAttributes } from '@opentelemetry/resources' + +// The package's own manifest is the single source of the instrumentation-scope +// version (same pattern as dsh-llm's attribution identity). +const { version } = createRequire(import.meta.url)('../package.json') as { version: string } + +/** + * Plugin configuration: two verbatim SDK option shapes plus nothing else. + * `exporter.url` is the one field this package validates itself — required, + * no default, must parse as an `http(s)` URL — because a missing endpoint + * must fail at plugin load, not at first export. + */ +export interface Config { + /** Passed verbatim to the SDK's OTLP/HTTP log exporter. */ + exporter?: { + /** Full logs endpoint (e.g. `https://collector.example.com/v1/logs`). Required; validated at plugin load. */ + url?: string + /** Extra request headers (auth etc.); owned and sent by the SDK exporter. */ + headers?: Record + } + /** + * Passed verbatim to `BatchLogRecordProcessor` (minus the exporter slot, + * which this plugin fills); the SDK owns and documents these knobs. + */ + processor?: Omit +} + +/** + * Schemastery validator for {@link Config}; cordis runs it before the plugin + * starts. Shape-level only — the load-bearing `exporter.url` check lives in + * the constructor so its error message names the field. + */ +export const Config: z = z.object({ + exporter: z.object({ + url: z.string(), + headers: z.dict(z.string()), + }), + // Opaque passthrough: the SDK owns this shape and validates its own + // options; re-declaring them here would violate the boundary axiom. + processor: z.any(), +}) + +/** Severity mapping from the seam's three-level vocabulary to OTel severity numbers. */ +const SEVERITY: Record = { + info: { severityNumber: SeverityNumber.INFO, severityText: 'INFO' }, + warn: { severityNumber: SeverityNumber.WARN, severityText: 'WARN' }, + error: { severityNumber: SeverityNumber.ERROR, severityText: 'ERROR' }, +} + +/** + * The backend plugin — the only entry a deployment loads. Constructing it + * wires the SDK pipeline, registers the `telemetry` service (duplicate load + * throws, cordis' standard duplicate-service behavior), and composes the + * seam's {@link TelemetryCoordinator}, which installs the capture side onto + * this fiber. + */ +export class TelemetryOtel extends Telemetry { + static inject = ['sessions'] + static Config = Config + + private readonly provider: LoggerProvider + private readonly ledger: Logger + private readonly ops: Logger + + constructor(ctx: Context, config: Config) { + super(ctx) + const url = config.exporter?.url + if (url === undefined || url.length === 0) { + throw new Error('session-telemetry-otel: exporter.url is required (the full OTLP logs endpoint)') + } + let parsed: URL + try { + parsed = new URL(url) + } catch { + // Re-thrown as a config error: the only way here is a malformed url string. + throw new Error(`session-telemetry-otel: exporter.url is not a valid URL: ${JSON.stringify(url)}`) + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error(`session-telemetry-otel: exporter.url must be http(s), got ${parsed.protocol}`) + } + this.provider = new LoggerProvider({ + resource: resourceFromAttributes({ + 'service.name': APP_IDENTITY.product, + 'service.version': APP_IDENTITY.version, + }), + processors: [ + new BatchLogRecordProcessor({ + ...config.processor, + exporter: new OTLPLogExporter({ + url, + // App identity travels in the Resource (service.name/version); + // the transport-level user-agent is the SDK's own, per the axiom. + // Schemastery fills `headers` with {} before cordis constructs the + // plugin, so the optional type exists for hand-authors only. + headers: config.exporter?.headers as Record, + }), + }), + ], + }) + this.ledger = this.provider.getLogger('@deepseek-ai/dsh-session-telemetry-otel', version) + this.ops = this.provider.getLogger('@deepseek-ai/dsh-session-telemetry-otel/ops', version) + new TelemetryCoordinator(ctx, this) + } + + /** + * Map one seam record onto the SDK logger for its channel — a synchronous + * enqueue into the batch processor's queue. + * @param record - the logical record handed over by the coordinator. + */ + emit(record: TelemetryRecord): void { + const logger = record.channel === 'ops' ? this.ops : this.ledger + logger.emit({ + timestamp: record.time, + observedTimestamp: record.time, + ...SEVERITY[record.severity], + // JSON-serializable by the seam's contract (validated at Session.append), + // which is exactly the AnyValue subset. + body: record.body as AnyValue, + attributes: record.attributes, + }) + } + + /** Forward the turn-boundary hint to the SDK's flush, fire-and-forget. */ + override flush(): void { + // Best-effort hint: the SDK resolves forceFlush even when exports fail + // (failures go to its own diagnostics), and the coordinator stops calling + // this once the fiber is disposed — a rejection would be SDK drift. + /* v8 ignore next -- unreachable guard: forceFlush does not reject while the provider is alive */ + void this.provider.forceFlush().catch(() => {}) + } + + /** + * Delegate disposal to the SDK's shutdown contract: flush the queue and + * quiesce. Awaited (and error-contained) by the coordinator's disposer. + * @returns resolves when the SDK pipeline has quiesced. + */ + shutdown(): Promise { + return this.provider.shutdown() + } +} + +export default TelemetryOtel diff --git a/packages/telemetry/session-telemetry-otel/src/invariant.ts b/packages/telemetry/session-telemetry-otel/src/invariant.ts new file mode 100644 index 0000000000..075e5cc193 --- /dev/null +++ b/packages/telemetry/session-telemetry-otel/src/invariant.ts @@ -0,0 +1,32 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-session-telemetry-otel`. + * @module @deepseek-ai/dsh-session-telemetry-otel/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-session-telemetry-otel' + +/** Cordis companion plugin name. */ +export const name = 'session-telemetry-otel-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the backend forwards seam records into the OTel SDK's + * in-process pipeline and appends nothing to any session; its only observable + * effects (batching, export) happen inside the SDK past the seam's boundary + * axiom, out of reach of an independent companion. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/telemetry/session-telemetry-otel/tests/loader-composition.e2e.ts b/packages/telemetry/session-telemetry-otel/tests/loader-composition.e2e.ts new file mode 100644 index 0000000000..2c72fd6170 --- /dev/null +++ b/packages/telemetry/session-telemetry-otel/tests/loader-composition.e2e.ts @@ -0,0 +1,98 @@ +/** + * REAL-composition tier: boot the examples-owned telemetry Loader fixture as + * a subprocess (per testing policy, through the same app/boot path a + * deployment uses), run one mocked-model turn with a real bash round trip, + * and assert against what the mock OTLP collector actually received on the + * wire: ledger mirroring, default redaction, ops markers, and the untouched + * canonical log. + */ + +import { readFile, readdir } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' +import { REDACTION_PLACEHOLDER } from '@deepseek-ai/dsh-session-telemetry' + +const driver = fileURLToPath(new URL( + '../../../../examples/headless-agent/tests/fixtures/telemetry-otel-driver.ts', + import.meta.url, +)) +const configPath = fileURLToPath(new URL( + '../../../../examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml', + import.meta.url, +)) +const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) + +const FIXTURE_SECRET = 'sk-e2efixture1234567890' + +interface OtlpLogRecord { + attributes?: { key: string; value: Record }[] + body?: unknown +} + +interface OtlpCapture { + resourceLogs: { + scopeLogs: { + scope: { name: string } + logRecords: OtlpLogRecord[] + }[] + }[] +} + +async function jsonlFiles(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }) + const paths = await Promise.all(entries.map(async (entry) => { + const path = join(dir, entry.name) + if (entry.isDirectory()) return jsonlFiles(path) + return entry.isFile() && entry.name.endsWith('.jsonl') ? [path] : [] + })) + return paths.flat() +} + +describe('session-telemetry-otel through a real headless cordis.yml', () => { + it('exports redacted ledger records to the collector while the canonical log keeps the secret', async () => { + let captures: OtlpCapture[] = [] + let logContent = '' + const { stderr } = await runLoaderSmoke({ + label: 'session-telemetry-otel loader smoke', + tempDirPrefix: 'telemetry-otel-e2e-', + binScript: driver, + libBinScript: driver, + configPath, + tsconfigPath: repoTsconfig, + inspect: async (cwd) => { + captures = JSON.parse(await readFile(join(cwd, 'otlp-captures.json'), 'utf8')) as OtlpCapture[] + const logs = await jsonlFiles(join(cwd, '.sessions')) + expect(logs).toHaveLength(1) + logContent = await readFile(logs[0] as string, 'utf8') + }, + }) + expect(stderr).not.toContain('UNHANDLED') + + const records = captures.flatMap(capture => capture.resourceLogs.flatMap(resource => + resource.scopeLogs.flatMap(scoped => scoped.logRecords.map(record => ({ scope: scoped.scope.name, record }))))) + expect(records.length).toBeGreaterThan(0) + + const eventTypes = records.flatMap(({ record }) => + record.attributes?.flatMap(attribute => + attribute.key === 'event.type' && typeof attribute.value['stringValue'] === 'string' + ? [attribute.value['stringValue']] + : []) ?? []) + for (const expected of ['turn/start', 'user/message', 'tool/call', 'tool/result', 'assistant/message', 'turn/end']) { + expect(eventTypes, expected).toContain(expected) + } + expect(records.some(({ scope }) => scope.endsWith('/ops'))).toBe(true) + + // Default redaction on the wire: the fixture credential never leaves the + // process, its surrounding prose does, and the placeholder marks the spot. + const wire = JSON.stringify(captures) + expect(wire).not.toContain(FIXTURE_SECRET) + expect(wire).toContain(REDACTION_PLACEHOLDER) + expect(wire).toContain('prove telemetry with key') + + // The canonical session log is never rewritten. + expect(logContent).toContain(FIXTURE_SECRET) + expect(logContent).not.toContain(REDACTION_PLACEHOLDER) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) +}) diff --git a/packages/telemetry/session-telemetry-otel/tests/otel.e2e.ts b/packages/telemetry/session-telemetry-otel/tests/otel.e2e.ts new file mode 100644 index 0000000000..91c92b3486 --- /dev/null +++ b/packages/telemetry/session-telemetry-otel/tests/otel.e2e.ts @@ -0,0 +1,25 @@ +/** + * Keyless-self-skipping smoke: ship one real session's records to a live + * OTLP collector named by $DSH_OTLP_E2E_ENDPOINT and require the SDK's + * shutdown (flush-and-quiesce) to resolve. Skipped without the endpoint so + * secretless CI stays green — a CI accommodation, not a cost signal. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import TelemetryOtel from '../src/index.ts' + +describe.skipIf(!process.env.DSH_OTLP_E2E_ENDPOINT)('telemetry-otel e2e (live collector)', () => { + it('exports a session and quiesces cleanly', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(TelemetryOtel, { + exporter: { url: process.env.DSH_OTLP_E2E_ENDPOINT! }, + }) + const session = ctx.sessions.create(SessionId(`e2e-${Date.now()}`), { meta: {} }) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + await expect(fiber.dispose()).resolves.not.toThrow() + }) +}) diff --git a/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts new file mode 100644 index 0000000000..88a09a0f3b --- /dev/null +++ b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts @@ -0,0 +1,166 @@ +/** + * OTel backend unit tier: wire assertions against a scripted `node:http` + * mock collector through the SDK's REAL pipeline (BatchLogRecordProcessor → + * OTLP/HTTP JSON), config fail-loud cases, and the real-Loader-path guard + * for the default-exported Service class. + */ + +import { afterEach, describe, expect, it } from 'vitest' +import { createServer, type Server } from 'node:http' +import { once } from 'node:events' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import TelemetryOtel, { Config } from '../src/index.ts' + +interface Capture { + headers: import('node:http').IncomingHttpHeaders + body: OtlpLogsRequest +} + +/** Just the slice of ExportLogsServiceRequest JSON these assertions touch. */ +interface OtlpLogsRequest { + resourceLogs: { + resource: { attributes: { key: string; value: { stringValue?: string } }[] } + scopeLogs: { + scope: { name: string } + logRecords: { + timeUnixNano: string + severityNumber: number + severityText: string + attributes?: { key: string; value: Record }[] + }[] + }[] + }[] +} + +const servers: Server[] = [] + +afterEach(async () => { + for (const server of servers.splice(0)) { + server.close() + server.closeAllConnections() + } +}) + +async function mockCollector(): Promise<{ url: string; captures: Capture[] }> { + const captures: Capture[] = [] + const server = createServer((request, response) => { + const chunks: Buffer[] = [] + request.on('data', chunk => chunks.push(chunk as Buffer)) + request.on('end', () => { + captures.push({ + headers: request.headers, + body: JSON.parse(Buffer.concat(chunks).toString()) as OtlpLogsRequest, + }) + response.writeHead(200, { 'content-type': 'application/json' }).end('{}') + }) + }) + servers.push(server) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('no port') + return { url: `http://127.0.0.1:${address.port}/v1/logs`, captures } +} + +async function boot(url: string) { + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(TelemetryOtel, { + exporter: { url, headers: { authorization: 'Bearer test-token' } }, + }) + return { ctx, fiber } +} + +function allRecords(captures: Capture[]) { + return captures.flatMap(c => c.body.resourceLogs.flatMap(r => r.scopeLogs.flatMap(s => + s.logRecords.map(record => ({ scope: s.scope.name, record }))))) +} + +describe('TelemetryOtel wire', () => { + it('ships session records and the ops shutdown marker through the real SDK pipeline', async () => { + const { url, captures } = await mockCollector() + const { ctx, fiber } = await boot(url) + const session = ctx.sessions.create(SessionId('wire'), { meta: { cwd: '/tmp/w' } }) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'boom' } }) + await fiber.dispose() + + expect(captures.length).toBeGreaterThan(0) + const first = captures[0]! + const authorization: string | undefined = first.headers.authorization + expect(authorization).toBe('Bearer test-token') + + const resource = first.body.resourceLogs[0]!.resource.attributes + expect(resource).toContainEqual({ key: 'service.name', value: { stringValue: 'deepseek-harness' } }) + + const records = allRecords(captures) + const ledger = records.filter(r => r.scope === '@deepseek-ai/dsh-session-telemetry-otel') + const ops = records.filter(r => r.scope === '@deepseek-ai/dsh-session-telemetry-otel/ops') + + const start = ledger.find(r => r.record.attributes?.some(a => a.key === 'event.type' && a.value.stringValue === 'turn/start')) + expect(start).toBeDefined() + expect(start?.record.severityNumber).toBe(9) + expect(BigInt(start!.record.timeUnixNano)).toBe(BigInt(session.events[0]!.time) * 1_000_000n) + expect(start?.record.attributes).toContainEqual({ key: 'session.cwd', value: { stringValue: '/tmp/w' } }) + + const end = ledger.find(r => r.record.attributes?.some(a => a.key === 'event.type' && a.value.stringValue === 'turn/end')) + expect(end?.record.severityNumber).toBe(17) + expect(end?.record.severityText).toBe('ERROR') + + expect(ops).toHaveLength(1) + expect(ops[0]!.record.attributes).toContainEqual({ key: 'telemetry.op', value: { stringValue: 'shutdown' } }) + }) + + it('maps the warn severity and forwards the flush hint to the SDK', async () => { + const { url, captures } = await mockCollector() + const { ctx, fiber } = await boot(url) + const session = ctx.sessions.create(SessionId('warn'), { meta: {} }) + session.append('prompt/blocked', { content: [], source: { kind: 'user' }, reason: 'vetoed' }) + // The turn-boundary hint: safe, non-blocking, and enough to push the batch out. + expect(() => { + ctx.telemetry.flush!() + }).not.toThrow() + await fiber.dispose() + const blocked = allRecords(captures).find(r => + r.record.attributes?.some(a => a.key === 'event.type' && a.value.stringValue === 'prompt/blocked')) + expect(blocked?.record.severityNumber).toBe(13) + }) +}) + +describe('TelemetryOtel config fails loud', () => { + it.each([ + [{}, /exporter\.url is required/], + [{ exporter: { url: '' } }, /exporter\.url is required/], + [{ exporter: { url: 'not a url' } }, /not a valid URL/], + [{ exporter: { url: 'ftp://collector' } }, /must be http\(s\)/], + ])('rejects %j at plugin load', async (config, message) => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await expect(ctx.plugin(TelemetryOtel, config as Config)).rejects.toThrow(message) + }) +}) + +describe('dsh-session-telemetry-otel real-load-path guard', () => { + it('keeps the Service class with inject/Config through unwrapExports', async () => { + const module = await import('../src/index.ts') + const loader = Object.create(Loader.prototype) as Loader + const unwrapped = loader.unwrapExports(module) as typeof TelemetryOtel + expect(unwrapped).toBe(TelemetryOtel) + expect(unwrapped.inject).toEqual(['sessions']) + expect(typeof unwrapped.Config).toBe('function') + }) + + it('boots through the unwrapped class and registers ctx.telemetry', async () => { + const { url } = await mockCollector() + const module = await import('../src/index.ts') + const loader = Object.create(Loader.prototype) as Loader + const unwrapped = loader.unwrapExports(module) as Parameters[0] + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(unwrapped, { exporter: { url } }) + expect(ctx.telemetry).toBeInstanceOf(TelemetryOtel) + await fiber.dispose() + }) +}) diff --git a/packages/telemetry/session-telemetry-otel/tsconfig.json b/packages/telemetry/session-telemetry-otel/tsconfig.json new file mode 100644 index 0000000000..9512133cf7 --- /dev/null +++ b/packages/telemetry/session-telemetry-otel/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/session" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../session-telemetry" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/telemetry/session-telemetry/README.md b/packages/telemetry/session-telemetry/README.md new file mode 100644 index 0000000000..bccc61fd06 --- /dev/null +++ b/packages/telemetry/session-telemetry/README.md @@ -0,0 +1,40 @@ +# @deepseek-ai/dsh-session-telemetry + +The telemetry seam: the CAPTURE side of session-event reporting, behind a backend contract any reporting SDK satisfies with zero bending. The boundary axiom that shapes everything here: **this package's aspect ends at `emit()`** — batching, retry, queueing, and loss policy belong to the backend's SDK and are neither specified nor wrapped. Rationale and rejected alternatives: [the revival Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md). + +## The backend contract + +`TelemetryBackend` is three members: `emit(record)` (MUST be a non-blocking enqueue — it runs synchronously on the `session/event` hot path), optional `flush()` (a turn-boundary hint, fire-and-forget), and `shutdown()` (the lifecycle forward: flush-and-quiesce, awaited at dispose). `Telemetry` is its service-registered form under the `telemetry` context key — one implementation per context, duplicate load throws. A backend composes `TelemetryCoordinator` in its constructor. + +## Capture points + +The coordinator registers, all through the composing fiber's effects: `session/created` (adopt: record the header, read the log back through the projection — seed events from fork/resume never re-emit on the firehose), `session/event` (project, deep-copy, redact, hand off; zero I/O), `session/flush` (forward the optional `flush()` hint and return void — the loop's awaited parallel must never wait on telemetry), `agent/error` (the one live-bus relay; turn-enclosure structurally bars those errors from the log), a dispose effect (per adopted session emit its `shutdown` operational record, then await the backend's `shutdown()`; failures warn instead of throwing), and an adoption sweep of `ctx.sessions.list()` (a hot reload does not replay `session/created`). + +## The redact waterfall + +Every record passes the `telemetry/redact` waterfall between projection and `emit()` — nothing reaches a backend unredacted. The innermost `next()` applies the built-in conservative rule set (`applyDefaultRedaction`: credential shapes — API keys, GitHub/Slack tokens, AWS/Google keys, JWTs, PEM blocks, URL userinfo — replaced with `[REDACTED]` in body strings and string attribute values). Listeners stack stricter rules by transforming `next()`'s return value; returning without `next()` replaces the default rule set, and a throwing listener withholds that one record fail-closed inside the coordinator's containment. The built-in pattern list is a security invariant, deliberately not configurable from cordis.yml. Redaction applies to the exported copy only; the canonical session log is never rewritten. + +## The handoff cursor + +A module-scope `WeakMap` marks the highest seq HANDED OFF (not delivered) per session, advanced at emit time. It survives reloads that do not re-evaluate this module — config re-applies and backend source reloads, which is where iteration happens; that asymmetry is why the cursor lives in the seam. On re-adoption the coordinator re-hands only events past the cursor (events at or below it still rebuild the chunk-projection state); a missing cursor safely degrades to a full re-hand, absorbed by receiver-side dedupe on `(session.id, event.seq)`. This is a deliberate, narrow exception to the registrations-are-effects discipline: entries die with their sessions, the value is a monotonic watermark, and losing it is never an error. + +## The fixed chunk projection + +Only the first `assistant/chunk` of each `(turn, step)` ships; the rest are dropped at capture and never advance the cursor. That one chunk is the stream-started signal: `step/start` + first-chunk presence + `assistant/message` presence + the `turn/end` reason distinguish "the request never started" from "the stream died midway" without chunk volume, and time-to-first-token stays computable. Chunk elision makes `seq` gaps routine on the wire — a gap is never a loss signal. Every other event type, including ones merged by plugins this package never heard of, passes through whole. + +## The logical record + +`TelemetryRecord`: `channel` (`ledger` | `ops`), `time` (epoch ms), `severity` (pre-mapped: ERROR for `tool/result.isError`, `turn/end` error reasons, `compact/end` errors; WARN for `prompt/blocked`; INFO otherwise), identity-only `attributes` (`session.id`, `event.type`, `event.seq`, plus `session.cwd`/`session.parent_id` when the header has them), and the complete deep-copied `event.data` as `body` — post-redaction. Operational records carry `telemetry.op` (`agent-error` | `shutdown`) and `session.id`, and deliberately NO `event.seq`/`event.type` — signals to alert on, not entries to sum. Delivery downstream of the handoff is the backend SDK's; duplicates remain possible (cursor-less re-adoption, SDK retries), so receivers dedupe on `(session.id, event.seq)`. + +## Model Experience + +None, as the seam only observes the session stream and hands redacted copies to a reporting backend; it never contributes to a model request. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + +- **Best-effort delivery** — the cursor marks handed-off, not delivered; a session torn down inside a reload window cannot be re-adopted; whatever sits in a backend queue at crash time is lost. A durable outbox (spool, per-sink cursors, at-least-once) is deferred until a deployment states a crash-loss requirement — see [the revival Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md). +- **Redaction is shape-based** — the default rules catch known credential shapes, not every secret; a deployment with stricter needs stacks `telemetry/redact` listeners, and exported data is only as clean as the mounted rules. diff --git a/packages/telemetry/session-telemetry/package.json b/packages/telemetry/session-telemetry/package.json new file mode 100644 index 0000000000..71646c130e --- /dev/null +++ b/packages/telemetry/session-telemetry/package.json @@ -0,0 +1,41 @@ +{ + "name": "@deepseek-ai/dsh-session-telemetry", + "description": "Telemetry seam for the DeepSeek Harness: session-event capture, projection, redaction, and handoff to a reporting backend", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/telemetry/session-telemetry/src/coordinator.ts b/packages/telemetry/session-telemetry/src/coordinator.ts new file mode 100644 index 0000000000..08fbe498b1 --- /dev/null +++ b/packages/telemetry/session-telemetry/src/coordinator.ts @@ -0,0 +1,244 @@ +/** + * Capture coordinator: the seam's upstream half. Subscribes to the session + * firehose plus the one live-bus relay (`agent/error`), applies the fixed + * chunk projection, builds logical records, runs each through the + * `telemetry/redact` waterfall, and hands the redacted copy to the backend — + * synchronously, with every handler self-contained so a failing backend can + * never starve other subscribers (cordis `emit` is stop-on-throw) or touch + * the agent loop. Composed by a backend in its constructor. + * + * @module @deepseek-ai/dsh-session-telemetry/coordinator + */ + +import type { Context } from 'cordis' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { TelemetryBackend, TelemetryRecord, TelemetrySeverity } from './index.ts' +import { applyDefaultRedaction } from './redact.ts' + +/** + * The handoff cursor: per session, the highest `seq` handed to a backend. + * Deliberately MODULE-scope ambient state — a narrow, documented exception + * to the registrations-are-effects discipline: cordis has no HMR + * state-handover API, and keying by the `Session` object (which belongs to + * the session store and outlives any telemetry fiber) is the only in-process + * lifetime that lets a re-adopting fiber resume instead of re-handing + * history. Entries die with their sessions; a missing entry safely means + * "re-hand everything". Advanced only at emit time — the cursor marks + * handed-off, not delivered. + */ +const handoffCursor = new WeakMap() + +/** + * Install the telemetry capture side onto a context for one backend. + * + * Registers the persistence-coordinator listener set plus the `agent/error` + * relay, all through `ctx.effect()`/`ctx.on()` on the composing fiber, and + * sweeps already-live sessions (a hot reload does not replay + * `session/created`). Disposal emits each adopted session's `shutdown` + * operational record and then awaits the backend's `shutdown()`; a failure + * there warns instead of throwing — best-effort reporting must not fail + * application teardown. + */ +export class TelemetryCoordinator { + /** Sessions adopted by THIS fiber, for dispose-time `shutdown` records and double-adoption protection. */ + private readonly adopted = new Set() + /** Per session, the `turn:step` keys whose first chunk already shipped; rebuilt from the log on re-adoption. */ + private readonly chunkSeen = new WeakMap>() + + /** + * @param ctx - the composing backend's context; listeners bind to its fiber. + * @param backend - the backend receiving records; owned elsewhere, never disposed here beyond `shutdown()` forwarding. + */ + constructor( + private readonly ctx: Context, + private readonly backend: TelemetryBackend, + ) { + ctx.on('session/created', (session) => { + this.adopt(session) + }) + ctx.on('session/event', (session, event) => { + this.contain(() => { + this.capture(session, event) + }) + }) + // Parallel listeners are awaited by the loop at turn end; returning void + // (not the SDK's flush promise) is the turn-latency contract. + ctx.on('session/flush', (session) => { + this.contain(() => { + this.hintFlush(session) + }) + }) + ctx.on('agent/error', (agent, turn, step, error) => { + this.contain(() => { + this.relayAgentError(agent, turn, step, error) + }) + }) + ctx.effect(() => async () => { + for (const session of this.adopted) { + this.contain(() => { + this.handOff(shutdownRecord(session)) + }) + } + try { + await this.backend.shutdown() + } catch (error) { + this.ctx.logger.warn(`telemetry: backend shutdown failed: ${String(error)}`) + } + }, 'telemetry capture') + for (const session of ctx.sessions.list()) { + this.adopt(session) + } + } + + /** + * Adopt a session: replay its log THROUGH the projection from the handoff + * cursor (or from the start when no cursor survived), then rely on the + * firehose for everything after. Events at or below the cursor still feed + * the projection state (first-chunk tracking) without being re-handed, so + * a resumed fiber drops mid-step chunk continuations exactly like the + * fiber that saw the step begin. + * @param session - the live session to adopt; a second adoption is a no-op. + */ + private adopt(session: Session): void { + this.contain(() => { + if (this.adopted.has(session)) return + this.adopted.add(session) + const cursor = handoffCursor.get(session) ?? -1 + for (const event of session.events) { + if (event.seq <= cursor) this.track(session, event) + else this.capture(session, event) + } + }) + } + + /** Feed the chunk projection without handing off — the ≤cursor half of re-adoption. */ + private track(session: Session, event: SessionEvent): void { + if (event.type === 'assistant/chunk') { + this.seen(session).add(`${event.data.turn}:${event.data.step}`) + } + } + + /** Project one event and hand it to the backend, advancing the cursor on handoff. */ + private capture(session: Session, event: SessionEvent): void { + if (event.type === 'assistant/chunk') { + const key = `${event.data.turn}:${event.data.step}` + const seen = this.seen(session) + // Fixed chunk projection: only the first chunk of each (turn, step) + // ships — the stream-started signal; content is byte-complete in the + // step's assembled assistant/message. Dropped chunks do not advance + // the cursor, so re-adoption re-drops them deterministically. + if (seen.has(key)) return + seen.add(key) + } + this.handOff({ + channel: 'ledger', + time: event.time, + severity: severityOf(event), + attributes: identityOf(session, event), + // The live event object is mutable and the backend serializes later; + // append-time validation guarantees this clone cannot throw. + body: structuredClone(event.data), + }) + handoffCursor.set(session, event.seq) + } + + /** + * Run the `telemetry/redact` waterfall over one record and hand the result + * to the backend. The innermost `next` applies the seam's conservative + * default rules, so an unconfigured deployment still never exports raw + * credential shapes; callers run inside {@link contain}, so a throwing + * rule withholds the record instead of reaching the loop (fail-closed). + */ + private handOff(record: TelemetryRecord): void { + this.backend.emit(this.ctx.waterfall('telemetry/redact', record, () => applyDefaultRedaction(record))) + } + + /** Forward the turn-end boundary to the backend's optional flush hint. */ + private hintFlush(session: Session): void { + if (this.adopted.has(session)) this.backend.flush?.() + } + + /** Relay one `agent/error` bus emission as an `agent-error` operational record. */ + private relayAgentError(agent: Agent, turn: number, step: number, error: Error): void { + this.handOff({ + channel: 'ops', + time: Date.now(), + severity: 'error', + attributes: { + 'telemetry.op': 'agent-error', + 'session.id': String(agent.session.id), + 'agent.id': agent.id, + 'error.name': error.name, + turn, + step, + }, + body: { name: error.name, message: error.message }, + }) + } + + /** Lazily create the per-session first-chunk tracking set. */ + private seen(session: Session): Set { + let set = this.chunkSeen.get(session) + if (!set) this.chunkSeen.set(session, set = new Set()) + return set + } + + /** + * Run one capture-side step with its exception contained: cordis `emit` + * is stop-on-throw, so a throwing listener would starve every subscriber + * registered after this plugin — nothing from the backend may escape. + */ + private contain(step: () => void): void { + try { + step() + } catch (error) { + this.ctx.logger.warn(`telemetry: capture step failed: ${String(error)}`) + } + } +} + +/** Build the per-session clean-exit marker emitted at dispose, before the backend's `shutdown()`. */ +function shutdownRecord(session: Session): TelemetryRecord { + return { + channel: 'ops', + time: Date.now(), + severity: 'info', + attributes: { 'telemetry.op': 'shutdown', 'session.id': String(session.id) }, + body: { op: 'shutdown' }, + } +} + +/** Map an event's own outcome flag to the pre-baked alerting severity. */ +function severityOf(event: SessionEvent): TelemetrySeverity { + switch (event.type) { + case 'tool/result': + return event.data.isError ? 'error' : 'info' + case 'turn/end': + return event.data.reason.kind === 'error' ? 'error' : 'info' + case 'prompt/blocked': + return 'warn' + default: { + // Merge-extensible fall-through (no assertNever): types this seam does + // not depend on still get their RFC-pinned severity via a widened + // probe — `compact/end` is declared by dsh-compact, which the seam + // deliberately does not import. + const type: string = event.type + if (type === 'compact/end' && (event.data as { error?: unknown }).error !== undefined) return 'error' + return 'info' + } + } +} + +/** Build the minimal identity attributes: envelope plus self-contained header facts. */ +function identityOf(session: Session, event: SessionEvent): Record { + const attributes: Record = { + 'session.id': String(session.id), + 'event.type': event.type, + 'event.seq': event.seq, + } + const { cwd, parentSession } = session.header + if (cwd !== undefined) attributes['session.cwd'] = cwd + if (parentSession !== undefined) attributes['session.parent_id'] = String(parentSession) + return attributes +} diff --git a/packages/telemetry/session-telemetry/src/index.ts b/packages/telemetry/session-telemetry/src/index.ts new file mode 100644 index 0000000000..93e7e7b0f9 --- /dev/null +++ b/packages/telemetry/session-telemetry/src/index.ts @@ -0,0 +1,145 @@ +/** + * Telemetry seam for the DeepSeek Harness. + * + * The seam owns the CAPTURE side of session-event reporting — which records + * exist (the chunk projection), what they carry (the logical record), when + * they are handed over (adoption, the per-append firehose, lifecycle + * forwarding), and the HMR handoff cursor. Everything downstream of + * {@link Telemetry.emit} — batching, retry, queueing, loss policy — is the + * reporting SDK's territory and is deliberately not modelled here. The + * design and its trade-offs are pinned in + * .agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md. + * + * @module @deepseek-ai/dsh-session-telemetry + */ + +import { Context, Service } from 'cordis' + +declare module 'cordis' { + interface Context { + telemetry: Telemetry + } + + interface Events { + /** + * Redact one outbound record before it reaches the backend. The innermost + * `next()` applies the seam's conservative default rule set + * (credential-shape scrubbing); listeners stack stricter rules by + * transforming its return value, and returning without `next()` replaces + * the default — the exported record is then only as clean as the + * replacing rule. Dispatched synchronously on the capture hot path inside + * the coordinator's containment: a throwing listener withholds that one + * record (fail-closed) and never reaches the agent loop. Redaction + * applies to the exported copy only; the canonical session log is never + * rewritten. + * @param record - the candidate record, already the coordinator's own deep + * copy; listeners return a (possibly new) record and must not mutate it. + * @mode waterfall + */ + 'telemetry/redact'(record: TelemetryRecord, next: () => TelemetryRecord): TelemetryRecord + } +} + +/** + * Severity of a telemetry record, pre-mapped at capture so a receiver can + * alert with zero configuration: `error` for events whose own outcome flag + * says so (`tool/result.isError`, `turn/end` error reasons, `compact/end` + * errors) and for `agent-error` operational records, `warn` for + * `prompt/blocked`, `info` for everything else. + */ +export type TelemetrySeverity = 'info' | 'warn' | 'error' + +/** + * One logical record handed to a backend — the seam's whole outbound + * vocabulary. Ledger records mirror session-log events one-to-one; + * operational records (`channel: 'ops'`) carry the two signals with no log + * home (`agent-error`, `shutdown`) and deliberately omit `event.seq`-style + * identity so they can never be mistaken for ledger rows. + */ +export interface TelemetryRecord { + /** Ledger (session-log mirror) or ops (operational signal) channel; backends keep the two under separate instrumentation scopes. */ + channel: 'ledger' | 'ops' + /** Unix epoch milliseconds — the source event's append time for ledger records, the emission time for ops records. */ + time: number + /** Pre-mapped alerting severity; see {@link TelemetrySeverity}. */ + severity: TelemetrySeverity + /** + * Identity attributes, deliberately minimal: ledger records carry + * `session.id`, `event.type`, `event.seq`, plus `session.cwd` / + * `session.parent_id` when the header has them; ops records carry + * `telemetry.op`, `session.id`, and (for `agent-error`) `agent.id`, + * `turn`, `step`, `error.name`. Anything recoverable from the body is + * intentionally NOT duplicated here. + */ + attributes: Record + /** + * The complete payload: a deep copy of the session event's `data` for + * ledger records (JSON-serializable by `Session.append`'s own + * validation), or the op payload for ops records. Never mutated after + * handoff. + */ + body: unknown +} + +/** + * The backend contract the coordinator hands records to — the minimum any + * reporting SDK satisfies with zero bending. {@link Telemetry} is its + * service-registered form; tests compose the coordinator with a bare + * implementation of this interface. + */ +export interface TelemetryBackend { + /** + * Hand one record to the backend's pipeline. MUST be a non-blocking + * enqueue — the coordinator calls this synchronously from the + * `session/event` hot path, so anything slower than a queue push would tax + * the agent loop. Errors thrown here are contained by the coordinator and + * logged; they never reach the loop. + * @param record - the logical record to report; owned by the backend after the call. + */ + emit(record: TelemetryRecord): void + /** + * Optional hint that a natural boundary (turn end) passed — a backend may + * forward it to its SDK's flush so records land at turn boundaries. Called + * fire-and-forget; implementations must not block and must not throw + * meaningfully (the coordinator contains exceptions). + */ + flush?(): void + /** + * Forward the fiber's disposal to the SDK: flush whatever is queued and + * reach quiescence, per the SDK's own shutdown contract. Awaited by the + * coordinator's dispose; a rejection is logged as a warning and never + * fails application teardown. + * @returns resolves when the backend's pipeline has quiesced. + */ + shutdown(): Promise +} + +/** + * The backend contract in its loadable form: one implementation per context — + * the cordis `Service` registration under the `telemetry` key throws on a + * duplicate, cordis' standard behavior. A backend composes a + * {@link TelemetryCoordinator} in its constructor to install the capture side. + */ +export abstract class Telemetry extends Service implements TelemetryBackend { + constructor(ctx: Context) { + super(ctx, 'telemetry') + } + + /** + * See {@link TelemetryBackend.emit} — the seam declaration is the contract's one home. + * @param record - the logical record to report; owned by the backend after the call. + */ + abstract emit(record: TelemetryRecord): void + + /** See {@link TelemetryBackend.flush}. */ + flush?(): void + + /** + * See {@link TelemetryBackend.shutdown}. + * @returns resolves when the backend's pipeline has quiesced. + */ + abstract shutdown(): Promise +} + +export { TelemetryCoordinator } from './coordinator.ts' +export { applyDefaultRedaction, REDACTION_PLACEHOLDER } from './redact.ts' diff --git a/packages/telemetry/session-telemetry/src/invariant.ts b/packages/telemetry/session-telemetry/src/invariant.ts new file mode 100644 index 0000000000..ffc55b0107 --- /dev/null +++ b/packages/telemetry/session-telemetry/src/invariant.ts @@ -0,0 +1,32 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-session-telemetry`. + * @module @deepseek-ai/dsh-session-telemetry/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-session-telemetry' + +/** Cordis companion plugin name. */ +export const name = 'session-telemetry-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the seam's whole output is the backend handoff — a + * synchronous `emit()` call outside every authoritative event stream — and its + * capture side never appends session events, so no event/data relation exists + * for an independent companion to observe. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/telemetry/session-telemetry/src/redact.ts b/packages/telemetry/session-telemetry/src/redact.ts new file mode 100644 index 0000000000..d25452581a --- /dev/null +++ b/packages/telemetry/session-telemetry/src/redact.ts @@ -0,0 +1,77 @@ +/** + * Conservative default redaction for outbound telemetry records. + * + * Session-event bodies carry file contents and command output that may embed + * credentials; nothing may cross the seam to a backend unredacted. This module + * is the innermost rule set of the `telemetry/redact` waterfall — always + * applied unless an outer listener deliberately replaces the whole chain. It + * scrubs credential-SHAPED substrings from every string in the record body, + * leaving structure (keys, nesting, surrounding prose) intact. The pattern + * list is a security invariant, deliberately not configurable; deployments + * add stricter rules by stacking `telemetry/redact` listeners. + * + * @module @deepseek-ai/dsh-session-telemetry/redact + */ + +import type { TelemetryRecord } from './index.ts' + +/** Replacement text substituted for each detected credential-shaped span. */ +export const REDACTION_PLACEHOLDER = '[REDACTED]' + +/** + * Well-known credential shapes. A match anywhere inside a body string is + * replaced; low-signal values (package names, versions, git SHAs, plain URLs) + * deliberately stay untouched — they are the observability signal. + */ +const SECRET_PATTERNS: readonly RegExp[] = [ + /sk-(?:ant-)?[A-Za-z0-9_-]{10,}/g, // DeepSeek / OpenAI / Anthropic API keys + /gh[pousr]_[A-Za-z0-9]{16,}/g, // GitHub personal/oauth/server/refresh tokens + /github_pat_[A-Za-z0-9_]{20,}/g, // GitHub fine-grained PAT + /xox[baprs]-[A-Za-z0-9-]{10,}/g, // Slack tokens + /AKIA[0-9A-Z]{16}/g, // AWS access key id + /AIza[0-9A-Za-z_-]{35}/g, // Google API key + /eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, // JWT + /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, // PEM blocks + /\b(?[a-z][a-z0-9+.-]*):\/\/[^/\s:@]+:[^/\s:@]+@/g, // URL userinfo credentials +] + +/** Replace every known credential shape inside one string. */ +function scrub(text: string): string { + let out = text + for (const pattern of SECRET_PATTERNS) { + out = out.replace(pattern, REDACTION_PLACEHOLDER) + } + return out +} + +/** + * Deep-scrub every string inside a lossless-JSON value, preserving structure. + * The record body is the coordinator's own `structuredClone` — mutation-free + * rebuilding keeps the exported copy independent of the canonical log either way. + */ +function scrubValue(value: unknown): unknown { + if (typeof value === 'string') return scrub(value) + if (Array.isArray(value)) return value.map(scrubValue) + if (value !== null && typeof value === 'object') { + const out: Record = {} + for (const [key, entry] of Object.entries(value)) out[key] = scrubValue(entry) + return out + } + return value +} + +/** + * Apply the conservative default rule set to one record — the innermost + * `next` of the `telemetry/redact` waterfall. Attribute VALUES are scrubbed + * alongside the body (identity attributes are seam-built and boring, but + * `session.cwd` is caller-supplied); attribute keys are seam-owned constants. + * @param record - the candidate record; not mutated. + * @returns a redacted copy safe to hand to a backend. + */ +export function applyDefaultRedaction(record: TelemetryRecord): TelemetryRecord { + const attributes: Record = {} + for (const [key, value] of Object.entries(record.attributes)) { + attributes[key] = typeof value === 'string' ? scrub(value) : value + } + return { ...record, attributes, body: scrubValue(record.body) } +} diff --git a/packages/telemetry/session-telemetry/tests/redact.spec.ts b/packages/telemetry/session-telemetry/tests/redact.spec.ts new file mode 100644 index 0000000000..cd23af6da2 --- /dev/null +++ b/packages/telemetry/session-telemetry/tests/redact.spec.ts @@ -0,0 +1,157 @@ +/** + * Default redaction rules and the `telemetry/redact` waterfall contract: + * credential shapes scrubbed from bodies and attribute values, structure + * preserved, canonical log untouched, listener stacking/replacement, and the + * fail-closed containment of a throwing rule. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import { + applyDefaultRedaction, + REDACTION_PLACEHOLDER, + TelemetryCoordinator, + type TelemetryBackend, + type TelemetryRecord, +} from '../src/index.ts' + +const SECRETS = { + deepseek: 'sk-abcdef1234567890abcdef', + anthropic: 'sk-ant-abcdef1234567890', + githubPat: 'ghp_ABCDEFGHIJKLMNOPqrstuv12345678', + finePat: 'github_pat_ABCDEFGHIJKLMNOPQRSTuvwx', + slack: 'xoxb-1234567890-abcdefghij', + aws: 'AKIAIOSFODNN7EXAMPLE', + google: 'AIzaSyA-1234567890abcdefghijklmnopqrstu', + jwt: 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpM', + pem: '-----BEGIN RSA PRIVATE KEY-----\nMIIEow\n-----END RSA PRIVATE KEY-----', + urlCreds: 'https://user:hunter2@internal.example.com/repo.git', +} as const + +function record(body: unknown, attributes: Record = {}): TelemetryRecord { + return { channel: 'ledger', time: 1, severity: 'info', attributes, body } +} + +describe('applyDefaultRedaction', () => { + it('scrubs every known credential shape while preserving surrounding text', () => { + for (const secret of Object.values(SECRETS)) { + const out = applyDefaultRedaction(record(`before ${secret} after`)) + expect(out.body, secret).not.toContain(secret.includes('\n') ? 'MIIEow' : secret) + expect(out.body).toContain('before ') + expect(out.body).toContain(' after') + expect(out.body).toContain(REDACTION_PLACEHOLDER) + } + }) + + it('scrubs URL userinfo credentials but leaves plain URLs alone', () => { + const out = applyDefaultRedaction(record(`${SECRETS.urlCreds} and https://example.com/path`)) + expect(out.body).not.toContain('hunter2') + expect(out.body).toContain('https://example.com/path') + }) + + it('recurses through arrays and objects, preserving structure and non-strings', () => { + const out = applyDefaultRedaction(record({ + list: [`key=${SECRETS.deepseek}`, 7, null, true], + nested: { text: SECRETS.githubPat, count: 3 }, + })) + expect(out.body).toEqual({ + list: [`key=${REDACTION_PLACEHOLDER}`, 7, null, true], + nested: { text: REDACTION_PLACEHOLDER, count: 3 }, + }) + }) + + it('leaves low-signal values untouched', () => { + const clean = { + pkg: '@deepseek-ai/dsh-session-telemetry@0.0.1', + sha: '342a4c3a9d3adf13cf4ad33b9f8d6e79170be5e2', + prose: 'ordinary sentence with kebab-case-identifier', + } + expect(applyDefaultRedaction(record(clean)).body).toEqual(clean) + }) + + it('scrubs string attribute values and keeps numeric ones', () => { + const out = applyDefaultRedaction(record(null, { + 'session.cwd': `/home/${SECRETS.aws}/proj`, + 'event.seq': 4, + })) + expect(out.attributes['session.cwd']).toBe(`/home/${REDACTION_PLACEHOLDER}/proj`) + expect(out.attributes['event.seq']).toBe(4) + }) + + it('never mutates its input', () => { + const input = record({ text: SECRETS.slack }, { 'session.cwd': SECRETS.aws }) + applyDefaultRedaction(input) + expect((input.body as { text: string }).text).toBe(SECRETS.slack) + expect(input.attributes['session.cwd']).toBe(SECRETS.aws) + }) +}) + +class CollectingBackend implements TelemetryBackend { + records: TelemetryRecord[] = [] + emit(record: TelemetryRecord): void { + this.records.push(record) + } + async shutdown(): Promise {} +} + +async function setup() { + const backend = new CollectingBackend() + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin({ + name: 'fake-telemetry', + inject: ['sessions'], + apply: (inner: Context) => void new TelemetryCoordinator(inner, backend), + }) + return { ctx, backend } +} + +describe('telemetry/redact waterfall', () => { + it('applies the default rules when no listener is registered', async () => { + const { ctx, backend } = await setup() + const session = ctx.sessions.create(SessionId('w')) + session.append('user/message', { content: [{ type: 'text', text: `key ${SECRETS.deepseek}` }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + const body = backend.records[0]!.body as { content: { text: string }[] } + expect(body.content[0]!.text).toBe(`key ${REDACTION_PLACEHOLDER}`) + }) + + it('keeps the canonical log unredacted', async () => { + const { ctx } = await setup() + const session = ctx.sessions.create(SessionId('log')) + session.append('user/message', { content: [{ type: 'text', text: SECRETS.githubPat }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + const logged = session.events[0]!.data as { content: { text: string }[] } + expect(logged.content[0]!.text).toBe(SECRETS.githubPat) + }) + + it('lets a listener stack a stricter rule on top of the defaults', async () => { + const { ctx, backend } = await setup() + ctx.on('telemetry/redact', (_record, next) => { + const defaulted = next() + return { ...defaulted, body: { shapeOnly: true } } + }) + const session = ctx.sessions.create(SessionId('stack')) + session.append('user/message', { content: [{ type: 'text', text: 'anything' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + expect(backend.records[0]!.body).toEqual({ shapeOnly: true }) + }) + + it('a listener that skips next() replaces the default rules', async () => { + const { ctx, backend } = await setup() + ctx.on('telemetry/redact', record => record) + const session = ctx.sessions.create(SessionId('veto')) + session.append('user/message', { content: [{ type: 'text', text: SECRETS.slack }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + const body = backend.records[0]!.body as { content: { text: string }[] } + expect(body.content[0]!.text).toBe(SECRETS.slack) + }) + + it('a throwing rule withholds the record fail-closed without disturbing the log', async () => { + const { ctx, backend } = await setup() + ctx.on('telemetry/redact', () => { + throw new Error('rule exploded') + }) + const session = ctx.sessions.create(SessionId('closed')) + session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + expect(backend.records).toHaveLength(0) + expect(session.events).toHaveLength(1) + }) +}) diff --git a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts new file mode 100644 index 0000000000..9f694749ef --- /dev/null +++ b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts @@ -0,0 +1,313 @@ +/** + * Coordinator semantics against a bare fake backend — the RFC's named unit + * tier for the seam: adoption (fresh, seeded, re-adoption via the handoff + * cursor), the fixed chunk projection, deep-copy isolation, turn-latency and + * dispose-ordering pins, failure containment, and the `agent/error` relay. + */ + +import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import SessionStore, { SessionId, type Session, type SessionEvent } from '@deepseek-ai/dsh-session' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { TelemetryCoordinator, type TelemetryBackend, type TelemetryRecord } from '../src/index.ts' + +declare module '@deepseek-ai/dsh-session' { + interface SessionEventMap { + /** + * Test-only merged event proving unknown types flow through unchanged. + * @mode emit + * @param payload - opaque test payload + */ + 'telemetry-test/opaque': { payload: { nested: string[] } } + /** + * Test-only stand-in for dsh-compact's merge, exercising the widened severity probe. + * @mode emit + * @param error - failure text when the compaction failed + */ + 'compact/end': { turn: number; error?: string } + } +} + +class FakeBackend implements TelemetryBackend { + records: TelemetryRecord[] = [] + calls: string[] = [] + emitError: Error | undefined + shutdownError: Error | undefined + shutdownResolved = false + + emit(record: TelemetryRecord): void { + if (this.emitError) throw this.emitError + this.records.push(record) + this.calls.push(`emit:${String(record.attributes['event.seq'] ?? record.attributes['telemetry.op'])}`) + } + + flush = vi.fn() + + async shutdown(): Promise { + this.calls.push('shutdown') + await new Promise(resolve => setTimeout(resolve, 5)) + if (this.shutdownError) throw this.shutdownError + this.shutdownResolved = true + } + + ledger(): TelemetryRecord[] { + return this.records.filter(r => r.channel === 'ledger') + } +} + +async function setup(backend: FakeBackend = new FakeBackend()) { + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin({ + name: 'fake-telemetry', + inject: ['sessions'], + apply: (inner: Context) => void new TelemetryCoordinator(inner, backend), + }) + return { ctx, backend, fiber } +} + +function liveSession(ctx: Context, id = `s-${Math.random().toString(36).slice(2)}`): Session { + return ctx.sessions.create(SessionId(id), { meta: {} }) +} + +function appendTurn(session: Session): void { + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('user/message', { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) +} + +describe('TelemetryCoordinator capture', () => { + it('hands every appended event over with envelope identity and cloned body', async () => { + const { ctx, backend } = await setup() + const session = liveSession(ctx, 'cap') + appendTurn(session) + + const start = backend.ledger()[0]! + const message = backend.ledger()[1]! + expect(start.attributes).toMatchObject({ 'session.id': 'cap', 'event.type': 'turn/start', 'event.seq': 0 }) + expect(start.time).toBe(session.events[0]!.time) + expect(start.severity).toBe('info') + expect(message.attributes['event.seq']).toBe(1) + // Deep-copy isolation: mutating the handed-off body never reaches the log. + ;(message.body as { content: { text: string }[] }).content[0]!.text = 'tampered' + const logged = session.events[1] as SessionEvent<'user/message'> + expect(logged.data.content[0]).toMatchObject({ text: 'hello' }) + }) + + it('stamps header facts on every record when present', async () => { + const { ctx, backend } = await setup() + const parent = SessionId('parent') + const session = ctx.sessions.create(SessionId('child'), { meta: { cwd: '/tmp/proj', parentSession: parent } }) + appendTurn(session) + for (const record of backend.ledger()) { + expect(record.attributes['session.cwd']).toBe('/tmp/proj') + expect(record.attributes['session.parent_id']).toBe('parent') + } + }) + + it('maps outcome flags to severity, including the widened merge-extensible probe', async () => { + const { ctx, backend } = await setup() + const session = liveSession(ctx) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('tool/result', { turn: 1, step: 1, callId: 'c1' as never, content: [], isError: true }, { surfaceOp: 'append' }) + session.append('tool/result', { turn: 1, step: 1, callId: 'c2' as never, content: [], isError: false }, { surfaceOp: 'append' }) + session.append('prompt/blocked', { content: [], source: { kind: 'user' }, reason: 'vetoed' }) + session.append('compact/end', { turn: 1, error: 'summarizer died' }) + session.append('compact/end', { turn: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'boom' } }) + const severities = backend.ledger().map(r => [r.attributes['event.type'], r.severity]) + expect(severities).toEqual([ + ['turn/start', 'info'], + ['tool/result', 'error'], + ['tool/result', 'info'], + ['prompt/blocked', 'warn'], + ['compact/end', 'error'], + ['compact/end', 'info'], + ['turn/end', 'error'], + ]) + }) + + it('passes unknown merged event types through unchanged', async () => { + const { ctx, backend } = await setup() + const session = liveSession(ctx) + session.append('telemetry-test/opaque', { payload: { nested: ['a', 'b'] } }) + const record = backend.ledger()[0]! + expect(record.attributes['event.type']).toBe('telemetry-test/opaque') + expect(record.severity).toBe('info') + expect(record.body).toEqual({ payload: { nested: ['a', 'b'] } }) + }) + + it('ships only the first chunk of each (turn, step), per session', async () => { + const { ctx, backend } = await setup() + const a = liveSession(ctx, 'a') + const b = liveSession(ctx, 'b') + const chunk = (s: Session, turn: number, step: number, text: string) => + s.append('assistant/chunk', { turn, step, chunk: { type: 'text-delta', index: 0, text } }) + chunk(a, 1, 1, 'a11-first') + chunk(a, 1, 1, 'a11-second') + chunk(a, 1, 2, 'a12-first') + chunk(b, 1, 1, 'b11-first') + chunk(b, 1, 1, 'b11-second') + const shipped = backend.ledger().map(r => [r.attributes['session.id'], (r.body as { chunk: { text: string } }).chunk.text]) + expect(shipped).toEqual([ + ['a', 'a11-first'], + ['a', 'a12-first'], + ['b', 'b11-first'], + ]) + }) +}) + +describe('TelemetryCoordinator adoption', () => { + it('reads seeded events back at adoption (fork/resume seeds never re-emit)', async () => { + const backend = new FakeBackend() + const ctx = new Context() + await ctx.plugin(SessionStore) + const parent = liveSession(ctx, 'seed-parent') + appendTurn(parent) + ctx.sessions.create(SessionId('seeded'), { seed: [...parent.events], meta: {} }) + await ctx.plugin({ + name: 'fake-telemetry', + inject: ['sessions'], + apply: (inner: Context) => void new TelemetryCoordinator(inner, backend), + }) + const seqs = backend.ledger().map(r => [r.attributes['session.id'], r.attributes['event.seq']]) + expect(seqs).toEqual(expect.arrayContaining([ + ['seed-parent', 0], ['seed-parent', 1], + ['seeded', 0], ['seeded', 1], + ])) + }) + + it('adopts exactly once when created fires after the sweep', async () => { + const backend = new FakeBackend() + const ctx = new Context() + await ctx.plugin(SessionStore) + // The enter/announce window: prepare+enter puts the session in the store + // (visible to the constructor sweep) before `session/created` fires, so a + // coordinator loaded inside that window sees the session twice — sweep + // first, created second. The second adoption must be a no-op. + const session = ctx.sessions.prepare(SessionId('overlap')) + appendTurn(session) + ctx.sessions.enter(session) + await ctx.plugin({ + name: 'fake-telemetry', + inject: ['sessions'], + apply: (inner: Context) => void new TelemetryCoordinator(inner, backend), + }) + expect(backend.ledger()).toHaveLength(2) + ctx.sessions.announce(session) + expect(backend.ledger()).toHaveLength(2) + }) + + it('resumes from the handoff cursor across a reload, re-dropping mid-step chunks', async () => { + const backend = new FakeBackend() + const { ctx, fiber } = await setup(backend) + const session = liveSession(ctx, 'hmr') + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'first' } }) + expect(backend.ledger()).toHaveLength(2) + + await fiber.dispose() + // The reload window: appends while no telemetry listener is registered. + session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'mid-step continuation' } }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + + const second = new FakeBackend() + await ctx.plugin({ + name: 'fake-telemetry-2', + inject: ['sessions'], + apply: (inner: Context) => void new TelemetryCoordinator(inner, second), + }) + // Only the window events past the cursor are re-handed, and the mid-step + // continuation is re-dropped because ≤cursor events rebuilt the projection. + expect(second.ledger().map(r => r.attributes['event.type'])).toEqual(['turn/end']) + }) + + it('re-hands the full log when no cursor survived (fresh session object)', async () => { + const backend = new FakeBackend() + const ctx = new Context() + await ctx.plugin(SessionStore) + const session = liveSession(ctx, 'fresh') + appendTurn(session) + await ctx.plugin({ + name: 'fake-telemetry', + inject: ['sessions'], + apply: (inner: Context) => void new TelemetryCoordinator(inner, backend), + }) + expect(backend.ledger().map(r => r.attributes['event.seq'])).toEqual([0, 1]) + }) +}) + +describe('TelemetryCoordinator lifecycle and containment', () => { + it('forwards session/flush as a hint without awaiting backend work', async () => { + const { ctx, backend } = await setup() + const session = liveSession(ctx) + let settled = false + backend.flush.mockImplementation(() => { + // The backend may kick off arbitrary async work; the loop's parallel must not wait for it. + void new Promise(resolve => setTimeout(resolve, 50)).then(() => { settled = true }) + }) + await ctx.parallel('session/flush', session) + expect(backend.flush).toHaveBeenCalledTimes(1) + expect(settled).toBe(false) + }) + + it('ignores flush hints for sessions it never adopted', async () => { + const { ctx, backend } = await setup() + const stranger = ctx.sessions.prepare(SessionId('stranger'), { meta: {} }) + await ctx.parallel('session/flush', stranger) + expect(backend.flush).not.toHaveBeenCalled() + }) + + it('emits each adopted session’s shutdown record before awaiting backend shutdown', async () => { + const { ctx, backend, fiber } = await setup() + liveSession(ctx, 's1') + liveSession(ctx, 's2') + await fiber.dispose() + expect(backend.calls).toEqual(['emit:shutdown', 'emit:shutdown', 'shutdown']) + expect(backend.shutdownResolved).toBe(true) + const ops = backend.records.filter(r => r.channel === 'ops') + expect(ops.map(r => r.attributes['session.id']).sort()).toEqual(['s1', 's2']) + expect(ops.every(r => r.attributes['telemetry.op'] === 'shutdown' && r.severity === 'info')).toBe(true) + expect(ops.every(r => !('event.seq' in r.attributes) && !('event.type' in r.attributes))).toBe(true) + }) + + it('warns instead of throwing when backend shutdown fails', async () => { + const backend = new FakeBackend() + backend.shutdownError = new Error('exporter unreachable') + const { ctx, fiber } = await setup(backend) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + liveSession(ctx) + await expect(fiber.dispose()).resolves.not.toThrow() + expect(warn.mock.calls.some(args => String(args[0]).includes('shutdown failed'))).toBe(true) + }) + + it('contains emit failures: the append succeeds and capture heals', async () => { + const { ctx, backend } = await setup() + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + const session = liveSession(ctx) + backend.emitError = new Error('backend broke') + expect(() => session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })).not.toThrow() + expect(warn).toHaveBeenCalled() + backend.emitError = undefined + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + expect(backend.ledger().map(r => r.attributes['event.type'])).toEqual(['turn/end']) + }) + + it('relays agent/error as an ops record with identity and structured name', async () => { + const { ctx, backend } = await setup() + const session = liveSession(ctx, 'erring') + // Only the members the relay reads; the full Agent surface is irrelevant here. + const agent = { id: 'agent-1', session } as Agent + ctx.emit('agent/error', agent, 3, 2, new TypeError('adapter exploded')) + const record = backend.records.find(r => r.channel === 'ops')! + expect(record.severity).toBe('error') + expect(record.attributes).toMatchObject({ + 'telemetry.op': 'agent-error', + 'session.id': 'erring', + 'agent.id': 'agent-1', + 'error.name': 'TypeError', + turn: 3, + step: 2, + }) + expect(record.body).toEqual({ name: 'TypeError', message: 'adapter exploded' }) + }) +}) diff --git a/packages/telemetry/session-telemetry/tsconfig.json b/packages/telemetry/session-telemetry/tsconfig.json new file mode 100644 index 0000000000..2c18a582e4 --- /dev/null +++ b/packages/telemetry/session-telemetry/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../core/session" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index edd75466c5..0e96b9cf4d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -318,6 +318,9 @@ importers: '@deepseek-ai/dsh-session-query-sqlite': specifier: workspace:* version: link:../packages/session-query/session-query-sqlite + '@deepseek-ai/dsh-session-telemetry-otel': + specifier: workspace:* + version: link:../packages/telemetry/session-telemetry-otel '@deepseek-ai/dsh-session-title-first-message-llm': specifier: workspace:* version: link:../packages/session-title/session-title-first-message-llm @@ -3538,6 +3541,61 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/telemetry/session-telemetry: + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + + packages/telemetry/session-telemetry-otel: + dependencies: + '@opentelemetry/api': + specifier: ^1.9.1 + version: 1.9.1 + '@opentelemetry/api-logs': + specifier: ^0.220.0 + version: 0.220.0 + '@opentelemetry/exporter-logs-otlp-http': + specifier: ^0.220.0 + version: 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': + specifier: ^2.9.0 + version: 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': + specifier: ^0.220.0 + version: 0.220.0(@opentelemetry/api@1.9.1) + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-telemetry': + specifier: workspace:^ + version: link:../session-telemetry + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + packages/timeout/timeout-policy: devDependencies: '@deepseek-ai/dsh-invariants': @@ -5803,10 +5861,78 @@ packages: '@nodable/entities@2.2.0': resolution: {integrity: sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==} + '@opentelemetry/api-logs@0.220.0': + resolution: {integrity: sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==} + engines: {node: '>=8.0.0'} + '@opentelemetry/api@1.9.0': resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/core@2.10.0': + resolution: {integrity: sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@2.9.0': + resolution: {integrity: sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/exporter-logs-otlp-http@0.220.0': + resolution: {integrity: sha512-8186thl+pTw64iz/qEEen5oJZoZ/gO73XruChdaGlYdWOdBIQ42r+vHLf6a7vIDqTD4b8ZOoMlyxptanECaI9A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-exporter-base@0.220.0': + resolution: {integrity: sha512-CXYo8UD5Mn9YbgebO2EL4wejtA+gxLmLiu6HCk2KH2BR7XhFN6/6p1UlCb23DYCjeYkndevLHuejCCN1yx4+OQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-transformer@0.220.0': + resolution: {integrity: sha512-lXGrv7KXZ0gNH9SVNUaa6vv6phVYGvJxfXAlMbzbakiXru75f5MZl8Z7oqiMMQD77riVHJCFlQvbZs/VVN2/4A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/resources@2.10.0': + resolution: {integrity: sha512-q6MMm2zhggzsHVNbabYwut+a6nbuQQe3URUoxaojM/8K1IBfwwPzvxIjNi2/lI1TFe+fMHMW9MWhrtDLEXEnkA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/resources@2.9.0': + resolution: {integrity: sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-logs@0.220.0': + resolution: {integrity: sha512-WywcTkQtv2iNmt+6y5Kcd4rzvx9bLVsBa2Nwcmg01IUaBTkTow3W4d9KE5vNBpEDtb9tp21WcRBY/lANRrApYA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + + '@opentelemetry/sdk-metrics@2.9.0': + resolution: {integrity: sha512-Xx8RGS4H5XEBl01WuCreMIpiah9cCXMbSkeuIePPdD2cUpq/vUzYmj8E/MK1OsbOc93FuAD4jfn2WOacKwLn7Q==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + + '@opentelemetry/sdk-trace@2.9.0': + resolution: {integrity: sha512-sGA19HvtrrSKYsseHphluH6j3p6Xa3fqc7c7y8f/7mYWejc1lyDFcpSdD1kYa50HCLUeEo4zA5bW0pniaPszuw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + '@opentelemetry/semantic-conventions@1.43.0': resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} engines: {node: '>=14'} @@ -10711,8 +10837,82 @@ snapshots: '@nodable/entities@2.2.0': {} + '@opentelemetry/api-logs@0.220.0': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api@1.9.0': {} + '@opentelemetry/api@1.9.1': {} + + '@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/exporter-logs-otlp-http@0.220.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.220.0 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.220.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-exporter-base@0.220.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.220.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-transformer@0.220.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.220.0 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.9.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/resources@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/resources@2.9.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/sdk-logs@0.220.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.220.0 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/sdk-metrics@2.9.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-trace@2.9.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + '@opentelemetry/semantic-conventions@1.43.0': {} '@oxc-parser/binding-android-arm-eabi@0.133.0': diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index a3274a83c3..4912729ff4 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -221,6 +221,7 @@ const TYPE_LINK_EXEMPTIONS: Readonly> = { SessionForkSource: 'service-local fork input is owned by packages/core/session/src/index.ts', SubagentRunEndInfo: 'event-local snapshot is owned by packages/subagent/subagent/src/index.ts', SubagentRunInfo: 'event-local snapshot is owned by packages/subagent/subagent/src/index.ts', + TelemetryRecord: 'seam-local record contract is owned by packages/telemetry/session-telemetry/src/index.ts', WorkflowAgentEndInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts', WorkflowAgentInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts', WorkflowResultInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 1f54f7358f..075f394565 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -77,6 +77,7 @@ const GROUP_ORDER = [ 'session-persistence', 'session-query', 'session-title', + 'telemetry', 'support', 'ui', ] @@ -132,6 +133,15 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['agent-loop', 'tool-bash', 'hooks-claude', 'hooks-codex', 'acp', 'session-query', 'session-query-sqlite'], note: 'Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time.', }, + { + key: 'telemetry', + pkg: 'session-telemetry', + title: 'Session telemetry seam', + mode: 'seam', + implementations: ['session-telemetry-otel'], + consumers: [], + note: 'The seam captures, redacts, and hands session records to one backend; nothing else consumes the service — its output leaves the process.', + }, { key: 'sessionQuery', pkg: 'session-query', diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index d5fa282a24..42792f14ca 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -80,6 +80,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/sdk/telemetry': { kind: 'none', reason: 'The launcher-side reporter sends developer-cycle telemetry and registers no live agent or model surface.' }, 'packages/session-query/session-query': { kind: 'none', reason: 'The trusted query service exposes cloned records only to callers and registers no model surface.' }, 'packages/session-query/session-query-sqlite': { kind: 'none', reason: 'The search backend returns hits only to callers and registers no model surface.' }, + 'packages/telemetry/session-telemetry': { kind: 'none', reason: 'The seam observes the session stream and hands redacted copies outward; it registers no model surface.' }, + 'packages/telemetry/session-telemetry-otel': { kind: 'none', reason: 'The backend forwards seam records into the OTel SDK pipeline and registers no model surface.' }, 'packages/skill/skill': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-skill.' }, 'packages/skill/skill-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-skill.' }, 'packages/spill/spill': { kind: 'indirect', reason: 'The storage seam delegates model rendering to spill consumers.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 56ab3ffef3..e33ef26038 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -74,6 +74,7 @@ "./packages/hooks/*/src/invariant.ts", "./packages/session-persistence/*/src/invariant.ts", "./packages/session-query/*/src/invariant.ts", + "./packages/telemetry/*/src/invariant.ts", "./packages/sdk/*/src/invariant.ts", "./packages/ui/*/src/invariant.ts", "./packages/examples/*/src/invariant.ts", @@ -142,6 +143,7 @@ "./packages/session-persistence/*/src", "./packages/session-query/*/src", "./packages/session-title/*/src", + "./packages/telemetry/*/src", "./packages/sdk/*/src", "./packages/ui/*/src", "./packages/examples/*/src", diff --git a/tsconfig.host.json b/tsconfig.host.json index a13bcf35e3..465b38edf4 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -48,6 +48,8 @@ { "path": "./packages/session-title/session-title-llm" }, { "path": "./packages/session-title/session-title-first-message-llm" }, { "path": "./packages/session-title/session-title-all-messages-llm" }, + { "path": "./packages/telemetry/session-telemetry" }, + { "path": "./packages/telemetry/session-telemetry-otel" }, { "path": "./packages/core/system-prompt" }, { "path": "./packages/core/agent" }, { "path": "./packages/ui/commands" }, From 70febffe1ad87f539cc50e0f5ea1d1cde5b0dc91 Mon Sep 17 00:00:00 2001 From: kingwl Date: Thu, 23 Jul 2026 11:58:47 +0800 Subject: [PATCH 026/319] refactor(telemetry): ship the redact waterfall without built-in rules The seam keeps the telemetry/redact scrubbing interface but ships no rules of its own: the innermost next() passes records through unchanged, and deployments mount their rules as waterfall listeners. As an SDK we cannot know which patterns are secrets in a given deployment; a shipped list invites false confidence while catching only known shapes, and false positives would corrupt exported bodies. Mechanism stays with the seam, policy moves to the deployment; both READMEs and the Agent Note state the raw-export default plainly. The loader-composition e2e now mounts a deployment-style rule fixture and pins the same wire behavior: secret absent, placeholder present, canonical log untouched. --- ...3-session-telemetry-otel-revival.i18n.yaml | 4 +- ...26-07-23-session-telemetry-otel-revival.md | 10 +- ...07-23-session-telemetry-otel-revival.zh.md | 10 +- docs/cordis-catalog/events.md | 25 +-- docs/cordis-catalog/services.md | 2 +- docs/event-producer-consumer.md | 2 +- .../tests/fixtures/telemetry-otel.cordis.yml | 5 + .../tests/fixtures/telemetry-redact-rule.ts | 29 ++++ knip.json | 1 + .../cordis/tool-cordis/src/api-catalog.ts | 4 +- packages/telemetry/README.md | 2 +- .../session-telemetry-otel/README.md | 2 +- .../tests/loader-composition.e2e.ts | 15 +- .../telemetry/session-telemetry/README.md | 4 +- .../session-telemetry/src/coordinator.ts | 18 +- .../telemetry/session-telemetry/src/index.ts | 22 +-- .../telemetry/session-telemetry/src/redact.ts | 77 --------- .../session-telemetry/tests/redact.spec.ts | 159 +++++++----------- 18 files changed, 155 insertions(+), 236 deletions(-) create mode 100644 examples/headless-agent/tests/fixtures/telemetry-redact-rule.ts delete mode 100644 packages/telemetry/session-telemetry/src/redact.ts diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml index 31a5914d0d..e5c566662a 100644 --- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-23-session-telemetry-otel-revival.md: 1150d363e9db98a39e1188b86dc41fa291edba41 -2026-07-23-session-telemetry-otel-revival.zh.md: 76749a59c38ef2b8c5b3f09960ca500c25d8e1a8 +2026-07-23-session-telemetry-otel-revival.md: 28a88218566dcfe34a3b99b682a4c21d22e00ce2 +2026-07-23-session-telemetry-otel-revival.zh.md: 79428c5fbb76bdd8f3c2d0b856edd712b6a650d0 diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md index 1150d363e9..28a8821856 100644 --- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md @@ -10,10 +10,10 @@ Every deployment that wants harness sessions in an observability stack must hand ## Decision -`packages/telemetry/` revives the two reviewed packages under the SDK stance — the harness provides the capability, the deployment configures where records go, and nothing crosses the seam unredacted: +`packages/telemetry/` revives the two reviewed packages under the SDK stance — the harness provides the capability, the deployment configures where records go and owns what leaves in them: - **`@deepseek-ai/dsh-session-telemetry`** — the seam. `TelemetryBackend` (`emit`/`flush?`/`shutdown`), the service-registered `Telemetry` form, and `TelemetryCoordinator` owning capture: adoption with cursor read-back, the per-append firehose (project → `structuredClone` → redact → `emit`, zero I/O), the fixed first-chunk-per-(turn, step) projection, the `agent/error` relay, and dispose-time `shutdown` records. -- **The `telemetry/redact` waterfall** — the delta over the branch version. Every record passes it before reaching any backend; the innermost `next()` applies a conservative built-in rule set (credential shapes: API keys, GitHub/Slack tokens, AWS/Google keys, JWTs, PEM blocks, URL userinfo), deployments stack stricter rules as listeners, and a throwing rule withholds the record fail-closed. The pattern list is a security invariant, deliberately not configurable. Redaction applies to the exported copy only; the canonical log is never rewritten. +- **The `telemetry/redact` waterfall** — the delta over the branch version. Every record passes it before reaching any backend; the seam ships NO rules of its own — the innermost `next()` is a pass-through, deployments mount their rules as listeners (stacking by transforming `next()`'s return value), and a throwing rule withholds the record fail-closed. Redaction applies to the exported copy only; the canonical log is never rewritten. - **`@deepseek-ai/dsh-session-telemetry-otel`** — the reference backend: OTel JS SDK log pipeline (`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP exporter), configured verbatim through `exporter`/`processor` passthroughs. `exporter.url` is required and validated at load; unmounted or unconfigured, nothing leaves the process. The boundary axiom holds: the harness's aspect ends at `emit()`. Batching, retry, queueing, and loss policy are the reporting SDK's, configured through passthroughs — delivery is best-effort (at-most-once across a crash), which the READMEs state plainly. @@ -22,12 +22,12 @@ The boundary axiom holds: the harness's aspect ends at `emit()`. Batching, retry **Implement the runtime-telemetry RFC's outbox (durable spool, per-sink cursors, at-least-once, a `readCommitted` persistence-seam method).** Deferred, not rejected: the SDK stance makes delivery semantics the reporting SDK's territory, and the OTel SDK's own batch pipeline is the honest default. The outbox is a pure additive layer (the `emit()` contract does not move); revive it when a deployment states a crash-loss requirement telemetry must satisfy. -**Export without built-in redaction, delegating to receiver-side collector processors.** Rejected — this is what legal declined. Receiver-side redaction ships the secret first and scrubs it second; the seam must scrub before bytes leave the process, and a waterfall makes the redaction point auditable and stackable. +**No in-process redaction point, delegating to receiver-side collector processors.** Rejected — receiver-side redaction ships the secret first and scrubs it second. The waterfall puts an auditable, stackable scrubbing point before bytes leave the process; where the branch version (what PR #222 shipped) had no redaction point at all, every record now passes one. -**A configurable pattern list for the default rules.** Rejected: deployment-varying tunables belong in config, but a security invariant does not — weakening the floor should require code, not YAML. Stricter rules stack as `telemetry/redact` listeners. +**A built-in conservative rule set as the waterfall's innermost `next()`.** Rejected: as an SDK we cannot know which patterns are secrets in a given deployment, a shipped list invites false confidence ("redaction is on") while catching only known shapes, and false positives would corrupt exported bodies for consumers who never asked. The seam owns the mechanism; the deployment owns the policy — the innermost `next()` is a pass-through, and rules mount as listeners. **Map onto OTel spans (GenAI semantic conventions) instead of logs.** Rejected for this revival: the branch implementation's log mapping is reviewed and shipped-shaped; the span model is lossy for forkable, interruptible sessions and belongs to a future consumer with real span queries to serve. ## Consequences -A deployment adds one `cordis.yml` entry with an OTLP endpoint and gets its session stream in any OTel-compatible stack; removing the entry is the opt-out, with no residual state. Credential-shaped substrings never leave the process even on a rule-free deployment, at the cost of a synchronous per-record scrub on the capture path (string-regex over lossless-JSON bodies — bounded by event size, no I/O). Exported bodies can differ from canonical log bytes wherever the placeholder landed, so receivers must not treat telemetry as a byte-exact replica; the log remains the source of truth. Crash durability is explicitly out of scope until the outbox decision above is revisited. +A deployment adds one `cordis.yml` entry with an OTLP endpoint and gets its session stream in any OTel-compatible stack; removing the entry is the opt-out, with no residual state. A rule-free deployment exports records exactly as captured — including any credentials embedded in file contents or command output — so a deployment crossing a trust boundary must mount `telemetry/redact` listeners, and both READMEs state this plainly. Where rules are mounted, exported bodies can differ from canonical log bytes, so receivers must not treat telemetry as a byte-exact replica; the log remains the source of truth. Crash durability is explicitly out of scope until the outbox decision above is revisited. diff --git a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md index 76749a59c3..79428c5fbb 100644 --- a/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.zh.md @@ -10,10 +10,10 @@ Status: implemented ## Decision -`packages/telemetry/` 以 SDK 立场复活这两个经过评审的包——harness 提供能力,部署方配置上报去向,且任何数据未经脱敏不得跨越 seam: +`packages/telemetry/` 以 SDK 立场复活这两个经过评审的包——harness 提供能力,部署方配置上报去向并对导出内容负责: - **`@deepseek-ai/dsh-session-telemetry`** —— seam 本体。`TelemetryBackend`(`emit`/`flush?`/`shutdown`)、服务注册形态的 `Telemetry`、以及拥有捕获侧的 `TelemetryCoordinator`:带游标回读的收养、逐 append 的 firehose(投影 → `structuredClone` → 脱敏 → `emit`,零 I/O)、固定的每 (turn, step) 首 chunk 投影、`agent/error` 转发、以及 dispose 时的 `shutdown` 记录。 -- **`telemetry/redact` waterfall** —— 相对分支版本的增量。每条记录抵达任何 backend 前必经此处;最内层 `next()` 应用保守的内置规则集(凭据形状:API key、GitHub/Slack token、AWS/Google key、JWT、PEM 块、URL userinfo),部署方以监听器堆叠更严规则,抛异常的规则将该记录 fail-closed 扣下。模式列表是安全不变量,刻意不可配置。脱敏只作用于导出副本;canonical log 永不改写。 +- **`telemetry/redact` waterfall** —— 相对分支版本的增量。每条记录抵达任何 backend 前必经此处;seam 自身不带任何规则——最内层 `next()` 原样透传,部署方以监听器挂载自己的规则(通过变换 `next()` 的返回值堆叠),抛异常的规则将该记录 fail-closed 扣下。脱敏只作用于导出副本;canonical log 永不改写。 - **`@deepseek-ai/dsh-session-telemetry-otel`** —— 参考 backend:OTel JS SDK 日志管线(`LoggerProvider` → `BatchLogRecordProcessor` → OTLP/HTTP exporter),经 `exporter`/`processor` passthrough 原样配置。`exporter.url` 必填且加载时校验;未挂载或未配置时,任何数据都不会离开进程。 边界公理保持不变:harness 的职责止于 `emit()`。批处理、重试、排队与丢失策略属于 reporting SDK,经 passthrough 配置——投递是尽力而为(崩溃时至多一次),README 对此如实陈述。 @@ -22,12 +22,12 @@ Status: implemented **实现 runtime-telemetry RFC 的 outbox(落盘 spool、每 sink 游标、at-least-once、persistence seam 的 `readCommitted` 方法)。** 推迟而非否决:SDK 立场使投递语义归属 reporting SDK,OTel SDK 自身的批处理管线是诚实的默认。outbox 是纯增量层(`emit()` 契约不动);待某个部署提出遥测必须满足的崩溃丢失要求时再复活。 -**不带内置脱敏直接导出,交给接收端 collector processor。** 否决——这正是法务否掉的方案。接收端脱敏是先把秘密发出去再擦除;seam 必须在字节离开进程前擦除,且 waterfall 使脱敏点可审计、可堆叠。 +**不设进程内脱敏点,交给接收端 collector processor。** 否决——接收端脱敏是先把秘密发出去再擦除。waterfall 在字节离开进程前提供一个可审计、可堆叠的擦除点;分支版本(PR #222 交付的形态)完全没有脱敏点,如今每条记录都必经其一。 -**默认规则的模式列表做成可配置。** 否决:随部署变化的调优项应进 config,但安全不变量不应——削弱底线应当需要改代码而非改 YAML。更严格的规则以 `telemetry/redact` 监听器堆叠。 +**在 waterfall 最内层 `next()` 内置一套保守规则集。** 否决:作为 SDK 我们无法预知某个部署里什么模式算秘密,内置列表只覆盖已知形状却会带来"脱敏已开启"的虚假信心,且误报会替从未要求过的消费者破坏导出 body。seam 拥有机制,部署方拥有策略——最内层 `next()` 原样透传,规则以监听器挂载。 **映射到 OTel span(GenAI 语义约定)而非日志。** 本次复活否决:分支实现的日志映射已经过评审、形态可交付;span 模型对可 fork、可中断的会话有损,留给将来真正有 span 查询需求的消费者。 ## Consequences -部署方在 `cordis.yml` 加一个带 OTLP endpoint 的条目即可把会话流接入任何 OTel 兼容体系;删除条目即退出,无残留状态。即使部署方未配置任何规则,凭据形状的子串也绝不离开进程,代价是捕获路径上每条记录一次同步擦除(对 lossless-JSON body 做字符串正则——受事件大小约束,无 I/O)。导出的 body 在占位符落点处可能与 canonical log 字节不同,接收端不得把遥测当作字节精确副本;日志仍是唯一事实源。崩溃持久性在上述 outbox 决定重启前明确不在范围内。 +部署方在 `cordis.yml` 加一个带 OTLP endpoint 的条目即可把会话流接入任何 OTel 兼容体系;删除条目即退出,无残留状态。未挂载规则的部署导出的记录与捕获时完全一致——包括文件内容与命令输出中内嵌的任何凭据——因此跨信任边界的部署必须挂载 `telemetry/redact` 监听器,两个 README 对此如实陈述。挂载规则后,导出的 body 可能与 canonical log 字节不同,接收端不得把遥测当作字节精确副本;日志仍是唯一事实源。崩溃持久性在上述 outbox 决定重启前明确不在范围内。 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 4ed5de022b..5356f07a5b 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -807,20 +807,21 @@ Source: [`packages/core/system-prompt/src/index.ts:35`](../../packages/core/syst ### `telemetry/redact` — waterfall -Redact one outbound record before it reaches the backend. The innermost `next()` applies the seam's conservative default rule set (credential-shape scrubbing); listeners stack stricter rules by transforming its return value, and returning without `next()` replaces the default — the exported record is then only as clean as the replacing rule. Dispatched synchronously on the capture hot path inside the coordinator's containment: a throwing listener withholds that one record (fail-closed) and never reaches the agent loop. Redaction applies to the exported copy only; the canonical session log is never rewritten. +Redact one outbound record before it reaches the backend — the seam's scrubbing extension point. The seam ships NO rules of its own: the innermost `next()` passes the record through unchanged, and with no listener mounted records reach the backend as captured, so exported data is exactly as clean as the rules a deployment mounts. Listeners stack by transforming `next()`'s return value; returning without `next()` replaces everything beneath. Dispatched synchronously on the capture hot path inside the coordinator's containment: a throwing listener withholds that one record (fail-closed) and never reaches the agent loop. Redaction applies to the exported copy only; the canonical session log is never rewritten. ```ts cordis-catalog /** - * Redact one outbound record before it reaches the backend. The innermost - * `next()` applies the seam's conservative default rule set - * (credential-shape scrubbing); listeners stack stricter rules by - * transforming its return value, and returning without `next()` replaces - * the default — the exported record is then only as clean as the - * replacing rule. Dispatched synchronously on the capture hot path inside - * the coordinator's containment: a throwing listener withholds that one - * record (fail-closed) and never reaches the agent loop. Redaction - * applies to the exported copy only; the canonical session log is never - * rewritten. + * Redact one outbound record before it reaches the backend — the seam's + * scrubbing extension point. The seam ships NO rules of its own: the + * innermost `next()` passes the record through unchanged, and with no + * listener mounted records reach the backend as captured, so exported + * data is exactly as clean as the rules a deployment mounts. Listeners + * stack by transforming `next()`'s return value; returning without + * `next()` replaces everything beneath. Dispatched synchronously on the + * capture hot path inside the coordinator's containment: a throwing + * listener withholds that one record (fail-closed) and never reaches the + * agent loop. Redaction applies to the exported copy only; the canonical + * session log is never rewritten. * @param record - the candidate record, already the coordinator's own deep * copy; listeners return a (possibly new) record and must not mutate it. * @mode waterfall @@ -828,7 +829,7 @@ Redact one outbound record before it reaches the backend. The innermost `next()` 'telemetry/redact'(record: TelemetryRecord, next: () => TelemetryRecord): TelemetryRecord ``` -Source: [`packages/telemetry/session-telemetry/src/index.ts:39`](../../packages/telemetry/session-telemetry/src/index.ts) +Source: [`packages/telemetry/session-telemetry/src/index.ts:40`](../../packages/telemetry/session-telemetry/src/index.ts) ## `tools/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 22120b2f8e..3dec10f786 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1554,7 +1554,7 @@ flush?(): void abstract shutdown(): Promise ``` -Source: [`packages/telemetry/session-telemetry/src/index.ts:123`](../../packages/telemetry/session-telemetry/src/index.ts) +Source: [`packages/telemetry/session-telemetry/src/index.ts:124`](../../packages/telemetry/session-telemetry/src/index.ts) ## `ctx.tokenMeter` — `TokenMeterService` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 30380261fe..da367e81f2 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -43,7 +43,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:130`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | -| `telemetry/redact` | `waterfall` | [`packages/telemetry/session-telemetry/src/index.ts:39`](../packages/telemetry/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/telemetry/session-telemetry) (`waterfall`) | - | +| `telemetry/redact` | `waterfall` | [`packages/telemetry/session-telemetry/src/index.ts:40`](../packages/telemetry/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/telemetry/session-telemetry) (`waterfall`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:143`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | | `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:113`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) | | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:125`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search), [`workspace-context`](../packages/context/workspace-context) | diff --git a/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml b/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml index defde98ff5..ff81d0116c 100644 --- a/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml +++ b/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml @@ -1,8 +1,13 @@ # Test-only composition: session-telemetry-otel through the real Loader/app # path, exporting to the mock OTLP collector the driver starts (url via env). +# The redact-rule entry models a deployment mounting its own scrub rule on the +# telemetry/redact waterfall — the seam itself ships no rules. - id: cli-mock-llm name: './cli-mock-llm.ts' +- id: telemetry-redact-rule + name: './telemetry-redact-rule.ts' + - id: bash name: '@deepseek-ai/dsh-bash-local' diff --git a/examples/headless-agent/tests/fixtures/telemetry-redact-rule.ts b/examples/headless-agent/tests/fixtures/telemetry-redact-rule.ts new file mode 100644 index 0000000000..10b7081dd3 --- /dev/null +++ b/examples/headless-agent/tests/fixtures/telemetry-redact-rule.ts @@ -0,0 +1,29 @@ +import type { Context } from 'cordis' + +/** + * Deployment-style redaction rule for the telemetry e2e: scrubs the fixture + * credential from body strings, exactly as a real deployment would mount its + * own rules on the `telemetry/redact` waterfall. + */ + +const SECRET = /sk-e2efixture[0-9]+/g +const PLACEHOLDER = '[E2E-REDACTED]' + +function scrub(value: unknown): unknown { + if (typeof value === 'string') return value.replace(SECRET, PLACEHOLDER) + if (Array.isArray(value)) return value.map(scrub) + if (value !== null && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, scrub(entry)])) + } + return value +} + +export const name = 'telemetry-redact-rule' + +/** Mount the fixture scrub rule onto the redact waterfall. */ +export function apply(ctx: Context): void { + ctx.on('telemetry/redact', (_record, next) => { + const record = next() + return { ...record, body: scrub(record.body) } + }) +} diff --git a/knip.json b/knip.json index 728ded39b7..753b3dda34 100644 --- a/knip.json +++ b/knip.json @@ -32,6 +32,7 @@ "headless-agent/tests/fixtures/time-context-driver.ts", "headless-agent/tests/fixtures/time-context-mock-llm.ts", "headless-agent/tests/fixtures/telemetry-otel-driver.ts", + "headless-agent/tests/fixtures/telemetry-redact-rule.ts", "acp-agent/tests/snapshots/lsp-definition/workspace/subject.ts", "tui-agent/tests/fixtures/tui-scripted-llm.ts", "acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts", diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index aee1ac9ae8..f8377ffd54 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1124,8 +1124,8 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'telemetry/redact', mode: 'waterfall', signature: '\'telemetry/redact\'(record: TelemetryRecord, next: () => TelemetryRecord): TelemetryRecord', - jsDoc: '/**\n * Redact one outbound record before it reaches the backend. The innermost\n * `next()` applies the seam\'s conservative default rule set\n * (credential-shape scrubbing); listeners stack stricter rules by\n * transforming its return value, and returning without `next()` replaces\n * the default — the exported record is then only as clean as the\n * replacing rule. Dispatched synchronously on the capture hot path inside\n * the coordinator\'s containment: a throwing listener withholds that one\n * record (fail-closed) and never reaches the agent loop. Redaction\n * applies to the exported copy only; the canonical session log is never\n * rewritten.\n * @param record - the candidate record, already the coordinator\'s own deep\n * copy; listeners return a (possibly new) record and must not mutate it.\n * @mode waterfall\n */', - summary: 'Redact one outbound record before it reaches the backend.', + jsDoc: '/**\n * Redact one outbound record before it reaches the backend — the seam\'s\n * scrubbing extension point. The seam ships NO rules of its own: the\n * innermost `next()` passes the record through unchanged, and with no\n * listener mounted records reach the backend as captured, so exported\n * data is exactly as clean as the rules a deployment mounts. Listeners\n * stack by transforming `next()`\'s return value; returning without\n * `next()` replaces everything beneath. Dispatched synchronously on the\n * capture hot path inside the coordinator\'s containment: a throwing\n * listener withholds that one record (fail-closed) and never reaches the\n * agent loop. Redaction applies to the exported copy only; the canonical\n * session log is never rewritten.\n * @param record - the candidate record, already the coordinator\'s own deep\n * copy; listeners return a (possibly new) record and must not mutate it.\n * @mode waterfall\n */', + summary: 'Redact one outbound record before it reaches the backend — the seam\'s scrubbing extension point.', }, { name: 'tools/change', diff --git a/packages/telemetry/README.md b/packages/telemetry/README.md index a5869077c2..ecb348d256 100644 --- a/packages/telemetry/README.md +++ b/packages/telemetry/README.md @@ -1,6 +1,6 @@ # telemetry/ -Outbound session reporting: the telemetry seam plus its OpenTelemetry backend. The design — the boundary axiom (the harness's aspect ends at `emit()`; delivery is the reporting SDK's), the mandatory `telemetry/redact` waterfall, the fixed chunk projection, the handoff cursor, and the operational-record channel — is pinned in [the revival Agent Note](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md). +Outbound session reporting: the telemetry seam plus its OpenTelemetry backend. The design — the boundary axiom (the harness's aspect ends at `emit()`; delivery is the reporting SDK's), the `telemetry/redact` waterfall (deployment-mounted rules; the seam ships none), the fixed chunk projection, the handoff cursor, and the operational-record channel — is pinned in [the revival Agent Note](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md). | Package | Role | |---|---| diff --git a/packages/telemetry/session-telemetry-otel/README.md b/packages/telemetry/session-telemetry-otel/README.md index 26edc62645..3fe9f34626 100644 --- a/packages/telemetry/session-telemetry-otel/README.md +++ b/packages/telemetry/session-telemetry-otel/README.md @@ -19,7 +19,7 @@ The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — th ## What leaves the machine -Records carry the seam's REDACTED copy of `event.data` — user and assistant message content, tool arguments and results (command output, file contents), the full system prompt and tool schemas (`request/header`), todo text, compaction summaries, hook `stderrSummary`, and the session `cwd` (a local path) — after the seam's `telemetry/redact` waterfall has scrubbed credential-shaped substrings (see [the seam README](../session-telemetry/README.md#the-redact-waterfall)). Provider credentials never appear regardless: adapter API keys are constructor parameters, not session events, so they are structurally absent from the log and therefore from telemetry. A deployment with stricter requirements stacks `telemetry/redact` listeners or opts out structurally. +Records carry the complete `event.data` as the seam's `telemetry/redact` waterfall returns it — user and assistant message content, tool arguments and results (command output, file contents), the full system prompt and tool schemas (`request/header`), todo text, compaction summaries, hook `stderrSummary`, and the session `cwd` (a local path). The seam ships no redaction rules: with no `telemetry/redact` listener mounted, that is the raw captured copy, so a deployment exporting beyond a trusted boundary mounts its own rules (see [the seam README](../session-telemetry/README.md#the-redact-waterfall)). Provider credentials never appear regardless: adapter API keys are constructor parameters, not session events, so they are structurally absent from the log and therefore from telemetry. ## Field mapping diff --git a/packages/telemetry/session-telemetry-otel/tests/loader-composition.e2e.ts b/packages/telemetry/session-telemetry-otel/tests/loader-composition.e2e.ts index 2c72fd6170..8f16662614 100644 --- a/packages/telemetry/session-telemetry-otel/tests/loader-composition.e2e.ts +++ b/packages/telemetry/session-telemetry-otel/tests/loader-composition.e2e.ts @@ -3,8 +3,8 @@ * a subprocess (per testing policy, through the same app/boot path a * deployment uses), run one mocked-model turn with a real bash round trip, * and assert against what the mock OTLP collector actually received on the - * wire: ledger mirroring, default redaction, ops markers, and the untouched - * canonical log. + * wire: ledger mirroring, the deployment-mounted redact rule applied to the + * exported copy, ops markers, and the untouched canonical log. */ import { readFile, readdir } from 'node:fs/promises' @@ -12,7 +12,6 @@ import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' -import { REDACTION_PLACEHOLDER } from '@deepseek-ai/dsh-session-telemetry' const driver = fileURLToPath(new URL( '../../../../examples/headless-agent/tests/fixtures/telemetry-otel-driver.ts', @@ -25,6 +24,7 @@ const configPath = fileURLToPath(new URL( const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) const FIXTURE_SECRET = 'sk-e2efixture1234567890' +const FIXTURE_PLACEHOLDER = '[E2E-REDACTED]' interface OtlpLogRecord { attributes?: { key: string; value: Record }[] @@ -84,15 +84,16 @@ describe('session-telemetry-otel through a real headless cordis.yml', () => { } expect(records.some(({ scope }) => scope.endsWith('/ops'))).toBe(true) - // Default redaction on the wire: the fixture credential never leaves the - // process, its surrounding prose does, and the placeholder marks the spot. + // The deployment-mounted rule on the wire: the fixture credential never + // leaves the process, its surrounding prose does, and the placeholder + // marks the spot — the seam itself ships no rules. const wire = JSON.stringify(captures) expect(wire).not.toContain(FIXTURE_SECRET) - expect(wire).toContain(REDACTION_PLACEHOLDER) + expect(wire).toContain(FIXTURE_PLACEHOLDER) expect(wire).toContain('prove telemetry with key') // The canonical session log is never rewritten. expect(logContent).toContain(FIXTURE_SECRET) - expect(logContent).not.toContain(REDACTION_PLACEHOLDER) + expect(logContent).not.toContain(FIXTURE_PLACEHOLDER) }, LOADER_SMOKE_TEST_TIMEOUT_MS) }) diff --git a/packages/telemetry/session-telemetry/README.md b/packages/telemetry/session-telemetry/README.md index bccc61fd06..0f3fe0ec28 100644 --- a/packages/telemetry/session-telemetry/README.md +++ b/packages/telemetry/session-telemetry/README.md @@ -12,7 +12,7 @@ The coordinator registers, all through the composing fiber's effects: `session/c ## The redact waterfall -Every record passes the `telemetry/redact` waterfall between projection and `emit()` — nothing reaches a backend unredacted. The innermost `next()` applies the built-in conservative rule set (`applyDefaultRedaction`: credential shapes — API keys, GitHub/Slack tokens, AWS/Google keys, JWTs, PEM blocks, URL userinfo — replaced with `[REDACTED]` in body strings and string attribute values). Listeners stack stricter rules by transforming `next()`'s return value; returning without `next()` replaces the default rule set, and a throwing listener withholds that one record fail-closed inside the coordinator's containment. The built-in pattern list is a security invariant, deliberately not configurable from cordis.yml. Redaction applies to the exported copy only; the canonical session log is never rewritten. +Every record passes the `telemetry/redact` waterfall between projection and `emit()` — the seam's scrubbing extension point. The seam ships NO rules of its own: the innermost `next()` passes the record through unchanged, so with no listener mounted records reach the backend exactly as captured, and exported data is precisely as clean as the rules a deployment mounts. Listeners stack by transforming `next()`'s return value; returning without `next()` replaces everything beneath, and a throwing listener withholds that one record fail-closed inside the coordinator's containment. Redaction applies to the exported copy only; the canonical session log is never rewritten. ## The handoff cursor @@ -37,4 +37,4 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **Best-effort delivery** — the cursor marks handed-off, not delivered; a session torn down inside a reload window cannot be re-adopted; whatever sits in a backend queue at crash time is lost. A durable outbox (spool, per-sink cursors, at-least-once) is deferred until a deployment states a crash-loss requirement — see [the revival Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md). -- **Redaction is shape-based** — the default rules catch known credential shapes, not every secret; a deployment with stricter needs stacks `telemetry/redact` listeners, and exported data is only as clean as the mounted rules. +- **No built-in redaction rules** — with no `telemetry/redact` listener mounted, records leave the process exactly as captured, including any credentials embedded in file contents or command output; a deployment exporting to a shared collector owns its rule set. diff --git a/packages/telemetry/session-telemetry/src/coordinator.ts b/packages/telemetry/session-telemetry/src/coordinator.ts index 08fbe498b1..8e464c6add 100644 --- a/packages/telemetry/session-telemetry/src/coordinator.ts +++ b/packages/telemetry/session-telemetry/src/coordinator.ts @@ -2,10 +2,11 @@ * Capture coordinator: the seam's upstream half. Subscribes to the session * firehose plus the one live-bus relay (`agent/error`), applies the fixed * chunk projection, builds logical records, runs each through the - * `telemetry/redact` waterfall, and hands the redacted copy to the backend — - * synchronously, with every handler self-contained so a failing backend can - * never starve other subscribers (cordis `emit` is stop-on-throw) or touch - * the agent loop. Composed by a backend in its constructor. + * `telemetry/redact` waterfall (deployment-mounted rules; pass-through when + * none), and hands the result to the backend — synchronously, with every + * handler self-contained so a failing backend can never starve other + * subscribers (cordis `emit` is stop-on-throw) or touch the agent loop. + * Composed by a backend in its constructor. * * @module @deepseek-ai/dsh-session-telemetry/coordinator */ @@ -14,7 +15,6 @@ import type { Context } from 'cordis' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' import type { TelemetryBackend, TelemetryRecord, TelemetrySeverity } from './index.ts' -import { applyDefaultRedaction } from './redact.ts' /** * The handoff cursor: per session, the highest `seq` handed to a backend. @@ -145,13 +145,13 @@ export class TelemetryCoordinator { /** * Run the `telemetry/redact` waterfall over one record and hand the result - * to the backend. The innermost `next` applies the seam's conservative - * default rules, so an unconfigured deployment still never exports raw - * credential shapes; callers run inside {@link contain}, so a throwing + * to the backend. The innermost `next` passes the record through unchanged + * — the seam ships no rules; exported data is as clean as the listeners a + * deployment mounts. Callers run inside {@link contain}, so a throwing * rule withholds the record instead of reaching the loop (fail-closed). */ private handOff(record: TelemetryRecord): void { - this.backend.emit(this.ctx.waterfall('telemetry/redact', record, () => applyDefaultRedaction(record))) + this.backend.emit(this.ctx.waterfall('telemetry/redact', record, () => record)) } /** Forward the turn-end boundary to the backend's optional flush hint. */ diff --git a/packages/telemetry/session-telemetry/src/index.ts b/packages/telemetry/session-telemetry/src/index.ts index 93e7e7b0f9..65f5868e04 100644 --- a/packages/telemetry/session-telemetry/src/index.ts +++ b/packages/telemetry/session-telemetry/src/index.ts @@ -22,16 +22,17 @@ declare module 'cordis' { interface Events { /** - * Redact one outbound record before it reaches the backend. The innermost - * `next()` applies the seam's conservative default rule set - * (credential-shape scrubbing); listeners stack stricter rules by - * transforming its return value, and returning without `next()` replaces - * the default — the exported record is then only as clean as the - * replacing rule. Dispatched synchronously on the capture hot path inside - * the coordinator's containment: a throwing listener withholds that one - * record (fail-closed) and never reaches the agent loop. Redaction - * applies to the exported copy only; the canonical session log is never - * rewritten. + * Redact one outbound record before it reaches the backend — the seam's + * scrubbing extension point. The seam ships NO rules of its own: the + * innermost `next()` passes the record through unchanged, and with no + * listener mounted records reach the backend as captured, so exported + * data is exactly as clean as the rules a deployment mounts. Listeners + * stack by transforming `next()`'s return value; returning without + * `next()` replaces everything beneath. Dispatched synchronously on the + * capture hot path inside the coordinator's containment: a throwing + * listener withholds that one record (fail-closed) and never reaches the + * agent loop. Redaction applies to the exported copy only; the canonical + * session log is never rewritten. * @param record - the candidate record, already the coordinator's own deep * copy; listeners return a (possibly new) record and must not mutate it. * @mode waterfall @@ -142,4 +143,3 @@ export abstract class Telemetry extends Service implements TelemetryBackend { } export { TelemetryCoordinator } from './coordinator.ts' -export { applyDefaultRedaction, REDACTION_PLACEHOLDER } from './redact.ts' diff --git a/packages/telemetry/session-telemetry/src/redact.ts b/packages/telemetry/session-telemetry/src/redact.ts deleted file mode 100644 index d25452581a..0000000000 --- a/packages/telemetry/session-telemetry/src/redact.ts +++ /dev/null @@ -1,77 +0,0 @@ -/** - * Conservative default redaction for outbound telemetry records. - * - * Session-event bodies carry file contents and command output that may embed - * credentials; nothing may cross the seam to a backend unredacted. This module - * is the innermost rule set of the `telemetry/redact` waterfall — always - * applied unless an outer listener deliberately replaces the whole chain. It - * scrubs credential-SHAPED substrings from every string in the record body, - * leaving structure (keys, nesting, surrounding prose) intact. The pattern - * list is a security invariant, deliberately not configurable; deployments - * add stricter rules by stacking `telemetry/redact` listeners. - * - * @module @deepseek-ai/dsh-session-telemetry/redact - */ - -import type { TelemetryRecord } from './index.ts' - -/** Replacement text substituted for each detected credential-shaped span. */ -export const REDACTION_PLACEHOLDER = '[REDACTED]' - -/** - * Well-known credential shapes. A match anywhere inside a body string is - * replaced; low-signal values (package names, versions, git SHAs, plain URLs) - * deliberately stay untouched — they are the observability signal. - */ -const SECRET_PATTERNS: readonly RegExp[] = [ - /sk-(?:ant-)?[A-Za-z0-9_-]{10,}/g, // DeepSeek / OpenAI / Anthropic API keys - /gh[pousr]_[A-Za-z0-9]{16,}/g, // GitHub personal/oauth/server/refresh tokens - /github_pat_[A-Za-z0-9_]{20,}/g, // GitHub fine-grained PAT - /xox[baprs]-[A-Za-z0-9-]{10,}/g, // Slack tokens - /AKIA[0-9A-Z]{16}/g, // AWS access key id - /AIza[0-9A-Za-z_-]{35}/g, // Google API key - /eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, // JWT - /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, // PEM blocks - /\b(?[a-z][a-z0-9+.-]*):\/\/[^/\s:@]+:[^/\s:@]+@/g, // URL userinfo credentials -] - -/** Replace every known credential shape inside one string. */ -function scrub(text: string): string { - let out = text - for (const pattern of SECRET_PATTERNS) { - out = out.replace(pattern, REDACTION_PLACEHOLDER) - } - return out -} - -/** - * Deep-scrub every string inside a lossless-JSON value, preserving structure. - * The record body is the coordinator's own `structuredClone` — mutation-free - * rebuilding keeps the exported copy independent of the canonical log either way. - */ -function scrubValue(value: unknown): unknown { - if (typeof value === 'string') return scrub(value) - if (Array.isArray(value)) return value.map(scrubValue) - if (value !== null && typeof value === 'object') { - const out: Record = {} - for (const [key, entry] of Object.entries(value)) out[key] = scrubValue(entry) - return out - } - return value -} - -/** - * Apply the conservative default rule set to one record — the innermost - * `next` of the `telemetry/redact` waterfall. Attribute VALUES are scrubbed - * alongside the body (identity attributes are seam-built and boring, but - * `session.cwd` is caller-supplied); attribute keys are seam-owned constants. - * @param record - the candidate record; not mutated. - * @returns a redacted copy safe to hand to a backend. - */ -export function applyDefaultRedaction(record: TelemetryRecord): TelemetryRecord { - const attributes: Record = {} - for (const [key, value] of Object.entries(record.attributes)) { - attributes[key] = typeof value === 'string' ? scrub(value) : value - } - return { ...record, attributes, body: scrubValue(record.body) } -} diff --git a/packages/telemetry/session-telemetry/tests/redact.spec.ts b/packages/telemetry/session-telemetry/tests/redact.spec.ts index cd23af6da2..c4b9d4d5bb 100644 --- a/packages/telemetry/session-telemetry/tests/redact.spec.ts +++ b/packages/telemetry/session-telemetry/tests/redact.spec.ts @@ -1,91 +1,19 @@ /** - * Default redaction rules and the `telemetry/redact` waterfall contract: - * credential shapes scrubbed from bodies and attribute values, structure - * preserved, canonical log untouched, listener stacking/replacement, and the - * fail-closed containment of a throwing rule. + * The `telemetry/redact` waterfall contract: pass-through when no listener is + * mounted, listener stacking and replacement, ops-record coverage, the + * untouched canonical log, and the fail-closed containment of a throwing rule. */ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import { - applyDefaultRedaction, - REDACTION_PLACEHOLDER, TelemetryCoordinator, type TelemetryBackend, type TelemetryRecord, } from '../src/index.ts' -const SECRETS = { - deepseek: 'sk-abcdef1234567890abcdef', - anthropic: 'sk-ant-abcdef1234567890', - githubPat: 'ghp_ABCDEFGHIJKLMNOPqrstuv12345678', - finePat: 'github_pat_ABCDEFGHIJKLMNOPQRSTuvwx', - slack: 'xoxb-1234567890-abcdefghij', - aws: 'AKIAIOSFODNN7EXAMPLE', - google: 'AIzaSyA-1234567890abcdefghijklmnopqrstu', - jwt: 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxwRJSMeKKF2QT4fwpM', - pem: '-----BEGIN RSA PRIVATE KEY-----\nMIIEow\n-----END RSA PRIVATE KEY-----', - urlCreds: 'https://user:hunter2@internal.example.com/repo.git', -} as const - -function record(body: unknown, attributes: Record = {}): TelemetryRecord { - return { channel: 'ledger', time: 1, severity: 'info', attributes, body } -} - -describe('applyDefaultRedaction', () => { - it('scrubs every known credential shape while preserving surrounding text', () => { - for (const secret of Object.values(SECRETS)) { - const out = applyDefaultRedaction(record(`before ${secret} after`)) - expect(out.body, secret).not.toContain(secret.includes('\n') ? 'MIIEow' : secret) - expect(out.body).toContain('before ') - expect(out.body).toContain(' after') - expect(out.body).toContain(REDACTION_PLACEHOLDER) - } - }) - - it('scrubs URL userinfo credentials but leaves plain URLs alone', () => { - const out = applyDefaultRedaction(record(`${SECRETS.urlCreds} and https://example.com/path`)) - expect(out.body).not.toContain('hunter2') - expect(out.body).toContain('https://example.com/path') - }) - - it('recurses through arrays and objects, preserving structure and non-strings', () => { - const out = applyDefaultRedaction(record({ - list: [`key=${SECRETS.deepseek}`, 7, null, true], - nested: { text: SECRETS.githubPat, count: 3 }, - })) - expect(out.body).toEqual({ - list: [`key=${REDACTION_PLACEHOLDER}`, 7, null, true], - nested: { text: REDACTION_PLACEHOLDER, count: 3 }, - }) - }) - - it('leaves low-signal values untouched', () => { - const clean = { - pkg: '@deepseek-ai/dsh-session-telemetry@0.0.1', - sha: '342a4c3a9d3adf13cf4ad33b9f8d6e79170be5e2', - prose: 'ordinary sentence with kebab-case-identifier', - } - expect(applyDefaultRedaction(record(clean)).body).toEqual(clean) - }) - - it('scrubs string attribute values and keeps numeric ones', () => { - const out = applyDefaultRedaction(record(null, { - 'session.cwd': `/home/${SECRETS.aws}/proj`, - 'event.seq': 4, - })) - expect(out.attributes['session.cwd']).toBe(`/home/${REDACTION_PLACEHOLDER}/proj`) - expect(out.attributes['event.seq']).toBe(4) - }) - - it('never mutates its input', () => { - const input = record({ text: SECRETS.slack }, { 'session.cwd': SECRETS.aws }) - applyDefaultRedaction(input) - expect((input.body as { text: string }).text).toBe(SECRETS.slack) - expect(input.attributes['session.cwd']).toBe(SECRETS.aws) - }) -}) +const FIXTURE_SECRET = 'sk-fixture1234567890' class CollectingBackend implements TelemetryBackend { records: TelemetryRecord[] = [] @@ -99,49 +27,80 @@ async function setup() { const backend = new CollectingBackend() const ctx = new Context() await ctx.plugin(SessionStore) - await ctx.plugin({ + const fiber = await ctx.plugin({ name: 'fake-telemetry', inject: ['sessions'], apply: (inner: Context) => void new TelemetryCoordinator(inner, backend), }) - return { ctx, backend } + return { ctx, backend, fiber } } describe('telemetry/redact waterfall', () => { - it('applies the default rules when no listener is registered', async () => { + it('passes records through unchanged when no listener is mounted', async () => { const { ctx, backend } = await setup() const session = ctx.sessions.create(SessionId('w')) - session.append('user/message', { content: [{ type: 'text', text: `key ${SECRETS.deepseek}` }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + session.append('user/message', { content: [{ type: 'text', text: `key ${FIXTURE_SECRET}` }], source: { kind: 'user' } }, { surfaceOp: 'append' }) const body = backend.records[0]!.body as { content: { text: string }[] } - expect(body.content[0]!.text).toBe(`key ${REDACTION_PLACEHOLDER}`) + expect(body.content[0]!.text).toBe(`key ${FIXTURE_SECRET}`) }) - it('keeps the canonical log unredacted', async () => { - const { ctx } = await setup() - const session = ctx.sessions.create(SessionId('log')) - session.append('user/message', { content: [{ type: 'text', text: SECRETS.githubPat }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - const logged = session.events[0]!.data as { content: { text: string }[] } - expect(logged.content[0]!.text).toBe(SECRETS.githubPat) - }) - - it('lets a listener stack a stricter rule on top of the defaults', async () => { - const { ctx, backend } = await setup() + it('applies a mounted rule to every outbound record, ops records included', async () => { + const { ctx, backend, fiber } = await setup() ctx.on('telemetry/redact', (_record, next) => { - const defaulted = next() - return { ...defaulted, body: { shapeOnly: true } } + const record = next() + return { ...record, body: { scrubbed: true } } + }) + const session = ctx.sessions.create(SessionId('rule')) + session.append('user/message', { content: [{ type: 'text', text: FIXTURE_SECRET }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + expect(backend.records[0]!.body).toEqual({ scrubbed: true }) + // The dispose-time shutdown ops record passes through the same waterfall. + await fiber.dispose() + const ops = backend.records.filter(record => record.channel === 'ops') + expect(ops).toHaveLength(1) + expect(ops[0]!.body).toEqual({ scrubbed: true }) + }) + + it('keeps the canonical log untouched by a mounted rule', async () => { + const { ctx } = await setup() + ctx.on('telemetry/redact', (_record, next) => ({ ...next(), body: null })) + const session = ctx.sessions.create(SessionId('log')) + session.append('user/message', { content: [{ type: 'text', text: FIXTURE_SECRET }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + const logged = session.events[0]!.data as { content: { text: string }[] } + expect(logged.content[0]!.text).toBe(FIXTURE_SECRET) + }) + + it('stacks listeners outermost-first around next()', async () => { + const { ctx, backend } = await setup() + const order: string[] = [] + ctx.on('telemetry/redact', (_record, next) => { + order.push('outer-before') + const record = next() + order.push('outer-after') + return { ...record, attributes: { ...record.attributes, outer: 1 } } + }) + ctx.on('telemetry/redact', (_record, next) => { + order.push('inner') + const record = next() + return { ...record, attributes: { ...record.attributes, inner: 1 } } }) const session = ctx.sessions.create(SessionId('stack')) - session.append('user/message', { content: [{ type: 'text', text: 'anything' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - expect(backend.records[0]!.body).toEqual({ shapeOnly: true }) + session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + expect(order).toEqual(['outer-before', 'inner', 'outer-after']) + expect(backend.records[0]!.attributes).toMatchObject({ outer: 1, inner: 1 }) }) - it('a listener that skips next() replaces the default rules', async () => { + it('a listener that skips next() replaces everything beneath it', async () => { const { ctx, backend } = await setup() - ctx.on('telemetry/redact', record => record) + const inner = { called: false } + ctx.on('telemetry/redact', () => ({ channel: 'ops', time: 0, severity: 'info', attributes: {}, body: 'replaced' } satisfies TelemetryRecord)) + ctx.on('telemetry/redact', (_record, next) => { + inner.called = true + return next() + }) const session = ctx.sessions.create(SessionId('veto')) - session.append('user/message', { content: [{ type: 'text', text: SECRETS.slack }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - const body = backend.records[0]!.body as { content: { text: string }[] } - expect(body.content[0]!.text).toBe(SECRETS.slack) + session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + expect(backend.records[0]!.body).toBe('replaced') + expect(inner.called).toBe(false) }) it('a throwing rule withholds the record fail-closed without disturbing the log', async () => { From b5523b0b483aedc6b0287917c10b3f8254bdf5a4 Mon Sep 17 00:00:00 2001 From: kingwl Date: Thu, 23 Jul 2026 17:49:17 +0800 Subject: [PATCH 027/319] refactor(telemetry): drop dead live-collector smoke and the compact/end severity probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tests/otel.e2e.ts self-skipped on $DSH_OTLP_E2E_ENDPOINT, which nothing in the repo sets — it never ran; the mock-collector wire spec and the keyless Loader-composition e2e already cover the pipeline both ways. The severityOf compact/end probe parsed another package's merged event shape by string comparison — an untyped cross-package contract that breaks silently — and its only consumer was the test's own stand-in declaration. Unknown event types now uniformly fall through as info; outcome semantics stay with the owning package. --- docs/cordis-catalog/services.md | 2 +- .../session-telemetry-otel/README.md | 2 +- .../session-telemetry-otel/tests/otel.e2e.ts | 25 ------------------- .../telemetry/session-telemetry/README.md | 2 +- .../session-telemetry/src/coordinator.ts | 12 +++------ .../telemetry/session-telemetry/src/index.ts | 7 +++--- .../session-telemetry/tests/telemetry.spec.ts | 14 +++-------- 7 files changed, 14 insertions(+), 50 deletions(-) delete mode 100644 packages/telemetry/session-telemetry-otel/tests/otel.e2e.ts diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 3dec10f786..b9943aaf06 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1554,7 +1554,7 @@ flush?(): void abstract shutdown(): Promise ``` -Source: [`packages/telemetry/session-telemetry/src/index.ts:124`](../../packages/telemetry/session-telemetry/src/index.ts) +Source: [`packages/telemetry/session-telemetry/src/index.ts:125`](../../packages/telemetry/session-telemetry/src/index.ts) ## `ctx.tokenMeter` — `TokenMeterService` diff --git a/packages/telemetry/session-telemetry-otel/README.md b/packages/telemetry/session-telemetry-otel/README.md index 3fe9f34626..da0e96d61f 100644 --- a/packages/telemetry/session-telemetry-otel/README.md +++ b/packages/telemetry/session-telemetry-otel/README.md @@ -36,4 +36,4 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **Upstream experimental tree** — `@opentelemetry/sdk-logs` is still published from the upstream experimental tree; SDK API churn lands here and only here — the seam contract does not move. -- **Live-collector smoke is opt-in** — the e2e smoke (`tests/otel.e2e.ts`) self-skips without `$DSH_OTLP_E2E_ENDPOINT`; the keyless Loader-composition e2e (`tests/loader-composition.e2e.ts`) covers the wire shape against a mock collector on every run. +- **No live-collector coverage** — every test exports to a local mock collector; the keyless Loader-composition e2e (`tests/loader-composition.e2e.ts`) covers the wire shape on every run, and behavior against a real OTLP deployment (auth, TLS, throttling) is the SDK exporter's documented territory. diff --git a/packages/telemetry/session-telemetry-otel/tests/otel.e2e.ts b/packages/telemetry/session-telemetry-otel/tests/otel.e2e.ts deleted file mode 100644 index 91c92b3486..0000000000 --- a/packages/telemetry/session-telemetry-otel/tests/otel.e2e.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Keyless-self-skipping smoke: ship one real session's records to a live - * OTLP collector named by $DSH_OTLP_E2E_ENDPOINT and require the SDK's - * shutdown (flush-and-quiesce) to resolve. Skipped without the endpoint so - * secretless CI stays green — a CI accommodation, not a cost signal. - */ - -import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import TelemetryOtel from '../src/index.ts' - -describe.skipIf(!process.env.DSH_OTLP_E2E_ENDPOINT)('telemetry-otel e2e (live collector)', () => { - it('exports a session and quiesces cleanly', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const fiber = await ctx.plugin(TelemetryOtel, { - exporter: { url: process.env.DSH_OTLP_E2E_ENDPOINT! }, - }) - const session = ctx.sessions.create(SessionId(`e2e-${Date.now()}`), { meta: {} }) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - await expect(fiber.dispose()).resolves.not.toThrow() - }) -}) diff --git a/packages/telemetry/session-telemetry/README.md b/packages/telemetry/session-telemetry/README.md index 0f3fe0ec28..26a28baa2d 100644 --- a/packages/telemetry/session-telemetry/README.md +++ b/packages/telemetry/session-telemetry/README.md @@ -24,7 +24,7 @@ Only the first `assistant/chunk` of each `(turn, step)` ships; the rest are drop ## The logical record -`TelemetryRecord`: `channel` (`ledger` | `ops`), `time` (epoch ms), `severity` (pre-mapped: ERROR for `tool/result.isError`, `turn/end` error reasons, `compact/end` errors; WARN for `prompt/blocked`; INFO otherwise), identity-only `attributes` (`session.id`, `event.type`, `event.seq`, plus `session.cwd`/`session.parent_id` when the header has them), and the complete deep-copied `event.data` as `body` — post-redaction. Operational records carry `telemetry.op` (`agent-error` | `shutdown`) and `session.id`, and deliberately NO `event.seq`/`event.type` — signals to alert on, not entries to sum. Delivery downstream of the handoff is the backend SDK's; duplicates remain possible (cursor-less re-adoption, SDK retries), so receivers dedupe on `(session.id, event.seq)`. +`TelemetryRecord`: `channel` (`ledger` | `ops`), `time` (epoch ms), `severity` (pre-mapped: ERROR for `tool/result.isError` and `turn/end` error reasons; WARN for `prompt/blocked`; INFO otherwise, including plugin-merged event types whose outcome semantics stay with their owners), identity-only `attributes` (`session.id`, `event.type`, `event.seq`, plus `session.cwd`/`session.parent_id` when the header has them), and the complete deep-copied `event.data` as `body` — post-redaction. Operational records carry `telemetry.op` (`agent-error` | `shutdown`) and `session.id`, and deliberately NO `event.seq`/`event.type` — signals to alert on, not entries to sum. Delivery downstream of the handoff is the backend SDK's; duplicates remain possible (cursor-less re-adoption, SDK retries), so receivers dedupe on `(session.id, event.seq)`. ## Model Experience diff --git a/packages/telemetry/session-telemetry/src/coordinator.ts b/packages/telemetry/session-telemetry/src/coordinator.ts index 8e464c6add..ba1610247d 100644 --- a/packages/telemetry/session-telemetry/src/coordinator.ts +++ b/packages/telemetry/session-telemetry/src/coordinator.ts @@ -218,15 +218,11 @@ function severityOf(event: SessionEvent): TelemetrySeverity { return event.data.reason.kind === 'error' ? 'error' : 'info' case 'prompt/blocked': return 'warn' - default: { - // Merge-extensible fall-through (no assertNever): types this seam does - // not depend on still get their RFC-pinned severity via a widened - // probe — `compact/end` is declared by dsh-compact, which the seam - // deliberately does not import. - const type: string = event.type - if (type === 'compact/end' && (event.data as { error?: unknown }).error !== undefined) return 'error' + default: + // Merge-extensible fall-through (no assertNever): event types this seam + // does not depend on — including plugin-merged ones it never heard of — + // pass through as info; their owners' outcome semantics stay theirs. return 'info' - } } } diff --git a/packages/telemetry/session-telemetry/src/index.ts b/packages/telemetry/session-telemetry/src/index.ts index 65f5868e04..09800aa1b5 100644 --- a/packages/telemetry/session-telemetry/src/index.ts +++ b/packages/telemetry/session-telemetry/src/index.ts @@ -44,9 +44,10 @@ declare module 'cordis' { /** * Severity of a telemetry record, pre-mapped at capture so a receiver can * alert with zero configuration: `error` for events whose own outcome flag - * says so (`tool/result.isError`, `turn/end` error reasons, `compact/end` - * errors) and for `agent-error` operational records, `warn` for - * `prompt/blocked`, `info` for everything else. + * says so (`tool/result.isError`, `turn/end` error reasons) and for + * `agent-error` operational records, `warn` for `prompt/blocked`, `info` + * for everything else — including event types merged by other packages, + * whose outcome semantics stay with their owners. */ export type TelemetrySeverity = 'info' | 'warn' | 'error' diff --git a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts index 9f694749ef..743e90073d 100644 --- a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts +++ b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts @@ -19,12 +19,6 @@ declare module '@deepseek-ai/dsh-session' { * @param payload - opaque test payload */ 'telemetry-test/opaque': { payload: { nested: string[] } } - /** - * Test-only stand-in for dsh-compact's merge, exercising the widened severity probe. - * @mode emit - * @param error - failure text when the compaction failed - */ - 'compact/end': { turn: number; error?: string } } } @@ -104,15 +98,14 @@ describe('TelemetryCoordinator capture', () => { } }) - it('maps outcome flags to severity, including the widened merge-extensible probe', async () => { + it('maps outcome flags to severity, unknown types falling through as info', async () => { const { ctx, backend } = await setup() const session = liveSession(ctx) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) session.append('tool/result', { turn: 1, step: 1, callId: 'c1' as never, content: [], isError: true }, { surfaceOp: 'append' }) session.append('tool/result', { turn: 1, step: 1, callId: 'c2' as never, content: [], isError: false }, { surfaceOp: 'append' }) session.append('prompt/blocked', { content: [], source: { kind: 'user' }, reason: 'vetoed' }) - session.append('compact/end', { turn: 1, error: 'summarizer died' }) - session.append('compact/end', { turn: 1 }) + session.append('telemetry-test/opaque', { payload: { nested: [] } }) session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'boom' } }) const severities = backend.ledger().map(r => [r.attributes['event.type'], r.severity]) expect(severities).toEqual([ @@ -120,8 +113,7 @@ describe('TelemetryCoordinator capture', () => { ['tool/result', 'error'], ['tool/result', 'info'], ['prompt/blocked', 'warn'], - ['compact/end', 'error'], - ['compact/end', 'info'], + ['telemetry-test/opaque', 'info'], ['turn/end', 'error'], ]) }) From e6a8ff2621fa6de8685dc6e202440404b5dfb997 Mon Sep 17 00:00:00 2001 From: kingwl Date: Sat, 25 Jul 2026 03:20:29 +0800 Subject: [PATCH 028/319] =?UTF-8?q?fix(telemetry):=20review=20fixes=20?= =?UTF-8?q?=E2=80=94=20flush/shutdown=20ordering,=20session=20retirement,?= =?UTF-8?q?=20whole-exporter=20passthrough?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review findings, each pinned by a red test first: - The OTel backend retains the latest turn-boundary flush promise and shutdown() awaits it before provider.shutdown(): the SDK's concurrent-flush guard makes the shutdown-internal flush return early while one is in flight, silently dropping everything enqueued after the flush snapshot (including the coordinator's dispose-time shutdown markers). - The coordinator retires sessions from the adopted set on session/disposed (mirroring the persistence coordinator): a long-lived backend no longer retains closed sessions and their event logs, and final unload no longer stamps shutdown markers for dead sessions. - The exporter config passes through whole to OTLPLogExporter and its type/JSDoc now advertise the full OTLPExporterNodeConfigBase shape: rebuilding url/headers only silently dropped documented SDK options (timeoutMillis, compression, keepAlive, ...). --- docs/config-catalog.md | 15 ++-- docs/cordis-catalog/services.md | 2 +- docs/event-producer-consumer.md | 2 +- .../session-telemetry-otel/README.md | 2 +- .../session-telemetry-otel/package.json | 1 + .../session-telemetry-otel/src/index.ts | 61 +++++++++------ .../session-telemetry-otel/tests/otel.spec.ts | 75 +++++++++++++++++-- .../telemetry/session-telemetry/README.md | 2 +- .../session-telemetry/src/coordinator.ts | 16 +++- .../telemetry/session-telemetry/src/index.ts | 10 ++- .../session-telemetry/tests/telemetry.spec.ts | 17 +++++ pnpm-lock.yaml | 3 + 12 files changed, 161 insertions(+), 45 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 2f55f60bec..e017d1255a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1059,12 +1059,15 @@ Requires: `sessions` * must fail at plugin load, not at first export. */ export interface Config { - /** Passed verbatim to the SDK's OTLP/HTTP log exporter. */ - exporter?: { + /** + * Passed verbatim to the SDK's OTLP/HTTP log exporter — the complete + * `OTLPExporterNodeConfigBase` shape (`headers`, `timeoutMillis`, + * `compression`, `keepAlive`, …), owned and documented by the SDK. `url` + * is the one field this package requires and validates itself. + */ + exporter?: OTLPExporterNodeConfigBase & { /** Full logs endpoint (e.g. `https://collector.example.com/v1/logs`). Required; validated at plugin load. */ url?: string - /** Extra request headers (auth etc.); owned and sent by the SDK exporter. */ - headers?: Record } /** * Passed verbatim to `BatchLogRecordProcessor` (minus the exporter slot, @@ -1074,9 +1077,9 @@ export interface Config { } ``` -Depends on: `BatchLogRecordProcessorOptions` (`@opentelemetry/sdk-logs`) +Depends on: `BatchLogRecordProcessorOptions` (`@opentelemetry/sdk-logs`) · `OTLPExporterNodeConfigBase` (`@opentelemetry/otlp-exporter-base`) -Source: [`packages/telemetry/session-telemetry-otel/src/index.ts:39`](../packages/telemetry/session-telemetry-otel/src/index.ts) +Source: [`packages/telemetry/session-telemetry-otel/src/index.ts:40`](../packages/telemetry/session-telemetry-otel/src/index.ts) ## `@deepseek-ai/dsh-session-title` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index b9943aaf06..bdd05ab65f 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1554,7 +1554,7 @@ flush?(): void abstract shutdown(): Promise ``` -Source: [`packages/telemetry/session-telemetry/src/index.ts:125`](../../packages/telemetry/session-telemetry/src/index.ts) +Source: [`packages/telemetry/session-telemetry/src/index.ts:129`](../../packages/telemetry/session-telemetry/src/index.ts) ## `ctx.tokenMeter` — `TokenMeterService` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index da367e81f2..dcf536df7f 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -34,7 +34,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:167`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:52`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`user-approval`](../packages/ui/user-approval) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:89`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `runtime`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:89`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `runtime`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:111`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:139`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | diff --git a/packages/telemetry/session-telemetry-otel/README.md b/packages/telemetry/session-telemetry-otel/README.md index da0e96d61f..bd36c7acb3 100644 --- a/packages/telemetry/session-telemetry-otel/README.md +++ b/packages/telemetry/session-telemetry-otel/README.md @@ -15,7 +15,7 @@ The OpenTelemetry backend for [the telemetry seam](../session-telemetry/) — th processor: {} # optional; passed verbatim to BatchLogRecordProcessor ``` -`exporter.url` is the one field this package validates itself — required, no default, must parse as `http(s)` — so a missing endpoint fails at plugin load. Everything else is the SDK's option shape, owned and documented by the SDK; batching, retry, queue bounds, and loss policy under sustained failure are its documented behavior, tuned through the `processor` passthrough. Removing this block from `cordis.yml` is the opt-out: no residual state, no `enabled` flag. +`exporter.url` is the one field this package validates itself — required, no default, must parse as `http(s)` — so a missing endpoint fails at plugin load. Everything else is the SDK's option shape, owned and documented by the SDK, and both blocks pass through whole: every `OTLPExporterNodeConfigBase` field (`headers`, `timeoutMillis`, `compression`, `keepAlive`, …) reaches the exporter, and batching, retry, queue bounds, and loss policy under sustained failure are the SDK's documented behavior, tuned through the `processor` passthrough. Removing this block from `cordis.yml` is the opt-out: no residual state, no `enabled` flag. ## What leaves the machine diff --git a/packages/telemetry/session-telemetry-otel/package.json b/packages/telemetry/session-telemetry-otel/package.json index 706112d1fe..7be8c04ce4 100644 --- a/packages/telemetry/session-telemetry-otel/package.json +++ b/packages/telemetry/session-telemetry-otel/package.json @@ -30,6 +30,7 @@ "@opentelemetry/api": "^1.9.1", "@opentelemetry/api-logs": "^0.220.0", "@opentelemetry/exporter-logs-otlp-http": "^0.220.0", + "@opentelemetry/otlp-exporter-base": "^0.220.0", "@opentelemetry/resources": "^2.9.0", "@opentelemetry/sdk-logs": "^0.220.0", "schemastery": "^3.18.0" diff --git a/packages/telemetry/session-telemetry-otel/src/index.ts b/packages/telemetry/session-telemetry-otel/src/index.ts index 4738cdd932..8dde46828e 100644 --- a/packages/telemetry/session-telemetry-otel/src/index.ts +++ b/packages/telemetry/session-telemetry-otel/src/index.ts @@ -23,6 +23,7 @@ import { type BatchLogRecordProcessorOptions, } from '@opentelemetry/sdk-logs' import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http' +import type { OTLPExporterNodeConfigBase } from '@opentelemetry/otlp-exporter-base' import { SeverityNumber, type AnyValue, type Logger } from '@opentelemetry/api-logs' import { resourceFromAttributes } from '@opentelemetry/resources' @@ -37,12 +38,15 @@ const { version } = createRequire(import.meta.url)('../package.json') as { versi * must fail at plugin load, not at first export. */ export interface Config { - /** Passed verbatim to the SDK's OTLP/HTTP log exporter. */ - exporter?: { + /** + * Passed verbatim to the SDK's OTLP/HTTP log exporter — the complete + * `OTLPExporterNodeConfigBase` shape (`headers`, `timeoutMillis`, + * `compression`, `keepAlive`, …), owned and documented by the SDK. `url` + * is the one field this package requires and validates itself. + */ + exporter?: OTLPExporterNodeConfigBase & { /** Full logs endpoint (e.g. `https://collector.example.com/v1/logs`). Required; validated at plugin load. */ url?: string - /** Extra request headers (auth etc.); owned and sent by the SDK exporter. */ - headers?: Record } /** * Passed verbatim to `BatchLogRecordProcessor` (minus the exporter slot, @@ -54,15 +58,13 @@ export interface Config { /** * Schemastery validator for {@link Config}; cordis runs it before the plugin * starts. Shape-level only — the load-bearing `exporter.url` check lives in - * the constructor so its error message names the field. + * the constructor so its error message names the field. Both slots are opaque + * passthroughs: the SDK owns their shapes and validates its own options; + * re-declaring them field-by-field here would violate the boundary axiom + * (and silently drop every field not re-declared). */ export const Config: z = z.object({ - exporter: z.object({ - url: z.string(), - headers: z.dict(z.string()), - }), - // Opaque passthrough: the SDK owns this shape and validates its own - // options; re-declaring them here would violate the boundary axiom. + exporter: z.any(), processor: z.any(), }) @@ -112,14 +114,13 @@ export class TelemetryOtel extends Telemetry { processors: [ new BatchLogRecordProcessor({ ...config.processor, - exporter: new OTLPLogExporter({ - url, - // App identity travels in the Resource (service.name/version); - // the transport-level user-agent is the SDK's own, per the axiom. - // Schemastery fills `headers` with {} before cordis constructs the - // plugin, so the optional type exists for hand-authors only. - headers: config.exporter?.headers as Record, - }), + // The complete validated exporter object, verbatim: every SDK + // option (`timeoutMillis`, `compression`, `keepAlive`, …) reaches + // the exporter — rebuilding selected fields here would silently + // ignore the rest. App identity travels in the Resource + // (service.name/version); the transport-level user-agent is the + // SDK's own, per the axiom. + exporter: new OTLPLogExporter(config.exporter), }), ], }) @@ -146,22 +147,34 @@ export class TelemetryOtel extends Telemetry { }) } + /** The latest turn-boundary flush, retained so {@link shutdown} can order behind it. */ + private inflightFlush: Promise = Promise.resolve() + /** Forward the turn-boundary hint to the SDK's flush, fire-and-forget. */ override flush(): void { // Best-effort hint: the SDK resolves forceFlush even when exports fail // (failures go to its own diagnostics), and the coordinator stops calling - // this once the fiber is disposed — a rejection would be SDK drift. + // this once the fiber is disposed — a rejection would be SDK drift. The + // settled promise is retained (not awaited): the SDK's concurrent-flush + // guard makes a flush that overlaps another return WITHOUT draining, so + // shutdown must wait this one out before trusting its own flush. /* v8 ignore next -- unreachable guard: forceFlush does not reject while the provider is alive */ - void this.provider.forceFlush().catch(() => {}) + this.inflightFlush = this.provider.forceFlush().catch(() => {}) } /** * Delegate disposal to the SDK's shutdown contract: flush the queue and - * quiesce. Awaited (and error-contained) by the coordinator's disposer. + * quiesce. Orders behind the last turn-boundary flush first — shutdown's + * internal flush is a no-op while one is in flight (the SDK's + * concurrent-flush guard), which would silently drop everything enqueued + * after that flush snapshot, including the coordinator's dispose-time + * `shutdown` markers. Awaited (and error-contained) by the coordinator's + * disposer. * @returns resolves when the SDK pipeline has quiesced. */ - shutdown(): Promise { - return this.provider.shutdown() + async shutdown(): Promise { + await this.inflightFlush + await this.provider.shutdown() } } diff --git a/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts index 88a09a0f3b..b44b42ea04 100644 --- a/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts +++ b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts @@ -8,6 +8,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { createServer, type Server } from 'node:http' import { once } from 'node:events' +import { gunzipSync } from 'node:zlib' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' @@ -43,17 +44,26 @@ afterEach(async () => { } }) -async function mockCollector(): Promise<{ url: string; captures: Capture[] }> { +async function mockCollector( + beforeRespond?: (requestIndex: number) => Promise | void, +): Promise<{ url: string; captures: Capture[] }> { const captures: Capture[] = [] + let requestIndex = 0 const server = createServer((request, response) => { const chunks: Buffer[] = [] request.on('data', chunk => chunks.push(chunk as Buffer)) request.on('end', () => { - captures.push({ - headers: request.headers, - body: JSON.parse(Buffer.concat(chunks).toString()) as OtlpLogsRequest, - }) - response.writeHead(200, { 'content-type': 'application/json' }).end('{}') + const index = requestIndex++ + void (async () => { + await beforeRespond?.(index) + const raw = Buffer.concat(chunks) + const body = request.headers['content-encoding'] === 'gzip' ? gunzipSync(raw) : raw + captures.push({ + headers: request.headers, + body: JSON.parse(body.toString()) as OtlpLogsRequest, + }) + response.writeHead(200, { 'content-type': 'application/json' }).end('{}') + })() }) }) servers.push(server) @@ -113,6 +123,59 @@ describe('TelemetryOtel wire', () => { expect(ops[0]!.record.attributes).toContainEqual({ key: 'telemetry.op', value: { stringValue: 'shutdown' } }) }) + it('delivers records enqueued while a turn-boundary flush is in flight (flush/shutdown race)', async () => { + // Hold the collector's response to the flush-triggered export open until + // after disposal has begun: the SDK's concurrent-flush guard makes the + // shutdown-internal flush return early while another flush is running, so + // without ordering in the backend the coordinator's dispose-time shutdown + // marker (enqueued after the flush snapshot) would be dropped silently. + const gate = Promise.withResolvers() + const arrived = Promise.withResolvers() + const { url, captures } = await mockCollector(async (index) => { + if (index === 0) { + arrived.resolve(true) + await gate.promise + } + }) + const { ctx, fiber } = await boot(url) + const session = ctx.sessions.create(SessionId('race'), { meta: {} }) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + ctx.telemetry.flush!() + await arrived.promise + + const disposal = fiber.dispose() + // Let disposal reach the backend's shutdown while the export is held open. + await new Promise(resolve => setTimeout(resolve, 50)) + gate.resolve(true) + await disposal + + const records = allRecords(captures) + const ops = records.filter(r => r.scope === '@deepseek-ai/dsh-session-telemetry-otel/ops') + expect(ops).toHaveLength(1) + expect(ops[0]!.record.attributes).toContainEqual({ key: 'telemetry.op', value: { stringValue: 'shutdown' } }) + }) + + it('passes exporter options beyond url and headers through to the SDK exporter', async () => { + const { url, captures } = await mockCollector() + const ctx = new Context() + await ctx.plugin(SessionStore) + // `compression` is a documented SDK exporter option; the advertised + // verbatim passthrough must hand it (and every other field) to the + // exporter rather than silently rebuilding url/headers only. + const fiber = await ctx.plugin(TelemetryOtel, { + exporter: { url, compression: 'gzip' }, + } as Config) + const session = ctx.sessions.create(SessionId('gzip'), { meta: {} }) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + await fiber.dispose() + + expect(captures.length).toBeGreaterThan(0) + expect(captures[0]!.headers['content-encoding']).toBe('gzip') + const types = allRecords(captures).flatMap(({ record }) => + record.attributes?.flatMap(a => a.key === 'event.type' ? [a.value.stringValue] : []) ?? []) + expect(types).toContain('turn/start') + }) + it('maps the warn severity and forwards the flush hint to the SDK', async () => { const { url, captures } = await mockCollector() const { ctx, fiber } = await boot(url) diff --git a/packages/telemetry/session-telemetry/README.md b/packages/telemetry/session-telemetry/README.md index 26a28baa2d..80667b308f 100644 --- a/packages/telemetry/session-telemetry/README.md +++ b/packages/telemetry/session-telemetry/README.md @@ -8,7 +8,7 @@ The telemetry seam: the CAPTURE side of session-event reporting, behind a backen ## Capture points -The coordinator registers, all through the composing fiber's effects: `session/created` (adopt: record the header, read the log back through the projection — seed events from fork/resume never re-emit on the firehose), `session/event` (project, deep-copy, redact, hand off; zero I/O), `session/flush` (forward the optional `flush()` hint and return void — the loop's awaited parallel must never wait on telemetry), `agent/error` (the one live-bus relay; turn-enclosure structurally bars those errors from the log), a dispose effect (per adopted session emit its `shutdown` operational record, then await the backend's `shutdown()`; failures warn instead of throwing), and an adoption sweep of `ctx.sessions.list()` (a hot reload does not replay `session/created`). +The coordinator registers, all through the composing fiber's effects: `session/created` (adopt: record the header, read the log back through the projection — seed events from fork/resume never re-emit on the firehose), `session/event` (project, deep-copy, redact, hand off; zero I/O), `session/flush` (forward the optional `flush()` hint and return void — the loop's awaited parallel must never wait on telemetry), `session/disposed` (retire: release the adopted entry so a long-lived backend neither retains closed sessions nor stamps dispose-time markers for them), `agent/error` (the one live-bus relay; turn-enclosure structurally bars those errors from the log), a dispose effect (per still-adopted session emit its `shutdown` operational record, then await the backend's `shutdown()`; failures warn instead of throwing), and an adoption sweep of `ctx.sessions.list()` (a hot reload does not replay `session/created`). ## The redact waterfall diff --git a/packages/telemetry/session-telemetry/src/coordinator.ts b/packages/telemetry/session-telemetry/src/coordinator.ts index ba1610247d..e8213e588a 100644 --- a/packages/telemetry/session-telemetry/src/coordinator.ts +++ b/packages/telemetry/session-telemetry/src/coordinator.ts @@ -35,13 +35,20 @@ const handoffCursor = new WeakMap() * Registers the persistence-coordinator listener set plus the `agent/error` * relay, all through `ctx.effect()`/`ctx.on()` on the composing fiber, and * sweeps already-live sessions (a hot reload does not replay - * `session/created`). Disposal emits each adopted session's `shutdown` + * `session/created`). A `session/disposed` retires the session from the + * adopted set — a long-lived backend must not retain closed sessions (and + * their frozen event logs) or stamp dispose-time markers for sessions that + * already ended. Disposal emits each still-adopted session's `shutdown` * operational record and then awaits the backend's `shutdown()`; a failure * there warns instead of throwing — best-effort reporting must not fail * application teardown. */ export class TelemetryCoordinator { - /** Sessions adopted by THIS fiber, for dispose-time `shutdown` records and double-adoption protection. */ + /** + * Sessions adopted by THIS fiber and still live, for dispose-time + * `shutdown` records and double-adoption protection; `session/disposed` + * retires entries. + */ private readonly adopted = new Set() /** Per session, the `turn:step` keys whose first chunk already shipped; rebuilt from the log on re-adoption. */ private readonly chunkSeen = new WeakMap>() @@ -57,6 +64,11 @@ export class TelemetryCoordinator { ctx.on('session/created', (session) => { this.adopt(session) }) + // Retirement is observe-only: the projection/cursor WeakMaps die with the + // Session object; only the strong adopted set needs the explicit release. + ctx.on('session/disposed', (session) => { + this.adopted.delete(session) + }) ctx.on('session/event', (session, event) => { this.contain(() => { this.capture(session, event) diff --git a/packages/telemetry/session-telemetry/src/index.ts b/packages/telemetry/session-telemetry/src/index.ts index 09800aa1b5..6d92a9547d 100644 --- a/packages/telemetry/session-telemetry/src/index.ts +++ b/packages/telemetry/session-telemetry/src/index.ts @@ -108,9 +108,13 @@ export interface TelemetryBackend { flush?(): void /** * Forward the fiber's disposal to the SDK: flush whatever is queued and - * reach quiescence, per the SDK's own shutdown contract. Awaited by the - * coordinator's dispose; a rejection is logged as a warning and never - * fails application teardown. + * reach quiescence, per the SDK's own shutdown contract. Everything + * emitted before this call must still be delivered — including records + * enqueued while a {@link flush} hint is in flight, so a backend whose SDK + * guards against concurrent flushes orders behind the outstanding one (the + * coordinator emits its dispose-time `shutdown` markers immediately before + * calling this). Awaited by the coordinator's dispose; a rejection is + * logged as a warning and never fails application teardown. * @returns resolves when the backend's pipeline has quiesced. */ shutdown(): Promise diff --git a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts index 743e90073d..55b5ed694a 100644 --- a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts +++ b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts @@ -262,6 +262,23 @@ describe('TelemetryCoordinator lifecycle and containment', () => { expect(ops.every(r => !('event.seq' in r.attributes) && !('event.type' in r.attributes))).toBe(true) }) + it('retires a disposed session: no retention, no stale shutdown marker at unload', async () => { + const { ctx, backend, fiber } = await setup() + liveSession(ctx, 'survivor') + // A session owned by its own fiber: disposing the fiber detaches it from + // the store and emits `session/disposed` — the authoritative retirement + // signal a long-lived telemetry backend must honor, or every closed + // session (and its full event log) stays strongly held for the backend's + // lifetime and final unload emits shutdown markers for dead sessions. + const owner = await ctx.plugin(Object.assign((inner: Context) => { + inner.sessions.create(SessionId('ephemeral'), { meta: {} }) + }, { inject: ['sessions'] })) + await owner.dispose() + await fiber.dispose() + const ops = backend.records.filter(r => r.channel === 'ops') + expect(ops.map(r => r.attributes['session.id'])).toEqual(['survivor']) + }) + it('warns instead of throwing when backend shutdown fails', async () => { const backend = new FakeBackend() backend.shutdownError = new Error('exporter unreachable') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e96b9cf4d..7b82b3bf3c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3567,6 +3567,9 @@ importers: '@opentelemetry/exporter-logs-otlp-http': specifier: ^0.220.0 version: 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': + specifier: ^0.220.0 + version: 0.220.0(@opentelemetry/api@1.9.1) '@opentelemetry/resources': specifier: ^2.9.0 version: 2.10.0(@opentelemetry/api@1.9.1) From d398eda43222b8a64b3afb733f2e37646ec9300f Mon Sep 17 00:00:00 2001 From: kingwl Date: Sat, 25 Jul 2026 03:48:32 +0800 Subject: [PATCH 029/319] fix(telemetry): join overlapping flush hints; contain adoption replay per event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second review round, both pinned red-first: - Overlapping turn-boundary flush hints now JOIN the outstanding flush promise (Promise.all) instead of displacing it: the SDK's concurrent-flush guard resolves an overlapping forceFlush() immediately, so retaining only the latest promise let shutdown() proceed while the first export was still in flight — the same silent drop the single-flush fix closed. - Adoption replay contains failures per event, matching the firehose: one rejected record is withheld fail-closed while the rest of the historical log still hands off. Wrapping the whole loop let a single failure silently skip the remainder on an already-adopted session. --- .../session-telemetry-otel/src/index.ts | 12 ++++--- .../session-telemetry-otel/tests/otel.spec.ts | 33 +++++++++++++++++++ .../session-telemetry/src/coordinator.ts | 18 ++++++---- .../session-telemetry/tests/telemetry.spec.ts | 26 +++++++++++++++ 4 files changed, 77 insertions(+), 12 deletions(-) diff --git a/packages/telemetry/session-telemetry-otel/src/index.ts b/packages/telemetry/session-telemetry-otel/src/index.ts index 8dde46828e..d415d1a7e8 100644 --- a/packages/telemetry/session-telemetry-otel/src/index.ts +++ b/packages/telemetry/session-telemetry-otel/src/index.ts @@ -147,7 +147,7 @@ export class TelemetryOtel extends Telemetry { }) } - /** The latest turn-boundary flush, retained so {@link shutdown} can order behind it. */ + /** Every not-yet-settled turn-boundary flush, retained so {@link shutdown} can order behind ALL of them. */ private inflightFlush: Promise = Promise.resolve() /** Forward the turn-boundary hint to the SDK's flush, fire-and-forget. */ @@ -157,15 +157,17 @@ export class TelemetryOtel extends Telemetry { // this once the fiber is disposed — a rejection would be SDK drift. The // settled promise is retained (not awaited): the SDK's concurrent-flush // guard makes a flush that overlaps another return WITHOUT draining, so - // shutdown must wait this one out before trusting its own flush. + // an overlapping hint resolves instantly and must JOIN the outstanding + // one, not displace it — shutdown orders behind the whole set. /* v8 ignore next -- unreachable guard: forceFlush does not reject while the provider is alive */ - this.inflightFlush = this.provider.forceFlush().catch(() => {}) + const flush = this.provider.forceFlush().catch(() => {}) + this.inflightFlush = Promise.all([this.inflightFlush, flush]).then(() => undefined) } /** * Delegate disposal to the SDK's shutdown contract: flush the queue and - * quiesce. Orders behind the last turn-boundary flush first — shutdown's - * internal flush is a no-op while one is in flight (the SDK's + * quiesce. Orders behind every outstanding turn-boundary flush first — + * shutdown's internal flush is a no-op while one is in flight (the SDK's * concurrent-flush guard), which would silently drop everything enqueued * after that flush snapshot, including the coordinator's dispose-time * `shutdown` markers. Awaited (and error-contained) by the coordinator's diff --git a/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts index b44b42ea04..85bfedeb1d 100644 --- a/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts +++ b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts @@ -155,6 +155,39 @@ describe('TelemetryOtel wire', () => { expect(ops[0]!.record.attributes).toContainEqual({ key: 'telemetry.op', value: { stringValue: 'shutdown' } }) }) + it('orders shutdown behind the OLDEST in-flight flush when hints overlap', async () => { + // The SDK's concurrent-flush guard resolves an overlapping forceFlush() + // immediately; if the backend RETAINS only the latest flush promise, two + // back-to-back turn flushes leave shutdown awaiting the instantly-resolved + // second one while the first still exports — reopening the same silent + // drop the single-flush race test pins. + const gate = Promise.withResolvers() + const arrived = Promise.withResolvers() + const { url, captures } = await mockCollector(async (index) => { + if (index === 0) { + arrived.resolve(true) + await gate.promise + } + }) + const { ctx, fiber } = await boot(url) + const session = ctx.sessions.create(SessionId('race2'), { meta: {} }) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + ctx.telemetry.flush!() + await arrived.promise + // Second hint while the first export is held open: resolves immediately + // under the SDK's guard and must not displace the outstanding one. + ctx.telemetry.flush!() + + const disposal = fiber.dispose() + await new Promise(resolve => setTimeout(resolve, 50)) + gate.resolve(true) + await disposal + + const ops = allRecords(captures).filter(r => r.scope === '@deepseek-ai/dsh-session-telemetry-otel/ops') + expect(ops).toHaveLength(1) + expect(ops[0]!.record.attributes).toContainEqual({ key: 'telemetry.op', value: { stringValue: 'shutdown' } }) + }) + it('passes exporter options beyond url and headers through to the SDK exporter', async () => { const { url, captures } = await mockCollector() const ctx = new Context() diff --git a/packages/telemetry/session-telemetry/src/coordinator.ts b/packages/telemetry/session-telemetry/src/coordinator.ts index e8213e588a..027c6af09e 100644 --- a/packages/telemetry/session-telemetry/src/coordinator.ts +++ b/packages/telemetry/session-telemetry/src/coordinator.ts @@ -113,15 +113,19 @@ export class TelemetryCoordinator { * @param session - the live session to adopt; a second adoption is a no-op. */ private adopt(session: Session): void { - this.contain(() => { - if (this.adopted.has(session)) return - this.adopted.add(session) - const cursor = handoffCursor.get(session) ?? -1 - for (const event of session.events) { + if (this.adopted.has(session)) return + this.adopted.add(session) + const cursor = handoffCursor.get(session) ?? -1 + // Containment is PER EVENT, matching the firehose: one rejected record + // is withheld fail-closed while the rest of the historical replay + // proceeds — wrapping the whole loop would let a single failure silently + // skip the remainder of the log on an already-adopted session. + for (const event of session.events) { + this.contain(() => { if (event.seq <= cursor) this.track(session, event) else this.capture(session, event) - } - }) + }) + } } /** Feed the chunk projection without handing off — the ≤cursor half of re-adoption. */ diff --git a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts index 55b5ed694a..d52baa91f5 100644 --- a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts +++ b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts @@ -26,11 +26,15 @@ class FakeBackend implements TelemetryBackend { records: TelemetryRecord[] = [] calls: string[] = [] emitError: Error | undefined + rejectSeq: number | undefined shutdownError: Error | undefined shutdownResolved = false emit(record: TelemetryRecord): void { if (this.emitError) throw this.emitError + if (this.rejectSeq !== undefined && record.attributes['event.seq'] === this.rejectSeq) { + throw new Error(`backend rejected seq ${this.rejectSeq}`) + } this.records.push(record) this.calls.push(`emit:${String(record.attributes['event.seq'] ?? record.attributes['telemetry.op'])}`) } @@ -213,6 +217,28 @@ describe('TelemetryCoordinator adoption', () => { expect(second.ledger().map(r => r.attributes['event.type'])).toEqual(['turn/end']) }) + it('replays past a record the backend rejects: one event withheld, the rest adopted', async () => { + const backend = new FakeBackend() + const ctx = new Context() + await ctx.plugin(SessionStore) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + const session = liveSession(ctx, 'partial') + appendTurn(session) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + // The backend rejects exactly the middle historical event: fail-closed + // must withhold THAT record only — an adoption replay that dies on the + // first contained failure would silently skip the rest of the log while + // the session stays marked adopted. + backend.rejectSeq = 1 + await ctx.plugin({ + name: 'fake-telemetry', + inject: ['sessions'], + apply: (inner: Context) => void new TelemetryCoordinator(inner, backend), + }) + expect(backend.ledger().map(r => r.attributes['event.seq'])).toEqual([0, 2]) + expect(warn).toHaveBeenCalled() + }) + it('re-hands the full log when no cursor survived (fresh session object)', async () => { const backend = new FakeBackend() const ctx = new Context() From 8372340f9c58cd12d73559ec8426846c39e14471 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 25 Jul 2026 07:47:51 +0800 Subject: [PATCH 030/319] feat(llm): add model-specific reasoning effort controls --- ...ed-reasoning-effort-capabilities.i18n.yaml | 6 + ...ter-owned-reasoning-effort-capabilities.md | 33 +++++ ...-owned-reasoning-effort-capabilities.zh.md | 33 +++++ docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- docs/config-catalog.md | 9 +- docs/cookbook/adding-an-llm-adapter.i18n.yaml | 4 +- docs/cookbook/adding-an-llm-adapter.md | 2 +- docs/cookbook/adding-an-llm-adapter.zh.md | 2 +- docs/cordis-catalog/events.md | 2 +- docs/cordis-catalog/services.md | 25 +++- docs/core-data-structures/core.md | 46 ++++++- docs/core-data-structures/llm-streaming.md | 13 +- docs/core-data-structures/session.md | 2 +- docs/event-producer-consumer.md | 2 +- .../develop/practice/llm-adapter.i18n.yaml | 4 +- docs/user/develop/practice/llm-adapter.md | 4 +- docs/user/develop/practice/llm-adapter.zh.md | 4 +- .../tests/fixtures/cli-mock-llm.ts | 26 +++- .../headless-agent/tests/headless.snapshot.ts | 41 ++++++ .../cordis/tool-cordis/src/api-catalog.ts | 24 +++- packages/core/agent-loop/README.md | 2 + packages/core/agent-loop/src/loop.ts | 39 +++++- .../core/agent-loop/tests/mock-adapter.ts | 14 +- .../tests/request-reconstruction.spec.ts | 78 ++++++++++- packages/core/session/src/index.ts | 5 + packages/core/session/src/types.ts | 2 +- .../core/session/tests/request-header.spec.ts | 5 + packages/core/session/tests/session.spec.ts | 36 ++++- packages/llm/llm-deepseek/README.md | 8 +- packages/llm/llm-deepseek/src/adapter.ts | 25 +++- packages/llm/llm-deepseek/src/index.ts | 10 +- packages/llm/llm-deepseek/src/serialize.ts | 18 ++- .../llm/llm-deepseek/tests/adapter.e2e.ts | 6 +- .../llm/llm-deepseek/tests/adapter.spec.ts | 76 ++++++++++- .../llm/llm-deepseek/tests/serialize.spec.ts | 22 ++- packages/llm/llm-pi-ai/README.md | 3 + packages/llm/llm-pi-ai/src/adapter.ts | 81 ++++++++++- packages/llm/llm-pi-ai/tests/adapter.e2e.ts | 13 +- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 70 +++++++++- packages/llm/llm/README.md | 10 +- packages/llm/llm/src/brand.ts | 12 ++ packages/llm/llm/src/call-config.ts | 23 +++- packages/llm/llm/src/index.ts | 112 ++++++++++++++- packages/llm/llm/src/types.ts | 25 +++- packages/llm/llm/tests/call-config.spec.ts | 6 + packages/llm/llm/tests/service.spec.ts | 127 +++++++++++++++++- scripts/gen-cordis-catalog.ts | 1 + scripts/type-equiv.manifest.json | 15 +++ 50 files changed, 1046 insertions(+), 88 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.md create mode 100644 .agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.i18n.yaml new file mode 100644 index 0000000000..489f9845f6 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-24-adapter-owned-reasoning-effort-capabilities.md: 806e808ab18d617003f757200f0cdee5853476f5 +2026-07-24-adapter-owned-reasoning-effort-capabilities.zh.md: 9be8b6f4f7bcbe063ff90492ac44b9590860f67f diff --git a/.agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.md b/.agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.md new file mode 100644 index 0000000000..806e808ab1 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.md @@ -0,0 +1,33 @@ +# Agent Note: Adapter-owned reasoning effort capabilities + +Status: implemented + +English | [中文](2026-07-24-adapter-owned-reasoning-effort-capabilities.zh.md) + +## Problem + +Reasoning strength was adapter configuration only, so a conversation could not discover or change the selected model's supported levels between requests. Promoting one adapter's level union into `dsh-llm` would make every provider and model adopt names it may not support, while a provider-specific options bag would make the loop unable to validate or durably reconstruct the effective request. + +## Decision + +`dsh-llm` represents a reasoning effort as the opaque branded `ReasoningEffortId`. An adapter's `resolveModelReasoning(provider, model)` returns a non-empty ordered list of ids with display metadata and may name one configured default. The core validates metadata, requires an explicit or configured effort to appear exactly in that list, and never clamps or aliases a value. + +`LlmCallConfig` and `GenerateOptions` carry the optional effort. The agent loop resolves the post-`agent/request` config before writing `request/header`, so defaults and dynamic changes are model-visible only after becoming durable facts. A route with no registered adapter retains its proposed config so an `llm/stream` middleware can own and short-circuit it; terminal dispatch still rejects an unhandled route. A resumed loop retains the logged effort only when its initial provider/model route is unchanged; a route change discards the previous model's opaque id. The terminal `LlmService` adapter boundary repeats resolution for direct calls that do not pass through the loop. + +The native DeepSeek adapter advertises `high` and `max`, defaults to configured effort or `high`, and exposes no effort capability while thinking is disabled. The pi-ai adapter derives each exact model's list from `getSupportedThinkingLevels()`, excludes `off`, preserves an absent profile default as a provider default, and leaves provider wire-value mapping inside pi-ai. + +## Alternatives considered + +**Define the pi-ai `ThinkingLevel` union in core.** Rejected because current pi-ai canonical names are an adapter implementation detail; a future provider can expose a different identifier without requiring a core release. + +**Carry an untyped provider options object.** Rejected because the loop could neither validate a selected value nor put a stable provider-neutral fact in the request header. + +**Clamp unsupported levels.** Rejected because a silent substitution makes the user's selected control differ from the logged request intent and hides stale deployment configuration. + +**Include `off` as an effort.** Rejected because disabling reasoning is a mode capability with different request and output semantics, not a reasoning-strength level. + +## Consequences + +Clients can query one exact route and render the adapter's order and names without knowing a global enum. Adapter configuration remains the deployment-default owner, while `agent/request` can replace the effective effort on each step. Invalid metadata fails with `INVALID_MODEL_REASONING`, and unsupported explicit or configured values fail with `UNSUPPORTED_REASONING_EFFORT` before provider I/O. + +The capability query is asynchronous and exact-model resolution may fail for adapters backed by authoritative catalogs. Keyless service, adapter, loop, session, and request-header tests pin validation, defaulting, dynamic changes, logging, and resume behavior; runnable snapshots pin the resolved effort in real assembled request headers, while key-gated adapter tests exercise provider serialization. diff --git a/.agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.zh.md b/.agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.zh.md new file mode 100644 index 0000000000..9be8b6f4f7 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-24-adapter-owned-reasoning-effort-capabilities.zh.md @@ -0,0 +1,33 @@ +# Agent Note:适配器持有的推理强度能力 + +Status: implemented + +[English](2026-07-24-adapter-owned-reasoning-effort-capabilities.md) | 中文 + +## 问题 + +推理强度过去只能在适配器中配置,因此对话无法在多次请求之间发现或更改所选模型支持的等级。若将某个适配器的等级联合类型提升到 `dsh-llm`,所有提供方和模型都必须采用一套自身可能并不支持的名称;若改用提供方特有的 options 对象,主循环又无法校验最终生效的请求,也无法通过持久化记录准确重建该请求。 + +## 决策 + +`dsh-llm` 使用不透明的品牌类型 `ReasoningEffortId` 表示推理强度。适配器的 `resolveModelReasoning(provider, model)` 返回非空的有序 ID 列表及其展示元数据,并可指定一个由配置确定的默认值。核心会校验元数据,要求显式指定或配置指定的推理强度与列表中的某个 ID 完全一致,且绝不自动调整或为值提供别名。 + +`LlmCallConfig` 和 `GenerateOptions` 携带可选的推理强度。agent loop(智能体循环)在 `agent/request` 处理完成后、写入 `request/header` 前解析配置,因此默认值和动态变更只有成为持久化事实后才对模型可见。没有已注册适配器的路由会保留原定配置,使 `llm/stream` 中间件可以接管并短路该请求;若仍未得到处理,最终分发会拒绝该路由。恢复后的主循环仅在初始提供方/模型路由未变时保留日志中记录的推理强度;如果路由发生变化,则丢弃上一模型的不透明 ID。最终的 `LlmService` 适配器边界会再次执行解析,以覆盖未经过主循环的直接调用。 + +原生 DeepSeek 适配器声明 `high` 和 `max`,默认使用配置指定的推理强度,若未配置则使用 `high`;禁用思考时不暴露推理强度能力。pi-ai 适配器通过 `getSupportedThinkingLevels()` 按具体模型推导等级列表,排除 `off`,在 profile 未指定默认值时保留提供方默认行为,并将提供方协议值的映射留在 pi-ai 内部。 + +## 备选方案 + +**在核心中定义 pi-ai 的 `ThinkingLevel` 联合类型。** 不予采纳:pi-ai 当前的规范名称属于适配器实现细节;未来的提供方可以暴露不同的标识符,而无需为此发布新的核心版本。 + +**携带无类型约束的提供方 options 对象。** 不予采纳:主循环既无法校验选定值,也无法在请求头中写入稳定且与提供方无关的事实。 + +**自动调整不支持的等级。** 不予采纳:静默替换会导致用户选定的控制项与日志记录的请求意图不一致,还会掩盖陈旧的部署配置。 + +**将 `off` 列为推理强度。** 不予采纳:禁用推理属于具有不同请求和输出语义的模式能力,而不是推理强度等级。 + +## 影响 + +客户端可以查询一条确切路由,并按适配器给出的顺序和名称渲染等级,而无需了解全局枚举。适配器配置仍负责提供部署默认值,`agent/request` 则可以在每个步骤替换实际生效的推理强度。元数据无效时抛出 `INVALID_MODEL_REASONING`;显式指定或配置指定的值不受支持时,会在提供方 I/O 前抛出 `UNSUPPORTED_REASONING_EFFORT`。 + +能力查询采用异步方式;对于由权威目录支持的适配器,确切模型解析可能失败。无密钥的服务、适配器、主循环、会话和请求头测试为校验、默认值解析、动态变更、日志记录和恢复行为提供回归保障;可运行快照锁定实际组装请求头中的已解析推理强度,仅在有密钥时运行的适配器测试则覆盖提供方序列化。 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 20553e1b90..e7c0dd02b3 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -architecture.md: d1051eecf51d8d1c7f8c235b0c5dd80478b43316 -architecture.zh.md: 502c0248a9d2c62af165ce07eb19489b76c5f6ee +architecture.md: 0bc015afcf583224b7162540ae53f2bb19952cd6 +architecture.zh.md: 20ed3229e0a8d5997cc982ae5c82fb90f900c948 diff --git a/docs/architecture.md b/docs/architecture.md index d1051eecf5..0bc015afcf 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -91,7 +91,7 @@ forever: agent/pre-step snapshot the derived messages (the reconstruction boundary) 'step/start' - agent/request (config only) -> log request/header -> checkpoint -> llm/stream (frozen) + agent/request -> resolve reasoning/default -> log request/header -> checkpoint -> llm/stream (frozen) on final adapter-path or terminal in-band failure: 'step/end' agent/request-error(original error, failure facts, immutable prior failures, signal) diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 502c0248a9..20ed3229e0 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -91,7 +91,7 @@ forever: agent/pre-step snapshot the derived messages (the reconstruction boundary) 'step/start' - agent/request (config only) -> log request/header -> checkpoint -> llm/stream (frozen) + agent/request -> resolve reasoning/default -> log request/header -> checkpoint -> llm/stream (frozen) on final adapter-path or terminal in-band failure: 'step/end' agent/request-error(original error, failure facts, immutable prior failures, signal) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 985e493184..b1f3f969c4 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -520,8 +520,9 @@ Requires: `llm` /** * Plugin config, validated by the same-named schemastery schema. Every field * is optional in yml: credentials/endpoint fall back to the environment (a - * missing API key fails plugin load, not the first call), and omitted - * thinking fields send nothing on the wire, so the provider default applies. + * missing API key fails plugin load, not the first call), omitted thinking + * mode uses the provider default, and omitted reasoning effort resolves to + * `high`. */ export interface Config { /** API key; falls back to $DEEPSEEK_API_KEY. Required one way or the other. */ @@ -530,7 +531,7 @@ export interface Config { baseURL?: string /** Thinking-mode default for every request (provider default: enabled). */ thinking?: 'enabled' | 'disabled' - /** Thinking effort (only meaningful with thinking enabled). */ + /** Default thinking effort when thinking is enabled (default `high`). */ reasoningEffort?: 'high' | 'max' /** Positive context capacity used when the selected model has no exact value. */ defaultContextWindow?: number @@ -553,7 +554,7 @@ export interface DeepSeekCatalogModel { } ``` -Source: [`packages/llm/llm-deepseek/src/index.ts:34`](../packages/llm/llm-deepseek/src/index.ts) +Source: [`packages/llm/llm-deepseek/src/index.ts:35`](../packages/llm/llm-deepseek/src/index.ts) ## `@deepseek-ai/dsh-llm-pi-ai` diff --git a/docs/cookbook/adding-an-llm-adapter.i18n.yaml b/docs/cookbook/adding-an-llm-adapter.i18n.yaml index 497ae08c32..15c62ae966 100644 --- a/docs/cookbook/adding-an-llm-adapter.i18n.yaml +++ b/docs/cookbook/adding-an-llm-adapter.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -adding-an-llm-adapter.md: f20442b8c2ce823452a3ea13409f202185d12d04 -adding-an-llm-adapter.zh.md: 2864dd1e18742c7449e24f22504a5a38976ab450 +adding-an-llm-adapter.md: 3adae89f360cd81e264912d74bd86f5a06cd42f8 +adding-an-llm-adapter.zh.md: 80329da5e7c89bfb5ee2e2aa5b9931a3ef0e83f6 diff --git a/docs/cookbook/adding-an-llm-adapter.md b/docs/cookbook/adding-an-llm-adapter.md index f20442b8c2..3adae89f36 100644 --- a/docs/cookbook/adding-an-llm-adapter.md +++ b/docs/cookbook/adding-an-llm-adapter.md @@ -32,7 +32,7 @@ Registration is effect-based (HMR-safe); one adapter per provider route — dupl - A `GenerateOptions` field your provider cannot honor (e.g. a `stop` list on a provider without stop sequences): throw `LlmError(..., 'UNSUPPORTED')` rather than silently dropping it. - If the provider requires response ids, signatures, or other native metadata on follow-up calls, emit the minimal lossless-JSON projection as `finish.replayState`. Validate it when rebuilding history. `LlmService` passes it only when the historical provider route and target provider route are currently owned by the exact same adapter instance; your adapter decides whether same-model, cross-model, or cross-provider restoration is legal. Never infer native replay from provider/model names alone when state is absent. -Provider-specific request knobs (thinking modes, effort levels) belong in the ADAPTER's Config, not in `GenerateOptions` — the core vocabulary stays provider-neutral. +Provider-specific thinking-mode toggles remain in the adapter's Config. Selectable reasoning strength uses the provider-neutral capability seam: return ordered opaque ids from `resolveModelReasoning()`, declare a configured `defaultEffort` only when one exists, and map `GenerateOptions.reasoningEffort` to the provider wire value. Do not expose provider wire spellings, clamp unsupported values, or include an `off` mode as an effort. ## Structure that worked diff --git a/docs/cookbook/adding-an-llm-adapter.zh.md b/docs/cookbook/adding-an-llm-adapter.zh.md index 2864dd1e18..80329da5e7 100644 --- a/docs/cookbook/adding-an-llm-adapter.zh.md +++ b/docs/cookbook/adding-an-llm-adapter.zh.md @@ -32,7 +32,7 @@ export function apply(ctx: Context, config: Config) { - 如果 `GenerateOptions` 中某个字段你的提供方无法支持(例如提供方不支持 stop sequences 时收到 `stop` 列表):抛出 `LlmError(..., 'UNSUPPORTED')`,而非静默丢弃。 - 如果提供方在后续调用中需要响应 ID、签名或其他原生元数据,请将其最小无损 JSON 投影作为 `finish.replayState` 发出。重建历史时验证该状态。只有历史提供方路由和目标提供方路由当前由完全相同的适配器实例拥有时,`LlmService` 才会传递该状态;由适配器决定同模型、跨模型或跨提供方恢复是否合法。状态缺失时,切勿仅根据提供方/模型名称推断原生回放。 -提供方特有的请求旋钮(thinking 模式、effort 级别)放在**适配器**的 Config 中,而非 `GenerateOptions` 中——核心词汇保持提供方无关。 +提供方特有的 thinking 模式开关仍放在适配器的 Config 中。可选的推理强度使用提供方无关的能力 seam:`resolveModelReasoning()` 返回有序的不透明 ID;仅当存在配置指定的默认值时才声明 `defaultEffort`;并将 `GenerateOptions.reasoningEffort` 映射为提供方协议值。不得暴露提供方协议值的具体拼写、自动调整不支持的值,也不得把 `off` 模式列为推理强度。 ## 经验证有效的结构 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 6becfd9434..30cc12af6b 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -547,7 +547,7 @@ Waterfall around every streaming model call (retry, replay, routing). Bound to t Types: [GenerateOptions](../core-data-structures/core.md) · [LlmService](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:52`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:54`](../../packages/llm/llm/src/index.ts) ## `session/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index c5d6d88d5c..718a45189b 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -671,6 +671,25 @@ async listModels(provider: string): Promise */ async resolveModelContext( provider: string, model: string, ): Promise +/** + * Resolve selectable reasoning efforts from the adapter that owns one exact + * route. Metadata is validated and detached; an absent result means an + * effort selector is unsupported for that model. + * @param provider - registered provider route to inspect. + * @param model - exact model id passed to the adapter. + * @returns detached reasoning metadata, or `undefined` when unsupported. + */ +async resolveModelReasoning( provider: string, model: string, ): Promise + +/** + * Validate a conversation call config against its exact model capability and + * materialize an adapter-configured default. Unsupported explicit efforts + * reject before provider I/O; no clamping or aliasing is performed. + * @param config - provider/model route and optional request controls. + * @returns a detached config only when a default must be materialized. + */ +async resolveCallConfig(config: LlmCallConfig): Promise + /** * Stream one model call as raw chunks (token-level deltas). Throws * `LlmError` with code `NO_ADAPTER` if no adapter is registered for @@ -686,9 +705,9 @@ async resolveModelContext( provider: string, model: string, ): Promise ``` -Types: [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmModelContext](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) +Types: [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmCallConfig](../core-data-structures/core.md) · [LlmModelContext](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmModelReasoningInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:159`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:175`](../../packages/llm/llm/src/index.ts) ## `ctx.permission` — `PermissionService` @@ -1238,7 +1257,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [OutOfBandSessionEventType](../core-data-structures/session.md) · [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md) · [SessionEventMap](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md) · [TurnTrigger](../core-data-structures/session.md) -Source: [`packages/core/session/src/index.ts:605`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:610`](../../packages/core/session/src/index.ts) ## `ctx.sessionTitle` — `SessionTitleService` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 86809ddc8b..5380573c55 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -205,12 +205,46 @@ interface LlmModelContext { } ``` +Reasoning effort is another exact-route capability. The core brands identifiers but does not enumerate their values; each adapter owns the ordered set, display names, and optional deployment default. + +```ts type-equiv +/** Adapter-owned identifier for one model's selectable reasoning effort. */ +type ReasoningEffortId = Branded<'ReasoningEffortId'> +``` + +```ts type-equiv +/** Display metadata for one adapter-owned reasoning effort. */ +interface LlmReasoningEffortInfo { + /** Opaque stable value accepted by {@link GenerateOptions.reasoningEffort}. */ + id: ReasoningEffortId + /** Human-readable effort name for selectors and diagnostics. */ + name: string + /** Optional user-facing distinction from otherwise similar efforts. */ + description?: string +} +``` + +```ts type-equiv +/** Selectable reasoning efforts for one exact provider/model route. */ +interface LlmModelReasoningInfo { + /** Supported efforts in adapter-preferred display order. */ + efforts: readonly LlmReasoningEffortInfo[] + /** + * Adapter-configured default materialized into requests when callers omit + * an effort. Absence preserves the provider's own default. + */ + defaultEffort?: ReasoningEffortId +} +``` + ```ts type-equiv /** A single model request, fully assembled. */ interface GenerateOptions { /** Registered provider route selecting the adapter instance. */ provider: string model: string + /** Adapter-owned reasoning effort selected for this exact model. */ + reasoningEffort?: ReasoningEffortId /** * Ordered conversation messages, exactly as the provider sees them (after * the `system` slot). A loop-built request assembles them as @@ -287,21 +321,23 @@ The model-facing `ToolSchema` is the wire shape; the registered `ToolDefinition` The loop builds each request from logged state. `EpochHeader` records call config, rendered prompt, authoritative returned tool order (configured by `toolOrder`, or lexicographic when unset), and session prefix through full `request/header` snapshots. Together with derived history, this makes the request reconstructable from the session log. See [session.md](session.md#the-request-header-event-requestheader) and the [reconstructability Agent Note](../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md). -`agent/request` receives a frozen call-config seed and may return a replacement to switch provider, model, or sampling. `agent/session-prefix` composes request-only prefix messages once per loop instance, and the header records the exact result used. Requests reaching `llm/stream` are deep-frozen, so mutation throws, and carry a process-local loop identity so observers do not confuse separately logged frozen auxiliary calls with conversation requests. +`agent/request` receives a frozen call-config seed and may return a replacement to switch provider, model, reasoning effort, or sampling. The loop resolves the exact model capability after the waterfall, rejects unsupported explicit effort ids without clamping, materializes an adapter-configured default, and logs the effective value. `agent/session-prefix` composes request-only prefix messages once per loop instance, and the header records the exact result used. Requests reaching `llm/stream` are deep-frozen, so mutation throws, and carry a process-local loop identity so observers do not confuse separately logged frozen auxiliary calls with conversation requests. On the wire, a loop-built request reads in this order: the `system` slot (the rendered prompt assembly) → `messagePrefix` (the frozen session prefix) → the derived history — the boundary snapshot, whose tail is the newest `user/message` on a turn's first step and the previous step's tool results on later steps. The prefix never enters the derived history; its durable record is the header events, and the dev invariant recomputes exactly this equation against every loop-built request. -FIXME(call-config-shape): revisit the exact definition of this type — which fields are genuinely epoch-level for cache purposes (`model` certainly; the sampling scalars sit here out of caution), and where provider-specific extras (reasoning options, extra body params) belong when an adapter needs them. +FIXME(call-config-shape): revisit which remaining fields are genuinely epoch-level for cache purposes (`model` and the model-owned reasoning effort are explicit; the sampling scalars sit here out of caution). ```ts type-equiv /** - * Provider + model + sampling scalars of one conversation's requests. Every field maps - * 1:1 onto the same-named `GenerateOptions` field; the loop builds requests - * from the logged header rather than accepting these per call. + * Provider, model, reasoning effort, and sampling scalars of one conversation's + * requests. Every field maps 1:1 onto the same-named `GenerateOptions` field; + * the loop builds requests from the logged header rather than accepting these + * per call. */ interface LlmCallConfig { provider: string model: string + reasoningEffort?: ReasoningEffortId temperature?: number maxTokens?: number stop?: string[] diff --git a/docs/core-data-structures/llm-streaming.md b/docs/core-data-structures/llm-streaming.md index 257ce90cda..b3c285ebba 100644 --- a/docs/core-data-structures/llm-streaming.md +++ b/docs/core-data-structures/llm-streaming.md @@ -154,7 +154,7 @@ declare class BlockAssembler { ## The seam -`LlmAdapter` is the provider seam: subclass, implement `stream()`, and register one adapter instance with `ctx.llm.registerAdapter(providers, adapter)`. `GenerateOptions.provider` selects the registered adapter; `GenerateOptions.model` is passed to that adapter and need not be registered at lifecycle start. Duplicate provider routes fail atomically. Optional `providerInfo()` and asynchronous `listModels()` methods feed `LlmService.listProviders()` / `listModels()` with detached selector metadata. That catalog is advisory rather than a request whitelist: the adapter remains authoritative and may accept unlisted model ids. The separate `resolveModelContext()` query exposes correctness-sensitive capacity for an exact route without making catalog membership authoritative; absence means unknown metadata, not invalid routing. Adapter lookup happens at the terminal continuation of the `llm/stream` waterfall, so a listener may short-circuit the call or route a mutable one-shot request before lookup. The `block-start` / `block-end` `index` correlation and the assembler together mean an adapter only has to emit well-formed chunks — block reassembly is not each adapter's problem. The consumer surface (`ctx.llm.stream()`) and the `llm/stream` waterfall are described in [architecture.md § Content blocks and streaming](../architecture.md#content-blocks-and-streaming-dsh-llm). +`LlmAdapter` is the provider seam: subclass, implement `stream()`, and register one adapter instance with `ctx.llm.registerAdapter(providers, adapter)`. `GenerateOptions.provider` selects the registered adapter; `GenerateOptions.model` is passed to that adapter and need not be registered at lifecycle start. Duplicate provider routes fail atomically. Optional `providerInfo()` and asynchronous `listModels()` methods feed `LlmService.listProviders()` / `listModels()` with detached selector metadata. That catalog is advisory rather than a request whitelist: the adapter remains authoritative and may accept unlisted model ids. The separate `resolveModelContext()` query exposes correctness-sensitive capacity, while `resolveModelReasoning()` exposes ordered model-owned effort ids and an optional deployment default; absence from either query means unavailable metadata or capability, not invalid catalog membership. The service validates and materializes reasoning through `resolveCallConfig()` at the final adapter boundary, so direct calls cannot bypass unsupported-effort rejection. Adapter lookup happens at the terminal continuation of the `llm/stream` waterfall, so a listener may short-circuit the call or route a mutable one-shot request before lookup. The `block-start` / `block-end` `index` correlation and the assembler together mean an adapter only has to emit well-formed chunks — block reassembly is not each adapter's problem. The consumer surface (`ctx.llm.stream()`) and the `llm/stream` waterfall are described in [architecture.md § Content blocks and streaming](../architecture.md#content-blocks-and-streaming-dsh-llm). ```ts public-api /** @@ -189,6 +189,17 @@ declare abstract class LlmAdapter { _provider: string, _model: string, ): Promise; + /** + * Resolve selectable reasoning efforts for one exact model. Absence means + * the model has no selectable reasoning-effort capability. + * @param _provider - one provider route owned by this adapter. + * @param _model - exact model id passed to {@link GenerateOptions.model}. + * @returns adapter-owned effort metadata, or `undefined` when unsupported. + */ + resolveModelReasoning( + _provider: string, + _model: string, + ): Promise; /** * Stream one model call as raw chunks. The only required method. * @param options - the fully-assembled request; implementations must honor `options.signal`. diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index ba1dcb98ba..eb530f7709 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -167,7 +167,7 @@ The request envelope — the `EpochHeader` (call config + rendered system prompt * canonical empty optional fields are absent. */ interface EpochHeader { - /** The conversation's call configuration (provider, model, and sampling scalars). */ + /** The conversation's call configuration (provider, model, reasoning effort, and sampling scalars). */ config: LlmCallConfig /** Rendered system prompt text; absent for a system-less request. */ system?: string diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 179ad0dcee..5659a8c7bf 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -30,7 +30,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/types.ts:167`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | -| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:52`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | +| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:54`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`user-approval`](../packages/ui/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:89`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `runtime`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), `runtime`, [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | diff --git a/docs/user/develop/practice/llm-adapter.i18n.yaml b/docs/user/develop/practice/llm-adapter.i18n.yaml index 8735e8d5a6..c179b693f8 100644 --- a/docs/user/develop/practice/llm-adapter.i18n.yaml +++ b/docs/user/develop/practice/llm-adapter.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -llm-adapter.md: 3e83289b8072ef231f83c0fa3cfe3260547b42fa -llm-adapter.zh.md: 92fcf9b22f4bb356ada4c46f9a03ef0cc2d159da +llm-adapter.md: f133a6ea748fd7d9bfa6a7adaa35b1cccbe40c8e +llm-adapter.zh.md: 3a4fd9a516126d1f9fa8675fdc87be10c85f3489 diff --git a/docs/user/develop/practice/llm-adapter.md b/docs/user/develop/practice/llm-adapter.md index 3e83289b80..f133a6ea74 100644 --- a/docs/user/develop/practice/llm-adapter.md +++ b/docs/user/develop/practice/llm-adapter.md @@ -110,7 +110,9 @@ async function* exampleChunks(): AsyncIterable { ## GenerateOptions -`stream()` receives the exported `GenerateOptions` type. It includes the model, conversation history, system prompt, tool schemas, generation parameters, stop sequences, and abort signal; treat the TypeScript type exported by `@deepseek-ai/dsh-llm` as authoritative. Map supported fields to the provider API. If the provider cannot honor a field, throw `LlmError` with a stable code instead of silently dropping it. +`stream()` receives the exported `GenerateOptions` type. It includes the model, adapter-owned reasoning-effort id, conversation history, system prompt, tool schemas, generation parameters, stop sequences, and abort signal; treat the TypeScript type exported by `@deepseek-ai/dsh-llm` as authoritative. Map supported fields to the provider API. If the provider cannot honor a field, throw `LlmError` with a stable code instead of silently dropping it. + +Override `resolveModelReasoning(provider, model)` when an exact model exposes selectable reasoning strengths. Return ordered opaque ids and display names plus an optional configured default; do not promote provider names into a core enum. The service validates the metadata and rejects unsupported explicit values before `stream()`. Returning `undefined` means that model has no selectable reasoning-effort capability. ## Register an adapter diff --git a/docs/user/develop/practice/llm-adapter.zh.md b/docs/user/develop/practice/llm-adapter.zh.md index 92fcf9b22f..3a4fd9a516 100644 --- a/docs/user/develop/practice/llm-adapter.zh.md +++ b/docs/user/develop/practice/llm-adapter.zh.md @@ -110,7 +110,9 @@ async function* exampleChunks(): AsyncIterable { ## GenerateOptions -`stream()` 接收仓库导出的 `GenerateOptions`。它包含模型名、对话历史、系统提示词、tool schema、生成参数、停止序列和中止信号;完整字段以 `@deepseek-ai/dsh-llm` 导出的 TypeScript 类型为准。适配器必须将支持的字段映射到具体 API;无法支持的字段应抛出带稳定 code 的 `LlmError`,不能静默丢弃。 +`stream()` 接收仓库导出的 `GenerateOptions`。它包含模型名、由适配器持有的推理强度 ID、对话历史、系统提示词、tool schema、生成参数、停止序列和中止信号;完整字段以 `@deepseek-ai/dsh-llm` 导出的 TypeScript 类型为准。适配器必须将支持的字段映射到具体 API;无法支持的字段应抛出带稳定 code 的 `LlmError`,不能静默丢弃。 + +当某个具体模型提供可选推理强度时,请覆写 `resolveModelReasoning(provider, model)`。返回有序的不透明 ID、展示名称,以及可选的配置默认值;不要将提供方使用的等级名称提升为核心枚举。服务会校验元数据,并在调用 `stream()` 前拒绝显式指定但不受支持的值。返回 `undefined` 表示该模型没有可选的推理强度能力。 ## 注册适配器 diff --git a/examples/headless-agent/tests/fixtures/cli-mock-llm.ts b/examples/headless-agent/tests/fixtures/cli-mock-llm.ts index 5238e67374..23b5e18fc9 100644 --- a/examples/headless-agent/tests/fixtures/cli-mock-llm.ts +++ b/examples/headless-agent/tests/fixtures/cli-mock-llm.ts @@ -1,8 +1,28 @@ import type { Context } from 'cordis' -import { CallId, LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm' +import { + CallId, + LlmAdapter, + ReasoningEffortId, + type GenerateOptions, + type LlmModelReasoningInfo, + type StreamChunk, +} from '@deepseek-ai/dsh-llm' + +const HIGH = ReasoningEffortId('high') +const MAX = ReasoningEffortId('max') /** Keyless headless-agent adapter: one real bash call followed by a final answer. */ class CliMockAdapter extends LlmAdapter { + override async resolveModelReasoning(): Promise { + return { + efforts: [ + { id: HIGH, name: 'High' }, + { id: MAX, name: 'Max' }, + ], + defaultEffort: HIGH, + } + } + async * stream(options: GenerateOptions): AsyncIterable { const toolResult = options.messages.at(-1)?.content.find(block => block.type === 'tool-result') if (toolResult === undefined) { @@ -34,4 +54,8 @@ export const inject = ['llm'] /** Register the keyless `cli-mock` adapter. */ export function apply(ctx: Context): void { ctx.llm.registerAdapter(['cli-mock'], new CliMockAdapter()) + ctx.on('agent/request', async (_agent, _turn, step, _config, _signal, next) => { + const config = await next() + return step === 2 ? { ...config, reasoningEffort: MAX } : config + }) } diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index 1626489bf5..70c1fa8bfe 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -28,6 +28,7 @@ const ralphScenarioDir = join(snapshotsDir, 'ralph-loop') const ralphConfigPath = fileURLToPath(new URL('../ralph.cordis.snapshot.yml', import.meta.url)) const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) +const reasoningConfigPath = fileURLToPath(new URL('./fixtures/cli.cordis.yml', import.meta.url)) const refreshing = process.env.DSH_SNAPSHOT === 'refresh' interface JsonObject { @@ -124,6 +125,46 @@ async function persistedLogs(cwd: string): Promise { } describe('headless stream-json snapshots', () => { + it('logs the model default and a dynamic next-step reasoning effort', async () => { + const result = await runLoaderSmoke({ + label: 'reasoning effort headless stream-json snapshot', + tempDirPrefix: 'headless-snapshot-reasoning-effort-', + binScript, + configPath: reasoningConfigPath, + binArgs: ['--config', reasoningConfigPath, '--output-format', 'stream-json', 'prove dynamic reasoning effort'], + tsconfigPath, + }) + + expect(result.stderr).toBe('') + const headers = parseJsonl(result.stdout) + .map(record => record.event) + .filter((event): event is JsonObject => ( + event !== null + && typeof event === 'object' + && !Array.isArray(event) + && 'type' in event + && event.type === 'request/header' + )) + .map((event) => { + const data = event.data as JsonObject + return (data.header as JsonObject).config + }) + expect(headers).toMatchInlineSnapshot(` + [ + { + "model": "cli-mock", + "provider": "cli-mock", + "reasoningEffort": "high", + }, + { + "model": "cli-mock", + "provider": "cli-mock", + "reasoningEffort": "max", + }, + ] + `) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('replays the advanced toolchain through the one-shot app', async () => { const prompt = await scenarioPrompt(advancedScenarioDir, 'advanced-toolchain') const fixtureFiles = [ diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index ffc1670f4e..604a490405 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -344,6 +344,14 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'async resolveModelContext( provider: string, model: string, ): Promise', jsDoc: '/**\n * Resolve context capacity from the adapter that owns one exact route.\n * This query is independent of the advisory model catalog: an unlisted model\n * may return metadata, while `undefined` never rejects later routing.\n * @param provider - registered provider route to inspect.\n * @param model - exact model id passed to the adapter.\n * @returns detached context metadata, or `undefined` when the adapter has none.\n */', }, + { + signature: 'async resolveModelReasoning( provider: string, model: string, ): Promise', + jsDoc: '/**\n * Resolve selectable reasoning efforts from the adapter that owns one exact\n * route. Metadata is validated and detached; an absent result means an\n * effort selector is unsupported for that model.\n * @param provider - registered provider route to inspect.\n * @param model - exact model id passed to the adapter.\n * @returns detached reasoning metadata, or `undefined` when unsupported.\n */', + }, + { + signature: 'async resolveCallConfig(config: LlmCallConfig): Promise', + jsDoc: '/**\n * Validate a conversation call config against its exact model capability and\n * materialize an adapter-configured default. Unsupported explicit efforts\n * reject before provider I/O; no clamping or aliasing is performed.\n * @param config - provider/model route and optional request controls.\n * @returns a detached config only when a default must be materialized.\n */', + }, { signature: 'stream(options: GenerateOptions): AsyncIterable', jsDoc: '/**\n * Stream one model call as raw chunks (token-level deltas). Throws\n * `LlmError` with code `NO_ADAPTER` if no adapter is registered for\n * `options.provider`. Replay state is retained only when the same adapter\n * instance owns its historical provider and the target provider. Final\n * adapter selection, dispatch, and iteration failures retain their original\n * Error identity and are tagged in a call-local scope for narrow agent-loop\n * request recovery; middleware and nested-call failures remain untagged for\n * the outer call.\n * @param options - the full request; `options.provider` selects the adapter.\n * @returns the chunk stream, possibly wrapped by `llm/stream` listeners.\n */', @@ -1455,7 +1463,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'GenerateOptions', - declaration: 'export interface GenerateOptions {\n provider: string;\n model: string;\n messages: Message[];\n system?: string;\n tools?: ToolSchema[];\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n signal?: AbortSignal;\n sessionId?: Branded<\'SessionId\'>;\n purpose?: \'compaction\' | \'session-title\';\n}', + declaration: 'export interface GenerateOptions {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n messages: Message[];\n system?: string;\n tools?: ToolSchema[];\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n signal?: AbortSignal;\n sessionId?: Branded<\'SessionId\'>;\n purpose?: \'compaction\' | \'session-title\';\n}', }, { name: 'GenericCallView', @@ -1527,7 +1535,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'LlmCallConfig', - declaration: 'export interface LlmCallConfig {\n provider: string;\n model: string;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n}', + declaration: 'export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n}', }, { name: 'LlmFailure', @@ -1541,10 +1549,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'LlmModelInfo', declaration: 'export interface LlmModelInfo {\n provider: string;\n id: string;\n name: string;\n description?: string;\n}', }, + { + name: 'LlmModelReasoningInfo', + declaration: 'export interface LlmModelReasoningInfo {\n efforts: readonly LlmReasoningEffortInfo[];\n defaultEffort?: ReasoningEffortId;\n}', + }, { name: 'LlmProviderInfo', declaration: 'export interface LlmProviderInfo {\n id: string;\n name: string;\n}', }, + { + name: 'LlmReasoningEffortInfo', + declaration: 'export interface LlmReasoningEffortInfo {\n id: ReasoningEffortId;\n name: string;\n description?: string;\n}', + }, { name: 'Message', declaration: 'export interface Message {\n role: \'system\' | \'user\' | \'assistant\';\n content: ContentBlock[];\n provenance?: AssistantProvenance;\n}', @@ -1689,6 +1705,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ReasoningBlock', declaration: 'export interface ReasoningBlock {\n type: \'reasoning\';\n text: string;\n}', }, + { + name: 'ReasoningEffortId', + declaration: 'export type ReasoningEffortId = Branded<\'ReasoningEffortId\'>;', + }, { name: 'ResumeAgentOptions', declaration: 'export interface ResumeAgentOptions {\n readonly resumeSessionId: SessionId;\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise | void;\n}', diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 79fbbd7824..0390d54f94 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -60,6 +60,8 @@ The driver owns one agent for its lifetime and runs inside `ctx.agents.withIniti Every provider call that reaches a successful finish appends exactly one `assistant/message` completion anchor, including content-less calls and `max-tokens` finishes. A successful `agent/step-result` stores its transformed content; a rejected result records empty content before the original failure continues. The anchor retains exact chunk provenance (`[]` for a stream with no chunks) and usage when available, while empty content stays out of derived message history. +After `agent/request` returns a provider/model call config, the loop asks `ctx.llm.resolveCallConfig()` to validate any adapter-owned reasoning effort and materialize its configured default. The effective config is logged in the full `request/header` before dispatch, so a listener can change effort between steps without hidden request drift. A route with no registered adapter preserves the proposed config so an `llm/stream` listener can own and short-circuit it; unhandled terminal dispatch still fails with `NO_ADAPTER`. A new loop instance restores the last effort only when its initial provider/model route exactly matches the logged route; a route change discards that opaque model-owned ID and resolves the new model independently. + Plugin failure ends the current turn, not the loop. Only final adapter dispatch/iteration failures and terminal in-band error or aborted finishes enter `agent/request-error`; middleware, result processing, tools, and `agent/post-step` remain ordinary turn failures. Recovery receives the exact live error, immutable provider facts, and immutable prior failures after the failed step closes. A retry rebuilds from the durable log in a new numbered step, success clears the consecutive history, and exhaustion records the structured failure once on `turn/end`. AgentLoop privately owns one cancellation holder whose explicit signal spans prompt policy, assembly, every step, model and tool work, recovery, continuation, and terminal stop; it retires the holder immediately before publishing `turn/end`, while the driver may remain `running` through the durability flush. An effective `cancel()` emits the typed runtime-only `user | parent` cause before clearing pending work and cooperatively aborting the holder; notification failures cannot veto cancellation, work queued by a notification observer is cleared, work queued by a later abort observer belongs to the next turn, and idle cancellation emits nothing. Durable `turn/end` remains coarse `aborted`; undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. Disposal wins terminal classification, and work that ignores the signal must settle before quiescence. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns the lifecycle and race contract. Terminal continuation stops remain authoritative through turn close and durability flush. Within a step, exclusive calls form barriers; parallel-safe calls use a bounded rolling pool and are reclassified before start. Only dispatch/body overlaps. Policy, durable results, and result context remain model-ordered. Abort stops new calls, drains started results, then drains accepted batch context before the turn closes through the normal abort path. diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 1cfd913b77..dae3cf0efc 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -621,19 +621,43 @@ async function runStep( // Seed the first request from agent options and later requests from the logged header; // detach and freeze so listeners must return an attributable replacement. - const seedConfig: LlmCallConfig = deepFreeze(structuredClone(transmission.loggedHeader - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- loggedHeader ⟹ a snapshot is in the log - ? session.requestHeader()!.config - : { provider: options.provider ?? '', model: options.model ?? '' })) + const loggedConfig = session.requestHeader()?.config + const initialProvider = options.provider ?? '' + const initialModel = options.model ?? '' + const initialConfig: LlmCallConfig = { + provider: initialProvider, + model: initialModel, + ...loggedConfig?.provider === initialProvider + && loggedConfig.model === initialModel + && loggedConfig.reasoningEffort !== undefined + ? { reasoningEffort: loggedConfig.reasoningEffort } + : {}, + } + const seedConfig: LlmCallConfig = deepFreeze(structuredClone( + transmission.loggedHeader + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- loggedHeader ⟹ a snapshot is in the log + ? session.requestHeader()!.config + : initialConfig, + )) // Listener replacements are recorded in the request header before dispatch. - const config = await events.waterfall( + const proposedConfig = await events.waterfall( 'agent/request', turn, step, seedConfig, signal, () => Promise.resolve(seedConfig), ) interruptionCheckpoint(signal) - if (!config.provider || !config.model) { + if (!proposedConfig.provider || !proposedConfig.model) { throw new Error(`agent "${agent.id}" has no provider/model: set AgentOptions.provider and AgentOptions.model or supply both via the agent/request waterfall`) } + let config: LlmCallConfig + try { + config = await ctx.llm.resolveCallConfig(proposedConfig) + } catch (error: unknown) { + // A waterfall listener may own and short-circuit a route with no adapter. + // Terminal dispatch still raises NO_ADAPTER when no listener handles it. + if (!(error instanceof LlmError) || error.code !== 'NO_ADAPTER') throw error + config = proposedConfig + } + interruptionCheckpoint(signal) // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- runTurn composes the prefix before every runStep call const sessionPrefix = transmission.sessionPrefix! @@ -651,6 +675,9 @@ async function runStep( const request: GenerateOptions = markAgentLoopRequest(deepFreeze({ provider: header.config.provider, model: header.config.model, + ...header.config.reasoningEffort !== undefined + ? { reasoningEffort: header.config.reasoningEffort } + : {}, messages: [...header.messagePrefix ?? [], ...boundaryMessages], ...header.system !== undefined ? { system: header.system } : {}, ...header.tools !== undefined ? { tools: header.tools } : {}, diff --git a/packages/core/agent-loop/tests/mock-adapter.ts b/packages/core/agent-loop/tests/mock-adapter.ts index 4aaff72415..8b47a9880d 100644 --- a/packages/core/agent-loop/tests/mock-adapter.ts +++ b/packages/core/agent-loop/tests/mock-adapter.ts @@ -1,4 +1,4 @@ -import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, LlmModelReasoningInfo, StreamChunk } from '@deepseek-ai/dsh-llm' import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' /** Helpers to write scripted responses tersely. */ @@ -64,10 +64,20 @@ export function toolCallResponse(rawCallId: string, name: string, args: object, export class MockAdapter extends LlmAdapter { requests: GenerateOptions[] = [] - constructor(private script: (StreamChunk[] | ((options: GenerateOptions) => StreamChunk[]) | 'hang')[]) { + constructor( + private script: (StreamChunk[] | ((options: GenerateOptions) => StreamChunk[]) | 'hang')[], + private readonly reasoning?: LlmModelReasoningInfo, + ) { super() } + override resolveModelReasoning( + _provider: string, + _model: string, + ): Promise { + return Promise.resolve(this.reasoning) + } + async * stream(options: GenerateOptions): AsyncIterable { this.requests.push(options) const entry = this.script.shift() diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index 63c59618e3..781ee908a7 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -7,7 +7,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' -import LlmService from '@deepseek-ai/dsh-llm' +import LlmService, { LlmError, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { GenerateOptions } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' @@ -104,6 +104,81 @@ describe('request stability across the loop', () => { expectPrefixExtension(adapter.requests[0]!, adapter.requests[1]!) }) + it('logs adapter defaults, supports per-turn effort changes, and restores the effective value', async () => { + const reasoning = { + efforts: [ + { id: ReasoningEffortId('high'), name: 'High' }, + { id: ReasoningEffortId('max'), name: 'Max' }, + ], + defaultEffort: ReasoningEffortId('high'), + } + const adapter = new MockAdapter([textResponse('one'), textResponse('two')], reasoning) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('effort'), { provider: 'mock', model: 'mock' }) + ctx.on('agent/request', async (_agent, turn, _step, _config, _signal, next) => { + const config = await next() + return turn === 2 ? { ...config, reasoningEffort: ReasoningEffortId('max') } : config + }) + + send(agent, 'first') + await waitForIdle(ctx, agent) + send(agent, 'second') + await waitForIdle(ctx, agent) + + expect(adapter.requests.map(request => request.reasoningEffort)).toEqual([ + ReasoningEffortId('high'), + ReasoningEffortId('max'), + ]) + const headers = agent.session.events.filter(event => event.type === 'request/header') + expect(headers.map(event => event.data.header.config.reasoningEffort)).toEqual([ + ReasoningEffortId('high'), + ReasoningEffortId('max'), + ]) + expect(headers.map(event => event.data.reason)).toEqual(['initial', 'change']) + + const resumedAdapter = new MockAdapter([textResponse('three')], reasoning) + const resumedCtx = await harness(resumedAdapter) + const resumedHandle = await resumedCtx.agents.create({ + sessionId: SessionId('effort-resumed'), + seed: structuredClone(agent.session.events), + agentOptions: { provider: 'mock', model: 'mock' }, + }) + send(resumedHandle.agent, 'third') + await waitForIdle(resumedCtx, resumedHandle.agent) + + expect(resumedAdapter.requests[0]?.reasoningEffort).toBe(ReasoningEffortId('max')) + const resumedHeaders = resumedHandle.agent.session.events.filter(event => event.type === 'request/header') + expect(resumedHeaders.at(-1)?.data.header.config.reasoningEffort).toBe(ReasoningEffortId('max')) + expect(resumedHeaders.at(-1)?.data.reason).toBe('resume') + }) + + it.each(['plain error', 'LLM error'] as const)( + 'does not swallow a %s from reasoning resolution', + async (kind) => { + const failure = kind === 'plain error' + ? new Error('reasoning metadata failed') + : new LlmError('unsupported effort', 'UNSUPPORTED_REASONING_EFFORT') + const adapter = new class extends MockAdapter { + override resolveModelReasoning(): Promise { + return Promise.reject(failure) + } + }([]) + const ctx = await harness(adapter) + const errors: Error[] = [] + ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error)) + const agent = ctx.agentLoop.create(SessionId(`reasoning-${kind}`), { + provider: 'mock', + model: 'mock', + }) + + send(agent, 'go') + await waitForIdle(ctx, agent) + + expect(errors).toContain(failure) + expect(adapter.requests).toHaveLength(0) + }, + ) + it('a compaction replace rewrites the resend, and the log explains it', async () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) @@ -301,6 +376,7 @@ describe('request stability across the loop', () => { const firstChunk = events.find(e => e.type === 'assistant/chunk' && e.seq > stepStart.seq)! const header = foldRequestHeader(events.slice(0, firstChunk.seq))! expect(request.model).toBe(header.config.model) + expect(request.reasoningEffort).toBe(header.config.reasoningEffort) expect(request.system).toEqual(header.system) expect(structuredClone(request.tools ?? [])).toEqual(structuredClone(header.tools ?? [])) expect(request.temperature).toBe(header.config.temperature) diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 9c5831bb18..4f287b1517 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -181,6 +181,11 @@ function assertCurrentLlmShape(event: Record, index: number): v const header = record['header'] const config = typeof header === 'object' && header !== null ? (header as Record)['config'] : undefined if (!hasProviderModel(config)) throw new Error(`seed request/header at index ${index} lacks provider/model`) + const reasoningEffort = (config as Record)['reasoningEffort'] + if (reasoningEffort !== undefined + && (typeof reasoningEffort !== 'string' || reasoningEffort.length === 0)) { + throw new Error(`seed request/header at index ${index} has an invalid reasoningEffort`) + } } if (event['type'] === 'assistant/message' && !hasProviderModel(record['provenance'])) { throw new Error(`seed assistant/message at index ${index} lacks provider/model provenance`) diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 8b3a1e8cb6..4bc1c7eb04 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -156,7 +156,7 @@ export interface TodoItem { * canonical empty optional fields are absent. */ export interface EpochHeader { - /** The conversation's call configuration (provider, model, and sampling scalars). */ + /** The conversation's call configuration (provider, model, reasoning effort, and sampling scalars). */ config: LlmCallConfig /** Rendered system prompt text; absent for a system-less request. */ system?: string diff --git a/packages/core/session/tests/request-header.spec.ts b/packages/core/session/tests/request-header.spec.ts index b185cf6e56..e2fd4cc781 100644 --- a/packages/core/session/tests/request-header.spec.ts +++ b/packages/core/session/tests/request-header.spec.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest' import { Session, SessionId, canonicalHeader, foldRequestHeader, headerEquals } from '@deepseek-ai/dsh-session' import type { EpochHeader, SessionEvent } from '@deepseek-ai/dsh-session' import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm' +import { ReasoningEffortId } from '@deepseek-ai/dsh-llm' const CONFIG = { provider: 'mock', model: 'm' } @@ -29,6 +30,10 @@ describe('headerEquals', () => { it('compares every canonical field and preserves tool order', () => { expect(headerEquals(base, structuredClone(base))).toBe(true) expect(headerEquals(base, { ...base, config: { provider: 'mock', model: 'other' } })).toBe(false) + expect(headerEquals(base, { + ...base, + config: { ...base.config, reasoningEffort: ReasoningEffortId('high') }, + })).toBe(false) expect(headerEquals(base, { ...base, system: 'other' })).toBe(false) expect(headerEquals(base, { ...base, messagePrefix: [msg('other')] })).toBe(false) expect(headerEquals(base, { ...base, tools: [] })).toBe(false) diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index d880153dd3..880a5df42f 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest' import { Context } from 'cordis' -import { CallId } from '@deepseek-ai/dsh-llm' +import { CallId, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import SessionStore, { displayPromptContent, findLastMessageTurnEnd, @@ -118,6 +118,11 @@ describe('Session', () => { }) it('renders context and steering messages as plain user content', () => { + expect(displayPromptContent({ + content: [{ type: 'text', text: 'plain prompt' }], + source: { kind: 'user' }, + })).toEqual([{ type: 'text', text: 'plain prompt' }]) + const session = new Session(SessionId('s2')) session.append('context/message', { content: [{ type: 'text', text: 'file changed: a.ts' }], @@ -228,6 +233,35 @@ describe('Session', () => { .toEqual([unrelatedPrimitiveData]) }) + it('round-trips a non-empty reasoning effort and rejects invalid durable values', () => { + const valid = { + type: 'request/header', + seq: 0, + time: 1, + data: { + header: { + config: { + provider: 'mock', + model: 'model', + reasoningEffort: ReasoningEffortId('adapter-owned'), + }, + }, + reason: 'initial', + }, + } as const + expect(new Session(SessionId('reasoning-effort'), [valid]).events[0]) + .toEqual(valid) + + for (const reasoningEffort of ['', 1]) { + const invalid = structuredClone(valid) as unknown as SessionEvent + if (invalid.type !== 'request/header') throw new Error('test fixture must be a request header') + const config = invalid.data.header.config as unknown as Record + config.reasoningEffort = reasoningEffort + expect(() => new Session(SessionId('invalid-reasoning-effort'), [invalid])) + .toThrow('seed request/header at index 0 has an invalid reasoningEffort') + } + }) + it('isolates the log from mutation through a derived message (append-only contract)', () => { const session = new Session(SessionId('s4')) session.append('user/message', { content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 5a9a1bfdbc..13e889847f 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -15,7 +15,7 @@ The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire apiKey: !!js process.env.DEEPSEEK_API_KEY # or rely on the env fallback baseURL: !!js process.env.DEEPSEEK_BASE_URL # default: https://api.deepseek.com thinking: enabled # optional; provider default is enabled - reasoningEffort: high # optional; high | max — omitted ⇒ not sent + reasoningEffort: high # optional; high | max — omitted ⇒ high streamIdleTimeoutMs: 300000 # optional; positive finite Node timer delay; five-minute default defaultContextWindow: 256000 # optional positive-integer fallback for models without an exact value models: # optional; defaults to V4 Flash and V4 Pro @@ -30,9 +30,9 @@ The plugin registers the single provider route `deepseek`. A request selects it `contextWindow` is optional per configured model and is not exposed through the advisory catalog. `ctx.llm.resolveModelContext('deepseek', model)` returns an exact model value first, then `defaultContextWindow` for an entry without capacity or an unlisted pass-through id. When neither value exists it returns `undefined` without invalidating routing. Pressure-sensitive plugins therefore get deployment-owned capacity without treating the model selector as authoritative. Registering another adapter for `deepseek` throws `LlmError('DUPLICATE_ADAPTER')`. -`reasoningEffort` is **omitted by default** — when unset, the `reasoning_effort` wire field is not sent and the server applies its own default for the model. The only accepted values are `high` and `max` (DeepSeek's official effort levels). It is meaningful only with thinking enabled (the provider default). +`ctx.llm.resolveModelReasoning('deepseek', model)` returns the ordered `high` and `max` efforts for every pass-through model while thinking is enabled. `reasoningEffort` selects the deployment default and falls back to `high` when omitted. `agent/request` can replace it on each conversation step; the resolved value is logged in `request/header` and serialized as the official top-level `reasoning_effort` field. An unsupported value fails with `UNSUPPORTED_REASONING_EFFORT` before network I/O. -`thinking`/`reasoningEffort` are adapter-level request defaults serialized as the official top-level `thinking: {type}` / `reasoning_effort` wire fields. They live in adapter config (not `GenerateOptions`) to keep the core vocabulary provider-neutral. A request with `GenerateOptions.purpose: 'session-title'` forces thinking disabled and omits `reasoning_effort`, reserving its bounded output for visible title text without changing conversation or compaction defaults. +`thinking: disabled` removes the reasoning capability and omits `reasoning_effort`; combining it with a configured default fails plugin loading, and a per-request effort fails as unsupported. A request with `GenerateOptions.purpose: 'session-title'` also forces thinking disabled and omits the already-resolved effort, reserving its bounded output for visible title text without changing conversation or compaction defaults. `streamIdleTimeoutMs` bounds each outstanding provider read, including the initial `fetch`, without counting time the consumer spends between chunks. One stable abort signal reaches the request and body reader for the whole call; expiry stops the transport and throws `LlmError('TIMEOUT')`, while an earlier caller abort throws `LlmError('ABORTED')`. The adapter makes exactly one provider request per `stream()` call; agent-level retry is a separate plugin policy. @@ -79,7 +79,7 @@ Reasoning, text, and raw-string tool arguments are translated into harness chunk #### Token effect -Generated tokens follow provider thinking and effort settings plus the request's `maxTokens`; only loop-retained blocks affect later input. +Generated tokens follow the request's logged reasoning effort and `maxTokens`; only loop-retained blocks affect later input. #### KV Cache effect diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 64faa3b725..d8ca534c89 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -5,11 +5,12 @@ * @module dsh-llm-deepseek/adapter */ -import { attributionHeaders, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmAdapter, LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm' +import { attributionHeaders, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmAdapter, LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, LlmModelContext, LlmModelInfo, + LlmModelReasoningInfo, LlmProviderInfo, StreamChunk, } from '@deepseek-ai/dsh-llm' @@ -51,6 +52,12 @@ export interface DeepSeekAdapterOptions { /** Default maximum idle interval while an adapter stream read is outstanding. */ export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000 const STREAM_IDLE_TIMEOUT_CODE = 'LLM_STREAM_IDLE_TIMEOUT' +const HIGH_REASONING_EFFORT = ReasoningEffortId('high') +const MAX_REASONING_EFFORT = ReasoningEffortId('max') +const REASONING_EFFORTS = [ + { id: HIGH_REASONING_EFFORT, name: 'High' }, + { id: MAX_REASONING_EFFORT, name: 'Max' }, +] as const function providerRetryAfterMs(value: string | null): number | undefined { if (value === null) return undefined @@ -98,6 +105,9 @@ export class DeepSeekAdapter extends LlmAdapter { constructor(private readonly options: DeepSeekAdapterOptions) { super() + if (options.defaults?.thinking === 'disabled' && options.defaults.reasoningEffort !== undefined) { + throw new Error('llm-deepseek: reasoningEffort cannot be configured when thinking is disabled') + } if (options.defaultContextWindow !== undefined && (!Number.isInteger(options.defaultContextWindow) || options.defaultContextWindow <= 0)) { throw new Error('llm-deepseek: defaultContextWindow must be a positive integer') @@ -134,6 +144,19 @@ export class DeepSeekAdapter extends LlmAdapter { return Promise.resolve(contextWindow === undefined ? undefined : { contextWindow }) } + override resolveModelReasoning( + _provider: string, + _model: string, + ): Promise { + if (this.options.defaults?.thinking === 'disabled') return Promise.resolve(undefined) + return Promise.resolve({ + efforts: REASONING_EFFORTS, + defaultEffort: this.options.defaults?.reasoningEffort === 'max' + ? MAX_REASONING_EFFORT + : HIGH_REASONING_EFFORT, + }) + } + async * stream(options: GenerateOptions): AsyncIterable { const consumer = new AbortController() const upstream = options.signal === undefined diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index 66828fc954..1dad768440 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -28,8 +28,9 @@ const DEFAULT_MODELS: DeepSeekCatalogModel[] = [ /** * Plugin config, validated by the same-named schemastery schema. Every field * is optional in yml: credentials/endpoint fall back to the environment (a - * missing API key fails plugin load, not the first call), and omitted - * thinking fields send nothing on the wire, so the provider default applies. + * missing API key fails plugin load, not the first call), omitted thinking + * mode uses the provider default, and omitted reasoning effort resolves to + * `high`. */ export interface Config { /** API key; falls back to $DEEPSEEK_API_KEY. Required one way or the other. */ @@ -38,7 +39,7 @@ export interface Config { baseURL?: string /** Thinking-mode default for every request (provider default: enabled). */ thinking?: 'enabled' | 'disabled' - /** Thinking effort (only meaningful with thinking enabled). */ + /** Default thinking effort when thinking is enabled (default `high`). */ reasoningEffort?: 'high' | 'max' /** Positive context capacity used when the selected model has no exact value. */ defaultContextWindow?: number @@ -94,6 +95,9 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee } export function apply(ctx: Context, config: Config): void { + if (config.thinking === 'disabled' && config.reasoningEffort !== undefined) { + throw new Error('llm-deepseek: reasoningEffort cannot be configured when thinking is disabled') + } const apiKey = config.apiKey ?? process.env.DEEPSEEK_API_KEY if (apiKey === undefined || apiKey.length === 0) { throw new Error('llm-deepseek: an API key is required (Config.apiKey or $DEEPSEEK_API_KEY)') diff --git a/packages/llm/llm-deepseek/src/serialize.ts b/packages/llm/llm-deepseek/src/serialize.ts index bf9515c942..70d51c8549 100644 --- a/packages/llm/llm-deepseek/src/serialize.ts +++ b/packages/llm/llm-deepseek/src/serialize.ts @@ -6,6 +6,7 @@ * @module dsh-llm-deepseek/serialize */ +import { LlmError } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' import type { WireMessage, WireRequest, WireTool } from './types.ts' @@ -15,6 +16,17 @@ export interface RequestDefaults { reasoningEffort?: 'high' | 'max' | undefined } +/** Validate the adapter-owned effort before assigning its narrower wire type. */ +function reasoningEffort(options: GenerateOptions): 'high' | 'max' | undefined { + const effort = options.reasoningEffort + if (effort === undefined) return undefined + if (effort === 'high' || effort === 'max') return effort as 'high' | 'max' + throw new LlmError( + `DeepSeek does not support reasoning effort "${effort}"`, + 'UNSUPPORTED_REASONING_EFFORT', + ) +} + /** Join the text blocks of a message (used for user/tool-result content). */ function flattenText(blocks: ContentBlock[]): string { return blocks @@ -121,7 +133,9 @@ export function serializeRequest(options: GenerateOptions, defaults: RequestDefa // A short title budget must produce visible text; conversation and // compaction calls continue to inherit the adapter's thinking defaults. const thinking = options.purpose === 'session-title' ? 'disabled' : defaults.thinking - const reasoningEffort = options.purpose === 'session-title' ? undefined : defaults.reasoningEffort + const resolvedReasoningEffort = options.purpose === 'session-title' + ? undefined + : reasoningEffort(options) return { model: options.model, @@ -129,7 +143,7 @@ export function serializeRequest(options: GenerateOptions, defaults: RequestDefa stream: true, stream_options: { include_usage: true }, ...thinking !== undefined ? { thinking: { type: thinking } } : {}, - ...reasoningEffort !== undefined ? { reasoning_effort: reasoningEffort } : {}, + ...resolvedReasoningEffort !== undefined ? { reasoning_effort: resolvedReasoningEffort } : {}, ...tools !== undefined && tools.length > 0 ? { tools } : {}, ...options.temperature !== undefined ? { temperature: options.temperature } : {}, ...options.maxTokens !== undefined ? { max_tokens: options.maxTokens } : {}, diff --git a/packages/llm/llm-deepseek/tests/adapter.e2e.ts b/packages/llm/llm-deepseek/tests/adapter.e2e.ts index e02476eaef..8897d500d5 100644 --- a/packages/llm/llm-deepseek/tests/adapter.e2e.ts +++ b/packages/llm/llm-deepseek/tests/adapter.e2e.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' -import LlmService, { CallId } from '@deepseek-ai/dsh-llm' +import LlmService, { CallId, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import type { Config } from '@deepseek-ai/dsh-llm-deepseek' @@ -80,11 +80,12 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () it.each(['high', 'max'] as const)( 'pro + thinking enabled (effort %s): tool-call round trip with reasoning passback', async (effort) => { - const ctx = await harness(PRO, { thinking: 'enabled', reasoningEffort: effort }) + const ctx = await harness(PRO, { thinking: 'enabled' }) // Turn 1: the model must call the tool (and think before it). const first = await assemble(ctx,{ model: PRO, + reasoningEffort: ReasoningEffortId(effort), messages: ask('What is the weather in Paris right now? Use the get_weather tool.'), tools: [weatherTool], maxTokens: 2000, @@ -99,6 +100,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () // block in history (the official thinking+tools passback rule). const second = await assemble(ctx,{ model: PRO, + reasoningEffort: ReasoningEffortId(effort), messages: [ ...ask('What is the weather in Paris right now? Use the get_weather tool.'), { role: 'assistant', content: first.message.content }, diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 145017ea3d..7d3d8f9f08 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -8,6 +8,7 @@ import LlmService, { LlmError, ProviderRequestId, QUOTA_EXCEEDED_CODE, + ReasoningEffortId, userAgent, } from '@deepseek-ai/dsh-llm' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' @@ -120,6 +121,7 @@ describe('DeepSeekAdapter against a mock server', () => { // The wire request carried the auth header contents we configured. expect(server.requests[0]).toMatchObject({ model: 'deepseek-v4-flash', + reasoning_effort: 'high', stream: true, stream_options: { include_usage: true }, }) @@ -173,9 +175,35 @@ describe('DeepSeekAdapter against a mock server', () => { expect(server.headers[0]?.['x-deepseek-harness-compact']).toBe('1') }) - it('forwards thinking config onto the wire', async () => { + it('forwards the configured reasoning default and a dynamic request override', async () => { + const server = await mockServer([ + { kind: 'sse', events: textEvents }, + { kind: 'sse', events: textEvents }, + ]) + const ctx = await harness(server.url, { thinking: 'enabled', reasoningEffort: 'max' }) + + await assemble(ctx,{ + model: 'deepseek-v4-flash', + messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }], + }) + await assemble(ctx,{ + model: 'deepseek-v4-flash', + reasoningEffort: ReasoningEffortId('high'), + messages: [{ role: 'user', content: [{ type: 'text', text: 'hi again' }] }], + }) + expect(server.requests[0]).toMatchObject({ + thinking: { type: 'enabled' }, + reasoning_effort: 'max', + }) + expect(server.requests[1]).toMatchObject({ + thinking: { type: 'enabled' }, + reasoning_effort: 'high', + }) + }) + + it('omits reasoning capability and effort when thinking is disabled', async () => { const server = await mockServer([{ kind: 'sse', events: textEvents }]) - const ctx = await harness(server.url, { thinking: 'disabled', reasoningEffort: 'high' }) + const ctx = await harness(server.url, { thinking: 'disabled' }) await assemble(ctx,{ model: 'deepseek-v4-flash', @@ -183,8 +211,22 @@ describe('DeepSeekAdapter against a mock server', () => { }) expect(server.requests[0]).toMatchObject({ thinking: { type: 'disabled' }, - reasoning_effort: 'high', }) + expect(server.requests[0]).not.toHaveProperty('reasoning_effort') + await expect(ctx.llm.resolveModelReasoning('deepseek', 'deepseek-v4-flash')) + .resolves.toBeUndefined() + }) + + it('rejects a per-request effort before I/O when thinking is disabled', async () => { + const server = await mockServer([]) + const ctx = await harness(server.url, { thinking: 'disabled' }) + + await expect(assemble(ctx, { + model: 'deepseek-v4-flash', + reasoningEffort: ReasoningEffortId('high'), + messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }], + })).rejects.toMatchObject({ code: 'UNSUPPORTED_REASONING_EFFORT' }) + expect(server.requests).toHaveLength(0) }) it.each([ @@ -533,6 +575,34 @@ describe('plugin registration and config', () => { ]) await expect(ctx.llm.resolveModelContext('deepseek', 'deepseek-v4-flash')) .resolves.toEqual({ contextWindow: 128_000 }) + await expect(ctx.llm.resolveModelReasoning('deepseek', 'deepseek-v4-flash')) + .resolves.toEqual({ + efforts: [ + { id: ReasoningEffortId('high'), name: 'High' }, + { id: ReasoningEffortId('max'), name: 'Max' }, + ], + defaultEffort: ReasoningEffortId('high'), + }) + }) + + it('rejects a configured reasoning effort when thinking is disabled', async () => { + const ctx = new Context() + await ctx.plugin(LlmService) + await expect(ctx.plugin(LlmDeepSeek, { + apiKey: 'k', + baseURL: 'http://127.0.0.1:1', + thinking: 'disabled', + reasoningEffort: 'high', + })).rejects.toThrow(/reasoningEffort cannot be configured/) + expect(ctx.llm.listProviders()).toEqual([]) + }) + + it('rejects a disabled-thinking effort at the direct constructor boundary', () => { + expect(() => new DeepSeekAdapter({ + apiKey: 'k', + baseURL: 'http://127.0.0.1:1', + defaults: { thinking: 'disabled', reasoningEffort: 'high' }, + })).toThrow(/reasoningEffort cannot be configured/) }) it('uses the default model catalog when apply is called directly', async () => { diff --git a/packages/llm/llm-deepseek/tests/serialize.spec.ts b/packages/llm/llm-deepseek/tests/serialize.spec.ts index 566c71b7f2..0f294a5e3a 100644 --- a/packages/llm/llm-deepseek/tests/serialize.spec.ts +++ b/packages/llm/llm-deepseek/tests/serialize.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { CallId } from '@deepseek-ai/dsh-llm' +import { CallId, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' import { serializeMessages, serializeRequest } from '../src/serialize.ts' @@ -174,15 +174,22 @@ describe('serializeRequest', () => { expect(wire.tools).toBeUndefined() }) - it('applies adapter defaults for thinking and effort', () => { - const wire = serializeRequest(request({ messages: history }), { thinking: 'enabled', reasoningEffort: 'max' }) + it('maps adapter-default thinking and the request reasoning effort', () => { + const wire = serializeRequest( + request({ messages: history, reasoningEffort: ReasoningEffortId('max') }), + { thinking: 'enabled', reasoningEffort: 'high' }, + ) expect(wire.thinking).toEqual({ type: 'enabled' }) expect(wire.reasoning_effort).toBe('max') }) it('disables thinking for session-title requests without changing adapter defaults', () => { const wire = serializeRequest( - request({ messages: history, purpose: 'session-title' }), + request({ + messages: history, + purpose: 'session-title', + reasoningEffort: ReasoningEffortId('max'), + }), { thinking: 'enabled', reasoningEffort: 'max' }, ) expect(wire.thinking).toEqual({ type: 'disabled' }) @@ -194,6 +201,13 @@ describe('serializeRequest', () => { expect(wire.thinking).toBeUndefined() expect(wire.reasoning_effort).toBeUndefined() }) + + it('rejects an effort outside the DeepSeek capability', () => { + expect(() => serializeRequest(request({ + messages: history, + reasoningEffort: ReasoningEffortId('medium'), + }))).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_REASONING_EFFORT' })) + }) }) describe('review fixes: assistant content shapes', () => { diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 8a6736f112..d32f06a9a1 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -30,6 +30,8 @@ Each provider name must exist in pi-ai's installed catalog and may appear only o The adapter exposes each configured provider's installed pi-ai models through `ctx.llm.listModels(provider)`. This is provider-neutral selector metadata derived from `getModels(provider)`; request-time resolution still performs the authoritative catalog lookup, so discovery does not create a second model registry. `ctx.llm.resolveModelContext(provider, model)` performs the same exact descriptor lookup and returns its context window, keeping capacity metadata on the route-owning adapter rather than a consuming plugin. +`ctx.llm.resolveModelReasoning(provider, model)` uses pi-ai's `getSupportedThinkingLevels(model)` and returns that model's ordered levels after filtering the separate `off` control. The Harness exposes the canonical pi-ai level as an opaque ID; provider/model wire spellings remain inside pi-ai's `thinkingLevelMap`. A non-reasoning model returns `undefined`. The profile `reasoning` value is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and any explicit value absent from the exact model capability fails with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. + Supported profile fields are `provider`, `apiKey`, `baseURL`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, and `streamIdleTimeoutMs`. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name. The adapter forces pi-ai's SDK `maxRetries` to zero so one `stream()` call makes one provider request. The removed profile fields `maxRetries` and `maxRetryDelayMs` fail load instead of silently multiplying or hiding the separately composed agent-level retry budget. Idle expiry aborts the SDK's stable request signal and surfaces `TIMEOUT`; an earlier caller abort remains `ABORTED`. @@ -47,6 +49,7 @@ If a listener rewrites assembled assistant content, the loop drops replay state - pi-ai tool-call arguments are parsed objects; the harness stores raw JSON strings. The adapter parses input and re-stringifies output. - pi-ai reports failures as in-stream error events; these map to `finish {kind:'error'|'aborted', failure}` chunks. Provider-specific error text distinguishes terminal `QUOTA` from transient `RATE_LIMIT`, while text and usage signals evaluated against the resolved model's context window normalize overflow to `CONTEXT_WINDOW_EXCEEDED`. - pi-ai folds reasoning tokens into output usage; there is no separate reasoning count to map. +- pi-ai may internally support an `off` thinking level, but the Harness reasoning-effort capability deliberately excludes mode changes. - `GenerateOptions.stop` is rejected with `UNSUPPORTED_OPTION` because pi-ai's common streaming surface cannot guarantee it across providers. ## App attribution diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index ed0fb9fae4..1dde9d9dc7 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -7,13 +7,27 @@ import { streamSimple } from '@earendil-works/pi-ai/compat' import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all' import type { BuiltinProvider } from '@earendil-works/pi-ai/providers/all' +import { getSupportedThinkingLevels } from '@earendil-works/pi-ai' import type { Api, Model, SimpleStreamOptions, + ThinkingLevel, } from '@earendil-works/pi-ai' -import { attributionHeaders, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' -import type { GenerateOptions, LlmModelContext, LlmModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' +import { + attributionHeaders, + LlmAdapter, + LlmError, + ReasoningEffortId, +} from '@deepseek-ai/dsh-llm' +import type { + GenerateOptions, + LlmModelContext, + LlmModelInfo, + LlmModelReasoningInfo, + ReasoningEffortId as ReasoningEffortIdType, + StreamChunk, +} from '@deepseek-ai/dsh-llm' import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout' import { resolveProfiles } from './config.ts' import type { PiAiProviderProfile, ResolvedPiAiProviderProfile } from './config.ts' @@ -39,10 +53,13 @@ function resolveModel(profile: PiAiProviderProfile, modelId: string): Model } /** Copy profile stream knobs into pi-ai's common option vocabulary. */ -function profileOptions(profile: PiAiProviderProfile): SimpleStreamOptions { +function profileOptions( + profile: PiAiProviderProfile, + reasoning: ThinkingLevel | undefined, +): SimpleStreamOptions { return { ...profile.apiKey === undefined ? {} : { apiKey: profile.apiKey }, - ...profile.reasoning === undefined ? {} : { reasoning: profile.reasoning }, + ...reasoning === undefined ? {} : { reasoning }, ...profile.thinkingBudgets === undefined ? {} : { thinkingBudgets: profile.thinkingBudgets }, ...profile.cacheRetention === undefined ? {} : { cacheRetention: profile.cacheRetention }, ...profile.transport === undefined ? {} : { transport: profile.transport }, @@ -53,6 +70,25 @@ function profileOptions(profile: PiAiProviderProfile): SimpleStreamOptions { } } +/** Selectable pi-ai levels exclude the separate on/off control. */ +function supportedReasoningLevels(model: Model): ThinkingLevel[] { + return getSupportedThinkingLevels(model).filter((level): level is ThinkingLevel => level !== 'off') +} + +/** Validate an explicit Harness/profile effort without invoking pi-ai's clamp. */ +function resolveReasoningLevel( + model: Model, + effort: ReasoningEffortIdType | ThinkingLevel | undefined, +): ThinkingLevel | undefined { + if (effort === undefined) return undefined + const supported = supportedReasoningLevels(model) + if (supported.some(level => level === effort)) return effort as ThinkingLevel + throw new LlmError( + `pi-ai provider "${model.provider}" model "${model.id}" does not support reasoning effort "${effort}"`, + 'UNSUPPORTED_REASONING_EFFORT', + ) +} + /** Merge deployment headers while removing case-insensitive attribution collisions. */ function requestHeaders(headers: Readonly> | undefined): Record { const attribution = attributionHeaders() @@ -103,6 +139,37 @@ export class PiAiAdapter extends LlmAdapter { })) } + override resolveModelReasoning( + provider: string, + model: string, + ): Promise { + const profile = this.profiles.get(provider) + if (profile === undefined) { + return Promise.reject(new LlmError( + `pi-ai adapter does not own provider "${provider}"`, + 'NO_ADAPTER', + )) + } + return Promise.resolve().then(() => { + const resolvedModel = resolveModel(profile, model) + const levels = supportedReasoningLevels(resolvedModel) + if (levels.length === 0) { + resolveReasoningLevel(resolvedModel, profile.reasoning) + return undefined + } + const defaultLevel = resolveReasoningLevel(resolvedModel, profile.reasoning) + return { + efforts: levels.map(level => ({ + id: ReasoningEffortId(level), + name: `${level.charAt(0).toUpperCase()}${level.slice(1)}`, + })), + ...defaultLevel === undefined + ? {} + : { defaultEffort: ReasoningEffortId(defaultLevel) }, + } + }) + } + async * stream(options: GenerateOptions): AsyncIterable { if (options.stop !== undefined) { throw new LlmError('llm-pi-ai does not support GenerateOptions.stop', 'UNSUPPORTED_OPTION') @@ -112,6 +179,10 @@ export class PiAiAdapter extends LlmAdapter { throw new LlmError(`pi-ai adapter does not own provider "${options.provider}"`, 'NO_ADAPTER') } const model = resolveModel(profile, options.model) + const reasoning = resolveReasoningLevel( + model, + options.reasoningEffort ?? profile.reasoning, + ) const consumer = new AbortController() const upstream = options.signal === undefined @@ -122,7 +193,7 @@ export class PiAiAdapter extends LlmAdapter { try { const events = streamSimple(model, toPiContext(options), { - ...profileOptions(profile), + ...profileOptions(profile, reasoning), ...options.temperature === undefined ? {} : { temperature: options.temperature }, ...options.maxTokens === undefined ? {} : { maxTokens: options.maxTokens }, ...options.sessionId === undefined ? {} : { sessionId: String(options.sessionId) }, diff --git a/packages/llm/llm-pi-ai/tests/adapter.e2e.ts b/packages/llm/llm-pi-ai/tests/adapter.e2e.ts index 77d2cc81dd..e91a951d85 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.e2e.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.e2e.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' -import LlmService, { CallId } from '@deepseek-ai/dsh-llm' +import LlmService, { CallId, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm' import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' import type { PiAiProviderProfile } from '@deepseek-ai/dsh-llm-pi-ai' @@ -9,7 +9,7 @@ import { assemble, type AssembledResult } from './assemble.ts' /** * Real-API e2e for the pi-ai-backed adapter: V4 Flash + V4 Pro with provider - * defaults and representative high/xhigh reasoning. Mirrors the native + * defaults and representative high/max reasoning. Mirrors the native * adapter's StreamChunk contract and exercises a replayed tool follow-up. * Key-gated. */ @@ -75,9 +75,10 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-pi-ai e2e (real API)', () => }) it.each([FLASH, PRO])('%s + reasoning high: reasoning blocks present', async (model) => { - const ctx = await harness(model, { reasoning: 'high' }) + const ctx = await harness(model) const result = await assemble(ctx,{ model, + reasoningEffort: ReasoningEffortId('high'), messages: ask('Which is larger, 9.11 or 9.8? Answer with just the number.'), maxTokens: 2000, }) @@ -86,11 +87,12 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-pi-ai e2e (real API)', () => expect(textOf(result)).toContain('9.8') }) - it('pro + reasoning xhigh (wire max): tool-call round trip', async () => { - const ctx = await harness(PRO, { reasoning: 'xhigh' }) + it('pro + reasoning max: tool-call round trip', async () => { + const ctx = await harness(PRO) const first = await assemble(ctx,{ model: PRO, + reasoningEffort: ReasoningEffortId('max'), messages: ask('What is the weather in Paris right now? Use the get_weather tool.'), tools: [weatherTool], maxTokens: 2000, @@ -103,6 +105,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-pi-ai e2e (real API)', () => const second = await assemble(ctx,{ model: PRO, + reasoningEffort: ReasoningEffortId('max'), messages: [ ...ask('What is the weather in Paris right now? Use the get_weather tool.'), first.message, diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index f37b07f624..0bf8d34da7 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -2,7 +2,7 @@ import { createServer } from 'node:http' import type { IncomingMessage, Server, ServerResponse } from 'node:http' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import LlmService, { CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, userAgent } from '@deepseek-ai/dsh-llm' +import LlmService, { CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, ReasoningEffortId, userAgent } from '@deepseek-ai/dsh-llm' import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' import { PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' @@ -124,7 +124,7 @@ describe('PiAiAdapter provider routing', () => { it('forwards common stream options and profile reasoning', async () => { const server = await mockServer([{ events: textEvents }]) const ctx = await harness(server.url, { - reasoning: 'xhigh', + reasoning: 'max', cacheRetention: 'none', transport: 'sse', timeoutMs: 5000, @@ -148,6 +148,25 @@ describe('PiAiAdapter provider routing', () => { }) }) + it('uses a dynamic request effort and rejects unsupported efforts before network I/O', async () => { + const server = await mockServer([{ events: textEvents }]) + const ctx = await harness(server.url, { reasoning: 'max' }) + + await assemble(ctx, { + model: 'deepseek-v4-flash', + reasoningEffort: ReasoningEffortId('high'), + messages: [], + }) + expect(server.requests[0]).toMatchObject({ reasoning_effort: 'high' }) + + await expect(assemble(ctx, { + model: 'deepseek-v4-flash', + reasoningEffort: ReasoningEffortId('xhigh'), + messages: [], + })).rejects.toMatchObject({ code: 'UNSUPPORTED_REASONING_EFFORT' }) + expect(server.requests).toHaveLength(1) + }) + it('preserves omitted profile options when constructing the adapter directly', async () => { const server = await mockServer([{ events: textEvents }]) const ctx = new Context() @@ -327,6 +346,51 @@ describe('provider profile lifecycle', () => { expect(typeof context?.contextWindow).toBe('number') }) + it('exposes model-specific reasoning levels without off or an invented provider default', async () => { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(LlmPiAi, { + providers: [{ provider: 'deepseek' }, { provider: 'openai' }], + }) + + await expect(ctx.llm.resolveModelReasoning('deepseek', 'deepseek-v4-flash')) + .resolves.toEqual({ + efforts: [ + { id: ReasoningEffortId('high'), name: 'High' }, + { id: ReasoningEffortId('max'), name: 'Max' }, + ], + }) + const extended = await ctx.llm.resolveModelReasoning('openai', 'gpt-5.6-sol') + expect(extended?.efforts.map(effort => effort.id)).toEqual([ + ReasoningEffortId('minimal'), + ReasoningEffortId('low'), + ReasoningEffortId('medium'), + ReasoningEffortId('high'), + ReasoningEffortId('xhigh'), + ReasoningEffortId('max'), + ]) + await expect(ctx.llm.resolveModelReasoning('openai', 'gpt-4.1')) + .resolves.toBeUndefined() + }) + + it('uses a supported profile reasoning value as the model default and rejects an unsupported one', async () => { + const supported = new Context() + await supported.plugin(LlmService) + await supported.plugin(LlmPiAi, { + providers: [{ provider: 'deepseek', reasoning: 'max' }], + }) + await expect(supported.llm.resolveModelReasoning('deepseek', 'deepseek-v4-flash')) + .resolves.toMatchObject({ defaultEffort: ReasoningEffortId('max') }) + + const unsupported = new Context() + await unsupported.plugin(LlmService) + await unsupported.plugin(LlmPiAi, { + providers: [{ provider: 'deepseek', reasoning: 'medium' }], + }) + await expect(unsupported.llm.resolveModelReasoning('deepseek', 'deepseek-v4-flash')) + .rejects.toMatchObject({ code: 'UNSUPPORTED_REASONING_EFFORT' }) + }) + it('accepts absent credentials for pi-ai ambient authentication', async () => { vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-key') const server = await mockServer([{ events: textEvents }]) @@ -378,6 +442,8 @@ describe('provider profile lifecycle', () => { await expect(adapter.listModels('anthropic')).rejects.toMatchObject({ code: 'NO_ADAPTER' }) await expect(adapter.resolveModelContext('anthropic', 'claude-sonnet-4')) .rejects.toMatchObject({ code: 'NO_ADAPTER' }) + await expect(adapter.resolveModelReasoning('anthropic', 'claude-sonnet-4')) + .rejects.toMatchObject({ code: 'NO_ADAPTER' }) await expect(adapter.resolveModelContext('openai', 'not-a-catalog-model')) .rejects.toMatchObject({ code: 'UNKNOWN_MODEL' }) await expect((async () => { diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index beac6d5e8e..d15ce7f0ac 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -12,6 +12,8 @@ An adapter registry plus a single streaming call surface, interceptable via a wa - `ctx.llm.listProviders(): LlmProviderInfo[]` Describe registered provider routes in registration order. - `ctx.llm.listModels(provider: string): Promise` Discover the models one registered provider currently advertises. - `ctx.llm.resolveModelContext(provider: string, model: string): Promise` Resolve authoritative context capacity for one exact route from its owning adapter. +- `ctx.llm.resolveModelReasoning(provider: string, model: string): Promise` Resolve ordered adapter-owned reasoning efforts and an optional deployment default for one exact route. +- `ctx.llm.resolveCallConfig(config: LlmCallConfig): Promise` Validate an explicit effort and materialize an adapter-configured default without clamping. - `ctx.llm.stream(options: GenerateOptions): AsyncIterable` Stream one model call as raw chunks (token-level deltas). Consumers assemble the chunks into blocks/messages with `BlockAssembler`. `LlmService` preserves errors from final adapter selection, synchronous dispatch, iterator construction, and iteration, and binds their provenance to the exact stream handle returned for that model call. `isLlmAdapterFailure(stream, value)` reports only errors from that call's final adapter boundary; `llmFailureOf(stream, value)` returns the adjacent immutable `LlmFailure`. Nested model calls, `llm/stream` middleware, and downstream consumer failures remain unclassified for the outer call. Classification never replaces or mutates the adapter's original coded `Error`. @@ -20,6 +22,8 @@ Provider and model metadata is a discovery surface, not a routing whitelist. `re Context capacity is a separate correctness query, not a catalog decoration or global LLM setting. `resolveModelContext()` asks the adapter that owns the exact provider/model route; an adapter can describe an unlisted dynamic model, and `undefined` means only that capacity is unavailable. Invalid returned capacity fails with `INVALID_MODEL_CONTEXT`. +Reasoning effort is also an exact-route capability, but its identifiers are opaque adapter-owned strings rather than a core enum. `resolveModelReasoning()` validates and detaches the ordered display metadata; `undefined` means the model has no selectable effort. `resolveCallConfig()` accepts only an exact advertised identifier, materializes `defaultEffort` when present, and otherwise preserves the provider default. Invalid capability metadata fails with `INVALID_MODEL_REASONING`; an unsupported explicit or configured effort fails with `UNSUPPORTED_REASONING_EFFORT` before provider I/O. + ### Events | Event | Mode | Purpose | @@ -28,7 +32,7 @@ Context capacity is a separate correctness query, not a catalog decoration or gl ### Extension points -- Subclass `LlmAdapter` and call `ctx.llm.registerAdapter(providers, adapter)` to add one or more provider routes. `GenerateOptions.provider` selects the adapter; `GenerateOptions.model` is adapter-owned and may be resolved dynamically. Override `providerInfo()` and asynchronous `listModels()` to expose selector metadata, and `resolveModelContext()` when exact capacity is known; the defaults use the route id as its name, advertise no models, and return no capacity. +- Subclass `LlmAdapter` and call `ctx.llm.registerAdapter(providers, adapter)` to add one or more provider routes. `GenerateOptions.provider` selects the adapter; `GenerateOptions.model` is adapter-owned and may be resolved dynamically. Override `providerInfo()` and asynchronous `listModels()` to expose selector metadata, `resolveModelContext()` when exact capacity is known, and `resolveModelReasoning()` when a model exposes selectable efforts; the defaults use the route id as its name, advertise no models, and return neither capacity nor reasoning metadata. - Wrap `llm/stream` via `ctx.on()` waterfall listeners for caching, logging, or routing. A wrapper that retries after emitting a chunk has no durable attempt boundary; shipped agent retry policy therefore uses `agent/request-error` instead. ### Content-block vocabulary (`types.ts`) @@ -39,7 +43,7 @@ Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta ### Call configuration (`call-config.ts`) -`LlmCallConfig` is the provider + model + sampling scalars of one conversation's requests (`provider`, `model`, `temperature`, `maxTokens`, `stop` — each mapping 1:1 onto the same-named `GenerateOptions` field). It is per-conversation state recorded in the session log as part of the request header (see the dsh-session `request/header` events), never a silently-adjustable per-call knob: the `agent/request` waterfall proposes a replacement and the loop logs a real change. `callConfigEquals(a, b)` is the field-wise real-change detector; `deepFreeze(value)` is the ownership helper the loop applies to every built request before dispatch (`llm/stream` listeners and adapters read, never rewrite). `markAgentLoopRequest()` gives that exact object process-local loop provenance, and `isAgentLoopRequest()` lets observers distinguish it from independently logged auxiliary calls that may also be frozen and session-associated. `GenerateOptions.purpose` classifies logged auxiliary compaction and session-title calls so adapters can apply purpose-specific transport policy without changing ordinary conversation requests. +`LlmCallConfig` is the provider, model, optional adapter-owned reasoning effort, and sampling scalars of one conversation's requests (`provider`, `model`, `reasoningEffort`, `temperature`, `maxTokens`, `stop` — each mapping 1:1 onto the same-named `GenerateOptions` field). It is per-conversation state recorded in the session log as part of the request header (see the dsh-session `request/header` events), never a silently-adjustable per-call knob: the `agent/request` waterfall proposes a replacement, `resolveCallConfig()` validates and defaults it, and the loop logs the effective value before dispatch. `callConfigEquals(a, b)` is the field-wise real-change detector; `deepFreeze(value)` is the ownership helper the loop applies to every built request before dispatch (`llm/stream` listeners and adapters read, never rewrite). `markAgentLoopRequest()` gives that exact object process-local loop provenance, and `isAgentLoopRequest()` lets observers distinguish it from independently logged auxiliary calls that may also be frozen and session-associated. `GenerateOptions.purpose` classifies logged auxiliary compaction and session-title calls so adapters can apply purpose-specific transport policy without changing ordinary conversation requests. ### App attribution (`attribution.ts`) @@ -61,7 +65,7 @@ Two adapters implement `LlmAdapter` on different internals: [`@deepseek-ai/dsh-l ## Model Experience -None, as this adapter registry forwards an already assembled request without adding or changing any model-bound text, schema, or message. +None, as the service adds no model-bound text, schema, or message; it only materializes and logs an adapter-configured reasoning effort. #### KV Cache effect diff --git a/packages/llm/llm/src/brand.ts b/packages/llm/llm/src/brand.ts index 259dc49bce..0c0190a325 100644 --- a/packages/llm/llm/src/brand.ts +++ b/packages/llm/llm/src/brand.ts @@ -38,3 +38,15 @@ export type ProviderRequestId = Branded<'ProviderRequestId'> export function ProviderRequestId(id: string): ProviderRequestId { return id as ProviderRequestId } + +/** Adapter-owned identifier for one model's selectable reasoning effort. */ +export type ReasoningEffortId = Branded<'ReasoningEffortId'> + +/** + * Brand an adapter-owned reasoning-effort identifier. + * @param id - the opaque identifier exposed by one model capability. + * @returns the same string, branded; no validation is performed. + */ +export function ReasoningEffortId(id: string): ReasoningEffortId { + return id as ReasoningEffortId +} diff --git a/packages/llm/llm/src/call-config.ts b/packages/llm/llm/src/call-config.ts index fd6ecf9df4..c5247af143 100644 --- a/packages/llm/llm/src/call-config.ts +++ b/packages/llm/llm/src/call-config.ts @@ -1,24 +1,27 @@ /** * Conversation call configuration and freeze utilities. Provider routing, - * model, and sampling values are request-header state that can affect cache - * reuse; request waterfalls replace them and the loop logs changed snapshots - * instead of allowing silent per-call drift. + * model, reasoning effort, and sampling values are request-header state that + * can affect cache reuse; request waterfalls replace them and the loop logs + * changed snapshots instead of allowing silent per-call drift. * @module dsh-llm/call-config */ import type { GenerateOptions } from './types.ts' +import type { ReasoningEffortId } from './brand.ts' /** Process-local identities of request objects assembled by dsh-agent-loop. */ const AGENT_LOOP_REQUESTS = new WeakSet() /** - * Provider + model + sampling scalars of one conversation's requests. Every field maps - * 1:1 onto the same-named `GenerateOptions` field; the loop builds requests - * from the logged header rather than accepting these per call. + * Provider, model, reasoning effort, and sampling scalars of one conversation's + * requests. Every field maps 1:1 onto the same-named `GenerateOptions` field; + * the loop builds requests from the logged header rather than accepting these + * per call. */ export interface LlmCallConfig { provider: string model: string + reasoningEffort?: ReasoningEffortId temperature?: number maxTokens?: number stop?: string[] @@ -33,7 +36,13 @@ export interface LlmCallConfig { * @returns whether every field (including the `stop` list, element-wise) matches. */ export function callConfigEquals(a: LlmCallConfig, b: LlmCallConfig): boolean { - if (a.provider !== b.provider || a.model !== b.model || a.temperature !== b.temperature || a.maxTokens !== b.maxTokens) return false + if ( + a.provider !== b.provider + || a.model !== b.model + || a.reasoningEffort !== b.reasoningEffort + || a.temperature !== b.temperature + || a.maxTokens !== b.maxTokens + ) return false if (a.stop === undefined || b.stop === undefined) return a.stop === b.stop return a.stop.length === b.stop.length && a.stop.every((s, i) => s === b.stop?.[i]) } diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 6765833e8c..e56c2d17a6 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -12,12 +12,14 @@ import type { LlmFailure, LlmModelContext, LlmModelInfo, + LlmModelReasoningInfo, LlmProviderInfo, Message, StreamChunk, } from './types.ts' import type { ProviderRequestId } from './brand.ts' -import { deepFreeze } from './call-config.ts' +import { callConfigEquals, deepFreeze } from './call-config.ts' +import type { LlmCallConfig } from './call-config.ts' import { HarnessError } from './error.ts' import { bindAdapterFailureScope, markLlmAdapterFailure } from './adapter-failure.ts' import type { AdapterFailureScope } from './adapter-failure.ts' @@ -144,6 +146,20 @@ export abstract class LlmAdapter { return Promise.resolve(undefined) } + /** + * Resolve selectable reasoning efforts for one exact model. Absence means + * the model has no selectable reasoning-effort capability. + * @param _provider - one provider route owned by this adapter. + * @param _model - exact model id passed to {@link GenerateOptions.model}. + * @returns adapter-owned effort metadata, or `undefined` when unsupported. + */ + resolveModelReasoning( + _provider: string, + _model: string, + ): Promise { + return Promise.resolve(undefined) + } + /** * Stream one model call as raw chunks. The only required method. * @param options - the fully-assembled request; implementations must honor `options.signal`. @@ -262,6 +278,90 @@ export class LlmService extends Service { return { contextWindow: context.contextWindow } } + /** + * Resolve selectable reasoning efforts from the adapter that owns one exact + * route. Metadata is validated and detached; an absent result means an + * effort selector is unsupported for that model. + * @param provider - registered provider route to inspect. + * @param model - exact model id passed to the adapter. + * @returns detached reasoning metadata, or `undefined` when unsupported. + */ + async resolveModelReasoning( + provider: string, + model: string, + ): Promise { + const reasoning = await this.registration(provider).adapter.resolveModelReasoning(provider, model) + if (reasoning === undefined) return undefined + if (reasoning.efforts.length === 0) { + throw new LlmError( + `adapter returned invalid reasoning metadata for provider "${provider}" model "${model}"`, + 'INVALID_MODEL_REASONING', + ) + } + const seen = new Set() + const efforts = reasoning.efforts.map((effort) => { + if ( + typeof effort.id !== 'string' + || effort.id.length === 0 + || typeof effort.name !== 'string' + || effort.name.length === 0 + || (effort.description !== undefined && typeof effort.description !== 'string') + || seen.has(effort.id) + ) { + throw new LlmError( + `adapter returned invalid or duplicate reasoning effort metadata for provider "${provider}" model "${model}"`, + 'INVALID_MODEL_REASONING', + ) + } + seen.add(effort.id) + return { + id: effort.id, + name: effort.name, + ...effort.description === undefined ? {} : { description: effort.description }, + } + }) + if (reasoning.defaultEffort !== undefined && !seen.has(reasoning.defaultEffort)) { + throw new LlmError( + `adapter returned an unknown default reasoning effort for provider "${provider}" model "${model}"`, + 'INVALID_MODEL_REASONING', + ) + } + return { + efforts, + ...reasoning.defaultEffort === undefined ? {} : { defaultEffort: reasoning.defaultEffort }, + } + } + + /** + * Validate a conversation call config against its exact model capability and + * materialize an adapter-configured default. Unsupported explicit efforts + * reject before provider I/O; no clamping or aliasing is performed. + * @param config - provider/model route and optional request controls. + * @returns a detached config only when a default must be materialized. + */ + async resolveCallConfig(config: LlmCallConfig): Promise { + const reasoning = await this.resolveModelReasoning(config.provider, config.model) + const requested = config.reasoningEffort + if (reasoning === undefined) { + if (requested !== undefined) { + throw new LlmError( + `provider "${config.provider}" model "${config.model}" does not support reasoning effort "${requested}"`, + 'UNSUPPORTED_REASONING_EFFORT', + ) + } + return config + } + const effective = requested ?? reasoning.defaultEffort + if (effective === undefined) return config + if (!reasoning.efforts.some(effort => effort.id === effective)) { + throw new LlmError( + `provider "${config.provider}" model "${config.model}" does not support reasoning effort "${effective}"`, + 'UNSUPPORTED_REASONING_EFFORT', + ) + } + return requested === effective ? config : { ...config, reasoningEffort: effective } + } + private registration(provider: string): { adapter: LlmAdapter; provider: LlmProviderInfo } { const registration = this.adapters.get(provider) if (!registration) throw new LlmError(`no adapter registered for provider "${provider}"`, 'NO_ADAPTER') @@ -298,8 +398,14 @@ export class LlmService extends Service { ): AsyncGenerator { let iterator: AsyncIterator try { - const adapter = this.registration(options.provider).adapter - const stream = adapter.stream(this.forAdapter(options, adapter)) + const resolvedConfig = await this.resolveCallConfig(options) + const resolvedOptions = callConfigEquals(options, resolvedConfig) + ? options + : Object.isFrozen(options) + ? deepFreeze({ ...options, ...resolvedConfig }) + : { ...options, ...resolvedConfig } + const adapter = this.registration(resolvedOptions.provider).adapter + const stream = adapter.stream(this.forAdapter(resolvedOptions, adapter)) iterator = stream[Symbol.asyncIterator]() } catch (error: unknown) { throw markLlmAdapterFailure(failures, error) diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index f9f97d532a..7882985288 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -5,7 +5,7 @@ */ import type { Branded } from '@deepseek-ai/dsh-brand' -import type { CallId, ProviderRequestId } from './brand.ts' +import type { CallId, ProviderRequestId, ReasoningEffortId } from './brand.ts' /** Serializable provider-boundary facts; policy decides whether they are retryable. */ export interface LlmFailure { @@ -161,6 +161,27 @@ export interface LlmModelContext { contextWindow: number } +/** Display metadata for one adapter-owned reasoning effort. */ +export interface LlmReasoningEffortInfo { + /** Opaque stable value accepted by {@link GenerateOptions.reasoningEffort}. */ + id: ReasoningEffortId + /** Human-readable effort name for selectors and diagnostics. */ + name: string + /** Optional user-facing distinction from otherwise similar efforts. */ + description?: string +} + +/** Selectable reasoning efforts for one exact provider/model route. */ +export interface LlmModelReasoningInfo { + /** Supported efforts in adapter-preferred display order. */ + efforts: readonly LlmReasoningEffortInfo[] + /** + * Adapter-configured default materialized into requests when callers omit + * an effort. Absence preserves the provider's own default. + */ + defaultEffort?: ReasoningEffortId +} + /** * Raw streaming protocol emitted by adapters. * Block indexes correlate interleaved deltas, and `block-end` carries the @@ -201,6 +222,8 @@ export interface GenerateOptions { /** Registered provider route selecting the adapter instance. */ provider: string model: string + /** Adapter-owned reasoning effort selected for this exact model. */ + reasoningEffort?: ReasoningEffortId /** * Ordered conversation messages, exactly as the provider sees them (after * the `system` slot). A loop-built request assembles them as diff --git a/packages/llm/llm/tests/call-config.spec.ts b/packages/llm/llm/tests/call-config.spec.ts index a5426e815f..2237d4639f 100644 --- a/packages/llm/llm/tests/call-config.spec.ts +++ b/packages/llm/llm/tests/call-config.spec.ts @@ -6,6 +6,7 @@ import { describe, expect, it } from 'vitest' import { callConfigEquals, deepFreeze, isAgentLoopRequest, markAgentLoopRequest } from '../src/call-config.ts' +import { ReasoningEffortId } from '../src/brand.ts' import type { GenerateOptions } from '../src/types.ts' describe('callConfigEquals', () => { @@ -14,6 +15,11 @@ describe('callConfigEquals', () => { expect(callConfigEquals(base, base)).toBe(true) expect(callConfigEquals(base, { provider: 'x', model: 'm' })).toBe(false) expect(callConfigEquals(base, { provider: 'p', model: 'x' })).toBe(false) + expect(callConfigEquals({ ...base, reasoningEffort: ReasoningEffortId('high') }, base)).toBe(false) + expect(callConfigEquals( + { ...base, reasoningEffort: ReasoningEffortId('high') }, + { ...base, reasoningEffort: ReasoningEffortId('high') }, + )).toBe(true) expect(callConfigEquals({ ...base, temperature: 0.5 }, base)).toBe(false) expect(callConfigEquals({ ...base, maxTokens: 1 }, { ...base, maxTokens: 2 })).toBe(false) expect(callConfigEquals({ ...base, stop: ['a'] }, base)).toBe(false) diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index 90be1ffcb0..59684ac8b8 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -11,9 +11,15 @@ import LlmService, { LlmError, llmFailureOf, ProviderRequestId, + ReasoningEffortId, StreamChunk, } from '@deepseek-ai/dsh-llm' -import type { LlmModelContext, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm' +import type { + LlmModelContext, + LlmModelInfo, + LlmModelReasoningInfo, + LlmProviderInfo, +} from '@deepseek-ai/dsh-llm' class ScriptedAdapter extends LlmAdapter { constructor(private script: StreamChunk[]) { @@ -49,6 +55,7 @@ class CatalogAdapter extends ScriptedAdapter { private readonly provider: LlmProviderInfo, private readonly models: readonly LlmModelInfo[], private readonly contexts: Readonly> = {}, + private readonly reasoning: Readonly> = {}, ) { super(SCRIPT) } @@ -67,6 +74,13 @@ class CatalogAdapter extends ScriptedAdapter { ): Promise { return Promise.resolve(this.contexts[model]) } + + override resolveModelReasoning( + _provider: string, + model: string, + ): Promise { + return Promise.resolve(this.reasoning[model]) + } } const SCRIPT: StreamChunk[] = [ @@ -674,6 +688,117 @@ describe('LlmService', () => { await expect(ctx.llm.resolveModelContext('route', 'other')).resolves.toBeUndefined() }) + it('resolves detached adapter-owned reasoning metadata and materializes its default', async () => { + const ctx = new Context() + await ctx.plugin(LlmService) + const source = { + efforts: [ + { id: ReasoningEffortId('standard'), name: 'Standard' }, + { id: ReasoningEffortId('ultra'), name: 'Ultra', description: 'Largest budget' }, + ], + defaultEffort: ReasoningEffortId('standard'), + } + ctx.llm.registerAdapter(['route'], new CatalogAdapter( + { id: 'route', name: 'Route' }, + [], + {}, + { model: source }, + )) + + const resolved = await ctx.llm.resolveModelReasoning('route', 'model') + expect(resolved).toEqual(source) + source.efforts[0]!.name = 'mutated' + expect(resolved?.efforts[0]?.name).toBe('Standard') + await expect(ctx.llm.resolveCallConfig({ provider: 'route', model: 'model' })).resolves.toEqual({ + provider: 'route', + model: 'model', + reasoningEffort: ReasoningEffortId('standard'), + }) + const explicit = { provider: 'route', model: 'model', reasoningEffort: ReasoningEffortId('ultra') } + await expect(ctx.llm.resolveCallConfig(explicit)).resolves.toBe(explicit) + }) + + it.each([ + [{ efforts: [] }, 'empty effort list'], + [{ efforts: [{ id: '', name: 'Empty' }] }, 'empty id'], + [{ efforts: [{ id: 'valid', name: '' }] }, 'empty name'], + [{ efforts: [{ id: 'valid', name: 'Valid', description: 1 }] }, 'non-string description'], + [{ efforts: [{ id: 'same', name: 'One' }, { id: 'same', name: 'Two' }] }, 'duplicate id'], + [{ efforts: [{ id: 'valid', name: 'Valid' }], defaultEffort: 'other' }, 'unknown default'], + ] as const)('rejects invalid model reasoning metadata (%s: %s)', async (metadata, _label) => { + const ctx = new Context() + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['route'], new CatalogAdapter( + { id: 'route', name: 'Route' }, + [], + {}, + { model: metadata as unknown as LlmModelReasoningInfo }, + )) + await expect(ctx.llm.resolveModelReasoning('route', 'model')) + .rejects.toMatchObject({ code: 'INVALID_MODEL_REASONING' }) + }) + + it('rejects unsupported reasoning efforts without clamping', async () => { + const ctx = new Context() + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['route'], new CatalogAdapter( + { id: 'route', name: 'Route' }, + [], + {}, + { model: { efforts: [{ id: ReasoningEffortId('ultra'), name: 'Ultra' }] } }, + )) + + await expect(ctx.llm.resolveCallConfig({ + provider: 'route', + model: 'model', + reasoningEffort: ReasoningEffortId('standard'), + })).rejects.toMatchObject({ code: 'UNSUPPORTED_REASONING_EFFORT' }) + await expect(ctx.llm.resolveCallConfig({ + provider: 'route', + model: 'plain', + reasoningEffort: ReasoningEffortId('standard'), + })).rejects.toMatchObject({ code: 'UNSUPPORTED_REASONING_EFFORT' }) + }) + + it('resolves reasoning defaults at the final adapter boundary after routing middleware', async () => { + const ctx = new Context() + await ctx.plugin(LlmService) + const adapter = new class extends RecordingAdapter { + override resolveModelReasoning( + _provider: string, + _model: string, + ): Promise { + return Promise.resolve({ + efforts: [{ id: ReasoningEffortId('standard'), name: 'Standard' }], + defaultEffort: ReasoningEffortId('standard'), + }) + } + }(SCRIPT) + ctx.llm.registerAdapter(['routed'], adapter) + const disposeRouting = ctx.on('llm/stream', (options, next) => { + options.provider = 'routed' + return next() + }) + + for await (const _chunk of ctx.llm.stream({ + provider: 'initial', + model: 'model', + messages: [], + })) { /* drain */ } + + expect(adapter.lastOptions?.reasoningEffort).toBe(ReasoningEffortId('standard')) + disposeRouting() + + const frozenRequest: GenerateOptions = Object.freeze({ + provider: 'routed', + model: 'model', + messages: [], + }) + for await (const _chunk of ctx.llm.stream(frozenRequest)) { /* drain */ } + expect(adapter.lastOptions?.reasoningEffort).toBe(ReasoningEffortId('standard')) + expect(Object.isFrozen(adapter.lastOptions)).toBe(true) + }) + it.each([0, -1, 1.5, Number.NaN])( 'rejects invalid adapter model context %s', async (contextWindow) => { diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 8318d59996..88f7ebcf4c 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -38,6 +38,7 @@ export const LINK_MAP: Record = { HookContext: 'core.md', LlmCallConfig: 'core.md', LlmModelContext: 'core.md', + LlmModelReasoningInfo: 'core.md', LlmFailure: 'llm-streaming.md', LlmModelInfo: 'core.md', LlmProviderInfo: 'core.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 89a0778827..f94dba52c4 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -46,6 +46,21 @@ "symbol": "LlmModelContext", "source": "packages/llm/llm/src/types.ts" }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "ReasoningEffortId", + "source": "packages/llm/llm/src/brand.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "LlmReasoningEffortInfo", + "source": "packages/llm/llm/src/types.ts" + }, + { + "doc": "docs/core-data-structures/core.md", + "symbol": "LlmModelReasoningInfo", + "source": "packages/llm/llm/src/types.ts" + }, { "doc": "docs/core-data-structures/core.md", "symbol": "GenerateOptions", From 1bfca8612835e9c246ebc1c3c2a7e180ed3ac0fd Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 25 Jul 2026 08:32:38 +0800 Subject: [PATCH 031/319] feat(tui): select model reasoning effort --- ...cated-full-screen-tui-front-door.i18n.yaml | 4 +- ...17-dedicated-full-screen-tui-front-door.md | 6 +- ...dedicated-full-screen-tui-front-door.zh.md | 6 +- docs/config-catalog.md | 2 +- docs/cordis-catalog/services.md | 2 +- examples/tui-agent/README.md | 2 +- .../tests/fixtures/tui-scripted-llm.ts | 32 +++- .../tui-agent/tests/tui-keyless-smoke.e2e.ts | 3 +- packages/core/agent/README.md | 2 +- packages/core/agent/src/llm-target.ts | 25 ++- packages/core/agent/tests/llm-target.spec.ts | 23 ++- packages/ui/tui/README.md | 10 +- packages/ui/tui/src/index.ts | 154 ++++++++++++++--- packages/ui/tui/tests/harness.ts | 15 +- .../model-effort-switching.expected.txt | 42 +++++ .../snapshots/model-selector.expected.txt | 38 ++--- .../snapshots/model-switching.expected.txt | 12 +- .../status-diagnostics-narrow.expected.txt | 6 +- .../snapshots/status-diagnostics.expected.txt | 62 +++---- packages/ui/tui/tests/tui.snapshot.ts | 24 ++- packages/ui/tui/tests/tui.spec.ts | 159 +++++++++++++++++- 21 files changed, 497 insertions(+), 132 deletions(-) create mode 100644 packages/ui/tui/tests/snapshots/model-effort-switching.expected.txt diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml index 8d6c7be831..56b2e3536d 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-17-dedicated-full-screen-tui-front-door.md: ecfda138593fc2b98ac42929acc586b11e437ee2 -2026-07-17-dedicated-full-screen-tui-front-door.zh.md: 6b8cc63f7657672a6da542e2033d765b54bd4f07 +2026-07-17-dedicated-full-screen-tui-front-door.md: 5ab38d2f9dc97d22ab35b42b04e6da68210690eb +2026-07-17-dedicated-full-screen-tui-front-door.zh.md: 8aee779d10370efd1bef832dbcaebdc25b34a595 diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md index ecfda13859..5ab38d2f9d 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md @@ -22,9 +22,9 @@ The selected front door receives the exact generated or resumed `SessionId` used The TUI rebuilds the transcript from the active `session.surface` and reprojects it whenever an event carries a `surfaceOp`, so resumed and compacted history matches the model-visible conversation. It renders Markdown text and reasoning, token totals, the latest `todo/write` plan, and tool cards produced through each tool definition's `presentCall` and `presentResult` methods. Long card bodies retain a configurable head/tail preview with the hidden-line count; one terminal control expands or collapses every card. Pending chunks and tool calls update the same components that completed events settle. -Editor input calls `agent.send()` while idle and `agent.steer()` while a turn is running. Cancellation, reasoning visibility, tool-card expansion, redraw, transcript clearing, and exit are terminal-only controls. The idle footer derives context occupancy from `tokenMeter` and shows the selected model; during a run, elapsed activity and the Escape interrupt hint replace that summary. `/status` remains available in either state and appends a detailed terminal-only snapshot: session identity and timestamps, selected model and reasoning visibility, lifecycle counts folded from the event log, the same deduplicated usage buckets and KV-cache rate as the footer, and context use from `tokenMeter` plus the selected model's advertised capacity. The plugin registers the shared `userInteraction` provider and presents queued questions in a wide bottom-left keyboard panel with batch progress, numbered options, and aligned descriptions; agent behavior and answer logging remain owned by their existing services. +Editor input calls `agent.send()` while idle and `agent.steer()` while a turn is running. Cancellation, reasoning visibility, tool-card expansion, redraw, transcript clearing, and exit are terminal-only controls. The idle footer derives context occupancy from `tokenMeter` and shows the selected model and explicit reasoning effort; during a run, elapsed activity and the Escape interrupt hint replace that summary. `/status` remains available in either state and appends a detailed terminal-only snapshot: session identity and timestamps, selected model, reasoning effort/default state and reasoning visibility, lifecycle counts folded from the event log, the same deduplicated usage buckets and KV-cache rate as the footer, and context use from `tokenMeter` plus the selected model's advertised capacity. The plugin registers the shared `userInteraction` provider and presents queued questions in a wide bottom-left keyboard panel with batch progress, numbered options, and aligned descriptions; agent behavior and answer logging remain owned by their existing services. -The `/model` command presents the advisory `ctx.llm` catalog as a keyboard selector and changes only this TUI session's target; argument forms remain available for direct selection. Agent-scoped prompt-assembly and request waterfalls snapshot one provider/model pair per step, so `{{provider}}` / `{{model}}` interpolation and request routing cannot split when a command arrives during assembly. The latest logged request header restores a used target; a selection that never reaches a request remains process-local. +The `/model` command presents the advisory `ctx.llm` catalog as a keyboard selector and changes only this TUI session's target; argument forms remain available for direct selection. Each model row owns the adapter-advertised reasoning-effort order and default: Shift+Tab cycles that row's efforts, while a model without selectable metadata keeps default behavior. Agent-scoped prompt-assembly and request waterfalls snapshot one provider/model/reasoning-effort target per step, so `{{provider}}` / `{{model}}` interpolation and request routing cannot split when a command arrives during assembly. The latest logged request header restores a used target; a selection that never reaches a request remains process-local. ### Terminal ownership @@ -49,4 +49,4 @@ The implemented [TUI terminal-state snapshot Agent Note](../testing/2026-07-18-t - The TUI carries a pi-tui dependency and a strict TTY requirement; non-TTY deployments use the Headless app or a structured protocol. - Session projection makes resume and compaction consistent with the durable conversation, but one configured session owns the transcript and editor. - Tool packages extend terminal cards through their existing presentation methods without adding tool-specific branches to the TUI. -- Model selection uses adapter-advertised metadata without turning catalog membership into request validation; unused selections are not durable state. +- Model and reasoning-effort selection use adapter-advertised metadata without turning catalog membership into request validation; unused selections are not durable state. diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md index 6b8cc63f76..8aee779d10 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md @@ -22,9 +22,9 @@ DeepSeek Harness 将 [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README TUI 从活跃的 `session.surface` 重建 transcript(文本记录),并在事件携带 `surfaceOp` 时重新投影,因此恢复或压缩后的历史与模型可见会话保持一致。TUI 渲染 Markdown 文本与推理、token 用量、最新 `todo/write` 计划,以及各工具定义通过 `presentCall` 和 `presentResult` 方法生成的工具卡片。较长的工具卡片正文会保留可配置的头尾预览,并显示隐藏行数;一个终端控制可以展开或收起全部卡片。进行中的分片与工具调用会更新同一组组件,随后由完成事件收束状态。 -agent 空闲时,编辑器输入调用 `agent.send()`;轮次运行中则调用 `agent.steer()`。取消、推理显隐、工具卡片展开、重绘、清空 transcript 和退出都只是终端控制。空闲态页脚根据 `tokenMeter` 得出上下文占用率,并显示所选模型;agent 运行期间,该摘要会替换为带已用时长的活动指示和 Escape 中断提示。`/status` 在这两种状态下均可用,并会追加一份仅在终端显示的详细快照,其中包括会话标识与时间戳、所选模型及推理显隐状态、从事件日志归并得出的生命周期计数、与页脚一致的去重用量分项和 KV 缓存命中率,以及 `tokenMeter` 给出的上下文用量和所选模型公布的容量。插件注册共享的 `userInteraction` 提供方,在左下角宽幅键盘操作面板中呈现排队的问题,面板显示批次进度、带编号的选项和对齐的描述;agent 行为和答案日志仍由既有服务负责。 +agent 空闲时,编辑器输入调用 `agent.send()`;轮次运行中则调用 `agent.steer()`。取消、推理显隐、工具卡片展开、重绘、清空 transcript 和退出都只是终端控制。空闲态页脚根据 `tokenMeter` 得出上下文占用率,并显示所选模型和显式选定的推理强度;agent 运行期间,该摘要会替换为带已用时长的活动指示和 Escape 中断提示。`/status` 在这两种状态下均可用,并会追加一份仅在终端显示的详细快照,其中包括会话标识与时间戳、所选模型、推理强度(或默认状态)及推理显隐状态、从事件日志归并得出的生命周期计数、与页脚一致的去重用量分项和 KV 缓存命中率,以及 `tokenMeter` 给出的上下文用量和所选模型公布的容量。插件注册共享的 `userInteraction` 提供方,在左下角宽幅键盘操作面板中呈现排队的问题,面板显示批次进度、带编号的选项和对齐的描述;agent 行为和答案日志仍由既有服务负责。 -`/model` 命令将建议性的 `ctx.llm` 目录呈现为键盘选择器,并且只更改当前 TUI 会话的目标;带参数的形式仍可直接选择目标。agent 作用域内的 prompt 组装和请求两条 waterfall(瀑布式事件)会为每个 step 快照一次同一个提供方/模型字段组合,因此即使命令在组装期间到达,`{{provider}}` / `{{model}}` 插值与请求路由也不会分裂。系统通过日志中最新的请求头恢复已经使用过的目标;未被请求使用的选择只保留在当前进程中。 +`/model` 命令将建议性的 `ctx.llm` 目录呈现为键盘选择器,并且只更改当前 TUI 会话的目标;带参数的形式仍可直接选择目标。每个模型行都持有适配器公布的推理强度顺序和默认值:按 Shift+Tab 可循环切换该行的推理强度;没有可选元数据的模型保持默认行为。agent 作用域内的 prompt 组装和请求两条 waterfall(瀑布式事件)会为每个步骤快照一次同一个提供方/模型/推理强度目标,因此即使命令在组装期间到达,`{{provider}}` / `{{model}}` 插值与请求路由也不会分裂。系统通过日志中最新的请求头恢复已经使用过的目标;未被请求使用的选择只保留在当前进程中。 ### 终端所有权 @@ -49,4 +49,4 @@ agent 空闲时,编辑器输入调用 `agent.send()`;轮次运行中则调 - TUI 会引入 pi-tui 依赖并严格要求 TTY;非 TTY 部署使用 Headless app 或结构化协议。 - 会话投影使恢复和压缩与持久会话保持一致,但只有一个已配置会话拥有 transcript 和编辑器。 - 工具包通过既有呈现方法扩展终端卡片,无需在 TUI 中增加工具专用分支。 -- 模型选择使用适配器提供的目录元数据,但不会把目录成员关系变成请求校验;未使用的选择不属于持久化状态。 +- 模型和推理强度选择使用适配器公布的元数据,但不会把目录成员关系变成请求校验;未使用的选择不属于持久化状态。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 0ad683d2f4..90da953d66 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1632,7 +1632,7 @@ export interface TuiConfig { } ``` -Source: [`packages/ui/tui/src/index.ts:270`](../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:273`](../packages/ui/tui/src/index.ts) ## `@deepseek-ai/dsh-tui-demo` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 537365a477..b4ba1a6698 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1728,7 +1728,7 @@ The concrete provider retains pi-tui, focus, and terminal lifecycle state. Plugi abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession ``` -Source: [`packages/ui/tui/src/index.ts:150`](../../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:153`](../../packages/ui/tui/src/index.ts) ## `ctx.userInteraction` — `UserInteractionService` diff --git a/examples/tui-agent/README.md b/examples/tui-agent/README.md index 2e87df0a27..106f1412a2 100644 --- a/examples/tui-agent/README.md +++ b/examples/tui-agent/README.md @@ -17,7 +17,7 @@ Type a coding task. The agent works through the `read`/`write`/`edit` filesystem The `todo_write` task tracker is opt-in and not in the shipped config: add `@deepseek-ai/dsh-tool-todo` to `cordis.yml` (or a personal-config overlay under `~/.dsh`) to expose it. Once loaded, the model records a whole-list plan to the session log and the TUI renders it. -The TUI renders Markdown history, reasoning, tool-owned terminal/diff/generic cards, token totals, and — when `todo_write` is loaded — the latest plan. Long tool bodies keep a head/tail preview; Ctrl+O expands or collapses every card. Enter submits or steers while the agent runs, Ctrl+R toggles reasoning, Escape cancels, and `/help` lists commands. `/plan` selects plan mode for the next step; `/plan ` also submits the message into that step, while `/plan off` selects the default mode without model input. `/status` expands the current session's identity, activity counts, exact token/cache buckets, context use, and timestamps without interrupting a running turn. `/model` opens a keyboard selector for the current provider catalog; use Up/Down and Enter, or `/model ` and `/model /` for direct selection. `ask_user_question` opens a wide bottom-left keyboard panel with batch progress and numbered options. +The TUI renders Markdown history, reasoning, tool-owned terminal/diff/generic cards, token totals, and — when `todo_write` is loaded — the latest plan. Long tool bodies keep a head/tail preview; Ctrl+O expands or collapses every card. Enter submits or steers while the agent runs, Ctrl+R toggles reasoning, Escape cancels, and `/help` lists commands. `/plan` selects plan mode for the next step; `/plan ` also submits the message into that step, while `/plan off` selects the default mode without model input. `/status` expands the current session's identity, activity counts, exact token/cache buckets, context use, and timestamps without interrupting a running turn. `/model` opens a keyboard selector for the current provider catalog; use Up/Down to focus a model, Shift+Tab to cycle its advertised reasoning efforts, and Enter to select, or use `/model ` and `/model /` for direct selection. `ask_user_question` opens a wide bottom-left keyboard panel with batch progress and numbered options. ### Resuming a prior session diff --git a/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts b/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts index c3bdbfd1b1..e668689ca9 100644 --- a/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts +++ b/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts @@ -1,6 +1,12 @@ import type { Context } from 'cordis' -import type { GenerateOptions, LlmModelContext, LlmModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' -import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' +import type { + GenerateOptions, + LlmModelContext, + LlmModelInfo, + LlmModelReasoningInfo, + StreamChunk, +} from '@deepseek-ai/dsh-llm' +import { CallId, LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm' const CONTROL_PROBE = '\u001b]2;MODEL_CONTROLLED\u0007\u001b[999CMODEL_CURSOR\u009b31mMODEL_C1' const INITIAL_TEXT = `I need one decision before I continue. ${CONTROL_PROBE}` @@ -39,6 +45,20 @@ class ScriptedTuiAdapter extends LlmAdapter { return Promise.resolve({ contextWindow: 128_000 }) } + override resolveModelReasoning( + _provider: string, + model: string, + ): Promise { + if (model !== 'tui-scripted-model-pro') return Promise.resolve(undefined) + return Promise.resolve({ + efforts: [ + { id: ReasoningEffortId('high'), name: 'High' }, + { id: ReasoningEffortId('max'), name: 'Max' }, + ], + defaultEffort: ReasoningEffortId('high'), + }) + } + override async * stream(options: GenerateOptions): AsyncIterable { // The session-title provider's auxiliary request carries no tool schemas, // unlike every agent turn; answer it with a fixed title so the PTY test can @@ -47,8 +67,12 @@ class ScriptedTuiAdapter extends LlmAdapter { for (const chunk of textChunks(TITLE_TEXT)) yield chunk return } - if (options.model !== 'tui-scripted-model-pro' || !options.system?.includes('tui-scripted-model-pro')) { - throw new Error('the scripted TUI request did not apply the selected model to routing and prompt variables') + if ( + options.model !== 'tui-scripted-model-pro' + || !options.system?.includes('tui-scripted-model-pro') + || options.reasoningEffort !== ReasoningEffortId('max') + ) { + throw new Error('the scripted TUI request did not apply the selected model and reasoning effort') } const lastMessage = options.messages.at(-1) const lastText = (lastMessage?.content ?? []) diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index 43ac327d8b..951cce98c5 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -104,7 +104,7 @@ function smoke(overrides: Partial & { label: string }): Prom // other route (see fixtures/tui-scripted-llm.ts). const SELECT_PRO_MODEL = [ { waitFor: 'scripted TUI ready.', send: '/model\r' }, - { waitFor: 'Select model', send: '\x1b[B\r' }, + { waitFor: 'Select model', send: '\x1b[B\x1b[Z\r' }, ] as const describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { @@ -157,6 +157,7 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { ], }) expect(output).toContain('I need one decision before I continue.') + expect(output).toContain('Reasoning effort: Max.') expect(output).toContain('Entering plan mode (applies from the next step). Use /plan off to leave.') expect(output).toContain('Leaving plan mode (applies from the next step).') expect(output).toContain('Default mode confirmed.') diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 511ce32235..75a16039cd 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -10,7 +10,7 @@ Tracks live agents and carries the initiating Agent through asynchronous driver ### Public API -The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `installAgentLlmTarget(agentCtx, target)` snapshots a mutable provider/model selection during prompt assembly and applies that pair to both prompt variables and request routing for one step. `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup is trusted, composition-only same-process code: drive the agent only after creation resolves. +The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `installAgentLlmTarget(agentCtx, target)` snapshots a mutable provider/model/reasoning-effort selection during prompt assembly, applies the route to prompt variables, and applies the complete target to request routing for one step; an absent selected effort clears an inherited effort so the target uses adapter/provider defaults. `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup is trusted, composition-only same-process code: drive the agent only after creation resolves. - `ctx.agents.register(agent: Agent): () => void` — record an **already-constructed** agent. Disposed with the calling fiber. - Advanced ordered lifecycle: `enter(agent, owner): () => void` enforces `agent.id === agent.session.id`, performs the authoritative ID collision check, and inserts without announcing; `owner` explicitly records the live creator-agent relation (or `undefined` for a root), independently of durable session lineage. `announce(agent)` emits `agent/created` exactly once. A detach requested synchronously by a creation listener is deferred until that dispatch unwinds, and every detach checks the captured entry object, so a stale capability cannot delete a later same-ID replacement. The async factory uses this split; ordinary plugins use `register()`. diff --git a/packages/core/agent/src/llm-target.ts b/packages/core/agent/src/llm-target.ts index 18287a3ff5..e0492a71d2 100644 --- a/packages/core/agent/src/llm-target.ts +++ b/packages/core/agent/src/llm-target.ts @@ -1,17 +1,19 @@ /** - * Agent-scoped provider/model target snapshot shared by interactive front doors. + * Agent-scoped LLM target snapshot shared by interactive front doors. * @module @deepseek-ai/dsh-agent/llm-target */ import type { Context } from 'cordis' -import type { LlmCallConfig } from '@deepseek-ai/dsh-llm' +import type { LlmCallConfig, ReasoningEffortId } from '@deepseek-ai/dsh-llm' -/** Complete provider/model route selected for one live agent. */ +/** Complete provider/model route and optional reasoning effort selected for one live agent. */ export interface AgentLlmTarget { /** Registered provider route. */ provider: string /** Provider-owned model id. */ model: string + /** Adapter-owned reasoning effort, or provider/default behavior when absent. */ + reasoningEffort?: ReasoningEffortId } /** Mutable selection plus the target captured for the current step. */ @@ -24,9 +26,11 @@ export interface AgentLlmTargetRef { /** * Couple one mutable target to agent-scoped prompt assembly and request routing. - * Prompt assembly snapshots the selected pair before delegating, then applies - * both prompt variables and request config to that snapshot so a concurrent - * switch takes effect on a later step instead of splitting the two surfaces. + * Prompt assembly snapshots the selected target before delegating, then applies + * its route to prompt variables and its route/effort to request config so a + * concurrent switch takes effect on a later step instead of splitting the two + * surfaces. An absent selected effort clears any inherited effort so a model + * switch can restore that target's provider/default behavior. * * @param agentCtx - The target agent's scoped context. * @param target - Mutable selection owned by the calling front door. @@ -52,10 +56,15 @@ export function installAgentLlmTarget(agentCtx: Context, target: AgentLlmTargetR async (_agent, _turn, _step, _config, _signal, next): Promise => { const resolved = await next() const selected = target.assembled - return selected === undefined ? resolved : { - ...resolved, + if (selected === undefined) return resolved + const { reasoningEffort: _inheritedEffort, ...withoutInheritedEffort } = resolved + return { + ...withoutInheritedEffort, provider: selected.provider, model: selected.model, + ...selected.reasoningEffort === undefined + ? {} + : { reasoningEffort: selected.reasoningEffort }, } }, ) diff --git a/packages/core/agent/tests/llm-target.spec.ts b/packages/core/agent/tests/llm-target.spec.ts index fa4ef2b459..10d688d6ed 100644 --- a/packages/core/agent/tests/llm-target.spec.ts +++ b/packages/core/agent/tests/llm-target.spec.ts @@ -7,7 +7,7 @@ import { type Agent, type AgentLlmTargetRef, } from '../src/index.ts' -import type { LlmCallConfig } from '@deepseek-ai/dsh-llm' +import { ReasoningEffortId, type LlmCallConfig } from '@deepseek-ai/dsh-llm' describe('installAgentLlmTarget()', () => { it('snapshots prompt variables and request routing together, then disposes both listeners', async () => { @@ -24,16 +24,31 @@ describe('installAgentLlmTarget()', () => { 'agent/request', 1, 0, seed, signal, () => Promise.resolve(seed), )).resolves.toBe(seed) - target.current = { provider: 'alpha', model: 'a1' } + target.current = { + provider: 'alpha', + model: 'a1', + reasoningEffort: ReasoningEffortId('high'), + } expect((await ctx.systemPrompt.assemble()).variables).toMatchObject({ provider: 'alpha', model: 'a1' }) target.current = { provider: 'beta', model: 'b1' } await expect(agentEvents(ctx, agent).waterfall( 'agent/request', 1, 0, seed, signal, () => Promise.resolve(seed), - )).resolves.toEqual({ provider: 'alpha', model: 'a1', temperature: 0.2 }) + )).resolves.toEqual({ + provider: 'alpha', + model: 'a1', + reasoningEffort: ReasoningEffortId('high'), + temperature: 0.2, + }) expect((await ctx.systemPrompt.assemble()).variables).toMatchObject({ provider: 'beta', model: 'b1' }) + const inherited: LlmCallConfig = { + provider: 'alpha', + model: 'a1', + reasoningEffort: ReasoningEffortId('max'), + temperature: 0.2, + } await expect(agentEvents(ctx, agent).waterfall( - 'agent/request', 1, 1, seed, signal, () => Promise.resolve(seed), + 'agent/request', 1, 1, inherited, signal, () => Promise.resolve(inherited), )).resolves.toEqual({ provider: 'beta', model: 'b1', temperature: 0.2 }) dispose() diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 616544779f..3326c548eb 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -10,7 +10,7 @@ This package owns interactive terminal presentation and input only. It injects ` After terminal startup succeeds, the package provides the terminal-local `ctx.tui` extension service. A plugin that injects it can call `openOverlay()` with a component factory and constrained layout options; the host exposes the viewport, semantic theme, display-text escaping, redraw, close, and a lifetime signal, but not the pi-tui tree, terminal, focus controller, or overlay handle. Plugin overlays, the model selector, and user questions share one FIFO modal queue. Each request is an effect of the calling plugin fiber, so unload removes queued work or closes visible work before cleanup settles; terminal shutdown unloads dependents before stopping pi-tui. Overlay state is not logged or replayed. Component code is trusted and may render ANSI styling, but must pass untrusted text through `host.display()`. The [interactive-extension Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md) owns the boundary and rejected alternatives. -The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the latest `todo/write` plan above the editor, and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes ``. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelContext()` for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode and the current model with reasoning state; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. Surface replacement events rebuild the transcript so compacted history does not reappear. +The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the latest `todo/write` plan above the editor, and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes ``. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelContext()` for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode plus the current model and any explicitly selected reasoning effort; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. Surface replacement events rebuild the transcript so compacted history does not reappear. An embedding may provide `TuiRuntime.formatCwd` when its logical workspace label differs from the session's host directory. The override changes only the footer label; tools continue to use the session `cwd`. @@ -22,13 +22,13 @@ When optional `ctx.sessionReferences` is mounted, the same `@` menu also offers While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.followup()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/reasoning`, `/tools`, `/redraw`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. Ctrl+C or Escape cancels a running turn. Tool cards collapse long bodies into a configurable head/tail preview; Ctrl+O toggles every card between its preview and full output. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. -`/model` opens the advisory `ctx.llm` catalog as a keyboard selector: Up/Down moves, Enter selects, and Escape closes it. `/model ` still selects an unambiguous model id directly, while `/model /` selects an exact target. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same pair through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local. +`/model` opens the advisory `ctx.llm` catalog as a keyboard selector: Up/Down moves, Shift+Tab cycles the focused model's adapter-advertised reasoning efforts in display order, Enter selects the model and effort, and Escape closes it. Models without selectable effort metadata ignore Shift+Tab; the selector does not synthesize `off`, clamp a value, or transfer an effort between models. `/model ` still selects an unambiguous model id directly, while `/model /` selects an exact target and uses its adapter default when one exists. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same provider/model/reasoning-effort target through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local. `/reload` (EXPERIMENTAL, dev-only) re-reads every file-backed loader config tree and applies the diff to the running app — the HMR watcher's config path, invoked manually; it needs the cordis Loader in the context and degrades to a warning without one, runs only while the agent is idle, and refuses re-entry while a reload is in flight. Module-source hot reload remains watcher-owned. When a `skills` service is mounted, `/skill: [instructions]` loads that skill's instructions into the conversation as a user turn; autocomplete lists the model-invocable skills, and any skill (including a model-disabled one) is loadable by its exact name. The footer sums the session's reported usage as `↑`, followed by `cache %` once any input has been billed — the share of billed prompt tokens (uncached input plus cache reads and writes) served from the provider cache, rounded to a percent. It also compares token-meter pressure with `ctx.llm.resolveModelContext()` for the current route (omitting the context share when the adapter has no capacity metadata) and shows the current model and tool-card mode; the right side clips first when the footer is narrow. -`/status` adds a point-in-time diagnostics card to the transcript and remains available while the agent runs. It reports the session id, title, working directory, selected provider/model, reasoning-block visibility, agent state, event/turn/step/tool-call counts, exact input/output/cache token buckets, KV-cache hit rate, token-meter context use and capacity, creation time, and latest event time. Missing titles, models, cache input, or context capacity are labeled instead of inferred. The card is terminal-only and does not duplicate the compact footer. +`/status` adds a point-in-time diagnostics card to the transcript and remains available while the agent runs. It reports the session id, title, working directory, selected provider/model, selected reasoning effort or default behavior, reasoning-block visibility, agent state, event/turn/step/tool-call counts, exact input/output/cache token buckets, KV-cache hit rate, token-meter context use and capacity, creation time, and latest event time. Missing titles, models, cache input, or context capacity are labeled instead of inferred. The card is terminal-only and does not duplicate the compact footer. `/resume` opens a full-viewport keyboard selector over the current workspace instead of a centered dialog. Its focused search field starts immediately after the search glyph and emits pi-tui's cursor marker, so terminal IME composition remains anchored inside the field. Candidates are sorted by last logged activity and searchable by log-backed title or session id; each row reports current/live/persisted state, last turn outcome, recent provider/model, and durable goal phase when present. Up/Down and Page Up/Page Down navigate, Enter resumes, Escape clears a non-empty search before a second Escape cancels, and Ctrl+C cancels directly. The current session, a session already live in this runtime, an unreadable log, a mismatched cwd, or a session whose logged provider has no current adapter remains visible but disabled. Selection repeats those checks and requires the current agent to be idle before flushing the current session. The TUI then stops the terminal UI and calls the optional host-owned `TuiRuntime.handoffResume`; where `process.execve` is available, the shipped `dsh` host disposes the app and replaces its process. Resume restores the same `SessionId`, transcript, title, todos, and durable goal; goal activation remains disarmed and the TUI asks for human confirmation or `/goal resume`. @@ -47,7 +47,7 @@ The footer sums the session's reported usage as `↑ | `maxResumeOptions` | `8` | Visible sessions in the resume selector | | `questionDialogWidth` | `200` | Question-panel width in columns, clamped to the terminal | | `questionDialogMaxHeight` | `20` | Question-panel maximum rows | -| `modelDialogWidth` | `72` | Model-selector width in columns | +| `modelDialogWidth` | `76` | Model-selector width in columns | | `modelDialogMaxHeight` | `20` | Model-selector maximum rows | | `fileSearchMaxResults` | `20` | Maximum file and directory candidates shown for one `@` query | | `fileSearchMaxEntries` | `10000` | Maximum paths retained in the bounded workspace index used by bare fuzzy queries | @@ -114,7 +114,7 @@ The fixed instruction is part of the stable system-prompt prefix and is reusable #### What the model sees -The `/model` command text and keyboard-selector input are not logged or sent. New steps receive the selected provider/model pair in both prompt variables and request routing. +The `/model` command text and keyboard-selector input are not logged or sent. New steps receive the selected provider/model route in prompt variables and the selected provider/model/reasoning-effort target in request routing. #### Token effect diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index de0ef02f7e..21db41789c 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -31,6 +31,7 @@ import { type EditorTheme, type Focusable, type MarkdownTheme, + type SelectItem, type SelectListTheme, type SlashCommand, type Terminal, @@ -53,6 +54,8 @@ import { assertNever, errorChain } from '@deepseek-ai/dsh-llm' import type { ContentBlock, LlmModelInfo, + LlmModelReasoningInfo, + ReasoningEffortId, StreamChunk, TokenUsage, } from '@deepseek-ai/dsh-llm' @@ -233,7 +236,7 @@ const maxModelOptionsSchema = z.number().step(1).min(1).default(8) const maxResumeOptionsSchema = z.number().step(1).min(1).default(8) const questionDialogWidthSchema = z.number().step(1).min(20).default(200) const questionDialogMaxHeightSchema = z.number().step(1).min(6).default(20) -const modelDialogWidthSchema = z.number().step(1).min(20).default(72) +const modelDialogWidthSchema = z.number().step(1).min(20).default(76) const modelDialogMaxHeightSchema = z.number().step(1).min(6).default(20) const fileSearchMaxResultsSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_RESULTS) const fileSearchMaxEntriesSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_ENTRIES) @@ -356,7 +359,7 @@ export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConf maxResumeOptions: config?.maxResumeOptions ?? 8, questionDialogWidth: config?.questionDialogWidth ?? 200, questionDialogMaxHeight: config?.questionDialogMaxHeight ?? 20, - modelDialogWidth: config?.modelDialogWidth ?? 72, + modelDialogWidth: config?.modelDialogWidth ?? 76, modelDialogMaxHeight: config?.modelDialogMaxHeight ?? 20, fileSearchMaxResults: config?.fileSearchMaxResults ?? DEFAULT_FILE_SEARCH_MAX_RESULTS, fileSearchMaxEntries: config?.fileSearchMaxEntries ?? DEFAULT_FILE_SEARCH_MAX_ENTRIES, @@ -580,15 +583,34 @@ function textBlocks(content: readonly ContentBlock[], type: 'text' | 'reasoning' interface ModelChoice extends AgentLlmTarget { modelName: string description?: string + reasoning?: LlmModelReasoningInfo } function targetLabel(target: AgentLlmTarget): string { return `${target.provider}/${target.model}` } +function compactTargetLabel(target: AgentLlmTarget): string { + return `${target.model}${target.reasoningEffort === undefined ? '' : ` ${target.reasoningEffort}`}` +} + +function targetReasoningLabel(choice: ModelChoice, effort: ReasoningEffortId | undefined): string | undefined { + if (effort === undefined) return choice.reasoning === undefined ? undefined : 'provider default' + return choice.reasoning?.efforts.find(candidate => candidate.id === effort)?.name ?? effort +} + function initialTarget(agent: Agent): AgentLlmTarget | undefined { const logged = agent.session.requestHeader()?.config - if (logged !== undefined) return { provider: logged.provider, model: logged.model } + if (logged !== undefined) { + if (logged.reasoningEffort === undefined) { + return { provider: logged.provider, model: logged.model } + } + return { + provider: logged.provider, + model: logged.model, + reasoningEffort: logged.reasoningEffort, + } + } if (agent.options.provider === undefined || agent.options.model === undefined) return undefined return { provider: agent.options.provider, model: agent.options.model } } @@ -607,11 +629,15 @@ async function readModelChoices( ) { models.push({ provider: provider.id, id: current.model, name: current.model }) } - return models.map((model): ModelChoice => ({ - provider: provider.id, - model: model.id, - modelName: model.name, - ...model.description === undefined ? {} : { description: model.description }, + return Promise.all(models.map(async (model): Promise => { + const reasoning = await ctx.llm.resolveModelReasoning(provider.id, model.id) + return { + provider: provider.id, + model: model.id, + modelName: model.name, + ...model.description === undefined ? {} : { description: model.description }, + ...reasoning === undefined ? {} : { reasoning }, + } })) })) return groups.flat() @@ -1226,6 +1252,10 @@ function renderDialog( class ModelDialog implements Component { private readonly list: SelectList + private readonly items: Map + private readonly choices: Map + private readonly efforts: Map + private readonly currentValue: string | undefined constructor( choices: readonly ModelChoice[], @@ -1235,15 +1265,27 @@ class ModelDialog implements Component { done: (choice: ModelChoice) => void, cancel: () => void, ) { - this.list = new SelectList(choices.map(choice => ({ - value: targetLabel(choice), - label: displayText(targetLabel(choice)), - description: [ - displayText(choice.modelName), - ...choice.description === undefined ? [] : [displayText(choice.description)], - ...current?.provider === choice.provider && current.model === choice.model ? ['current'] : [], - ].join(' — '), - })), maxVisible, dialogSelectTheme(palette)) + this.items = new Map() + this.choices = new Map() + this.efforts = new Map() + this.currentValue = current === undefined ? undefined : targetLabel(current) + for (const choice of choices) { + const value = targetLabel(choice) + const isCurrent = current?.provider === choice.provider && current.model === choice.model + this.choices.set(value, choice) + this.efforts.set( + value, + isCurrent + ? current.reasoningEffort ?? choice.reasoning?.defaultEffort + : choice.reasoning?.defaultEffort, + ) + this.items.set(value, { + value, + label: displayText(value), + description: this.describeChoice(choice, isCurrent), + }) + } + this.list = new SelectList([...this.items.values()], maxVisible, dialogSelectTheme(palette)) const currentIndex = current === undefined ? 0 : choices.findIndex(choice => choice.provider === current.provider && choice.model === current.model) @@ -1252,17 +1294,57 @@ class ModelDialog implements Component { const selected = choices.find(choice => targetLabel(choice) === item.value) /* v8 ignore next -- SelectList only returns values built from `choices`. */ if (selected === undefined) return - done(selected) + const effort = this.efforts.get(item.value) + done({ + ...selected, + ...effort === undefined ? {} : { reasoningEffort: effort }, + }) } this.list.onCancel = cancel } + private describeChoice(choice: ModelChoice, isCurrent: boolean): string { + const selectedEffort = this.efforts.get(targetLabel(choice)) + const effort = choice.reasoning?.efforts.find(candidate => candidate.id === selectedEffort) + const effortLabel = selectedEffort === undefined + ? choice.reasoning === undefined ? undefined : 'provider default' + : effort?.name ?? selectedEffort + return [ + displayText(choice.modelName), + ...choice.description === undefined ? [] : [displayText(choice.description)], + ...effortLabel === undefined ? [] : [displayText(effortLabel)], + ...isCurrent ? ['current'] : [], + ].join(' — ') + } + + private cycleReasoningEffort(): void { + const selectedItem = this.list.getSelectedItem() + /* v8 ignore next -- the dialog is opened only for a non-empty catalog. */ + if (selectedItem === null) return + const choice = this.choices.get(selectedItem.value) + if (choice?.reasoning === undefined) return + const current = this.efforts.get(selectedItem.value) + const currentIndex = choice.reasoning.efforts.findIndex(effort => effort.id === current) + const next = choice.reasoning.efforts[(currentIndex + 1) % choice.reasoning.efforts.length] + /* v8 ignore next -- validated reasoning metadata always carries at least one effort. */ + if (next === undefined) return + this.efforts.set(selectedItem.value, next.id) + const item = this.items.get(selectedItem.value) + /* v8 ignore next -- items and choices are constructed from the same values. */ + if (item === undefined) return + item.description = this.describeChoice(choice, selectedItem.value === this.currentValue) + } + invalidate(): void { this.list.invalidate() } handleInput(data: string): void { - this.list.handleInput(data) + if (matchesKey(data, Key.shift(Key.tab))) { + this.cycleReasoningEffort() + } else { + this.list.handleInput(data) + } this.invalidate() } @@ -1271,7 +1353,7 @@ class ModelDialog implements Component { return renderDialog('Select model', [ ...this.list.render(innerWidth), '', - this.palette.dim('↑/↓ navigate • Enter select • Esc cancel'), + this.palette.dim('↑/↓ navigate • Shift+Tab reasoning • Enter select • Esc cancel'), ], width, this.palette) } } @@ -1938,7 +2020,7 @@ export function createTuiChat( () => sessionTitle ?? config.welcome, palette, resolved.color && resolved.truecolor, - () => target.current?.model, + () => target.current === undefined ? undefined : compactTargetLabel(target.current), ) const footer = new FooterComponent( agent, @@ -1946,7 +2028,7 @@ export function createTuiChat( () => toolsExpanded, () => tokens, runtime.formatCwd, - () => target.current?.model, + () => target.current === undefined ? undefined : compactTargetLabel(target.current), () => contextWindow === undefined ? undefined : Math.min(100, Math.round(ctx.tokenMeter.measure(agent.session).totalTokens / contextWindow * 100)), @@ -2037,13 +2119,26 @@ export function createTuiChat( resolveContextWindow(target.current) const selectModel = (selected: ModelChoice): void => { - if (target.current?.provider === selected.provider && target.current.model === selected.model) { - appendNotice(`Model is already ${targetLabel(selected)}.`) + const sameRoute = target.current?.provider === selected.provider && target.current.model === selected.model + const reasoningEffort = selected.reasoningEffort + ?? (sameRoute ? target.current?.reasoningEffort ?? selected.reasoning?.defaultEffort : selected.reasoning?.defaultEffort) + if (sameRoute && target.current?.reasoningEffort === reasoningEffort) { + const reasoning = targetReasoningLabel(selected, reasoningEffort) + appendNotice(`Model is already ${targetLabel(selected)}${reasoning === undefined ? '' : ` with reasoning effort ${displayText(reasoning)}`}.`) return } - target.current = { provider: selected.provider, model: selected.model } + target.current = { + provider: selected.provider, + model: selected.model, + ...reasoningEffort === undefined ? {} : { reasoningEffort }, + } resolveContextWindow(target.current) - appendNotice(`Model selected: ${targetLabel(selected)}. New steps will use it.`) + const reasoning = targetReasoningLabel(selected, reasoningEffort) + appendNotice([ + `Model selected: ${targetLabel(selected)}.`, + ...reasoning === undefined ? [] : [`Reasoning effort: ${displayText(reasoning)}.`], + 'New steps will use it.', + ].join(' ')) } const showModelSelector = (choices: readonly ModelChoice[]): void => { @@ -2633,12 +2728,17 @@ export function createTuiChat( const steps = events.filter(event => event.type === 'step/start').length const toolCalls = events.filter(event => event.type === 'tool/call').length const model = target.current === undefined ? 'unset' : displayText(targetLabel(target.current)) + const effort = target.current === undefined + ? 'unset' + : target.current.reasoningEffort === undefined + ? 'default' + : displayText(target.current.reasoningEffort) const groups: readonly (readonly StatusCardRow[])[] = [ [ ['Session', displayText(agent.session.id)], ['Title', displayText(sessionTitle ?? 'untitled')], ['Directory', displayText(cwd)], - ['Model', `${model} ${palette.dim(`(reasoning ${showReasoning ? 'shown' : 'hidden'})`)}`], + ['Model', `${model} ${palette.dim(`(effort ${effort}; reasoning blocks ${showReasoning ? 'shown' : 'hidden'})`)}`], ], [ ['Agent', [ diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index 37eee99735..e42610644c 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -8,7 +8,13 @@ import AgentRegistry, { type AgentStatus, type SendOptions, } from '@deepseek-ai/dsh-agent' -import type { ContentBlock, LlmModelContext, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm' +import type { + ContentBlock, + LlmModelContext, + LlmModelInfo, + LlmModelReasoningInfo, + LlmProviderInfo, +} from '@deepseek-ai/dsh-llm' import CommandService from '@deepseek-ai/dsh-commands' import SessionStore, { SessionId, type Session, type SessionHeader } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' @@ -48,6 +54,10 @@ export interface TuiHarnessOptions { models: LlmModelInfo[] listModels?: (provider: string) => Promise resolveModelContext?: (provider: string, model: string) => Promise + resolveModelReasoning?: ( + provider: string, + model: string, + ) => Promise } /** Provide a fake `sessionPersistence` service so resume surfaces can list sessions. */ sessionPersistence?: { @@ -122,6 +132,9 @@ export async function createTuiTestHarness