Merge latest master into codex/llm-error-recovery-rfc
# Conflicts: # docs/architecture.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/event-producer-consumer.md # docs/module-graph.md # docs/persistence-catalog.md # packages/cordis/tool-cordis/src/api-catalog.ts # packages/core/agent-loop/src/loop.ts # packages/core/agent/src/types.ts # packages/examples/agent-spine-demo/README.md # packages/examples/stdio-demo/README.md # packages/examples/stdio-demo/src/index.ts # packages/llm/llm-deepseek/README.md # packages/llm/llm-deepseek/src/adapter.ts # packages/llm/llm-deepseek/tests/adapter.spec.ts # packages/llm/llm/README.md # packages/llm/llm/src/error.ts # packages/llm/llm/tests/service.spec.ts # packages/sandbox/sandbox-policy/tsconfig.json # packages/ui/stdio/README.md # packages/ui/stdio/src/index.ts # packages/ui/stdio/tests/stdio.spec.ts # packages/ui/tui/src/index.ts # pnpm-lock.yaml # website/zh-CN/api/harness/events.md # website/zh-CN/api/harness/llm.md
This commit is contained in:
@@ -11,9 +11,9 @@ import { isAbsolute } from 'node:path'
|
||||
import { deepFreeze } from '@deepseek-ai/dsh-llm'
|
||||
import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import type { ContentBlock, Message, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type { Message } from '@deepseek-ai/dsh-llm'
|
||||
import { SESSION_FORMAT_VERSION, SessionId } from './types.ts'
|
||||
import type { ContextEnvelope, CreateSessionOptions, EpochHeader, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
|
||||
import type { CreateSessionOptions, EpochHeader, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
|
||||
import { snapshotJsonValue } from './json.ts'
|
||||
import { SurfaceManager } from './surface.ts'
|
||||
import type { SessionSurface } from './surface.ts'
|
||||
@@ -80,21 +80,6 @@ declare module 'cordis' {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render injected context as tagged synthetic user-role content, keeping the
|
||||
* canonical session vocabulary provider-neutral. Adapter-specific exceptions
|
||||
* belong in the adapter.
|
||||
*/
|
||||
function renderTagged(tag: string, content: ContentBlock[], source: MessageSource): ContentBlock[] {
|
||||
const open = `<${tag} source=${JSON.stringify(source.kind)}>`
|
||||
const close = `</${tag}>`
|
||||
return [
|
||||
{ type: 'text', text: open },
|
||||
...content,
|
||||
{ type: 'text', text: close },
|
||||
]
|
||||
}
|
||||
|
||||
/** Detach, validate, and freeze the creation metadata published by a session. */
|
||||
function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHeader {
|
||||
const input: unknown = source === undefined
|
||||
@@ -128,6 +113,10 @@ function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHe
|
||||
&& (typeof record.seedLength !== 'number' || !Number.isSafeInteger(record.seedLength) || record.seedLength < 0)) {
|
||||
throw new Error('session header seedLength must be a non-negative safe integer')
|
||||
}
|
||||
if (record.delegationDepth !== undefined
|
||||
&& (typeof record.delegationDepth !== 'number' || !Number.isSafeInteger(record.delegationDepth) || record.delegationDepth < 0)) {
|
||||
throw new Error('session header delegationDepth must be a non-negative safe integer')
|
||||
}
|
||||
return deepFreeze(record as unknown as SessionHeader)
|
||||
}
|
||||
|
||||
@@ -228,22 +217,6 @@ interface SessionEntry {
|
||||
/** Store attachment for the append path; module-private to keep Session store-agnostic publicly. */
|
||||
const attachments = new WeakMap<Session, SessionEntry>()
|
||||
|
||||
/**
|
||||
* Render one context contribution exactly as it will appear in model history.
|
||||
* @param content - content blocks supplied by the context producer.
|
||||
* @param source - attribution used by the canonical context envelope.
|
||||
* @param envelope - canonical tagged framing or caller-owned raw framing.
|
||||
* @returns a detached block list ready for the derived model transcript.
|
||||
*/
|
||||
export function renderContextContent(
|
||||
content: ContentBlock[],
|
||||
source: MessageSource,
|
||||
envelope: ContextEnvelope = 'context',
|
||||
): ContentBlock[] {
|
||||
const cloned = structuredClone(content)
|
||||
return envelope === 'raw' ? cloned : renderTagged('context', cloned, source)
|
||||
}
|
||||
|
||||
/**
|
||||
* An event-sourced session: an append-only log of {@link SessionEvent}s.
|
||||
*
|
||||
@@ -509,7 +482,18 @@ export class Session {
|
||||
// trace/replay data.
|
||||
|
||||
switch (event.type) {
|
||||
case 'user/message': {
|
||||
// Injected context and mid-turn steering project identically to a user
|
||||
// prompt: content verbatim, in user role. context's `source`/`meta` and
|
||||
// steering's `turn` are log-only and do not reach the model. Do NOT
|
||||
// re-add per-type framing (e.g. `<context>`/`<steering>`) here: framing is
|
||||
// caller-owned — a producer bakes it into `content`, as workspace-context
|
||||
// does with `<system-reminder>` — or, if reintroduced, must be driven by
|
||||
// the event `meta` map and a dedicated renderer, keeping this projection a
|
||||
// verbatim pass-through. See the deferred design note in
|
||||
// ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md
|
||||
case 'user/message':
|
||||
case 'context/message':
|
||||
case 'steering/message': {
|
||||
return { role: 'user', content: event.data.content }
|
||||
}
|
||||
case 'assistant/message': {
|
||||
@@ -526,14 +510,6 @@ export class Session {
|
||||
content: [{ type: 'tool-result', toolCallId: callId, content, isError }],
|
||||
}
|
||||
}
|
||||
case 'context/message': {
|
||||
const { content, source, envelope } = event.data
|
||||
return { role: 'user', content: renderContextContent(content, source, envelope) }
|
||||
}
|
||||
case 'steering/message': {
|
||||
const { content, source } = event.data
|
||||
return { role: 'user', content: renderTagged('steering', content, source) }
|
||||
}
|
||||
default:
|
||||
// A non-surface event (boundary, chunk, log-only record) projects to
|
||||
// no message. Merge-extensible union: no assertNever here.
|
||||
@@ -586,9 +562,9 @@ export class SessionStore extends Service {
|
||||
* Create a session owned by the calling fiber: disposing that fiber stops
|
||||
* event notification and removes the session from the store. `options.seed`
|
||||
* populates the session with a copy of those events (replay/fork);
|
||||
* `options.meta` attaches creation metadata (validated absolute `cwd`,
|
||||
* `parentSession` lineage) as the immutable {@link SessionHeader} (the store
|
||||
* fills `version`/`id`/`createdAt`).
|
||||
* `options.meta` attaches creation metadata (validated absolute `cwd`, seed
|
||||
* and parent lineage, and delegation depth) as the immutable
|
||||
* {@link SessionHeader} (the store fills `version`/`id`/`createdAt`).
|
||||
*
|
||||
* For an agent whose session must be torn down IN ORDER with its loop (so the
|
||||
* loop's final flush is captured before the store attachment ends), do NOT use this
|
||||
@@ -650,6 +626,7 @@ export class SessionStore extends Service {
|
||||
...meta?.cwd === undefined ? {} : { cwd: meta.cwd },
|
||||
...meta?.parentSession === undefined ? {} : { parentSession: meta.parentSession },
|
||||
...meta?.seedLength === undefined ? {} : { seedLength: meta.seedLength },
|
||||
...meta?.delegationDepth === undefined ? {} : { delegationDepth: meta.delegationDepth },
|
||||
}
|
||||
return new Session(sessionId, seed, header)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* @module @deepseek-ai/dsh-session/surface
|
||||
*/
|
||||
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import type { SessionEvent, SurfaceEvent, SurfaceEventType, SurfaceOp } from './types.ts'
|
||||
|
||||
/** Runtime counterpart of the message-producing event union. */
|
||||
@@ -187,11 +188,37 @@ function replacementRange(
|
||||
}
|
||||
}
|
||||
|
||||
/** Restrict a tool-result replacement to one current result's content. */
|
||||
function assertToolResultRewrite(
|
||||
event: SessionEvent,
|
||||
shadowedSeqs: readonly number[],
|
||||
events: readonly SessionEvent[],
|
||||
): void {
|
||||
if (event.type !== 'tool/result') return
|
||||
if (shadowedSeqs.length !== 1) {
|
||||
throw new Error('tool/result surface replacement must rewrite exactly one current node')
|
||||
}
|
||||
for (const originalSeq of shadowedSeqs) {
|
||||
const original = events[originalSeq]
|
||||
if (original?.type !== 'tool/result') {
|
||||
throw new Error('tool/result surface replacement must target a current tool/result')
|
||||
}
|
||||
const originalRest = { ...original.data } as Record<string, unknown>
|
||||
const replacementRest = { ...event.data } as Record<string, unknown>
|
||||
delete originalRest['content']
|
||||
delete replacementRest['content']
|
||||
if (!isDeepStrictEqual(originalRest, replacementRest)) {
|
||||
throw new Error('tool/result surface replacement may change only content')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate one event at its replay boundary and prepare its atomic fold transition. */
|
||||
function planSurfaceEvent(
|
||||
state: SurfaceFoldState,
|
||||
event: SessionEvent,
|
||||
expectedSeq: number,
|
||||
events: readonly SessionEvent[],
|
||||
): SurfacePlan | undefined {
|
||||
if (event.seq !== expectedSeq) {
|
||||
throw new Error(`session event seq ${event.seq} is not contiguous; expected ${expectedSeq}`)
|
||||
@@ -204,6 +231,7 @@ function planSurfaceEvent(
|
||||
}
|
||||
const range = replacementRange(state, surfaceOp)
|
||||
assertProvenance(event, range.shadowedSeqs)
|
||||
assertToolResultRewrite(event, range.shadowedSeqs, events)
|
||||
return {
|
||||
kind: 'replace',
|
||||
seq: event.seq,
|
||||
@@ -218,8 +246,9 @@ function applySurfaceEvent(
|
||||
state: SurfaceFoldState,
|
||||
event: SessionEvent,
|
||||
expectedSeq: number,
|
||||
events: readonly SessionEvent[],
|
||||
): SurfaceFoldReplacement | undefined {
|
||||
const plan = planSurfaceEvent(state, event, expectedSeq)
|
||||
const plan = planSurfaceEvent(state, event, expectedSeq, events)
|
||||
if (plan?.kind === 'append') {
|
||||
state.nodes.push(plan.seq)
|
||||
} else if (plan?.kind === 'replace') {
|
||||
@@ -239,13 +268,13 @@ function applySurfaceEvent(
|
||||
* Replay a complete session log through the canonical surface fold.
|
||||
* @param events - session events in contiguous seq order.
|
||||
* @returns detached current sequences and replacement history.
|
||||
* @throws when an event violates surface metadata, provenance, or range rules.
|
||||
* @throws when an event violates surface metadata, provenance, range, or tool-result rewrite rules.
|
||||
*/
|
||||
export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult {
|
||||
const state = createFoldState()
|
||||
const replacements: SurfaceFoldReplacement[] = []
|
||||
for (const [index, event] of events.entries()) {
|
||||
const replacement = applySurfaceEvent(state, event, index)
|
||||
const replacement = applySurfaceEvent(state, event, index, events)
|
||||
if (replacement !== undefined) replacements.push(replacement)
|
||||
}
|
||||
return { nodes: [...state.nodes], replacements }
|
||||
@@ -266,7 +295,7 @@ export class SurfaceManager implements SessionSurface {
|
||||
*/
|
||||
validateNext(event: SessionEvent): void {
|
||||
if (this._lastProcessedSeq < this.log.length - 1) this._processDelta()
|
||||
planSurfaceEvent(this._state, event, this.log.length)
|
||||
planSurfaceEvent(this._state, event, this.log.length, this.log)
|
||||
}
|
||||
|
||||
/** Monotonic count of folded positional replacements. */
|
||||
@@ -285,7 +314,7 @@ export class SurfaceManager implements SessionSurface {
|
||||
private _processDelta(): void {
|
||||
for (let i = this._lastProcessedSeq + 1; i < this.log.length; i++) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounded by the loop condition
|
||||
applySurfaceEvent(this._state, this.log[i]!, i)
|
||||
applySurfaceEvent(this._state, this.log[i]!, i, this.log)
|
||||
this._lastProcessedSeq = i
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,6 @@ import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { AssistantProvenance, CallId, ContentBlock, LlmCallConfig, LlmFailure, Message, MessageSource, StreamChunk, TokenUsage, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import type { JsonValue } from './json.ts'
|
||||
|
||||
/** Canonical context-tag framing, or caller-owned framing rendered verbatim. */
|
||||
export type ContextEnvelope = 'context' | 'raw'
|
||||
|
||||
/** Identifies one session in the store (and its persistence artifacts). */
|
||||
export type SessionId = Branded<'SessionId'>
|
||||
|
||||
@@ -50,6 +47,12 @@ export interface SessionHeader {
|
||||
* boundary lets resume and replay distinguish parent history from child work.
|
||||
*/
|
||||
readonly seedLength?: number
|
||||
/**
|
||||
* Delegation depth: absent (zero) for a top-level session, parent depth + 1
|
||||
* for a subagent child. Persisted so a recursion budget survives restart and
|
||||
* resume — a runtime-only depth would reset a resumed child to top-level.
|
||||
*/
|
||||
readonly delegationDepth?: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -69,6 +72,7 @@ export interface CreateSessionOptions {
|
||||
readonly parentSession?: SessionId
|
||||
readonly createdAt?: number
|
||||
readonly seedLength?: number
|
||||
readonly delegationDepth?: number
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,8 +117,8 @@ export interface TurnEndReasonMap {
|
||||
/** At least one step reached its output-token ceiling, even if a plugin continued the turn. */
|
||||
'max-tokens': { kind: 'max-tokens' }
|
||||
/**
|
||||
* Policy blocked every prompt before the first step. The zero-step turn still
|
||||
* records a balanced durable boundary and the veto reason.
|
||||
* Policy blocked the turn's claimed prompt before the first step. The
|
||||
* zero-step turn still records a balanced durable boundary and veto reason.
|
||||
*/
|
||||
rejected: { kind: 'rejected'; reason: string }
|
||||
/**
|
||||
@@ -183,40 +187,44 @@ export type RequestHeaderReason = 'initial' | 'resume' | 'change'
|
||||
*/
|
||||
export interface SessionEventMap {
|
||||
/**
|
||||
* Opens turn `turn`. `trigger` records what started it — a drained message
|
||||
* batch or an idle-time injection. The turn is the durability/replay
|
||||
* Opens turn `turn`. `trigger` records what started it — one claimed queued
|
||||
* message or an idle-time injection. The turn is the durability/replay
|
||||
* boundary: every event sits between a `turn/start` and its matching
|
||||
* `turn/end` (the turn-enclosure invariant).
|
||||
*/
|
||||
'turn/start': { turn: number; trigger: TurnTrigger }
|
||||
/**
|
||||
* Closes turn `turn` with the {@link TurnEndReason} that ended it. The loop
|
||||
* fires the awaited `session/flush` checkpoint at every turn end, so the turn
|
||||
* boundary is also the durable-commit boundary.
|
||||
* awaits `session/flush` after an ordinary turn ends before claiming the next
|
||||
* queued item. Success commits the turn; rejection is reported live and does
|
||||
* not prevent later work.
|
||||
*/
|
||||
'turn/end': { turn: number; reason: TurnEndReason }
|
||||
/** Opens step `step` of turn `turn` — one model call plus the tool executions it requested. */
|
||||
'step/start': { turn: number; step: number }
|
||||
/** Closes step `step` of turn `turn`. */
|
||||
'step/end': { turn: number; step: number }
|
||||
/** A user-visible prompt (queued message drained at turn start). */
|
||||
/** A user-visible prompt (the queued message claimed for this turn). */
|
||||
'user/message': { content: ContentBlock[]; source: MessageSource }
|
||||
/**
|
||||
* Durable record of a prompt veto and its reason. It is log-only: the blocked
|
||||
* prompt never enters the model-visible surface, including in a mixed batch.
|
||||
* prompt never enters the model-visible surface, and its turn runs zero steps.
|
||||
*/
|
||||
'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string }
|
||||
/**
|
||||
* In-session context injection (file-change notices, subdir AGENTS.md,
|
||||
* skill content, cron notifications, …). Rendered into the derived history
|
||||
* as synthetic context — NOT a user prompt. `envelope: 'raw'` lets a caller
|
||||
* own the complete model-facing frame; `meta` is durable JSON state omitted
|
||||
* from the model projection.
|
||||
* as a synthetic user-role message carrying `content` verbatim — NOT a
|
||||
* user prompt. `meta` is durable JSON state omitted from the model
|
||||
* projection; it is also the intended channel for any future framing
|
||||
* directive (a producer declares the frame, a dedicated renderer applies it —
|
||||
* see the deferred note in
|
||||
* ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md),
|
||||
* so the surface keeps projecting `content` verbatim rather than wrapping it.
|
||||
*/
|
||||
'context/message': {
|
||||
content: ContentBlock[]
|
||||
source: MessageSource
|
||||
envelope?: ContextEnvelope
|
||||
meta?: JsonValue
|
||||
}
|
||||
/** Raw stream chunk — token-level replay fidelity. */
|
||||
|
||||
Reference in New Issue
Block a user