Merge remote-tracking branch 'origin/master' into feat/todo-multi-in-progress

This commit is contained in:
Chinesezjc
2026-07-27 23:17:37 +08:00
456 changed files with 8495 additions and 10470 deletions

View File

@@ -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 } from '@deepseek-ai/dsh-llm'
import type { Message } from '@deepseek-ai/dsh-llm'
import { SESSION_FORMAT_VERSION, SessionId } from './types.ts'
import type { CreateSessionOptions, EpochHeader, OutOfBandSessionEventType, PromptMessageData, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType, TurnTrigger } from './types.ts'
import type { CreateSessionOptions, EpochHeader, OutOfBandSessionEventType, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType, TurnTrigger } from './types.ts'
import { snapshotJsonValue } from './json.ts'
import { SurfaceManager } from './surface.ts'
import type { SessionSurface } from './surface.ts'
@@ -29,15 +29,6 @@ export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from '
export { foldSurface, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts'
/**
* Return the human-facing prompt blocks from a durable prompt message.
* @param data - ordinary or steering prompt event data.
* @returns the effective direct prompt, excluding baked prefix context.
*/
export function displayPromptContent(data: PromptMessageData): ContentBlock[] {
return data.envelope?.displayContent ?? data.content
}
/**
* Find the latest closed message-triggered turn, excluding injection and
* plugin-owned zero-step turns.
@@ -555,9 +546,7 @@ export class Session {
switch (event.type) {
// Ordinary prompts, injected context, and mid-turn steering project
// identically in user role: the event's model-facing content stays
// verbatim. A prompt envelope is model-hidden display metadata; its
// prefix bytes are already present in content. The message's `source`/`meta`
// and steering's `turn` are also log-only. Do NOT
// verbatim. The message's `source` and steering's `turn` are log-only. 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

View File

@@ -66,8 +66,8 @@ function validateEvent(
let nextStep = trace.nextStep
let pendingCalls: SessionTraceTransition['pendingCalls'] = { kind: 'none' }
// SessionEventMap is merge-extensible, so the default enforces turn
// enclosure for package-added events as well as the built-in variants.
// Model input may be appended between turns without running the model.
// Merge-extensible package events remain turn-enclosed by default.
switch (event.type) {
case 'turn/start': {
if (trace.openTurn !== null) {
@@ -141,6 +141,8 @@ function validateEvent(
pendingCalls = { kind: 'delete', callId: event.data.callId }
break
}
case 'user/message':
break
default: {
if (trace.openTurn === null) {
fail(`${event.type} appended outside any open turn (every event must be turn-enclosed)`)

View File

@@ -8,13 +8,13 @@
*/
import { callConfigEquals } from '@deepseek-ai/dsh-llm'
import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm'
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
import type { EpochHeader, SessionEvent } from './types.ts'
/**
* Normalize a header to canonical form: an empty system prompt, an empty tool
* list, and an empty session prefix become absent fields, matching how requests
* are built. Logging, folding, and comparison use this one representation.
* Normalize a header to canonical form: an empty system prompt and empty tool
* list become absent fields, matching how requests are built. Logging, folding,
* and comparison use this one representation.
* @param header - the header to normalize (not mutated).
* @returns the canonical header.
*/
@@ -23,7 +23,6 @@ export function canonicalHeader(header: EpochHeader): EpochHeader {
config: header.config,
...header.system !== undefined && header.system.length > 0 ? { system: header.system } : {},
...header.tools !== undefined && header.tools.length > 0 ? { tools: header.tools } : {},
...header.messagePrefix !== undefined && header.messagePrefix.length > 0 ? { messagePrefix: header.messagePrefix } : {},
}
}
@@ -32,21 +31,14 @@ function sameSchema(a: ToolSchema, b: ToolSchema): boolean {
return JSON.stringify(a) === JSON.stringify(b)
}
/** Canonical JSON equality over session-prefix arrays; absence equals empty. */
function sameMessages(a: readonly Message[] | undefined, b: readonly Message[] | undefined): boolean {
return JSON.stringify(a ?? []) === JSON.stringify(b ?? [])
}
/**
* Field-wise equality over canonical headers. Tool schemas compare in order;
* the session prefix compares as canonical JSON.
* Field-wise equality over canonical headers. Tool schemas compare in order.
* @param a - one canonical header.
* @param b - the other.
* @returns whether config, system, tools, and session prefix all match.
* @returns whether config, system, and tools all match.
*/
export function headerEquals(a: EpochHeader, b: EpochHeader): boolean {
if (!callConfigEquals(a.config, b.config) || a.system !== b.system) return false
if (!sameMessages(a.messagePrefix, b.messagePrefix)) return false
const at = a.tools ?? []
const bt = b.tools ?? []
return at.length === bt.length && at.every((tool, i) => sameSchema(tool, bt[i] as ToolSchema))

View File

@@ -1,5 +1,5 @@
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 { AssistantProvenance, CallId, ContentBlock, LlmCallConfig, LlmFailure, MessageSource, StreamChunk, TokenUsage, ToolSchema } from '@deepseek-ai/dsh-llm'
import type { JsonValue } from './json.ts'
/** Identifies one session in the store (and its persistence artifacts). */
@@ -82,14 +82,12 @@ export interface CreateSessionOptions {
*/
export interface TurnTriggerMap {
message: { kind: 'message'; source: MessageSource }
/** Recovery turn reopened over the repaired current session log. */
retry: { kind: 'retry' }
/**
* An out-of-band context injection (`agent.inject()`) made while the agent
* was idle. The loop wraps the injected `user/message` (a non-`user` source,
* plugin by default) in a one-shot turn (`turn/start` → `user/message`
* `turn/end`) so every event in the log stays turn-enclosed — the
* durability/replay boundary is the turn, and a bare event between turns would
* otherwise be indistinguishable from a crash tail on reload. The trigger's
* `source` mirrors that message's producer.
* An out-of-band producer explicitly enclosed injected context in a one-shot
* turn. `Agent.inject()` appends idle context directly and does not use this
* trigger; the source mirrors the producer of the enclosed `user/message`.
*/
injection: { kind: 'injection'; source: MessageSource }
}
@@ -109,7 +107,8 @@ export interface TurnEndReasonMap {
* step number the failure occurred on (the operational error's location — the
* single durable record of an in-turn failure; live diagnostics also fire via
* `agent/error`). Final model-request failures retain their normalized facts
* as one `failure`; other turn failures retain their live Error message/code.
* as one `failure`; other thrown values retain their rendered message and a
* real `HarnessError` code when present.
*/
error: { kind: 'error'; step: number } & (
| { failure: LlmFailure; message?: never; code?: never }
@@ -118,11 +117,6 @@ export interface TurnEndReasonMap {
disposed: { kind: 'disposed' }
/** At least one step reached its output-token ceiling, even if a plugin continued the turn. */
'max-tokens': { kind: 'max-tokens' }
/**
* 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 }
/**
* A persistence backend closed a crash-orphaned turn on reload. The loop never
* emits this marker, and the events recorded before the crash remain intact.
@@ -151,9 +145,9 @@ export interface TodoItem {
}
/**
* Logged request state outside derived history: call config, system prompt,
* tools, and prefix. The latest full `request/header` snapshot reconstructs it;
* canonical empty optional fields are absent.
* Logged request state outside derived history: call config, system prompt, and
* tools. The latest full `request/header` snapshot reconstructs it; canonical
* empty optional fields are absent.
*/
export interface EpochHeader {
/** The conversation's call configuration (provider, model, reasoning effort, and sampling scalars). */
@@ -162,14 +156,6 @@ export interface EpochHeader {
system?: string
/** Assembled tool schemas; absent for a tool-less request. */
tools?: ToolSchema[]
/**
* The session prefix: request-only messages sent BEFORE the entire derived
* history (the `agent/session-prefix` waterfall's product, composed once
* per loop instance and reused for every request it sends). Not session
* history — `deriveMessages()` never returns it — so the header is its
* only durable record; absent when the instance composed none.
*/
messagePrefix?: Message[]
}
/**
@@ -180,50 +166,18 @@ export interface EpochHeader {
*/
export type RequestHeaderReason = 'initial' | 'resume' | 'change'
/** Durable model-hidden annotation for one context baked into a prompt message. */
export interface PromptPrefixContext {
/** Producer provenance retained for transcript presentation and inspection. */
source: MessageSource
/** Opaque JSON state retained in the session event but hidden from the model. */
meta?: JsonValue
}
/**
* Human-facing view of a prompt whose exact model content includes prefixed
* context. `content` on the owning event remains the reconstructable model
* input; this envelope prevents transcript, title, and re-reference consumers
* from treating the baked context as direct human text.
*/
export interface PromptMessageEnvelope {
/** Effective user prompt after interception rewrites, without baked context. */
displayContent: ContentBlock[]
/** Ordered descriptors for contexts already baked into the event content. */
prefixContexts: PromptPrefixContext[]
}
/**
* Shared payload for user, injected-context, and steering prompt messages. A
* Shared payload for user, injected-context, and steering messages. A
* direct human prompt, a synthetic `agent.inject()` context, and mid-turn
* steering all project into the model transcript as verbatim user-role content;
* they are told apart by `source` (a non-`user` kind marks injected context),
* not by event type. `meta` carries durable model-hidden producer state.
* not by event type.
*/
export interface PromptMessageData {
/** Exact model-facing blocks, including any baked prompt-prefix contexts. */
export interface UserMessageData {
/** Exact model-facing blocks. */
content: ContentBlock[]
/** Producer provenance for the direct prompt. */
/** Producer provenance. */
source: MessageSource
/** Present only when prompt-prefix contexts were baked into `content`. */
envelope?: PromptMessageEnvelope
/**
* Opaque durable JSON state retained on the event but hidden from the model
* projection. It is the intended channel for a 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.
*/
meta?: JsonValue
}
/**
@@ -234,10 +188,7 @@ export interface PromptMessageData {
*/
export interface SessionEventMap {
/**
* 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).
* Opens turn `turn`. `trigger` records what started the model loop.
*/
'turn/start': { turn: number; trigger: TurnTrigger }
/**
@@ -256,16 +207,10 @@ export interface SessionEventMap {
* (the queued message claimed for this turn), a synthetic `agent.inject()`
* context (file-change notices, subdir AGENTS.md, skill content, cron
* notifications, …), or an admitted goal continuation round. All three
* project their `content` verbatim; `source` (with a non-`user` kind marking
* injected context) is the only channel that tells them apart. An idle
* injection wraps this event in a one-shot turn so the log stays turn-enclosed.
* project their `content` verbatim; `source` tells them apart. An idle
* injection may append this event between turns without running the model.
*/
'user/message': PromptMessageData
/**
* Durable record of a prompt veto and its reason. It is log-only: the blocked
* prompt never enters the model-visible surface, and its turn runs zero steps.
*/
'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string }
'user/message': UserMessageData
/** Raw stream chunk — token-level replay fidelity. */
'assistant/chunk': { turn: number; step: number; chunk: StreamChunk }
/**
@@ -302,7 +247,7 @@ export interface SessionEventMap {
meta?: JsonValue
}
/** Steering content injected between steps of a running turn. */
'steering/message': PromptMessageData & { turn: number }
'steering/message': UserMessageData & { turn: number }
/** Whole-list snapshot; latest write wins on replay. Log-only UI state; never derived history. */
'todo/write': { todos: TodoItem[] }
/**