From a0c269b0fbc4ee1226e57308303bd8c47f814646 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 22 Jul 2026 13:02:44 +0800 Subject: [PATCH 01/36] 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 02/36] =?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 03/36] 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 04/36] =?UTF-8?q?docs(agents):=20web=20todo=20display=20Ag?= =?UTF-8?q?ent=20Note=20=E2=80=94=20side-effect=20channel=20+=20two=20surf?= =?UTF-8?q?aces?= 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 05/36] 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 06/36] 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 07/36] 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 08/36] 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 09/36] 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 10/36] 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 11/36] 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 12/36] 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 13/36] 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 14/36] 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 62bfc6b4fb49b41c403a6556a18050702cea2fc9 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 24 Jul 2026 23:10:19 +0800 Subject: [PATCH 15/36] 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 34eef10f045cb8874db6426e3847465c6409afd0 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sat, 25 Jul 2026 00:00:50 +0800 Subject: [PATCH 16/36] 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 17/36] 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 8372340f9c58cd12d73559ec8426846c39e14471 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 25 Jul 2026 07:47:51 +0800 Subject: [PATCH 18/36] 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 19/36] 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