Merge branch 'codex/simp-agent-entry-state' into codex/simp-unify-agent-session-id
# Conflicts: # docs/config-catalog.md # docs/cordis-catalog/services.md # docs/event-producer-consumer.md # docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md # docs/rfc/implemented/architecture/2026-06-20-branded-ids.md # docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md # docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md # docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md # packages/bash/tool-bash/README.md # packages/bash/tool-bash/src/index.ts # packages/bash/tool-bash/tests/tools.spec.ts # packages/core/agent-loop/README.md # packages/core/agent-loop/tests/properties.spec.ts # packages/core/agent/README.md # packages/core/agent/src/index.ts # packages/guard/repeat-tool-guard/src/index.ts # packages/hooks/hooks-claude/tests/bridge.spec.ts # packages/subagent/subagent-acp/tests/mock-acp-server.ts # packages/ui/acp/tests/dispose.spec.ts # packages/ui/stdio-agent/src/index.ts # packages/ui/stdio-agent/src/stdio-chat.ts # packages/ui/stdio-agent/tests/stdio-chat.spec.ts # packages/util/brand/src/index.ts
This commit is contained in:
@@ -34,67 +34,41 @@ declare module 'cordis' {
|
||||
|
||||
interface Events {
|
||||
/**
|
||||
* A session was created in the store. A synchronous listener throw vetoes
|
||||
* publication and rollback emits the matching `session/disposed` edge;
|
||||
* returned-promise rejection is observed and logged but cannot retroactively
|
||||
* veto this synchronous boundary. A synchronous listener that requests the
|
||||
* advanced detach does not remove the entry immediately: removal and the
|
||||
* paired `session/disposed` edge wait until the creation dispatch unwinds.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is the
|
||||
* session's owner scope, captured when the session was ENTERED (an agent's
|
||||
* session is entered through `agent.ctx`, so its events dispatch in that
|
||||
* agent's scope; a bare `sessions.create()` from a plain plugin dispatches
|
||||
* subject-less). A listener registered through `agent.ctx` hears only that
|
||||
* agent's sessions; a plain plugin listener hears every session.
|
||||
* Creation announcement during session publication. A synchronous throw vetoes and rolls
|
||||
* back with a paired disposal; detach requested during dispatch is deferred.
|
||||
* A returned-promise rejection is logged but cannot retroactively veto this
|
||||
* synchronous boundary.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners
|
||||
* receive only sessions entered through that agent's context.
|
||||
* @param session - the session just entered and announced.
|
||||
* @mode emit
|
||||
*/
|
||||
'session/created'(this: Scoped<Session>, session: Session): void
|
||||
/**
|
||||
* A previously announced session left the store. Emitted exactly once on
|
||||
* normal detach or publication rollback, and never for a prepared/entered
|
||||
* session whose `session/created` announcement did not begin. Listener
|
||||
* failures (including returned-promise rejections) are logged and contained
|
||||
* per listener so teardown always reaches quiescence.
|
||||
* Scope-filtered dispatch uses the same owner carrier captured at entry;
|
||||
* agent-scoped listeners hear only their own session's teardown.
|
||||
* Emitted once when an announced session leaves the store, including
|
||||
* publication rollback, but never for an entry whose creation announcement
|
||||
* did not begin. Listener failures are logged and contained.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) reuses the owner scope.
|
||||
* @param session - the session that is no longer live in the store.
|
||||
* @mode emit
|
||||
*/
|
||||
'session/disposed'(this: Scoped<Session>, session: Session): void
|
||||
/**
|
||||
* An event was appended to a session log (sync, fire-and-forget). This is
|
||||
* the per-append feed a UI or invariant plugin tails. The log push is the
|
||||
* commit point; synchronous throws and returned-promise rejections from
|
||||
* observers are logged and contained per listener, so they cannot make a
|
||||
* committed append appear to fail or starve later listeners. The exact
|
||||
* callback list and Cordis internal-dispatch checks resolve before the push;
|
||||
* callbacks themselves run only after it.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is the
|
||||
* session's owner scope, captured when the session was ENTERED (an agent's
|
||||
* session is entered through `agent.ctx`, so its events dispatch in that
|
||||
* agent's scope; a bare `sessions.create()` from a plain plugin dispatches
|
||||
* subject-less). A listener registered through `agent.ctx` hears only that
|
||||
* agent's sessions; a plain plugin listener hears every session.
|
||||
* Post-commit, fire-and-forget append feed. The listener snapshot resolves
|
||||
* before the log push, but callbacks run after it; observer failures are
|
||||
* logged and contained without making the committed append fail.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners
|
||||
* receive only events from sessions entered through that agent's context.
|
||||
* @param session - the session whose log grew.
|
||||
* @param event - the appended event, exactly as recorded.
|
||||
* @mode emit
|
||||
*/
|
||||
'session/event'(this: Scoped<Session>, session: Session, event: SessionEvent): void
|
||||
/**
|
||||
* Awaited durability checkpoint. The agent loop awaits
|
||||
* `ctx.sessions.flush(session)` at every turn end; persistence
|
||||
* plugins (JSONL, SQLite) drain their write-behind buffers here and on
|
||||
* fiber dispose. Awaited (parallel), not a waterfall: every listener runs
|
||||
* and the caller waits for all of them, but none can veto. Dispatch it
|
||||
* through {@link SessionStore.flush} — the store owns the carrier — never
|
||||
* via a raw `ctx.parallel`.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is the
|
||||
* session's owner scope, captured when the session was ENTERED (an agent's
|
||||
* session is entered through `agent.ctx`, so its events dispatch in that
|
||||
* agent's scope; a bare `sessions.create()` from a plain plugin dispatches
|
||||
* subject-less). A listener registered through `agent.ctx` hears only that
|
||||
* agent's sessions; a plain plugin listener hears every session.
|
||||
* Awaited parallel durability checkpoint: every listener runs and the
|
||||
* caller awaits all of them, with no waterfall veto. Dispatch through
|
||||
* {@link SessionStore.flush}. Scope-filtered dispatch
|
||||
* (`@deepseek-ai/dsh-scope`) reuses the session's owner scope.
|
||||
* @param session - the session whose buffered events must reach durable storage.
|
||||
* @mode parallel
|
||||
*/
|
||||
@@ -103,13 +77,9 @@ declare module 'cordis' {
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a `context/message` or `steering/message` event as a tagged
|
||||
* synthetic user-role message (the system-reminder pattern: zero adapter
|
||||
* burden, models distinguish it from real user prompts by the envelope).
|
||||
*
|
||||
* Live-adapter review has validated the tagged-envelope rendering against
|
||||
* current DeepSeek behavior; provider-specific mismatches belong in that
|
||||
* adapter, not in the canonical session vocabulary.
|
||||
* 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)}>`
|
||||
|
||||
@@ -1,17 +1,4 @@
|
||||
/**
|
||||
* Lossless-JSON validation and snapshot materialization for session data.
|
||||
*
|
||||
* The session event log is the durable source of truth (the event-sourcing / session-persistence RFCs): every
|
||||
* `event.data` must round-trip losslessly through JSON so any persistence
|
||||
* backend can store and reload it byte-identically. This invariant belongs to
|
||||
* the log itself — `Session.append` enforces it at the source, so a
|
||||
* non-serializable event never enters `session.events` and the live log can
|
||||
* never diverge from what a backend can persist. Other public boundaries use
|
||||
* {@link snapshotJsonValue} when they must validate and detach in one pass;
|
||||
* {@link isJsonValue} remains the non-copying structural predicate.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-session/json
|
||||
*/
|
||||
/** Lossless-JSON validation and detached snapshots for durable session data. @module @deepseek-ai/dsh-session/json */
|
||||
|
||||
/**
|
||||
* A value that round-trips losslessly through JSON: `null`, a boolean, a finite
|
||||
@@ -25,19 +12,10 @@
|
||||
export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }
|
||||
|
||||
/**
|
||||
* Materialize one detached lossless-JSON snapshot in a SINGLE recursive pass.
|
||||
* Each array slot or own enumerable string-keyed object value is read exactly
|
||||
* once, validated, and copied immediately. This is intentionally not
|
||||
* `isJsonValue(value)` followed by `structuredClone(value)`: a stateful getter
|
||||
* could return plain JSON to the check and an exotic class instance to the
|
||||
* clone, whose prototype `structuredClone` would erase before a later check.
|
||||
*
|
||||
* Accepts the same scalar/object vocabulary as {@link isJsonValue}: arrays use
|
||||
* the ordinary `Array.prototype` (subclass instances are not plain JSON
|
||||
* containers), while null-prototype objects are accepted and normalized to
|
||||
* ordinary plain objects. Sparse arrays, cycles, negative zero, non-finite
|
||||
* numbers, unsupported scalar types, and exotic object or array shells return
|
||||
* `undefined`. A throwing getter is a caller failure and propagates unchanged.
|
||||
* Validate and detach lossless JSON in one read per property, so a stateful
|
||||
* getter cannot change between validation and copying. Accepts ordinary arrays,
|
||||
* plain or null-prototype objects, and JSON scalars; rejects sparse, cyclic,
|
||||
* exotic, negative-zero, and non-finite values. Getter throws propagate.
|
||||
*
|
||||
* @param value - the candidate value to validate and detach.
|
||||
* @returns the detached snapshot, or `undefined` when the value is not
|
||||
@@ -104,28 +82,12 @@ export function snapshotJsonValue<T>(value: T): T | undefined {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `value` is losslessly JSON-serializable: only `null`, finite numbers
|
||||
* other than negative zero, booleans, strings, plain arrays, and plain objects
|
||||
* of such values. Rejects `BigInt`, function, symbol, `undefined`, `-0` (which
|
||||
* JSON rewrites to `0`), non-finite numbers (`NaN`/`Infinity`, which JSON turns
|
||||
* into `null`), and exotic objects (`Map`/`Set`/`Date`/class instances) —
|
||||
* anything `JSON.stringify` would drop, throw on, or convert lossily. Sparse
|
||||
* arrays are rejected too: a hole serializes to `null`, so `[1, , 3]` would not
|
||||
* round-trip. Detects circular references (which would throw) and reports them
|
||||
* as non-serializable rather than propagating the throw.
|
||||
*
|
||||
* Scope — this is a structural plain-data predicate, not an invocation of
|
||||
* `JSON.stringify`: only an object's OWN ENUMERABLE STRING-keyed properties are
|
||||
* inspected (`Object.values`). Symbol-keyed and non-enumerable properties are
|
||||
* omitted from the durable data surface. Custom `toJSON` behavior is not
|
||||
* executed; boundaries that persist a value first materialize a new plain-data
|
||||
* record with {@link snapshotJsonValue}. Getters are invoked during this check,
|
||||
* so callers that need a stable detached value use that one-pass materializer
|
||||
* instead of checking and then rereading a side-effecting record.
|
||||
* Test the same lossless JSON boundary as {@link snapshotJsonValue} without
|
||||
* detaching it. Only own enumerable string properties participate; `toJSON`
|
||||
* is ignored and getters run, so persistence boundaries use the snapshotter.
|
||||
* @param value - the candidate event data to test.
|
||||
* @param seen - objects on the current descent path, for circular-reference
|
||||
* detection; the recursion threads it — callers omit it.
|
||||
* @returns true when `value` survives a JSON round-trip losslessly.
|
||||
* @param seen - current recursion path; callers omit it.
|
||||
* @returns whether `value` survives JSON round-trip losslessly.
|
||||
*/
|
||||
export function isJsonValue(value: unknown, seen: Set<object> = new Set()): boolean {
|
||||
if (value === null) return true
|
||||
|
||||
@@ -1,37 +1,7 @@
|
||||
/**
|
||||
* Crash-recovery repair for an interrupted session log.
|
||||
*
|
||||
* A persistence backend flushes only at `turn/end`, so a crash can leave a
|
||||
* durable log whose final turn never closed: real, fully-written events sit
|
||||
* after the last `turn/end` with no closing boundary. A single turn can be huge
|
||||
* in a long-horizon task (many steps, large tool output), so those events MUST
|
||||
* be preserved — truncating the turn would silently destroy real work. Instead,
|
||||
* on reload the backend CLOSES the orphaned turn by appending the minimal
|
||||
* synthetic boundary events:
|
||||
*
|
||||
* 1. an error `tool/result` for every `tool-call` in the interrupted turn that
|
||||
* never got its matching `tool/result` (so the rehydrated history is a
|
||||
* VALID provider transcript — see below),
|
||||
* 2. a `step/end` if a step was still open, then
|
||||
* 3. a `turn/end` carrying the merge-extensible `{ kind: 'interrupted' }` reason.
|
||||
*
|
||||
* The marker records that the turn was cut short by a crash, not completed by
|
||||
* the model. See the session-persistence RFC.
|
||||
*
|
||||
* Why the synthetic tool results matter: `deriveMessages()` renders the
|
||||
* `tool-call` blocks inside a durable `assistant/message` but only emits a
|
||||
* matching tool-result when a `tool/result` EVENT exists. A crash between the
|
||||
* assistant message and its tool results (the loop runs the tools AFTER logging
|
||||
* the assistant message, so a process killed mid-tool leaves the calls without
|
||||
* results) would otherwise reload a history with a dangling assistant tool-call
|
||||
* — which every provider rejects as an invalid transcript on the next request.
|
||||
* Synthesizing an error result per orphaned call keeps resume safe.
|
||||
*
|
||||
* This module computes those synthetic closers from an event list; backends
|
||||
* return them inline from `load` (so the reconstructed session is balanced and
|
||||
* immediately usable) and persist them during that mutating load before any
|
||||
* later append continues the log.
|
||||
*
|
||||
* Crash-recovery repair for an interrupted session log. It preserves a fully
|
||||
* written final turn and supplies the missing tool, step, and turn boundaries
|
||||
* needed to resume with a provider-valid transcript.
|
||||
* @module @deepseek-ai/dsh-session/repair
|
||||
*/
|
||||
|
||||
@@ -39,36 +9,19 @@ import type { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionEvent } from './types.ts'
|
||||
|
||||
/**
|
||||
* Scan `events` for an open turn/step at the tail and return the synthetic
|
||||
* boundary events that close them, with `seq` continuing the log and `time`
|
||||
* copied from the last real event (the closers stand in for the crash moment;
|
||||
* reusing the last timestamp keeps them deterministic and never invents a
|
||||
* "future" time). Returns an empty array when the log is already balanced
|
||||
* (ends on a `turn/end`, or is empty) — the common, non-crash case.
|
||||
* Return deterministic synthetic events that close an open tail turn. Unmatched
|
||||
* calls receive error results first, followed by an open `step/end` and an
|
||||
* interrupted `turn/end`; sequences continue the log and timestamps reuse the
|
||||
* last real event. A balanced or empty log returns no events.
|
||||
*
|
||||
* The closers, in order: an error `tool/result` for each unmatched `tool-call`
|
||||
* in the interrupted turn, then a `step/end` if a step is open, then the
|
||||
* `turn/end {interrupted}`. The tool-results come first so a step that issued
|
||||
* tool calls is balanced (every call has a result) before its `step/end`.
|
||||
*
|
||||
* Only the LAST turn can be open: the invariants plugin guarantees a `turn/end`
|
||||
* before any later `turn/start`, so an interior open turn is impossible in a
|
||||
* valid committed log. Likewise at most one step is open within that turn.
|
||||
* @param events - the loaded durable log to scan (a valid committed prefix, possibly with a crash tail).
|
||||
* @returns the synthetic closer events to append after `events`, in order; empty when the log is already balanced.
|
||||
*/
|
||||
export function interruptedTurnClosers(events: readonly SessionEvent[]): SessionEvent[] {
|
||||
let openTurn: number | null = null
|
||||
let openStep: number | null = null
|
||||
// Track tool calls vs. their results WITHIN the currently-open turn only: a
|
||||
// call is "pending" until its matching tool/result arrives. Reset at every
|
||||
// turn boundary so a committed earlier turn (already balanced) never leaks a
|
||||
// phantom pending call into the interrupted-turn repair.
|
||||
// Track pending tool calls with their callSeq (the seq of the `tool/call`
|
||||
// event, captured for surface sourceEventSeqs provenance on the synthetic
|
||||
// result). CallSeq is set from `tool/call` events; the assistant/message
|
||||
// block scan may register a call first (it appears earlier in the log), and
|
||||
// the later `tool/call` event fills in the seq.
|
||||
// Reset at each turn boundary so earlier calls cannot leak into tail repair.
|
||||
// Assistant blocks register calls; later tool/call events add provenance seqs.
|
||||
const pendingCalls = new Map<CallId, { step: number; callSeq?: number }>()
|
||||
for (const event of events) {
|
||||
switch (event.type) {
|
||||
@@ -97,10 +50,7 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session
|
||||
}
|
||||
break
|
||||
case 'tool/call':
|
||||
// Capture the tool/call event seq for surface provenance on the
|
||||
// synthesized tool/result. The entry may already exist (registered by
|
||||
// the assistant/message above) or may be new (if the assistant/message
|
||||
// came from a prior step that was already closed).
|
||||
// Add the tool/call seq used as provenance on a synthetic result.
|
||||
{
|
||||
const entry = pendingCalls.get(event.data.callId)
|
||||
if (entry) {
|
||||
@@ -129,10 +79,8 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session
|
||||
const time = last.time
|
||||
const closers: SessionEvent[] = []
|
||||
|
||||
// Synthesize an error tool/result for each tool-call left unanswered by the
|
||||
// crash, so deriveMessages() yields a valid provider transcript on resume (a
|
||||
// dangling assistant tool-call is rejected by every provider). Insertion
|
||||
// order follows the Map (insertion = log order of the assistant messages).
|
||||
// Close calls before their step: providers reject dangling assistant calls,
|
||||
// and Map insertion order preserves their transcript order.
|
||||
for (const [callId, { step, callSeq }] of pendingCalls) {
|
||||
closers.push({
|
||||
type: 'tool/result',
|
||||
|
||||
Reference in New Issue
Block a user