Merge remote-tracking branch 'origin/session-query' into session-query-trace-filter
# Conflicts: # docs/config-catalog.md # docs/core-data-structures/core.md # docs/rfc/INDEX.md # packages/support/invariants/src/index.ts
This commit is contained in:
@@ -4,13 +4,16 @@ The session log, system-prompt assembly, tool registry, agent vocabulary, and co
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `scope/` | Scoped-context registration primitive (scope tags, scope-filtered dispatch) | (library — no ctx key) |
|
||||
| `session/` | Event-sourced session log + in-memory store | `ctx.sessions` |
|
||||
| `system-prompt/` | Prompt-section + tool-schema assembly registry | `ctx.systemPrompt` |
|
||||
| `tools/` | Tool registry + `tools/pre-execute`/`tools/post-execute` pipeline | `ctx.tools` |
|
||||
| `tools/` | Scoped tool registry + pre-policy, guards, around-dispatch, post-policy, and final-result observation | `ctx.tools` |
|
||||
| `agent/` | Agent interface, registry, `agent/*` event vocabulary | `ctx.agents` |
|
||||
| `agent-loop/` | The concrete loop plugin: `ReactLoopAgent` + the loop driver | `ctx.agentLoop` |
|
||||
| `agent-core/` | Bundle plugin: the default executor-less/UI-less spine as code | (loads the spine) |
|
||||
|
||||
`scope/` is the one non-service package here: a dependency-free library (`createScope`/`scopeOf`/`scopeTarget`) the registries and the loop build per-agent scoping on — it sits below `session/` and `system-prompt/` in the module graph precisely so they can consume it without a cycle.
|
||||
|
||||
`agent-loop` is the one concrete implementation of the `agent` seam and lives here because it is the harness's default product loop; everything else in `core/` is interface/vocabulary. Plugins depend on the `agent` vocabulary, never on `agent-loop` directly, so the loop stays swappable.
|
||||
|
||||
`agent-core` is the composition counterpart: one bundle plugin that loads the control spine plus selected default capabilities (`timer` + `llm` + sessions + system-prompt + tools + agents + invariants + the local [skill family](../skill/README.md) + `tool-bash` + `agent-loop`) and forwards `agent-loop`'s `agents` list as its own config. App packages (`ui/stdio-agent`, `ui/acp-agent`) consume it and add only a front door; a leaf adds the swappable backends plus any optional product tools it wants to expose. It lives in `core/` because it composes the shared control spine while leaving executors, LLM adapters, alternate skill providers, and UI front doors outside the bundle.
|
||||
|
||||
@@ -13,7 +13,7 @@ This is the package to read to see **the whole plugin tree at once** — the tea
|
||||
@deepseek-ai/dsh-llm abstract LLM service + content-block vocabulary
|
||||
@deepseek-ai/dsh-session event-sourced session log + store
|
||||
@deepseek-ai/dsh-system-prompt prompt-section + tool-schema assembly
|
||||
@deepseek-ai/dsh-tools tool registry + tools/pre-execute/post-execute
|
||||
@deepseek-ai/dsh-tools registry + guarded pre/around/post/final-result pipeline
|
||||
@deepseek-ai/dsh-skill skill provider registry
|
||||
@deepseek-ai/dsh-skill-local local filesystem skill provider
|
||||
@deepseek-ai/dsh-agent agent registry + agent/* event vocabulary
|
||||
|
||||
@@ -6,14 +6,15 @@ import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
|
||||
import * as agentCore from '../src/index.ts'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import { AgentId, agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { Message } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
async function composePrefix(ctx: Context, cwd: string): Promise<Message[]> {
|
||||
const agent = { session: { header: { cwd } } } as unknown as Agent
|
||||
const empty: Message[] = []
|
||||
return await ctx.waterfall(
|
||||
'agent/session-prefix', { session: { header: { cwd } } } as never,
|
||||
empty, new AbortController().signal, () => Promise.resolve(empty),
|
||||
return await agentEvents(ctx, agent).waterfall(
|
||||
'agent/session-prefix', empty, new AbortController().signal,
|
||||
() => Promise.resolve(empty),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,14 +8,20 @@ This is the only package in the harness that contains concrete loop logic. Every
|
||||
|
||||
### Public API
|
||||
|
||||
- `ctx.agentLoop.create(id: string, options?: AgentOptions, meta?: { cwd?: string }): ReactLoopAgent` — config-driven create: an agent on a fresh per-run session id `${id}-session-<uuid>` with optional session metadata. Used for `cordis.yml`-configured agents. The per-run uuid avoids colliding with the on-disk log a prior run materialized once a durable persistence backend is loaded; each run is a new session (a deliberate demo simplification — a real resume-or-create policy is a TODO). Disposed with the calling fiber.
|
||||
Creation and resume are one rollback-covered transaction: construct a private session, concrete agent, and scoped context; await optional setup; enter both registries; announce `session/created` then `agent/created`; emit `agent/session-start`; and only then start the driver. Setup receives the full scoped `Context` as trusted same-process composition code and must not drive the unpublished agent. Ordinary typed identity and option inputs are borrowed under their readonly contract, while seed events and session metadata are validated and snapshotted because they cross the durable session boundary. An optional `AbortSignal` cancels only load/setup/publication and is detached before the returned handle becomes visible.
|
||||
|
||||
The caller fiber and the AgentLoop provider are co-owners. `AgentFactory.createAgent(ownerCtx, options)` and `resume(ownerCtx, options)` receive caller ownership explicitly, while the factory keeps its own dependency context for `sessions`/`llm`/`tools`/`systemPrompt`; this lets a caller inject only `agents` without shrinking the new agent's service surface. Caller unload, handle disposal, or provider unload converge on one memoized quiescence boundary. Provider shutdown waits both resource teardown and the public create/resume wrapper that observed deactivation, so no continuation can publish after dependencies disappear.
|
||||
|
||||
IDs are caller-chosen and assumed globally unique; accidental UUID collisions are outside the supported model. Two concurrent operations with the same agent or session id may both prepare, but the final `enter()` calls arbitrate publication and every loser rolls its private resources back. Each detach is bound to the exact entered object, so a stale disposer cannot remove a later same-id replacement. A detach requested during a synchronous creation notification waits for that dispatch to unwind, preserving created/disposed pairing. Teardown runs stop and drain (including outstanding idle-injection flushes) → detach agent → detach session → unwind scope; IDs become reusable at detach even if private scope cleanup is still finishing. Ordinary non-vetoing `agent/*` notifications go through `agentEvents(ctx, agent)`, per-step assembly goes through `assembleContextFor(agent)`, and turn-end durability checkpoints go through `ctx.sessions.flush(session)`.
|
||||
|
||||
- `ctx.agentLoop.create(id: string, options?: AgentOptions, meta?: { cwd?: string }): ReactLoopAgent` — synchronous no-setup create, used directly by programs and by `cordis.yml`-configured agents. It creates a fresh per-run session id `${id}-session-<uuid>` with optional metadata; the uuid avoids colliding with a prior durable log. Each call is a new session (a deliberate demo simplification — a real resume-or-create policy is a TODO). Disposed with the calling fiber.
|
||||
|
||||
`AgentLoop` also implements the `AgentFactory` seam and registers itself via `ctx.agents.setFactory(this)`, so plugins create/resume agents through `ctx.agents` (the interface):
|
||||
|
||||
- `ctx.agents.create({ agentId, sessionId, meta?, seed?, agentOptions? }): AgentHandle` — programmatic create on a caller-supplied `sessionId` (e.g. an ACP-generated id), NOT `${id}-session`; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix. Returns an [`AgentHandle`](../agent/README.md) — the owner disposes it to tear down exactly this agent (stop loop + await quiescence + unregister + remove session).
|
||||
- `ctx.agents.resume({ agentId, resumeSessionId, agentOptions? }): Promise<AgentHandle>` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)) and resume an agent on it. The live session id is the resumed id; turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). Returns an `AgentHandle`.
|
||||
- `ctx.agents.create({ agentId, sessionId, meta?, seed?, agentOptions?, setup?, signal? }): Promise<AgentHandle>` — programmatic create on a caller-supplied `sessionId`, NOT `${id}-session`. It awaits the unpublished setup transaction before returning; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix after the session boundary validates and snapshots the durable values. `signal` applies only until this promise settles. The resolved [`AgentHandle`](../agent/README.md) owns exact teardown.
|
||||
- `ctx.agents.resume({ agentId, resumeSessionId, agentOptions?, setup?, signal? }): Promise<AgentHandle>` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), reconstruct its history, then await setup against a fresh unpublished agent scope before rollback-covered publication. The live session id is the resumed id; turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). `signal` is creation-only. Returns an `AgentHandle`.
|
||||
|
||||
The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loop fiber (it discards the handle) — only the programmatic factory callers (the ACP bridge and in-process subagent backends) hold a handle and own per-agent teardown.
|
||||
The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loop fiber (it discards the handle). For a programmatic agent, the handle holder is the only consumer-facing teardown capability; AgentLoop provider unload is the independent structural teardown edge, not another handle exposed to application code.
|
||||
|
||||
### Injected services
|
||||
|
||||
@@ -28,21 +34,23 @@ interface Config {
|
||||
agents: Array<{
|
||||
id: string // required
|
||||
model?: string
|
||||
resumeSessionId?: string // load this persisted session instead of creating one
|
||||
cwd?: string // optional workspace cwd for the fresh session
|
||||
}>
|
||||
}
|
||||
```
|
||||
|
||||
Agents listed in config are auto-created at startup. `cwd` applies only to fresh config-created sessions; `resumeSessionId` keeps the persisted session header. There is no per-agent persona: the deployment persona is `dsh-system-prompt`'s own `persona` config, shared by every agent in the context. The plugin registers the built-in `model`/`cwd` prompt variables on `ctx.systemPrompt`, resolved per step from the `assemble({ agent })` context — runtime facts of the agents THIS loop drives, unlike the `harness:identity`/`deployment:persona` sections, which live on `dsh-system-prompt` so they survive a swapped loop plugin.
|
||||
Agents listed in config are auto-created at startup. `cwd` applies only to fresh config-created sessions; `resumeSessionId` keeps the persisted session header. Config agents have no per-agent persona field: they use `dsh-system-prompt`'s deployment default, while programmatic factory callers can register an agent-scoped `deployment:persona` shadow in `setup`. The plugin registers the built-in `model`/`cwd` prompt variables on `ctx.systemPrompt`, resolved per step from `assembleContextFor(agent)` — the helper couples the typed agent with its matching scope selector. These are runtime facts of the agents THIS loop drives, unlike the `harness:identity` and default `deployment:persona` sections, which live on `dsh-system-prompt` so they survive a swapped loop plugin.
|
||||
|
||||
### Classes
|
||||
### Exported concrete class
|
||||
|
||||
- `ReactLoopAgent` — the concrete `Agent` implementation. Owns the inbox (`Inbox`), the per-step `AbortController`, and the loop driver. Everything observable happens through session events and the `agent/*` event taxonomy.
|
||||
- `Inbox` — per-agent queued + steering FIFOs (`enqueue`, `steer`, `drainQueued`, `drainSteering`, `waitForQueued`).
|
||||
- `ReactLoopAgent` — the concrete `Agent` implementation. Its inbox is a JavaScript native-private field, and one prepared session can be claimed by only one concrete driver. Everything observable happens through session events and the `agent/*` event taxonomy.
|
||||
|
||||
`Inbox`, `runLoop`, and the instance-bound publication/start controls are package-internal. The package root does not export them, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than constructing or starting the driver internals. `ReactLoopAgent.send()` and running `steer()` materialize content plus resolved source once as detached, deeply frozen lossless JSON, then share that accepted record between `agent/queued` and the inbox; malformed data throws before either boundary.
|
||||
|
||||
### Loop lifecycle (`loop.ts`)
|
||||
|
||||
One invocation of `runLoop()` drives one agent for its whole lifetime:
|
||||
The internal loop driver runs one agent for its whole lifetime:
|
||||
|
||||
```
|
||||
create agent → emit agent/session-start(source) ⟵ once, before turn 1
|
||||
@@ -55,7 +63,8 @@ forever:
|
||||
if every prompt blocked: 'turn/end'(rejected), no step ⟵ zero-step turn
|
||||
STEP loop:
|
||||
drain steering
|
||||
assembly = systemPrompt.assemble({agent}) ⟵ renderPrompt(assembly) IS the full prompt
|
||||
assembly = await systemPrompt.assemble(assembleContextFor(agent))
|
||||
⟵ renderPrompt(assembly) IS the full prompt
|
||||
prefix ??= waterfall agent/session-prefix ⟵ once per instance (first step): frozen
|
||||
session prefix; on the header, never history
|
||||
await serial agent/pre-step(…, prefix) ⟵ surface mutation (compaction) outside the step;
|
||||
@@ -68,29 +77,33 @@ forever:
|
||||
message = waterfall agent/step-result
|
||||
session('assistant/message')
|
||||
each tool-call: session('tool/call')
|
||||
→ tools.execute() [waterfall tools/pre-execute → dispatch → tools/post-execute]
|
||||
→ tools.execute() [pre waterfall → monotonic guards → around dispatch → post waterfall → final notification]
|
||||
→ session('tool/result')
|
||||
append buffered post-execute additionalContext as session('context/message')(s)
|
||||
drain steering → session('steering/message')
|
||||
cont = waterfall agent/turn-continuation → ContinuationDecision
|
||||
({action:'continue', reason?} records reason as next-step steering)
|
||||
if action==stop (and no pending steering): break
|
||||
pending steering can override an ordinary stop
|
||||
terminal = serial agent/turn-stop → ContinuationStop | undefined
|
||||
(after ordinary decision/reason/steering folding)
|
||||
if terminal stop, or ordinary action==stop with no pending steering: break
|
||||
session('turn/end')
|
||||
await session/flush
|
||||
re-enqueue leftover steering as queued
|
||||
terminal turn: discard steering added before/during close and flush; keep ordinary queued sends
|
||||
ordinary turn: re-enqueue leftover steering as queued
|
||||
idle unless more queued
|
||||
```
|
||||
|
||||
Error containment: a throwing plugin ends the **turn**, never the loop. Dispose mid-turn emits `agent/status('disposed')` and ends with reason `disposed`. A step that hits the model's output-token ceiling makes the turn end `max-tokens` (the rule: any `max-tokens` step in the turn surfaces as `max-tokens`; `disposed`/`aborted`/`error` still take precedence) — distinct from a clean `completed` stop.
|
||||
Error containment: a throwing plugin ends the **turn**, never the loop. A throwing `agent/turn-stop` policy likewise fails the turn closed. A successful terminal stop stays authoritative through `turn/end` and `session/flush`, preventing their listeners from resurrecting steering through the late fallback. Dispose mid-turn emits `agent/status('disposed')` and ends with reason `disposed`. A step that hits the model's output-token ceiling makes the turn end `max-tokens` (the rule: any `max-tokens` step in the turn surfaces as `max-tokens`; `disposed`/`aborted`/`error` still take precedence) — distinct from a clean `completed` stop.
|
||||
|
||||
Cancellation: `agent.cancel()` is the single public stop primitive — it clears the queued + steering FIFOs, aborts the in-flight step, and drives a turn-scoped marker the driver checks at every point a turn could start or continue (right after the idle wait, after the `running` flip, before each step, and at the continuation gate) so a turn about to start is dropped. A cancelled turn ends `aborted`; a queued-but-not-started prompt never runs and cannot be batched into the cancelled turn. The marker is reset once per loop iteration, so a cancel governs exactly one turn and never leaks onto a later prompt. (The loop still aborts its own per-step `AbortController` directly on disposal and from `cancel()`; that controller is loop-internal, not a public verb.)
|
||||
|
||||
### What is NOT here
|
||||
|
||||
Everything that goes beyond "call the model, run the tools, repeat" belongs to plugins listening on the event taxonomy:
|
||||
- Hooks: `agent/session-start`, `agent/prompt-submit`, `agent/pre-step`, `agent/request`, `agent/session-prefix`, `agent/step-result`, `tools/pre-execute`, `tools/post-execute`, `agent/turn-continuation`
|
||||
- Hooks and policy: the relevant `agent/*` checkpoints plus the guarded `tools/pre-execute` → `tools/execute` → `tools/post-execute` → `tools/result` pipeline; exact signatures and modes live in the [generated event catalog](../../../docs/cordis-catalog/events.md)
|
||||
- Compaction: `agent/pre-step`
|
||||
- Sandbox, permission, plan mode: `tools/pre-execute` (deny/ask gate), `tools/post-execute`
|
||||
- Sandbox, permission, plan mode: `tools/pre-execute` for extensible deny/ask, `tools.guard()` for monotonic owner policy, `tools/post-execute` for result decisions, and `tools/result` for final observation
|
||||
- Sub-agents: implemented outside the loop as `ctx.subagents` providers; in-process providers use `ctx.agents.create()` and owned `AgentHandle` teardown, while child streaming/progress and background/poll collection remain deferred.
|
||||
- Persistence: `session/event` + `session/flush`
|
||||
- UI: `session/event` (assistant token stream, boundaries, tool activity) + `agent/*` control events (`agent/status`, `agent/created`/`agent/disposed`)
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
@@ -24,6 +23,7 @@
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-scope": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
@@ -37,6 +37,7 @@
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
|
||||
@@ -7,13 +7,89 @@
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { agentEvents } from '@deepseek-ai/dsh-agent'
|
||||
import type { AgentId, AgentOptions, AgentStatus, SendOptions } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { deepFreeze } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import { Inbox } from './inbox.ts'
|
||||
import { snapshotJsonValue, type Session } from '@deepseek-ai/dsh-session'
|
||||
import { Inbox, type InboxMessage } from './inbox.ts'
|
||||
import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts'
|
||||
|
||||
/** Sessions already claimed by a concrete driver construction. */
|
||||
const claimedDriverSessions = new WeakSet<Session>()
|
||||
|
||||
/** Module-private driver entry: its symbol is absent from the package surface. */
|
||||
const startDriver = Symbol('dsh.agent-loop.start-driver')
|
||||
|
||||
/** Module-private quiescent stop, valid both before and after driver start. */
|
||||
const stopDriver = Symbol('dsh.agent-loop.stop-driver')
|
||||
|
||||
/** Module-private context binding for the mutually referential agent scope. */
|
||||
const bindContext = Symbol('dsh.agent-loop.bind-context')
|
||||
|
||||
/** Module-private publication marker. */
|
||||
const publishAgent = Symbol('dsh.agent-loop.publish-agent')
|
||||
|
||||
/** Factory-owned controls that can operate only on the agent created with them. */
|
||||
export interface PreparedReactLoopAgent {
|
||||
/** The unpublished concrete agent. */
|
||||
agent: ReactLoopAgent
|
||||
/** Mark the agent public so teardown emits its status lifecycle. */
|
||||
markPublished(): void
|
||||
/** Stop the prepared instance even when publication has not started its loop. */
|
||||
dispose(): Promise<void> | void
|
||||
/**
|
||||
* Start its driver after publication and session-start notification.
|
||||
* The returned disposer reaches quiescence for both the loop and every
|
||||
* fire-and-forget idle-injection flush the agent started.
|
||||
*/
|
||||
startDriver(): () => Promise<void> | void
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct one concrete agent together with unforgeable, instance-bound
|
||||
* lifecycle controls. The package surface deliberately exposes neither source
|
||||
* subpaths nor this helper: setup code may identify the concrete class, but it
|
||||
* cannot publish or start the factory's unpublished instance.
|
||||
* @param ctx - the agent-loop service context used for driving and events.
|
||||
* @param id - the concrete agent identity.
|
||||
* @param options - loop options for the agent.
|
||||
* @param session - the prepared session the agent will own.
|
||||
* @returns the agent and closures bound only to that exact instance.
|
||||
*/
|
||||
export function prepareReactLoopAgent(
|
||||
ctx: Context, id: AgentId, options: AgentOptions, session: Session,
|
||||
): PreparedReactLoopAgent {
|
||||
if (claimedDriverSessions.has(session)) {
|
||||
throw new Error(`session "${session.id}" already has a concrete agent driver`)
|
||||
}
|
||||
const agent = new ReactLoopAgent(ctx, id, options, session)
|
||||
claimedDriverSessions.add(session)
|
||||
const dispose = () => agent[stopDriver]()
|
||||
return {
|
||||
agent,
|
||||
markPublished: () => { agent[publishAgent]() },
|
||||
dispose,
|
||||
startDriver: () => {
|
||||
agent[startDriver]()
|
||||
return dispose
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the concrete agent's scope context exactly once. Construction and
|
||||
* scope minting are mutually referential (the scope key is the agent), so the
|
||||
* factory performs this one post-construction binding before setup receives
|
||||
* the unpublished agent. The module-private binding rejects a second bind.
|
||||
* @param agent - the unpublished concrete agent to bind.
|
||||
* @param ctx - its fully extended agent scope context.
|
||||
*/
|
||||
export function bindReactLoopAgentContext(agent: ReactLoopAgent, ctx: Context): void {
|
||||
agent[bindContext](ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
* The concrete {@link Agent} implementation owned by the agent-loop plugin.
|
||||
*
|
||||
@@ -22,14 +98,31 @@ import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts'
|
||||
* the agent/* event taxonomy — plugins never need this class.
|
||||
*/
|
||||
export class ReactLoopAgent implements Agent {
|
||||
/** Queued + steering FIFOs; native-private so callers cannot bypass the public driving verbs. */
|
||||
readonly #inbox = new Inbox()
|
||||
|
||||
/**
|
||||
* The queued + steering FIFOs behind {@link send}/{@link steer}. Public so
|
||||
* the driver loop can drain it; {@link cancel} clears it wholesale.
|
||||
* The agent's scope context ({@link Agent.ctx}), wired by the factory right
|
||||
* after the scope is minted — before the agent is registered, announced, or
|
||||
* driven, so no consumer can observe it unset. Definite-assignment (`!`)
|
||||
* expresses that two-phase construction: the agent object and its scope
|
||||
* context are mutually referential (the scope is keyed BY this agent), so
|
||||
* neither can exist strictly before the other.
|
||||
*/
|
||||
readonly inbox = new Inbox()
|
||||
private boundContext: Context | undefined
|
||||
|
||||
/** The agent's scoped composition context, bound once by its factory. */
|
||||
get ctx(): Context {
|
||||
if (this.boundContext === undefined) throw new Error(`agent "${this.id}" context is not bound`)
|
||||
return this.boundContext
|
||||
}
|
||||
|
||||
private _status: AgentStatus = 'idle'
|
||||
private currentAbort: AbortController | undefined
|
||||
/** Whether runLoop has been installed into {@link done}. */
|
||||
private driverStarted = false
|
||||
/** Whether registry publication began and status disposal is externally visible. */
|
||||
private published = false
|
||||
/**
|
||||
* Turn-scoped cancel marker, set by {@link cancel} and read/cleared by the
|
||||
* driver loop (via the LoopHandle) at every point a turn could start or
|
||||
@@ -61,9 +154,15 @@ export class ReactLoopAgent implements Agent {
|
||||
* the `disposed` transition fires and leave the promise hanging.
|
||||
*/
|
||||
private idleWaiters: (() => void)[] = []
|
||||
/**
|
||||
* Durability checkpoints started by idle {@link inject} calls. `inject()` is
|
||||
* synchronous, so it cannot await them itself; the driver disposer drains
|
||||
* this set before the lifecycle unregisters the agent or detaches its session.
|
||||
*/
|
||||
private pendingIdleFlushes = new Set<Promise<void>>()
|
||||
|
||||
constructor(
|
||||
private ctx: Context,
|
||||
private loopCtx: Context,
|
||||
public readonly id: AgentId,
|
||||
public readonly options: AgentOptions,
|
||||
public readonly session: Session,
|
||||
@@ -86,17 +185,13 @@ export class ReactLoopAgent implements Agent {
|
||||
// waiter (docs/defensive-patterns.md "contain callback exceptions" — a lifecycle await must
|
||||
// not hang on one bad listener).
|
||||
if (status !== 'running') this.settleIdleWaiters()
|
||||
try {
|
||||
this.ctx.emit('agent/status', this, status)
|
||||
} catch (error: unknown) {
|
||||
this.ctx.logger.warn(`agent "${this.id}": agent/status listener threw on ${status}: ${String(error)}`)
|
||||
}
|
||||
agentEvents(this.loopCtx, this).emit('agent/status', status)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve and clear all pending {@link whenIdle} waiters. Called on a
|
||||
* running→idle transition (from {@link setStatus}) and on disposal (from the
|
||||
* {@link start} disposer, which chains `done` for true loop-exit quiescence).
|
||||
* internal driver disposer, which chains `done` for true loop-exit quiescence).
|
||||
*/
|
||||
private settleIdleWaiters(): void {
|
||||
const waiters = this.idleWaiters
|
||||
@@ -108,23 +203,45 @@ export class ReactLoopAgent implements Agent {
|
||||
return options?.source ?? { kind: 'user' }
|
||||
}
|
||||
|
||||
send(content: ContentBlock[], options?: SendOptions): void {
|
||||
if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
|
||||
/**
|
||||
* Accept one public send/steer payload as the exact detached record shared by
|
||||
* the live notification and inbox. Lossless-JSON materialization reads every
|
||||
* nested field once; deep freeze prevents an observer from rewriting queued
|
||||
* work before the loop drains it.
|
||||
*/
|
||||
private acceptInboxMessage(content: ContentBlock[], options?: SendOptions): InboxMessage {
|
||||
const source = this.resolveSource(options)
|
||||
this.inbox.enqueue({ content, source })
|
||||
this.ctx.emit('agent/queued', this, content, { source, steering: false })
|
||||
const accepted = snapshotJsonValue({ content, source })
|
||||
if (accepted === undefined) {
|
||||
throw new TypeError('agent message content and source must be losslessly JSON-serializable')
|
||||
}
|
||||
return deepFreeze(accepted)
|
||||
}
|
||||
|
||||
/** Reject a driving operation once teardown has synchronously closed the agent. */
|
||||
private assertNotDisposed(): void {
|
||||
if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
|
||||
}
|
||||
|
||||
send(content: ContentBlock[], options?: SendOptions): void {
|
||||
this.assertNotDisposed()
|
||||
const accepted = this.acceptInboxMessage(content, options)
|
||||
this.#inbox.enqueue(accepted)
|
||||
const info = { source: accepted.source, steering: false } as const
|
||||
agentEvents(this.loopCtx, this).emit('agent/queued', accepted.content, info)
|
||||
}
|
||||
|
||||
steer(content: ContentBlock[], options?: SendOptions): void {
|
||||
if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
|
||||
this.assertNotDisposed()
|
||||
if (this._status !== 'running') { this.send(content, options); return }
|
||||
const source = this.resolveSource(options)
|
||||
this.inbox.steer({ content, source })
|
||||
this.ctx.emit('agent/queued', this, content, { source, steering: true })
|
||||
const accepted = this.acceptInboxMessage(content, options)
|
||||
this.#inbox.steer(accepted)
|
||||
const info = { source: accepted.source, steering: true } as const
|
||||
agentEvents(this.loopCtx, this).emit('agent/queued', accepted.content, info)
|
||||
}
|
||||
|
||||
inject(content: ContentBlock[], options?: SendOptions): void {
|
||||
if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
|
||||
this.assertNotDisposed()
|
||||
const source = this.resolveSource(options)
|
||||
if (isTurnOpen(this.session)) {
|
||||
// A turn is open in the LOG (decided from the log, not agent status —
|
||||
@@ -136,61 +253,49 @@ export class ReactLoopAgent implements Agent {
|
||||
// No turn open: wrap the injection in a one-shot turn so every event stays
|
||||
// turn-enclosed (the durability/replay boundary is the turn).
|
||||
const turn = lastTurnNumber(this.session) + 1
|
||||
// Once turn/start enters the log, a turn/end is OWED no matter what — even
|
||||
// if a throwing `session/event` listener escapes from the turn/start append
|
||||
// (Session.append pushes the event BEFORE notifying listeners) or the
|
||||
// context/message append throws (non-serializable content, throwing
|
||||
// listener). The finally re-checks the log via isTurnOpen() and closes the
|
||||
// turn if one was actually opened, so the log never carries a permanently
|
||||
// open injection turn that would corrupt later turns/replay. (If the
|
||||
// turn/start append throws BEFORE pushing — non-serializable trigger, which
|
||||
// can't happen for our fixed trigger — no turn was opened and none is owed.)
|
||||
// Once turn/start enters the log, a turn/end is owed even if the message
|
||||
// append fails acceptance or pre-commit validation. The finally re-checks
|
||||
// the log and closes only a turn that actually opened; post-commit observers
|
||||
// are contained by Session and cannot create a false append failure.
|
||||
try {
|
||||
this.session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
|
||||
this.session.append('context/message', { content, source }, { surfaceOp: 'append' })
|
||||
} finally {
|
||||
// Close the turn if turn/start made it into the log. Contain a throwing
|
||||
// turn/end listener: Session.append pushes before notifying, so a throw
|
||||
// here still leaves turn/end in the log (the turn is balanced) — swallow
|
||||
// it so it neither replaces the original exception nor skips the flush
|
||||
// decision below. (It surfaces through the flush path is not needed; the
|
||||
// turn-balance contract is what matters and it holds.)
|
||||
// Close the turn if turn/start made it into the log. A pre-commit veto
|
||||
// must escape rather than being mistaken for a committed turn/end.
|
||||
if (isTurnOpen(this.session)) {
|
||||
try {
|
||||
this.session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
} catch {
|
||||
// turn/end is already in the log (pushed before the listener threw),
|
||||
// so the turn is balanced; the throw is the listener's bug.
|
||||
}
|
||||
this.session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
}
|
||||
// Decide the durability checkpoint from the LOG, not a flag: a turn was
|
||||
// recorded iff this turn's turn/start is logged (it may have been closed
|
||||
// by a throwing-listener turn/end above, which still counts). A
|
||||
// `turnRecorded` boolean set after append('turn/end') would be skipped by
|
||||
// a throwing turn/end listener, losing the flush for a balanced in-memory
|
||||
// turn (crash before the next turn/dispose would drop the idle injection).
|
||||
// Decide the durability checkpoint from the log: an accepted one-shot
|
||||
// turn must be flushed even when its message append was the failing step.
|
||||
const turnRecorded = this.session.events.some(e => e.type === 'turn/start' && e.data.turn === turn)
|
||||
// Checkpoint the one-shot turn for durability, exactly as the loop does at
|
||||
// every turn/end. The loop is NOT running (we are idle), so nothing else
|
||||
// will flush this turn. Fire-and-forget with error containment: inject()
|
||||
// is synchronous, and a persistence backend failing must not throw into
|
||||
// the caller (e.g. a tool-bash task-done callback). Disposal still drains
|
||||
// independently, so a slow flush is safe. A flush failure is reported via
|
||||
// agent/error (step 0 — the idle-injection convention, there is no real
|
||||
// step) AND the logger, mirroring the loop's post-turn/end flush path so
|
||||
// plugins monitoring agent/error see idle-injection persistence failures
|
||||
// too. A throwing agent/error listener is contained.
|
||||
// independently, so a slow flush is safe. The task is tracked until it
|
||||
// settles: driver disposal awaits every pending idle-injection checkpoint
|
||||
// before unregistering the agent or detaching the session. A flush failure
|
||||
// is reported via agent/error (step 0 — the idle-injection convention,
|
||||
// there is no real step) AND the logger, mirroring the loop's post-turn/end
|
||||
// flush path so plugins monitoring agent/error see idle-injection
|
||||
// persistence failures too. A throwing agent/error listener is contained.
|
||||
if (turnRecorded) {
|
||||
void Promise.resolve(this.ctx.parallel('session/flush', this.session)).catch((error: unknown) => {
|
||||
const err = error instanceof Error ? error : new Error(String(error))
|
||||
this.ctx.logger.warn(`agent "${this.id}": flush after idle injection failed: ${err.message}`)
|
||||
try {
|
||||
this.ctx.emit('agent/error', this, turn, 0, err)
|
||||
} catch {
|
||||
// contained: the failure is already logged; a throwing agent/error
|
||||
// listener must not escape this fire-and-forget catch.
|
||||
}
|
||||
// Through the store's flush (the carrier owner), never a raw parallel.
|
||||
const flush = this.loopCtx.sessions.flush(this.session).catch((error: unknown) => {
|
||||
const rendered = renderThrown(error)
|
||||
const err = error instanceof Error ? error : new Error(rendered)
|
||||
this.loopCtx.logger.warn(`agent "${this.id}": flush after idle injection failed: ${rendered}`)
|
||||
agentEvents(this.loopCtx, this).emit('agent/error', turn, 0, err)
|
||||
})
|
||||
this.pendingIdleFlushes.add(flush)
|
||||
// Attach the same retirement callback to both settlement arms so even a
|
||||
// logger failure in the catch above cannot become an unhandled rejection.
|
||||
// Teardown uses allSettled for the same reason: a reporting failure must
|
||||
// not strand ownership.
|
||||
const retire = (): void => { this.pendingIdleFlushes.delete(flush) }
|
||||
void flush.then(retire, retire)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -205,7 +310,7 @@ export class ReactLoopAgent implements Agent {
|
||||
// the pre-step window (a send() queued but the loop not yet flipped to
|
||||
// running) has status `idle` with `hasQueued` true, and the marker exists
|
||||
// precisely to cover it.
|
||||
if (this._status === 'running' || this.currentAbort !== undefined || this.inbox.hasQueued || this.inbox.hasSteering) {
|
||||
if (this._status === 'running' || this.currentAbort !== undefined || this.#inbox.hasQueued || this.#inbox.hasSteering) {
|
||||
this.cancelRequested = true
|
||||
// Capture the resolved reason for the marker-only windows (pre-step /
|
||||
// continuation). The mid-step path reads it from abort.signal.reason
|
||||
@@ -216,7 +321,7 @@ export class ReactLoopAgent implements Agent {
|
||||
// cancelled turn's steering is not re-enqueued). Cleared directly even when
|
||||
// the loop is parked in waitForQueued — there is no turn to stop and nothing
|
||||
// left for the parked loop to run, so no wake is needed.
|
||||
this.inbox.clear()
|
||||
this.#inbox.clear()
|
||||
// Interrupt an in-flight step immediately (the running turn observes the
|
||||
// abort and ends `aborted`). The marker covers the windows where no step is
|
||||
// running (pre-step, continuation).
|
||||
@@ -234,12 +339,12 @@ export class ReactLoopAgent implements Agent {
|
||||
* fully ended) or chaining {@link done} on `disposed` (wait for the loop to
|
||||
* actually exit). Implements the {@link Agent.whenIdle} contract: a non-owner
|
||||
* quiescence-observation hook, distinct from teardown (a lifecycle owner stops
|
||||
* and unregisters via `AgentHandle.dispose()`, which awaits {@link done}
|
||||
* directly, not through this).
|
||||
* and unregisters via `AgentHandle.dispose()`, whose driver boundary awaits
|
||||
* both {@link done} and outstanding idle-injection flushes, not through this).
|
||||
*/
|
||||
whenIdle(): Promise<void> {
|
||||
if (this._status === 'disposed') return this.done
|
||||
if (this._status !== 'running' && !this.inbox.hasQueued) return Promise.resolve()
|
||||
if (this._status !== 'running' && !this.#inbox.hasQueued) return Promise.resolve()
|
||||
// Register an internal waiter (resolved by settleIdleWaiters on the next
|
||||
// running→idle/disposed transition), NOT an effect-scoped `ctx.on` listener:
|
||||
// a concurrent fiber disposal runs this agent's listener disposers, which
|
||||
@@ -254,17 +359,27 @@ export class ReactLoopAgent implements Agent {
|
||||
})
|
||||
}
|
||||
|
||||
/** Bind the mutually referential scope context once. */
|
||||
private [bindContext](ctx: Context): void {
|
||||
if (this.boundContext !== undefined) throw new Error(`agent "${this.id}" context is already bound`)
|
||||
this.boundContext = ctx
|
||||
}
|
||||
|
||||
/** Mark that public lifecycle publication began. */
|
||||
private [publishAgent](): void {
|
||||
this.published = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the driver loop. Returns a disposer: calling it sets status to
|
||||
* `disposed`, emits `agent/status('disposed')`, resolves the disposed
|
||||
* promise (unblocking the idle wait), releases any `whenIdle` waiters, and
|
||||
* aborts the current request if any. The returned `agent.done` promise
|
||||
* resolves once the loop exits.
|
||||
* @returns the disposer — idempotent and infallible (it runs inside the
|
||||
* fiber's LIFO disposal chain, where a throw would skip later disposers).
|
||||
* Start the driver loop. The prepared controller already owns its stable
|
||||
* disposer, so teardown can mark the agent disposed even in the narrow
|
||||
* publication window before this method runs.
|
||||
*/
|
||||
start(): () => void {
|
||||
this.done = runLoop(this.ctx, this, {
|
||||
[startDriver](): void {
|
||||
if (this._status === 'disposed') return
|
||||
this.driverStarted = true
|
||||
this.done = runLoop(this.loopCtx, this, {
|
||||
inbox: this.#inbox,
|
||||
setStatus: (status) => { this.setStatus(status) },
|
||||
setAbort: controller => void (this.currentAbort = controller),
|
||||
disposed: this.disposed,
|
||||
@@ -280,11 +395,15 @@ export class ReactLoopAgent implements Agent {
|
||||
// that would resolve a freshly-queued prompt as cancelled.
|
||||
settleIdle: () => { this.settleIdleWaiters() },
|
||||
})
|
||||
// The disposer must be infallible: it runs inside the fiber's LIFO
|
||||
// disposal chain, where a throw would skip later disposers (e.g. the
|
||||
// registry unregistration) and leave `done` pending forever.
|
||||
return () => {
|
||||
if (this._status === 'disposed') return
|
||||
}
|
||||
|
||||
/**
|
||||
* Quiescent stop shared by pre-start rollback and live teardown. It marks the
|
||||
* agent disposed synchronously, contains an unexpected loop rejection, and
|
||||
* drains every idle-injection flush before resolving.
|
||||
*/
|
||||
private [stopDriver](): Promise<void> | void {
|
||||
if (this._status !== 'disposed') {
|
||||
this._status = 'disposed'
|
||||
this.resolveDisposed()
|
||||
// Release whenIdle waiters BEFORE the (guarded) event emit — they are
|
||||
@@ -292,14 +411,39 @@ export class ReactLoopAgent implements Agent {
|
||||
// waiter chains `done`, so it resolves only once the loop actually exits.
|
||||
this.settleIdleWaiters()
|
||||
this.currentAbort?.abort('disposed')
|
||||
// setStatus refuses transitions out of 'disposed', so emit directly —
|
||||
// 'disposed' is part of the agent/status contract. Guarded: a throwing
|
||||
// listener must not break the disposal chain.
|
||||
try {
|
||||
this.ctx.emit('agent/status', this, 'disposed')
|
||||
} catch {
|
||||
// listener error during disposal — nothing safe left to do with it
|
||||
// An unpublished rollback has no public status lifecycle to announce.
|
||||
// Once publication begins, disposed is part of the agent/status contract.
|
||||
if (this.published) {
|
||||
agentEvents(this.loopCtx, this).emit('agent/status', 'disposed')
|
||||
}
|
||||
}
|
||||
// Before runLoop starts there is normally nothing asynchronous to drain;
|
||||
// keep publication rollback synchronous so create() cannot throw while its
|
||||
// session/agent entries are still briefly live. A session-start listener
|
||||
// may have called inject(), however, so preserve
|
||||
// its durability checkpoint as a real quiescence boundary.
|
||||
if (!this.driverStarted && this.pendingIdleFlushes.size === 0) return
|
||||
return this.drainDriver()
|
||||
}
|
||||
|
||||
/** Await the loop (when started) and every outstanding idle flush. */
|
||||
private async drainDriver(): Promise<void> {
|
||||
// An unexpected driver rejection must not skip registry/session/scope
|
||||
// cleanup. The normal loop contains turn failures itself; allSettled is the
|
||||
// final lifecycle backstop for anything outside those boundaries.
|
||||
await Promise.allSettled([this.done])
|
||||
// No new inject() can start after the synchronous disposed transition.
|
||||
// Loop because settled tasks retire themselves in promise reactions that
|
||||
// may run beside this continuation; either the set is empty or this waits
|
||||
// the exact remaining quiescence boundary. allSettled keeps a failure in
|
||||
// error reporting from skipping registry/session/scope disposers.
|
||||
while (this.pendingIdleFlushes.size > 0) {
|
||||
await Promise.allSettled([...this.pendingIdleFlushes])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Render an ordinary thrown value for the error event and log. */
|
||||
function renderThrown(value: unknown): string {
|
||||
return value instanceof Error ? value.message : String(value)
|
||||
}
|
||||
|
||||
@@ -1,27 +1,319 @@
|
||||
/**
|
||||
* THE concrete agent plugin: creates ReactLoopAgents, runs their loops, and
|
||||
* registers them in ctx.agents. Deliberately thin — every behavior beyond
|
||||
* "call the model, run the tools, repeat" belongs to plugins on the event
|
||||
* taxonomy.
|
||||
* Concrete agent-loop plugin: creates scoped ReactLoopAgents, publishes them
|
||||
* through the agent/session registries, and owns their ordered teardown.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-agent-loop
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import { Context, FiberState, Service } from 'cordis'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import z from 'schemastery'
|
||||
import type { AgentFactory, AgentHandle, AgentId, AgentOptions, CreateAgentOptions, ResumeAgentOptions, SessionStartSource } from '@deepseek-ai/dsh-agent'
|
||||
import { createScope } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scope } from '@deepseek-ai/dsh-scope'
|
||||
import { agentEvents } from '@deepseek-ai/dsh-agent'
|
||||
import type {
|
||||
AgentFactory,
|
||||
AgentHandle,
|
||||
AgentId,
|
||||
AgentOptions,
|
||||
CreateAgentOptions,
|
||||
ResumeAgentOptions,
|
||||
SessionStartSource,
|
||||
} from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId, type SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
||||
import { ReactLoopAgent } from './agent.ts'
|
||||
import {
|
||||
bindReactLoopAgentContext,
|
||||
prepareReactLoopAgent,
|
||||
ReactLoopAgent,
|
||||
} from './agent.ts'
|
||||
import type { PreparedReactLoopAgent } from './agent.ts'
|
||||
|
||||
export { ReactLoopAgent } from './agent.ts'
|
||||
export { Inbox, type InboxMessage } from './inbox.ts'
|
||||
export { runLoop } from './loop.ts'
|
||||
|
||||
/** Fiber states that cannot own or serve a new lifecycle. */
|
||||
const INACTIVE_STATES: ReadonlySet<FiberState> = new Set([
|
||||
FiberState.UNLOADING,
|
||||
FiberState.DISPOSED,
|
||||
FiberState.FAILED,
|
||||
])
|
||||
|
||||
/** Factory-level ownership of every preparing or live transaction. */
|
||||
class FactoryOwnership {
|
||||
private accepting = true
|
||||
private transactions = new Set<AgentCreationTransaction>()
|
||||
|
||||
constructor(private readonly fiber: Context['fiber']) {}
|
||||
|
||||
isActive(): boolean {
|
||||
return this.accepting && !INACTIVE_STATES.has(this.fiber.state)
|
||||
}
|
||||
|
||||
track(transaction: AgentCreationTransaction): () => void {
|
||||
this.transactions.add(transaction)
|
||||
return () => { this.transactions.delete(transaction) }
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.accepting = false
|
||||
const reason = new Error('agent loop is not active')
|
||||
await Promise.all(
|
||||
[...this.transactions].map(transaction => transaction.disposeForFactory(reason)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the public cancellation error while preserving a caller-supplied cause. */
|
||||
function signalAbortError(id: AgentId, signal: AbortSignal): Error {
|
||||
if (signal.reason instanceof Error) return signal.reason
|
||||
return new Error(`agent "${id}" creation aborted`, { cause: signal.reason })
|
||||
}
|
||||
|
||||
/**
|
||||
* One create/resume transaction from caller ownership through unpublished
|
||||
* setup, rollback-covered publication, and final quiescent teardown.
|
||||
*
|
||||
* The class deliberately owns the state machine in one place. Registries only
|
||||
* arbitrate identity at their final `enter()` calls; before that point every
|
||||
* resource is private to this transaction.
|
||||
*/
|
||||
class AgentCreationTransaction {
|
||||
private active = true
|
||||
private failure: Error | undefined
|
||||
private readonly deactivation = Promise.withResolvers<void>()
|
||||
private readonly publication = Promise.withResolvers<void>()
|
||||
private readonly torndown = Promise.withResolvers<void>()
|
||||
private readonly wrapperCompletion = Promise.withResolvers<void>()
|
||||
private preparing: Promise<void> | undefined
|
||||
private driver: PreparedReactLoopAgent | undefined
|
||||
private scope: Scope | undefined
|
||||
private session: Session | undefined
|
||||
private lifecycleDispose: (() => Promise<void> | void) | undefined
|
||||
private detachSession: (() => void) | undefined
|
||||
private detachAgent: (() => void) | undefined
|
||||
private publishing = false
|
||||
private cleanupTask: Promise<void> | undefined
|
||||
private ownerFollowing = true
|
||||
private readonly ownerDispose: () => Promise<void> | void
|
||||
private readonly untrackFactory: () => void
|
||||
private readonly abortListener: (() => void) | undefined
|
||||
readonly ownerAgent: Context['agent']
|
||||
readonly ownerFiber: Context['fiber']
|
||||
|
||||
constructor(
|
||||
private readonly loopCtx: Context,
|
||||
private readonly ownerCtx: Context,
|
||||
private readonly ownership: FactoryOwnership,
|
||||
readonly id: AgentId,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
ownerCtx.fiber.assertActive()
|
||||
this.ownerAgent = ownerCtx.agent
|
||||
this.ownerFiber = ownerCtx.fiber
|
||||
if (!ownership.isActive()) throw new Error('agent loop is not active')
|
||||
this.ownerDispose = ownerCtx.effect(() => () => {
|
||||
if (!this.ownerFollowing) return
|
||||
return this.dispose(new Error(`agent "${id}" setup aborted: owner disposed during setup`))
|
||||
}, `agentLoop.owner(${id})`)
|
||||
this.untrackFactory = ownership.track(this)
|
||||
if (signal === undefined) {
|
||||
this.abortListener = undefined
|
||||
} else {
|
||||
this.abortListener = () => {
|
||||
/* v8 ignore next 3 -- transaction teardown contains callback/driver failures; rejection is a future-drift backstop. */
|
||||
void this.dispose(signalAbortError(id, signal)).catch((error: unknown) => {
|
||||
this.loopCtx.logger.error(error)
|
||||
})
|
||||
}
|
||||
signal.addEventListener('abort', this.abortListener, { once: true })
|
||||
if (signal.aborted) this.deactivate(signalAbortError(id, signal))
|
||||
}
|
||||
this.signal = signal
|
||||
}
|
||||
|
||||
private readonly signal: AbortSignal | undefined
|
||||
|
||||
/** Whether caller, provider, and optional parent-agent ownership remain live. */
|
||||
isActive(): boolean {
|
||||
return this.active
|
||||
&& this.ownership.isActive()
|
||||
&& this.ownerFiber.uid !== null
|
||||
&& !INACTIVE_STATES.has(this.ownerFiber.state)
|
||||
&& this.ownerAgent?.status !== 'disposed'
|
||||
}
|
||||
|
||||
/** Fail synchronously at every real lifecycle boundary after deactivation. */
|
||||
assertActive(): void {
|
||||
if (this.isActive()) return
|
||||
if (!this.ownership.isActive()) throw new Error('agent loop is not active')
|
||||
throw this.failure ?? new Error(`agent "${this.id}" setup aborted: owner disposed during setup`)
|
||||
}
|
||||
|
||||
/** Race an external async operation against structural/signal deactivation. */
|
||||
async waitFor<T>(operation: PromiseLike<T> | T): Promise<T> {
|
||||
this.assertActive()
|
||||
return await Promise.race([
|
||||
Promise.resolve(operation),
|
||||
this.deactivation.promise.then(() => {
|
||||
/* v8 ignore next -- deactivate() assigns failure before resolving deactivation. */
|
||||
throw this.failure ?? new Error(`agent "${this.id}" creation deactivated`)
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
/** Construct the driver and scope, then install their complete ordered lifecycle. */
|
||||
prepare(options: AgentOptions, session: Session): ReactLoopAgent {
|
||||
this.assertActive()
|
||||
const gate = Promise.withResolvers<void>()
|
||||
this.preparing = gate.promise
|
||||
try {
|
||||
this.session = session
|
||||
const driver = prepareReactLoopAgent(this.loopCtx, this.id, options, session)
|
||||
this.driver = driver
|
||||
const agent = driver.agent
|
||||
const scope = createScope(this.loopCtx, agent)
|
||||
this.scope = scope
|
||||
bindReactLoopAgentContext(agent, scope.ctx.extend({ agent }))
|
||||
this.installLifecycle(scope, driver)
|
||||
this.assertActive()
|
||||
return agent
|
||||
} catch (error: unknown) {
|
||||
if (!this.isActive() && error instanceof Error && /inactive context/.test(error.message)) {
|
||||
throw this.failure ?? this.disposalReason()
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
gate.resolve()
|
||||
this.preparing = undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Register the exact scope disposer inside the ordered transaction effect. */
|
||||
private installLifecycle(scope: Scope, driver: PreparedReactLoopAgent): void {
|
||||
this.lifecycleDispose = this.ownerCtx.effect(function* (this: AgentCreationTransaction) {
|
||||
// First yielded, disposed last.
|
||||
yield () => { this.finish() }
|
||||
yield scope.rawDispose
|
||||
yield () => {
|
||||
this.detachSession?.()
|
||||
this.detachSession = undefined
|
||||
}
|
||||
yield () => {
|
||||
this.detachAgent?.()
|
||||
this.detachAgent = undefined
|
||||
}
|
||||
// Last yielded, disposed first.
|
||||
yield () => {
|
||||
this.deactivate(this.disposalReason())
|
||||
if (this.publishing) {
|
||||
return this.publication.promise.then(() => driver.dispose())
|
||||
}
|
||||
return driver.dispose()
|
||||
}
|
||||
}.bind(this), `agentLoop.lifecycle(${this.id})`)
|
||||
}
|
||||
|
||||
/** Publish the exact prepared objects and start the driver. */
|
||||
publish(source: SessionStartSource): AgentHandle {
|
||||
this.assertActive()
|
||||
const driver = this.driver
|
||||
/* v8 ignore next -- publish() is private and every caller invokes prepare() first. */
|
||||
if (driver === undefined) throw new Error(`agent "${this.id}" is not prepared`)
|
||||
const agent = driver.agent
|
||||
const session = this.session
|
||||
/* v8 ignore next -- prepare() assigns the session before it can produce the driver above. */
|
||||
if (session === undefined) throw new Error(`agent "${this.id}" has no prepared session`)
|
||||
this.publishing = true
|
||||
try {
|
||||
this.detachSession = agent.ctx.sessions.enter(session)
|
||||
this.detachAgent = this.loopCtx.agents.enter(agent)
|
||||
|
||||
agent.ctx.sessions.announce(session)
|
||||
this.assertActive()
|
||||
this.loopCtx.agents.announce(agent)
|
||||
this.assertActive()
|
||||
|
||||
driver.markPublished()
|
||||
agentEvents(this.loopCtx, agent).emit('agent/session-start', source)
|
||||
this.assertActive()
|
||||
driver.startDriver()
|
||||
return { agent, dispose: () => this.dispose() }
|
||||
} finally {
|
||||
this.publishing = false
|
||||
this.publication.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
/** Mark the transaction inactive exactly once and wake load/setup races. */
|
||||
private deactivate(reason: Error): void {
|
||||
if (!this.active) return
|
||||
this.active = false
|
||||
this.failure = reason
|
||||
this.deactivation.resolve()
|
||||
}
|
||||
|
||||
/** Choose the structural cause when an owner/factory effect starts teardown first. */
|
||||
private disposalReason(): Error {
|
||||
if (this.failure !== undefined) return this.failure
|
||||
if (!this.ownership.isActive()) return new Error('agent loop is not active')
|
||||
if (this.ownerFiber.uid === null || INACTIVE_STATES.has(this.ownerFiber.state) || this.ownerAgent?.status === 'disposed') {
|
||||
return new Error(`agent "${this.id}" setup aborted: owner disposed during setup`)
|
||||
}
|
||||
return new Error(`agent "${this.id}" lifecycle disposed`)
|
||||
}
|
||||
|
||||
/** Complete ownership bookkeeping after every resource reached quiescence. */
|
||||
private finish(): void {
|
||||
this.untrackFactory()
|
||||
this.ownerFollowing = false
|
||||
void this.ownerDispose()
|
||||
this.torndown.resolve()
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate and quiesce this transaction. The promise is memoized because
|
||||
* Cordis effect disposers are single-shot while handles promise shared
|
||||
* quiescence to every racing owner.
|
||||
*/
|
||||
dispose(reason = new Error(`agent "${this.id}" lifecycle disposed`)): Promise<void> {
|
||||
this.deactivate(reason)
|
||||
return (this.cleanupTask ??= (async () => {
|
||||
if (this.preparing !== undefined) await this.preparing
|
||||
if (this.lifecycleDispose !== undefined) {
|
||||
await this.lifecycleDispose()
|
||||
await this.torndown.promise
|
||||
return
|
||||
}
|
||||
try {
|
||||
await this.driver?.dispose()
|
||||
} finally {
|
||||
try {
|
||||
await this.scope?.dispose()
|
||||
} finally {
|
||||
this.finish()
|
||||
}
|
||||
}
|
||||
})())
|
||||
}
|
||||
|
||||
/** Mark the public create/resume continuation settled and detach its creation-only signal. */
|
||||
finishWrapper(): void {
|
||||
if (this.signal !== undefined && this.abortListener !== undefined) {
|
||||
this.signal.removeEventListener('abort', this.abortListener)
|
||||
}
|
||||
this.wrapperCompletion.resolve()
|
||||
}
|
||||
|
||||
/** Factory shutdown joins both resource teardown and the public wrapper's deactivation continuation. */
|
||||
async disposeForFactory(reason: Error): Promise<void> {
|
||||
await this.dispose(reason)
|
||||
await this.wrapperCompletion.promise
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
@@ -29,52 +321,24 @@ declare module 'cordis' {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin config: the agents to create — or resume, via `resumeSessionId` —
|
||||
* declaratively at startup, so a cordis.yml deployment needs no code.
|
||||
*/
|
||||
/** Plugin configuration for declarative startup agents. */
|
||||
export interface Config {
|
||||
/** Agents created from configuration at startup. */
|
||||
/** Agents created or resumed at plugin startup. */
|
||||
agents: (AgentOptions & {
|
||||
/** Agent id to register under; also seeds the fresh per-run session id (`${id}-session-<uuid>`). */
|
||||
/** Registry identity for the live agent. */
|
||||
id: AgentId
|
||||
/** Optional workspace cwd for the config-created fresh session. */
|
||||
/** Optional workspace for a fresh session. */
|
||||
cwd?: string
|
||||
/**
|
||||
* If set, the config agent RESUMES this persisted session id instead of
|
||||
* starting a fresh `${id}-session-<uuid>`. Sourced from an env var in
|
||||
* cordis.yml (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`), so a
|
||||
* demo can continue a prior conversation without code changes. Requires a
|
||||
* `dsh-session-persistence` backend; the resume is deferred until that
|
||||
* service is available (via `ctx.inject`) and the loaded session's events
|
||||
* seed the live session so history continues.
|
||||
*
|
||||
* The schema accepts a plain string at runtime (cordis.yml values are
|
||||
* untyped); the brand is compile-time only — the config format is the
|
||||
* boundary where an id enters, so the TYPE declares the brand here.
|
||||
*/
|
||||
/** Persisted session to resume instead of creating a fresh session. */
|
||||
resumeSessionId?: SessionId
|
||||
})[]
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent-loop plugin (`ctx.agentLoop`): creates {@link ReactLoopAgent}s, runs
|
||||
* their loops, and registers them in `ctx.agents`. Also implements the
|
||||
* {@link AgentFactory} seam, so plugins create/resume agents through
|
||||
* `ctx.agents` (the interface) without depending on this concrete package.
|
||||
*
|
||||
* The loop itself is deliberately thin — every behavior beyond "call the
|
||||
* model, run the tools, repeat" belongs to plugins listening on the event
|
||||
* taxonomy declared in @deepseek-ai/dsh-agent.
|
||||
*/
|
||||
/** Concrete ReactLoopAgent factory and driver service. */
|
||||
export class AgentLoop extends Service implements AgentFactory {
|
||||
static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt']
|
||||
|
||||
// The schema validates plain strings (cordis.yml config values are untyped at
|
||||
// runtime); the {@link Config} TYPE declares the branded `id`/`resumeSessionId`
|
||||
// because the config format is the boundary where an id enters. The brand is a
|
||||
// zero-cost compile-time cast, so the runtime schema stays string-based and we
|
||||
// assert the branded view once here — the single schema boundary.
|
||||
/** Runtime schema for declarative agents. */
|
||||
static Config = z.object({
|
||||
agents: z.array(z.object({
|
||||
id: z.string().required(),
|
||||
@@ -84,269 +348,145 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
})).default([]),
|
||||
}) as unknown as z<Config>
|
||||
|
||||
private readonly ownership: FactoryOwnership
|
||||
/** Plain holder prevents Cordis from re-tracing the factory's dependency context through a caller shadow. */
|
||||
private readonly runtime: { ctx: Context }
|
||||
|
||||
constructor(ctx: Context, public config: Config) {
|
||||
super(ctx, 'agentLoop')
|
||||
// Provide the agent-creation factory to the registry (effect-scoped: the
|
||||
// slot is cleared on dispose).
|
||||
ctx.effect(() => this.ctx.agents.setFactory(this), 'agentLoop.setFactory()')
|
||||
// The prompt variables the shipped loop provides, registered once. The
|
||||
// sections themselves (`harness:identity`, `deployment:persona`) belong to
|
||||
// dsh-system-prompt — they must survive a swapped loop plugin — but
|
||||
// `{{model}}`/`{{cwd}}` are runtime facts of the agents THIS loop drives:
|
||||
// it assembles with `{ agent }` each step (loop.ts), and the variables
|
||||
// project the agent's configured model and its session workspace from that
|
||||
// context. A provider returns undefined when the fact is absent
|
||||
// (renderPrompt then rejects a persona that claims it — fail loud).
|
||||
this.ownership = new FactoryOwnership(ctx.fiber)
|
||||
this.runtime = { ctx }
|
||||
ctx.effect(() => () => this.ownership.dispose(), 'agentLoop.transactions()')
|
||||
ctx.effect(() => ctx.agents.setFactory(this), 'agentLoop.setFactory()')
|
||||
ctx.systemPrompt.variable('model', context => context.agent?.options.model)
|
||||
ctx.systemPrompt.variable('cwd', context => context.agent?.session.header.cwd)
|
||||
|
||||
for (const { id, cwd, resumeSessionId, ...options } of config.agents) {
|
||||
if (resumeSessionId !== undefined && resumeSessionId !== '') {
|
||||
// Resume a prior session instead of starting fresh. resume() needs
|
||||
// `ctx.sessionPersistence`, which may load AFTER this plugin (cordis.yml
|
||||
// lists the backend later). `ctx.inject(['sessionPersistence'], cb)`
|
||||
// runs `cb` with a child ctx once the service exists; the child reads
|
||||
// the persistence and hands it to resumeWith (which uses this.ctx — the
|
||||
// parent — for sessions/registry, all in AgentLoop's static inject). A
|
||||
// failed resume is contained + logged: startup must not crash.
|
||||
ctx.effect(() => {
|
||||
const fiber = this.ctx.inject(['sessionPersistence'], (childCtx: Context) => {
|
||||
void this.resumeWith(childCtx.sessionPersistence, { agentId: id, resumeSessionId, agentOptions: options })
|
||||
.catch((error: unknown) => {
|
||||
this.ctx.logger.warn(`agent "${id}": config-driven resume of "${resumeSessionId}" failed: ${String(error)}`)
|
||||
})
|
||||
})
|
||||
return () => void fiber.dispose()
|
||||
}, `agentLoop.resume(${id})`)
|
||||
} else {
|
||||
if (resumeSessionId === undefined || resumeSessionId === '') {
|
||||
this.create(id, options, cwd === undefined ? {} : { cwd })
|
||||
continue
|
||||
}
|
||||
ctx.effect(() => {
|
||||
const fiber = ctx.inject(['sessionPersistence'], (childCtx: Context) => {
|
||||
void this.resumeWith(ctx, childCtx.sessionPersistence, {
|
||||
agentId: id,
|
||||
resumeSessionId,
|
||||
agentOptions: options,
|
||||
}).catch((error: unknown) => {
|
||||
ctx.logger.warn(`agent "${id}": config-driven resume of "${resumeSessionId}" failed: ${String(error)}`)
|
||||
})
|
||||
})
|
||||
return fiber.dispose
|
||||
}, `agentLoop.resume(${id})`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Config-driven create: an agent on a FRESH, non-colliding session id per run
|
||||
* (`${id}-session-<uuid>`). Used for `cordis.yml`-configured agents and as
|
||||
* the shared core for the programmatic factory {@link createAgent}.
|
||||
*
|
||||
* Why a per-run id, not a fixed `${id}-session`: once a durable persistence
|
||||
* backend is loaded, a fixed id collides on the second run — the backend
|
||||
* refuses to re-create an id whose log already exists on disk (the SessionId
|
||||
* is the identity). A fresh id means each run is a new session.
|
||||
*
|
||||
* TODO(demo): each run starting a brand-new session is fine for demos but is
|
||||
* NOT real conversation continuity. A production config-driven agent needs a
|
||||
* deliberate resume-or-create policy (resume the prior session if one exists,
|
||||
* else start fresh) or an explicit caller-chosen session id — revisit when the
|
||||
* UI/ACP path owns session selection.
|
||||
* @param id - the agent id; also seeds the generated session id.
|
||||
* @param options - loop options (model, limits, …); defaults applied per option.
|
||||
* @param meta - optional session metadata for the fresh session.
|
||||
* @returns the running agent, owned by the calling fiber (no handle).
|
||||
* Create an agent on a fresh per-run session, owned by the accessing fiber.
|
||||
* Constructor-driven config calls use the loop fiber itself.
|
||||
* @param id - agent registry id.
|
||||
* @param options - concrete loop options.
|
||||
* @param meta - optional fresh-session workspace metadata.
|
||||
* @returns the published running agent.
|
||||
*/
|
||||
create(id: AgentId, options: AgentOptions = {}, meta: Pick<SessionHeader, 'cwd'> = {}): ReactLoopAgent {
|
||||
this.assertAgentIdFree(id)
|
||||
// Config/programmatic path: prepare the session and let start() fold its
|
||||
// lifecycle into the agent's composite effect (so a fiber unload tears the
|
||||
// session + agent down as one ordered chain, capturing the loop's closing
|
||||
// flush). The whole effect is owned by THIS fiber; no AgentHandle is needed.
|
||||
const session = this.ctx.sessions.prepare(SessionId(`${id}-session-${randomUUID()}`), { meta })
|
||||
const { agent } = this.start(id, options, session, 'startup')
|
||||
return agent
|
||||
const loopCtx = this.runtime.ctx
|
||||
const transaction = new AgentCreationTransaction(loopCtx, this.ctx, this.ownership, id)
|
||||
try {
|
||||
const sessionId = SessionId(`${id}-session-${randomUUID()}`)
|
||||
const session = loopCtx.sessions.prepare(sessionId, { meta })
|
||||
const agent = transaction.prepare(options, session)
|
||||
transaction.publish('startup')
|
||||
return agent
|
||||
} catch (error: unknown) {
|
||||
void transaction.dispose(error instanceof Error ? error : new Error(String(error)))
|
||||
throw error
|
||||
} finally {
|
||||
transaction.finishWrapper()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Programmatic factory create ({@link AgentFactory}): an agent on a
|
||||
* caller-supplied `sessionId` (NOT `${id}-session`), with optional session
|
||||
* metadata (validated `cwd`, lineage) and an optional `seed` event prefix. The
|
||||
* ACP bridge uses this so the client-generated session id becomes the
|
||||
* live/persisted session id; the in-process FORK subagent backend passes a
|
||||
* `seed` (a balanced completed-turn prefix of the parent's log) so the child
|
||||
* starts with the parent's context. Returns an {@link AgentHandle} the owner
|
||||
* disposes to tear down exactly this agent.
|
||||
* @param options - agent id, caller-supplied session id, optional seed/meta,
|
||||
* and agent options.
|
||||
* @returns the handle whose dispose tears down exactly this agent.
|
||||
* Create an owned agent on a caller-supplied session id.
|
||||
* @param ownerCtx - caller context that structurally owns the transaction.
|
||||
* @param options - identities, session seed/metadata, loop options, setup, and cancellation.
|
||||
* @returns the published handle.
|
||||
*/
|
||||
createAgent(options: CreateAgentOptions): AgentHandle {
|
||||
// Check the agent id BEFORE preparing the session: register() would reject a
|
||||
// duplicate id only AFTER the session enters the store, leaving an orphaned
|
||||
// live session (and lazy persistence state) that blocks reuse of that id.
|
||||
this.assertAgentIdFree(options.agentId)
|
||||
const session = this.ctx.sessions.prepare(options.sessionId, {
|
||||
...options.seed !== undefined ? { seed: options.seed } : {},
|
||||
meta: options.meta ?? {},
|
||||
})
|
||||
// A seeded (forked) create is still a fresh start, NOT a resume — `resume`
|
||||
// is reserved for reloading a PERSISTED session via resume()/resumeWith().
|
||||
return this.startOwned(options.agentId, options.agentOptions ?? {}, session, 'startup')
|
||||
async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle> {
|
||||
const transaction = new AgentCreationTransaction(
|
||||
this.runtime.ctx,
|
||||
ownerCtx,
|
||||
this.ownership,
|
||||
options.agentId,
|
||||
options.signal,
|
||||
)
|
||||
try {
|
||||
const session = this.runtime.ctx.sessions.prepare(options.sessionId, {
|
||||
...options.seed === undefined ? {} : { seed: options.seed },
|
||||
...options.meta === undefined ? {} : { meta: options.meta },
|
||||
})
|
||||
const agent = transaction.prepare(options.agentOptions ?? {}, session)
|
||||
await transaction.waitFor(options.setup?.(agent.ctx))
|
||||
transaction.assertActive()
|
||||
return transaction.publish('startup')
|
||||
} catch (error: unknown) {
|
||||
await transaction.dispose(error instanceof Error ? error : new Error(String(error)))
|
||||
throw error
|
||||
} finally {
|
||||
transaction.finishWrapper()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume an agent on a persisted session ({@link AgentFactory}). Loads the
|
||||
* session log + metadata via `ctx.sessionPersistence`, reconstructs the live
|
||||
* session with the loaded events (so `lastTurnNumber`/`deriveMessages`
|
||||
* continue), and starts a fresh agent on it. The live session id is the
|
||||
* resumed id, NOT `${agentId}-session`.
|
||||
*
|
||||
* Requires `ctx.sessionPersistence`; rejects with a clear error if it is not
|
||||
* configured. NOT hard-injected (that would make non-persistent demos pend
|
||||
* forever) — callers that need resume (ACP) inject `sessionPersistence`, so
|
||||
* by the time this runs the service exists.
|
||||
* @param options - the persisted session id to reload, plus agent id/options.
|
||||
* @returns the handle for the agent resumed on the reconstructed session.
|
||||
* Resume an owned agent from the configured persistence service.
|
||||
* @param ownerCtx - caller context that owns load, setup, and the live lifecycle.
|
||||
* @param options - persisted identity, loop options, setup, and cancellation.
|
||||
* @returns the published handle.
|
||||
*/
|
||||
async resume(options: ResumeAgentOptions): Promise<AgentHandle> {
|
||||
// Read the service through `ctx.get('sessionPersistence')` — a direct
|
||||
// global-store lookup keyed by the isolate symbol — NOT
|
||||
// `this.ctx.sessionPersistence`. AgentLoop deliberately does NOT inject
|
||||
// `sessionPersistence` (injecting it would pend non-persistent demos
|
||||
// forever). The `ctx.<name>` property proxy resolves a service by an
|
||||
// ancestor-only walk of the current fiber's parent chain; from AgentLoop's
|
||||
// own fiber (which lacks the inject) that walk never reaches the sibling
|
||||
// backend fiber and throws "cannot get property … without inject". Worse,
|
||||
// when the call arrives via a traceable shadow (e.g. the ACP bridge child
|
||||
// fiber → `ctx.agents.resume()` → `this.factory.resume()`), the walk starts
|
||||
// at the shadow's origin fiber and fails the same way. `ctx.get(name)`
|
||||
// sidesteps the fiber walk entirely (a store lookup by the global isolate
|
||||
// key), so resume works from any caller fiber. It is strict by default: a
|
||||
// backend that is not ACTIVE (absent, or mid-teardown) reads as undefined
|
||||
// and we reject below, rather than handing back an unusable handle.
|
||||
const persistence = this.ctx.get('sessionPersistence')
|
||||
async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle> {
|
||||
const persistence = this.runtime.ctx.get('sessionPersistence')
|
||||
if (persistence === undefined) {
|
||||
throw new Error('cannot resume: session persistence is not configured (load a dsh-session-persistence backend)')
|
||||
}
|
||||
return this.resumeWith(persistence, options)
|
||||
return this.resumeWith(ownerCtx, persistence, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume against an EXPLICIT persistence handle. Factored out of {@link resume}
|
||||
* so the config-driven path can pass the handle it obtained from a
|
||||
* `ctx.inject(['sessionPersistence'], …)` child context: `this.ctx` (the
|
||||
* service's own fiber) did not inject `sessionPersistence`, so reading it
|
||||
* there from inside the inject child trips the cordis inject guard. The
|
||||
* sessions store + registry are still read through `this.ctx` (both are in
|
||||
* AgentLoop's static inject, so they resolve fine).
|
||||
*/
|
||||
private async resumeWith(persistence: SessionPersistence, options: ResumeAgentOptions): Promise<AgentHandle> {
|
||||
this.assertAgentIdFree(options.agentId)
|
||||
const { meta, events } = await persistence.load(options.resumeSessionId)
|
||||
// Re-check the agent id AFTER the await: the pre-load check above can go
|
||||
// stale while load() is pending (a concurrent resume/create may register the
|
||||
// same id). Re-checking immediately before prepare()/start keeps the
|
||||
// "no orphaned session on a duplicate id" guarantee under concurrency.
|
||||
this.assertAgentIdFree(options.agentId)
|
||||
// Reconstruct the live session with the FULL persisted header (createdAt,
|
||||
// cwd, lineage) so resume preserves identity, not just the cwd. The seed
|
||||
// events make lastTurnNumber/deriveMessages continue; the backend already
|
||||
// has state (cursor) from the load above, so onCreated is a no-op and the
|
||||
// seed is not re-persisted. prepare() (not create()) so the session
|
||||
// lifecycle folds into the agent's composite effect (ordered teardown).
|
||||
const session = this.ctx.sessions.prepare(options.resumeSessionId, {
|
||||
seed: events,
|
||||
meta: {
|
||||
createdAt: meta.createdAt,
|
||||
...meta.cwd !== undefined ? { cwd: meta.cwd } : {},
|
||||
...meta.parentSession !== undefined ? { parentSession: meta.parentSession } : {},
|
||||
// Reconstruct the seed boundary from the persisted header, NOT from
|
||||
// `events.length` (the resume seeds the WHOLE stored log).
|
||||
...meta.seedLength !== undefined ? { seedLength: meta.seedLength } : {},
|
||||
},
|
||||
})
|
||||
return this.startOwned(options.agentId, options.agentOptions ?? {}, session, 'resume')
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a duplicate agent id BEFORE the session is entered into the store, so
|
||||
* a failed factory call never leaves an orphaned live session (and lazy
|
||||
* persistence state) behind. `register()` enforces the same uniqueness, but
|
||||
* only after the session has already entered the store.
|
||||
*/
|
||||
private assertAgentIdFree(id: AgentId): void {
|
||||
if (this.ctx.agents.get(id) !== undefined) {
|
||||
throw new Error(`agent "${id}" is already registered`)
|
||||
/** Resume through an explicit persistence handle used by the deferred config path. */
|
||||
private async resumeWith(
|
||||
ownerCtx: Context,
|
||||
persistence: SessionPersistence,
|
||||
options: ResumeAgentOptions,
|
||||
): Promise<AgentHandle> {
|
||||
const transaction = new AgentCreationTransaction(
|
||||
this.runtime.ctx,
|
||||
ownerCtx,
|
||||
this.ownership,
|
||||
options.agentId,
|
||||
options.signal,
|
||||
)
|
||||
try {
|
||||
const loaded = await transaction.waitFor(persistence.load(options.resumeSessionId))
|
||||
transaction.assertActive()
|
||||
const session = this.runtime.ctx.sessions.prepare(options.resumeSessionId, {
|
||||
seed: loaded.events,
|
||||
meta: {
|
||||
createdAt: loaded.meta.createdAt,
|
||||
...loaded.meta.cwd === undefined ? {} : { cwd: loaded.meta.cwd },
|
||||
...loaded.meta.parentSession === undefined ? {} : { parentSession: loaded.meta.parentSession },
|
||||
...loaded.meta.seedLength === undefined ? {} : { seedLength: loaded.meta.seedLength },
|
||||
},
|
||||
})
|
||||
const agent = transaction.prepare(options.agentOptions ?? {}, session)
|
||||
await transaction.waitFor(options.setup?.(agent.ctx))
|
||||
transaction.assertActive()
|
||||
return transaction.publish('resume')
|
||||
} catch (error: unknown) {
|
||||
await transaction.dispose(error instanceof Error ? error : new Error(String(error)))
|
||||
throw error
|
||||
} finally {
|
||||
transaction.finishWrapper()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared: construct a ReactLoopAgent over a PREPARED (not-yet-entered)
|
||||
* session, then build the ONE composite effect that owns the whole agent
|
||||
* lifecycle — session entry, registry registration, and the loop. Keeping all
|
||||
* three in a SINGLE effect (not sibling effects) is load-bearing: a fiber
|
||||
* unload disposes sibling effects CONCURRENTLY (`Promise.all`), which would
|
||||
* race the session detach against the loop's closing flush and drop the
|
||||
* closing `turn/end`. Inside one effect the disposers run as an ORDERED LIFO
|
||||
* chain — the runtime awaits each disposer's returned promise before the next:
|
||||
*
|
||||
* yield session-detach (disposed LAST — detach onAppend + remove entry)
|
||||
* yield register (disposed 2nd — unregister)
|
||||
* yield stop-and-drain (disposed FIRST — request loop stop, await agent.done)
|
||||
*
|
||||
* So on teardown: the loop is stopped and AWAITED to exit (its final
|
||||
* `session/flush` + `turn/end` fire through the still-attached `onAppend`),
|
||||
* THEN the agent is unregistered, THEN the session is detached — capturing the
|
||||
* closing events before detach, whether the trigger is the handle's `dispose()`
|
||||
* OR a fiber unload. Rollback safety: each yield runs before the next mutation,
|
||||
* so a throwing `session/created`/`agent/created` listener unwinds the
|
||||
* already-yielded disposers instead of leaking.
|
||||
*
|
||||
* `source` says why the session began ({@link SessionStartSource}); it is
|
||||
* emitted as `agent/session-start` once, AFTER the agent is registered (so a
|
||||
* listener can resolve the agent via `ctx.agents.get(id)` and `inject()` into
|
||||
* it) and BEFORE the loop starts its first turn. The emit is contained: a
|
||||
* throwing session-start listener must not abort agent construction — it is
|
||||
* logged, and the agent still starts. (Unlike a turn-boundary throw, there is
|
||||
* no open turn here to balance; the durable evidence of a session-start hook
|
||||
* is whatever it `inject()`ed.)
|
||||
*
|
||||
* Returns the agent plus the composite effect's disposer (`disposeAgent`).
|
||||
*/
|
||||
private start(
|
||||
id: AgentId, options: AgentOptions, session: Session, source: SessionStartSource,
|
||||
): { agent: ReactLoopAgent; disposeAgent: () => Promise<void> } {
|
||||
const agent = new ReactLoopAgent(this.ctx, id, options, session)
|
||||
const dispose = this.ctx.effect(function* (this: AgentLoop) {
|
||||
yield this.ctx.sessions.enter(session)
|
||||
this.ctx.sessions.announce(session)
|
||||
yield this.ctx.agents.register(agent)
|
||||
// Fire AFTER register (a listener can ctx.agents.get(id) + inject()) and
|
||||
// BEFORE the loop's first turn. Contained: a throwing listener is logged,
|
||||
// never aborts construction (no open turn to balance here).
|
||||
try {
|
||||
this.ctx.emit('agent/session-start', agent, source)
|
||||
} catch (error: unknown) {
|
||||
this.ctx.logger.warn(`agent "${id}": agent/session-start listener threw: ${String(error)}`)
|
||||
}
|
||||
const stop = agent.start()
|
||||
// Disposed FIRST (LIFO): request loop stop (sync), then AWAIT the loop's
|
||||
// actual exit so its closing flush lands while onAppend (yielded above,
|
||||
// disposed later) is still attached.
|
||||
yield async () => { stop(); await agent.done }
|
||||
}.bind(this), 'agentLoop.start()')
|
||||
return { agent, disposeAgent: async () => { await dispose() } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an {@link AgentHandle} for a PREPARED session + a fresh agent. The
|
||||
* handle's `dispose()` runs the composite effect's disposer (see
|
||||
* {@link start}) — which stops the loop, awaits its exit (final flush
|
||||
* captured), unregisters the agent, and detaches the session, in that order.
|
||||
* The same composite effect is what a fiber unload disposes, so both teardown
|
||||
* triggers honor the ordering identically.
|
||||
*
|
||||
* `dispose()` is MEMOIZED: the underlying cordis effect disposer is
|
||||
* single-shot (a second call returns immediately because the effect's epoch is
|
||||
* already cleared, NOT awaiting the in-flight teardown), so concurrent/repeated
|
||||
* `dispose()` calls would otherwise resolve before the first call's
|
||||
* `await agent.done` + final flush completed. Memoizing the promise makes every
|
||||
* caller observe the SAME quiescence boundary, honoring the
|
||||
* `AgentHandle.dispose(): Promise<void>` contract (mirrors the ACP `quiesce()`
|
||||
* helper).
|
||||
*/
|
||||
private startOwned(id: AgentId, options: AgentOptions, session: Session, source: SessionStartSource): AgentHandle {
|
||||
const { agent, disposeAgent } = this.start(id, options, session, source)
|
||||
let disposing: Promise<void> | undefined
|
||||
return { agent, dispose: () => (disposing ??= disposeAgent()) }
|
||||
}
|
||||
}
|
||||
|
||||
export default AgentLoop
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
import type { Context } from 'cordis'
|
||||
import type { FinishReason, GenerateOptions, LlmCallConfig, Message } from '@deepseek-ai/dsh-llm'
|
||||
import { BlockAssembler, HarnessError, deepFreeze } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContinuationDecision, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent'
|
||||
import { agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent'
|
||||
import type { AgentEventDispatch, ContinuationDecision, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent'
|
||||
import { canonicalHeader } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session'
|
||||
import { createTransmissionLog, recordRequestHeader } from './request-log.ts'
|
||||
@@ -19,6 +20,7 @@ import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
import type { ReactLoopAgent } from './agent.ts'
|
||||
import type { Inbox } from './inbox.ts'
|
||||
|
||||
/** An Error with an optional machine-readable code (e.g., from LlmError or a throwing plugin). */
|
||||
type CodedError = Error & { code?: string }
|
||||
@@ -107,6 +109,8 @@ function stepFinishReason(finish: FinishReason): TurnEndReason | undefined {
|
||||
* loop testable without a real agent.
|
||||
*/
|
||||
export interface LoopHandle {
|
||||
/** Native-private agent inbox handed to the driver only at internal startup. */
|
||||
readonly inbox: Inbox
|
||||
setStatus(status: 'idle' | 'running'): void
|
||||
setAbort(controller: AbortController | undefined): void
|
||||
/** Resolves when the agent is disposed — unblocks the idle wait. */
|
||||
@@ -155,12 +159,13 @@ export interface LoopHandle {
|
||||
* every prompt blocked → 'turn/end'(rejected), 0 steps
|
||||
* STEP loop:
|
||||
* drain steering → session('steering/message') ⟵ catches late steering
|
||||
* assembly = ctx.systemPrompt.assemble({agent}) ⟵ waterfall system-prompt/assemble; renderPrompt
|
||||
* assembly = ctx.systemPrompt.assemble(assembleContextFor(agent)) ⟵ waterfall system-prompt/assemble
|
||||
* (scope-filtered; scoped sections/tools join); renderPrompt
|
||||
* (persona section + {{variables}}) IS the full prompt
|
||||
* prefix ??= waterfall agent/session-prefix ⟵ once per loop instance (first step): frozen
|
||||
* prefix ??= waterfall agent/session-prefix ⟵ once per loop instance (first step): frozen
|
||||
* session prefix; logged on the header, never
|
||||
* session history
|
||||
* await ctx.serial('agent/pre-step', …, prefix) ⟵ surface mutation (compaction) OUTSIDE the step;
|
||||
* session history (scope-filtered, fused dispatch)
|
||||
* await events.serial('agent/pre-step', …, prefix) ⟵ surface mutation (compaction) OUTSIDE the step;
|
||||
* pressure gates see the prefix the request carries
|
||||
* boundary = session.deriveMessages() ⟵ the reconstruction boundary: snapshot in the
|
||||
* session('step/start') same sync frame, strictly before step/start
|
||||
@@ -183,9 +188,12 @@ export interface LoopHandle {
|
||||
* {action: hadToolCalls||steered ? 'continue':'stop'}; a continue.reason is
|
||||
* recorded as next-step steering
|
||||
* if action==stop && steering arrived (step/end/continuation listeners): continue anyway
|
||||
* terminal = serial agent/turn-stop ⟵ stop or abstain; after all ordinary
|
||||
* continuation and steering folding
|
||||
* if terminal: discard pending steering and break
|
||||
* if action==stop: break
|
||||
* session('turn/end') ⟵ durable turn boundary (no agent/* mirror)
|
||||
* await ctx.parallel('session/flush', session) ⟵ durability checkpoint
|
||||
* await ctx.sessions.flush(session) ⟵ durability checkpoint (store-owned carrier)
|
||||
* re-enqueue leftover steering as queued ⟵ steering is never stranded
|
||||
* idle (emit agent/status) unless more queued
|
||||
* ```
|
||||
@@ -202,9 +210,13 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH
|
||||
const transmission = createTransmissionLog()
|
||||
|
||||
const { session } = agent
|
||||
// The fused agent-subject dispatcher: every agent/* dispatch below carries
|
||||
// the agent's scope (an `agent.ctx` listener hears only this agent) with
|
||||
// the subject injected — one spelling, checked by the dev invariants.
|
||||
const events = agentEvents(ctx, agent)
|
||||
|
||||
while (!handle.isDisposed()) {
|
||||
await agent.inbox.waitForQueued(handle.disposed)
|
||||
await handle.inbox.waitForQueued(handle.disposed)
|
||||
if (handle.isDisposed()) break
|
||||
|
||||
// Pre-step cancel (window 1): a `cancel()` landed after a `send()` woke the
|
||||
@@ -222,7 +234,7 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH
|
||||
// resolve before it runs (the quiescence contract).
|
||||
if (handle.isCancelled()) {
|
||||
handle.clearCancel()
|
||||
if (!agent.inbox.hasQueued) {
|
||||
if (!handle.inbox.hasQueued) {
|
||||
handle.settleIdle()
|
||||
continue
|
||||
}
|
||||
@@ -244,7 +256,7 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH
|
||||
// is still queued and unrun (the same early-resolve race window 1 fixes).
|
||||
if (handle.isCancelled()) {
|
||||
handle.clearCancel()
|
||||
if (!agent.inbox.hasQueued) {
|
||||
if (!handle.inbox.hasQueued) {
|
||||
handle.setStatus('idle')
|
||||
continue
|
||||
}
|
||||
@@ -255,8 +267,9 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH
|
||||
// the loop waits above, so the next real turn must continue from whatever
|
||||
// turn number is actually last in the log — a stale counter would collide.
|
||||
const turn = lastTurnNumber(session) + 1
|
||||
let terminalStopped = false
|
||||
try {
|
||||
await runTurn(ctx, agent, handle, turn, transmission)
|
||||
terminalStopped = await runTurn(ctx, events, agent, handle, turn, transmission)
|
||||
} catch (error: unknown) {
|
||||
// Backstop: runTurn rethrows only a PRE-turn throw (the invariant guard
|
||||
// before turn/start) — no turn/start was appended, so no turn is open and
|
||||
@@ -264,10 +277,13 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH
|
||||
// the previous turn/end), where the persistence backend drops it as a
|
||||
// crash tail (the turn-enclosure RFC). Report via agent/error + the logger only; the
|
||||
// driver survives and moves on.
|
||||
// Acceptance and internal dispatch validation can reject before
|
||||
// turn/start commits. Report that supported pre-turn failure without
|
||||
// inventing a turn/end for a turn that never opened.
|
||||
const err = toError(error)
|
||||
ctx.logger.warn(`agent "${agent.id}": turn ${turn} failed before it started: ${err.message}`)
|
||||
try {
|
||||
ctx.emit('agent/error', agent, turn, 0, err)
|
||||
events.emit('agent/error', turn, 0, err)
|
||||
} catch { /* contained: a throwing agent/error listener must not kill the driver */ }
|
||||
}
|
||||
|
||||
@@ -280,27 +296,30 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH
|
||||
// cancelled.
|
||||
handle.clearCancel()
|
||||
|
||||
// Steering that arrived too late to join this turn (turn-end listeners,
|
||||
// flush) becomes a queued message — it must never be stranded. (A cancelled
|
||||
// turn already cleared its steering, so there is nothing to re-enqueue.)
|
||||
for (const message of agent.inbox.drainSteering()) {
|
||||
agent.inbox.enqueue(message)
|
||||
// Steering that arrived too late to join an ordinary turn (turn-end
|
||||
// listeners, flush) becomes queued input so it is never stranded. A
|
||||
// terminal-stop owner is the deliberate exception: discard the steering
|
||||
// again after the close + flush window so terminal policy cannot be undone
|
||||
// after its in-turn drain. Ordinary queued sends live in a separate FIFO and
|
||||
// remain untouched.
|
||||
for (const message of handle.inbox.drainSteering()) {
|
||||
if (!terminalStopped) handle.inbox.enqueue(message)
|
||||
}
|
||||
|
||||
if (!agent.inbox.hasQueued) handle.setStatus('idle')
|
||||
if (!handle.inbox.hasQueued) handle.setStatus('idle')
|
||||
}
|
||||
}
|
||||
|
||||
async function runTurn(
|
||||
ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, turn: number, transmission: TransmissionLog,
|
||||
): Promise<void> {
|
||||
ctx: Context, events: AgentEventDispatch, agent: ReactLoopAgent, handle: LoopHandle, turn: number, transmission: TransmissionLog,
|
||||
): Promise<boolean> {
|
||||
const { session } = agent
|
||||
|
||||
// --- Pre-turn. A throw here (the invariant guard) is owed NO turn/end —
|
||||
// turn/start has not been appended — so it propagates to runLoop's backstop
|
||||
// untouched. The queued messages are drained here but appended AFTER
|
||||
// turn/start (below), so every event in the log lives inside a turn.
|
||||
const queued = agent.inbox.drainQueued()
|
||||
const queued = handle.inbox.drainQueued()
|
||||
const first = queued[0]
|
||||
/* v8 ignore next 3 -- invariant guard: runLoop only calls runTurn when hasQueued */
|
||||
if (!first) throw new Error('runTurn invariant violated: no queued message at turn start')
|
||||
@@ -310,35 +329,16 @@ async function runTurn(
|
||||
let step = 0
|
||||
let stepOpen = false
|
||||
let errorReported = false
|
||||
let terminalStopped = false
|
||||
|
||||
// Close the open step exactly once (idempotent via stepOpen). Step boundaries
|
||||
// are durable session events only — there is no agent/* step emit to mirror
|
||||
// them (see the agent event-domain rule). A throwing step/end session-event
|
||||
// listener must not abort finalization and strand the turn open (turn/end
|
||||
// balance > notifying one bad listener); it is contained and surfaced as a
|
||||
// turn error below.
|
||||
const closeStep = (): boolean => {
|
||||
if (!stepOpen) return false
|
||||
// Close the open step exactly once (idempotent via stepOpen). Post-commit
|
||||
// session/event observers are contained by Session; a pre-commit validator
|
||||
// failure still escapes so the outer recovery path may retry the boundary or
|
||||
// fail loudly without pretending an uncommitted step/end exists.
|
||||
const closeStep = (): void => {
|
||||
if (!stepOpen) return
|
||||
session.append('step/end', { turn, step })
|
||||
stepOpen = false
|
||||
// Session.append pushes step/end BEFORE notifying session/event listeners,
|
||||
// so a throwing listener leaves step/end in the log (balance holds) but
|
||||
// would otherwise abort finalization. Contain it and surface it as a turn
|
||||
// error below.
|
||||
let failure: unknown
|
||||
try {
|
||||
session.append('step/end', { turn, step })
|
||||
} catch (error: unknown) {
|
||||
failure = error
|
||||
}
|
||||
// A throwing step/end session-event listener surfaces as a turn error via
|
||||
// failTurn (idempotent). This prevents a throwing listener from producing a
|
||||
// silent "completed" turn when the step itself succeeded, AND keeps
|
||||
// finalization going when closeStep runs from the outer catch.
|
||||
if (failure !== undefined) {
|
||||
failTurn(toError(failure))
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Record a step/turn failure exactly once: set the error reason (carrying the
|
||||
@@ -350,45 +350,29 @@ async function runTurn(
|
||||
const failTurn = (err: CodedError): void => {
|
||||
if (errorReported) return
|
||||
errorReported = true
|
||||
// The turn is always still open here: the only failure that can reach
|
||||
// failTurn once turn/end is appended would be a throwing turn-boundary
|
||||
// listener, and turn boundaries are durable session events with no agent/*
|
||||
// mirror to throw. A throwing `turn/end` session-event listener is already
|
||||
// contained inside closeTurn (append pushes before notifying, so the
|
||||
// boundary is durable). So set the error reason for closeTurn to append.
|
||||
// The turn is still open here. Post-commit observers cannot escape append,
|
||||
// and a pre-commit turn/end veto leaves no closing boundary to overwrite.
|
||||
// Set the reason that the next successful closeTurn will append.
|
||||
reason = { kind: 'error', step, ...errorData(err) }
|
||||
try {
|
||||
ctx.emit('agent/error', agent, turn, step, err)
|
||||
events.emit('agent/error', turn, step, err)
|
||||
} catch {
|
||||
// contained: the error is already captured on `reason`; a throwing
|
||||
// agent/error listener must not prevent the turn from closing.
|
||||
}
|
||||
}
|
||||
|
||||
// Close the turn. Called exactly once per turn — the normal loop exit and the
|
||||
// outer catch are mutually exclusive paths, and this never throws (the append
|
||||
// is contained below), so there is no re-entry to guard against (unlike
|
||||
// closeStep, which the cancel branches and the outer catch can both reach).
|
||||
// Turn boundaries are durable session events only — there is no agent/* turn
|
||||
// emit to mirror them (see the agent event-domain rule).
|
||||
// Close the turn. Post-commit observer failures are contained by Session;
|
||||
// pre-commit validation failures escape to recovery instead of being mistaken
|
||||
// for a committed boundary. Turn boundaries are durable session events only.
|
||||
const closeTurn = (): void => {
|
||||
// Session.append pushes turn/end BEFORE notifying session/event listeners,
|
||||
// so a throwing listener leaves turn/end in the log (the turn is balanced)
|
||||
// but would otherwise escape — from the outer catch it would propagate to
|
||||
// the runLoop backstop. Contain it: the boundary is durable either way, and
|
||||
// finalization must not abort on a bad listener.
|
||||
try {
|
||||
session.append('turn/end', { turn, reason })
|
||||
} catch (error: unknown) {
|
||||
ctx.logger.warn(`agent "${agent.id}": session/event listener threw on turn/end at turn ${turn}: ${toError(error).message}`)
|
||||
}
|
||||
session.append('turn/end', { turn, reason })
|
||||
}
|
||||
|
||||
try {
|
||||
// --- Turn boundary. Once turn/start is appended, a turn/end is owed no
|
||||
// matter what throws below; the catch + closeTurn guarantee it (the catch
|
||||
// decides "owed" from the log via isTurnOpen, so even a throwing turn/start
|
||||
// listener — append pushes before notifying — still gets its turn/end).
|
||||
// matter what throws below; the catch + closeTurn guarantee it. A pre-commit
|
||||
// veto leaves no turn/start in the log and therefore owes no turn/end.
|
||||
session.append('turn/start', { turn, trigger })
|
||||
// Each drained queued message runs the `agent/prompt-submit` waterfall before
|
||||
// it becomes a `user/message` — a hook can rewrite the prompt or block it.
|
||||
@@ -402,8 +386,8 @@ async function runTurn(
|
||||
// batch always reports the last vetoing reason.
|
||||
let lastBlockReason = 'prompt blocked by hook'
|
||||
for (const message of queued) {
|
||||
const decision = await ctx.waterfall(
|
||||
'agent/prompt-submit', agent, message.content, message.source,
|
||||
const decision = await events.waterfall(
|
||||
'agent/prompt-submit', message.content, message.source,
|
||||
() => Promise.resolve<PromptDecision>({ kind: 'allow' }),
|
||||
)
|
||||
if (decision.kind === 'block') {
|
||||
@@ -443,7 +427,7 @@ async function runTurn(
|
||||
|
||||
// Steering from the previous round's continuation listeners joins before
|
||||
// the request.
|
||||
drainSteering(agent, turn)
|
||||
drainSteering(agent, handle.inbox, turn)
|
||||
|
||||
// The step's AbortController exists BEFORE any async pre-step work so a
|
||||
// dispose() or cancel() — in a synchronous turn-start listener or an
|
||||
@@ -458,9 +442,9 @@ async function runTurn(
|
||||
// against the system prompt (it counts toward the budget). runStep reuses
|
||||
// this same assembly for the request, so the prompt is assembled once per
|
||||
// step. renderPrompt IS the full prompt — the persona is the order-0
|
||||
// section (registered by the AgentLoop plugin) and `{{variable}}`
|
||||
// section (owned by dsh-system-prompt) and `{{variable}}`
|
||||
// interpolation happens in the render, so there is no separate join.
|
||||
const assembly = await ctx.systemPrompt.assemble({ agent })
|
||||
const assembly = await ctx.systemPrompt.assemble(assembleContextFor(agent))
|
||||
const fullSystemPrompt = renderPrompt(assembly)
|
||||
|
||||
// Interruption landing after assembly: dispose() or cancel() in a
|
||||
@@ -497,8 +481,8 @@ async function runTurn(
|
||||
// CURRENT request.
|
||||
if (transmission.sessionPrefix === undefined) {
|
||||
const emptyPrefix: Message[] = deepFreeze([])
|
||||
const composed = await ctx.waterfall(
|
||||
'agent/session-prefix', agent, emptyPrefix, abort.signal,
|
||||
const composed = await events.waterfall(
|
||||
'agent/session-prefix', emptyPrefix, abort.signal,
|
||||
() => Promise.resolve(emptyPrefix),
|
||||
)
|
||||
|
||||
@@ -533,7 +517,7 @@ async function runTurn(
|
||||
// pre-step plugin ends the turn, not the loop. The composed session
|
||||
// prefix rides along so token-pressure listeners count everything the
|
||||
// request will actually carry.
|
||||
await ctx.serial('agent/pre-step', agent, turn, step, fullSystemPrompt, transmission.sessionPrefix, abort.signal)
|
||||
await events.serial('agent/pre-step', turn, step, fullSystemPrompt, transmission.sessionPrefix, abort.signal)
|
||||
|
||||
// Interruption landing during the pre-step seam: do not open an empty step.
|
||||
if (handle.isCancelled() || handle.isDisposed()) {
|
||||
@@ -546,20 +530,19 @@ async function runTurn(
|
||||
// messages are snapshotted HERE, in the same synchronous frame as the
|
||||
// step/start append directly below — so the snapshot is exactly the
|
||||
// derivation over the log prefix strictly before step/start's seq.
|
||||
// Anything appended later — by a step/start session/event listener, an
|
||||
// agent/request-window inject(), any concurrent task — lands after the
|
||||
// boundary and joins the NEXT request. An external reconstructor
|
||||
// Anything appended later by the request-window inject seam or a
|
||||
// concurrent task lands after the boundary and joins the NEXT request.
|
||||
// session/event itself is observe-only: append reentrancy is rejected
|
||||
// until the current callback list drains. An external reconstructor
|
||||
// recovers these exact messages by folding the surface over
|
||||
// events[0..stepStartSeq).
|
||||
const boundaryMessages = session.deriveMessages()
|
||||
|
||||
// Mark the step open BEFORE the append: Session.append pushes the event
|
||||
// to the log before notifying session/event listeners, so a THROWING
|
||||
// step/start listener leaves step/start in the log. Setting stepOpen first
|
||||
// means the outer catch's closeStep() then appends the balancing step/end
|
||||
// (turn stays enclosed) instead of stranding an open step under turn/end.
|
||||
stepOpen = true
|
||||
session.append('step/start', { turn, step })
|
||||
// Only a committed step/start creates a balancing obligation. A
|
||||
// pre-commit veto throws before this assignment; post-commit observers
|
||||
// are contained inside Session.append().
|
||||
stepOpen = true
|
||||
|
||||
// Cancel landing in the step-start window: a synchronous `session/event`
|
||||
// step/start listener can cancel after the step is already open. Check
|
||||
@@ -574,7 +557,8 @@ async function runTurn(
|
||||
|
||||
let stepOutcome: { hadToolCalls: boolean; finish: FinishReason } | { error: Error }
|
||||
try {
|
||||
stepOutcome = await runStep(ctx, agent, turn, step, assembly, fullSystemPrompt, boundaryMessages, transmission, abort.signal)
|
||||
stepOutcome = await runStep(
|
||||
ctx, events, agent, turn, step, assembly, fullSystemPrompt, boundaryMessages, transmission, abort.signal)
|
||||
} catch (error: unknown) {
|
||||
stepOutcome = { error: toError(error) }
|
||||
} finally {
|
||||
@@ -609,15 +593,15 @@ async function runTurn(
|
||||
if (stepReason) reason = stepReason
|
||||
|
||||
// Steering that arrived during streaming/tool execution.
|
||||
const steered = drainSteering(agent, turn)
|
||||
const steered = drainSteering(agent, handle.inbox, turn)
|
||||
|
||||
if (closeStep()) break
|
||||
closeStep()
|
||||
|
||||
const defaultDecision: ContinuationDecision = { action: stepOutcome.hadToolCalls || steered ? 'continue' : 'stop' }
|
||||
let decision: ContinuationDecision
|
||||
try {
|
||||
decision = await ctx.waterfall(
|
||||
'agent/turn-continuation', agent, turn, defaultDecision,
|
||||
decision = await events.waterfall(
|
||||
'agent/turn-continuation', turn, defaultDecision,
|
||||
() => Promise.resolve(defaultDecision),
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
@@ -631,14 +615,38 @@ async function runTurn(
|
||||
// iteration drains it before its request — the typed twin of the /goal
|
||||
// step/end-steer pattern.
|
||||
if (decision.action === 'continue' && decision.reason) {
|
||||
agent.inbox.steer({ content: decision.reason.content, source: decision.reason.source })
|
||||
handle.inbox.steer({ content: decision.reason.content, source: decision.reason.source })
|
||||
}
|
||||
let shouldContinue = decision.action === 'continue'
|
||||
|
||||
// Steering from step/end session-event or continuation listeners (the
|
||||
// /goal pattern) demands the model see it — it overrides a stop decision;
|
||||
// the next iteration's drain records it.
|
||||
if (!shouldContinue && agent.inbox.hasSteering) shouldContinue = true
|
||||
if (!shouldContinue && handle.inbox.hasSteering) shouldContinue = true
|
||||
|
||||
// Terminal policy runs only AFTER the extensible continuation waterfall,
|
||||
// its optional reason, and late steering have all been folded. Unlike the
|
||||
// waterfall, this serial seam is monotonic: the first stop bail wins, and
|
||||
// no later listener or steering override can resurrect the turn.
|
||||
let terminalStop = false
|
||||
try {
|
||||
const stop = await events.serial('agent/turn-stop', turn)
|
||||
terminalStop = stop !== undefined
|
||||
} catch (error: unknown) {
|
||||
// A broken terminal policy is an ordinary continuation failure: fail
|
||||
// this turn closed while leaving the driver alive for later turns.
|
||||
failTurn(toError(error))
|
||||
break
|
||||
}
|
||||
if (terminalStop) {
|
||||
terminalStopped = true
|
||||
// A continuation reason or listener may have queued steering before the
|
||||
// terminal checkpoint. Discard only steering (never ordinary queued
|
||||
// prompts) so it cannot become a next step or be re-enqueued as a fresh
|
||||
// turn by runLoop's late-steering fallback.
|
||||
handle.inbox.drainSteering()
|
||||
shouldContinue = false
|
||||
}
|
||||
|
||||
// A cancel that landed during the continuation window — after the step's
|
||||
// AbortController was cleared (setAbort(undefined)) but before the next
|
||||
@@ -660,21 +668,10 @@ async function runTurn(
|
||||
// Normal / inline-error loop exit: close the turn.
|
||||
closeTurn()
|
||||
} catch (error: unknown) {
|
||||
// Decide whether this turn was ever opened from the LOG, not a flag.
|
||||
// Session.append pushes the event BEFORE notifying session/event listeners,
|
||||
// so a throwing listener on the `turn/start` append leaves turn/start in the
|
||||
// log even though execution never reached the lines after that append.
|
||||
// Gating on a "turn started" boolean would skip turn/end and leave a
|
||||
// permanently OPEN turn that poisons the next turn/replay (the turn-enclosure RFC). We
|
||||
// check the log for THIS turn's turn/start: present means a turn/end is owed
|
||||
// and the normal-exit `closeTurn()` did NOT run (we are here because a throw
|
||||
// preceded it — the two `closeTurn()` sites are on mutually exclusive paths),
|
||||
// so this catch appends turn/end with the disposed/error reason chosen below.
|
||||
// `closeStep()` IS idempotent (guarded by `stepOpen`) — it may have run
|
||||
// already in a step branch, so running it again is a safe no-op. Absent
|
||||
// turn/start means the append threw BEFORE its push (a non-serializable
|
||||
// trigger — impossible for our fixed trigger); nothing was opened, so rethrow
|
||||
// to the runLoop backstop.
|
||||
// Decide whether this turn opened from the LOG, not a speculative flag. A
|
||||
// pre-commit validator or acceptance failure leaves no turn/start and owes
|
||||
// no turn/end, so it propagates to runLoop's backstop. Once turn/start is
|
||||
// present, this path balances any committed step and records the failure.
|
||||
const turnStartLogged = session.events.some(e => e.type === 'turn/start' && e.data.turn === turn)
|
||||
if (!turnStartLogged) throw error
|
||||
closeStep()
|
||||
@@ -694,8 +691,9 @@ async function runTurn(
|
||||
|
||||
// Durability checkpoint: persistence plugins drain write-behind buffers.
|
||||
// A failing persistence plugin is reported but doesn't kill the agent.
|
||||
// Through the store's flush (the carrier owner), never a raw parallel.
|
||||
try {
|
||||
await ctx.parallel('session/flush', session)
|
||||
await ctx.sessions.flush(session)
|
||||
} catch (error: unknown) {
|
||||
// The turn is already closed (turn/end appended above) and flush must run
|
||||
// AFTER turn/end to be a checkpoint — so there is no in-turn position left
|
||||
@@ -707,16 +705,17 @@ async function runTurn(
|
||||
const err = toError(error)
|
||||
ctx.logger.warn(`agent "${agent.id}": session/flush failed at turn ${turn}: ${err.message}`)
|
||||
try {
|
||||
ctx.emit('agent/error', agent, turn, step, err)
|
||||
events.emit('agent/error', turn, step, err)
|
||||
} catch {
|
||||
// contained: a throwing agent/error listener must not escape the loop.
|
||||
}
|
||||
}
|
||||
return terminalStopped
|
||||
}
|
||||
|
||||
/** Drain the steering queue into the session. Returns whether any arrived. */
|
||||
function drainSteering(agent: ReactLoopAgent, turn: number): boolean {
|
||||
const messages = agent.inbox.drainSteering()
|
||||
function drainSteering(agent: ReactLoopAgent, inbox: Inbox, turn: number): boolean {
|
||||
const messages = inbox.drainSteering()
|
||||
for (const message of messages) {
|
||||
agent.session.append('steering/message', { turn, content: message.content, source: message.source }, { surfaceOp: 'append' })
|
||||
}
|
||||
@@ -732,6 +731,7 @@ function drainSteering(agent: ReactLoopAgent, turn: number): boolean {
|
||||
* the surface prefix at step/start and already reflects any compaction. */
|
||||
async function runStep(
|
||||
ctx: Context,
|
||||
events: AgentEventDispatch,
|
||||
agent: ReactLoopAgent,
|
||||
turn: number,
|
||||
step: number,
|
||||
@@ -764,7 +764,7 @@ async function runStep(
|
||||
// model-visible content flows through the log channels). The header event
|
||||
// below records whatever the request ACTUALLY uses, so a listener's switch
|
||||
// is a logged, reconstructable fact, never silent drift.
|
||||
const config = await ctx.waterfall('agent/request', agent, turn, step, seedConfig, () => Promise.resolve(seedConfig))
|
||||
const config = await events.waterfall('agent/request', turn, step, seedConfig, () => Promise.resolve(seedConfig))
|
||||
if (!config.model) {
|
||||
throw new Error(`agent "${agent.id}" has no model: set AgentOptions.model or supply one via the agent/request waterfall`)
|
||||
}
|
||||
@@ -823,7 +823,7 @@ async function runStep(
|
||||
|
||||
if (assembler.finish.kind === 'max-tokens') {
|
||||
let message: Message = withoutToolCalls(assembler.message())
|
||||
message = withoutToolCalls(await ctx.waterfall('agent/step-result', agent, turn, step, message, () => Promise.resolve(message)))
|
||||
message = withoutToolCalls(await events.waterfall('agent/step-result', turn, step, message, () => Promise.resolve(message)))
|
||||
// Fire the assistant/message when there is content OR usage: a max-tokens
|
||||
// step can be cut off with empty content but still carry token accounting,
|
||||
// and assistant/message is the only host for usage (there is no standalone
|
||||
@@ -846,7 +846,7 @@ async function runStep(
|
||||
// source of truth for derived history and replay) records the message that
|
||||
// tool dispatch actually uses.
|
||||
let message: Message = assembler.message()
|
||||
message = await ctx.waterfall('agent/step-result', agent, turn, step, message, () => Promise.resolve(message))
|
||||
message = await events.waterfall('agent/step-result', turn, step, message, () => Promise.resolve(message))
|
||||
|
||||
// Same content-or-usage guard as the max-tokens branch: a step that finishes
|
||||
// with neither assembled content nor usage (e.g. a bare `stop` finish that
|
||||
|
||||
@@ -7,6 +7,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import { bindReactLoopAgentContext, prepareReactLoopAgent } from '../src/agent.ts'
|
||||
import { MockAdapter, textResponse } from './mock-adapter.ts'
|
||||
|
||||
async function harness(adapter: MockAdapter) {
|
||||
@@ -48,6 +49,33 @@ function send(agent: ReactLoopAgent, text: string) {
|
||||
}
|
||||
|
||||
describe('ReactLoopAgent', () => {
|
||||
it('rejects access before context binding and a second driver for one session', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('exclusive-driver'))
|
||||
const prepared = prepareReactLoopAgent(ctx, AgentId('first-driver'), { model: 'mock' }, session)
|
||||
|
||||
expect(() => prepared.agent.ctx).toThrow('context is not bound')
|
||||
expect(() => prepareReactLoopAgent(ctx, AgentId('second-driver'), { model: 'mock' }, session))
|
||||
.toThrow('already has a concrete agent driver')
|
||||
|
||||
await prepared.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('borrows caller options and binds its scoped context exactly once', async () => {
|
||||
const ctx = await harness(new MockAdapter([textResponse('unused')]))
|
||||
const options = { model: 'mock' }
|
||||
const agent = ctx.agentLoop.create(AgentId('owned-bindings'), options)
|
||||
|
||||
expect(agent.options).toBe(options)
|
||||
expect(agent.id).toBe('owned-bindings')
|
||||
expect(agent.session.id).toMatch(/^owned-bindings-session-/)
|
||||
expect(() => { bindReactLoopAgentContext(agent, new Context()) }).toThrow(/context is already bound/)
|
||||
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('send() throws after disposal', async () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
@@ -158,10 +186,8 @@ describe('ReactLoopAgent', () => {
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
let flushes = 0
|
||||
ctx.on('session/flush', () => { flushes += 1 })
|
||||
// A session/event listener that throws on the synthetic turn/end. Append
|
||||
// pushes before notifying, so turn/end is in the log (turn balanced) but the
|
||||
// throw must NOT skip the durability checkpoint — the flush decision is made
|
||||
// from the log, not a flag set after the (throwing) append.
|
||||
// Session contains a throwing post-commit turn/end observer. The accepted
|
||||
// boundary still triggers the idle injection's durability checkpoint.
|
||||
let threw = false
|
||||
ctx.on('session/event', (_s, event) => {
|
||||
if (!threw && event.type === 'turn/end') { threw = true; throw new Error('boom turn/end') }
|
||||
@@ -226,26 +252,45 @@ describe('ReactLoopAgent', () => {
|
||||
})
|
||||
|
||||
it('disposer is idempotent (double-stop)', async () => {
|
||||
// Create a bare ReactLoopAgent and call start() directly to get the disposer.
|
||||
// Then call it twice — the second call hits the early-return branch.
|
||||
// Create a bare ReactLoopAgent and start it through the package-internal
|
||||
// test seam. Then call its disposer twice — the second call hits the
|
||||
// early-return branch.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('test'))
|
||||
const agent = new ReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session)
|
||||
const prepared = prepareReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session)
|
||||
const { agent } = prepared
|
||||
|
||||
// Start the loop to get the disposer; the agent waits for messages
|
||||
// (idle, never-resolving cancel), so it will stay idle.
|
||||
const dispose = agent.start()
|
||||
prepared.markPublished()
|
||||
const dispose = prepared.startDriver()
|
||||
|
||||
// First dispose
|
||||
dispose()
|
||||
const firstDisposal = dispose()
|
||||
expect(agent.status).toBe('disposed')
|
||||
await firstDisposal
|
||||
|
||||
// Second dispose — idempotent, no throw
|
||||
expect(() => { dispose() }).not.toThrow()
|
||||
await expect(dispose()).resolves.toBeUndefined()
|
||||
expect(agent.status).toBe('disposed')
|
||||
})
|
||||
|
||||
it('a pre-start disposal makes a later driver-start attempt inert', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('pre-start-dispose'))
|
||||
const prepared = prepareReactLoopAgent(ctx, AgentId('pre-start-dispose'), { model: 'mock' }, session)
|
||||
|
||||
await prepared.dispose()
|
||||
expect(prepared.agent.status).toBe('disposed')
|
||||
const dispose = prepared.startDriver()
|
||||
await dispose()
|
||||
await expect(prepared.agent.done).resolves.toBeUndefined()
|
||||
expect(prepared.agent.session.events).toEqual([])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('setting the same status does not emit agent/status again', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
@@ -324,7 +369,7 @@ describe('ReactLoopAgent', () => {
|
||||
// Covers the waiter's disposed arm: whenIdle() queues an internal waiter
|
||||
// while running (not the fast path), then the disposer settles it and chains
|
||||
// `done` (loop exit), not an eager resolve. A bare ReactLoopAgent + direct
|
||||
// start() disposer keeps the emit synchronous.
|
||||
// internal driver disposer keeps the emit synchronous.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
@@ -334,17 +379,19 @@ describe('ReactLoopAgent', () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const session = ctx.sessions.create(SessionId('bare'))
|
||||
const agent = new ReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session)
|
||||
const dispose = agent.start()
|
||||
const prepared = prepareReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session)
|
||||
const { agent } = prepared
|
||||
prepared.markPublished()
|
||||
const dispose = prepared.startDriver()
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
expect(agent.status).toBe('running')
|
||||
|
||||
const idle = agent.whenIdle() // queues an internal waiter (running)
|
||||
dispose() // settles the waiter synchronously; whenIdle chains done
|
||||
const disposal = dispose() // settles the waiter synchronously; whenIdle chains done
|
||||
await idle
|
||||
expect(agent.status).toBe('disposed')
|
||||
await agent.done
|
||||
await disposal
|
||||
})
|
||||
|
||||
it('whenIdle() subscribed while running survives a FIBER dispose (no hung promise)', async () => {
|
||||
@@ -409,7 +456,7 @@ describe('ReactLoopAgent', () => {
|
||||
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(agent.status).toBe('idle')
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('agent/status listener threw on running'))
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('agent event "agent/status" listener threw'))
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
@@ -427,7 +474,7 @@ describe('ReactLoopAgent', () => {
|
||||
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(agent.status).toBe('idle')
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('agent/status listener threw on idle'))
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('agent event "agent/status" listener threw'))
|
||||
warn.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -202,7 +202,7 @@ describe('Agent.cancel()', () => {
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
|
||||
const handle = ctx.agents.create({
|
||||
const handle = await ctx.agents.create({
|
||||
agentId: AgentId('a-dispose-prefix'),
|
||||
sessionId: SessionId('dispose-prefix-session'),
|
||||
agentOptions: { model: 'mock' },
|
||||
@@ -333,7 +333,7 @@ describe('Agent.cancel()', () => {
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
|
||||
const handle = ctx.agents.create({
|
||||
const handle = await ctx.agents.create({
|
||||
agentId: AgentId('a-dispose-step-start'),
|
||||
sessionId: SessionId('dispose-step-start-session'),
|
||||
agentOptions: { model: 'mock' },
|
||||
|
||||
@@ -24,6 +24,24 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
}
|
||||
|
||||
describe('config-driven session id', () => {
|
||||
it('identity-nests the deferred resume fiber under its labeled owner effect', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const loopFiber = await ctx.plugin(AgentLoop, {
|
||||
agents: [{ id: AgentId('main'), model: 'mock', resumeSessionId: SessionId('deferred') }],
|
||||
})
|
||||
|
||||
const resumeEffect = loopFiber.getEffects().find(effect => effect.label === 'agentLoop.resume(main)')
|
||||
expect(resumeEffect?.children.map(child => child.label)).toEqual(['ctx.plugin()'])
|
||||
expect(loopFiber.getEffects().filter(effect => effect.label === 'ctx.plugin()')).toEqual([])
|
||||
|
||||
await loopFiber.dispose()
|
||||
})
|
||||
|
||||
it('config-driven create uses a fresh ${id}-session-<uuid> per run (restart-safe)', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-session-'))
|
||||
dirs.push(root)
|
||||
@@ -78,7 +96,7 @@ describe('config-driven session id', () => {
|
||||
await ctx1.plugin(AgentLoop, { agents: [] })
|
||||
await ctx1.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('first')]))
|
||||
const a1 = ctx1.agents.create({ agentId: AgentId('main'), sessionId: SessionId('sticky-1') }).agent as ReactLoopAgent
|
||||
const a1 = (await ctx1.agents.create({ agentId: AgentId('main'), sessionId: SessionId('sticky-1') })).agent as ReactLoopAgent
|
||||
a1.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } })
|
||||
await waitForIdle(ctx1, a1)
|
||||
await ctx1.fiber.dispose()
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { CallId, LlmError, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
@@ -35,31 +36,24 @@ function send(agent: ReactLoopAgent, text: string) {
|
||||
agent.send([{ type: 'text', text }])
|
||||
}
|
||||
|
||||
describe('turn boundary listener throws (handled in-turn, loop survives)', () => {
|
||||
it('a pre-push turn/start failure (non-serializable source) is rethrown to the runLoop backstop', async () => {
|
||||
// A non-serializable message source makes the turn/start append throw BEFORE
|
||||
// the event is pushed (Session.append validates before push), so turn/start
|
||||
// never enters the log. runTurn sees no logged turn/start and rethrows; the
|
||||
// runLoop backstop reports via agent/error (step 0) + the logger and the
|
||||
// driver survives. This is the ONLY path that reaches the backstop.
|
||||
const adapter = new MockAdapter([textResponse('turn 2')])
|
||||
describe('inbox acceptance', () => {
|
||||
it('rejects non-serializable content or source synchronously before notification or enqueue', async () => {
|
||||
const adapter = new MockAdapter([textResponse('turn 1')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
let queued = 0
|
||||
ctx.on('agent/queued', () => { queued += 1 })
|
||||
|
||||
const errors: { turn: number; step: number; message: string }[] = []
|
||||
ctx.on('agent/error', (_a, turn, step, error) => void errors.push({ turn, step, message: error.message }))
|
||||
expect(() => {
|
||||
agent.send([{ type: 'text', text: 'first', bad: 1n } as never])
|
||||
}).toThrow(/losslessly JSON-serializable/)
|
||||
expect(() => {
|
||||
agent.send([{ type: 'text', text: 'first' }], { source: { kind: 'plugin', plugin: 'p', bad: 1n } as never })
|
||||
}).toThrow(/losslessly JSON-serializable/)
|
||||
expect(queued).toBe(0)
|
||||
expect(agent.session.events).toHaveLength(0)
|
||||
|
||||
// A non-serializable source (BigInt) on the queued message.
|
||||
agent.send([{ type: 'text', text: 'first' }], { source: { kind: 'plugin', plugin: 'p', bad: 1n } as never })
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(errors[0]!.step).toBe(0)
|
||||
expect(errors[0]!.message).toMatch(/non-JSON-serializable/)
|
||||
// No turn boundary was written (the turn/start append threw before push).
|
||||
expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false)
|
||||
|
||||
// loop survives: a well-formed second turn runs normally.
|
||||
// The rejected value never woke or poisoned the loop; a valid message runs.
|
||||
send(agent, 'second')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
@@ -129,13 +123,15 @@ describe('tool JSON parse', () => {
|
||||
})
|
||||
|
||||
describe('toError normalization', () => {
|
||||
it('normalizes non-Error throws from a turn/start session-event listener via toError', async () => {
|
||||
it('normalizes non-Error throws from pre-commit dispatch validation via the runLoop backstop', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
let threwOnce = false
|
||||
ctx.on('session/event', (_session, event) => {
|
||||
ctx.on('internal/dispatch', (_mode, name, args) => {
|
||||
if (name !== 'session/event') return
|
||||
const event = args[1] as SessionEvent
|
||||
if (event.type === 'turn/start' && !threwOnce) {
|
||||
threwOnce = true
|
||||
throw 'naked string error' // non-Error throw, normalized via toError
|
||||
@@ -148,11 +144,9 @@ describe('toError normalization', () => {
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(errors[0]!.message).toBe('naked string error')
|
||||
// A non-Error throw is wrapped in a HarnessError with code UNKNOWN, so the
|
||||
// turn-end error reason carries a routable code instead of degrading.
|
||||
const turnEnd = agent.session.events.find(e => e.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error' && turnEnd.data.reason.code).toBe('UNKNOWN')
|
||||
expect(errors[0]).toMatchObject({ message: 'naked string error', code: 'UNKNOWN' })
|
||||
expect(adapter.requests).toEqual([])
|
||||
expect(agent.session.events.some(event => event.type === 'turn/start' || event.type === 'turn/end')).toBe(false)
|
||||
})
|
||||
|
||||
it('normalizes non-Error throws from agent/request waterfall via inline toError in runStep catch', async () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Inbox } from '@deepseek-ai/dsh-agent-loop'
|
||||
import { Inbox } from '../src/inbox.ts'
|
||||
|
||||
function resolverPair() {
|
||||
let r!: () => void
|
||||
|
||||
@@ -311,6 +311,36 @@ describe('agent/session-start', () => {
|
||||
})
|
||||
|
||||
describe('agent/session-prefix', () => {
|
||||
it('dispatches to global and matching agent-scope listeners only', async () => {
|
||||
const adapter = new MockAdapter([textResponse('a done'), textResponse('b done')])
|
||||
const ctx = await harness(adapter)
|
||||
const agentA = ctx.agentLoop.create(AgentId('prefix-a'), { model: 'mock' })
|
||||
const agentB = ctx.agentLoop.create(AgentId('prefix-b'), { model: 'mock' })
|
||||
const seen: string[] = []
|
||||
ctx.on('agent/session-prefix', async (agent, _prefix, _signal, next) => {
|
||||
seen.push(`global:${agent.id}`)
|
||||
return next()
|
||||
})
|
||||
agentA.ctx.on('agent/session-prefix', async (agent, _prefix, _signal, next) => {
|
||||
seen.push(`a:${agent.id}`)
|
||||
return next()
|
||||
})
|
||||
agentB.ctx.on('agent/session-prefix', async (agent, _prefix, _signal, next) => {
|
||||
seen.push(`b:${agent.id}`)
|
||||
return next()
|
||||
})
|
||||
|
||||
send(agentA, 'run a')
|
||||
await waitForIdle(ctx, agentA)
|
||||
send(agentB, 'run b')
|
||||
await waitForIdle(ctx, agentB)
|
||||
|
||||
expect(seen).toEqual([
|
||||
'global:prefix-a', 'a:prefix-a',
|
||||
'global:prefix-b', 'b:prefix-b',
|
||||
])
|
||||
})
|
||||
|
||||
it('composes once per loop instance and fronts every request; the header records it; history stays untouched', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('c1', 'echo', { text: 'ping' }),
|
||||
|
||||
@@ -168,7 +168,7 @@ describe('agent loop', () => {
|
||||
it('resolves {{cwd}} from the agent session workspace (factory create with meta.cwd)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter, 'Working in {{cwd}}.')
|
||||
const handle = ctx.agents.create({
|
||||
const handle = await ctx.agents.create({
|
||||
agentId: AgentId('a-cwd'),
|
||||
sessionId: SessionId('s-cwd'),
|
||||
meta: { cwd: '/work/space' },
|
||||
@@ -243,6 +243,44 @@ describe('agent loop', () => {
|
||||
expect(adapter.requests[0]!.system).toBe('You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou run on mock.')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['BigInt', { n: 1n }],
|
||||
['Map', new Map([['key', 'value']])],
|
||||
['class instance', new (class ResultMeta { x = 1 })()],
|
||||
])('normalizes non-JSON tool meta (%s) before the durable result commit', async (_kind, meta) => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('bad-meta-call', 'bad-meta', {}, 'calling'),
|
||||
textResponse('recovered'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'bad-meta',
|
||||
description: 'returns invalid durable metadata',
|
||||
parameters: {},
|
||||
execute: () => Promise.resolve({ content: [{ type: 'text' as const, text: 'apparent success' }], meta }),
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('bad-meta-agent'), { model: 'mock' })
|
||||
|
||||
send(agent, 'use the tool')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const result = agent.session.events.find(event => event.type === 'tool/result')
|
||||
expect(result?.type).toBe('tool/result')
|
||||
if (result?.type === 'tool/result') {
|
||||
expect(result.data.callId).toBe('bad-meta-call')
|
||||
expect(result.data.isError).toBe(true)
|
||||
expect(result.data.meta).toBeUndefined()
|
||||
expect(result.data.content).toEqual([{
|
||||
type: 'text',
|
||||
text: 'Error: tool result must be losslessly JSON-serializable',
|
||||
}])
|
||||
}
|
||||
// The normalized failure was durably logged and fed back to the model; the
|
||||
// turn continued normally instead of failing after an apparent success.
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('losslessly JSON-serializable')
|
||||
})
|
||||
|
||||
it('omits the system field when a system-prompt/assemble veto empties the assembly', async () => {
|
||||
// The documented escape valve: a deployment that must drop the harness
|
||||
// openers short-circuits the assemble waterfall; the request then carries
|
||||
@@ -772,10 +810,10 @@ describe('agent loop', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('stops the turn when a step/end session-event listener failure has recorded an error', async () => {
|
||||
it('contains a step/end observer failure without changing continuation', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('c1', 'echo', { text: 'x' }),
|
||||
textResponse('should not run'),
|
||||
textResponse('continued after tool call'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.tools.register(defineTool({
|
||||
@@ -788,9 +826,8 @@ describe('agent loop', () => {
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
let threw = false
|
||||
// A throwing step/end session-event listener is the surviving boundary-listener
|
||||
// failure path (step boundaries have no agent/* mirror): closeStep contains it
|
||||
// and surfaces it as a turn error rather than stranding the turn open.
|
||||
// Post-commit session observers cannot control the loop. The tool call still
|
||||
// drives the second model request, and the turn completes normally.
|
||||
ctx.on('session/event', (_session, event) => {
|
||||
if (event.type === 'step/end' && !threw) { threw = true; throw new Error('bad step/end listener') }
|
||||
})
|
||||
@@ -798,9 +835,9 @@ describe('agent loop', () => {
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
const turnEnd = agent.session.events.findLast(e => e.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('error')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('completed')
|
||||
})
|
||||
|
||||
it('chains queued messages into consecutive turns', async () => {
|
||||
|
||||
@@ -222,7 +222,7 @@ describe('request stability across the loop', () => {
|
||||
// one's full log (the resume/fork path).
|
||||
const adapter2 = new MockAdapter([textResponse('two')])
|
||||
const ctx2 = await harness(adapter2)
|
||||
const handle = ctx2.agents.create({
|
||||
const handle = await ctx2.agents.create({
|
||||
agentId: AgentId('gen2'),
|
||||
sessionId: SessionId('gen2-session'),
|
||||
seed: [...agent.session.events],
|
||||
|
||||
@@ -19,6 +19,10 @@ afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive:
|
||||
async function persistentHarness(adapter: MockAdapter): Promise<{ ctx: Context; root: string }> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-resume-'))
|
||||
dirs.push(root)
|
||||
return { ctx: await mountPersistentHarness(root, adapter), root }
|
||||
}
|
||||
|
||||
async function mountPersistentHarness(root: string, adapter: MockAdapter): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
@@ -28,7 +32,22 @@ async function persistentHarness(adapter: MockAdapter): Promise<{ ctx: Context;
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return { ctx, root }
|
||||
return ctx
|
||||
}
|
||||
|
||||
async function persistSession(sessionId: SessionId): Promise<string> {
|
||||
const { ctx, root } = await persistentHarness(new MockAdapter([textResponse('seed')]))
|
||||
// Persistence deliberately has no artifact for a truly empty session. A
|
||||
// balanced completed turn is the smallest resumable log and avoids running
|
||||
// the model merely to construct this lifecycle fixture.
|
||||
const seed: SessionEvent[] = [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
]
|
||||
const session = ctx.sessions.create(sessionId, { seed })
|
||||
await ctx.sessions.flush(session)
|
||||
await ctx.fiber.dispose()
|
||||
return root
|
||||
}
|
||||
|
||||
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
@@ -39,11 +58,44 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
/** Fail a lifecycle regression promptly instead of waiting for Vitest's suite timeout. */
|
||||
async function promptly<T>(task: Promise<T>): Promise<T> {
|
||||
const timeout = Promise.withResolvers<never>()
|
||||
const timer = setTimeout(() => { timeout.reject(new Error('lifecycle task did not settle promptly')) }, 1000)
|
||||
try {
|
||||
return await Promise.race([task, timeout.promise])
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
/** Throw an arbitrary callback value to exercise the public unknown-error boundary. */
|
||||
function throwUnknown(value: unknown): never {
|
||||
throw value
|
||||
}
|
||||
|
||||
describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
it('normalizes a non-Error resume publication failure for rollback and rethrows it', async () => {
|
||||
const sessionId = SessionId('unknown-resume-failure-s')
|
||||
const root = await persistSession(sessionId)
|
||||
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
|
||||
const failure = { source: 'resume' }
|
||||
ctx.on('session/created', () => throwUnknown(failure))
|
||||
|
||||
await expect(ctx.agents.resume({
|
||||
agentId: AgentId('unknown-resume-failure'),
|
||||
resumeSessionId: sessionId,
|
||||
})).rejects.toBe(failure)
|
||||
|
||||
expect(ctx.agents.get(AgentId('unknown-resume-failure'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(sessionId)).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('createAgent uses the caller-supplied sessionId (not ${id}-session)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('hi')])
|
||||
const { ctx } = await persistentHarness(adapter)
|
||||
const { agent } = ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('custom-session'), meta: { cwd: '/w' } })
|
||||
const { agent } = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('custom-session'), meta: { cwd: '/w' } })
|
||||
expect(agent.session.id).toBe('custom-session')
|
||||
expect(agent.session.header.cwd).toBe('/w')
|
||||
await ctx.fiber.dispose()
|
||||
@@ -52,10 +104,10 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
it('createAgent rejects a duplicate agent id BEFORE creating the session (no orphan)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('hi')])
|
||||
const { ctx } = await persistentHarness(adapter)
|
||||
ctx.agents.create({ agentId: AgentId('dup'), sessionId: SessionId('sess-a') })
|
||||
await ctx.agents.create({ agentId: AgentId('dup'), sessionId: SessionId('sess-a') })
|
||||
// A second create with the SAME agent id but a fresh session id must reject
|
||||
// up front — and must NOT leave an orphaned 'sess-b' session behind.
|
||||
expect(() => ctx.agents.create({ agentId: AgentId('dup'), sessionId: SessionId('sess-b') })).toThrow(/already registered/)
|
||||
await expect(ctx.agents.create({ agentId: AgentId('dup'), sessionId: SessionId('sess-b') })).rejects.toThrow(/already registered/)
|
||||
expect(ctx.sessions.get(SessionId('sess-b'))).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
@@ -63,7 +115,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
it('createAgent works without meta (no cwd)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('hi')])
|
||||
const { ctx } = await persistentHarness(adapter)
|
||||
const { agent } = ctx.agents.create({ agentId: AgentId('a-nometa'), sessionId: SessionId('nometa-session') })
|
||||
const { agent } = await ctx.agents.create({ agentId: AgentId('a-nometa'), sessionId: SessionId('nometa-session') })
|
||||
expect(agent.session.id).toBe('nometa-session')
|
||||
expect(agent.session.header.cwd).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
@@ -73,7 +125,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
// Lifecycle 1: create a no-cwd session and run a turn.
|
||||
const adapter1 = new MockAdapter([textResponse('a')])
|
||||
const { ctx: ctx1, root } = await persistentHarness(adapter1)
|
||||
const a1 = ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('nocwd-sess') }).agent as ReactLoopAgent
|
||||
const a1 = (await ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('nocwd-sess') })).agent as ReactLoopAgent
|
||||
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
|
||||
await waitForIdle(ctx1, a1)
|
||||
await ctx1.fiber.dispose()
|
||||
@@ -100,7 +152,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
const { ctx: ctx1, root } = await persistentHarness(adapter1)
|
||||
const sources1: string[] = []
|
||||
ctx1.on('agent/session-start', (_agent, source) => void sources1.push(source))
|
||||
const a1 = ctx1.agents.create({ agentId: AgentId('s'), sessionId: SessionId('start-sess') }).agent as ReactLoopAgent
|
||||
const a1 = (await ctx1.agents.create({ agentId: AgentId('s'), sessionId: SessionId('start-sess') })).agent as ReactLoopAgent
|
||||
expect(sources1).toEqual(['startup'])
|
||||
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
|
||||
await waitForIdle(ctx1, a1)
|
||||
@@ -124,6 +176,250 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
await ctx2.fiber.dispose()
|
||||
})
|
||||
|
||||
it('resume awaits setup while unpublished, then publishes a fully composed world in order', async () => {
|
||||
const sessionId = SessionId('resume-setup-success')
|
||||
const root = await persistSession(sessionId)
|
||||
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
const setupStarted = Promise.withResolvers<undefined>()
|
||||
const order: string[] = []
|
||||
|
||||
ctx.on('session/created', (session) => {
|
||||
expect(ctx.sessions.get(session.id)).toBe(session)
|
||||
expect(ctx.agents.get(AgentId('resumed-atomic'))?.session).toBe(session)
|
||||
order.push('session/created')
|
||||
})
|
||||
ctx.on('agent/created', (agent) => {
|
||||
expect(agent.status).toBe('idle')
|
||||
order.push('agent/created')
|
||||
})
|
||||
ctx.on('agent/session-start', (agent) => {
|
||||
expect(() => { agent.cancel('now live') }).not.toThrow()
|
||||
order.push('agent/session-start')
|
||||
})
|
||||
|
||||
const resuming = ctx.agents.resume({
|
||||
agentId: AgentId('resumed-atomic'),
|
||||
resumeSessionId: sessionId,
|
||||
agentOptions: { model: 'mock' },
|
||||
setup: async (agentCtx) => {
|
||||
expect(agentCtx.agent?.id).toBe(AgentId('resumed-atomic'))
|
||||
expect(agentCtx.agent?.session.events).toHaveLength(2)
|
||||
agentCtx.on('session/created', () => void order.push('setup-listener:session/created'))
|
||||
agentCtx.on('agent/created', () => void order.push('setup-listener:agent/created'))
|
||||
order.push('setup:start')
|
||||
setupStarted.resolve(undefined)
|
||||
await gate.promise
|
||||
order.push('setup:end')
|
||||
},
|
||||
})
|
||||
|
||||
await setupStarted.promise
|
||||
expect(ctx.agents.get(AgentId('resumed-atomic'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(sessionId)).toBeUndefined()
|
||||
expect(order).toEqual(['setup:start'])
|
||||
|
||||
gate.resolve(undefined)
|
||||
const handle = await resuming
|
||||
expect(order).toEqual([
|
||||
'setup:start',
|
||||
'setup:end',
|
||||
'session/created',
|
||||
'setup-listener:session/created',
|
||||
'agent/created',
|
||||
'setup-listener:agent/created',
|
||||
'agent/session-start',
|
||||
])
|
||||
await handle.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('successful resume disposal retires its caller-owned transaction effects', async () => {
|
||||
const sessionId = SessionId('resume-retired-effects-s')
|
||||
const agentId = AgentId('resume-retired-effects')
|
||||
const root = await persistSession(sessionId)
|
||||
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
|
||||
const handle = await ctx.agents.resume({
|
||||
agentId,
|
||||
resumeSessionId: sessionId,
|
||||
agentOptions: { model: 'mock' },
|
||||
})
|
||||
const transactionLabels = [
|
||||
`agentLoop.owner(${agentId})`,
|
||||
`agentLoop.lifecycle(${agentId})`,
|
||||
]
|
||||
|
||||
expect(ctx.fiber.getEffects().map(effect => effect.label)).toEqual(expect.arrayContaining(transactionLabels))
|
||||
await handle.dispose()
|
||||
expect(ctx.fiber.getEffects().filter(effect => transactionLabels.includes(effect.label))).toEqual([])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('resume setup rejection publishes nothing, unwinds, and releases both identities', async () => {
|
||||
const sessionId = SessionId('resume-setup-reject')
|
||||
const root = await persistSession(sessionId)
|
||||
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
|
||||
const published: string[] = []
|
||||
ctx.on('session/created', () => void published.push('session/created'))
|
||||
ctx.on('agent/created', () => void published.push('agent/created'))
|
||||
ctx.on('agent/session-start', () => void published.push('agent/session-start'))
|
||||
|
||||
await expect(ctx.agents.resume({
|
||||
agentId: AgentId('resume-reject'),
|
||||
resumeSessionId: sessionId,
|
||||
agentOptions: { model: 'mock' },
|
||||
setup: async () => {
|
||||
await Promise.resolve()
|
||||
throw new Error('resume setup failed')
|
||||
},
|
||||
})).rejects.toThrow('resume setup failed')
|
||||
|
||||
expect(published).toEqual([])
|
||||
expect(ctx.agents.get(AgentId('resume-reject'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(sessionId)).toBeUndefined()
|
||||
const retry = await ctx.agents.resume({
|
||||
agentId: AgentId('resume-reject'),
|
||||
resumeSessionId: sessionId,
|
||||
agentOptions: { model: 'mock' },
|
||||
})
|
||||
await retry.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('owner unload aborts resume setup and cannot publish after the callback settles', async () => {
|
||||
const sessionId = SessionId('resume-setup-owner-unload')
|
||||
const root = await persistSession(sessionId)
|
||||
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
const setupStarted = Promise.withResolvers<undefined>()
|
||||
const published: string[] = []
|
||||
ctx.on('session/created', () => void published.push('session/created'))
|
||||
ctx.on('agent/created', () => void published.push('agent/created'))
|
||||
|
||||
let resuming!: ReturnType<typeof ctx.agents.resume>
|
||||
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
resuming = inner.agents.resume({
|
||||
agentId: AgentId('resume-owner-race'),
|
||||
resumeSessionId: sessionId,
|
||||
agentOptions: { model: 'mock' },
|
||||
setup: async () => {
|
||||
setupStarted.resolve(undefined)
|
||||
await gate.promise
|
||||
},
|
||||
})
|
||||
}, { inject: ['agents'] }))
|
||||
await setupStarted.promise
|
||||
|
||||
await owner.dispose()
|
||||
await expect(resuming).rejects.toThrow(/owner disposed during setup/)
|
||||
expect(published).toEqual([])
|
||||
expect(ctx.agents.get(AgentId('resume-owner-race'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(sessionId)).toBeUndefined()
|
||||
|
||||
gate.resolve(undefined)
|
||||
await Promise.resolve()
|
||||
expect(published).toEqual([])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('owner unload aborts a never-settling persistence load, releases identities, and blocks late publication', async () => {
|
||||
const sessionId = SessionId('resume-load-owner-unload')
|
||||
const agentId = AgentId('resume-load-race')
|
||||
const root = await persistSession(sessionId)
|
||||
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
|
||||
const snapshot = await ctx.sessionPersistence.load(sessionId)
|
||||
const lateLoad = Promise.withResolvers<typeof snapshot>()
|
||||
const loadStarted = Promise.withResolvers<undefined>()
|
||||
let loads = 0
|
||||
ctx.sessionPersistence.load = (id) => {
|
||||
expect(id).toBe(sessionId)
|
||||
loads += 1
|
||||
if (loads === 1) {
|
||||
loadStarted.resolve(undefined)
|
||||
return lateLoad.promise
|
||||
}
|
||||
return Promise.resolve(structuredClone(snapshot))
|
||||
}
|
||||
|
||||
const published: string[] = []
|
||||
ctx.on('session/created', () => void published.push('session/created'))
|
||||
ctx.on('agent/created', () => void published.push('agent/created'))
|
||||
ctx.on('agent/session-start', () => void published.push('agent/session-start'))
|
||||
|
||||
let resuming!: ReturnType<typeof ctx.agents.resume>
|
||||
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
resuming = inner.agents.resume({ agentId, resumeSessionId: sessionId, agentOptions: { model: 'mock' } })
|
||||
}, { inject: ['agents'] }))
|
||||
await loadStarted.promise
|
||||
|
||||
const rejection = expect(promptly(resuming)).rejects.toThrow(/owner disposed during setup/)
|
||||
await promptly(owner.dispose())
|
||||
expect(published).toEqual([])
|
||||
expect(ctx.agents.get(agentId)).toBeUndefined()
|
||||
expect(ctx.sessions.get(sessionId)).toBeUndefined()
|
||||
|
||||
// owner.dispose() awaited transaction settlement, so the same identities
|
||||
// can be reused before awaiting the public rejection.
|
||||
const retry = await promptly(ctx.agents.resume({ agentId, resumeSessionId: sessionId, agentOptions: { model: 'mock' } }))
|
||||
await rejection
|
||||
expect(loads).toBe(2)
|
||||
expect(published).toEqual(['session/created', 'agent/created', 'agent/session-start'])
|
||||
|
||||
// Settlement of the abandoned backend promise cannot resume the old
|
||||
// transaction or emit a second publication after the retry owns the ids.
|
||||
lateLoad.resolve(structuredClone(snapshot))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(ctx.agents.get(agentId)).toBe(retry.agent)
|
||||
expect(ctx.sessions.get(sessionId)).toBe(retry.agent.session)
|
||||
expect(published).toEqual(['session/created', 'agent/created', 'agent/session-start'])
|
||||
|
||||
await retry.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('AgentLoop unload aborts persistence load and awaits wrapper settlement', async () => {
|
||||
const sessionId = SessionId('resume-load-factory-unload')
|
||||
const agentId = AgentId('resume-load-factory-race')
|
||||
const root = await persistSession(sessionId)
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const loopFiber = await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('next')]))
|
||||
|
||||
const snapshot = await ctx.sessionPersistence.load(sessionId)
|
||||
const lateLoad = Promise.withResolvers<typeof snapshot>()
|
||||
const loadStarted = Promise.withResolvers<undefined>()
|
||||
ctx.sessionPersistence.load = (id) => {
|
||||
expect(id).toBe(sessionId)
|
||||
loadStarted.resolve(undefined)
|
||||
return lateLoad.promise
|
||||
}
|
||||
const published: string[] = []
|
||||
ctx.on('session/created', () => void published.push('session/created'))
|
||||
ctx.on('agent/created', () => void published.push('agent/created'))
|
||||
|
||||
const resuming = ctx.agents.resume({ agentId, resumeSessionId: sessionId, agentOptions: { model: 'mock' } })
|
||||
await loadStarted.promise
|
||||
const rejection = expect(promptly(resuming)).rejects.toThrow(/agent loop is not active/)
|
||||
await promptly(loopFiber.dispose())
|
||||
await rejection
|
||||
|
||||
expect(published).toEqual([])
|
||||
expect(ctx.agents.get(agentId)).toBeUndefined()
|
||||
expect(ctx.sessions.get(sessionId)).toBeUndefined()
|
||||
lateLoad.resolve(structuredClone(snapshot))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(published).toEqual([])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('resume of a forked session preserves the parentSession lineage and seed boundary in the header', async () => {
|
||||
// Lifecycle 1: persist a FORKED session (carries parentSession + seedLength
|
||||
// in its header) by creating it with a complete-turn seed — the write path
|
||||
@@ -170,7 +466,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
// disk, since a crash before the next turn would otherwise lose it.
|
||||
const adapter1 = new MockAdapter([textResponse('answer')])
|
||||
const { ctx: ctx1, root } = await persistentHarness(adapter1)
|
||||
const a1 = ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } }).agent as ReactLoopAgent
|
||||
const a1 = (await ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent as ReactLoopAgent
|
||||
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
|
||||
await waitForIdle(ctx1, a1)
|
||||
a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } })
|
||||
@@ -195,7 +491,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
// drop it on reload (the bug this guards).
|
||||
const adapter1 = new MockAdapter([textResponse('answer')])
|
||||
const { ctx: ctx1, root } = await persistentHarness(adapter1)
|
||||
const a1 = ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } }).agent as ReactLoopAgent
|
||||
const a1 = (await ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent as ReactLoopAgent
|
||||
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
|
||||
await waitForIdle(ctx1, a1)
|
||||
a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } })
|
||||
@@ -223,7 +519,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
// Lifecycle 1: run one full turn, persisting it.
|
||||
const adapter1 = new MockAdapter([textResponse('first answer')])
|
||||
const { ctx: ctx1, root } = await persistentHarness(adapter1)
|
||||
const a1 = ctx1.agents.create({ agentId: AgentId('main'), sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } }).agent as ReactLoopAgent
|
||||
const a1 = (await ctx1.agents.create({ agentId: AgentId('main'), sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } })).agent as ReactLoopAgent
|
||||
a1.send([{ type: 'text', text: 'first question' }], { source: { kind: 'user' } })
|
||||
await waitForIdle(ctx1, a1)
|
||||
const events1 = [...a1.session.events]
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { CallId, MessageSource, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import LlmService, { CallId, ContentBlock, MessageSource, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { AgentId, type ContinuationDecision } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import { prepareReactLoopAgent } from '../src/agent.ts'
|
||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
|
||||
|
||||
/**
|
||||
* Regression tests for the findings of the first architecture review
|
||||
* (Codex + sub-agent, post phase-1). Each describe block names the finding.
|
||||
*/
|
||||
/** Regression tests for agent-loop boundary, identity, and lifecycle contracts. */
|
||||
|
||||
async function harness(adapter: MockAdapter) {
|
||||
const ctx = new Context()
|
||||
@@ -436,6 +434,94 @@ describe('MEDIUM: misc registry and config fixes', () => {
|
||||
const steeringSources = agent.session.events.flatMap(e => e.type === 'steering/message' ? [e.data.source] : [])
|
||||
expect(steeringSources).toEqual([{ kind: 'plugin', plugin: 'goal' }])
|
||||
})
|
||||
|
||||
it('send() owns content and source before notification and delivery', async () => {
|
||||
const adapter = new MockAdapter([textResponse('done')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('owned-send'), { model: 'mock' })
|
||||
const content = [{ type: 'text' as const, text: 'accepted-send' }]
|
||||
const source = { kind: 'plugin' as const, plugin: 'accepted-source' }
|
||||
let notifiedContent: ContentBlock[] | undefined
|
||||
let notifiedSource: MessageSource | undefined
|
||||
ctx.on('agent/queued', (subject, acceptedContent, info) => {
|
||||
if (subject !== agent || info.steering) return
|
||||
// Retain the exact notification references: cloning here would test the
|
||||
// listener's copy rather than the event/inbox ownership boundary.
|
||||
notifiedContent = acceptedContent
|
||||
notifiedSource = info.source
|
||||
})
|
||||
|
||||
agent.send(content, { source })
|
||||
content[0]!.text = 'caller-mutated-send'
|
||||
source.plugin = 'caller-mutated-source'
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(notifiedContent).toEqual([{ type: 'text', text: 'accepted-send' }])
|
||||
expect(notifiedSource).toEqual({ kind: 'plugin', plugin: 'accepted-source' })
|
||||
expect(Object.isFrozen(notifiedContent)).toBe(true)
|
||||
expect(Object.isFrozen(notifiedContent?.[0])).toBe(true)
|
||||
expect(Object.isFrozen(notifiedSource)).toBe(true)
|
||||
const recorded = agent.session.events.flatMap(event => event.type === 'user/message' ? [event.data] : [])
|
||||
expect(recorded).toContainEqual({
|
||||
content: [{ type: 'text', text: 'accepted-send' }],
|
||||
source: { kind: 'plugin', plugin: 'accepted-source' },
|
||||
})
|
||||
const request = JSON.stringify(adapter.requests[0]!.messages)
|
||||
expect(request).toContain('accepted-send')
|
||||
expect(request).not.toContain('caller-mutated-send')
|
||||
})
|
||||
|
||||
it('running steer() owns content and source before notification and delivery', async () => {
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'gate', {}), textResponse('done')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('owned-steer'), { model: 'mock' })
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'gate',
|
||||
description: '',
|
||||
parameters: {},
|
||||
async execute() {
|
||||
entered.resolve(undefined)
|
||||
await release.promise
|
||||
return [{ type: 'text', text: 'tool done' }]
|
||||
},
|
||||
}))
|
||||
let notifiedContent: ContentBlock[] | undefined
|
||||
let notifiedSource: MessageSource | undefined
|
||||
ctx.on('agent/queued', (subject, acceptedContent, info) => {
|
||||
if (subject !== agent || !info.steering) return
|
||||
notifiedContent = acceptedContent
|
||||
notifiedSource = info.source
|
||||
})
|
||||
|
||||
agent.send([{ type: 'text', text: 'start' }])
|
||||
await entered.promise
|
||||
expect(agent.status).toBe('running')
|
||||
const content = [{ type: 'text' as const, text: 'accepted-steer' }]
|
||||
const source = { kind: 'plugin' as const, plugin: 'accepted-source' }
|
||||
agent.steer(content, { source })
|
||||
content[0]!.text = 'caller-mutated-steer'
|
||||
source.plugin = 'caller-mutated-source'
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
release.resolve(undefined)
|
||||
await idle
|
||||
|
||||
expect(notifiedContent).toEqual([{ type: 'text', text: 'accepted-steer' }])
|
||||
expect(notifiedSource).toEqual({ kind: 'plugin', plugin: 'accepted-source' })
|
||||
expect(Object.isFrozen(notifiedContent)).toBe(true)
|
||||
expect(Object.isFrozen(notifiedContent?.[0])).toBe(true)
|
||||
expect(Object.isFrozen(notifiedSource)).toBe(true)
|
||||
const recorded = agent.session.events.flatMap(event => event.type === 'steering/message' ? [event.data] : [])
|
||||
expect(recorded).toContainEqual({
|
||||
turn: 1,
|
||||
content: [{ type: 'text', text: 'accepted-steer' }],
|
||||
source: { kind: 'plugin', plugin: 'accepted-source' },
|
||||
})
|
||||
const request = JSON.stringify(adapter.requests[1]!.messages)
|
||||
expect(request).toContain('accepted-steer')
|
||||
expect(request).not.toContain('caller-mutated-steer')
|
||||
})
|
||||
})
|
||||
|
||||
describe('MEDIUM: turn numbering continues across seeded (forked) sessions', () => {
|
||||
@@ -458,8 +544,10 @@ describe('MEDIUM: turn numbering continues across seeded (forked) sessions', ()
|
||||
ctx2.llm.registerAdapter(['mock'], second)
|
||||
|
||||
const seeded = ctx2.sessions.create(SessionId('forked'), { seed: [...agent.session.events] })
|
||||
const forked = new ReactLoopAgent(ctx2, AgentId('forked-agent'), { model: 'mock' }, seeded)
|
||||
ctx2.effect(() => forked.start())
|
||||
const prepared = prepareReactLoopAgent(ctx2, AgentId('forked-agent'), { model: 'mock' }, seeded)
|
||||
const forked = prepared.agent
|
||||
prepared.markPublished()
|
||||
ctx2.effect(() => prepared.startDriver())
|
||||
|
||||
const turns: number[] = []
|
||||
ctx2.on('session/event', (_s, event) => { if (event.type === 'turn/start') turns.push(event.data.turn) })
|
||||
@@ -558,7 +646,7 @@ describe('HIGH: a finish-error stream chunk ends the turn as error, not complete
|
||||
})
|
||||
})
|
||||
|
||||
describe('P1-6: a step/start session-event listener sees the event already in the log', () => {
|
||||
describe('step boundary publication order', () => {
|
||||
it('the step/start event is in session.events when its session/event listener fires', async () => {
|
||||
const adapter = new MockAdapter([textResponse('done')])
|
||||
const ctx = await harness(adapter)
|
||||
@@ -589,7 +677,7 @@ describe('P1-6: a step/start session-event listener sees the event already in th
|
||||
})
|
||||
})
|
||||
|
||||
describe('P1-5: a started turn (and any open step) is always closed on a boundary throw', () => {
|
||||
describe('turn and step boundary recovery', () => {
|
||||
// Harness with the invariants plugin loaded as an oracle: it throws on
|
||||
// append if the log goes unbalanced (turn/end while a step is open,
|
||||
// turn/start while a turn is open, etc.), so a regression surfaces as an
|
||||
@@ -602,7 +690,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(Invariants, { freeze: false })
|
||||
await ctx.plugin(Invariants)
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return ctx
|
||||
}
|
||||
@@ -620,19 +708,13 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
|
||||
}
|
||||
}
|
||||
|
||||
it('a throwing step/start session-event listener closes the open step then the turn (step/end before turn/end)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('never reached')])
|
||||
it('a throwing step/start observer cannot change a successful turn', async () => {
|
||||
const adapter = new MockAdapter([textResponse('request completed')])
|
||||
const ctx = await balancedHarness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a-stepstart'), { model: 'mock' })
|
||||
|
||||
// Step boundaries have no agent/* mirror; a throwing step/start session-event
|
||||
// listener is the surviving step-boundary-listener failure. The loop marks
|
||||
// the step open BEFORE appending step/start (Session.append pushes before
|
||||
// notifying, so a post-push listener throw still leaves stepOpen=true), so
|
||||
// the outer catch's closeStep() appends the balancing step/end — the turn
|
||||
// stays enclosed. The invariants oracle (balancedHarness) rejects any
|
||||
// imbalance, so a green run proves turn/start → step/start → step/end →
|
||||
// turn/end nesting holds.
|
||||
// Session owns post-commit containment. The loop sees a successful append,
|
||||
// runs the request, and balances the ordinary step and turn boundaries.
|
||||
let threw = false
|
||||
ctx.on('session/event', (_s, event) => {
|
||||
if (event.type === 'step/start' && !threw) { threw = true; throw new Error('boom step-start') }
|
||||
@@ -645,8 +727,8 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
|
||||
|
||||
const e = [...agent.session.events]
|
||||
const c = boundaryCounts(agent)
|
||||
expect(c).toMatchObject({ turnStart: 1, turnEnd: 1, stepStart: 1, stepEnd: 1, errors: 1 })
|
||||
expect(errors.map(x => x.message)).toEqual(['boom step-start'])
|
||||
expect(c).toMatchObject({ turnStart: 1, turnEnd: 1, stepStart: 1, stepEnd: 1, errors: 0 })
|
||||
expect(errors).toEqual([])
|
||||
// step/end precedes turn/end (the invariants oracle would reject
|
||||
// turn/end-while-step-open, but assert the order explicitly too).
|
||||
const stepEndIdx = e.findIndex(x => x.type === 'step/end')
|
||||
@@ -655,6 +737,101 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
|
||||
expect(stepEndIdx).toBeLessThan(turnEndIdx)
|
||||
})
|
||||
|
||||
it('a pre-commit step/start validation failure does not invent a step boundary', async () => {
|
||||
const adapter = new MockAdapter([textResponse('never reached')])
|
||||
const ctx = await balancedHarness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a-stepstart-veto'), { model: 'mock' })
|
||||
let rejected = false
|
||||
ctx.on('internal/dispatch', (_mode, name, args) => {
|
||||
if (name !== 'session/event') return
|
||||
const event = args[1] as SessionEvent
|
||||
if (event.type === 'step/start' && !rejected) {
|
||||
rejected = true
|
||||
throw new Error('reject step-start before commit')
|
||||
}
|
||||
})
|
||||
const errors: Error[] = []
|
||||
ctx.on('agent/error', (_agent, _turn, _step, error) => { errors.push(error) })
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(adapter.requests).toEqual([])
|
||||
expect(boundaryCounts(agent)).toMatchObject({
|
||||
turnStart: 1,
|
||||
turnEnd: 1,
|
||||
stepStart: 0,
|
||||
stepEnd: 0,
|
||||
errors: 1,
|
||||
})
|
||||
expect(errors.map(error => error.message)).toEqual(['reject step-start before commit'])
|
||||
})
|
||||
|
||||
it('a one-shot turn/end validation failure preserves the earlier turn error on retry', async () => {
|
||||
const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider failed' } }]
|
||||
const adapter = new MockAdapter([errorStream])
|
||||
const ctx = await balancedHarness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a-turnend-veto'), { model: 'mock' })
|
||||
let rejected = false
|
||||
ctx.on('internal/dispatch', (_mode, name, args) => {
|
||||
if (name !== 'session/event') return
|
||||
const event = args[1] as SessionEvent
|
||||
if (event.type === 'turn/end' && !rejected) {
|
||||
rejected = true
|
||||
throw new Error('reject first turn-end')
|
||||
}
|
||||
})
|
||||
const errors: Error[] = []
|
||||
ctx.on('agent/error', (_agent, _turn, _step, error) => { errors.push(error) })
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(errors.map(error => error.message)).toEqual(['provider failed'])
|
||||
expect(boundaryCounts(agent)).toMatchObject({
|
||||
turnStart: 1,
|
||||
turnEnd: 1,
|
||||
stepStart: 1,
|
||||
stepEnd: 1,
|
||||
errors: 1,
|
||||
})
|
||||
const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toMatchObject({
|
||||
kind: 'error',
|
||||
message: 'provider failed',
|
||||
})
|
||||
})
|
||||
|
||||
it('a one-shot step/end validation failure keeps the step open until retry succeeds', async () => {
|
||||
const adapter = new MockAdapter([textResponse('completed before close validation')])
|
||||
const ctx = await balancedHarness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a-stepend-veto'), { model: 'mock' })
|
||||
let rejected = false
|
||||
ctx.on('internal/dispatch', (_mode, name, args) => {
|
||||
if (name !== 'session/event') return
|
||||
const event = args[1] as SessionEvent
|
||||
if (event.type === 'step/end' && !rejected) {
|
||||
rejected = true
|
||||
throw new Error('reject first step-end')
|
||||
}
|
||||
})
|
||||
const errors: Error[] = []
|
||||
ctx.on('agent/error', (_agent, _turn, _step, error) => { errors.push(error) })
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(errors.map(error => error.message)).toEqual(['reject first step-end'])
|
||||
expect(boundaryCounts(agent)).toMatchObject({
|
||||
turnStart: 1,
|
||||
turnEnd: 1,
|
||||
stepStart: 1,
|
||||
stepEnd: 1,
|
||||
errors: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('a throwing agent/error listener during a step-error path still balances the turn, loop survives', async () => {
|
||||
// First turn: model stream ends with a finish-error → step error path →
|
||||
// failTurn emits agent/error, whose listener throws. The turn must still
|
||||
@@ -717,13 +894,9 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
|
||||
})
|
||||
|
||||
it('preserves reason disposed when a pre-step listener disposes then throws (outer-catch disposed branch)', async () => {
|
||||
// Reach the OUTER catch while disposed: an `agent/pre-step` listener requests
|
||||
// disposal AND throws. The throw escapes the pre-step `await` (line ~419) to
|
||||
// the loop's outer catch — BEFORE the post-pre-step disposal check at ~422
|
||||
// gets to run — so the catch sees `isDisposed() && !errorReported` and must
|
||||
// PRESERVE reason=disposed rather than overwrite it with the listener's throw
|
||||
// (disposal is not a failure). This is the surviving path to that sub-branch
|
||||
// now that there is no turn-boundary emit to throw from.
|
||||
// A pre-step listener requests disposal and then throws before the ordinary
|
||||
// post-listener disposal check. The outer catch sees disposal already won
|
||||
// and must preserve reason=disposed rather than rewrite it as a plugin error.
|
||||
const adapter = new MockAdapter([textResponse('never reached')])
|
||||
const ctx = await balancedHarness(adapter)
|
||||
let agent!: ReactLoopAgent
|
||||
@@ -759,16 +932,8 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
|
||||
expect(errorEmits).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('a throwing session/event listener on the turn/start append still balances the turn', async () => {
|
||||
// Session.append pushes the event BEFORE notifying session/event listeners,
|
||||
// so a listener throwing on turn/start leaves turn/start IN THE LOG. The
|
||||
// loop must therefore still owe (and append) a turn/end — deciding "owed"
|
||||
// from the log via isTurnOpen, not a "turn started" flag that the throw
|
||||
// skipped. Otherwise the turn stays permanently open and poisons the next
|
||||
// turn/replay (the turn-enclosure RFC). (Uses the plain harness — NOT the invariants
|
||||
// oracle — because the throwing listener is itself a session/event
|
||||
// subscriber.)
|
||||
const adapter = new MockAdapter([textResponse('turn 2')])
|
||||
it('a throwing turn/start observer cannot starve the loop or later turns', async () => {
|
||||
const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a-preturn'), { model: 'mock' })
|
||||
|
||||
@@ -782,12 +947,9 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// The error was surfaced exactly once via agent/error.
|
||||
expect(errors.map(e => e.message)).toEqual(['boom turn/start append'])
|
||||
// The turn is BALANCED: turn/start is in the log (it was pushed before the
|
||||
// listener threw), so a turn/end was owed and appended — no open turn. The
|
||||
// last turn-boundary event being turn/end is exactly the loop's isTurnOpen
|
||||
// check (no open turn remains).
|
||||
expect(errors).toEqual([])
|
||||
// Session contains the observer failure per listener, so the committed turn
|
||||
// remains visible to later observers and executes normally.
|
||||
const types = [...agent.session.events].map(e => e.type)
|
||||
expect(types.filter(t => t === 'turn/start')).toHaveLength(1)
|
||||
expect(types.filter(t => t === 'turn/end')).toHaveLength(1)
|
||||
@@ -798,15 +960,10 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
|
||||
// loop survives: a second turn runs normally.
|
||||
send(agent, 'second')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('a throwing step/end session-event listener during a successful step ends the turn as error, not completed', async () => {
|
||||
// closeStep() must surface a throwing step/end listener via failTurn so the
|
||||
// turn ends with reason error, not a silent "completed" with the throw
|
||||
// swallowed. Regression test for the closeStep() catch that previously
|
||||
// swallowed the throw in the normal (no-tool, no-steering) path. (Step
|
||||
// boundaries have no agent/* mirror; the session-event listener is the path.)
|
||||
it('a throwing step/end observer cannot rewrite the turn outcome', async () => {
|
||||
const adapter = new MockAdapter([textResponse('all good'), textResponse('turn 2 ok')])
|
||||
const ctx = await balancedHarness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a-stepend-throw'), { model: 'mock' })
|
||||
@@ -822,11 +979,10 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const c = boundaryCounts(agent)
|
||||
// step opened and closed; exactly one error turn-end; turn balanced.
|
||||
expect(c).toMatchObject({ turnStart: 1, turnEnd: 1, stepStart: 1, stepEnd: 1, errors: 1 })
|
||||
expect(errors.map(e => e.message)).toEqual(['boom step-end'])
|
||||
expect(c).toMatchObject({ turnStart: 1, turnEnd: 1, stepStart: 1, stepEnd: 1, errors: 0 })
|
||||
expect(errors).toEqual([])
|
||||
expect(c.lastTurnEnd?.type === 'turn/end' && c.lastTurnEnd.data.reason)
|
||||
.toEqual({ kind: 'error', step: 1, message: 'boom step-end' })
|
||||
.toEqual({ kind: 'completed' })
|
||||
|
||||
// step/end precedes turn/end (ordering contract)
|
||||
const e = [...agent.session.events]
|
||||
@@ -844,14 +1000,11 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
|
||||
expect(c2.stepStart).toBe(c2.stepEnd)
|
||||
})
|
||||
|
||||
it('a throwing session/event listener on step/end during finalization still appends turn/end', async () => {
|
||||
it('a throwing step/end observer cannot interrupt error finalization', async () => {
|
||||
// A finish-error stream opens a step then fails it, driving finalization
|
||||
// through closeStep() with the step open. closeStep appends step/end; a
|
||||
// session/event listener throwing on THAT must not abort the catch before
|
||||
// closeTurn — step/end is already logged (balance holds) and the throw is
|
||||
// contained + surfaced via failTurn, so turn/end is still appended. (The
|
||||
// failed step itself also routes through failTurn; the step/end-listener
|
||||
// throw is the second, contained, failure.)
|
||||
// through closeStep() with the step open. Session contains the observer
|
||||
// failure after committing step/end, so closeTurn still records the model
|
||||
// failure and balances the turn.
|
||||
const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider 500' } }]
|
||||
const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')])
|
||||
const ctx = await harness(adapter)
|
||||
@@ -872,7 +1025,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
|
||||
expect(e.some(x => x.type === 'step/end')).toBe(true)
|
||||
expect(e.some(x => x.type === 'turn/end')).toBe(true)
|
||||
expect(e.at(-1)?.type).toBe('turn/end')
|
||||
expect(errors.length).toBeGreaterThanOrEqual(1) // surfaced via agent/error
|
||||
expect(errors.map(error => error.message)).toEqual(['provider 500'])
|
||||
|
||||
// loop survives.
|
||||
send(agent, 'again')
|
||||
@@ -881,12 +1034,8 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
|
||||
})
|
||||
|
||||
it('a throwing session/event listener on turn/end is contained (turn still balanced, loop survives)', async () => {
|
||||
// closeTurn appends turn/end; Session.append pushes it BEFORE notifying
|
||||
// session/event listeners, so a throwing listener leaves turn/end in the log
|
||||
// (the turn is balanced) but must not escape — from the normal-path closeTurn
|
||||
// it would otherwise propagate; the append is contained so the loop continues.
|
||||
// Turn boundaries are durable session events only (no agent/* mirror), so this
|
||||
// session/event append-notify throw is the sole turn-end-listener failure path.
|
||||
// Session contains the observer failure after committing turn/end, so the
|
||||
// boundary stays authoritative and the loop continues normally.
|
||||
const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a-turnendappend'), { model: 'mock' })
|
||||
@@ -912,7 +1061,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
|
||||
})
|
||||
})
|
||||
|
||||
describe('P1-7: tool/result is logged under the originating call.id, not result.callId', () => {
|
||||
describe('tool result call identity', () => {
|
||||
it('the loop records tool/result under the model call.id even when a post-execute listener replaces content', async () => {
|
||||
// Model emits a tool-call with id "c1", then a final text turn.
|
||||
const adapter = new MockAdapter([
|
||||
@@ -992,7 +1141,7 @@ describe('surface: assistant/message omits sourceEventSeqs when no chunks stream
|
||||
|
||||
|
||||
|
||||
describe('disposal/cancel honored during pre-step assembly (P1-1)', () => {
|
||||
describe('disposal and cancellation during pre-step assembly', () => {
|
||||
it('disposal during system-prompt assembly drops the about-to-start step as disposed', { timeout: 30000 }, async () => {
|
||||
// Block `system-prompt/assemble` on a promise. Start disposal (which
|
||||
// calls stop() synchronously, setting status=disposed), then release the
|
||||
@@ -1011,7 +1160,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => {
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(Invariants, { freeze: false })
|
||||
await ctx.plugin(Invariants)
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
|
||||
// Blocking listener on the parent context (survives fiber disposal).
|
||||
@@ -1068,7 +1217,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => {
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(Invariants, { freeze: false })
|
||||
await ctx.plugin(Invariants)
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
|
||||
const unlisten = ctx.on('system-prompt/assemble', async function (_assembly, _context, next) {
|
||||
@@ -1124,7 +1273,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => {
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(Invariants, { freeze: false })
|
||||
await ctx.plugin(Invariants)
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
|
||||
ctx.on('agent/pre-step', async () => {
|
||||
@@ -1176,7 +1325,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => {
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(Invariants, { freeze: false })
|
||||
await ctx.plugin(Invariants)
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
|
||||
ctx.on('agent/pre-step', async () => {
|
||||
@@ -1225,7 +1374,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => {
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(Invariants, { freeze: false })
|
||||
await ctx.plugin(Invariants)
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
|
||||
ctx.on('system-prompt/assemble', async function (_assembly, _context, next) {
|
||||
|
||||
1088
packages/core/agent-loop/tests/scope-lifecycle.spec.ts
Normal file
1088
packages/core/agent-loop/tests/scope-lifecycle.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -107,7 +107,7 @@ describe('loop-level canonical tool order', () => {
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(errors.map(e => e.message)).toEqual(['toolOrder lists unregistered tool "ghost"; registered tools: alpha'])
|
||||
expect(errors.map(e => e.message)).toEqual(['toolOrder lists unregistered tool "ghost"; known tools: alpha'])
|
||||
expect(foldRequestHeader(agent.session.events)).toBeUndefined()
|
||||
const end = agent.session.events.find(e => e.type === 'turn/end')
|
||||
expect(end?.type === 'turn/end' && end.data.reason).toMatchObject({ kind: 'error', step: 1 })
|
||||
|
||||
185
packages/core/agent-loop/tests/turn-stop.spec.ts
Normal file
185
packages/core/agent-loop/tests/turn-stop.spec.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { AgentId, type ContinuationStop } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
|
||||
|
||||
async function harness(adapter: MockAdapter): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(Invariants)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return ctx
|
||||
}
|
||||
|
||||
function send(agent: ReactLoopAgent, text = 'go'): Promise<void> {
|
||||
agent.send([{ type: 'text', text }])
|
||||
return agent.whenIdle()
|
||||
}
|
||||
|
||||
function registerEcho(ctx: Context): void {
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'echo',
|
||||
description: 'echo',
|
||||
parameters: { text: { type: 'string' } },
|
||||
async execute(args) {
|
||||
return [{ type: 'text', text: String(args.text) }]
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
describe('agent/turn-stop', () => {
|
||||
it('runs after steering folding and discards terminal steering instead of creating another step or turn', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
textResponse('the ordinary decision is stop'),
|
||||
textResponse('must not be requested'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('terminal-steering'), { model: 'mock' })
|
||||
agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
|
||||
|
||||
let steered = false
|
||||
ctx.on('agent/turn-continuation', async (subject, _turn, _default, next) => {
|
||||
const downstream = await next()
|
||||
if (subject === agent && !steered) {
|
||||
steered = true
|
||||
subject.steer([{ type: 'text', text: 'late continuation steering' }])
|
||||
}
|
||||
return downstream
|
||||
}, { prepend: true })
|
||||
|
||||
await send(agent)
|
||||
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
|
||||
expect(agent.session.events.filter(event => event.type === 'step/start')).toHaveLength(1)
|
||||
expect(agent.session.events.filter(event => event.type === 'steering/message')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('discards steering that arrives from session/flush after the terminal checkpoint', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
textResponse('terminal answer'),
|
||||
textResponse('must not become a late-steering turn'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('terminal-flush-steering'), { model: 'mock' })
|
||||
agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
|
||||
|
||||
let injected = false
|
||||
ctx.on('session/flush', (session) => {
|
||||
if (session !== agent.session || injected) return
|
||||
injected = true
|
||||
agent.steer([{ type: 'text', text: 'steering from flush' }])
|
||||
})
|
||||
|
||||
await send(agent)
|
||||
|
||||
expect(injected).toBe(true)
|
||||
expect(agent.status).toBe('idle')
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
|
||||
expect(agent.session.events.filter(event => event.type === 'step/start')).toHaveLength(1)
|
||||
expect(agent.session.events.filter(event => event.type === 'steering/message')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('preserves an ordinary queued send that arrives during terminal flush', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
textResponse('first terminal answer'),
|
||||
textResponse('queued follow-up answer'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('terminal-flush-send'), { model: 'mock' })
|
||||
agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
|
||||
|
||||
let queued = false
|
||||
ctx.on('session/flush', (session) => {
|
||||
if (session !== agent.session || queued) return
|
||||
queued = true
|
||||
agent.send([{ type: 'text', text: 'ordinary queued follow-up' }])
|
||||
})
|
||||
|
||||
await send(agent)
|
||||
|
||||
expect(agent.status).toBe('idle')
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
|
||||
expect(agent.session.events.filter(event => event.type === 'step/start')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('filters a scoped terminal listener to its own agent', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('a1', 'echo', { text: 'a' }),
|
||||
toolCallResponse('b1', 'echo', { text: 'b' }),
|
||||
textResponse('b continues normally'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
registerEcho(ctx)
|
||||
const stopped = ctx.agentLoop.create(AgentId('stopped'), { model: 'mock' })
|
||||
const ordinary = ctx.agentLoop.create(AgentId('ordinary'), { model: 'mock' })
|
||||
stopped.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
|
||||
|
||||
await send(stopped)
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
await send(ordinary)
|
||||
|
||||
expect(adapter.requests).toHaveLength(3)
|
||||
expect(stopped.session.events.filter(event => event.type === 'step/start')).toHaveLength(1)
|
||||
expect(ordinary.session.events.filter(event => event.type === 'step/start')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('unregisters with its scoped owner disposer', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('first', 'echo', { text: 'first' }),
|
||||
toolCallResponse('second', 'echo', { text: 'second' }),
|
||||
textResponse('continued after listener disposal'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
registerEcho(ctx)
|
||||
const agent = ctx.agentLoop.create(AgentId('owned-listener'), { model: 'mock' })
|
||||
const disposeStop = agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
|
||||
|
||||
await send(agent, 'first turn')
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
|
||||
disposeStop()
|
||||
await send(agent, 'second turn')
|
||||
expect(adapter.requests).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('fails a throwing terminal policy closed while the driver survives', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
textResponse('throwing policy'),
|
||||
textResponse('healthy later turn'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('bad-policy'), { model: 'mock' })
|
||||
const reasons: TurnEndReason[] = []
|
||||
const errors: string[] = []
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (session === agent.session && event.type === 'turn/end') reasons.push(event.data.reason)
|
||||
})
|
||||
agent.ctx.on('agent/error', (_subject, _turn, _step, error) => { errors.push(error.message) })
|
||||
|
||||
const disposeThrowing = agent.ctx.on('agent/turn-stop', () => {
|
||||
throw new Error('terminal policy exploded')
|
||||
})
|
||||
await send(agent, 'first')
|
||||
disposeThrowing()
|
||||
|
||||
await send(agent, 'healthy')
|
||||
|
||||
expect(reasons.map(reason => reason.kind)).toEqual(['error', 'completed'])
|
||||
expect(errors).toContain('terminal policy exploded')
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
@@ -34,6 +34,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/scope"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -8,61 +8,39 @@ Tracks live agents so UI, hook, and orchestrator plugins can find them without i
|
||||
|
||||
### 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). `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): () => void` performs the authoritative ID collision check and inserts without announcing; `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()`.
|
||||
- `ctx.agents.get(id: AgentId): Agent | undefined`
|
||||
- `ctx.agents.list(): Agent[]`
|
||||
|
||||
#### Factory seam (creation)
|
||||
|
||||
Agent *creation* is provided by whichever plugin implements `AgentFactory` (phase 1: `dsh-agent-loop`), registered via `setFactory`. This keeps creation on the `dsh-agent` interface so consumers (UI, the ACP bridge) program against `ctx.agents` without depending on the concrete loop package.
|
||||
Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-agent-loop`), registered via `setFactory`. This keeps creation on the `dsh-agent` interface so consumers (UI, the ACP bridge) program against `ctx.agents` without depending on the concrete loop package. The registry canonicalizes an already traced Service to its concrete target and re-traces each call through the caller's context; this avoids nested Cordis shadows while passing an explicit caller-bound `ownerCtx` to plain factories.
|
||||
|
||||
- `ctx.agents.setFactory(factory: AgentFactory): () => void` — register the creation factory (the loop calls this on construction). Throws on a second factory; the slot clears on dispose.
|
||||
- `ctx.agents.create(options: CreateAgentOptions): AgentHandle` — construct, start, AND register a new agent on a caller-supplied `sessionId` (with optional `meta.cwd`/`meta.parentSession`/`meta.seedLength` and optional `seed` events for forked children). Distinct from `register` (which only records). Throws if no factory is registered.
|
||||
- `ctx.agents.resume(options: ResumeAgentOptions): Promise<AgentHandle>` — load a persisted session ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)) and resume an agent on it. Async; rejects if no factory is registered, or if the factory finds session persistence unconfigured.
|
||||
- `ctx.agents.create(options: CreateAgentOptions): Promise<AgentHandle>` — create a session and agent, await optional setup while unpublished, then publish through final `SessionStore.enter()` and `AgentRegistry.enter()` checks. Concurrent same-ID creation is unsupported: more than one operation may prepare, but only one can enter; every loser rolls its private scope/session/driver back. An optional creation-only `signal` cancels unpublished setup and is detached before the handle is returned; later cancellation uses `handle.dispose()` or `agent.cancel()`. Publication is rollback-covered and every delivered creation edge is paired during rollback. Rejects if no factory is registered.
|
||||
- `ctx.agents.resume(options: ResumeAgentOptions): Promise<AgentHandle>` — load a persisted session ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), mint a fresh unpublished agent scope, await optional setup, and use the same final-entry publication sequence. Its optional `signal` is likewise creation-only. Rejects if no factory is registered or session persistence is unconfigured.
|
||||
|
||||
`AgentHandle = { agent: Agent; dispose(): Promise<void> }`. The disposer is a **capability** — only the holder can tear this agent down. `dispose()` stops the loop, `await`s its exit (quiescence — NOT just the `disposed` status flip), unregisters the agent, and removes its session from the store, in an order that captures the loop's final `session/flush` before the session is detached. `ctx.agents.get(id)` still returns a bare `Agent` — the handle is only for the OWNER that created it. The ACP bridge and in-process subagent backends are production consumers; config-created agents are owned by the loop fiber and never need a handle.
|
||||
`AgentHandle = { agent: Agent; dispose(): Promise<void> }`. The disposer is a **consumer capability** — no observer holding the bare registry entry can tear the agent down. The caller fiber and the registered factory provider are structural co-owners: caller unload enforces structured ownership, while factory unload must stop old instances because their scoped dependency surface belongs to that provider. `dispose()` from any owner reaches one memoized quiescence boundary: it stops the loop, `await`s its exit plus every outstanding idle-injection flush (not just the `disposed` status flip), unregisters the agent, removes its session from the store, and finally unwinds its scoped world. This order captures every agent-started `session/flush` before the session is detached and keeps scoped listeners alive through those checkpoints. `ctx.agents.get(id)` still returns a bare `Agent`; the ACP bridge and in-process subagent backends hold consumer handles, while config-created agents are already owned by the loop fiber.
|
||||
|
||||
### Events
|
||||
### Live events
|
||||
|
||||
The full `agent/*` event taxonomy is declared via declaration merging in `dsh-agent` (not `dsh-agent-loop`), so plugins depend only on this package.
|
||||
`dsh-agent` declares the live `agent/*` coordination vocabulary so plugins do not depend on the concrete loop. Exact signatures, dispatch modes, scope-filtering rules, and payload contracts live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md); the [architecture turn flow](../../../docs/architecture.md#turn-flow) shows their order relative to durable session events.
|
||||
|
||||
#### Lifecycle (emit)
|
||||
The lifecycle edges have two important local caveats. `agent/created` runs after scoped setup and after both session and agent registry entries exist. Setup is trusted composition-only code; the immediately following non-vetoing `agent/session-start` notification is the first supported startup injection point. `agent/disposed` always means the exact agent has left the registry. AgentLoop emits it after its driver is quiescent, while ordered teardown may still be detaching the session and unwinding the scope; custom agents registered directly own any stronger driver-ordering contract themselves.
|
||||
|
||||
- `agent/created`, `agent/disposed` — registration/deregistration
|
||||
- `agent/status` — idle / running / disposed transition
|
||||
- `agent/queued` — message entered inbox (source-resolved, steering flag)
|
||||
- `agent/session-start` — the session lifecycle began (once, before turn 1), carrying a `SessionStartSource` (`startup` for a fresh or forked create, `resume` for a reloaded persisted session; `clear`/`compact` reserved). A pure notification — it cannot block startup; a listener seeds context via `agent.inject()` (a `context/message` the first request sees).
|
||||
Most interception points are cooperative waterfalls returning seam-specific decisions. `agent/pre-step` is a serial surface-mutation checkpoint, while `agent/turn-stop` is the terminal serial fold: it runs after ordinary continuation and steering folding, and a returned stop remains in force through turn close and flush so later steering cannot create an extra step or turn. Ordinary queued prompts remain intact. The full rationale is in the [agent-scope runtime-design RFC](../../../docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way).
|
||||
|
||||
#### Boundaries are durable session events, not `agent/*` emits
|
||||
|
||||
Turn and step boundaries are NOT mirrored as `agent/*` emits: a consumer that needs them reads the durable `turn/start`/`turn/end`/`step/start`/`step/end` events off the `session/event` feed (the session log is the live boundary feed, carrying the `Session` — the turn/step numbers and reasons ride on the event data). See [the event-domain-semantics RFC](../../../docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md) and [the remove-boundary-mirror-events RFC](../../../docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md).
|
||||
|
||||
#### Interception seams
|
||||
|
||||
`agent/pre-step` is a **serial** surface-mutation checkpoint; the rest are **waterfalls** that return a small, seam-specific typed **Decision** union (the unified idiom across the taxonomy — a CC/Codex bridge maps its `permissionDecision`/`decision`/`continue` fields onto these, a native plugin returns them directly):
|
||||
|
||||
- `agent/session-start` (emit) — fired once before the first turn; a listener seeds context via `agent.inject()` (it cannot veto startup).
|
||||
- `agent/prompt-submit` — decide what happens to one drained queued message before it becomes a `user/message`: `PromptDecision` = `allow` (optionally rewriting the prompt `content` or attaching `additionalContext`) or `block` (drop it; a batch whose every prompt is blocked opens a zero-step turn that ends `rejected`). Maps onto Claude Code's `UserPromptSubmit`.
|
||||
- `agent/pre-step` (serial) — mutate the session surface before the step opens and history is derived (compaction). Fires after `turn/start` and before `step/start`, so a listener's appended events land outside the step; carries the assembled system prompt and the instance's composed session prefix so a token-pressure gate counts everything the request will carry.
|
||||
- `agent/request` — shape the call config before the model call: a frozen `LlmCallConfig` seed in, a replacement out (model switching, sampling overrides). Content is not shapeable here — every request is a pure function of the session log ([reconstructability RFC](../../../docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md)); the loop logs whatever config the request actually uses as a `request/header*` event
|
||||
- `agent/session-prefix` — compose the session prefix: request-only messages placed in front of the ENTIRE derived history on every request. Fired ONCE per loop instance, lazily before its first pre-step (so pressure gates see this instance's real prefix, never a previous instance's logged one); the composed result is deep-frozen, recorded as `EpochHeader.messagePrefix` on the anchoring `request/header` snapshot, and reused verbatim afterwards — the prefix cannot change mid-session, so the provider prefix cache holds by construction (resume = a new instance = a recompose, attributably anchored by its `'resume'` snapshot). The home for session-stable openers that must not become durable history (a skills catalog, an AGENTS.md digest); `deriveMessages()` never returns it. Content that CHANGES mid-session belongs in the append-only history channels instead — `agent.inject()`, `tools/post-execute` `additionalContext`, prompt-submit `additionalContext` — each a durable `context/message` paid once and prefix-cached thereafter
|
||||
- `agent/step-result` — post-process the assembled assistant message before tool dispatch (validates what the log records)
|
||||
- `agent/turn-continuation` — override the continue/stop decision via `ContinuationDecision` = `{action:'stop'}` or `{action:'continue', reason?}` (a `continue` `reason` is recorded as next-step steering in the same turn — the typed `/goal` pattern). Force-continue `/loop`, force-stop budget guard.
|
||||
|
||||
Tool interception is the `tools/pre-execute` / `tools/post-execute` pair in [`dsh-tools`](../tools/README.md) (`PreToolDecision` allow/deny/ask, `PostToolDecision` accept/block) — same typed-Decision idiom, owned there because it is the tool registry's seam.
|
||||
|
||||
#### Error notifications (emit)
|
||||
|
||||
- `agent/error` — step/turn error
|
||||
|
||||
The model's token stream is NOT an `agent/*` event: read it off the durable `session/event` feed as `assistant/chunk` (the same feed persistence and the ACP bridge use).
|
||||
Turn and step boundaries and the model token stream are durable `session/event` facts rather than mirrored `agent/*` notifications. Consumers read `turn/*`, `step/*`, and `assistant/chunk` from the session feed; tool policy and outcome observation belong to the complete pipeline documented by [`dsh-tools`](../tools/README.md).
|
||||
|
||||
### Agent interface (`types.ts`)
|
||||
|
||||
The handle every plugin programs against:
|
||||
|
||||
- `agent.send(content, options?)` — queue a message; starts a turn when idle
|
||||
- `agent.steer(content, options?)` — steer a running turn (inject between steps); behaves like `send` when idle
|
||||
- `agent.send(content, options?)` — queue a message; starts a turn when idle. Content and resolved source become one detached, deeply frozen lossless-JSON record before `agent/queued` and enqueue; invalid data throws synchronously, and caller or notification-listener in-place mutation cannot change the log or model input (`agent/prompt-submit` still rewrites by returning replacement content).
|
||||
- `agent.steer(content, options?)` — steer a running turn (inject between steps); uses the same owned acceptance boundary and behaves like `send` when idle
|
||||
- `agent.inject(content, options?)` — inject in-session context (context/message event); the next request sees it. Does not run the model. While a turn is open it joins that turn; while idle it is wrapped in a one-shot `injection` turn so every event stays turn-enclosed ([the turn-enclosure invariant](../../../docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md))
|
||||
- `agent.cancel(reason?)` — cancel ALL pending work: clears the queued + steering FIFOs, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs. A UI/ACP `session/cancel` maps to this. The single public stop primitive. Idle with nothing pending → a safe no-op.
|
||||
- `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit). A non-owner's quiescence-observation hook: it observes the work settling WITHOUT tearing the agent down. Teardown is separate — a lifecycle owner stops and unregisters via `AgentHandle.dispose()`, which awaits the loop exit directly.
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-scope": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
@@ -31,6 +32,7 @@
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
|
||||
134
packages/core/agent/src/dispatch.ts
Normal file
134
packages/core/agent/src/dispatch.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Fused scope-carrier dispatch for agent-subject operations, plus the assembly
|
||||
* context builder. The sanctioned ordinary spelling is
|
||||
* `agentEvents(ctx, agent).waterfall('agent/request', …)`: it builds the scope
|
||||
* carrier ({@link scopeTarget} keyed by the agent) AND injects the subject as
|
||||
* the first argument in one move, so a site cannot name a different subject.
|
||||
* The registry lifecycle pair is the deliberate exception: `enter()` captures
|
||||
* one stable carrier before commit and `announce()`/detach dispatch through it
|
||||
* directly, so both lifecycle edges use the same routing identity. The dev
|
||||
* scoped-dispatch invariant checks both shapes.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-agent/dispatch
|
||||
*/
|
||||
|
||||
import type { Context, Events } from 'cordis'
|
||||
import { scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import type { AssembleContext } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { Agent } from './types.ts'
|
||||
|
||||
/** Extract the parameter tuple from an event handler type (its `this` is not part of the tuple). */
|
||||
type Params<F> = F extends (...args: infer P) => unknown ? P : never
|
||||
/** Extract the return type from an event handler type. */
|
||||
type Return<F> = F extends (...args: never[]) => infer R ? R : never
|
||||
|
||||
/**
|
||||
* The event names whose subject is an agent: handler parameters start with an
|
||||
* `Agent` AND the handler declares a `Scoped<Agent>` `this` (the scope-carrier
|
||||
* contract). The `this` check keeps accidental first-parameter-happens-to-be-
|
||||
* an-Agent events (or zero-arg events, whose parameter tuple would satisfy a
|
||||
* bare rest-tuple check via callability) out of the fused-dispatch surface.
|
||||
*/
|
||||
export type AgentSubjectEvent = {
|
||||
[K in keyof Events]: Events[K] extends (this: Scoped<Agent>, ...args: infer P) => unknown
|
||||
? P extends [Agent, ...unknown[]] ? K : never
|
||||
: never
|
||||
}[keyof Events]
|
||||
|
||||
/** The event arguments AFTER the injected agent subject. */
|
||||
type Tail<K extends AgentSubjectEvent> = Params<Events[K]> extends [Agent, ...infer R] ? R : never
|
||||
|
||||
/**
|
||||
* The fused dispatcher {@link agentEvents} returns: each method dispatches the
|
||||
* named agent-subject event with the agent's scope carrier as `thisArg` and
|
||||
* the agent itself injected as the first event argument.
|
||||
*/
|
||||
export interface AgentEventDispatch {
|
||||
/**
|
||||
* Fire-and-forget notification in the agent's scope. Every listener is
|
||||
* invoked; synchronous throws and returned-promise rejections are logged and
|
||||
* contained per listener, so a notification cannot veto lifecycle progress
|
||||
* or starve a later observer.
|
||||
* @param name - the agent-subject event to emit.
|
||||
* @param rest - the event's arguments after the injected agent.
|
||||
*/
|
||||
emit<K extends AgentSubjectEvent>(name: K, ...rest: Tail<K>): void
|
||||
/**
|
||||
* Awaited in-order dispatch (Cordis `serial`) in the agent's scope.
|
||||
* @param name - the agent-subject event to dispatch.
|
||||
* @param rest - the event's arguments after the injected agent.
|
||||
* @returns the serial chain's result (the first bail value, if any).
|
||||
*/
|
||||
serial<K extends AgentSubjectEvent>(name: K, ...rest: Tail<K>): Promise<Awaited<Return<Events[K]>>>
|
||||
/**
|
||||
* Around-middleware dispatch (Cordis `waterfall`) in the agent's scope. The
|
||||
* declared event parameters already end with the `next` callback, so `rest`
|
||||
* is exactly the event's arguments after the injected agent — the final
|
||||
* element being the innermost `next` (the default the listener chain wraps).
|
||||
* @param name - the agent-subject event to dispatch.
|
||||
* @param rest - the event's arguments after the injected agent.
|
||||
* @returns the waterfall's composed result.
|
||||
*/
|
||||
waterfall<K extends AgentSubjectEvent>(name: K, ...rest: Tail<K>): Return<Events[K]>
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the fused dispatcher for `agent`'s events (see the module doc). Cheap
|
||||
* (one carrier + one small object) — dispatch sites create it per run/turn
|
||||
* rather than caching it on the agent.
|
||||
* @param ctx - the context to dispatch through (any context of the app).
|
||||
* @param agent - the subject agent; also the scope-carrier key.
|
||||
* @returns the fused dispatcher.
|
||||
*/
|
||||
export function agentEvents(ctx: Context, agent: Agent): AgentEventDispatch {
|
||||
const carrier: Scoped<Agent> = scopeTarget(agent, agent)
|
||||
// The ordinary dispatch methods forward through Cordis' variadic mixins. The
|
||||
// fused (carrier, name, agent, ...rest) tuple is provably a valid argument
|
||||
// list for the matching thisArg overload, but TypeScript cannot relate the
|
||||
// generic Tail<K> spread back to that overload's conditional parameter
|
||||
// tuple — hence one contained, shape-preserving cast per method.
|
||||
return {
|
||||
emit(name, ...rest) {
|
||||
// Cordis emit invokes callbacks through Array.map: one synchronous throw
|
||||
// starves later listeners, and returned promises are discarded. Agent
|
||||
// notifications are non-vetoing, so resolve the same filtered callback
|
||||
// set ourselves and contain both failure modes independently.
|
||||
const args: unknown[] = [carrier, name, agent, ...rest]
|
||||
const callbacks = ctx.events.dispatch('emit', args)
|
||||
for (const callback of callbacks) {
|
||||
try {
|
||||
const returned: unknown = callback(...args)
|
||||
void Promise.resolve(returned).catch((error: unknown) => {
|
||||
ctx.logger.warn(`agent event "${name}" listener rejected: ${String(error)}`)
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
ctx.logger.warn(`agent event "${name}" listener threw: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
async serial(name, ...rest) {
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method -- the events mixin accessor returns a pre-bound function
|
||||
const serial = ctx.serial as (thisArg: Scoped<Agent>, name: string, ...args: unknown[]) => Promise<never>
|
||||
return await serial(carrier, name, agent, ...rest)
|
||||
},
|
||||
waterfall(name, ...rest) {
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method -- the events mixin accessor returns a pre-bound function
|
||||
const waterfall = ctx.waterfall as (thisArg: Scoped<Agent>, name: string, ...args: unknown[]) => never
|
||||
return waterfall(carrier, name, agent, ...rest)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The assembly context for one agent's prompt: the typed `agent` DX field and
|
||||
* the `scope` layer selector, set together (setting `agent` without `scope`
|
||||
* silently drops the agent's scoped sections/tools from the assembly — the
|
||||
* dev invariants flag it). THE way the loop (and any custom driver) builds
|
||||
* its per-step `ctx.systemPrompt.assemble(…)` input.
|
||||
* @param agent - the agent the assembly is for.
|
||||
* @returns the context to pass to `assemble()`.
|
||||
*/
|
||||
export function assembleContextFor(agent: Agent): AssembleContext {
|
||||
return { agent, scope: agent }
|
||||
}
|
||||
@@ -5,15 +5,29 @@
|
||||
* @module @deepseek-ai/dsh-agent
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import { Context, getTraceable, Service, symbols } from 'cordis'
|
||||
import { scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Agent, AgentId, AgentOptions } from './types.ts'
|
||||
|
||||
export * from './types.ts'
|
||||
export { agentEvents, assembleContextFor } from './dispatch.ts'
|
||||
export type { AgentEventDispatch, AgentSubjectEvent } from './dispatch.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
agents: AgentRegistry
|
||||
/**
|
||||
* The agent association installed as an own property on `Agent.ctx`, or
|
||||
* `undefined` on a plain context. Contexts derived from `Agent.ctx` inherit
|
||||
* the association; a deliberately nested scope may carry a nearer
|
||||
* `dsh-scope` tag while retaining it, so this field is DX context rather
|
||||
* than the scope resolver. {@link AgentRegistry} registers a root accessor
|
||||
* defaulting to `undefined`, and core packages below the agent layer use
|
||||
* `scopeOf()` for layer selection instead of reading this field.
|
||||
*/
|
||||
agent?: Agent
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,31 +40,52 @@ declare module 'cordis' {
|
||||
*/
|
||||
export interface CreateAgentOptions {
|
||||
/** The agent's id (the registry handle). */
|
||||
agentId: AgentId
|
||||
readonly agentId: AgentId
|
||||
/** The live session's id (NOT derived from agentId). */
|
||||
sessionId: SessionId
|
||||
readonly sessionId: SessionId
|
||||
/**
|
||||
* Session creation metadata: validated absolute `cwd`, `parentSession`
|
||||
* fork lineage, and the `seedLength` seed boundary. Mirrors the
|
||||
* `cwd`/`parentSession`/`seedLength` fields of
|
||||
* {@link CreateSessionOptions.meta} in dsh-session (the internal-only
|
||||
* `createdAt`, used when reconstructing a persisted session, is deliberately
|
||||
* excluded — a factory caller never sets it).
|
||||
* excluded — a factory caller never sets it). This is durable session data,
|
||||
* so the session boundary validates and snapshots it before asynchronous
|
||||
* setup begins.
|
||||
*/
|
||||
meta?: { cwd?: string; parentSession?: SessionId; seedLength?: number }
|
||||
readonly meta?: { readonly cwd?: string; readonly parentSession?: SessionId; readonly seedLength?: number }
|
||||
/**
|
||||
* Seed events to reconstruct the child session's log from (the fork lineage
|
||||
* primitive). When present, the factory creates the session with this event
|
||||
* prefix so `deriveMessages()`/`lastTurnNumber` continue from it — used by the
|
||||
* in-process FORK subagent backend to seed a child with a balanced
|
||||
* completed-turn prefix of the parent's log. The prefix MUST be contiguous
|
||||
* from seq 0 and balanced (no open turn/step, no dangling tool-call), or the
|
||||
* session constructor (and the dev-mode invariants replay) reject it. Absent
|
||||
* for a fresh (spawn) child.
|
||||
* from seq 0, carry only lossless-JSON data, and be balanced (no open
|
||||
* turn/step, no dangling tool-call), or the session constructor (and the
|
||||
* dev-mode invariants replay) reject it. The factory passes the raw seed to
|
||||
* the session's durable validator/snapshot boundary. Absent for a fresh
|
||||
* (spawn) child.
|
||||
*/
|
||||
seed?: SessionEvent[]
|
||||
readonly seed?: readonly SessionEvent[]
|
||||
/** Per-agent options (model, …). */
|
||||
agentOptions?: AgentOptions
|
||||
readonly agentOptions?: AgentOptions
|
||||
/** Optional creation-only cancellation signal; detached before the returned handle becomes visible. */
|
||||
readonly signal?: AbortSignal
|
||||
/**
|
||||
* Creation-time composition of the agent's scoped world. The factory awaits
|
||||
* setup after minting `agentCtx` but BEFORE inserting or announcing either
|
||||
* the session or agent, so observers can never see a partially configured
|
||||
* world. Everything registered through `agentCtx` (scoped tools, prompt
|
||||
* sections/variables, `restrict()`, listeners, awaited child plugins) exists
|
||||
* before `session/created`, `agent/created`, `agent/session-start`, and the
|
||||
* first prompt assembly. A throw/rejection or owner disposal rolls the scope
|
||||
* back without publishing either id.
|
||||
*
|
||||
* **Setup composes, it never drives**: the callback is trusted same-process
|
||||
* code and receives the full scoped context, so this is a contract rather
|
||||
* than a runtime restriction. Drive the agent only after creation resolves.
|
||||
*/
|
||||
readonly setup?: (agentCtx: Context) => Promise<void> | void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,24 +94,42 @@ export interface CreateAgentOptions {
|
||||
*/
|
||||
export interface ResumeAgentOptions {
|
||||
/** The agent's id (the registry handle). */
|
||||
agentId: AgentId
|
||||
readonly agentId: AgentId
|
||||
/** The persisted session id to load and resume on. */
|
||||
resumeSessionId: SessionId
|
||||
readonly resumeSessionId: SessionId
|
||||
/** Per-agent options (model, …). */
|
||||
agentOptions?: AgentOptions
|
||||
readonly agentOptions?: AgentOptions
|
||||
/** Optional creation-only cancellation signal for persistence load/setup; detached before return. */
|
||||
readonly signal?: AbortSignal
|
||||
/**
|
||||
* Resume-time composition of the agent's fresh scoped world. Persistence is
|
||||
* loaded first; the factory then mints `agentCtx` and awaits setup while the
|
||||
* reconstructed session and agent remain unpublished. The callback has the
|
||||
* same trusted composition-only contract as
|
||||
* {@link CreateAgentOptions.setup}: all registrations exist before either
|
||||
* creation announcement, and rejection or owner disposal rolls the
|
||||
* transaction back without publishing either id.
|
||||
*/
|
||||
readonly setup?: (agentCtx: Context) => Promise<void> | void
|
||||
}
|
||||
|
||||
/**
|
||||
* An owned agent plus its disposer, returned by {@link AgentRegistry.create} /
|
||||
* {@link AgentRegistry.resume}. The disposer is a CAPABILITY: only the holder
|
||||
* can tear this agent down. `dispose()` unregisters the agent, stops its loop,
|
||||
* awaits the loop's exit (quiescence — NOT just the `disposed` status flip), and
|
||||
* removes the agent's session from the store, in an order that captures the
|
||||
* loop's final `session/flush` before the session is detached.
|
||||
* {@link AgentRegistry.resume}. The disposer is a CAPABILITY: among consumers,
|
||||
* only the holder can tear this agent down. The registered factory provider is
|
||||
* also a structural owner because the scoped agent depends on that provider's
|
||||
* service surface; provider unload stops and drains every live handle it made.
|
||||
* `dispose()` stops the loop, awaits its exit and every outstanding
|
||||
* idle-injection flush (quiescence — NOT just the `disposed`
|
||||
* status flip), unregisters the agent, removes its session from the store, and
|
||||
* finally unwinds its scoped world. This order captures every agent-started
|
||||
* `session/flush` before the session is detached and keeps scoped listeners
|
||||
* alive through those checkpoints.
|
||||
*
|
||||
* `ctx.agents.get(id)` still returns a bare {@link Agent} — the handle is only
|
||||
* for the OWNER that created it. Config-created agents (the loop's own startup)
|
||||
* are owned by the loop fiber and never need a handle.
|
||||
* `ctx.agents.get(id)` still returns a bare {@link Agent} — the handle is
|
||||
* exposed only to the consumer owner that created it; the structural provider
|
||||
* reaches the same teardown internally. Config-created agents (the loop's own
|
||||
* startup) are owned by the loop fiber and never need a handle.
|
||||
*/
|
||||
export interface AgentHandle {
|
||||
agent: Agent
|
||||
@@ -91,121 +144,289 @@ export interface AgentHandle {
|
||||
*/
|
||||
export interface AgentFactory {
|
||||
/**
|
||||
* Create, start, and register a new agent on a caller-supplied session id.
|
||||
* Returns an {@link AgentHandle} — the owner disposes it to tear down exactly
|
||||
* this agent (unregister + stop loop + await quiescence + remove session).
|
||||
* Create a new agent on a caller-supplied session id. Async because creation
|
||||
* awaits unpublished setup, inserts both session and agent, emits their
|
||||
* creation notifications in order, emits `agent/session-start`, and only
|
||||
* then starts the loop. The sequence is
|
||||
* rollback-covered, but notifications delivered before a later listener
|
||||
* failure remain observable; every agent or session creation announcement
|
||||
* that began is paired by `agent/disposed` or `session/disposed` during
|
||||
* rollback. The owner disposes the resolved handle to stop/drain,
|
||||
* unregister, remove the session, and unwind the scope.
|
||||
* The registry passes a context carrying the `create()` caller's fiber and
|
||||
* scope as `ownerCtx`. The implementation attaches the unpublished
|
||||
* transaction and resulting lifecycle to that owner; it must not infer
|
||||
* ownership from the factory object's registration context.
|
||||
* @param ownerCtx - caller-bound context that owns the transaction and live handle.
|
||||
* @param options - agent/session identity, configuration, and optional setup.
|
||||
* @returns the owned handle after setup, both announcements, and loop start complete.
|
||||
*/
|
||||
createAgent(options: CreateAgentOptions): AgentHandle
|
||||
createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle>
|
||||
/**
|
||||
* Load a persisted session and resume an agent on it. Async because it awaits
|
||||
* `ctx.sessionPersistence.load`; must be called after that service exists
|
||||
* (consumers inject `sessionPersistence`). Returns an {@link AgentHandle}.
|
||||
* both `ctx.sessionPersistence.load` and the optional unpublished setup
|
||||
* transaction; must be called after that service exists (consumers inject
|
||||
* `sessionPersistence`). Publication follows the same ordered boundary as
|
||||
* {@link createAgent}.
|
||||
* @param ownerCtx - caller-bound context that owns load, setup, and the live handle.
|
||||
* @param options - persisted identity, configuration, and optional setup.
|
||||
* @returns the owned handle after setup, both announcements, and loop start complete.
|
||||
*/
|
||||
resume(options: ResumeAgentOptions): Promise<AgentHandle>
|
||||
resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle>
|
||||
}
|
||||
|
||||
/** Thrown when create/resume is called before an agent factory is registered. */
|
||||
const NO_FACTORY_MESSAGE = 'no agent factory registered (load an agent-loop plugin)'
|
||||
|
||||
/** All mutable lifecycle state for one exact registry entry. */
|
||||
interface AgentEntry {
|
||||
readonly id: AgentId
|
||||
readonly agent: Agent
|
||||
readonly carrier: Scoped<Agent>
|
||||
announced: boolean
|
||||
announcing: boolean
|
||||
detachRequested: boolean
|
||||
}
|
||||
|
||||
/** Plain holder prevents Cordis from tracing the factory field before the caller context is known. */
|
||||
interface FactorySlot {
|
||||
readonly target: AgentFactory
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent registry (`ctx.agents`): tracks live agents so UI, hook, and
|
||||
* orchestrator plugins can find them without depending on the concrete loop
|
||||
* package. Agent *creation* is provided by whichever plugin implements the
|
||||
* {@link AgentFactory} (phase 1: `@deepseek-ai/dsh-agent-loop`), registered via
|
||||
* {@link AgentFactory} (`@deepseek-ai/dsh-agent-loop`), registered via
|
||||
* {@link setFactory}.
|
||||
*/
|
||||
export class AgentRegistry extends Service {
|
||||
private store = new Map<AgentId, Agent>()
|
||||
private factory: AgentFactory | undefined
|
||||
private store = new Map<AgentId, AgentEntry>()
|
||||
private entries = new WeakMap<Agent, AgentEntry>()
|
||||
private factory: FactorySlot | undefined
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'agents')
|
||||
// The `ctx.agent` DX accessor: default `undefined` on every context, so a
|
||||
// plain plugin context reads cleanly instead of hitting the Cordis
|
||||
// unknown-property throw. Each Agent.ctx shadows it with an own property
|
||||
// (own properties resolve before the context proxy is consulted), so the
|
||||
// accessor body never needs to resolve a scope itself. Effect-scoped:
|
||||
// unwinds with this service's fiber.
|
||||
ctx.accessor('agent', { get: () => undefined })
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the agent-creation factory (the loop calls this on construction,
|
||||
* effect-scoped). Throws if a factory is already registered. Returns the
|
||||
* disposer; on dispose the factory slot is cleared.
|
||||
* effect-scoped). A traced Cordis service is canonicalized to its concrete
|
||||
* target; each create/resume call is then traced through that caller's
|
||||
* context so ownership follows the caller without stacking proxy layers.
|
||||
* Throws if a factory is already registered. Returns the disposer; on
|
||||
* dispose the factory slot is cleared.
|
||||
* @param factory - the loop-owned factory {@link create}/{@link resume} delegate to.
|
||||
* @returns the disposer that clears the factory slot.
|
||||
* @returns the disposer that clears the factory slot. The exact
|
||||
* Cordis effect disposer (single-shot): composite (generator) effects may
|
||||
* yield it directly — exact identity nests the teardown in order.
|
||||
*/
|
||||
setFactory(factory: AgentFactory): () => void {
|
||||
const dispose = this.ctx.effect(() => {
|
||||
if (this.factory !== undefined) throw new Error('an agent factory is already registered')
|
||||
this.factory = factory
|
||||
// Avoid stacking two Cordis shadow layers when a caller passes a Service
|
||||
// already read through a context. Calls are re-traced through their
|
||||
// actual owner context below.
|
||||
const target = (factory as AgentFactory & { [symbols.original]?: AgentFactory })[symbols.original] ?? factory
|
||||
this.factory = { target }
|
||||
return () => { this.factory = undefined }
|
||||
}, 'agents.setFactory()')
|
||||
return () => void dispose()
|
||||
// The exact cordis effect disposer (the agents.register() convention): a
|
||||
// caller's composite effect can yield it for in-order teardown; the
|
||||
// loop's constructor effect returns it directly, identity-nesting the
|
||||
// registration under that effect.
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||
return dispose
|
||||
}
|
||||
|
||||
/** Return the active creation factory. */
|
||||
private requireFactory(): FactorySlot {
|
||||
if (this.factory === undefined) throw new Error(NO_FACTORY_MESSAGE)
|
||||
return this.factory
|
||||
}
|
||||
|
||||
/**
|
||||
* Create, start, and register a new agent through the registered factory.
|
||||
* Create and publish a new agent through the registered factory.
|
||||
* Distinct from {@link register} (which records an already-constructed
|
||||
* agent): this constructs the agent and its session. Throws if no factory is
|
||||
* registered. Returns an {@link AgentHandle} — the owner disposes it to tear
|
||||
* down exactly this agent.
|
||||
* agent): this constructs the agent and its session. Rejects if no factory is
|
||||
* registered or creation/setup fails. The resolved {@link AgentHandle} lets
|
||||
* the owner tear down exactly this agent.
|
||||
* @param options - agent id, session id/seed/metadata, and agent options.
|
||||
* @returns the handle whose dispose tears down exactly this agent.
|
||||
* @returns the handle after setup, rollback-covered publication, and loop start complete.
|
||||
*/
|
||||
create(options: CreateAgentOptions): AgentHandle {
|
||||
if (this.factory === undefined) throw new Error(NO_FACTORY_MESSAGE)
|
||||
return this.factory.createAgent(options)
|
||||
async create(options: CreateAgentOptions): Promise<AgentHandle> {
|
||||
const ownerCtx = this.ctx
|
||||
// Re-trace a Service-backed factory through the accessing context
|
||||
// explicitly. This preserves AgentLoop's dependency origin while binding
|
||||
// its effects to ownerCtx; plain factories receive ownerCtx as an explicit
|
||||
// capability and need no Cordis tracker magic.
|
||||
const { target } = this.requireFactory()
|
||||
const receiver = getTraceable(ownerCtx, target)
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method -- Reflect.apply intentionally supplies the caller-traced receiver
|
||||
return Reflect.apply(target.createAgent, receiver, [ownerCtx, options])
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a persisted session and resume an agent on it through the registered
|
||||
* factory. Rejects if no factory is registered; the factory rejects if
|
||||
* session persistence is not configured. Returns an {@link AgentHandle}.
|
||||
* @param options - the persisted session id plus agent id and options.
|
||||
* @returns the handle for the resumed agent.
|
||||
* session persistence is not configured or persistence/setup fails.
|
||||
* @param options - persisted identity, configuration, and optional setup.
|
||||
* @returns the handle after setup, rollback-covered publication, and loop start complete.
|
||||
*/
|
||||
async resume(options: ResumeAgentOptions): Promise<AgentHandle> {
|
||||
if (this.factory === undefined) throw new Error(NO_FACTORY_MESSAGE)
|
||||
return this.factory.resume(options)
|
||||
const ownerCtx = this.ctx
|
||||
const { target } = this.requireFactory()
|
||||
const receiver = getTraceable(ownerCtx, target)
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method -- Reflect.apply intentionally supplies the caller-traced receiver
|
||||
return Reflect.apply(target.resume, receiver, [ownerCtx, options])
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a live agent. Throws if an agent with the same id is already
|
||||
* registered. Emits `agent/created` on registration and `agent/disposed`
|
||||
* when the calling fiber is disposed. Returns the disposer.
|
||||
* when the calling fiber is disposed — both with the agent's scope carrier
|
||||
* (`scopeTarget(agent, agent)`): the subject is the agent in hand, so the
|
||||
* emits are scope-filtered regardless of which context invoked `register`
|
||||
* (calling through `agent.ctx` scopes EFFECTS; dispatch scoping always
|
||||
* requires passing the carrier). Returns the disposer.
|
||||
* @param agent - the already-constructed agent to record in the store.
|
||||
* @returns the disposer that removes the agent and emits `agent/disposed`.
|
||||
* @returns the EXACT Cordis effect disposer (single-shot; a repeat call
|
||||
* returns undefined without awaiting an in-flight teardown). Exact
|
||||
* identity is load-bearing: a composite (generator) effect that owns a
|
||||
* teardown ORDER — the agent factory's lifecycle chain — must yield THIS
|
||||
* function so Cordis nests the unregistration at that yield position;
|
||||
* yielding a wrapper would leave it disposing as a concurrent sibling on
|
||||
* owner unload, unregistering the agent (and emitting `agent/disposed`)
|
||||
* while its final turn is still draining.
|
||||
*/
|
||||
register(agent: Agent): () => void {
|
||||
const dispose = this.ctx.effect(function* (this: AgentRegistry) {
|
||||
if (this.store.has(agent.id)) {
|
||||
throw new Error(`agent "${agent.id}" is already registered`)
|
||||
}
|
||||
this.store.set(agent.id, agent)
|
||||
// Yield the rollback BEFORE emitting `agent/created`: a generator effect
|
||||
// collects each yielded disposer before the next step runs, so a
|
||||
// throwing `agent/created` listener rolls the entry back instead of
|
||||
// leaking it (a leak would wedge the duplicate-id check until restart).
|
||||
// The duplicate throw above fires before any mutation — it leaks nothing.
|
||||
yield () => {
|
||||
this.store.delete(agent.id)
|
||||
// CONTAIN a throwing `agent/disposed` listener: this disposer runs as
|
||||
// one link in the owning fiber/effect's disposal chain, and Cordis
|
||||
// chains later disposers with `task.then(next)` — so an UNCAUGHT throw
|
||||
// here rejects the chain and SKIPS every later disposer. When this
|
||||
// registration shares a composite effect with a session (the agent
|
||||
// factory's `AgentLoop.start`, where the session-detach disposer runs
|
||||
// AFTER this one), a swallowed-less throw would strand the session in
|
||||
// the store with `onAppend` attached — a leak AND a durability hole.
|
||||
// The store entry is already removed above (the useful state), so
|
||||
// logging the listener bug and continuing is correct (mirrors the
|
||||
// guarded `agent/status` emit in dsh-agent-loop's ReactLoopAgent).
|
||||
try {
|
||||
this.ctx.emit('agent/disposed', agent)
|
||||
} catch (error: unknown) {
|
||||
this.ctx.logger.warn(`agent "${agent.id}": agent/disposed listener threw: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
this.ctx.emit('agent/created', agent)
|
||||
yield this.enter(agent)
|
||||
this.announce(agent)
|
||||
}.bind(this), 'agents.register()')
|
||||
// ctx.effect's disposer returns Promise<void>; our disposer API is
|
||||
// synchronous fire-and-forget — discard the (always-resolved) promise.
|
||||
return () => void dispose()
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||
return dispose
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert an already-constructed agent without announcing it. This is the
|
||||
* advanced ordered-lifecycle primitive used by the async agent factory: it
|
||||
* first completes setup while the agent is unpublished, then assigns the
|
||||
* returned detach closure into its pre-installed composite teardown before
|
||||
* calling {@link announce}. Ordinary callers use {@link register}.
|
||||
* @param agent - the prepared, unpublished agent.
|
||||
* @returns an idempotent closure that removes this exact entry and emits
|
||||
* `agent/disposed` with listener failures contained. When called from a
|
||||
* synchronous `agent/created` listener, removal and disposal wait until
|
||||
* that creation dispatch unwinds.
|
||||
*/
|
||||
enter(agent: Agent): () => void {
|
||||
const id = agent.id
|
||||
const carrier = scopeTarget(agent, agent)
|
||||
// This is the authoritative collision boundary. Concurrent create/resume
|
||||
// operations may both prepare, but only one exact entry can publish.
|
||||
if (this.entries.has(agent) || this.store.has(id)) throw new Error(`agent "${id}" is already registered`)
|
||||
const entry: AgentEntry = {
|
||||
id,
|
||||
agent,
|
||||
carrier,
|
||||
announced: false,
|
||||
announcing: false,
|
||||
detachRequested: false,
|
||||
}
|
||||
this.store.set(id, entry)
|
||||
this.entries.set(agent, entry)
|
||||
let entered = true
|
||||
const detach = (): void => {
|
||||
if (!entered) return
|
||||
entered = false
|
||||
// Every callback reached by this creation dispatch must observe the same
|
||||
// live entry, and disposal must follow creation. A listener may own
|
||||
// the advanced detach capability, so make that ordering structural:
|
||||
// visibility and the paired disposal are deferred until announce()'s
|
||||
// synchronous dispatch has unwound.
|
||||
if (entry.announcing) {
|
||||
entry.detachRequested = true
|
||||
return
|
||||
}
|
||||
this.detachEntered(entry)
|
||||
}
|
||||
return detach
|
||||
}
|
||||
|
||||
/** Remove one exact entered agent and emit its paired disposal when announced. */
|
||||
private detachEntered(entry: AgentEntry): void {
|
||||
entry.detachRequested = false
|
||||
// A stale capability can never delete a later same-id lifecycle. The
|
||||
// captured entry identity is the final boundary.
|
||||
/* v8 ignore next -- enter() rejects replacement while this single-shot detach capability is live. */
|
||||
if (this.store.get(entry.id) !== entry) return
|
||||
this.store.delete(entry.id)
|
||||
this.entries.delete(entry.agent)
|
||||
// An insertion rolled back before announce was never externally created,
|
||||
// so emitting disposed would invent an impossible lifecycle edge. Marking
|
||||
// happens before the created emit: if a later created listener throws,
|
||||
// earlier listeners may already have observed it and must see disposal.
|
||||
if (!entry.announced) return
|
||||
this.emitDisposed(entry)
|
||||
}
|
||||
|
||||
/** Emit the paired disposal edge through the entry's stable carrier. */
|
||||
private emitDisposed(entry: AgentEntry): void {
|
||||
const args: unknown[] = [entry.carrier, 'agent/disposed', entry.agent]
|
||||
for (const callback of this.ctx.events.dispatch('emit', args)) {
|
||||
try {
|
||||
const returned: unknown = callback(...args)
|
||||
void Promise.resolve(returned).catch((error: unknown) => {
|
||||
this.ctx.logger.warn(`agent "${entry.id}": agent/disposed listener rejected: ${String(error)}`)
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
this.ctx.logger.warn(`agent "${entry.id}": agent/disposed listener threw: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Announce an agent previously inserted with {@link enter}.
|
||||
* @param agent - the live inserted agent to announce.
|
||||
* @throws if `agent` is not the exact live registry entry for its id, or its
|
||||
* creation announcement already began (including a reentrant call from a
|
||||
* creation listener).
|
||||
*/
|
||||
announce(agent: Agent): void {
|
||||
const entry = this.entries.get(agent)
|
||||
if (entry === undefined || this.store.get(entry.id) !== entry) {
|
||||
throw new Error(`agent "${agent.id}" is not live in this registry`)
|
||||
}
|
||||
if (entry.announced || entry.announcing) {
|
||||
throw new Error(`agent "${entry.id}" was already announced`)
|
||||
}
|
||||
// Mark before dispatch so a listener cannot recursively create a second
|
||||
// lifecycle edge; detach still pairs a partially delivered first edge.
|
||||
entry.announcing = true
|
||||
entry.announced = true
|
||||
const args: unknown[] = [entry.carrier, 'agent/created', entry.agent]
|
||||
try {
|
||||
for (const callback of this.ctx.events.dispatch('emit', args)) {
|
||||
// A synchronous creation failure vetoes publication and rolls back.
|
||||
// Returned-promise rejection happens after this synchronous boundary, so
|
||||
// observe and report it instead of leaking an unhandled rejection.
|
||||
const returned: unknown = callback(...args)
|
||||
void Promise.resolve(returned).catch((error: unknown) => {
|
||||
this.ctx.logger.warn(`agent "${entry.id}": agent/created listener rejected: ${String(error)}`)
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
entry.announcing = false
|
||||
if (entry.detachRequested) this.detachEntered(entry)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -214,7 +435,7 @@ export class AgentRegistry extends Service {
|
||||
* @returns the agent, or undefined when no live agent has that id.
|
||||
*/
|
||||
get(id: AgentId): Agent | undefined {
|
||||
return this.store.get(id)
|
||||
return this.store.get(id)?.agent
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -222,7 +443,7 @@ export class AgentRegistry extends Service {
|
||||
* @returns a fresh array; mutating it does not affect the registry.
|
||||
*/
|
||||
list(): Agent[] {
|
||||
return [...this.store.values()]
|
||||
return [...this.store.values()].map(entry => entry.agent)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
* - **`agent/*`** (this module) — the LIVE runtime surface. Always carries the
|
||||
* live `Agent`. Two shapes: INTERCEPTION seams (the `agent/prompt-submit`/
|
||||
* `agent/request`/`agent/session-prefix`/`agent/step-result`/
|
||||
* `agent/turn-continuation` waterfalls and
|
||||
* the serial `agent/pre-step`) that mutate/veto, and TRANSIENT emits
|
||||
* `agent/turn-continuation` waterfalls and the serial `agent/pre-step` /
|
||||
* `agent/turn-stop` checkpoints) that mutate/veto, and TRANSIENT emits
|
||||
* (`agent/status`, `agent/error`, `agent/created`/
|
||||
* `agent/disposed`, `agent/queued`, `agent/session-start`)
|
||||
* that notify with the `Agent` in hand. Turn/step boundaries are NOT here —
|
||||
@@ -37,14 +37,17 @@
|
||||
* and `docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md`.
|
||||
*
|
||||
* The interception waterfalls here (`agent/prompt-submit`, `agent/request`,
|
||||
* `agent/step-result`, `agent/turn-continuation`) each return a typed Decision —
|
||||
* the convention pinned by
|
||||
* `agent/step-result`, `agent/turn-continuation`) each return a typed Decision;
|
||||
* the terminal serial `agent/turn-stop` returns the stop-only subset. The
|
||||
* convention is pinned by
|
||||
* `docs/rfc/implemented/feature/2026-06-30-interception-seams.md`.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-agent/types
|
||||
*/
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { Context } from 'cordis'
|
||||
import type { Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import type { ContentBlock, LlmCallConfig, Message, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
|
||||
@@ -65,19 +68,23 @@ declare module '@deepseek-ai/dsh-system-prompt' {
|
||||
interface AssembleContext {
|
||||
/**
|
||||
* The agent this assembly is for. The agent loop passes it on every
|
||||
* per-step `assemble({ agent })`; variable providers project per-agent
|
||||
* facts from it (`options.model` → `{{model}}`, `session.header.cwd` →
|
||||
* per-step assembly (via its `assembleContextFor(agent)` helper, which
|
||||
* also sets the `scope` field to the same agent — the layer selector
|
||||
* `dsh-system-prompt` reads); variable providers project per-agent facts
|
||||
* from it (`options.model` → `{{model}}`, `session.header.cwd` →
|
||||
* `{{cwd}}`). Optional because a bare `assemble()` (tests, diagnostics)
|
||||
* has no agent — providers must tolerate its absence.
|
||||
* has no agent — providers must tolerate its absence. Never set `agent`
|
||||
* without `scope`: the assembly would silently miss the agent's scoped
|
||||
* sections/tools (the dev invariants flag it).
|
||||
*/
|
||||
agent?: Agent
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Options an agent is created with. The persona is NOT here — it is the
|
||||
* deployment's `persona` config on the dsh-system-prompt plugin, shared by
|
||||
* every agent in the context.
|
||||
* Options an agent is created with. The persona is NOT here: the
|
||||
* dsh-system-prompt config supplies the global default, and a scoped
|
||||
* `deployment:persona` section may override it for one agent.
|
||||
* Merge-extensible: plugins declare extra fields via declaration merging.
|
||||
*/
|
||||
export interface AgentOptions {
|
||||
@@ -155,6 +162,13 @@ export type ContinuationDecision =
|
||||
| { action: 'stop' }
|
||||
| { action: 'continue'; reason?: HookContext }
|
||||
|
||||
/**
|
||||
* The terminal subset of {@link ContinuationDecision}. A listener on
|
||||
* `agent/turn-stop` returns this to make the already-composed continuation
|
||||
* outcome terminal; `undefined` abstains.
|
||||
*/
|
||||
export type ContinuationStop = Extract<ContinuationDecision, { action: 'stop' }>
|
||||
|
||||
/**
|
||||
* Why an agent's session lifecycle began, carried by `agent/session-start`. A
|
||||
* bridge keys its SessionStart hook's matcher on this (Claude Code's
|
||||
@@ -176,13 +190,33 @@ export interface Agent {
|
||||
readonly options: AgentOptions
|
||||
readonly session: Session
|
||||
readonly status: AgentStatus
|
||||
/**
|
||||
* The agent's scope context (`@deepseek-ai/dsh-scope`, key = this agent).
|
||||
* Registrations through it — tools, prompt sections/variables, event
|
||||
* listeners, restrictions — are visible to THIS agent only and unwind when
|
||||
* the agent is disposed; `agent.ctx.on('agent/…')` listeners fire only for
|
||||
* this agent's dispatches (zero self-filtering). Service resolution through
|
||||
* it flows through the loop plugin's dependency surface — handing out
|
||||
* `agent.ctx` hands out that capability. Live for exactly the agent's
|
||||
* lifetime: registrations after disposal throw Cordis's INACTIVE_EFFECT.
|
||||
*/
|
||||
readonly ctx: Context
|
||||
|
||||
/** Queue a user message. Starts a turn when idle; otherwise waits for the next turn. */
|
||||
/**
|
||||
* Queue a user message. Starts a turn when idle; otherwise waits for the next
|
||||
* turn. Content and the resolved source are accepted as one detached,
|
||||
* deeply-frozen lossless-JSON record before notification or enqueue, so
|
||||
* caller or `agent/queued` listener in-place mutation cannot change later
|
||||
* log/model input. Throws synchronously when either value is not losslessly
|
||||
* JSON-serializable; `agent/prompt-submit` may still return an explicit
|
||||
* replacement.
|
||||
*/
|
||||
send(content: ContentBlock[], options?: SendOptions): void
|
||||
|
||||
/**
|
||||
* Steer a running turn: content is injected between steps of the current
|
||||
* turn. When idle, behaves like {@link send}.
|
||||
* turn. Uses the same owned-value and synchronous-validation boundary as
|
||||
* {@link send}; when idle, behaves exactly like that method.
|
||||
*/
|
||||
steer(content: ContentBlock[], options?: SendOptions): void
|
||||
|
||||
@@ -197,8 +231,10 @@ export interface Agent {
|
||||
* turn (`turn/start` → `context/message` → `turn/end`) and checkpoints it for
|
||||
* durability, so every event stays inside a turn and a persistence backend
|
||||
* never loses a between-turn notice. The idle checkpoint is fire-and-forget
|
||||
* (inject is synchronous): a failing flush is reported via `agent/error`
|
||||
* (step `0`) and the logger, never thrown into the caller.
|
||||
* from this synchronous method, but lifecycle disposal awaits it before
|
||||
* unregistering the agent or detaching its session. A failing flush is
|
||||
* reported via `agent/error` (step `0`) and the logger, never thrown into the
|
||||
* caller.
|
||||
*
|
||||
* Live-adapter review has validated the tagged-envelope rendering against
|
||||
* current DeepSeek behavior; provider-specific mismatches belong in that
|
||||
@@ -257,52 +293,92 @@ declare module 'cordis' {
|
||||
interface Events {
|
||||
// ---- lifecycle (emit) ----
|
||||
/**
|
||||
* An agent was registered in the {@link AgentRegistry} and is ready to
|
||||
* receive messages.
|
||||
* @param agent - the newly registered agent, already resolvable in the registry.
|
||||
* An agent's fully composed scoped world was published in the
|
||||
* {@link AgentRegistry}. Its session is already live in the session store.
|
||||
* Setup is composition-only by contract; the subsequent
|
||||
* `agent/session-start` boundary is the first supported place to inject or
|
||||
* queue startup work. A synchronous listener throw
|
||||
* vetoes publication and rollback emits the matching disposal edges;
|
||||
* returned-promise rejection is observed and logged but cannot
|
||||
* retroactively veto this synchronous boundary. A synchronous listener
|
||||
* that requests the advanced registry detach does not remove the entry
|
||||
* immediately: removal and the paired `agent/disposed` edge wait until the
|
||||
* creation dispatch unwinds, so no later creation listener observes a
|
||||
* disposal that preceded its own creation callback.
|
||||
* @param agent - the newly registered agent with its live session and completed setup.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
|
||||
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
|
||||
* plain plugin context fires for every agent. The dispatch `this` is the
|
||||
* scope carrier (`Scoped<Agent>`), built by the emitting side via
|
||||
* `scopeTarget`/`agentEvents`.
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/created'(agent: Agent): void
|
||||
'agent/created'(this: Scoped<Agent>, agent: Agent): void
|
||||
/**
|
||||
* An agent was disposed and removed from the registry; its fiber and any
|
||||
* in-flight turn have been torn down.
|
||||
* @param agent - the agent that was torn down; its handle is now inert.
|
||||
* An agent was removed from the registry. The concrete AgentLoop lifecycle
|
||||
* emits this only after its driver and any in-flight turn reach quiescence;
|
||||
* a custom agent registered through the public registry owns its own driver
|
||||
* contract, which the registry cannot infer. Ordered teardown may still be
|
||||
* detaching the session and unwinding scoped registrations when this runs.
|
||||
* @param agent - the exact agent removed from the registry.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
|
||||
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
|
||||
* plain plugin context fires for every agent. The dispatch `this` is the
|
||||
* scope carrier (`Scoped<Agent>`), built by the emitting side via
|
||||
* `scopeTarget`/`agentEvents`.
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/disposed'(agent: Agent): void
|
||||
'agent/disposed'(this: Scoped<Agent>, agent: Agent): void
|
||||
/**
|
||||
* Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive
|
||||
* lifecycle off this transition, never off a status you just requested —
|
||||
* `send()` does not flip status to `running` before it returns.
|
||||
* @param agent - the agent whose status flipped.
|
||||
* @param status - the status just entered (the transition's destination).
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
|
||||
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
|
||||
* plain plugin context fires for every agent. The dispatch `this` is the
|
||||
* scope carrier (`Scoped<Agent>`), built by the emitting side via
|
||||
* `scopeTarget`/`agentEvents`.
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/status'(agent: Agent, status: AgentStatus): void
|
||||
'agent/status'(this: Scoped<Agent>, agent: Agent, status: AgentStatus): void
|
||||
/**
|
||||
* A message entered the agent's inbox (queued or steering). `source` is
|
||||
* the resolved source (defaults applied), not the caller's raw options.
|
||||
* A message entered the agent's inbox (queued or steering). Content and the
|
||||
* resolved source are the detached, deeply-frozen values retained by the
|
||||
* inbox. `source` has defaults applied and is not the caller's raw options.
|
||||
* @param agent - the agent whose inbox received the message.
|
||||
* @param content - the enqueued content blocks, verbatim.
|
||||
* @param info - the resolved source plus whether it entered as steering.
|
||||
* @param content - the accepted content blocks retained by the inbox.
|
||||
* @param info - the accepted source plus whether it entered as steering.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
|
||||
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
|
||||
* plain plugin context fires for every agent. The dispatch `this` is the
|
||||
* scope carrier (`Scoped<Agent>`), built by the emitting side via
|
||||
* `scopeTarget`/`agentEvents`.
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/queued'(agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void
|
||||
'agent/queued'(this: Scoped<Agent>, agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void
|
||||
|
||||
// ---- session lifecycle (emit) ----
|
||||
/**
|
||||
* The agent's session lifecycle began, fired once before its first turn.
|
||||
* `source` says why ({@link SessionStartSource}: fresh startup, a resumed
|
||||
* persisted session, …). A pure NOTIFICATION (emit, not waterfall): it
|
||||
* carries no veto — a session-start listener that wants to seed context does
|
||||
* so via `agent.inject()` (a `context/message` the first request sees), not
|
||||
* by returning a decision. Cannot block the session from starting; that gap
|
||||
* is deliberate (a bridge logs/injects, it does not gate startup).
|
||||
* persisted session, …). A pure NOTIFICATION (emit, not waterfall): a
|
||||
* listener cannot veto by returning a decision or throwing. A listener that
|
||||
* wants to seed context does so via `agent.inject()` (a `context/message` the
|
||||
* first request sees). A lifecycle owner can still dispose its structural
|
||||
* ownership edge during this notification; publication rechecks liveness and
|
||||
* then aborts before the driver starts.
|
||||
* @param agent - the agent whose session lifecycle began.
|
||||
* @param source - why the session started (fresh startup, resume, …).
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
|
||||
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
|
||||
* plain plugin context fires for every agent. The dispatch `this` is the
|
||||
* scope carrier (`Scoped<Agent>`), built by the emitting side via
|
||||
* `scopeTarget`/`agentEvents`.
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/session-start'(agent: Agent, source: SessionStartSource): void
|
||||
'agent/session-start'(this: Scoped<Agent>, agent: Agent, source: SessionStartSource): void
|
||||
|
||||
// Turn and step boundaries are NOT mirrored as agent/* emits: a consumer
|
||||
// that needs them reads the durable `turn/start`/`turn/end`/`step/start`/
|
||||
@@ -340,6 +416,11 @@ declare module 'cordis' {
|
||||
* request will actually send (never a stale logged one). `signal` cancels
|
||||
* any in-flight work a listener starts (e.g. a
|
||||
* summarization model call).
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
|
||||
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
|
||||
* plain plugin context fires for every agent. The dispatch `this` is the
|
||||
* scope carrier (`Scoped<Agent>`), built by the emitting side via
|
||||
* `scopeTarget`/`agentEvents`.
|
||||
* @param agent - the agent about to open the step.
|
||||
* @param turn - the already-open turn this step belongs to.
|
||||
* @param step - the number of the step about to start.
|
||||
@@ -354,7 +435,7 @@ declare module 'cordis' {
|
||||
// reads. Revisit if no second consumer appears: e.g. hand listeners a lazy
|
||||
// prompt provider, or move token-pressure measurement behind a
|
||||
// compaction-specific seam instead of the shared pre-step checkpoint.
|
||||
'agent/pre-step'(agent: Agent, turn: number, step: number, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal): Promise<void> | void
|
||||
'agent/pre-step'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal): Promise<void> | void
|
||||
/**
|
||||
* Waterfall: decide what happens to ONE drained queued message before it
|
||||
* becomes a `user/message` — allow (optionally rewriting the prompt bytes or
|
||||
@@ -365,9 +446,14 @@ declare module 'cordis' {
|
||||
* @param agent - the agent draining its inbox.
|
||||
* @param content - the drained message's blocks, as queued.
|
||||
* @param source - the message's resolved source.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
|
||||
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
|
||||
* plain plugin context fires for every agent. The dispatch `this` is the
|
||||
* scope carrier (`Scoped<Agent>`), built by the emitting side via
|
||||
* `scopeTarget`/`agentEvents`.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'agent/prompt-submit'(agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise<PromptDecision>): Promise<PromptDecision>
|
||||
'agent/prompt-submit'(this: Scoped<Agent>, agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise<PromptDecision>): Promise<PromptDecision>
|
||||
/**
|
||||
* Waterfall: shape the step's call configuration — model switching,
|
||||
* sampling overrides — by returning a replacement {@link LlmCallConfig}
|
||||
@@ -389,9 +475,14 @@ declare module 'cordis' {
|
||||
* @param turn - the open turn number.
|
||||
* @param step - the step whose request this is.
|
||||
* @param config - the config the loop would use (frozen); return a replacement to switch.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
|
||||
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
|
||||
* plain plugin context fires for every agent. The dispatch `this` is the
|
||||
* scope carrier (`Scoped<Agent>`), built by the emitting side via
|
||||
* `scopeTarget`/`agentEvents`.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'agent/request'(agent: Agent, turn: number, step: number, config: LlmCallConfig, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>
|
||||
'agent/request'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, config: LlmCallConfig, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>
|
||||
/**
|
||||
* Waterfall: compose the SESSION PREFIX — request-only messages placed in
|
||||
* front of the ENTIRE derived history (directly after the provider's
|
||||
@@ -433,12 +524,17 @@ declare module 'cordis' {
|
||||
* every later-registered plugin's — reverse registration order when all
|
||||
* contributors append. Call `next()` to
|
||||
* delegate, or return a list without it to short-circuit.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
|
||||
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
|
||||
* plain plugin context fires for every agent. The dispatch `this` is the
|
||||
* scope carrier (`Scoped<Agent>`), built by the emitting side via
|
||||
* `scopeTarget`/`agentEvents`.
|
||||
* @param agent - the agent whose session prefix is being composed.
|
||||
* @param prefix - the frozen empty seed; return an extended replacement to contribute.
|
||||
* @param signal - aborts in-flight listener work (e.g. a discovery scan) when the step is torn down.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'agent/session-prefix'(agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise<Message[]>): Promise<Message[]>
|
||||
'agent/session-prefix'(this: Scoped<Agent>, agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise<Message[]>): Promise<Message[]>
|
||||
/**
|
||||
* Waterfall: post-process the assembled assistant {@link Message} before
|
||||
* tool dispatch (validation, content rewriting, …).
|
||||
@@ -446,9 +542,14 @@ declare module 'cordis' {
|
||||
* @param turn - the open turn number.
|
||||
* @param step - the step that produced the message.
|
||||
* @param message - the assistant message as assembled from the stream.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
|
||||
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
|
||||
* plain plugin context fires for every agent. The dispatch `this` is the
|
||||
* scope carrier (`Scoped<Agent>`), built by the emitting side via
|
||||
* `scopeTarget`/`agentEvents`.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'agent/step-result'(agent: Agent, turn: number, step: number, message: Message, next: () => Promise<Message>): Promise<Message>
|
||||
'agent/step-result'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, message: Message, next: () => Promise<Message>): Promise<Message>
|
||||
/**
|
||||
* Waterfall: override the turn-continuation decision via a typed
|
||||
* {@link ContinuationDecision}. The loop's `defaultDecision` is `continue`
|
||||
@@ -459,9 +560,32 @@ declare module 'cordis' {
|
||||
* @param agent - the agent deciding whether to run another step.
|
||||
* @param turn - the turn being continued or stopped.
|
||||
* @param defaultDecision - what the loop would do absent an override.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
|
||||
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
|
||||
* plain plugin context fires for every agent. The dispatch `this` is the
|
||||
* scope carrier (`Scoped<Agent>`), built by the emitting side via
|
||||
* `scopeTarget`/`agentEvents`.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'agent/turn-continuation'(agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise<ContinuationDecision>): Promise<ContinuationDecision>
|
||||
'agent/turn-continuation'(this: Scoped<Agent>, agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise<ContinuationDecision>): Promise<ContinuationDecision>
|
||||
/**
|
||||
* Serial terminal-stop checkpoint after the ordinary
|
||||
* `agent/turn-continuation` waterfall, any `continue.reason`, and the
|
||||
* pending-steering continuation override have been folded. A listener
|
||||
* returns `{ action: 'stop' }` to make this turn terminal, or `undefined`
|
||||
* to abstain. Terminal stop is monotonic: listener order and steering
|
||||
* cannot resume the turn, and pending steering is discarded rather than
|
||||
* becoming another step or turn.
|
||||
* @param agent - the agent whose composed continuation outcome may be stopped.
|
||||
* @param turn - the turn at its terminal-stop checkpoint.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
|
||||
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
|
||||
* plain plugin context fires for every agent. The dispatch `this` is the
|
||||
* scope carrier (`Scoped<Agent>`), built by the emitting side via
|
||||
* `scopeTarget`/`agentEvents`.
|
||||
* @mode serial
|
||||
*/
|
||||
'agent/turn-stop'(this: Scoped<Agent>, agent: Agent, turn: number): ContinuationStop | undefined
|
||||
|
||||
// ---- error notifications (emit) ----
|
||||
/**
|
||||
@@ -471,8 +595,13 @@ declare module 'cordis' {
|
||||
* @param turn - the turn in which the failure surfaced.
|
||||
* @param step - the step at which the failure surfaced.
|
||||
* @param error - the failure, verbatim.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): a listener registered
|
||||
* through `agent.ctx` fires only for that agent's dispatches; a listener on a
|
||||
* plain plugin context fires for every agent. The dispatch `this` is the
|
||||
* scope carrier (`Scoped<Agent>`), built by the emitting side via
|
||||
* `scopeTarget`/`agentEvents`.
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/error'(agent: Agent, turn: number, step: number, error: Error): void
|
||||
'agent/error'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, error: Error): void
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, expectTypeOf, it } from 'vitest'
|
||||
import { Context, Service, symbols } from 'cordis'
|
||||
import type { Events } from 'cordis'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry, { Agent, AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { AgentId, agentEvents } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentFactory, ContinuationStop, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
function stubAgent(rawId: string): Agent {
|
||||
const id = AgentId(rawId)
|
||||
@@ -10,6 +12,7 @@ function stubAgent(rawId: string): Agent {
|
||||
options: {},
|
||||
session: new Session(SessionId(`${id}-session`)),
|
||||
status: 'idle',
|
||||
ctx: new Context(),
|
||||
send() {},
|
||||
steer() {},
|
||||
inject() {},
|
||||
@@ -19,120 +22,211 @@ function stubAgent(rawId: string): Agent {
|
||||
}
|
||||
|
||||
describe('AgentRegistry', () => {
|
||||
it('registers agents and emits created/disposed events', async () => {
|
||||
it('keeps terminal stop decisions synchronous', () => {
|
||||
type TurnStopListener = Events['agent/turn-stop']
|
||||
type AsyncTurnStopListener = () => Promise<ContinuationStop | undefined>
|
||||
|
||||
expectTypeOf<AsyncTurnStopListener>().not.toExtend<TurnStopListener>()
|
||||
expectTypeOf<ReturnType<TurnStopListener>>().toEqualTypeOf<ContinuationStop | undefined>()
|
||||
})
|
||||
|
||||
it('registers exact entries, emits lifecycle events, and unregisters on owner disposal', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
|
||||
const created: string[] = []
|
||||
const disposed: string[] = []
|
||||
ctx.on('agent/created', agent => void created.push(agent.id))
|
||||
ctx.on('agent/disposed', agent => void disposed.push(agent.id))
|
||||
const lifecycle: string[] = []
|
||||
ctx.on('agent/created', agent => void lifecycle.push(`created:${agent.id}`))
|
||||
ctx.on('agent/disposed', agent => void lifecycle.push(`disposed:${agent.id}`))
|
||||
|
||||
const agent = stubAgent('a1')
|
||||
const dispose = ctx.agents.register(agent)
|
||||
expect(created).toEqual(['a1'])
|
||||
expect(ctx.agents.get(AgentId('a1'))).toBe(agent)
|
||||
expect(ctx.agents.get(agent.id)).toBe(agent)
|
||||
expect(ctx.agents.list()).toEqual([agent])
|
||||
expect(() => ctx.agents.register(stubAgent('a1'))).toThrow(/already registered/)
|
||||
|
||||
dispose()
|
||||
expect(disposed).toEqual(['a1'])
|
||||
expect(ctx.agents.get(AgentId('a1'))).toBeUndefined()
|
||||
expect(ctx.agents.get(agent.id)).toBeUndefined()
|
||||
expect(lifecycle).toEqual(['created:a1', 'disposed:a1'])
|
||||
})
|
||||
|
||||
it('rejects duplicate ids and unregisters on fiber dispose (HMR safety)', async () => {
|
||||
it('rolls an entry back and pairs a partially delivered creation when a listener throws', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
ctx.agents.register(stubAgent('main'))
|
||||
expect(() => ctx.agents.register(stubAgent('main'))).toThrow('already registered')
|
||||
const lifecycle: string[] = []
|
||||
ctx.on('agent/created', agent => void lifecycle.push(`created:${agent.id}`))
|
||||
ctx.on('agent/created', () => { throw new Error('creation veto') })
|
||||
ctx.on('agent/disposed', agent => void lifecycle.push(`disposed:${agent.id}`))
|
||||
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
inner.agents.register(stubAgent('scoped'))
|
||||
}, { inject: ['agents'] }))
|
||||
expect(ctx.agents.list().map(a => a.id)).toEqual(['main', 'scoped'])
|
||||
|
||||
await fiber.dispose()
|
||||
expect(ctx.agents.list().map(a => a.id)).toEqual(['main'])
|
||||
expect(() => ctx.agents.register(stubAgent('vetoed'))).toThrow('creation veto')
|
||||
expect(ctx.agents.get(AgentId('vetoed'))).toBeUndefined()
|
||||
expect(lifecycle).toEqual(['created:vetoed', 'disposed:vetoed'])
|
||||
})
|
||||
|
||||
it('rolls back the agent entry when an agent/created listener throws (P1-1)', async () => {
|
||||
it('contains asynchronous creation rejection and every disposal-listener failure', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const warnings: string[] = []
|
||||
const heard: string[] = []
|
||||
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
|
||||
ctx.on('agent/created', () => Promise.reject(new Error('created async')) as never)
|
||||
ctx.on('agent/disposed', () => { throw new Error('disposed sync') })
|
||||
ctx.on('agent/disposed', () => Promise.reject(new Error('disposed async')) as never)
|
||||
ctx.on('agent/disposed', agent => void heard.push(agent.id))
|
||||
|
||||
let threw = false
|
||||
const dispose = ctx.agents.register(stubAgent('contained'))
|
||||
await Promise.resolve()
|
||||
dispose()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(heard).toEqual(['contained'])
|
||||
expect(warnings).toEqual([
|
||||
'agent "contained": agent/created listener rejected: Error: created async',
|
||||
'agent "contained": agent/disposed listener threw: Error: disposed sync',
|
||||
'agent "contained": agent/disposed listener rejected: Error: disposed async',
|
||||
])
|
||||
})
|
||||
|
||||
it('separates entry from announcement and stale/idempotent detach cannot remove a replacement', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const lifecycle: string[] = []
|
||||
ctx.on('agent/created', agent => void lifecycle.push(`created:${agent.id}`))
|
||||
ctx.on('agent/disposed', agent => void lifecycle.push(`disposed:${agent.id}`))
|
||||
|
||||
const first = stubAgent('split')
|
||||
const detachFirst = ctx.agents.enter(first)
|
||||
expect(lifecycle).toEqual([])
|
||||
ctx.agents.announce(first)
|
||||
expect(() => { ctx.agents.announce(first) }).toThrow(/already announced/)
|
||||
detachFirst()
|
||||
detachFirst()
|
||||
|
||||
const replacement = stubAgent('split')
|
||||
const detachReplacement = ctx.agents.enter(replacement)
|
||||
detachFirst()
|
||||
expect(ctx.agents.get(replacement.id)).toBe(replacement)
|
||||
expect(() => { ctx.agents.announce(first) }).toThrow(/not live/)
|
||||
detachReplacement()
|
||||
expect(lifecycle).toEqual(['created:split', 'disposed:split'])
|
||||
})
|
||||
|
||||
it('defers detach requested by a creation listener until that dispatch unwinds', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const order: string[] = []
|
||||
const agent = stubAgent('reentrant')
|
||||
ctx.on('agent/created', () => {
|
||||
if (!threw) { threw = true; throw new Error('boom created listener') }
|
||||
order.push(`first:${ctx.agents.get(agent.id) === agent}`)
|
||||
detach()
|
||||
order.push(`after-detach:${ctx.agents.get(agent.id) === agent}`)
|
||||
})
|
||||
ctx.on('agent/created', () => void order.push(`second:${ctx.agents.get(agent.id) === agent}`))
|
||||
ctx.on('agent/disposed', () => void order.push('disposed'))
|
||||
const detach = ctx.agents.enter(agent)
|
||||
ctx.agents.announce(agent)
|
||||
expect(order).toEqual(['first:true', 'after-detach:true', 'second:true', 'disposed'])
|
||||
expect(ctx.agents.get(agent.id)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// The throwing emit must roll the entry back, not leak it.
|
||||
expect(() => ctx.agents.register(stubAgent('main'))).toThrow('boom created listener')
|
||||
expect(ctx.agents.get(AgentId('main'))).toBeUndefined() // rolled back, not leaked
|
||||
describe('agentEvents()', () => {
|
||||
it('contains each synchronous throw and returned-promise rejection', async () => {
|
||||
const ctx = new Context()
|
||||
const warnings: string[] = []
|
||||
const heard: string[] = []
|
||||
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
|
||||
const agent = stubAgent('event')
|
||||
ctx.on('agent/status', () => { throw new Error('sync listener') })
|
||||
ctx.on('agent/status', () => Promise.reject(new Error('async listener')) as never)
|
||||
ctx.on('agent/status', (_agent, status) => void heard.push(status))
|
||||
|
||||
// A subsequent listener-free register of the SAME id succeeds and is
|
||||
// tracked exactly once (the duplicate-id check is not wedged).
|
||||
const dispose = ctx.agents.register(stubAgent('main'))
|
||||
expect(ctx.agents.list().map(a => a.id)).toEqual(['main'])
|
||||
dispose()
|
||||
expect(ctx.agents.get(AgentId('main'))).toBeUndefined()
|
||||
agentEvents(ctx, agent).emit('agent/status', 'running')
|
||||
await Promise.resolve()
|
||||
expect(heard).toEqual(['running'])
|
||||
expect(warnings).toEqual([
|
||||
'agent event "agent/status" listener threw: Error: sync listener',
|
||||
'agent event "agent/status" listener rejected: Error: async listener',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('AgentRegistry factory seam', () => {
|
||||
/** A stub AgentFactory that records calls and returns a stub agent. */
|
||||
function stubFactory() {
|
||||
const calls: { create: unknown[]; resume: unknown[] } = { create: [], resume: [] }
|
||||
const factory: import('@deepseek-ai/dsh-agent').AgentFactory = {
|
||||
createAgent(options) {
|
||||
calls.create.push(options)
|
||||
const calls: {
|
||||
create: Array<{ ownerCtx: Context; options: CreateAgentOptions }>
|
||||
resume: Array<{ ownerCtx: Context; options: ResumeAgentOptions }>
|
||||
} = { create: [], resume: [] }
|
||||
const factory: AgentFactory = {
|
||||
async createAgent(ownerCtx, options) {
|
||||
calls.create.push({ ownerCtx, options })
|
||||
return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() }
|
||||
},
|
||||
resume(options) {
|
||||
calls.resume.push(options)
|
||||
return Promise.resolve({ agent: stubAgent(options.agentId), dispose: () => Promise.resolve() })
|
||||
async resume(ownerCtx, options) {
|
||||
calls.resume.push({ ownerCtx, options })
|
||||
return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() }
|
||||
},
|
||||
}
|
||||
return { factory, calls }
|
||||
}
|
||||
|
||||
it('create()/resume() throw when no factory is registered', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
expect(() => ctx.agents.create({ agentId: AgentId('a'), sessionId: SessionId('s') })).toThrow(/no agent factory/)
|
||||
await expect(ctx.agents.resume({ agentId: AgentId('a'), resumeSessionId: SessionId('s') })).rejects.toThrow(/no agent factory/)
|
||||
})
|
||||
|
||||
it('setFactory registers a factory; create/resume delegate to it', async () => {
|
||||
it('requires a factory and delegates through the calling context', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await expect(ctx.agents.create({ agentId: AgentId('a'), sessionId: SessionId('s') })).rejects.toThrow(/no agent factory/)
|
||||
const { factory, calls } = stubFactory()
|
||||
ctx.agents.setFactory(factory)
|
||||
|
||||
const created = ctx.agents.create({ agentId: AgentId('c1'), sessionId: SessionId('sess-1'), meta: { cwd: '/w' } })
|
||||
expect(created.agent.id).toBe('c1')
|
||||
expect(calls.create).toEqual([{ agentId: AgentId('c1'), sessionId: SessionId('sess-1'), meta: { cwd: '/w' } }])
|
||||
|
||||
const resumed = await ctx.agents.resume({ agentId: AgentId('r1'), resumeSessionId: SessionId('old-sess') })
|
||||
expect(resumed.agent.id).toBe('r1')
|
||||
expect(calls.resume).toEqual([{ agentId: AgentId('r1'), resumeSessionId: SessionId('old-sess') }])
|
||||
})
|
||||
|
||||
it('setFactory rejects a second factory', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
ctx.agents.setFactory(stubFactory().factory)
|
||||
expect(() => ctx.agents.setFactory(stubFactory().factory)).toThrow(/already registered/)
|
||||
})
|
||||
|
||||
it('disposing the setFactory fiber clears the factory (HMR safety)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
let dispose!: () => void
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
dispose = inner.agents.setFactory(stubFactory().factory)
|
||||
let callerFiber: Context['fiber'] | undefined
|
||||
await ctx.plugin(Object.assign(async (inner: Context) => {
|
||||
callerFiber = inner.fiber
|
||||
await inner.agents.create({ agentId: AgentId('create'), sessionId: SessionId('create-s') })
|
||||
await inner.agents.resume({ agentId: AgentId('resume'), resumeSessionId: SessionId('resume-s') })
|
||||
}, { inject: ['agents'] }))
|
||||
expect(() => ctx.agents.create({ agentId: AgentId('a'), sessionId: SessionId('s') })).not.toThrow()
|
||||
void dispose
|
||||
await fiber.dispose()
|
||||
// factory slot cleared → create throws again
|
||||
expect(() => ctx.agents.create({ agentId: AgentId('a2'), sessionId: SessionId('s2') })).toThrow(/no agent factory/)
|
||||
expect(calls.create[0]?.ownerCtx.fiber).toBe(callerFiber)
|
||||
expect(calls.resume[0]?.ownerCtx.fiber).toBe(callerFiber)
|
||||
})
|
||||
|
||||
it('rejects a second factory and clears the slot with its owner (HMR)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
inner.agents.setFactory(stubFactory().factory)
|
||||
expect(() => inner.agents.setFactory(stubFactory().factory)).toThrow(/already registered/)
|
||||
}, { inject: ['agents'] }))
|
||||
await expect(ctx.agents.create({ agentId: AgentId('before'), sessionId: SessionId('before-s') })).resolves.toBeDefined()
|
||||
await owner.dispose()
|
||||
await expect(ctx.agents.create({ agentId: AgentId('after'), sessionId: SessionId('after-s') })).rejects.toThrow(/no agent factory/)
|
||||
})
|
||||
|
||||
it('canonicalizes an already traced Service before tracing it for the caller', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const states = new WeakMap<object, string[]>()
|
||||
class TracedFactory extends Service implements AgentFactory {
|
||||
constructor(inner: Context) {
|
||||
super(inner, 'tracedFactory')
|
||||
states.set(this, [])
|
||||
}
|
||||
private calls(): string[] {
|
||||
const original = (this as unknown as { [symbols.original]?: TracedFactory })[symbols.original] ?? this
|
||||
const calls = states.get(original)
|
||||
if (calls === undefined) throw new Error('factory receiver was not canonicalized')
|
||||
return calls
|
||||
}
|
||||
async createAgent(_ownerCtx: Context, options: CreateAgentOptions) {
|
||||
this.calls().push('create')
|
||||
return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() }
|
||||
}
|
||||
async resume(_ownerCtx: Context, options: ResumeAgentOptions) {
|
||||
this.calls().push('resume')
|
||||
return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() }
|
||||
}
|
||||
}
|
||||
await ctx.plugin(TracedFactory)
|
||||
const traced = (ctx as Context & { tracedFactory: TracedFactory }).tracedFactory
|
||||
ctx.agents.setFactory(traced)
|
||||
await ctx.agents.create({ agentId: AgentId('create'), sessionId: SessionId('create-s') })
|
||||
await ctx.agents.resume({ agentId: AgentId('resume'), resumeSessionId: SessionId('resume-s') })
|
||||
const raw = (traced as unknown as { [symbols.original]?: TracedFactory })[symbols.original]
|
||||
expect(states.get(raw!)).toEqual(['create', 'resume'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../core/scope"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
|
||||
20
packages/core/scope/README.md
Normal file
20
packages/core/scope/README.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# dsh-scope
|
||||
|
||||
Scoped-context registration primitive. `createScope(ctx, key)` mints a Cordis context that TAGS everything registered through it with an opaque `ScopeKey` and OWNS those registrations' lifetime (one backing fiber drives both facts); `scopeOf(ctx)` reads the tag; `scopeTarget(base, key)` builds the dispatch carrier that makes an event scope-filtered — listeners registered through a scoped context fire only for their key's subject, while plain plugin listeners keep firing for every subject. The agent loop is the one scope minter today (one scope per live agent, key = the `Agent` object — the `Agent.ctx` contract in `dsh-agent`), but the mechanism is key-agnostic so packages below the agent layer (`dsh-session`, `dsh-system-prompt`) depend on it without a dependency cycle.
|
||||
|
||||
## Public API
|
||||
|
||||
- `createScope(ctx: Context, key: ScopeKey): Scope` Mint a scope under `ctx`'s fiber. Usable synchronously (effect collection is uid-gated; service resolution falls through to the minting plugin's dependency surface). The typed, same-process key is trusted; an inactive minting context still fails through Cordis (`INACTIVE_EFFECT`).
|
||||
- `Scope.ctx` The tagged context: registrations through it are scope-visible AND scope-lifetime. Derived contexts (an `extend`, a fiber mounted under it) inherit the tag; nested scopes shadow (nearest tag wins).
|
||||
- `Scope.rawDispose` The EXACT Cordis disposer for the backing fiber — a composite (generator) effect yields THIS function to nest the scope's teardown at that yield position (Cordis dedupes nested effects by function identity; yielding a wrapper leaves the scope disposing as a concurrent sibling).
|
||||
- `Scope.dispose(): Promise<void>` Idempotent, shared quiescence boundary for every registration made through the scope. Racing/repeat calls await the same teardown, including when `rawDispose` invoked the underlying single-shot Cordis disposer first.
|
||||
- `scopeOf(ctx: Context): ScopeKey | undefined` The tag a context (or any context derived from it) carries; `undefined` = context-global.
|
||||
- `scopeTarget(base: T, key: ScopeKey | undefined): Scoped<T>` Build the opaque dispatch `thisArg` for a scope-filtered event. It composes `base`'s existing `Context.filter` with the scope predicate (untagged listener ⇒ admitted; tagged ⇒ admitted iff tag === key; `key === undefined` ⇒ untagged only). The carrier contains routing state only; the real subject is carried by the event arguments. `{ global: true }` listeners bypass filtering (Cordis semantics).
|
||||
- `Scoped<T>` The compile-time opaque carrier brand: scope-filtered events demand it as their `this` type, so dispatching with a bare subject is a compile error. The type parameter records the subject type but does not expose its properties.
|
||||
- `isScopeCarrier(value)` / `carrierKeyOf(value)` Runtime carrier marks, used by the dev invariants to assert every scope-filtered dispatch carries a carrier keyed to the subject its arguments name.
|
||||
|
||||
## Design contract
|
||||
|
||||
Ownership and visibility derive from ONE fact — which context a registration went through. An explicit `{ scope }` registration parameter could express "visible to X, disposed with Y", which is almost always a bug; the scoped context makes it unrepresentable. This is trusted registration and listener routing, not sandboxing or an authority hierarchy: a same-process plugin is not confined, and a child scope need not be a subset of its parent's view. Rationale, alternatives, and the security non-goal: [the agent-scope RFC](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals).
|
||||
|
||||
Handing out a scoped context hands out the minting plugin's service-resolution surface (resolution walks the minting fiber's dependency chain, not the holder's) — mint it from the plugin whose dependencies the scoped registrations need to resolve.
|
||||
30
packages/core/scope/package.json
Normal file
30
packages/core/scope/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-scope",
|
||||
"description": "Scoped-context registration primitive (scope tags, scope-filtered event dispatch) for the DeepSeek Harness",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
116
packages/core/scope/src/index.ts
Normal file
116
packages/core/scope/src/index.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Scoped-context primitive: mint a Cordis context that tags registrations with
|
||||
* an opaque identity and build routing-only event carriers for that identity.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-scope
|
||||
*/
|
||||
|
||||
import type { Context, Fiber } from 'cordis'
|
||||
import { Context as CordisContext } from 'cordis'
|
||||
|
||||
/** An opaque, identity-compared scope key. */
|
||||
export type ScopeKey = object
|
||||
|
||||
/** Context tag written by {@link createScope}. */
|
||||
const kScope = Symbol('dsh.scope')
|
||||
|
||||
declare const ScopedBrand: unique symbol
|
||||
|
||||
/**
|
||||
* A routing-only event receiver built by {@link scopeTarget}. The type
|
||||
* parameter records the subject type for dispatch checking; the carrier does
|
||||
* not expose the subject's properties. Event payloads carry the real subject.
|
||||
*/
|
||||
export type Scoped<T extends object> = object & { readonly [ScopedBrand]: T }
|
||||
|
||||
/** The key associated with each carrier. Presence distinguishes an unkeyed carrier from a non-carrier. */
|
||||
const carrierKeys = new WeakMap<object, ScopeKey | undefined>()
|
||||
|
||||
/** A minted registration scope and its quiescent disposal boundaries. */
|
||||
export interface Scope {
|
||||
/** Context through which scope-owned registrations are made. */
|
||||
ctx: Context
|
||||
/** Exact Cordis disposer, used when nesting this scope in an ordered composite effect. */
|
||||
rawDispose: () => Promise<void> | void
|
||||
/** Dispose every scope-owned registration; racing calls await the same completion. */
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
|
||||
/** Follow a Cordis fiber through asynchronous teardown even if its raw disposer was already claimed. */
|
||||
async function quiesceFiber(fiber: Fiber): Promise<void> {
|
||||
await Promise.resolve(fiber.dispose())
|
||||
while (fiber.inertia !== undefined) await fiber.inertia
|
||||
}
|
||||
|
||||
/** Shared no-op plugin used as the backing scope fiber. */
|
||||
function scope(): void {}
|
||||
|
||||
/**
|
||||
* Mint a scope under `ctx`. The scoped context inherits the minting plugin's
|
||||
* dependency surface and owns every registration made through it.
|
||||
* @param ctx - active context whose dependency surface the scope inherits.
|
||||
* @param key - opaque identity used for listener routing.
|
||||
* @returns the scoped context and exact/shared disposal boundaries.
|
||||
*/
|
||||
export function createScope(ctx: Context, key: ScopeKey): Scope {
|
||||
const fiber = ctx.plugin(scope)
|
||||
const scoped: Context = fiber.ctx.extend({ [kScope]: key })
|
||||
let disposing: Promise<void> | undefined
|
||||
return {
|
||||
ctx: scoped,
|
||||
rawDispose: fiber.dispose,
|
||||
dispose: () => (disposing ??= quiesceFiber(fiber)),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the nearest scope tag inherited by a context.
|
||||
* @param ctx - context to inspect.
|
||||
* @returns its scope key, or `undefined` for an unscoped context.
|
||||
*/
|
||||
export function scopeOf(ctx: Context): ScopeKey | undefined {
|
||||
return (ctx as Context & { [kScope]?: ScopeKey })[kScope]
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the routing receiver for a scope-filtered event. Untagged listeners
|
||||
* remain global; tagged listeners run only when their key matches. A base
|
||||
* Cordis filter is composed before the scope predicate.
|
||||
*
|
||||
* The receiver is deliberately opaque: listener code obtains the real subject
|
||||
* from event arguments, never from `this`.
|
||||
* @param base - subject or service whose existing Cordis filter is preserved.
|
||||
* @param key - routed scope identity, or `undefined` for an unscoped subject.
|
||||
* @returns an opaque dispatch carrier.
|
||||
*/
|
||||
export function scopeTarget<T extends object>(base: T, key: ScopeKey | undefined): Scoped<T> {
|
||||
const baseFilter = (base as { [CordisContext.filter]?: (ctx: Context) => boolean })[CordisContext.filter]
|
||||
const carrier = {
|
||||
[CordisContext.filter](ctx: Context): boolean {
|
||||
if (baseFilter !== undefined && !baseFilter.call(base, ctx)) return false
|
||||
const tag = scopeOf(ctx)
|
||||
return tag === undefined || tag === key
|
||||
},
|
||||
}
|
||||
carrierKeys.set(carrier, key)
|
||||
return carrier as unknown as Scoped<T>
|
||||
}
|
||||
|
||||
/**
|
||||
* Test whether a value is a scope carrier.
|
||||
* @param value - dispatch receiver to inspect.
|
||||
* @returns whether {@link scopeTarget} created it.
|
||||
*/
|
||||
export function isScopeCarrier(value: unknown): value is Scoped<object> {
|
||||
return typeof value === 'object' && value !== null && carrierKeys.has(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a carrier's routing key.
|
||||
* @param value - dispatch receiver to inspect.
|
||||
* @returns the carrier key, or `undefined` for an unkeyed/non-carrier value.
|
||||
*/
|
||||
export function carrierKeyOf(value: unknown): ScopeKey | undefined {
|
||||
if (!isScopeCarrier(value)) return undefined
|
||||
return carrierKeys.get(value)
|
||||
}
|
||||
155
packages/core/scope/tests/scope.spec.ts
Normal file
155
packages/core/scope/tests/scope.spec.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { describe, expect, expectTypeOf, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { carrierKeyOf, createScope, isScopeCarrier, scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scope, Scoped } from '@deepseek-ai/dsh-scope'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Events {
|
||||
/**
|
||||
* Test-only event for scope-filtered dispatch.
|
||||
* @param value - opaque payload recorded by listeners.
|
||||
* @mode emit
|
||||
*/
|
||||
'scope-test/ping'(value: string): void
|
||||
}
|
||||
}
|
||||
|
||||
/** Mount a host plugin and mint a scope inside it. */
|
||||
async function mintScope(ctx: Context, key: object): Promise<Scope> {
|
||||
let scope!: Scope
|
||||
await ctx.plugin((inner: Context) => { scope = createScope(inner, key) })
|
||||
return scope
|
||||
}
|
||||
|
||||
describe('createScope', () => {
|
||||
it('tags contexts and derived contexts, with the nearest tag winning', async () => {
|
||||
const ctx = new Context()
|
||||
const outerKey = { name: 'outer' }
|
||||
const innerKey = { name: 'inner' }
|
||||
const outer = await mintScope(ctx, outerKey)
|
||||
const inner = createScope(outer.ctx, innerKey)
|
||||
|
||||
expect(scopeOf(ctx)).toBeUndefined()
|
||||
expect(scopeOf(outer.ctx)).toBe(outerKey)
|
||||
expect(scopeOf(outer.ctx.extend({}))).toBe(outerKey)
|
||||
expect(scopeOf(inner.ctx)).toBe(innerKey)
|
||||
|
||||
await inner.dispose()
|
||||
await outer.dispose()
|
||||
})
|
||||
|
||||
it('is usable synchronously before the backing fiber activates', async () => {
|
||||
const ctx = new Context()
|
||||
const events: string[] = []
|
||||
let scope!: Scope
|
||||
await ctx.plugin((inner: Context) => {
|
||||
scope = createScope(inner, { name: 'sync' })
|
||||
scope.ctx.effect(() => () => void events.push('disposed'))
|
||||
events.push('registered')
|
||||
})
|
||||
expect(events).toEqual(['registered'])
|
||||
await scope.dispose()
|
||||
expect(events).toEqual(['registered', 'disposed'])
|
||||
})
|
||||
|
||||
it('shares quiescence across repeat and raw-disposer-first calls', async () => {
|
||||
const ctx = new Context()
|
||||
const scope = await mintScope(ctx, { name: 'quiescence' })
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
let finished = false
|
||||
scope.ctx.effect(() => async () => {
|
||||
await gate.promise
|
||||
finished = true
|
||||
})
|
||||
|
||||
const raw = Promise.resolve(scope.rawDispose())
|
||||
const publicDispose = scope.dispose()
|
||||
await Promise.resolve()
|
||||
expect(finished).toBe(false)
|
||||
gate.resolve(undefined)
|
||||
await Promise.all([raw, publicDispose, scope.dispose()])
|
||||
expect(finished).toBe(true)
|
||||
})
|
||||
|
||||
it('exposes the exact raw disposer for ordered composite teardown', async () => {
|
||||
const ctx = new Context()
|
||||
const order: string[] = []
|
||||
let dispose!: () => Promise<void> | void
|
||||
await ctx.plugin((inner: Context) => {
|
||||
dispose = inner.effect(function* () {
|
||||
yield () => void order.push('outer')
|
||||
const scope = createScope(inner, { name: 'nested' })
|
||||
scope.ctx.effect(() => () => void order.push('scope'))
|
||||
yield scope.rawDispose
|
||||
yield () => void order.push('inner')
|
||||
})
|
||||
})
|
||||
await dispose()
|
||||
expect(order).toEqual(['inner', 'scope', 'outer'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('scopeTarget', () => {
|
||||
it('routes scoped listeners by key while untagged listeners remain global', async () => {
|
||||
const ctx = new Context()
|
||||
const keyA = { name: 'A' }
|
||||
const keyB = { name: 'B' }
|
||||
const scopeA = await mintScope(ctx, keyA)
|
||||
const scopeB = await mintScope(ctx, keyB)
|
||||
const heard: string[] = []
|
||||
ctx.on('scope-test/ping', value => void heard.push(`global:${value}`))
|
||||
scopeA.ctx.on('scope-test/ping', value => void heard.push(`A:${value}`))
|
||||
scopeB.ctx.on('scope-test/ping', value => void heard.push(`B:${value}`))
|
||||
|
||||
ctx.emit(scopeTarget(ctx, keyA), 'scope-test/ping', 'a')
|
||||
ctx.emit(scopeTarget(ctx, keyB), 'scope-test/ping', 'b')
|
||||
ctx.emit(scopeTarget(ctx, undefined), 'scope-test/ping', 'none')
|
||||
|
||||
expect(heard).toEqual(['global:a', 'A:a', 'global:b', 'B:b', 'global:none'])
|
||||
await Promise.all([scopeA.dispose(), scopeB.dispose()])
|
||||
})
|
||||
|
||||
it('preserves a base Cordis filter and its receiver', async () => {
|
||||
const ctx = new Context()
|
||||
const key = { name: 'A' }
|
||||
const scope = await mintScope(ctx, key)
|
||||
const heard: string[] = []
|
||||
ctx.on('scope-test/ping', value => void heard.push(`global:${value}`))
|
||||
scope.ctx.on('scope-test/ping', value => void heard.push(`A:${value}`))
|
||||
let receiverMatches = false
|
||||
const base = {
|
||||
[Context.filter](this: object): boolean {
|
||||
receiverMatches = this === base
|
||||
return false
|
||||
},
|
||||
}
|
||||
|
||||
ctx.emit(scopeTarget(base, key), 'scope-test/ping', 'vetoed')
|
||||
expect(heard).toEqual([])
|
||||
expect(receiverMatches).toBe(true)
|
||||
await scope.dispose()
|
||||
})
|
||||
|
||||
it('{ global: true } listeners retain Cordis global-listener semantics', async () => {
|
||||
const ctx = new Context()
|
||||
const scope = await mintScope(ctx, { name: 'A' })
|
||||
const heard: string[] = []
|
||||
scope.ctx.on('scope-test/ping', value => void heard.push(value), { global: true })
|
||||
ctx.emit(scopeTarget(ctx, { name: 'other' }), 'scope-test/ping', 'foreign')
|
||||
ctx.emit(scopeTarget(ctx, undefined), 'scope-test/ping', 'none')
|
||||
expect(heard).toEqual(['foreign', 'none'])
|
||||
await scope.dispose()
|
||||
})
|
||||
|
||||
it('uses an opaque branded carrier with a separately tracked key', () => {
|
||||
const key = { name: 'key' }
|
||||
const subject = { value: 1 }
|
||||
const carrier = scopeTarget(subject, key)
|
||||
expect(isScopeCarrier(carrier)).toBe(true)
|
||||
expect(carrierKeyOf(carrier)).toBe(key)
|
||||
expect(isScopeCarrier(subject)).toBe(false)
|
||||
expect(carrierKeyOf(subject)).toBeUndefined()
|
||||
expect('value' in carrier).toBe(false)
|
||||
expectTypeOf(carrier).toEqualTypeOf<Scoped<typeof subject>>()
|
||||
})
|
||||
})
|
||||
18
packages/core/scope/tsconfig.json
Normal file
18
packages/core/scope/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -4,43 +4,45 @@ Event-sourced session log and in-memory store. A `Session` is the append-only so
|
||||
|
||||
## Service: `SessionStore` (ctx key: `sessions`)
|
||||
|
||||
Creates and holds event-sourced `Session` instances. Persistence is intentionally not implemented here — plugins subscribe to `session/event` and flush on `session/flush`.
|
||||
Creates and holds event-sourced `Session` instances. Persistence is intentionally not implemented here — plugins subscribe to `session/event`, flush on `session/flush`, and may mirror the paired `session/created`/`session/disposed` lifecycle.
|
||||
|
||||
### Public API
|
||||
|
||||
- `ctx.sessions.create(id?: SessionId, options?: { seed?: SessionEvent[]; meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number; seedLength?: number } }): Session` — Create a session. `options.seed` replays/forks an existing event log; `options.meta` attaches creation metadata (validated absolute `cwd`, `parentSession` lineage, seed boundary) as the immutable `SessionHeader`. The store fills `version`/`id` and defaults `createdAt` to now; a caller reconstructing a persisted session passes the original `createdAt` and persisted `seedLength` to preserve them. Disposed with the calling fiber.
|
||||
- `ctx.sessions.create(id?: SessionId, options?: { seed?: readonly SessionEvent[]; meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number; seedLength?: number } }): Session` — Create a session. The persistence/replay seed and resulting header are validated, detached, and deep-frozen at this durable boundary. The store fills `version`/`id` and defaults `createdAt` to now; a persisted reconstruction supplies the original `createdAt` and `seedLength`. Disposed with the calling fiber.
|
||||
- `ctx.sessions.flush(session: Session): Promise<void>` Dispatch the awaited `session/flush` durability checkpoint with the carrier captured at enter — THE flush entry point (the loop's turn-end checkpoint and idle injection call it; never dispatch a raw `ctx.parallel`). Every captured listener starts, the call waits for all of them to settle, and a failure rejects only after the other listeners finish. Rejects a prepared, detached, or stale same-id object instead of inventing a subject-less carrier.
|
||||
- `ctx.sessions.fork(source, boundary?, childSessionId?): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that boundary to be `turn/end`, and create a live child session with lineage metadata.
|
||||
- `ctx.sessions.get(id: SessionId): Session | undefined`
|
||||
- `ctx.sessions.list(): Session[]`
|
||||
|
||||
#### Advanced: ordered-teardown lifecycle primitives
|
||||
|
||||
`create()` covers the common case (the session is owned by the calling fiber). When a session must be torn down **in order with another resource** — so a final flush is captured before `onAppend` detaches — `create()`'s self-contained effect is wrong, because a fiber unload disposes sibling effects *concurrently*. For that, split the lifecycle and fold it into the owner's single effect:
|
||||
`create()` covers the common case (the session is owned by the calling fiber). When a session must be torn down **in order with another resource** — so a final flush is captured before the store attachment and publication hooks are removed — `create()`'s self-contained effect is wrong, because a fiber unload disposes sibling effects *concurrently*. For that, split the lifecycle and fold it into the owner's single effect:
|
||||
|
||||
- `ctx.sessions.prepare(id?, options?): Session` — validate the id/cwd and construct the `Session`, WITHOUT entering it into the store. Same options as `create`.
|
||||
- `ctx.sessions.enter(session): () => void` — wire `onAppend` → `session/event` and add the session to the store; returns the DETACH disposer. Does NOT emit `session/created` (the caller yields the disposer first, then calls `announce`, so a throwing listener rolls the attach back). The id was already validated by `prepare`, which runs in the same synchronous sequence, so `enter` does not re-check.
|
||||
- `ctx.sessions.announce(session): void` — emit `session/created` for an entered session.
|
||||
- `ctx.sessions.prepare(id?, options?): Session` — validate durable seed/header data and construct the `Session` WITHOUT entering it into the store. Same options as `create`.
|
||||
- `ctx.sessions.enter(session): () => void` — perform the authoritative ID collision check, install append publication state, and insert the exact session without announcing it. Returns an idempotent detach bound to the captured entry object, so a stale disposer cannot remove a later same-ID replacement. Concurrent same-ID preparation is allowed; only one final entry succeeds.
|
||||
- `ctx.sessions.announce(session): void` — begin the one allowed `session/created` announcement for an entered session; repeat and reentrant calls reject before dispatch. A detach requested synchronously by a creation listener is deferred until that dispatch unwinds, so another creation listener cannot observe `session/disposed` before its own `session/created` callback. Detach emits `session/disposed` exactly once, including rollback after a partially delivered creation notification; a never-announced entry emits neither edge.
|
||||
|
||||
`dsh-agent-loop`'s `AgentLoop.start` is the canonical consumer: it yields `enter`'s detach disposer, the registry unregister, and the loop-stop disposer into ONE composite effect, so teardown stops + awaits the loop (final flush captured) BEFORE detaching the session — whether the trigger is the `AgentHandle`'s `dispose()` or a fiber unload.
|
||||
`dsh-agent-loop` is the canonical consumer: after unpublished agent setup it enters both session and agent before announcing either, then nests loop stop, agent removal, session detach, and scope unwind in one ordered lifecycle. The final flush therefore settles before this package detaches the session, whether teardown starts from an `AgentHandle` or owner-fiber unload.
|
||||
|
||||
### Events
|
||||
### Live service events
|
||||
|
||||
| Event | Mode | Purpose |
|
||||
|---|---|---|
|
||||
| `session/created` | emit | A session was created |
|
||||
| `session/event` | emit | An event was appended (sync, fire-and-forget) |
|
||||
| `session/flush` | parallel | Awaited durability checkpoint (persistence plugins drain buffers here) |
|
||||
The store pairs announced creation with disposal, publishes each append, and provides an awaited durability checkpoint. Before the log push it resolves the scoped `session/event` callback list. The push is the commit point; callback throws or returned-promise rejections are logged and contained per observer. A committed append therefore returns normally, later observers still run, and detach waits until publication unwinds. Exact `session/*` signatures, modes, and scope-carrier behavior live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md); the append-only payload vocabulary is separately generated into the [persistence catalog](../../../docs/persistence-catalog.md).
|
||||
|
||||
### Class: `Session`
|
||||
|
||||
Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
|
||||
|
||||
- `session.append(type, data, opts?): SessionEvent` — synchronous, never blocks on I/O. **Throws** if `data` is not losslessly JSON-serializable (BigInt, function, symbol, undefined, non-finite number, circular ref, or an exotic object like Map/Set/Date) — the event log is the durable source of truth, so this invariant is enforced at the source (exported as `isJsonValue` for backends to reuse on their replay/fork entry points). A third parameter `opts: SurfaceIntent` carries surface metadata: `surfaceOp` controls how the event enters the surface linked list, and `sourceEventSeqs` records provenance (the seq numbers of events this one derives from). It is **required** for the five `SurfaceEventType` events (every message-producing event must declare how it joins the surface) and rejected by the compiler for non-surface types. The marker requirement is enforced two ways: the typed overload makes `opts` mandatory when `type` is a specific `SurfaceEventType` literal, AND `append` **throws** at runtime if a surface-eligible event arrives with no `surfaceOp` — covering the case where `type` widens to the `SessionEventType` union (a caller iterating raw events, where the conditional overload collapses to optional) so a marker-less message event can never silently land in the log and vanish from `deriveMessages()`.
|
||||
- `session.deriveMessages(): Message[]` — the LLM message history, CACHED: each surface node is projected exactly once, when first seen (O(new nodes) per call; a surface rewrite rebuilds via `surface.replaceGeneration`). Returns a fresh array snapshot per call over SHARED, deep-frozen `Message` objects — cloned once off the log at projection time, so a consumer can never mutate logged data (mutation throws). The surface is the single source of derived history — there is no raw-log fallback.
|
||||
- `session.deriveEventMessage(event): Message | null` — the per-event projection `deriveMessages()` folds: one event's derived message (an unfrozen clone), or `null` when it produces none (a non-surface event, or an empty-content `assistant/message` hosting only usage). External reconstructors and the dev invariant fold the same function over a log prefix's surface, so no two paths can disagree about what a request's messages were (the reconstructability RFC).
|
||||
- `session.append(type, data, opts?): SessionEvent` — synchronous, never blocks on I/O. At this durable boundary, data and surface metadata are lossless-JSON snapshotted and deep-frozen. For an attached session, a reentrant append during dispatch/observer publication rejects, and detach waits for that publication to unwind. Callbacks resolve before the log push; the push is the commit point, after which each observer failure is contained independently. Runtime surface validation covers widened unions and raw seed/load logs.
|
||||
- `session.deriveMessages(): Message[]` — the LLM message history, CACHED: each surface node is projected exactly once, when first seen (O(new nodes) per call; a surface rewrite rebuilds via `surface.replaceGeneration`). Returns a fresh array per call over shared, deep-frozen `Message` objects. Each projection reuses the already deep-frozen content in its durable log event, so no second deep clone is needed and a consumer still cannot mutate logged data. The surface is the single source of derived history — there is no raw-log fallback.
|
||||
- `session.deriveEventMessage(event): Message | null` — the per-event projection `deriveMessages()` folds: a fresh message wrapper that reuses the event's already frozen content, or `null` when the event produces none (a non-surface event, or an empty-content `assistant/message` hosting only usage). External reconstructors and the dev invariant fold the same function over a log prefix's surface, so no two paths can disagree about what a request's messages were (the reconstructability RFC).
|
||||
- `session.surface: SurfaceManager` — the derived surface, lazily rebuilt from `surfaceOp` markers in the log. Processes only new events (delta) on each access — the log is append-only, so prior events never change. `surface.replaceGeneration` is the rewrite signal: bumped by every folded `replace` and by `invalidate()`, never reset, so an incremental consumer comparing generations cannot be fooled.
|
||||
- `session.events`, `session.seq`, `session.id`
|
||||
- `session.header: SessionHeader` — immutable creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`). Kept out of the event log (a storage concern, not replayable state); a minimal header (stamped with the current `SESSION_FORMAT_VERSION`) is synthesized for bare `Session` construction.
|
||||
- `session.events` — a cached, frozen array snapshot over deep-frozen events. Repeated reads without an append return the same array; an append invalidates the cache and the next read returns a new snapshot, while earlier snapshots stay unchanged. Neither a cast nor a retained reference can push into the live log or rewrite an accepted event.
|
||||
- `session.seq`, `session.id` — current sequence and readonly typed identity.
|
||||
- `session.header: SessionHeader` — detached, deep-frozen creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`). Construction validates the durable record and requires its id to match `session.id`.
|
||||
|
||||
### Lossless JSON utilities
|
||||
|
||||
Durable values need one accepted representation, not a check followed by a second read. `isJsonValue(value)` is the boolean predicate; `snapshotJsonValue(value)` recursively validates and copies a plain value in one pass, returning `undefined` for invalid input and propagating a throwing getter. The snapshot helper accepts finite JSON numbers except `-0` (JSON rewrites it to `0`), dense ordinary arrays, and plain or null-prototype objects; it rejects cycles, unsupported scalars, and exotic prototypes before normalization.
|
||||
|
||||
### Surface types
|
||||
|
||||
@@ -70,12 +72,12 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata)
|
||||
|
||||
### Metadata types (`types.ts`)
|
||||
|
||||
- `SessionHeader` — immutable session metadata, written once: `{ version, id, createdAt, cwd?, parentSession?, seedLength? }`. Owned here (beside `SessionId`) because `Session.header` is typed by it; persistence backends re-export it rather than own it (which would force a package cycle).
|
||||
- `SessionHeader` — session metadata written once when published as `Session.header`, where detachment and deep-freezing enforce immutability at runtime: `{ version, id, createdAt, cwd?, parentSession?, seedLength? }`. Persistence loaders may return mutable detached copies of the same data type. Owned here (beside `SessionId`) because `Session.header` is typed by it; persistence backends re-export it rather than own it (which would force a package cycle).
|
||||
|
||||
### Extension points
|
||||
|
||||
- Persistence plugins: subscribe to `session/event` (write-behind) and drain on `session/flush` (awaited) and fiber dispose. A durable backend reads the log and reloads it into a live session; the metadata seam (`SessionHeader`, `session.header`) is what such a backend stores beside the log.
|
||||
- Replay/fork: `ctx.sessions.create(id, { seed })` seeds a new session with an existing event log. The surface rebuilds deterministically from `surfaceOp` markers in the seeded events. The seed is validated to the SAME always-on invariants `append` enforces — contiguous seqs, JSON-serializable data, and required `surfaceOp` markers on surface-eligible events — so marker-less message events are rejected at construction rather than silently vanishing from `deriveMessages()`. Broader turn-enclosure checks stay in `dsh-invariants` and persistence repair. Ordinary live-session forks use `ctx.sessions.fork(source, boundary?, childSessionId?)`, where `boundary` is the inclusive source event seq to fork through.
|
||||
- Replay/fork: `ctx.sessions.create(id, { seed })` seeds a new session with an existing event log. The surface rebuilds deterministically from `surfaceOp` markers in the seeded events. The constructor reads each seed entry once and uses the same one-pass lossless-JSON snapshot and exact surface-metadata shape checks as `append`, then enforces contiguous seqs and deep-freezes every accepted record; a stateful caller, exotic nested value, marker-less or malformed surface event, metadata on a non-surface event, or retained seed reference therefore cannot silently change the reconstructed history. Broader turn-enclosure checks stay in `dsh-invariants` and persistence repair. Ordinary live-session forks use `ctx.sessions.fork(source, boundary?, childSessionId?)`, where `boundary` is the inclusive source event seq to fork through.
|
||||
- Compaction: the `dsh-compact-basic` plugin appends a `user/message` with `surfaceOp: { op: 'replace', start, end }` to shadow old surface nodes behind a summary checkpoint.
|
||||
|
||||
### What is NOT here (TODO)
|
||||
|
||||
@@ -24,11 +24,13 @@
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-scope": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,15 +9,17 @@
|
||||
import { Context, Service } from 'cordis'
|
||||
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 { SESSION_FORMAT_VERSION, SessionId } from './types.ts'
|
||||
import type { CreateSessionOptions, EpochHeader, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
|
||||
import { isJsonValue } from './json.ts'
|
||||
import { snapshotJsonValue } from './json.ts'
|
||||
import { SurfaceManager, isSurfaceEligibleType } from './surface.ts'
|
||||
import { foldRequestHeader } from './request-header.ts'
|
||||
|
||||
export * from './types.ts'
|
||||
export { isJsonValue } from './json.ts'
|
||||
export { isJsonValue, snapshotJsonValue } from './json.ts'
|
||||
export type { JsonValue } from './json.ts'
|
||||
export { interruptedTurnClosers } from './repair.ts'
|
||||
export type { SurfaceFoldReplacement, SurfaceFoldResult, SurfaceNode } from './surface.ts'
|
||||
@@ -32,29 +34,71 @@ declare module 'cordis' {
|
||||
|
||||
interface Events {
|
||||
/**
|
||||
* A session was created in the store.
|
||||
* 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.
|
||||
* @param session - the session just entered and announced.
|
||||
* @mode emit
|
||||
*/
|
||||
'session/created'(session: Session): void
|
||||
'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.
|
||||
* @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 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.
|
||||
* @param session - the session whose log grew.
|
||||
* @param event - the appended event, exactly as recorded.
|
||||
* @mode emit
|
||||
*/
|
||||
'session/event'(session: Session, event: SessionEvent): void
|
||||
'session/event'(this: Scoped<Session>, session: Session, event: SessionEvent): void
|
||||
/**
|
||||
* Awaited durability checkpoint. The agent loop awaits
|
||||
* `ctx.parallel('session/flush', session)` at every turn end; persistence
|
||||
* `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 loop waits for all of them, but none can veto.
|
||||
* 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.
|
||||
* @param session - the session whose buffered events must reach durable storage.
|
||||
* @mode parallel
|
||||
*/
|
||||
'session/flush'(session: Session): Promise<void> | void
|
||||
'session/flush'(this: Scoped<Session>, session: Session): Promise<void> | void
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +121,137 @@ function renderTagged(tag: string, content: ContentBlock[], source: MessageSourc
|
||||
]
|
||||
}
|
||||
|
||||
/** Detach, validate, and freeze the creation metadata published by a session. */
|
||||
function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHeader {
|
||||
const input: unknown = source === undefined
|
||||
? { version: SESSION_FORMAT_VERSION, id, createdAt: Date.now() }
|
||||
: source
|
||||
const snapshot = snapshotJsonValue(input)
|
||||
if (snapshot === undefined) throw new Error('session header is not losslessly JSON-serializable')
|
||||
if (snapshot === null || typeof snapshot !== 'object' || Array.isArray(snapshot)) {
|
||||
throw new Error('session header is not a plain JSON record')
|
||||
}
|
||||
const record = snapshot as Record<string, unknown>
|
||||
if (record.version !== SESSION_FORMAT_VERSION) {
|
||||
throw new Error(`session header version must be ${SESSION_FORMAT_VERSION}, got ${String(record.version)}`)
|
||||
}
|
||||
if (record.id !== id) {
|
||||
throw new Error(`session header id "${String(record.id)}" does not match session id "${id}"`)
|
||||
}
|
||||
if (typeof record.createdAt !== 'number' || !Number.isFinite(record.createdAt)) {
|
||||
throw new Error('session header createdAt must be a finite number')
|
||||
}
|
||||
if (record.cwd !== undefined) {
|
||||
if (typeof record.cwd !== 'string') throw new Error('session header cwd must be a string')
|
||||
if (!isAbsolute(record.cwd)) {
|
||||
throw new Error(`session header cwd must be an absolute path, got "${record.cwd}"`)
|
||||
}
|
||||
}
|
||||
if (record.parentSession !== undefined && typeof record.parentSession !== 'string') {
|
||||
throw new Error('session header parentSession must be a string')
|
||||
}
|
||||
if (record.seedLength !== undefined
|
||||
&& (typeof record.seedLength !== 'number' || !Number.isSafeInteger(record.seedLength) || record.seedLength < 0)) {
|
||||
throw new Error('session header seedLength must be a non-negative safe integer')
|
||||
}
|
||||
return deepFreeze(record as unknown as SessionHeader)
|
||||
}
|
||||
|
||||
/** Validate the runtime shape of surface metadata after its JSON snapshot. */
|
||||
function assertSurfaceMetadataShape(
|
||||
type: string,
|
||||
surfaceOp: unknown,
|
||||
sourceEventSeqs: unknown,
|
||||
): void {
|
||||
const eligible = isSurfaceEligibleType(type)
|
||||
if (!eligible) {
|
||||
if (surfaceOp !== undefined || sourceEventSeqs !== undefined) {
|
||||
throw new Error(`session event "${type}" is not surface-eligible and cannot carry surface metadata`)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (surfaceOp === undefined) {
|
||||
throw new Error(`session event "${type}" is surface-eligible and requires a surfaceOp marker`)
|
||||
}
|
||||
if (surfaceOp !== 'append') {
|
||||
if (surfaceOp === null || typeof surfaceOp !== 'object' || Array.isArray(surfaceOp)) {
|
||||
throw new Error(`session event "${type}" carries an invalid surfaceOp`)
|
||||
}
|
||||
const op = surfaceOp as Record<string, unknown>
|
||||
const keys = Object.keys(op)
|
||||
if (keys.length !== 3 || !Object.hasOwn(op, 'op') || !Object.hasOwn(op, 'start') || !Object.hasOwn(op, 'end')
|
||||
|| op['op'] !== 'replace'
|
||||
|| typeof op['start'] !== 'number' || !Number.isSafeInteger(op['start']) || op['start'] < 0
|
||||
|| typeof op['end'] !== 'number' || !Number.isSafeInteger(op['end']) || op['end'] < 0) {
|
||||
throw new Error(`session event "${type}" carries an invalid replace surfaceOp`)
|
||||
}
|
||||
}
|
||||
if (sourceEventSeqs !== undefined) {
|
||||
if (!Array.isArray(sourceEventSeqs)
|
||||
|| sourceEventSeqs.some(seq => typeof seq !== 'number' || !Number.isSafeInteger(seq) || seq < 0)) {
|
||||
throw new Error(`session event "${type}" sourceEventSeqs must contain non-negative safe integers`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate the fixed event envelope after one-pass JSON materialization. */
|
||||
function assertSessionEventEnvelope(value: Record<string, unknown>, index: number): asserts value is SessionEvent {
|
||||
const event = value
|
||||
const allowed = new Set(['type', 'seq', 'time', 'data', 'surfaceOp', 'sourceEventSeqs'])
|
||||
if (Object.keys(event).some(key => !allowed.has(key))
|
||||
|| !Object.hasOwn(event, 'type') || typeof event['type'] !== 'string'
|
||||
|| !Object.hasOwn(event, 'seq') || typeof event['seq'] !== 'number'
|
||||
|| !Number.isSafeInteger(event['seq']) || event['seq'] < 0
|
||||
|| !Object.hasOwn(event, 'time') || typeof event['time'] !== 'number'
|
||||
|| !Number.isSafeInteger(event['time']) || event['time'] < 0
|
||||
|| !Object.hasOwn(event, 'data')) {
|
||||
throw new Error(`seed event at index ${index} has an invalid event envelope`)
|
||||
}
|
||||
}
|
||||
|
||||
type SessionCallback = (...args: unknown[]) => unknown
|
||||
|
||||
/** Resolve one listener snapshot, including Cordis's internal dispatch checks. */
|
||||
function collectSessionCallbacks(ctx: Context, args: unknown[]): SessionCallback[] {
|
||||
return [...ctx.events.dispatch('emit', args)] as SessionCallback[]
|
||||
}
|
||||
|
||||
/** Invoke one resolved observe-only listener snapshot with per-listener containment. */
|
||||
function invokeContainedSessionObservers(
|
||||
ctx: Context,
|
||||
name: 'session/event' | 'session/disposed',
|
||||
id: SessionId,
|
||||
args: unknown[],
|
||||
callbacks: SessionCallback[],
|
||||
): void {
|
||||
for (const callback of callbacks) {
|
||||
try {
|
||||
const returned: unknown = callback(...args)
|
||||
void Promise.resolve(returned).catch((error: unknown) => {
|
||||
ctx.logger.warn(`session "${id}": ${name} listener rejected: ${String(error)}`)
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
ctx.logger.warn(`session "${id}": ${name} listener threw: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** All mutable lifecycle state for one exact store entry. */
|
||||
interface SessionEntry {
|
||||
readonly id: SessionId
|
||||
readonly session: Session
|
||||
readonly carrier: Scoped<Session>
|
||||
readonly emitCtx: Context
|
||||
announced: boolean
|
||||
announcing: boolean
|
||||
appending: boolean
|
||||
detachRequested: boolean
|
||||
detach(): void
|
||||
}
|
||||
|
||||
/** Store attachment for the append path; module-private to keep Session store-agnostic publicly. */
|
||||
const attachments = new WeakMap<Session, SessionEntry>()
|
||||
|
||||
/**
|
||||
* An event-sourced session: an append-only log of {@link SessionEvent}s.
|
||||
*
|
||||
@@ -85,8 +260,6 @@ function renderTagged(tag: string, content: ContentBlock[], source: MessageSourc
|
||||
*/
|
||||
export class Session {
|
||||
private log: SessionEvent[] = []
|
||||
/** Set by the store so appends are observable; undefined when detached. */
|
||||
onAppend: ((event: SessionEvent) => void) | undefined
|
||||
|
||||
/**
|
||||
* Derived surface — a cached linked list of message-producing events.
|
||||
@@ -104,16 +277,16 @@ export class Session {
|
||||
}
|
||||
|
||||
/**
|
||||
* Immutable creation metadata (format version, cwd, lineage, seed boundary).
|
||||
* Supplied by the store via `ctx.sessions.create()`. When a `Session` is
|
||||
* constructed bare (tests, ad-hoc replay), a minimal header is synthesized
|
||||
* (stamped with the current {@link SESSION_FORMAT_VERSION}) so
|
||||
* Detached, deep-frozen creation metadata (format version, cwd, lineage,
|
||||
* seed boundary). Supplied by the store via `ctx.sessions.create()`. When a
|
||||
* `Session` is constructed bare (tests, ad-hoc replay), a minimal header is
|
||||
* synthesized (stamped with the current {@link SESSION_FORMAT_VERSION}) so
|
||||
* `session.header` is always present. Kept out of the event log — it is a
|
||||
* storage concern, not replayable conversation state.
|
||||
*/
|
||||
readonly header: SessionHeader
|
||||
|
||||
constructor(public readonly id: SessionId, seed?: SessionEvent[], header?: SessionHeader) {
|
||||
constructor(public readonly id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader) {
|
||||
if (seed) {
|
||||
// Validate the seed to the SAME invariants `append` enforces, so a
|
||||
// replay/fork (`ctx.sessions.create(id, { seed })`) cannot construct a
|
||||
@@ -122,12 +295,16 @@ export class Session {
|
||||
// `seq = log.length` contract the whole system relies on). Without this,
|
||||
// a bad seed would surface only later as a backend rejection or a silent
|
||||
// divergence between the live log and disk.
|
||||
seed.forEach((event, index) => {
|
||||
if (event.seq !== index) {
|
||||
throw new Error(`seed event at index ${index} has seq ${event.seq} (expected ${index}); seed must be contiguous from 0`)
|
||||
this.log = Array.from(seed, (source, index) => {
|
||||
// The seed is a persistence/replay boundary: validate and detach the
|
||||
// complete event in one lossless-JSON pass.
|
||||
const snapshot = snapshotJsonValue(source)
|
||||
if (snapshot === undefined) {
|
||||
throw new Error(`seed event at index ${index} is not losslessly JSON-serializable`)
|
||||
}
|
||||
if (!isJsonValue(event.data)) {
|
||||
throw new Error(`seed event "${event.type}" (seq ${event.seq}) carries non-JSON-serializable data`)
|
||||
assertSessionEventEnvelope(snapshot, index)
|
||||
if (snapshot.seq !== index) {
|
||||
throw new Error(`seed event at index ${index} has seq ${snapshot.seq} (expected ${index}); seed must be contiguous from 0`)
|
||||
}
|
||||
// Surface-eligible events MUST carry a surfaceOp marker — the surface is
|
||||
// the sole source of derived history, so a marker-less message event
|
||||
@@ -135,30 +312,30 @@ export class Session {
|
||||
// this at compile time via its typed overload; a seed arrives as raw
|
||||
// SessionEvent[] (replay/fork/load), bypassing that, so re-check at
|
||||
// runtime here rather than silently resuming with empty history.
|
||||
if (isSurfaceEligibleType(event.type)
|
||||
&& (event as SessionEvent<SurfaceEventType>).surfaceOp === undefined) {
|
||||
throw new Error(`seed event "${event.type}" (seq ${event.seq}) is surface-eligible but carries no surfaceOp marker`)
|
||||
const structural = snapshot as SessionEvent & { surfaceOp?: unknown; sourceEventSeqs?: unknown }
|
||||
try {
|
||||
assertSurfaceMetadataShape(snapshot.type, structural.surfaceOp, structural.sourceEventSeqs)
|
||||
} catch (error: unknown) {
|
||||
throw new Error(`invalid seed event at index ${index}: ${error instanceof Error ? error.message : 'invalid surface metadata'}`)
|
||||
}
|
||||
return deepFreeze(snapshot)
|
||||
})
|
||||
// Deep-clone each seed event, NOT just the array: the seed events and
|
||||
// their `data` are still owned by the caller (or the source session of a
|
||||
// fork), so keeping the references would let a post-create mutation of the
|
||||
// original rewrite this session's durable log — or reintroduce a
|
||||
// non-JSON-serializable value AFTER the validation above. Snapshotting at
|
||||
// the boundary makes `session.events` independent and keeps it equal to
|
||||
// what was validated. Serializability is guaranteed by the check above, so
|
||||
// structuredClone can never hit a non-cloneable value here.
|
||||
this.log = seed.map(event => structuredClone(event))
|
||||
}
|
||||
this.header = header ?? { version: SESSION_FORMAT_VERSION, id, createdAt: Date.now() }
|
||||
this.header = snapshotSessionHeader(id, header)
|
||||
}
|
||||
|
||||
/** Cached immutable public snapshot of the private append-only log. */
|
||||
private eventsSnapshot: readonly SessionEvent[] | undefined
|
||||
|
||||
/**
|
||||
* The append-only event log, exposed live by reference (readonly-typed, not
|
||||
* a snapshot): later appends are visible through the same array.
|
||||
* An immutable snapshot of the append-only event log. The snapshot is reused
|
||||
* until the next append; a previously returned array does not grow later.
|
||||
* Events and their nested data are deep-frozen at acceptance, so neither a
|
||||
* cast nor ordinary JavaScript can rewrite durable history.
|
||||
*/
|
||||
get events(): readonly SessionEvent[] {
|
||||
return this.log
|
||||
this.eventsSnapshot ??= Object.freeze([...this.log])
|
||||
return this.eventsSnapshot
|
||||
}
|
||||
|
||||
/** The next event's sequence number — always the log length (the `seq = log.length` contiguity contract). */
|
||||
@@ -168,8 +345,11 @@ export class Session {
|
||||
|
||||
/**
|
||||
* Append one typed event to the log and synchronously notify observers via
|
||||
* `onAppend`. The hot path never blocks on I/O — persistence plugins buffer
|
||||
* asynchronously.
|
||||
* the store-owned, module-private publication hooks. The hot path never blocks
|
||||
* on I/O — persistence plugins buffer asynchronously. Once the event enters
|
||||
* the log, the append is committed: observer failures are logged and
|
||||
* contained per listener, so they do not change the return value or prevent
|
||||
* later listeners from observing the same accepted event.
|
||||
*
|
||||
* @param type - The event type (key of {@link SessionEventMap}).
|
||||
* @param data - The event payload; must be JSON-serializable.
|
||||
@@ -183,66 +363,71 @@ export class Session {
|
||||
* @returns the logged event — its assigned `seq`/`time` plus the SNAPSHOT of
|
||||
* `data` that entered the log, so reading `event.data` back sees the logged
|
||||
* value, never the caller's still-mutable input.
|
||||
* @throws if `data` is not losslessly JSON-serializable (BigInt, function,
|
||||
* symbol, undefined, non-finite number, circular ref, or an exotic object
|
||||
* like Map/Set/Date). The event log is the durable source of truth, so this
|
||||
* invariant is enforced at the source — a bad event never enters the log,
|
||||
* keeping `session.events` always equal to what a backend can persist. The
|
||||
* throw surfaces at the buggy caller's append site, not asynchronously in a
|
||||
* backend flush.
|
||||
* @throws if `data` or surface metadata is not losslessly JSON-serializable
|
||||
* (BigInt, function, symbol, undefined, negative zero, non-finite number,
|
||||
* circular reference, sparse array, or an exotic object such as
|
||||
* Map/Set/Date/class instance). One recursive pass reads, validates, and
|
||||
* copies each nested value once, so a stateful getter cannot supply one value
|
||||
* to validation and another to storage. The event log is the durable source
|
||||
* of truth, so a bad event fails at the append site rather than later during
|
||||
* a backend flush. A synchronous internal dispatch validation failure or an
|
||||
* append reentered while this acceptance/publication boundary is open also
|
||||
* rejects before the log changes.
|
||||
*/
|
||||
append<T extends SessionEventType>(
|
||||
type: T,
|
||||
data: SessionEventMap[T],
|
||||
...opts: T extends SurfaceEventType ? [opts: SurfaceIntent] : []
|
||||
): SessionEvent<T> {
|
||||
if (!isJsonValue(data)) {
|
||||
const surfaceOpts: SurfaceIntent | undefined = opts[0]
|
||||
const surfaceMetadata = {
|
||||
...surfaceOpts?.sourceEventSeqs === undefined ? {} : { sourceEventSeqs: surfaceOpts.sourceEventSeqs },
|
||||
...surfaceOpts?.surfaceOp === undefined ? {} : { surfaceOp: surfaceOpts.surfaceOp },
|
||||
}
|
||||
const dataSnapshot = snapshotJsonValue(data)
|
||||
if (dataSnapshot === undefined) {
|
||||
throw new Error(`session event "${type}" carries non-JSON-serializable data`)
|
||||
}
|
||||
const surfaceOpts: SurfaceIntent | undefined = opts[0]
|
||||
// Surface-eligible events MUST carry a surfaceOp marker — the surface is the
|
||||
// sole source of derived history, so a marker-less message event would be
|
||||
// logged yet vanish from deriveMessages(). The typed `opts` overload makes
|
||||
// the marker mandatory only when `T` is a SPECIFIC SurfaceEventType literal;
|
||||
// when `T` widens to the SessionEventType union (a caller iterating raw
|
||||
// events: `for (const e of log) append(e.type, e.data)`), the conditional
|
||||
// rest collapses to optional and the compiler stops enforcing it. Re-check
|
||||
// at runtime so that loophole can't silently drop history.
|
||||
if (isSurfaceEligibleType(type) && surfaceOpts?.surfaceOp === undefined) {
|
||||
throw new Error(`session event "${type}" is surface-eligible and requires a surfaceOp marker`)
|
||||
const surfaceMetadataSnapshot = snapshotJsonValue(surfaceMetadata)
|
||||
if (surfaceMetadataSnapshot === undefined) {
|
||||
throw new Error(`session event "${type}" carries non-JSON-serializable surface metadata`)
|
||||
}
|
||||
// Snapshot `data` into the log, NOT the caller's reference: the validation
|
||||
// above proves it is JSON-serializable AT THIS MOMENT, but the caller still
|
||||
// owns the object and could mutate it afterwards (before a persistence
|
||||
// flush, or permanently in the in-memory history) — making `session.events`
|
||||
// diverge from the value that passed validation, or reintroducing a
|
||||
// non-serializable value. Cloning here keeps the log equal to what was
|
||||
// validated. structuredClone is safe because serializability was just
|
||||
// checked. The returned event carries the SAME snapshot, so a caller reading
|
||||
// back `event.data` sees the logged value, not its own mutable input.
|
||||
//
|
||||
// Surface metadata is snapshot separately: sourceEventSeqs (number[] —
|
||||
// primitives, so array spread is a complete copy) and surfaceOp (a string
|
||||
// primitive, or cloned if it's a replace object).
|
||||
// Build the event shape with conditional surface fields via spreading.
|
||||
// The result is cast through `unknown` because the conditional spreads
|
||||
// produce an intersection type that the assignability checker can't
|
||||
// narrow to a specific discriminated-union member when T is generic.
|
||||
// This is a safe internal boundary: data was validated above, and
|
||||
// surface metadata was snapshot from primitive/clone-safe values.
|
||||
const event = {
|
||||
assertSurfaceMetadataShape(
|
||||
type,
|
||||
seq: this.log.length,
|
||||
time: Date.now(),
|
||||
data: structuredClone(data),
|
||||
...surfaceOpts?.sourceEventSeqs !== undefined ? { sourceEventSeqs: [...surfaceOpts.sourceEventSeqs] } : {},
|
||||
...surfaceOpts?.surfaceOp !== undefined ? {
|
||||
surfaceOp: typeof surfaceOpts.surfaceOp === 'string' ? surfaceOpts.surfaceOp : structuredClone(surfaceOpts.surfaceOp),
|
||||
} : {},
|
||||
} as unknown as SessionEvent<T>
|
||||
this.log.push(event as unknown as SessionEvent)
|
||||
this.onAppend?.(event as unknown as SessionEvent)
|
||||
return event
|
||||
(surfaceMetadataSnapshot as { surfaceOp?: unknown }).surfaceOp,
|
||||
(surfaceMetadataSnapshot as { sourceEventSeqs?: unknown }).sourceEventSeqs,
|
||||
)
|
||||
|
||||
const entry = attachments.get(this)
|
||||
if (entry?.appending) {
|
||||
throw new Error('session append cannot reenter while another append is being published')
|
||||
}
|
||||
if (entry !== undefined) entry.appending = true
|
||||
try {
|
||||
const event = deepFreeze({
|
||||
type,
|
||||
seq: this.log.length,
|
||||
time: Date.now(),
|
||||
data: dataSnapshot,
|
||||
...surfaceMetadataSnapshot,
|
||||
} as unknown as SessionEvent<T>)
|
||||
let callbacks: SessionCallback[] | undefined
|
||||
const callbackArgs: unknown[] = [this, event]
|
||||
if (entry !== undefined) {
|
||||
callbacks = collectSessionCallbacks(entry.emitCtx, [entry.carrier, 'session/event', ...callbackArgs])
|
||||
}
|
||||
this.log.push(event as SessionEvent)
|
||||
this.eventsSnapshot = undefined
|
||||
if (callbacks !== undefined && entry !== undefined) {
|
||||
invokeContainedSessionObservers(entry.emitCtx, 'session/event', entry.id, callbackArgs, callbacks)
|
||||
}
|
||||
return event
|
||||
} finally {
|
||||
if (entry !== undefined) {
|
||||
entry.appending = false
|
||||
if (entry.detachRequested && !entry.announcing) entry.detach()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Cached fold of the request-header events — see {@link requestHeader}. */
|
||||
@@ -290,10 +475,9 @@ export class Session {
|
||||
* call costs O(new nodes), and a surface rewrite (a `replace`;
|
||||
* {@link SurfaceManager.replaceGeneration}) rebuilds. The returned array is
|
||||
* a fresh snapshot per call (later appends never grow an array a caller
|
||||
* already holds); the `Message` objects in it are SHARED and **deep-frozen**
|
||||
* — cloned once off the log at projection time, so consumers can never
|
||||
* mutate logged data, and mutation attempts throw instead of silently
|
||||
* diverging replay from history.
|
||||
* already holds); the `Message` objects in it are SHARED and **deep-frozen**.
|
||||
* Their content reuses the already frozen durable event data, so the cache
|
||||
* needs no second deep clone and consumers still cannot mutate the log.
|
||||
* @returns a fresh array of the shared, frozen derived history.
|
||||
*/
|
||||
deriveMessages(): Message[] {
|
||||
@@ -325,9 +509,10 @@ export class Session {
|
||||
* The per-node pure function {@link deriveMessages} folds over the surface;
|
||||
* an external reconstructor (or the dev invariant) folds the same function
|
||||
* over a log prefix's surface to rebuild the exact messages any request was
|
||||
* built from (the reconstructability RFC). The returned `content` is
|
||||
* deep-cloned off the logged event: the log is append-only by contract, so
|
||||
* no live reference to logged data leaves this boundary.
|
||||
* built from (the reconstructability RFC). The returned message wrapper is
|
||||
* fresh; its content reuses the logged event's already deep-frozen durable
|
||||
* data, so changing the wrapper cannot rewrite the log and changing content
|
||||
* throws.
|
||||
* @param event - the event to project.
|
||||
* @returns the derived message, or null when the event produces none.
|
||||
*/
|
||||
@@ -338,29 +523,29 @@ export class Session {
|
||||
|
||||
switch (event.type) {
|
||||
case 'user/message': {
|
||||
return { role: 'user', content: structuredClone(event.data.content) }
|
||||
return { role: 'user', content: event.data.content }
|
||||
}
|
||||
case 'assistant/message': {
|
||||
// Skip an empty-content assistant/message: it exists only to host a
|
||||
// max-tokens step's usage and must not inject a content-less assistant
|
||||
// turn into the provider transcript.
|
||||
if (event.data.content.length === 0) return null
|
||||
return { role: 'assistant', content: structuredClone(event.data.content) }
|
||||
return { role: 'assistant', content: event.data.content }
|
||||
}
|
||||
case 'tool/result': {
|
||||
const { callId, content, isError } = event.data
|
||||
return {
|
||||
role: 'user',
|
||||
content: [{ type: 'tool-result', toolCallId: callId, content: structuredClone(content), isError }],
|
||||
content: [{ type: 'tool-result', toolCallId: callId, content, isError }],
|
||||
}
|
||||
}
|
||||
case 'context/message': {
|
||||
const { content, source } = event.data
|
||||
return { role: 'user', content: renderTagged('context', structuredClone(content), source) }
|
||||
return { role: 'user', content: renderTagged('context', content, source) }
|
||||
}
|
||||
case 'steering/message': {
|
||||
const { content, source } = event.data
|
||||
return { role: 'user', content: renderTagged('steering', structuredClone(content), source) }
|
||||
return { role: 'user', content: renderTagged('steering', content, source) }
|
||||
}
|
||||
default:
|
||||
// A non-surface event (boundary, chunk, log-only record) projects to
|
||||
@@ -403,7 +588,7 @@ export class SessionForkError extends Error {
|
||||
* subscribe to `session/event` and flush on `session/flush` / dispose.
|
||||
*/
|
||||
export class SessionStore extends Service {
|
||||
private store = new Map<SessionId, Session>()
|
||||
private store = new Map<SessionId, SessionEntry>()
|
||||
private counter = 0
|
||||
|
||||
constructor(ctx: Context) {
|
||||
@@ -419,15 +604,16 @@ export class SessionStore extends Service {
|
||||
* 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 `onAppend` detaches), do NOT use this
|
||||
* loop's final flush is captured before the store attachment ends), do NOT use this
|
||||
* — fold the session lifecycle into the agent's own effect via
|
||||
* {@link prepare} + {@link enter} + {@link announce} (see `dsh-agent-loop`'s
|
||||
* `startOwned`).
|
||||
* {@link prepare} + {@link enter} + {@link announce} (see
|
||||
* `dsh-agent-loop`'s creation transaction).
|
||||
*
|
||||
* @param id - the session id; omitted, the store mints `session-<n>`.
|
||||
* @param options - seed events and/or creation metadata for the header.
|
||||
* @returns the live session, already entered and announced.
|
||||
* @throws if a session with `id` already exists, or if `meta.cwd` is a
|
||||
* @throws if a session with `id` already exists, metadata is not a plain
|
||||
* lossless-JSON record with valid scalar fields, or `meta.cwd` is a
|
||||
* non-absolute path (storage backends key directories off it).
|
||||
*/
|
||||
create(id?: SessionId, options?: CreateSessionOptions): Session {
|
||||
@@ -435,7 +621,7 @@ export class SessionStore extends Service {
|
||||
// Single effect owned by the calling fiber. Yield the detach BEFORE
|
||||
// announcing so a throwing `session/created` listener rolls the attach back
|
||||
// (the generator effect disposes already-yielded disposers on a throw)
|
||||
// instead of leaking the store entry + onAppend.
|
||||
// instead of leaking the store entry and its publication hooks.
|
||||
this.ctx.effect(function* (this: SessionStore) {
|
||||
yield this.enter(session)
|
||||
this.announce(session)
|
||||
@@ -449,37 +635,42 @@ export class SessionStore extends Service {
|
||||
* Pairs with {@link enter} + {@link announce}: a caller that owns a composite
|
||||
* `ctx.effect` (the agent factory) folds the session lifecycle into that ONE
|
||||
* effect so a fiber unload tears the session + agent down as a single ORDERED
|
||||
* chain rather than as racing sibling effects — which would detach `onAppend`
|
||||
* chain rather than as racing sibling effects — which would remove the publication hooks
|
||||
* before the loop's closing `session/flush`, dropping the closing events.
|
||||
*
|
||||
* @param id - the session id; omitted, the store mints `session-<n>`.
|
||||
* @param options - seed events and/or creation metadata for the header.
|
||||
* @returns the constructed session, NOT yet in the store.
|
||||
* @throws if a session with `id` already exists, or if `meta.cwd` is a
|
||||
* @throws if a session with `id` already exists, metadata is not a plain
|
||||
* lossless-JSON record with valid scalar fields, or `meta.cwd` is a
|
||||
* non-absolute path.
|
||||
*/
|
||||
prepare(id?: SessionId, options?: CreateSessionOptions): Session {
|
||||
const sessionId = SessionId(id ?? `session-${++this.counter}`)
|
||||
if (this.store.has(sessionId)) throw new Error(`session "${sessionId}" already exists`)
|
||||
const cwd = options?.meta?.cwd
|
||||
if (cwd !== undefined && !isAbsolute(cwd)) {
|
||||
throw new Error(`session cwd must be an absolute path, got "${cwd}"`)
|
||||
let sessionId: SessionId
|
||||
if (id === undefined) {
|
||||
do sessionId = SessionId(`session-${++this.counter}`)
|
||||
while (this.store.has(sessionId))
|
||||
} else {
|
||||
sessionId = SessionId(id)
|
||||
}
|
||||
if (this.store.has(sessionId)) throw new Error(`session "${sessionId}" already exists`)
|
||||
const seed = options?.seed
|
||||
const meta = options?.meta
|
||||
const header: SessionHeader = {
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
id: sessionId,
|
||||
createdAt: options?.meta?.createdAt ?? Date.now(),
|
||||
...cwd !== undefined ? { cwd } : {},
|
||||
...options?.meta?.parentSession !== undefined ? { parentSession: options.meta.parentSession } : {},
|
||||
...options?.meta?.seedLength !== undefined ? { seedLength: options.meta.seedLength } : {},
|
||||
createdAt: meta?.createdAt ?? Date.now(),
|
||||
...meta?.cwd === undefined ? {} : { cwd: meta.cwd },
|
||||
...meta?.parentSession === undefined ? {} : { parentSession: meta.parentSession },
|
||||
...meta?.seedLength === undefined ? {} : { seedLength: meta.seedLength },
|
||||
}
|
||||
return new Session(sessionId, options?.seed, header)
|
||||
return new Session(sessionId, seed, header)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter a {@link prepare}d session into the store: wire `onAppend` →
|
||||
* `session/event` and add it to the store. Returns the DETACH disposer
|
||||
* (`onAppend = undefined` + store removal). Does NOT emit `session/created` —
|
||||
* Enter a {@link prepare}d session into the store: install the module-private
|
||||
* append publication hooks and add it to the store. Returns the DETACH
|
||||
* disposer (hooks + store removal). Does NOT emit `session/created` —
|
||||
* the caller yields this disposer inside its effect and THEN calls
|
||||
* {@link announce}, so a throwing `session/created` listener rolls the attach
|
||||
* back instead of leaking it.
|
||||
@@ -493,25 +684,143 @@ export class SessionStore extends Service {
|
||||
* assume that.
|
||||
*
|
||||
* @param session - a {@link prepare}d session not yet in the store.
|
||||
* @returns the detach disposer (`onAppend = undefined` + store removal).
|
||||
* @returns the detach disposer (publication hooks + store removal). When called from
|
||||
* a synchronous `session/created` listener, removal and disposal wait until
|
||||
* that creation dispatch unwinds.
|
||||
* @throws if a session with this id is already in the store.
|
||||
*/
|
||||
enter(session: Session): () => void {
|
||||
if (this.store.has(session.id)) throw new Error(`session "${session.id}" already exists`)
|
||||
session.onAppend = (event) => { this.ctx.emit('session/event', session, event) }
|
||||
this.store.set(session.id, session)
|
||||
return () => {
|
||||
session.onAppend = undefined
|
||||
this.store.delete(session.id)
|
||||
const id = session.id
|
||||
const carrier = scopeTarget(session, scopeOf(this.ctx))
|
||||
// This is the authoritative collision boundary after arbitrary unpublished
|
||||
// preparation. Only one exact same-id transaction can publish.
|
||||
if (this.store.has(id)) throw new Error(`session "${id}" already exists`)
|
||||
if (attachments.has(session)) throw new Error(`session "${id}" is already attached to a store`)
|
||||
const entry: SessionEntry = {
|
||||
id,
|
||||
session,
|
||||
carrier,
|
||||
emitCtx: this.ctx,
|
||||
announced: false,
|
||||
announcing: false,
|
||||
appending: false,
|
||||
detachRequested: false,
|
||||
detach: () => { this.detachEntered(entry) },
|
||||
}
|
||||
this.store.set(id, entry)
|
||||
attachments.set(session, entry)
|
||||
let entered = true
|
||||
const detach = (): void => {
|
||||
if (!entered) return
|
||||
entered = false
|
||||
// A lifecycle listener may own the advanced detach capability. Keep the
|
||||
// entry and its publication hooks live until synchronous creation or append
|
||||
// publication unwinds, then publish the paired disposal edge.
|
||||
if (entry.announcing || entry.appending) {
|
||||
entry.detachRequested = true
|
||||
return
|
||||
}
|
||||
entry.detach()
|
||||
}
|
||||
return detach
|
||||
}
|
||||
|
||||
/** Remove one exact entered session and emit its paired disposal when announced. */
|
||||
private detachEntered(entry: SessionEntry): void {
|
||||
entry.detachRequested = false
|
||||
// A stale capability cannot remove observers or storage belonging to a
|
||||
// later same-id lifecycle.
|
||||
/* v8 ignore next -- enter() rejects replacement while this single-shot detach capability is live. */
|
||||
if (this.store.get(entry.id) !== entry) return
|
||||
this.store.delete(entry.id)
|
||||
attachments.delete(entry.session)
|
||||
if (entry.announced) this.emitDisposed(entry)
|
||||
}
|
||||
|
||||
/** Emit `session/created` exactly once for an {@link enter}ed session (with
|
||||
* the carrier {@link enter} captured). Separate from {@link enter} so the
|
||||
* caller can yield the detach disposer first (rollback safety — see
|
||||
* {@link enter}).
|
||||
* @param session - the entered session to announce to listeners.
|
||||
* @throws if the session is not live or its announcement already began,
|
||||
* including a reentrant call from a creation listener. */
|
||||
announce(session: Session): void {
|
||||
const entry = this.liveEntryFor(session)
|
||||
if (entry.announced || entry.announcing) {
|
||||
throw new Error(`session "${entry.id}" was already announced`)
|
||||
}
|
||||
// Mark before emit: Cordis emit may deliver to earlier listeners and then
|
||||
// throw. Rollback must still pair that partial creation with disposal, and
|
||||
// a listener cannot recursively create a second lifecycle edge.
|
||||
entry.announced = true
|
||||
const callbackArgs: unknown[] = [session]
|
||||
entry.announcing = true
|
||||
try {
|
||||
const callbacks = collectSessionCallbacks(this.ctx, [entry.carrier, 'session/created', session])
|
||||
for (const callback of callbacks) {
|
||||
// Synchronous throws intentionally propagate and veto publication; the
|
||||
// yielded detach then emits the paired disposal edge. An async function
|
||||
// is nevertheless assignable to a void listener, so observe its returned
|
||||
// promise: rejection is too late to roll back and must be logged instead
|
||||
// of becoming unhandled.
|
||||
const returned: unknown = callback(...callbackArgs)
|
||||
void Promise.resolve(returned).catch((error: unknown) => {
|
||||
this.ctx.logger.warn(`session "${entry.id}": session/created listener rejected: ${String(error)}`)
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
entry.announcing = false
|
||||
if (entry.detachRequested && !entry.appending) entry.detach()
|
||||
}
|
||||
}
|
||||
|
||||
/** Emit `session/created` for an {@link enter}ed session. Separate from
|
||||
* {@link enter} so the caller can yield the detach disposer first (rollback
|
||||
* safety — see {@link enter}).
|
||||
* @param session - the entered session to announce to listeners. */
|
||||
announce(session: Session): void {
|
||||
this.ctx.emit('session/created', session)
|
||||
/** Emit the paired teardown notification with per-listener containment. */
|
||||
private emitDisposed(entry: SessionEntry): void {
|
||||
const callbackArgs: unknown[] = [entry.session]
|
||||
try {
|
||||
const callbacks = collectSessionCallbacks(this.ctx, [entry.carrier, 'session/disposed', entry.session])
|
||||
invokeContainedSessionObservers(this.ctx, 'session/disposed', entry.id, callbackArgs, callbacks)
|
||||
} catch (error: unknown) {
|
||||
this.ctx.logger.warn(`session "${entry.id}": session/disposed dispatch threw: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch the awaited `session/flush` durability checkpoint for `session`,
|
||||
* with the carrier captured at {@link enter}. THE flush entry point: the
|
||||
* store owns the carrier, so callers (the loop's turn-end checkpoint, idle
|
||||
* injection, teardown drains) must come through here rather than dispatch a
|
||||
* raw `ctx.parallel('session/flush', …)` — one owner, one spelling, and the
|
||||
* scoped-dispatch invariant can pin it.
|
||||
* @param session - the session whose buffered events must reach durable storage.
|
||||
* @returns resolves when every flush listener has settled; after all settle,
|
||||
* rejects with the first registered listener failure if any listener failed.
|
||||
*/
|
||||
async flush(session: Session): Promise<void> {
|
||||
const { carrier } = this.liveEntryFor(session)
|
||||
const callbackArgs: unknown[] = [session]
|
||||
const callbacks = collectSessionCallbacks(this.ctx, [carrier, 'session/flush', session])
|
||||
const results = await Promise.allSettled(callbacks.map((callback) => {
|
||||
try {
|
||||
return callback(...callbackArgs)
|
||||
} catch (error: unknown) {
|
||||
// Preserve the listener's exact rejection value; flush is a caller-owned
|
||||
// failure boundary, and Cordis listeners may throw arbitrary values.
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}))
|
||||
const failure = results.find((result): result is PromiseRejectedResult => result.status === 'rejected')
|
||||
if (failure !== undefined) throw failure.reason
|
||||
}
|
||||
|
||||
/** Return the exact live entry; detached/prepared objects reject. */
|
||||
private liveEntryFor(session: Session): SessionEntry {
|
||||
const entry = attachments.get(session)
|
||||
if (entry === undefined || this.store.get(entry.id) !== entry) {
|
||||
throw new Error(`session "${session.id}" is not live in this store`)
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -520,7 +829,7 @@ export class SessionStore extends Service {
|
||||
* @returns the session, or undefined when no live session has that id.
|
||||
*/
|
||||
get(id: SessionId): Session | undefined {
|
||||
return this.store.get(id)
|
||||
return this.store.get(id)?.session
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -528,7 +837,7 @@ export class SessionStore extends Service {
|
||||
* @returns a fresh array; mutating it does not affect the store.
|
||||
*/
|
||||
list(): Session[] {
|
||||
return [...this.store.values()]
|
||||
return [...this.store.values()].map(entry => entry.session)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -598,7 +907,7 @@ export class SessionStore extends Service {
|
||||
)
|
||||
}
|
||||
|
||||
return events.slice(0, boundary + 1).map(event => structuredClone(event))
|
||||
return events.slice(0, boundary + 1)
|
||||
}
|
||||
|
||||
private _resolveForkSource(source: SessionForkSource): Session {
|
||||
|
||||
@@ -1,45 +1,127 @@
|
||||
/**
|
||||
* JSON-serializability validation for session event data.
|
||||
* 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. Backends re-use the same
|
||||
* predicate to validate their own `append(events)` entry point (replay/fork
|
||||
* paths that do not go through a live `Session`).
|
||||
* 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
|
||||
*/
|
||||
|
||||
/**
|
||||
* A value that round-trips losslessly through JSON: `null`, a boolean, a finite
|
||||
* number, a string, an array of such values, or a plain object whose values are
|
||||
* such values. The static type companion to {@link isJsonValue} (which validates
|
||||
* the same shape at runtime). Use it to type a payload that must survive
|
||||
* session-log persistence and replay byte-identically — e.g. a tool's private
|
||||
* presentation `meta`.
|
||||
* number other than negative zero, a string, an array of such values, or a
|
||||
* plain object whose values are such values. TypeScript cannot distinguish
|
||||
* `-0` from `number`, so {@link isJsonValue} and {@link snapshotJsonValue}
|
||||
* enforce that last numeric detail at runtime. Use this type for a payload that
|
||||
* must survive session-log persistence and replay byte-identically — e.g. a
|
||||
* tool's private presentation `meta`.
|
||||
*/
|
||||
export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }
|
||||
|
||||
/**
|
||||
* Whether `value` is losslessly JSON-serializable: only `null`, finite numbers,
|
||||
* booleans, strings, plain arrays, and plain objects of such values. Rejects
|
||||
* `BigInt`, function, symbol, `undefined`, non-finite numbers (`NaN`/`Infinity`,
|
||||
* which `JSON.stringify` 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.
|
||||
* 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.
|
||||
*
|
||||
* Scope — matches `JSON.stringify` exactly: only an object's OWN ENUMERABLE
|
||||
* STRING-keyed properties are inspected (`Object.values`). Symbol-keyed and
|
||||
* non-enumerable properties are NOT examined, because `JSON.stringify` likewise
|
||||
* drops them — they never reach the durable form, so a non-serializable value
|
||||
* hiding under a symbol/non-enumerable key cannot make the round-trip lossy.
|
||||
* Getters are invoked during the check (again as `JSON.stringify` would), so the
|
||||
* contract is for plain data records, not objects with side-effecting accessors.
|
||||
* 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.
|
||||
*
|
||||
* @param value - the candidate value to validate and detach.
|
||||
* @returns the detached snapshot, or `undefined` when the value is not
|
||||
* losslessly JSON-serializable.
|
||||
*/
|
||||
export function snapshotJsonValue<T>(value: T): T | undefined {
|
||||
const ancestors = new Set<object>()
|
||||
|
||||
const visit = (current: unknown): JsonValue | undefined => {
|
||||
if (current === null) return null
|
||||
switch (typeof current) {
|
||||
case 'boolean':
|
||||
case 'string':
|
||||
return current
|
||||
case 'number':
|
||||
return Number.isFinite(current) && !Object.is(current, -0) ? current : undefined
|
||||
case 'bigint':
|
||||
case 'function':
|
||||
case 'symbol':
|
||||
case 'undefined':
|
||||
return undefined
|
||||
case 'object':
|
||||
break
|
||||
}
|
||||
|
||||
if (ancestors.has(current)) return undefined
|
||||
ancestors.add(current)
|
||||
try {
|
||||
if (Array.isArray(current)) {
|
||||
if (Object.getPrototypeOf(current) !== Array.prototype) return undefined
|
||||
const length = current.length
|
||||
const snapshot: JsonValue[] = []
|
||||
for (let index = 0; index < length; index++) {
|
||||
if (!Object.prototype.hasOwnProperty.call(current, index)) return undefined
|
||||
const item = visit(current[index])
|
||||
if (item === undefined) return undefined
|
||||
snapshot.push(item)
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
const prototype = Object.getPrototypeOf(current) as unknown
|
||||
if (prototype !== Object.prototype && prototype !== null) return undefined
|
||||
const snapshot: { [key: string]: JsonValue } = {}
|
||||
for (const key of Object.keys(current)) {
|
||||
const item = visit((current as Record<string, unknown>)[key])
|
||||
if (item === undefined) return undefined
|
||||
// Define the key as data so a JSON field literally named "__proto__"
|
||||
// cannot mutate the snapshot's prototype through ordinary assignment.
|
||||
Object.defineProperty(snapshot, key, {
|
||||
value: item,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
})
|
||||
}
|
||||
return snapshot
|
||||
} finally {
|
||||
ancestors.delete(current)
|
||||
}
|
||||
}
|
||||
|
||||
return visit(value) as 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.
|
||||
* @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.
|
||||
@@ -52,7 +134,7 @@ export function isJsonValue(value: unknown, seen: Set<object> = new Set()): bool
|
||||
case 'string':
|
||||
return true
|
||||
case 'number':
|
||||
return Number.isFinite(value)
|
||||
return Number.isFinite(value) && !Object.is(value, -0)
|
||||
case 'bigint':
|
||||
case 'function':
|
||||
case 'symbol':
|
||||
@@ -66,6 +148,7 @@ export function isJsonValue(value: unknown, seen: Set<object> = new Set()): bool
|
||||
seen.add(value)
|
||||
try {
|
||||
if (Array.isArray(value)) {
|
||||
if (Object.getPrototypeOf(value) !== Array.prototype) return false
|
||||
// Reject sparse arrays: a hole is skipped by `every`/`forEach` but
|
||||
// JSON.stringify writes it as `null`, so `[1, , 3]` would round-trip
|
||||
// lossily. Require every index 0..length-1 to be an OWN property.
|
||||
|
||||
@@ -32,6 +32,9 @@ export const SESSION_FORMAT_VERSION = 0
|
||||
|
||||
/**
|
||||
* Immutable session metadata — written once at creation and never rewritten.
|
||||
* {@link Session} enforces that contract at runtime: it validates and detaches
|
||||
* the accepted scalar fields, requires this header's id to match the session
|
||||
* id, and deep-freezes the published record.
|
||||
*
|
||||
* Kept SEPARATE from the event log deliberately: format-version, cwd, and
|
||||
* lineage are storage concerns, not conversation events, so they stay out of
|
||||
@@ -45,15 +48,15 @@ export interface SessionHeader {
|
||||
* session is created. A persistence backend rejects any other version on load
|
||||
* (no migration — see the constant).
|
||||
*/
|
||||
version: number
|
||||
readonly version: number
|
||||
/** The session's id (mirrors the {@link Session}'s id). */
|
||||
id: SessionId
|
||||
readonly id: SessionId
|
||||
/** Unix epoch milliseconds when the session was created. */
|
||||
createdAt: number
|
||||
readonly createdAt: number
|
||||
/** Absolute working directory the session was created in (if any). */
|
||||
cwd?: string
|
||||
readonly cwd?: string
|
||||
/** The session this one was forked from (seed lineage), if any. */
|
||||
parentSession?: SessionId
|
||||
readonly parentSession?: SessionId
|
||||
/**
|
||||
* How many leading events were INHERITED via a seed rather than produced by
|
||||
* this session — the seed boundary. Set when a fork seeds a child with a
|
||||
@@ -63,7 +66,7 @@ export interface SessionHeader {
|
||||
* harness can skip the inherited prefix when deriving the child's OWN script
|
||||
* (the seeded events are the parent's, not this child's model calls).
|
||||
*/
|
||||
seedLength?: number
|
||||
readonly seedLength?: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,9 +76,10 @@ export interface SessionHeader {
|
||||
*/
|
||||
export interface CreateSessionOptions {
|
||||
/** Events to seed the new session with (replay/fork). */
|
||||
seed?: SessionEvent[]
|
||||
readonly seed?: readonly SessionEvent[]
|
||||
/**
|
||||
* Creation metadata. The store fills in `version`/`id` and defaults
|
||||
* Creation metadata. The store reads this plain record and each accepted
|
||||
* field once, then fills in `version`/`id` and defaults
|
||||
* `createdAt` to now; the caller supplies the storage-level fields (validated
|
||||
* absolute `cwd`, `parentSession` lineage, the seed boundary `seedLength`, and
|
||||
* — when reconstructing a persisted session — the original `createdAt` to
|
||||
@@ -86,7 +90,12 @@ export interface CreateSessionOptions {
|
||||
* length, not the original boundary — the caller must pass the persisted
|
||||
* boundary back. A fresh fork passes its actual seeded-prefix length.
|
||||
*/
|
||||
meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number; seedLength?: number }
|
||||
readonly meta?: {
|
||||
readonly cwd?: string
|
||||
readonly parentSession?: SessionId
|
||||
readonly createdAt?: number
|
||||
readonly seedLength?: number
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -89,15 +89,15 @@ describe('Session.deriveEventMessage — the per-event projection', () => {
|
||||
expect(session.deriveEventMessage(event)).toEqual(session.deriveMessages().at(-1))
|
||||
})
|
||||
|
||||
it('clones content off the log: the projection never aliases the logged event', () => {
|
||||
it('reuses the logged event\'s already frozen content', () => {
|
||||
const session = new Session(SessionId('per-event-clone'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
const event = session.append('user/message', { content: [{ type: 'text', text: 'orig' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
const message = session.deriveEventMessage(event)!
|
||||
expect(message.content).not.toBe(event.data.content)
|
||||
// deriveEventMessage returns an unfrozen clone (the cache freezes ITS
|
||||
// copies); mutating it must not reach the log.
|
||||
;(message.content[0] as { text: string }).text = 'mutated'
|
||||
expect(message.content).toBe(event.data.content)
|
||||
expect(Object.isFrozen(message.content)).toBe(true)
|
||||
expect(Object.isFrozen(message.content[0])).toBe(true)
|
||||
expect(() => { (message.content[0] as { text: string }).text = 'mutated' }).toThrow()
|
||||
expect(session.deriveMessages().at(-1)!.content).toEqual([{ type: 'text', text: 'orig' }])
|
||||
})
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ describe('SessionStore.fork', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('forks the latest completed boundary by default and deep-clones seed events', async () => {
|
||||
it('forks the latest completed boundary by default into detached frozen seed events', async () => {
|
||||
const { ctx, sessions } = await setup()
|
||||
const source = ctx.sessions.create(SessionId('parent'), { meta: { cwd: '/workspace' } })
|
||||
appendClosedTurn(source, 1, 'hello')
|
||||
@@ -70,8 +70,11 @@ describe('SessionStore.fork', () => {
|
||||
expect(child.events).toEqual(source.events)
|
||||
expect(child.events).not.toBe(source.events)
|
||||
expect(child.events[1]).not.toBe(source.events[1])
|
||||
firstUserMessage(child.events).data.content[0] = { type: 'text', text: 'child mutation' }
|
||||
expect(() => {
|
||||
firstUserMessage(child.events).data.content[0] = { type: 'text', text: 'child mutation' }
|
||||
}).toThrow(TypeError)
|
||||
expect(firstUserMessage(source.events).data.content).toEqual([{ type: 'text', text: 'hello' }])
|
||||
expect(firstUserMessage(child.events).data.content).toEqual([{ type: 'text', text: 'hello' }])
|
||||
expect(child.header).toMatchObject({
|
||||
id: SessionId('child'),
|
||||
cwd: '/workspace',
|
||||
|
||||
152
packages/core/session/tests/json.spec.ts
Normal file
152
packages/core/session/tests/json.spec.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isJsonValue, snapshotJsonValue } from '@deepseek-ai/dsh-session'
|
||||
|
||||
describe('snapshotJsonValue', () => {
|
||||
it('copies the complete JSON scalar vocabulary and rejects unsupported scalars', () => {
|
||||
const unsupportedFunction = (): void => {}
|
||||
|
||||
expect(snapshotJsonValue(null)).toBeNull()
|
||||
expect(snapshotJsonValue(true)).toBe(true)
|
||||
expect(snapshotJsonValue('text')).toBe('text')
|
||||
expect(snapshotJsonValue(1.25)).toBe(1.25)
|
||||
expect(snapshotJsonValue(-0)).toBeUndefined()
|
||||
expect(isJsonValue(-0)).toBe(false)
|
||||
expect(snapshotJsonValue(Number.NaN)).toBeUndefined()
|
||||
expect(snapshotJsonValue(Number.POSITIVE_INFINITY)).toBeUndefined()
|
||||
expect(snapshotJsonValue(1n)).toBeUndefined()
|
||||
expect(snapshotJsonValue(unsupportedFunction)).toBeUndefined()
|
||||
expect(snapshotJsonValue(Symbol('value'))).toBeUndefined()
|
||||
const unsupportedUndefined: unknown = undefined
|
||||
expect(snapshotJsonValue(unsupportedUndefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('recursively detaches dense arrays and plain or null-prototype objects', () => {
|
||||
const shared = { value: 1 }
|
||||
const nullPrototype = Object.assign(Object.create(null) as Record<string, unknown>, { shared })
|
||||
const source = { list: [nullPrototype, shared], alias: shared }
|
||||
|
||||
const snapshot = snapshotJsonValue(source)!
|
||||
shared.value = 2
|
||||
|
||||
expect(snapshot).toEqual({ list: [{ shared: { value: 1 } }, { value: 1 }], alias: { value: 1 } })
|
||||
expect(snapshot).not.toBe(source)
|
||||
expect(snapshot.list).not.toBe(source.list)
|
||||
expect(snapshot.alias).not.toBe(shared)
|
||||
expect(snapshot.list[0]).not.toBe(nullPrototype)
|
||||
expect(Object.getPrototypeOf(snapshot.list[0])).toBe(Object.prototype)
|
||||
})
|
||||
|
||||
it('reads each object value and array slot once while materializing', () => {
|
||||
class Exotic {
|
||||
readonly accepted = false
|
||||
}
|
||||
let objectReads = 0
|
||||
let arrayReads = 0
|
||||
const nested = Object.defineProperty({}, 'value', {
|
||||
enumerable: true,
|
||||
get: () => {
|
||||
objectReads += 1
|
||||
return objectReads === 1 ? { accepted: true } : new Exotic()
|
||||
},
|
||||
})
|
||||
const array = new Array<unknown>(1)
|
||||
Object.defineProperty(array, 0, {
|
||||
enumerable: true,
|
||||
get: () => {
|
||||
arrayReads += 1
|
||||
return arrayReads === 1 ? nested : new Exotic()
|
||||
},
|
||||
})
|
||||
|
||||
expect(snapshotJsonValue(array)).toEqual([{ value: { accepted: true } }])
|
||||
expect(objectReads).toBe(1)
|
||||
expect(arrayReads).toBe(1)
|
||||
})
|
||||
|
||||
it('rejects exotic containers, sparse arrays, cycles, and invalid children', () => {
|
||||
class ExoticObject {
|
||||
readonly value = 1
|
||||
}
|
||||
class ExoticArray extends Array<number> {}
|
||||
const sparse = new Array<number>(1)
|
||||
const cyclic: Record<string, unknown> = {}
|
||||
cyclic.self = cyclic
|
||||
|
||||
expect(snapshotJsonValue(new ExoticObject())).toBeUndefined()
|
||||
expect(snapshotJsonValue(new Map([['value', 1]]))).toBeUndefined()
|
||||
expect(snapshotJsonValue(new ExoticArray(1))).toBeUndefined()
|
||||
expect(snapshotJsonValue(sparse)).toBeUndefined()
|
||||
expect(snapshotJsonValue(cyclic)).toBeUndefined()
|
||||
expect(snapshotJsonValue([undefined])).toBeUndefined()
|
||||
expect(snapshotJsonValue({ value: undefined })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('preserves a literal __proto__ JSON key without changing the snapshot prototype', () => {
|
||||
const source = Object.create(null) as Record<string, unknown>
|
||||
source.__proto__ = { safe: true }
|
||||
|
||||
const snapshot = snapshotJsonValue(source)!
|
||||
|
||||
expect(Object.getPrototypeOf(snapshot)).toBe(Object.prototype)
|
||||
expect(Object.prototype.hasOwnProperty.call(snapshot, '__proto__')).toBe(true)
|
||||
expect(snapshot.__proto__).toEqual({ safe: true })
|
||||
})
|
||||
|
||||
it('propagates a throwing getter after reading it once', () => {
|
||||
const failure = new Error('getter failed')
|
||||
let reads = 0
|
||||
const source = Object.defineProperty({}, 'value', {
|
||||
enumerable: true,
|
||||
get: () => {
|
||||
reads += 1
|
||||
throw failure
|
||||
},
|
||||
})
|
||||
|
||||
expect(() => snapshotJsonValue(source)).toThrow(failure)
|
||||
expect(reads).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isJsonValue', () => {
|
||||
it('recognizes supported scalars and rejects every lossy scalar case', () => {
|
||||
const unsupportedFunction = (): void => {}
|
||||
const unsupportedUndefined: unknown = undefined
|
||||
|
||||
expect(isJsonValue(null)).toBe(true)
|
||||
expect(isJsonValue(false)).toBe(true)
|
||||
expect(isJsonValue('text')).toBe(true)
|
||||
expect(isJsonValue(1.25)).toBe(true)
|
||||
expect(isJsonValue(-0)).toBe(false)
|
||||
expect(isJsonValue(Number.NaN)).toBe(false)
|
||||
expect(isJsonValue(1n)).toBe(false)
|
||||
expect(isJsonValue(unsupportedFunction)).toBe(false)
|
||||
expect(isJsonValue(Symbol('value'))).toBe(false)
|
||||
expect(isJsonValue(unsupportedUndefined)).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts dense arrays and plain objects, including null-prototype records', () => {
|
||||
const nullPrototype = Object.assign(Object.create(null) as Record<string, unknown>, { value: true })
|
||||
|
||||
expect(isJsonValue([1, { nested: null }, nullPrototype])).toBe(true)
|
||||
expect(isJsonValue({ value: [1, 2] })).toBe(true)
|
||||
expect(isJsonValue(nullPrototype)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects sparse arrays, invalid children, exotic objects, and cycles', () => {
|
||||
class Exotic {
|
||||
readonly value = 1
|
||||
}
|
||||
class ExoticArray extends Array<number> {}
|
||||
const sparse = new Array<number>(1)
|
||||
const cyclic: Record<string, unknown> = {}
|
||||
cyclic.self = cyclic
|
||||
|
||||
expect(isJsonValue(sparse)).toBe(false)
|
||||
expect(isJsonValue(new ExoticArray(1))).toBe(false)
|
||||
expect(isJsonValue([undefined])).toBe(false)
|
||||
expect(isJsonValue({ value: undefined })).toBe(false)
|
||||
expect(isJsonValue(new Exotic())).toBe(false)
|
||||
expect(isJsonValue(cyclic)).toBe(false)
|
||||
})
|
||||
})
|
||||
180
packages/core/session/tests/scoped.spec.ts
Normal file
180
packages/core/session/tests/scoped.spec.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { createScope, scopeOf } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scope, ScopeKey } from '@deepseek-ai/dsh-scope'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
|
||||
async function mount(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
return ctx
|
||||
}
|
||||
|
||||
async function mintScope(ctx: Context, name: string): Promise<Scope> {
|
||||
let scope!: Scope
|
||||
// The scoped context resolves services through the MINTING plugin's
|
||||
// dependency chain — the minter must inject what scope holders will reach.
|
||||
await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, { name }) },
|
||||
{ inject: ['sessions'] }))
|
||||
return scope
|
||||
}
|
||||
|
||||
/** The key a test scope was minted with. */
|
||||
function keyOf(scope: Scope): ScopeKey {
|
||||
|
||||
return scopeOf(scope.ctx)!
|
||||
}
|
||||
|
||||
describe('session dispatch carriers', () => {
|
||||
it('a session entered through a scoped context dispatches its events in that scope', async () => {
|
||||
const ctx = await mount()
|
||||
const scope = await mintScope(ctx, 'owner')
|
||||
const otherScope = await mintScope(ctx, 'other')
|
||||
|
||||
const heard: string[] = []
|
||||
ctx.on('session/event', (_session, event) => void heard.push(`global:${event.type}`))
|
||||
scope.ctx.on('session/event', (_session, event) => void heard.push(`owner:${event.type}`))
|
||||
otherScope.ctx.on('session/event', (_session, event) => void heard.push(`other:${event.type}`))
|
||||
scope.ctx.on('session/created', session => void heard.push(`owner-created:${session.id}`))
|
||||
otherScope.ctx.on('session/created', session => void heard.push(`other-created:${session.id}`))
|
||||
|
||||
const session = scope.ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
|
||||
expect(heard).toEqual([
|
||||
`owner-created:${session.id}`,
|
||||
'global:turn/start',
|
||||
'owner:turn/start',
|
||||
])
|
||||
})
|
||||
|
||||
it('a bare session dispatches subject-less: scoped listeners never hear it', async () => {
|
||||
const ctx = await mount()
|
||||
const scope = await mintScope(ctx, 'owner')
|
||||
const heard: string[] = []
|
||||
ctx.on('session/event', (_s, event) => void heard.push(`global:${event.type}`))
|
||||
scope.ctx.on('session/event', (_s, event) => void heard.push(`owner:${event.type}`))
|
||||
|
||||
const bare = ctx.sessions.create()
|
||||
bare.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
expect(heard).toEqual(['global:turn/start'])
|
||||
})
|
||||
|
||||
it('reuses the captured owner carrier for the paired disposal notification', async () => {
|
||||
const ctx = await mount()
|
||||
const owner = await mintScope(ctx, 'owner')
|
||||
const other = await mintScope(ctx, 'other')
|
||||
const heard: string[] = []
|
||||
ctx.on('session/disposed', (session) => { heard.push(`global:${session.id}`) })
|
||||
owner.ctx.on('session/disposed', (session) => { heard.push(`owner:${session.id}`) })
|
||||
other.ctx.on('session/disposed', (session) => { heard.push(`other:${session.id}`) })
|
||||
|
||||
const session = owner.ctx.sessions.prepare()
|
||||
const detach = owner.ctx.sessions.enter(session)
|
||||
owner.ctx.sessions.announce(session)
|
||||
detach()
|
||||
|
||||
expect(heard).toEqual([`global:${session.id}`, `owner:${session.id}`])
|
||||
})
|
||||
})
|
||||
|
||||
describe('sessions.flush()', () => {
|
||||
it('dispatches session/flush with the owning carrier and awaits all listeners', async () => {
|
||||
const ctx = await mount()
|
||||
const scope = await mintScope(ctx, 'owner')
|
||||
const flushed: string[] = []
|
||||
ctx.on('session/flush', async (session: Session) => {
|
||||
await Promise.resolve()
|
||||
flushed.push(`global:${session.id}`)
|
||||
})
|
||||
scope.ctx.on('session/flush', (session: Session) => void flushed.push(`owner:${session.id}`))
|
||||
|
||||
const owned = scope.ctx.sessions.create()
|
||||
const bare = ctx.sessions.create()
|
||||
await ctx.sessions.flush(owned)
|
||||
await ctx.sessions.flush(bare)
|
||||
|
||||
// Parallel dispatch: listener completion order is unspecified (the global
|
||||
// listener awaits a microtask) — assert set membership per flush instead.
|
||||
expect(flushed.slice(0, 2).sort()).toEqual([`global:${owned.id}`, `owner:${owned.id}`])
|
||||
expect(flushed.slice(2)).toEqual([`global:${bare.id}`])
|
||||
})
|
||||
|
||||
it('propagates a rejecting flush listener (the caller owns the failure policy)', async () => {
|
||||
const ctx = await mount()
|
||||
ctx.on('session/flush', () => Promise.reject(new Error('disk full')))
|
||||
const session = ctx.sessions.create()
|
||||
await expect(ctx.sessions.flush(session)).rejects.toThrow('disk full')
|
||||
})
|
||||
|
||||
it('does not let a synchronous flush failure starve later listeners', async () => {
|
||||
const ctx = await mount()
|
||||
const flushed: Session[] = []
|
||||
ctx.on('session/flush', () => { throw new Error('disk full') })
|
||||
ctx.on('session/flush', (session) => { flushed.push(session) })
|
||||
const session = ctx.sessions.create()
|
||||
|
||||
await expect(ctx.sessions.flush(session)).rejects.toThrow('disk full')
|
||||
expect(flushed).toEqual([session])
|
||||
})
|
||||
|
||||
it('waits for slower flush listeners before reporting another listener failure', async () => {
|
||||
const ctx = await mount()
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
let slowStarted = false
|
||||
let settled = false
|
||||
ctx.on('session/flush', () => Promise.reject(new Error('disk full')))
|
||||
ctx.on('session/flush', () => {
|
||||
slowStarted = true
|
||||
return gate.promise
|
||||
})
|
||||
const session = ctx.sessions.create()
|
||||
|
||||
const flushing = ctx.sessions.flush(session)
|
||||
void flushing.finally(() => { settled = true }).catch(() => undefined)
|
||||
await Promise.resolve()
|
||||
expect(slowStarted).toBe(true)
|
||||
expect(settled).toBe(false)
|
||||
|
||||
gate.resolve(undefined)
|
||||
await expect(flushing).rejects.toThrow('disk full')
|
||||
expect(settled).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a never-entered session instead of inventing a carrier', async () => {
|
||||
const ctx = await mount()
|
||||
const scope = await mintScope(ctx, 'owner')
|
||||
const flushed: string[] = []
|
||||
ctx.on('session/flush', (session: Session) => void flushed.push(`global:${session.id}`))
|
||||
scope.ctx.on('session/flush', (session: Session) => void flushed.push(`owner:${session.id}`))
|
||||
|
||||
const prepared = ctx.sessions.prepare()
|
||||
await expect(ctx.sessions.flush(prepared)).rejects.toThrow(/not live/)
|
||||
expect(flushed).toEqual([])
|
||||
})
|
||||
|
||||
it('clears a detached carrier and rejects stale flushes', async () => {
|
||||
const ctx = await mount()
|
||||
const scope = await mintScope(ctx, 'owner')
|
||||
const flushed: string[] = []
|
||||
ctx.on('session/flush', (session: Session) => void flushed.push(`global:${session.id}`))
|
||||
scope.ctx.on('session/flush', (session: Session) => void flushed.push(`owner:${session.id}`))
|
||||
|
||||
const session = scope.ctx.sessions.prepare()
|
||||
const detach = scope.ctx.sessions.enter(session)
|
||||
await ctx.sessions.flush(session)
|
||||
expect(flushed.sort()).toEqual([`global:${session.id}`, `owner:${session.id}`])
|
||||
|
||||
detach()
|
||||
await expect(ctx.sessions.flush(session)).rejects.toThrow(/not live/)
|
||||
expect(flushed).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('keyOf sanity: distinct scopes carry distinct keys', async () => {
|
||||
const ctx = await mount()
|
||||
const a = await mintScope(ctx, 'a')
|
||||
const b = await mintScope(ctx, 'b')
|
||||
expect(keyOf(a)).not.toBe(keyOf(b))
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEventType, TodoItem } from '@deepseek-ai/dsh-session'
|
||||
import type { CreateSessionOptions, SessionEventType, SessionHeader, TodoItem } from '@deepseek-ai/dsh-session'
|
||||
|
||||
describe('Session', () => {
|
||||
it('derives message history from the event log', () => {
|
||||
@@ -156,7 +156,7 @@ describe('Session', () => {
|
||||
const badSeed = [
|
||||
{ type: 'user/message' as const, seq: 0, time: 1, data: { content: [{ type: 'text' as const, text: 'x' }], source: { kind: 'user' as const }, bad: 1n } },
|
||||
] as unknown as SessionEvent[]
|
||||
expect(() => new Session(SessionId('seed-bad'), badSeed)).toThrow(/non-JSON-serializable/)
|
||||
expect(() => new Session(SessionId('seed-bad'), badSeed)).toThrow(/losslessly JSON-serializable/)
|
||||
})
|
||||
|
||||
it('validates seed events: rejects a non-contiguous seq', () => {
|
||||
@@ -177,7 +177,7 @@ describe('Session', () => {
|
||||
{ type: 'user/message' as const, seq: 1, time: 2, data: { content: [{ type: 'text' as const, text: 'hi' }], source: { kind: 'user' as const } } },
|
||||
{ type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } },
|
||||
] as SessionEvent[]
|
||||
expect(() => new Session(SessionId('seed-no-marker'), markerlessSeed)).toThrow(/surface-eligible but carries no surfaceOp/)
|
||||
expect(() => new Session(SessionId('seed-no-marker'), markerlessSeed)).toThrow(/requires a surfaceOp marker/)
|
||||
})
|
||||
|
||||
it('accepts a well-formed contiguous serializable seed', () => {
|
||||
@@ -190,6 +190,151 @@ describe('Session', () => {
|
||||
expect(session.events).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('reads each seed array entry once so validation and storage use the same event', () => {
|
||||
const accepted = {
|
||||
type: 'turn/start' as const,
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } },
|
||||
}
|
||||
const drifted = { ...accepted, seq: 99, data: { invalid: 1n } }
|
||||
let reads = 0
|
||||
const seed = new Array<SessionEvent>(1)
|
||||
Object.defineProperty(seed, 0, {
|
||||
enumerable: true,
|
||||
get: () => {
|
||||
reads += 1
|
||||
return reads === 1 ? accepted : drifted
|
||||
},
|
||||
})
|
||||
|
||||
const session = new Session(SessionId('seed-entry-snapshot'), seed)
|
||||
|
||||
expect(reads).toBe(1)
|
||||
expect(session.events).toEqual([accepted])
|
||||
})
|
||||
|
||||
it('reads a nested seed-data getter once and stores its first JSON value', () => {
|
||||
let reads = 0
|
||||
const data = Object.defineProperty({}, 'value', {
|
||||
enumerable: true,
|
||||
get: () => {
|
||||
reads += 1
|
||||
return reads === 1 ? 'accepted' : 1n
|
||||
},
|
||||
})
|
||||
const seed = [{ type: 'test/unstable', seq: 0, time: 1, data }] as unknown as SessionEvent[]
|
||||
|
||||
const session = new Session(SessionId('seed-nested-drift'), seed)
|
||||
|
||||
expect(reads).toBe(1)
|
||||
expect(session.events[0]!.data).toEqual({ value: 'accepted' })
|
||||
})
|
||||
|
||||
it('rejects non-JSON surface metadata in a seed event', () => {
|
||||
const seed = [{
|
||||
type: 'user/message',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
|
||||
surfaceOp: { op: 'replace', start: 1n, end: 2 },
|
||||
}] as unknown as SessionEvent[]
|
||||
|
||||
expect(() => new Session(SessionId('seed-bad-metadata'), seed))
|
||||
.toThrow(/losslessly JSON-serializable/)
|
||||
})
|
||||
|
||||
it('rejects exotic seed metadata before cloning can erase its prototype', () => {
|
||||
class ReplaceOp {
|
||||
readonly op = 'replace' as const
|
||||
readonly start = 0
|
||||
readonly end = 0
|
||||
}
|
||||
const seed = [{
|
||||
type: 'user/message',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
|
||||
surfaceOp: new ReplaceOp(),
|
||||
}] as unknown as SessionEvent[]
|
||||
|
||||
expect(() => new Session(SessionId('seed-exotic-metadata'), seed))
|
||||
.toThrow(/losslessly JSON-serializable/)
|
||||
})
|
||||
|
||||
it('rejects an exotic seed event shell before spreading erases its prototype', () => {
|
||||
class SeedEvent {
|
||||
readonly type = 'turn/start' as const
|
||||
readonly seq = 0
|
||||
readonly time = 1
|
||||
readonly data = { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } }
|
||||
}
|
||||
const seed: SessionEvent[] = [new SeedEvent()]
|
||||
|
||||
expect(() => new Session(SessionId('seed-exotic-shell'), seed))
|
||||
.toThrow(/not losslessly JSON-serializable/)
|
||||
})
|
||||
|
||||
it('accepts a null-prototype seed event shell as a plain JSON record', () => {
|
||||
const event = Object.assign(Object.create(null) as Record<string, unknown>, {
|
||||
type: 'turn/start' as const,
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } },
|
||||
}) as unknown as SessionEvent
|
||||
|
||||
const session = new Session(SessionId('seed-null-prototype'), [event])
|
||||
|
||||
expect(session.events).toEqual([{ ...event }])
|
||||
})
|
||||
|
||||
it('reads a nested seed-metadata getter once and stores its first JSON value', () => {
|
||||
let reads = 0
|
||||
const surfaceOp = Object.defineProperty({ op: 'replace', end: 0 }, 'start', {
|
||||
enumerable: true,
|
||||
get: () => {
|
||||
reads += 1
|
||||
return reads === 1 ? 0 : 1n
|
||||
},
|
||||
})
|
||||
const seed = [{
|
||||
type: 'user/message',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
|
||||
surfaceOp,
|
||||
}] as unknown as SessionEvent[]
|
||||
|
||||
const session = new Session(SessionId('seed-unstable-metadata'), seed)
|
||||
const event = session.events[0]!
|
||||
if (event.type !== 'user/message') throw new Error('test fixture must remain a user/message')
|
||||
|
||||
expect(reads).toBe(1)
|
||||
expect(event.surfaceOp).toEqual({ op: 'replace', start: 0, end: 0 })
|
||||
})
|
||||
|
||||
it('adds seed context when surface validation throws a non-Error value', () => {
|
||||
const originalHasOwn = Object.hasOwn
|
||||
const hasOwn = vi.spyOn(Object, 'hasOwn').mockImplementation((object: object, property: PropertyKey): boolean => {
|
||||
if ((object as Record<string, unknown>)['op'] === 'replace') throw 'validator failed'
|
||||
return originalHasOwn(object, property)
|
||||
})
|
||||
const seed = [{
|
||||
type: 'user/message',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
|
||||
surfaceOp: { op: 'replace', start: 0, end: 0 },
|
||||
}] as unknown as SessionEvent[]
|
||||
|
||||
try {
|
||||
expect(() => new Session(SessionId('seed-non-error-metadata-failure'), seed))
|
||||
.toThrow('invalid seed event at index 0: invalid surface metadata')
|
||||
} finally {
|
||||
hasOwn.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('snapshots the seed: mutating the original after construction does not affect session.events', () => {
|
||||
const seed = [
|
||||
{ type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
|
||||
@@ -222,6 +367,261 @@ describe('Session', () => {
|
||||
// The returned event carries the same snapshot, not the caller's input.
|
||||
expect((event.data.content[0] as { text: string }).text).toBe('original')
|
||||
})
|
||||
|
||||
it('reads a nested append-data getter once and stores its first JSON value', () => {
|
||||
const session = new Session(SessionId('append-nested-drift'))
|
||||
let reads = 0
|
||||
const data = Object.defineProperty({}, 'value', {
|
||||
enumerable: true,
|
||||
get: () => {
|
||||
reads += 1
|
||||
return reads === 1 ? 'accepted' : 1n
|
||||
},
|
||||
})
|
||||
|
||||
const event = session.append('todo/write', data as never)
|
||||
|
||||
expect(reads).toBe(1)
|
||||
expect(event.data).toEqual({ value: 'accepted' })
|
||||
expect(session.events).toEqual([event])
|
||||
})
|
||||
|
||||
it('rejects non-JSON surface metadata before appending the event', () => {
|
||||
const session = new Session(SessionId('append-bad-metadata'))
|
||||
|
||||
expect(() => session.append(
|
||||
'user/message',
|
||||
{ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
|
||||
{ surfaceOp: { op: 'replace', start: 1n, end: 2 } } as never,
|
||||
)).toThrow(/non-JSON-serializable surface metadata/)
|
||||
expect(session.events).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects exotic surface metadata before cloning can erase its prototype', () => {
|
||||
class ReplaceOp {
|
||||
readonly op = 'replace' as const
|
||||
readonly start = 0
|
||||
readonly end = 0
|
||||
}
|
||||
const session = new Session(SessionId('append-exotic-metadata'))
|
||||
|
||||
expect(() => session.append(
|
||||
'user/message',
|
||||
{ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
|
||||
{ surfaceOp: new ReplaceOp() },
|
||||
)).toThrow(/non-JSON-serializable surface metadata/)
|
||||
expect(session.events).toEqual([])
|
||||
})
|
||||
|
||||
it('reads a nested append-metadata getter once and stores its first JSON value', () => {
|
||||
const session = new Session(SessionId('append-unstable-metadata'))
|
||||
let reads = 0
|
||||
const surfaceOp = Object.defineProperty({ op: 'replace', end: 0 }, 'start', {
|
||||
enumerable: true,
|
||||
get: () => {
|
||||
reads += 1
|
||||
return reads === 1 ? 0 : 1n
|
||||
},
|
||||
})
|
||||
|
||||
const event = session.append(
|
||||
'user/message',
|
||||
{ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
|
||||
{ surfaceOp } as never,
|
||||
)
|
||||
|
||||
expect(reads).toBe(1)
|
||||
expect(event.surfaceOp).toEqual({ op: 'replace', start: 0, end: 0 })
|
||||
expect(session.events).toEqual([event])
|
||||
})
|
||||
|
||||
it('rejects invalid plain surface metadata shapes at append', () => {
|
||||
const session = new Session(SessionId('append-invalid-surface-shape'))
|
||||
const appendRaw = session.append.bind(session) as unknown as (
|
||||
type: SessionEventType,
|
||||
data: unknown,
|
||||
opts?: unknown,
|
||||
) => SessionEvent
|
||||
const data = { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }
|
||||
|
||||
expect(() => appendRaw('user/message', data, { surfaceOp: 'invalid' }))
|
||||
.toThrow(/invalid surfaceOp/)
|
||||
expect(() => appendRaw('user/message', data, {
|
||||
surfaceOp: { op: 'replace', start: -1, end: 0 },
|
||||
})).toThrow(/invalid replace surfaceOp/)
|
||||
expect(() => appendRaw('user/message', data, {
|
||||
surfaceOp: 'append',
|
||||
sourceEventSeqs: [0, -1],
|
||||
})).toThrow(/non-negative safe integers/)
|
||||
expect(session.events).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects surface metadata on non-surface append and seed events', () => {
|
||||
const session = new Session(SessionId('non-surface-metadata'))
|
||||
const appendRaw = session.append.bind(session) as unknown as (
|
||||
type: SessionEventType,
|
||||
data: unknown,
|
||||
opts?: unknown,
|
||||
) => SessionEvent
|
||||
|
||||
expect(() => appendRaw(
|
||||
'turn/start',
|
||||
{ turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
{ surfaceOp: 'append' },
|
||||
)).toThrow(/not surface-eligible and cannot carry surface metadata/)
|
||||
expect(() => new Session(SessionId('non-surface-metadata-seed'), [{
|
||||
type: 'turn/start',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
surfaceOp: 'append',
|
||||
} as unknown as SessionEvent])).toThrow(/invalid seed event.*not surface-eligible/)
|
||||
expect(session.events).toEqual([])
|
||||
})
|
||||
|
||||
it('deep-freezes seeded and appended event snapshots', () => {
|
||||
const seeded = new Session(SessionId('seed-frozen'), [{
|
||||
type: 'turn/start',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
}])
|
||||
const seededEvent = seeded.events[0]!
|
||||
if (seededEvent.type !== 'turn/start') throw new Error('test fixture must remain a turn/start')
|
||||
expect(Object.isFrozen(seededEvent)).toBe(true)
|
||||
expect(Object.isFrozen(seededEvent.data)).toBe(true)
|
||||
expect(Object.isFrozen(seededEvent.data.trigger)).toBe(true)
|
||||
expect(() => { seededEvent.data.turn = 99 }).toThrow(TypeError)
|
||||
|
||||
const appended = new Session(SessionId('append-frozen'))
|
||||
const appendedEvent = appended.append('todo/write', {
|
||||
todos: [{ content: 'first', status: 'pending' }],
|
||||
})
|
||||
expect(Object.isFrozen(appendedEvent)).toBe(true)
|
||||
expect(Object.isFrozen(appendedEvent.data)).toBe(true)
|
||||
expect(Object.isFrozen(appendedEvent.data.todos)).toBe(true)
|
||||
expect(Object.isFrozen(appendedEvent.data.todos[0])).toBe(true)
|
||||
expect(() => { appendedEvent.data.todos[0]!.content = 'mutated' }).toThrow(TypeError)
|
||||
})
|
||||
|
||||
it('returns cached frozen event-array snapshots that do not grow after append', () => {
|
||||
const session = new Session(SessionId('events-snapshot'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
const before = session.events
|
||||
const beforeEvent = before[0]!
|
||||
if (beforeEvent.type !== 'turn/start') throw new Error('test fixture must remain a turn/start')
|
||||
|
||||
expect(session.events).toBe(before)
|
||||
expect(Object.isFrozen(before)).toBe(true)
|
||||
expect(() => { (before as SessionEvent[]).push(beforeEvent) }).toThrow(TypeError)
|
||||
expect(() => { beforeEvent.data.turn = 99 }).toThrow(TypeError)
|
||||
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
const after = session.events
|
||||
expect(before).toHaveLength(1)
|
||||
expect(after).toHaveLength(2)
|
||||
expect(after).not.toBe(before)
|
||||
expect(session.events).toBe(after)
|
||||
})
|
||||
|
||||
it('detaches and freezes an explicitly supplied session header', () => {
|
||||
const input = {
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
id: SessionId('header-owned'),
|
||||
createdAt: 123,
|
||||
cwd: '/accepted',
|
||||
parentSession: SessionId('parent'),
|
||||
seedLength: 2,
|
||||
}
|
||||
|
||||
const session = new Session(SessionId('header-owned'), undefined, input)
|
||||
input.cwd = '/caller-mutated'
|
||||
|
||||
expect(session.header).toEqual({
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
id: 'header-owned',
|
||||
createdAt: 123,
|
||||
cwd: '/accepted',
|
||||
parentSession: 'parent',
|
||||
seedLength: 2,
|
||||
})
|
||||
expect(session.header).not.toBe(input)
|
||||
expect(Object.isFrozen(session.header)).toBe(true)
|
||||
expect(Reflect.set(session.header, 'cwd', '/published-mutated')).toBe(false)
|
||||
expect(session.id).toBe('header-owned')
|
||||
expect(session.header.cwd).toBe('/accepted')
|
||||
})
|
||||
|
||||
it('rejects an exotic, non-JSON, or mismatched supplied header', () => {
|
||||
class ExoticHeader implements SessionHeader {
|
||||
readonly version = SESSION_FORMAT_VERSION
|
||||
readonly id = SessionId('header-invalid')
|
||||
readonly createdAt = 123
|
||||
}
|
||||
|
||||
expect(() => new Session(SessionId('header-invalid'), undefined, new ExoticHeader()))
|
||||
.toThrow(/not losslessly JSON-serializable/)
|
||||
expect(() => new Session(SessionId('header-invalid'), undefined, {
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
id: SessionId('header-invalid'),
|
||||
createdAt: 123,
|
||||
parentSession: 1n,
|
||||
} as unknown as SessionHeader)).toThrow(/not losslessly JSON-serializable/)
|
||||
expect(() => new Session(SessionId('header-invalid'), undefined, {
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
id: SessionId('other'),
|
||||
createdAt: 123,
|
||||
})).toThrow(/does not match session id/)
|
||||
})
|
||||
|
||||
it('rejects invalid scalar fields in an explicitly supplied header', () => {
|
||||
const base = {
|
||||
version: SESSION_FORMAT_VERSION,
|
||||
id: SessionId('header-shape'),
|
||||
createdAt: 123,
|
||||
}
|
||||
const cases: Array<{ header: unknown; error: RegExp }> = [
|
||||
{ header: 1, error: /not a plain JSON record/ },
|
||||
{ header: null, error: /not a plain JSON record/ },
|
||||
{ header: { ...base, version: 1 }, error: /header version/ },
|
||||
{ header: { ...base, createdAt: '123' }, error: /createdAt must be a finite number/ },
|
||||
{ header: { ...base, cwd: 1 }, error: /header cwd must be a string/ },
|
||||
{ header: { ...base, cwd: 'relative' }, error: /header cwd must be an absolute path/ },
|
||||
{ header: { ...base, parentSession: 1 }, error: /header parentSession must be a string/ },
|
||||
{ header: { ...base, seedLength: '1' }, error: /seedLength must be a non-negative safe integer/ },
|
||||
{ header: { ...base, seedLength: 0.5 }, error: /seedLength must be a non-negative safe integer/ },
|
||||
{ header: { ...base, seedLength: -1 }, error: /seedLength must be a non-negative safe integer/ },
|
||||
]
|
||||
|
||||
for (const { header, error } of cases) {
|
||||
expect(() => new Session(SessionId('header-shape'), undefined, header as SessionHeader)).toThrow(error)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects seed records with invalid fixed-envelope fields', () => {
|
||||
const base = {
|
||||
type: 'turn/start',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
}
|
||||
const cases: unknown[] = [
|
||||
{ ...base, extra: true },
|
||||
{ ...base, type: 1 },
|
||||
{ ...base, seq: '0' },
|
||||
{ ...base, seq: 0.5 },
|
||||
{ ...base, seq: -1 },
|
||||
{ ...base, time: '1' },
|
||||
{ ...base, time: 0.5 },
|
||||
{ ...base, time: -1 },
|
||||
{ type: base.type, seq: base.seq, time: base.time },
|
||||
]
|
||||
|
||||
for (const [index, event] of cases.entries()) {
|
||||
expect(() => new Session(SessionId(`bad-envelope-${index}`), [event as SessionEvent]))
|
||||
.toThrow(/invalid event envelope/)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -238,6 +638,10 @@ describe('SessionStore', () => {
|
||||
const session = ctx.sessions.create()
|
||||
expect(created).toEqual([session])
|
||||
|
||||
// The store-owned append publication hooks are module-private. A JavaScript caller
|
||||
// may create an unrelated property with the old implementation's name,
|
||||
// but cannot suppress the durable event feed.
|
||||
expect(Reflect.set(session, 'onAppend', undefined)).toBe(true)
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]![0]).toBe(session)
|
||||
@@ -289,9 +693,97 @@ describe('SessionStore', () => {
|
||||
expect(created).toEqual([session])
|
||||
// The detach disposer removes the entry + stops notification.
|
||||
detach()
|
||||
detach() // idempotent: cannot disturb a later same-id lifecycle
|
||||
expect(ctx.sessions.get(SessionId('lifecycle'))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('prevents simultaneous attachment of one session object to two stores', async () => {
|
||||
const firstCtx = new Context()
|
||||
const secondCtx = new Context()
|
||||
await firstCtx.plugin(SessionStore)
|
||||
await secondCtx.plugin(SessionStore)
|
||||
const session = new Session(SessionId('owned-key'))
|
||||
const detachFirst = firstCtx.sessions.enter(session)
|
||||
|
||||
expect(() => secondCtx.sessions.enter(session)).toThrow(/already attached to a store/)
|
||||
expect(firstCtx.sessions.get(SessionId('owned-key'))).toBe(session)
|
||||
|
||||
detachFirst()
|
||||
expect(firstCtx.sessions.get(SessionId('owned-key'))).toBeUndefined()
|
||||
const detachSecond = secondCtx.sessions.enter(session)
|
||||
expect(secondCtx.sessions.get(SessionId('owned-key'))).toBe(session)
|
||||
detachSecond()
|
||||
|
||||
})
|
||||
|
||||
it('rejects direct and reentrant repeat announcements to preserve one lifecycle pair', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
let created = 0
|
||||
let disposed = 0
|
||||
let reentrantError = ''
|
||||
ctx.on('session/created', (session) => {
|
||||
created += 1
|
||||
try {
|
||||
ctx.sessions.announce(session)
|
||||
} catch (error: unknown) {
|
||||
reentrantError = String(error)
|
||||
}
|
||||
})
|
||||
ctx.on('session/disposed', () => { disposed += 1 })
|
||||
|
||||
const session = ctx.sessions.prepare(SessionId('once'))
|
||||
const detach = ctx.sessions.enter(session)
|
||||
ctx.sessions.announce(session)
|
||||
expect(reentrantError).toMatch(/already announced/)
|
||||
expect(() => { ctx.sessions.announce(session) }).toThrow(/already announced/)
|
||||
detach()
|
||||
expect({ created, disposed }).toEqual({ created: 1, disposed: 1 })
|
||||
})
|
||||
|
||||
it('defers a reentrant detach until the creation dispatch unwinds', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const order: string[] = []
|
||||
const session = ctx.sessions.prepare(SessionId('reentrant-detach'))
|
||||
const detach = ctx.sessions.enter(session)
|
||||
|
||||
ctx.on('session/created', (created) => {
|
||||
order.push('created:first')
|
||||
detach()
|
||||
expect(ctx.sessions.get(created.id)).toBe(created)
|
||||
})
|
||||
ctx.on('session/created', (created) => {
|
||||
order.push('created:second')
|
||||
expect(ctx.sessions.get(created.id)).toBe(created)
|
||||
})
|
||||
ctx.on('session/disposed', (disposed) => {
|
||||
order.push('disposed')
|
||||
expect(ctx.sessions.get(disposed.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
ctx.sessions.announce(session)
|
||||
|
||||
expect(order).toEqual(['created:first', 'created:second', 'disposed'])
|
||||
expect(ctx.sessions.get(session.id)).toBeUndefined()
|
||||
detach()
|
||||
})
|
||||
|
||||
it('rolls back create when its owner unloads from session/created', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
let ownerCtx!: Context
|
||||
const owner = await ctx.plugin(Object.assign((inner: Context) => { ownerCtx = inner }, { inject: ['sessions'] }))
|
||||
const id = SessionId('create-unload-race')
|
||||
ctx.on('session/created', (session) => {
|
||||
if (session.id === id) void owner.dispose()
|
||||
})
|
||||
|
||||
ownerCtx.sessions.create(id)
|
||||
await owner.dispose()
|
||||
expect(ctx.sessions.get(id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('synthesizes a minimal current-version header for a bare-created session', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
@@ -316,6 +808,26 @@ describe('SessionStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects non-JSON and invalid scalar session metadata', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const cases: Array<{ meta: unknown; error: RegExp }> = [
|
||||
{ meta: { parentSession: 1n }, error: /header is not losslessly JSON-serializable/ },
|
||||
{ meta: { cwd: 1 }, error: /header cwd must be a string/ },
|
||||
{ meta: { parentSession: 1 }, error: /header parentSession must be a string/ },
|
||||
{ meta: { createdAt: '123' }, error: /header createdAt must be a finite number/ },
|
||||
{ meta: { seedLength: '1' }, error: /seedLength must be a non-negative safe integer/ },
|
||||
{ meta: { seedLength: 0.5 }, error: /seedLength must be a non-negative safe integer/ },
|
||||
{ meta: { seedLength: -1 }, error: /seedLength must be a non-negative safe integer/ },
|
||||
]
|
||||
|
||||
for (const [index, { meta, error }] of cases.entries()) {
|
||||
expect(() => ctx.sessions.prepare(SessionId(`bad-meta-${index}`), {
|
||||
meta: meta as NonNullable<CreateSessionOptions['meta']>,
|
||||
})).toThrow(error)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a non-absolute meta.cwd', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
@@ -350,11 +862,13 @@ describe('SessionStore', () => {
|
||||
expect(observed).toBe(0)
|
||||
})
|
||||
|
||||
it('rolls back the session (and onAppend) when a session/created listener throws (P1-1)', async () => {
|
||||
it('pairs a partial session/created announcement with disposal during rollback', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
|
||||
let threw = false
|
||||
const disposed: Session[] = []
|
||||
ctx.on('session/disposed', (session) => { disposed.push(session) })
|
||||
ctx.on('session/created', () => {
|
||||
if (!threw) { threw = true; throw new Error('boom created listener') }
|
||||
})
|
||||
@@ -362,9 +876,10 @@ describe('SessionStore', () => {
|
||||
// The throwing emit must roll the store entry back, not leak it.
|
||||
expect(() => ctx.sessions.create(SessionId('fixed'))).toThrow('boom created listener')
|
||||
expect(ctx.sessions.get(SessionId('fixed'))).toBeUndefined() // rolled back, not leaked
|
||||
expect(disposed.map(session => session.id)).toEqual(['fixed'])
|
||||
|
||||
// A subsequent create of the SAME id succeeds (the already-exists check is
|
||||
// not wedged) and its onAppend is correctly wired (events observable).
|
||||
// not wedged) and its store-owned publication hooks are correctly wired.
|
||||
const events: SessionEvent[] = []
|
||||
ctx.on('session/event', (_session, event) => void events.push(event))
|
||||
const session = ctx.sessions.create(SessionId('fixed'))
|
||||
@@ -372,6 +887,243 @@ describe('SessionStore', () => {
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
expect(events).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('contains session/event observer failures after the append commit point', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const warnings: string[] = []
|
||||
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
|
||||
const session = ctx.sessions.create(SessionId('contained-event'))
|
||||
const heard: SessionEvent[] = []
|
||||
let committedBeforeNotify = false
|
||||
ctx.on('session/event', (observedSession, event) => {
|
||||
committedBeforeNotify = observedSession.events.at(-1) === event
|
||||
throw new Error('sync event observer')
|
||||
})
|
||||
ctx.on('session/event', () => Promise.reject(new Error('async event observer')) as never)
|
||||
ctx.on('session/event', (_observedSession, event) => { heard.push(event) })
|
||||
|
||||
let appended!: SessionEvent
|
||||
expect(() => {
|
||||
appended = session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
}).not.toThrow()
|
||||
expect(committedBeforeNotify).toBe(true)
|
||||
expect(session.events).toEqual([appended])
|
||||
expect(heard).toEqual([appended])
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(warnings).toEqual([
|
||||
'session "contained-event": session/event listener threw: Error: sync event observer',
|
||||
'session "contained-event": session/event listener rejected: Error: async event observer',
|
||||
])
|
||||
})
|
||||
|
||||
it('runs internal dispatch validation on one frozen candidate before commit and resets after a veto', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('dispatch-veto'))
|
||||
const validations: Array<{ event: SessionEvent; logLength: number; frozen: boolean }> = []
|
||||
const observed: SessionEvent[] = []
|
||||
let reject = true
|
||||
ctx.on('internal/dispatch', (_mode, name, args) => {
|
||||
if (name !== 'session/event') return
|
||||
const [observedSession, event] = args as [Session, SessionEvent]
|
||||
validations.push({
|
||||
event,
|
||||
logLength: observedSession.events.length,
|
||||
frozen: Object.isFrozen(event) && Object.isFrozen(event.data),
|
||||
})
|
||||
if (reject) {
|
||||
reject = false
|
||||
throw new Error('reject first candidate')
|
||||
}
|
||||
})
|
||||
ctx.on('session/event', (_observedSession, event) => { observed.push(event) })
|
||||
|
||||
expect(() => session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})).toThrow('reject first candidate')
|
||||
expect(session.events).toEqual([])
|
||||
expect(observed).toEqual([])
|
||||
|
||||
const appended = session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
expect(validations.map(({ logLength, frozen }) => ({ logLength, frozen }))).toEqual([
|
||||
{ logLength: 0, frozen: true },
|
||||
{ logLength: 0, frozen: true },
|
||||
])
|
||||
expect(validations.map(({ event }) => event.seq)).toEqual([0, 0])
|
||||
expect(validations[1]!.event).toBe(appended)
|
||||
expect(session.events).toEqual([appended])
|
||||
expect(observed).toEqual([appended])
|
||||
})
|
||||
|
||||
it('resolves session/event dispatch before commit so instrumentation failure cannot hide a logged event', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('dispatch-check'))
|
||||
const observed: SessionEvent[] = []
|
||||
ctx.on('internal/dispatch', (_mode, name) => {
|
||||
if (name === 'session/event') throw new Error('dispatch instrumentation rejected the carrier')
|
||||
})
|
||||
ctx.on('session/event', (_observedSession, event) => { observed.push(event) })
|
||||
|
||||
expect(() => session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})).toThrow('dispatch instrumentation rejected the carrier')
|
||||
expect(session.events).toEqual([])
|
||||
expect(observed).toEqual([])
|
||||
})
|
||||
|
||||
it('contains a reentrant observer append without reordering later observers', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const warnings: string[] = []
|
||||
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
|
||||
const session = ctx.sessions.create(SessionId('reentrant-observer'))
|
||||
const heard: SessionEvent[] = []
|
||||
ctx.on('session/event', (observedSession) => {
|
||||
observedSession.append('todo/write', { todos: [] })
|
||||
})
|
||||
ctx.on('session/event', (_observedSession, event) => { heard.push(event) })
|
||||
|
||||
const appended = session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
expect(session.events).toEqual([appended])
|
||||
expect(heard).toEqual([appended])
|
||||
expect(warnings).toEqual([
|
||||
'session "reentrant-observer": session/event listener threw: Error: session append cannot reenter while another append is being published',
|
||||
])
|
||||
})
|
||||
|
||||
it('defers detach through dispatch resolution, commit, and observer publication', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const order: string[] = []
|
||||
const session = ctx.sessions.prepare(SessionId('detach-during-append'))
|
||||
const detach = ctx.sessions.enter(session)
|
||||
ctx.on('internal/dispatch', (_mode, name, args) => {
|
||||
if (name !== 'session/event') return
|
||||
const session = args[0] as Session
|
||||
order.push(`resolve:${ctx.sessions.get(session.id) === session ? 'live' : 'detached'}`)
|
||||
detach()
|
||||
})
|
||||
ctx.on('session/event', (session) => {
|
||||
order.push(`observe:${ctx.sessions.get(session.id) === session ? 'live' : 'detached'}`)
|
||||
})
|
||||
ctx.on('session/disposed', (session) => {
|
||||
order.push(`dispose:${ctx.sessions.get(session.id) === session ? 'live' : 'detached'}`)
|
||||
})
|
||||
ctx.sessions.announce(session)
|
||||
|
||||
const appended = session.append('turn/start', {
|
||||
turn: 1,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
|
||||
expect(session.events).toEqual([appended])
|
||||
expect(order).toEqual(['resolve:live', 'observe:live', 'dispose:detached'])
|
||||
expect(ctx.sessions.get(session.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('observes async session/created rejection without rolling back or starving peers', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const warnings: string[] = []
|
||||
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
|
||||
const heard: string[] = []
|
||||
ctx.on('session/created', () => Promise.reject(new Error('late creation failure')) as never)
|
||||
ctx.on('session/created', (session) => { heard.push(session.id) })
|
||||
|
||||
const session = ctx.sessions.create(SessionId('async-created'))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(ctx.sessions.get(session.id)).toBe(session)
|
||||
expect(heard).toEqual(['async-created'])
|
||||
expect(warnings).toEqual([
|
||||
'session "async-created": session/created listener rejected: Error: late creation failure',
|
||||
])
|
||||
})
|
||||
|
||||
it('contains synchronous and async session/disposed listener failures per observer', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const warnings: string[] = []
|
||||
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
|
||||
const heard: string[] = []
|
||||
ctx.on('session/disposed', () => { throw new Error('sync disposed') })
|
||||
ctx.on('session/disposed', () => Promise.reject(new Error('async disposed')) as never)
|
||||
ctx.on('session/disposed', (session) => { heard.push(session.id) })
|
||||
|
||||
const unannounced = ctx.sessions.prepare(SessionId('never-announced'))
|
||||
const detachUnannounced = ctx.sessions.enter(unannounced)
|
||||
detachUnannounced()
|
||||
expect(heard).toEqual([])
|
||||
|
||||
const announced = ctx.sessions.prepare(SessionId('contained-disposal'))
|
||||
const detach = ctx.sessions.enter(announced)
|
||||
ctx.sessions.announce(announced)
|
||||
expect(() => { detach() }).not.toThrow()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(heard).toEqual(['contained-disposal'])
|
||||
expect(warnings).toEqual([
|
||||
'session "contained-disposal": session/disposed listener threw: Error: sync disposed',
|
||||
'session "contained-disposal": session/disposed listener rejected: Error: async disposed',
|
||||
])
|
||||
})
|
||||
|
||||
it('contains internal dispatch failure after session detachment', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const warnings: string[] = []
|
||||
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
|
||||
const heard: Session[] = []
|
||||
ctx.on('internal/dispatch', (_mode, name) => {
|
||||
if (name === 'session/disposed') throw new Error('disposed dispatch instrumentation')
|
||||
})
|
||||
ctx.on('session/disposed', (session) => { heard.push(session) })
|
||||
const session = ctx.sessions.prepare(SessionId('disposed-dispatch'))
|
||||
const detach = ctx.sessions.enter(session)
|
||||
ctx.sessions.announce(session)
|
||||
|
||||
expect(() => { detach() }).not.toThrow()
|
||||
expect(ctx.sessions.get(session.id)).toBeUndefined()
|
||||
expect(heard).toEqual([])
|
||||
expect(warnings).toEqual([
|
||||
'session "disposed-dispatch": session/disposed dispatch threw: Error: disposed dispatch instrumentation',
|
||||
])
|
||||
})
|
||||
|
||||
it('does not let internal dispatch replace the disposed callback tuple', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const replacement = new Session(SessionId('replacement-disposed'))
|
||||
const heard: Session[] = []
|
||||
ctx.on('internal/dispatch', (_mode, name, args) => {
|
||||
if (name === 'session/disposed') args[0] = replacement
|
||||
})
|
||||
ctx.on('session/disposed', (session) => { heard.push(session) })
|
||||
const session = ctx.sessions.prepare(SessionId('fixed-disposed-tuple'))
|
||||
const detach = ctx.sessions.enter(session)
|
||||
ctx.sessions.announce(session)
|
||||
|
||||
detach()
|
||||
|
||||
expect(heard).toEqual([session])
|
||||
})
|
||||
})
|
||||
|
||||
describe('todo/write event', () => {
|
||||
|
||||
@@ -19,6 +19,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/scope"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,34 +1,31 @@
|
||||
# dsh-system-prompt
|
||||
|
||||
System prompt assembly registry. Plugins contribute ordered text sections, tool-schema providers, and named prompt variables; the agent loop calls `assemble(context)` once per step, and `renderPrompt(assembly)` is the full system prompt the model sees. The plugin registers the harness-owned openers itself — the static `harness:identity` section and the deployment's `deployment:persona` section — so they exist for every agent regardless of which loop plugin drives it.
|
||||
System prompt assembly registry. Plugins contribute ordered text sections, tool-schema providers, and named prompt variables. The agent loop calls `assemble(context)` once per step, and `renderPrompt(assembly)` is the full system prompt the model sees. The plugin registers the harness-owned openers itself — the static `harness:identity` section and the global default `deployment:persona` section — so they remain available regardless of which loop plugin drives an agent. An agent-scoped contribution with the same persona name shadows that default for its agent.
|
||||
|
||||
## Config
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `persona` | `''` | The deployment persona: the ONE deployment-authored prompt fragment, rendered as the order-0 `deployment:persona` section and shared by every agent in the context (subagents included). A template — complete `{{…}}` groups are interpreted strictly against the registered variables (the shipped loop registers `{{model}}`/`{{cwd}}`), with no escape syntax for literal braces yet. Empty ⇒ the section is dropped at render. |
|
||||
| `persona` | `''` | The global deployment-persona default: the ONE config-authored prompt fragment, rendered as the order-0 `deployment:persona` section unless an agent-scoped contribution shadows it. A template — complete `{{…}}` groups are interpreted strictly against the registered variables (the shipped loop registers `{{model}}`/`{{cwd}}`), with no escape syntax for literal braces yet. Empty ⇒ the section is dropped at render. |
|
||||
| `toolOrder` | — | Explicit model-facing tool order, as a list of `ToolSchema.name`s with one `'<unlisted-tools>'` rest entry (`TOOL_ORDER_REST`): listed tools take their listed position, unlisted tools land at the rest entry in lexicographic name order. Absent ⇒ plain lexicographic name order. Applied to the collected tools BEFORE the `system-prompt/assemble` waterfall — like the sections' `order` sort, it canonicalizes what the registry contributed (registration order is a plugin-load artifact), and a waterfall listener that mutates the list owns the determinism of what it emits. Misconfiguration fails loud: a list without exactly one rest entry, or with duplicates, throws at load; a listed name with no registered tool rejects every `assemble()`; a tool provider returning the reserved rest-entry name also rejects. Under the shipped loop the turn fails before any model request. Why a central list and not per-plugin weights: [Explicit model-facing tool order](../../../docs/rfc/implemented/feature/2026-07-06-explicit-tool-order.md). |
|
||||
|
||||
## Service: `SystemPrompt` (ctx key: `systemPrompt`)
|
||||
|
||||
### Public API
|
||||
|
||||
- `ctx.systemPrompt.section(section: PromptSection): () => void` Contribute a section. Duplicate names throw. Disposed with the calling fiber.
|
||||
- `ctx.systemPrompt.tools(provider: () => ToolSchema[]): () => void` Contribute tool schemas (evaluated at each assembly). A provider must not return a schema named `TOOL_ORDER_REST`; that name is reserved for `toolOrder`'s rest entry. Disposed with the calling fiber.
|
||||
- `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => void` Contribute a prompt variable, referenced from section text as `{{name}}`. Duplicate or unreferenceable names throw; `undefined` means "no value for this assembly". Disposed with the calling fiber.
|
||||
- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise<PromptAssembly>` Assemble the prompt for one caller. Runs through the `system-prompt/assemble` waterfall. Rejects when a configured `toolOrder` names a tool no provider contributed, or when a provider returns the reserved rest-entry name.
|
||||
- `ctx.systemPrompt.section(section: PromptSection): () => void` Contribute a section. The layer is the calling context's scope: `agent.ctx` contributes to that agent alone, shadowing a same-named global section there. Duplicate names within one layer and non-finite orders throw. Disposed with the calling fiber.
|
||||
- `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => void` Contribute tool schemas, evaluated at each assembly with that assembly's context. `ToolProviderResult` = `{ schemas, knownNames? }`: `schemas` is the post-restriction visible set; `knownNames` is the pre-restriction universe used by `toolOrder`. A provider must not return a schema named `TOOL_ORDER_REST`. Scoped providers are consulted only for their scope's assemblies. Disposed with the calling fiber.
|
||||
- `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => void` Contribute a prompt variable, referenced from section text as `{{name}}`. Scoped variables shadow a same-named global for that agent. Duplicate-in-layer or unreferenceable names throw; `undefined` means "no value for this assembly". Disposed with the calling fiber.
|
||||
- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise<PromptAssembly>` Assemble the prompt for one caller: the global layer merged with `context.scope`'s layer, with tool schemas detached before the transform seam. Runs through the scope-filtered `system-prompt/assemble` waterfall and returns its authoritative result. Rejects when a configured `toolOrder` names a tool outside the providers' `knownNames` universe, or when a provider returns the reserved rest-entry name.
|
||||
|
||||
### Events
|
||||
### Live events
|
||||
|
||||
| Event | Mode | Purpose |
|
||||
|---|---|---|
|
||||
| `system-prompt/assemble` | waterfall | Mutate/extend the assembly (with the caller's context) before it reaches the model |
|
||||
| `system-prompt/change` | emit | A section, tool provider, or variable was registered or unregistered |
|
||||
`system-prompt/assemble` is an expert cooperative seam: its returned assembly is authoritative, and a listener that replaces or removes entries owns preserving any active Code Mode or structured-output protocol. Prefer [`ToolRegistry.restrict()`](../tools/README.md) when tool filtering must stay aligned across model presentation, lookup, and execution. Registry change is the deliberately unfiltered notification that an assembly input changed, possibly for one scope; exact signatures, dispatch modes, and filtering contracts live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md).
|
||||
|
||||
### Key types
|
||||
|
||||
- `AssembleContext` — what one `assemble()` call is FOR. Declared empty here and merge-extensible; `dsh-agent` declares `agent?: Agent`, so providers project per-agent facts. Providers must tolerate absent fields (a bare `assemble()` carries an empty context).
|
||||
- `PromptSection` — `{ name, order, text: string | ((context) => string) }`. Sections are concatenated in ascending `order`. Order bands: `-100` is the harness identity, `0` the deployment persona (both registered by this plugin), tool guidance uses `100–199`; other negative orders also render before the persona.
|
||||
- `AssembleContext` — what one `assemble()` call is FOR. Merge-extensible; declares `scope?: ScopeKey` (the layer selector) here, and `dsh-agent` declares `agent?: Agent` (the typed DX field — never set without `scope`; use `assembleContextFor(agent)`). Providers must tolerate absent fields (a bare `assemble()` carries an empty, scope-less context).
|
||||
- `PromptSection` — `{ name, order, text }`. Sections are concatenated in ascending `order`. Order bands: `-100` is the harness identity, `0` the deployment persona, tool guidance uses `100–199`.
|
||||
- `PromptAssembly` — `{ sections: AssembledSection[], tools: ToolSchema[], variables: Record<string, string | undefined> }`. Section texts arrive resolved but not yet interpolated; `variables` holds every registered variable resolved against the context. Tool schemas are part of the assembly by design: "what the model is told it can do" is one coherent thing, even though adapters transmit schemas as a separate wire field.
|
||||
- `renderPrompt(assembly)` — interpolates `{{variable}}` references in each section, drops empty sections, joins with blank lines. STRICT: an unknown reference (`Object.hasOwn` lookup — prototype names like `{{constructor}}` are unknown), a registered-but-valueless reference, a malformed complete `{{…}}` group, or a `{{` that opens no complete group while a `}}` still follows (`{{{model}}}`) throws — fail loud beats shipping a malformed prompt. A lone `{{` with no `}}` anywhere after it passes through verbatim; substituted values are never re-scanned.
|
||||
|
||||
@@ -39,11 +36,11 @@ Merge-extensible: plugins can declare extra fields on `PromptAssembly` and `Asse
|
||||
- Section providers: tool packages own their cross-call guidance (`tool:bash`, `tool:read`, …); this plugin owns `harness:identity` and `deployment:persona`.
|
||||
- Variable providers: the agent loop registers `model` and `cwd`; any plugin can register the facts it owns (a future `date`, git state, …).
|
||||
- Tool schema providers: `ToolRegistry` registers itself as a tool provider automatically.
|
||||
- The `system-prompt/assemble` waterfall: mutate or replace the assembly per caller (dynamic tool filtering, extra variables).
|
||||
- The [`system-prompt/assemble` waterfall](#live-events): cooperatively mutate or replace the assembly per caller.
|
||||
|
||||
### What is NOT here
|
||||
|
||||
- Any deployment-authored prompt text outside config — the persona is this plugin's `persona` config, and every other section comes from the plugin that owns the fact. (The `harness:identity` line is deliberately a code literal: a harness fact, not a deployment choice; the `system-prompt/assemble` waterfall is the escape valve for a deployment that must drop it.)
|
||||
- Any end-user prompt-editing API — this plugin owns the config-authored global persona default, creator plugins may register agent-scoped shadows during setup, and every other section comes from the plugin that owns the fact. (The `harness:identity` line is deliberately a code literal: a harness fact, not a deployment choice; the `system-prompt/assemble` waterfall is the escape valve for a deployment that must drop it.)
|
||||
- Prompt compaction (belongs on the `agent/pre-step` seam in `dsh-agent`).
|
||||
|
||||
Design rationale: [the prompt-variables RFC](../../../docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md).
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-scope": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -30,6 +31,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* System prompt assembly registry. Plugins contribute ordered text sections,
|
||||
* tool schema providers, and named prompt variables; `assemble(context)`
|
||||
* collates them through a waterfall that runs once per step, and
|
||||
* `renderPrompt` interpolates `{{variable}}` references into the final text.
|
||||
* collates them through a waterfall that runs once per step, and `renderPrompt`
|
||||
* interpolates `{{variable}}` references into the final text.
|
||||
*
|
||||
* The harness-owned prompt openers live here too: this plugin registers the
|
||||
* static `harness:identity` section (order −100) and the deployment's
|
||||
@@ -14,6 +14,8 @@
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import type { ScopeKey, Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
declare module 'cordis' {
|
||||
@@ -27,6 +29,15 @@ declare module 'cordis' {
|
||||
* {@link PromptAssembly} (sections + tools + variables) before it is
|
||||
* rendered. Bound to the {@link SystemPrompt} service; call `next()` to
|
||||
* delegate.
|
||||
*
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed
|
||||
* by `context.scope` — a listener registered through `agent.ctx` fires only
|
||||
* for that agent's assemblies; a plain plugin listener fires for every
|
||||
* assembly (scope-less ones included, dispatched subject-less).
|
||||
*
|
||||
* The returned assembly is authoritative. This is an expert composition
|
||||
* seam: a listener that removes or replaces another plugin's protocol
|
||||
* contribution owns preserving that protocol's invariants.
|
||||
* @param assembly - the assembly built from the registered sections, tool
|
||||
* providers, and variable providers; listeners may mutate it or return a
|
||||
* replacement.
|
||||
@@ -35,10 +46,14 @@ declare module 'cordis' {
|
||||
* is for), so a listener can filter or extend per agent.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'system-prompt/assemble'(this: SystemPrompt, assembly: PromptAssembly, context: AssembleContext, next: () => Promise<PromptAssembly>): Promise<PromptAssembly>
|
||||
'system-prompt/assemble'(this: Scoped<SystemPrompt>, assembly: PromptAssembly, context: AssembleContext, next: () => Promise<PromptAssembly>): Promise<PromptAssembly>
|
||||
/**
|
||||
* A section, tool provider, or variable provider was registered or
|
||||
* unregistered (the assembly inputs changed).
|
||||
* A section, tool provider, or variable provider was registered
|
||||
* or unregistered (the assembly inputs changed — possibly for one scope
|
||||
* only). An UNFILTERED registry-subject notification, deliberately not
|
||||
* scope-filtered dispatch: a global change concerns every agent's next
|
||||
* assembly, so a scoped listener subscribing here sees every change, not
|
||||
* just its own scope's.
|
||||
* @mode emit
|
||||
*/
|
||||
'system-prompt/change'(): void
|
||||
@@ -47,30 +62,41 @@ declare module 'cordis' {
|
||||
|
||||
/**
|
||||
* Per-assembly input: what one {@link SystemPrompt.assemble} call is FOR.
|
||||
* Declared empty here so this package stays agnostic of who assembles;
|
||||
* merge-extensible — `@deepseek-ai/dsh-agent` declares the `agent` field, so
|
||||
* section text and variable providers can be functions of the calling agent.
|
||||
* Every field is optional by nature: a bare `assemble()` (tests, diagnostics)
|
||||
* carries an empty context, and providers must tolerate absent fields.
|
||||
* Merge-extensible and agnostic of who assembles — `@deepseek-ai/dsh-agent`
|
||||
* declares the `agent` field, so section text and variable providers can be
|
||||
* functions of the calling agent. Every field is optional by nature: a bare
|
||||
* `assemble()` (tests, diagnostics) carries an empty, scope-less context, and
|
||||
* providers must tolerate absent fields.
|
||||
*/
|
||||
export interface AssembleContext {}
|
||||
export interface AssembleContext {
|
||||
/**
|
||||
* The scope layer this assembly resolves (`@deepseek-ai/dsh-scope`): scoped
|
||||
* sections/variables/tool-providers registered through this key's context
|
||||
* join the assembly (shadowing same-named global contributions), and the
|
||||
* `system-prompt/assemble` waterfall dispatches in this scope. The agent
|
||||
* loop sets it to the agent (alongside the `agent` DX field — never set
|
||||
* `agent` without `scope`; the dev invariants flag the mismatch). Absent =
|
||||
* a scope-less assembly: global layer only, subject-less dispatch.
|
||||
*/
|
||||
scope?: ScopeKey
|
||||
}
|
||||
|
||||
/** One contributed section of the system prompt (registry input). */
|
||||
export interface PromptSection {
|
||||
/** Unique name — a duplicate registration throws (see {@link SystemPrompt.section}). */
|
||||
name: string
|
||||
readonly name: string
|
||||
/**
|
||||
* Sections are concatenated in ascending order. Convention: `-100` is the
|
||||
* harness identity, `0` the deployment persona, tool guidance uses 100–199;
|
||||
* other negative orders also render before the persona.
|
||||
*/
|
||||
order: number
|
||||
readonly order: number
|
||||
/**
|
||||
* Static text or a provider evaluated at each assembly with that assembly's
|
||||
* {@link AssembleContext}. The text may reference `{{variable}}`s — they are
|
||||
* interpolated later, by {@link renderPrompt}.
|
||||
*/
|
||||
text: string | ((context: AssembleContext) => string)
|
||||
readonly text: string | ((context: AssembleContext) => string)
|
||||
}
|
||||
|
||||
/** One section of an assembly: {@link PromptSection} with its text resolved. */
|
||||
@@ -83,6 +109,23 @@ export interface AssembledSection {
|
||||
text: string
|
||||
}
|
||||
|
||||
/**
|
||||
* What one tool-schema provider contributes to an assembly
|
||||
* ({@link SystemPrompt.tools}). `schemas` is the provider's POST-restriction
|
||||
* visible set for the assembly's scope — exactly what the model may be shown.
|
||||
* `knownNames` is its PRE-restriction name universe: the set configured names
|
||||
* (`toolOrder`) are validated against, so a config typo fails loud while a
|
||||
* restricted-away tool stays a normal, non-erroneous absence. Omitted,
|
||||
* `knownNames` defaults to the names of `schemas` (right for providers with no
|
||||
* restriction concept).
|
||||
*/
|
||||
export interface ToolProviderResult {
|
||||
/** The schemas this provider contributes to THIS assembly. */
|
||||
readonly schemas: readonly ToolSchema[]
|
||||
/** The pre-restriction name universe for config validation (defaults to `schemas`' names). */
|
||||
readonly knownNames?: readonly string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* The assembled prompt.
|
||||
*
|
||||
@@ -146,23 +189,26 @@ function validateToolOrder(toolOrder: string[] | undefined): string[] | undefine
|
||||
* list, plain lexicographic name order; with one, listed names take their
|
||||
* listed position and every unlisted tool lands at the
|
||||
* {@link TOOL_ORDER_REST} rest entry in lexicographic name order. A listed
|
||||
* name with no collected tool throws — misconfiguration fails loud, and this
|
||||
* is the earliest moment the registered tool set exists to check against
|
||||
* (tool plugins register after the service constructs, so load time is too
|
||||
* early): the assembly rejects, failing the caller's turn before any model
|
||||
* request. Never drops a tool, and both sorts are stable, so tools sharing a
|
||||
* name keep their collection order.
|
||||
* name outside `knownNames` — the providers' PRE-restriction name universe —
|
||||
* throws: misconfiguration fails loud, and each assembly is the earliest
|
||||
* moment the registered tool set exists to check against (tool plugins
|
||||
* register after the service constructs, so load time is too early); the
|
||||
* assembly rejects, failing the caller's turn before any model request. A
|
||||
* listed name that is KNOWN but not collected (a tool restricted away for
|
||||
* this assembly's scope) is a normal absence: its position simply
|
||||
* contributes nothing — `toolOrder` stays compatible with per-agent
|
||||
* `restrict()` masks. Never drops a collected tool, and both sorts are
|
||||
* stable, so tools sharing a name keep their collection order.
|
||||
*/
|
||||
function orderTools(tools: ToolSchema[], toolOrder: string[] | undefined): ToolSchema[] {
|
||||
function orderTools(tools: ToolSchema[], toolOrder: string[] | undefined, knownNames: ReadonlySet<string>): ToolSchema[] {
|
||||
const reserved = tools.find(tool => tool.name === TOOL_ORDER_REST)
|
||||
if (reserved !== undefined) {
|
||||
throw new Error(`tool provider returned reserved tool name "${TOOL_ORDER_REST}" (reserved for toolOrder's rest entry)`)
|
||||
}
|
||||
if (toolOrder === undefined) return tools.sort(compareToolNames)
|
||||
const registered = new Set(tools.map(tool => tool.name))
|
||||
const unknown = toolOrder.filter(name => name !== TOOL_ORDER_REST && !registered.has(name))
|
||||
const unknown = toolOrder.filter(name => name !== TOOL_ORDER_REST && !knownNames.has(name))
|
||||
if (unknown.length > 0) {
|
||||
throw new Error(`toolOrder lists unregistered tool${unknown.length > 1 ? 's' : ''} ${unknown.map(name => `"${name}"`).join(', ')}; registered tools: ${[...registered].sort().join(', ') || '(none)'}`)
|
||||
throw new Error(`toolOrder lists unregistered tool${unknown.length > 1 ? 's' : ''} ${unknown.map(name => `"${name}"`).join(', ')}; known tools: ${[...knownNames].sort().join(', ') || '(none)'}`)
|
||||
}
|
||||
const listed = new Set(toolOrder)
|
||||
const rest = tools.filter(tool => !listed.has(tool.name)).sort(compareToolNames)
|
||||
@@ -181,7 +227,10 @@ export interface Config {
|
||||
* The deployment's persona — the ONE deployment-authored fragment of the
|
||||
* system prompt, rendered as the order-0 `deployment:persona` section
|
||||
* (after the harness identity, before all tool guidance). Every agent in
|
||||
* the context shares it, subagents included. Template, not free-form text:
|
||||
* the context shares it by default; a per-agent persona is a SCOPED section
|
||||
* of the same name registered through that agent's `agent.ctx` (it shadows
|
||||
* this one for that agent — the subagent seam's `persona` request field does
|
||||
* exactly that). Template, not free-form text:
|
||||
* every complete `{{…}}` group is interpreted strictly against the
|
||||
* registered prompt variables (the shipped agent loop registers `{{model}}`
|
||||
* and `{{cwd}}`), and there is no escape syntax for literal `{{…}}` prose
|
||||
@@ -301,8 +350,12 @@ export class SystemPrompt extends Service {
|
||||
})
|
||||
|
||||
private sections: PromptSection[] = []
|
||||
private toolProviders: (() => ToolSchema[])[] = []
|
||||
private toolProviders: ((context: AssembleContext) => ToolProviderResult)[] = []
|
||||
private variableProviders = new Map<string, (context: AssembleContext) => string | undefined>()
|
||||
/** Per-scope layers (`@deepseek-ai/dsh-scope`); entries drop when a layer empties, so a disposed scope leaves no residue. */
|
||||
private scopedSections = new Map<ScopeKey, PromptSection[]>()
|
||||
private scopedToolProviders = new Map<ScopeKey, ((context: AssembleContext) => ToolProviderResult)[]>()
|
||||
private scopedVariableProviders = new Map<ScopeKey, Map<string, (context: AssembleContext) => string | undefined>>()
|
||||
private readonly toolOrder: string[] | undefined
|
||||
|
||||
constructor(ctx: Context, public config: Config) {
|
||||
@@ -330,61 +383,110 @@ export class SystemPrompt extends Service {
|
||||
|
||||
/**
|
||||
* Contribute a text section to the system prompt. Order is determined by
|
||||
* `section.order` (ascending). Throws if a section with the same name is
|
||||
* already registered (a duplicate would silently double prompt text — e.g.
|
||||
* a double-loaded tool plugin). The section is removed when the calling
|
||||
* fiber is disposed. Emits `system-prompt/change` on register/unregister.
|
||||
* `section.order` (ascending). The layer is decided by the CALLING context
|
||||
* (`@deepseek-ai/dsh-scope`): a plain plugin context contributes globally; a
|
||||
* scoped context (`agent.ctx`) contributes to that scope alone — and a
|
||||
* scoped section SHADOWS a same-named global section for that scope's
|
||||
* assemblies (most-specific-wins; this is how a per-agent persona overrides
|
||||
* `deployment:persona`). The readonly typed contribution is borrowed until
|
||||
* disposal; only the semantic
|
||||
* finite-order rule is checked at runtime. Throws if the SAME layer already has the name (a
|
||||
* duplicate would silently double prompt text — e.g. a double-loaded tool
|
||||
* plugin; the global-duplicate message names `agent.ctx` as the per-agent
|
||||
* alternative). Removed when the calling fiber is disposed. Emits
|
||||
* `system-prompt/change` on register/unregister.
|
||||
* @param section - the section to contribute (name, order, text or provider).
|
||||
* @returns the disposer that removes the section.
|
||||
* @returns the disposer that removes the section. The exact
|
||||
* Cordis effect disposer (single-shot): composite (generator) effects may
|
||||
* yield it directly — exact identity nests the teardown in order.
|
||||
*/
|
||||
section(section: PromptSection): () => void {
|
||||
if (!Number.isFinite(section.order)) {
|
||||
throw new TypeError(`prompt section "${section.name}" order must be a finite number`)
|
||||
}
|
||||
const scope = scopeOf(this.ctx)
|
||||
const dispose = this.ctx.effect(function* (this: SystemPrompt) {
|
||||
if (this.sections.some(existing => existing.name === section.name)) {
|
||||
throw new Error(`prompt section "${section.name}" is already registered`)
|
||||
const layer = scope === undefined
|
||||
? this.sections
|
||||
: this.scopedSections.get(scope) ?? (() => {
|
||||
const created: PromptSection[] = []
|
||||
this.scopedSections.set(scope, created)
|
||||
return created
|
||||
})()
|
||||
if (layer.some(existing => existing.name === section.name)) {
|
||||
throw new Error(scope === undefined
|
||||
? `prompt section "${section.name}" is already registered (for a per-agent override, register through that agent's \`agent.ctx\` instead)`
|
||||
: `prompt section "${section.name}" is already registered in this scope`)
|
||||
}
|
||||
this.sections.push(section)
|
||||
layer.push(section)
|
||||
// Yield the rollback BEFORE emitting `system-prompt/change`: a generator
|
||||
// effect collects each yielded disposer before the next step runs, so a
|
||||
// throwing change listener removes the section instead of leaking it into
|
||||
// every future assembly.
|
||||
yield () => {
|
||||
const index = this.sections.indexOf(section)
|
||||
const index = layer.indexOf(section)
|
||||
/* v8 ignore next 3 -- defensive: section was registered, so indexOf is guaranteed >= 0 */
|
||||
if (index >= 0) this.sections.splice(index, 1)
|
||||
if (index >= 0) layer.splice(index, 1)
|
||||
if (scope !== undefined && layer.length === 0) this.scopedSections.delete(scope)
|
||||
this.ctx.emit('system-prompt/change')
|
||||
}
|
||||
this.ctx.emit('system-prompt/change')
|
||||
}.bind(this), 'systemPrompt.section()')
|
||||
// ctx.effect's disposer returns Promise<void>; our disposer API is
|
||||
// synchronous fire-and-forget — discard the (always-resolved) promise.
|
||||
return () => void dispose()
|
||||
// The EXACT cordis effect disposer, not a wrapper: a composite (generator)
|
||||
// effect that owns a teardown ORDER must be able to yield THIS function —
|
||||
// cordis nests a disposer out of the fiber's concurrent sibling list by
|
||||
// exact function identity, so a wrapper would silently break the nesting
|
||||
// (the agents.register() lesson). Cleanup is synchronous because this
|
||||
// registration installs only synchronous state and notifications.
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||
return dispose
|
||||
}
|
||||
|
||||
/**
|
||||
* Contribute a tool-schema provider that is evaluated at each assembly
|
||||
* call (so it can reflect the live registry state). The provider is
|
||||
* removed when the calling fiber is disposed. A provider must not return a
|
||||
* schema named {@link TOOL_ORDER_REST}; that name is reserved for
|
||||
* Contribute a tool-schema provider, evaluated at each assembly call with
|
||||
* that assembly's {@link AssembleContext} (so it reflects the live registry
|
||||
* state AND the assembly's scope — see {@link ToolProviderResult} for the
|
||||
* `schemas`/`knownNames` split). The layer is decided by the calling
|
||||
* context: a scoped provider (registered through `agent.ctx`) is consulted
|
||||
* only for that scope's assemblies. Removed when the calling fiber is
|
||||
* disposed. A provider must not return a schema named
|
||||
* {@link TOOL_ORDER_REST}; that name is reserved for
|
||||
* {@link Config.toolOrder}'s rest entry and rejects the assembly. Emits
|
||||
* `system-prompt/change`.
|
||||
* @param provider - evaluated at every {@link assemble} for fresh schemas.
|
||||
* @returns the disposer that removes the provider.
|
||||
* @returns the disposer that removes the provider. The exact
|
||||
* Cordis effect disposer (single-shot): composite (generator) effects may
|
||||
* yield it directly — exact identity nests the teardown in order.
|
||||
*/
|
||||
tools(provider: () => ToolSchema[]): () => void {
|
||||
tools(provider: (context: AssembleContext) => ToolProviderResult): () => void {
|
||||
const scope = scopeOf(this.ctx)
|
||||
const dispose = this.ctx.effect(function* (this: SystemPrompt) {
|
||||
this.toolProviders.push(provider)
|
||||
const layer = scope === undefined
|
||||
? this.toolProviders
|
||||
: this.scopedToolProviders.get(scope) ?? (() => {
|
||||
const created: ((context: AssembleContext) => ToolProviderResult)[] = []
|
||||
this.scopedToolProviders.set(scope, created)
|
||||
return created
|
||||
})()
|
||||
layer.push(provider)
|
||||
// Yield the rollback BEFORE emitting `system-prompt/change` (see section()).
|
||||
yield () => {
|
||||
const index = this.toolProviders.indexOf(provider)
|
||||
const index = layer.indexOf(provider)
|
||||
/* v8 ignore next 3 -- defensive: provider was registered, so indexOf is guaranteed >= 0 */
|
||||
if (index >= 0) this.toolProviders.splice(index, 1)
|
||||
if (index >= 0) layer.splice(index, 1)
|
||||
if (scope !== undefined && layer.length === 0) this.scopedToolProviders.delete(scope)
|
||||
this.ctx.emit('system-prompt/change')
|
||||
}
|
||||
this.ctx.emit('system-prompt/change')
|
||||
}.bind(this), 'systemPrompt.tools()')
|
||||
// ctx.effect's disposer returns Promise<void>; our disposer API is
|
||||
// synchronous fire-and-forget — discard the (always-resolved) promise.
|
||||
return () => void dispose()
|
||||
// The EXACT cordis effect disposer, not a wrapper: a composite (generator)
|
||||
// effect that owns a teardown ORDER must be able to yield THIS function —
|
||||
// cordis nests a disposer out of the fiber's concurrent sibling list by
|
||||
// exact function identity, so a wrapper would silently break the nesting
|
||||
// (the agents.register() lesson). Cleanup is synchronous because this
|
||||
// registration installs only synchronous state and notifications.
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||
return dispose
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -392,50 +494,75 @@ export class SystemPrompt extends Service {
|
||||
* `{{name}}`. The provider is evaluated at each assembly with that
|
||||
* assembly's {@link AssembleContext}; returning `undefined` means "no value
|
||||
* for this assembly" (a section referencing it then fails to render — a
|
||||
* deployment must not claim facts it does not have). Throws on a name that
|
||||
* does not match `[a-z][a-z0-9_]*` (it could never be referenced) or is
|
||||
* already registered. Removed when the calling fiber is disposed; emits
|
||||
* deployment must not claim facts it does not have). The layer is decided
|
||||
* by the calling context: a scoped variable (registered through
|
||||
* `agent.ctx`) resolves only for that scope's assemblies and SHADOWS a
|
||||
* same-named global variable there. Throws on a name that does not match
|
||||
* `[a-z][a-z0-9_]*` (it could never be referenced) or one already registered
|
||||
* in the SAME layer. Removed when the calling fiber is disposed; emits
|
||||
* `system-prompt/change` on register/unregister.
|
||||
* @param name - the reference name (matches `[a-z][a-z0-9_]*`).
|
||||
* @param provider - evaluated at every {@link assemble} for the value.
|
||||
* @returns the disposer that removes the variable.
|
||||
* @returns the disposer that removes the variable. The exact
|
||||
* Cordis effect disposer (single-shot): composite (generator) effects may
|
||||
* yield it directly — exact identity nests the teardown in order.
|
||||
*/
|
||||
variable(name: string, provider: (context: AssembleContext) => string | undefined): () => void {
|
||||
if (!VARIABLE_NAME.test(name)) {
|
||||
throw new Error(`invalid prompt variable name "${name}" (must match ${String(VARIABLE_NAME)})`)
|
||||
}
|
||||
const scope = scopeOf(this.ctx)
|
||||
const dispose = this.ctx.effect(function* (this: SystemPrompt) {
|
||||
if (!VARIABLE_NAME.test(name)) {
|
||||
throw new Error(`invalid prompt variable name "${name}" (must match ${String(VARIABLE_NAME)})`)
|
||||
const layer = scope === undefined
|
||||
? this.variableProviders
|
||||
: this.scopedVariableProviders.get(scope) ?? (() => {
|
||||
const created = new Map<string, (context: AssembleContext) => string | undefined>()
|
||||
this.scopedVariableProviders.set(scope, created)
|
||||
return created
|
||||
})()
|
||||
if (layer.has(name)) {
|
||||
throw new Error(scope === undefined
|
||||
? `prompt variable "${name}" is already registered (for a per-agent value, register through that agent's \`agent.ctx\` instead)`
|
||||
: `prompt variable "${name}" is already registered in this scope`)
|
||||
}
|
||||
if (this.variableProviders.has(name)) {
|
||||
throw new Error(`prompt variable "${name}" is already registered`)
|
||||
}
|
||||
this.variableProviders.set(name, provider)
|
||||
layer.set(name, provider)
|
||||
// Yield the rollback BEFORE emitting `system-prompt/change` (see section()).
|
||||
yield () => {
|
||||
this.variableProviders.delete(name)
|
||||
layer.delete(name)
|
||||
if (scope !== undefined && layer.size === 0) this.scopedVariableProviders.delete(scope)
|
||||
this.ctx.emit('system-prompt/change')
|
||||
}
|
||||
this.ctx.emit('system-prompt/change')
|
||||
}.bind(this), 'systemPrompt.variable()')
|
||||
// ctx.effect's disposer returns Promise<void>; our disposer API is
|
||||
// synchronous fire-and-forget — discard the (always-resolved) promise.
|
||||
return () => void dispose()
|
||||
// The EXACT cordis effect disposer, not a wrapper: a composite (generator)
|
||||
// effect that owns a teardown ORDER must be able to yield THIS function —
|
||||
// cordis nests a disposer out of the fiber's concurrent sibling list by
|
||||
// exact function identity, so a wrapper would silently break the nesting
|
||||
// (the agents.register() lesson). Cleanup is synchronous because this
|
||||
// registration installs only synchronous state and notifications.
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||
return dispose
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble the current prompt for one caller: section texts are resolved
|
||||
* against `context` and sorted by order, tools collected from all providers
|
||||
* and put in the canonical model-facing order ({@link Config.toolOrder}, or
|
||||
* lexicographic name order when unconfigured — provider registration order
|
||||
* is a plugin-load artifact and never reaches the assembly; a configured
|
||||
* order naming a tool no provider contributed rejects the assembly), and every
|
||||
* registered variable resolved against `context` into `assembly.variables`.
|
||||
* Tool schemas are deep-cloned because adapters and request waterfalls may
|
||||
* mutate schema objects. Runs through the `system-prompt/assemble`
|
||||
* waterfall, giving listeners the opportunity to mutate or replace the
|
||||
* assembly before it reaches the model — like the sections' `order` sort,
|
||||
* tool canonicalization happens on the initial assembly, and a listener
|
||||
* owns the determinism of whatever it emits. Await the result before
|
||||
* reading the assembly values — waterfall listeners may be async.
|
||||
* Assemble the current prompt for one caller: the global layer merged with
|
||||
* {@link AssembleContext.scope}'s layer (scoped sections/variables SHADOW
|
||||
* same-named global ones — most-specific-wins) — section texts resolved
|
||||
* against `context` and sorted by order across the union, tools collected
|
||||
* from the global providers plus the scope's and put in the canonical
|
||||
* model-facing order ({@link Config.toolOrder}, or lexicographic name order
|
||||
* when unconfigured — provider registration order is a plugin-load artifact
|
||||
* and never reaches the assembly; a configured order naming a tool outside
|
||||
* the providers' `knownNames` universe rejects the assembly, while a known
|
||||
* name restricted away for this scope is a normal absence), and every
|
||||
* visible variable resolved against `context` into `assembly.variables`.
|
||||
* Tool schemas are detached because assembly waterfalls may mutate them.
|
||||
* Runs through the `system-prompt/assemble` waterfall, giving listeners the
|
||||
* opportunity to mutate or replace the assembly; the returned value is the
|
||||
* authoritative model-visible composition. Like the sections' `order`
|
||||
* sort, tool canonicalization happens on the initial assembly; listener
|
||||
* output owns its own determinism. Await the result before reading the
|
||||
* assembly values — waterfall listeners may be async.
|
||||
* Interpolation happens later, in {@link renderPrompt}.
|
||||
* @param context - what this assembly is for (defaults to an empty context;
|
||||
* see {@link AssembleContext}).
|
||||
@@ -445,25 +572,64 @@ export class SystemPrompt extends Service {
|
||||
// rejection: a Promise-returning method must not throw synchronously
|
||||
// (`assemble().catch(...)` would miss it).
|
||||
async assemble(context: AssembleContext = {}): Promise<PromptAssembly> {
|
||||
const scope = context.scope
|
||||
// Variables: global layer first, then the scope's layer OVERWRITES
|
||||
// same-named entries (shadowing — a per-agent value wins for that agent).
|
||||
const variables: Record<string, string | undefined> = {}
|
||||
for (const [name, provider] of this.variableProviders) {
|
||||
variables[name] = provider(context)
|
||||
}
|
||||
const scopedVariables = scope === undefined ? undefined : this.scopedVariableProviders.get(scope)
|
||||
for (const [name, provider] of scopedVariables ?? []) {
|
||||
variables[name] = provider(context)
|
||||
}
|
||||
// Sections: merge by name, scoped REPLACING same-named global entries
|
||||
// (most-specific-wins — the per-agent persona mechanism), then sort by
|
||||
// order across the union. Registration order within a layer is preserved
|
||||
// for equal orders (stable sort).
|
||||
const sectionByName = new Map<string, PromptSection>()
|
||||
for (const section of this.sections) sectionByName.set(section.name, section)
|
||||
for (const section of (scope === undefined ? [] : this.scopedSections.get(scope)) ?? []) {
|
||||
sectionByName.set(section.name, section)
|
||||
}
|
||||
// Tools: consult the global providers plus the scope's, each with this
|
||||
// assembly's context. `schemas` are what the model may see (already
|
||||
// post-restriction, per provider); `knownNames` (defaulting to the
|
||||
// schemas' names) form the pre-restriction universe `toolOrder` is
|
||||
// validated against, so a restricted-away tool is a normal absence while
|
||||
// a config typo still fails every assembly loudly.
|
||||
const providers = [
|
||||
...this.toolProviders,
|
||||
...(scope === undefined ? [] : this.scopedToolProviders.get(scope)) ?? [],
|
||||
]
|
||||
const collected: ToolSchema[] = []
|
||||
const knownNames = new Set<string>()
|
||||
for (const provider of providers) {
|
||||
const result = provider(context)
|
||||
const schemas = result.schemas.map(({ name, description, parameters }): ToolSchema => ({
|
||||
name,
|
||||
description,
|
||||
parameters: structuredClone(parameters),
|
||||
}))
|
||||
const acceptedKnownNames = result.knownNames ?? schemas.map(tool => tool.name)
|
||||
collected.push(...schemas)
|
||||
for (const name of acceptedKnownNames) knownNames.add(name)
|
||||
}
|
||||
const assembly: PromptAssembly = {
|
||||
sections: this.sections
|
||||
sections: [...sectionByName.values()]
|
||||
.map(section => ({
|
||||
name: section.name,
|
||||
order: section.order,
|
||||
text: typeof section.text === 'function' ? section.text(context) : section.text,
|
||||
}))
|
||||
.sort((a, b) => a.order - b.order),
|
||||
tools: orderTools(
|
||||
this.toolProviders.flatMap(provider =>
|
||||
provider().map(tool => ({ ...tool, parameters: structuredClone(tool.parameters) }))),
|
||||
this.toolOrder),
|
||||
tools: orderTools(collected, this.toolOrder, knownNames),
|
||||
variables,
|
||||
}
|
||||
return this.ctx.waterfall(this, 'system-prompt/assemble', assembly, context, () => Promise.resolve(assembly))
|
||||
return this.ctx.waterfall(
|
||||
scopeTarget(this, scope), 'system-prompt/assemble', assembly, context,
|
||||
() => Promise.resolve(assembly),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
153
packages/core/system-prompt/tests/scoped.spec.ts
Normal file
153
packages/core/system-prompt/tests/scoped.spec.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { createScope, scopeOf } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scope, ScopeKey } from '@deepseek-ai/dsh-scope'
|
||||
import SystemPrompt, { TOOL_ORDER_REST, renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { Config, PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
|
||||
|
||||
async function mount(config: Config = {}): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt, config)
|
||||
return ctx
|
||||
}
|
||||
|
||||
async function mintScope(ctx: Context, name: string): Promise<Scope> {
|
||||
let scope!: Scope
|
||||
// The scoped context resolves services through the MINTING plugin's
|
||||
// dependency chain — the minter must inject what scope holders will reach.
|
||||
await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, { name }) },
|
||||
{ inject: ['systemPrompt'] }))
|
||||
return scope
|
||||
}
|
||||
|
||||
const schema = (name: string) => ({ name, description: `tool ${name}`, parameters: {} })
|
||||
|
||||
/** The key a test scope was minted with (scopeOf over the scope's own ctx). */
|
||||
function scopeKeyOf(scope: Scope): ScopeKey {
|
||||
// scopeOf never answers undefined for a context the scope itself minted.
|
||||
|
||||
return scopeOf(scope.ctx)!
|
||||
}
|
||||
|
||||
describe('scoped sections', () => {
|
||||
it('a scoped persona shadows deployment:persona for that scope only (either order)', async () => {
|
||||
const ctx = await mount({ persona: 'You are the deployment.' })
|
||||
const scope = await mintScope(ctx, 'child')
|
||||
scope.ctx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: 'You run tests.' })
|
||||
|
||||
const scoped = renderPrompt(await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) }))
|
||||
const global = renderPrompt(await ctx.systemPrompt.assemble())
|
||||
expect(scoped).toContain('You run tests.')
|
||||
expect(scoped).not.toContain('You are the deployment.')
|
||||
expect(global).toContain('You are the deployment.')
|
||||
expect(global).not.toContain('You run tests.')
|
||||
})
|
||||
|
||||
it('scoped-only sections join that scope alone; disposal removes them', async () => {
|
||||
const ctx = await mount()
|
||||
const scope = await mintScope(ctx, 'child')
|
||||
scope.ctx.systemPrompt.section({ name: 'child:extra', order: 50, text: 'Extra guidance.' })
|
||||
|
||||
expect(renderPrompt(await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) }))).toContain('Extra guidance.')
|
||||
expect(renderPrompt(await ctx.systemPrompt.assemble())).not.toContain('Extra guidance.')
|
||||
await scope.dispose()
|
||||
expect(renderPrompt(await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) }))).not.toContain('Extra guidance.')
|
||||
})
|
||||
|
||||
it('duplicate names throw per layer, naming agent.ctx for the global case', async () => {
|
||||
const ctx = await mount()
|
||||
const scope = await mintScope(ctx, 'child')
|
||||
ctx.systemPrompt.section({ name: 'x', order: 1, text: 'a' })
|
||||
expect(() => ctx.systemPrompt.section({ name: 'x', order: 1, text: 'b' })).toThrow(/agent\.ctx/)
|
||||
scope.ctx.systemPrompt.section({ name: 'y', order: 1, text: 'a' })
|
||||
expect(() => scope.ctx.systemPrompt.section({ name: 'y', order: 1, text: 'b' })).toThrow(/already registered in this scope/)
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('scoped variables', () => {
|
||||
it('a scoped variable shadows its global name-twin for that scope', async () => {
|
||||
const ctx = await mount({ persona: 'Mode: {{mode}}.' })
|
||||
const scope = await mintScope(ctx, 'child')
|
||||
ctx.systemPrompt.variable('mode', () => 'normal')
|
||||
scope.ctx.systemPrompt.variable('mode', () => 'strict')
|
||||
|
||||
expect(renderPrompt(await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) }))).toContain('Mode: strict.')
|
||||
expect(renderPrompt(await ctx.systemPrompt.assemble())).toContain('Mode: normal.')
|
||||
})
|
||||
|
||||
it('same-layer duplicates throw; scoped layer cleans up on dispose', async () => {
|
||||
const ctx = await mount()
|
||||
const scope = await mintScope(ctx, 'child')
|
||||
scope.ctx.systemPrompt.variable('v', () => '1')
|
||||
expect(() => scope.ctx.systemPrompt.variable('v', () => '2')).toThrow(/already registered in this scope/)
|
||||
await scope.dispose()
|
||||
// Re-minting a scope with the SAME key starts clean.
|
||||
const again = await mintScope(ctx, 'child2')
|
||||
again.ctx.systemPrompt.variable('v', () => '3')
|
||||
})
|
||||
})
|
||||
|
||||
describe('scoped tool providers and toolOrder × restriction', () => {
|
||||
it('scoped providers are consulted only for their scope', async () => {
|
||||
const ctx = await mount()
|
||||
const scope = await mintScope(ctx, 'child')
|
||||
ctx.systemPrompt.tools(() => ({ schemas: [schema('global_tool')] }))
|
||||
scope.ctx.systemPrompt.tools(() => ({ schemas: [schema('scoped_tool')] }))
|
||||
|
||||
const scoped = await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) })
|
||||
const global = await ctx.systemPrompt.assemble()
|
||||
expect(scoped.tools.map(t => t.name)).toEqual(['global_tool', 'scoped_tool'])
|
||||
expect(global.tools.map(t => t.name)).toEqual(['global_tool'])
|
||||
})
|
||||
|
||||
it('disposing a scoped tool provider empties its layer without residue', async () => {
|
||||
const ctx = await mount()
|
||||
const scope = await mintScope(ctx, 'child')
|
||||
const dispose = scope.ctx.systemPrompt.tools(() => ({ schemas: [schema('scoped_tool')] }))
|
||||
dispose()
|
||||
const after = await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) })
|
||||
expect(after.tools.map(t => t.name)).toEqual([])
|
||||
// Re-registering through the same scope starts a fresh layer.
|
||||
scope.ctx.systemPrompt.tools(() => ({ schemas: [schema('again')] }))
|
||||
const again = await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) })
|
||||
expect(again.tools.map(t => t.name)).toEqual(['again'])
|
||||
})
|
||||
|
||||
it('a toolOrder entry restricted away for a scope is a normal absence, while a typo still throws', async () => {
|
||||
const ctx = await mount({ toolOrder: ['bash', TOOL_ORDER_REST] })
|
||||
// A provider mimicking the registry's restriction split: bash exists
|
||||
// (knownNames) but is masked for this assembly (schemas).
|
||||
ctx.systemPrompt.tools(() => ({
|
||||
schemas: [schema('read')],
|
||||
knownNames: ['read', 'bash'],
|
||||
}))
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
expect(assembly.tools.map(t => t.name)).toEqual(['read'])
|
||||
|
||||
const bad = await mount({ toolOrder: ['basj', TOOL_ORDER_REST] })
|
||||
bad.systemPrompt.tools(() => ({ schemas: [schema('read')], knownNames: ['read', 'bash'] }))
|
||||
await expect(bad.systemPrompt.assemble()).rejects.toThrow('toolOrder lists unregistered tool "basj"; known tools: bash, read')
|
||||
})
|
||||
})
|
||||
|
||||
describe('scoped assemble dispatch', () => {
|
||||
it('an agent.ctx assemble listener shapes only its own scope\'s assemblies', async () => {
|
||||
const ctx = await mount()
|
||||
const scope = await mintScope(ctx, 'child')
|
||||
const shaped: (ScopeKey | undefined)[] = []
|
||||
scope.ctx.on('system-prompt/assemble', async (_assembly: PromptAssembly, context, next: () => Promise<PromptAssembly>) => {
|
||||
shaped.push(context.scope)
|
||||
const result = await next()
|
||||
result.sections.push({ name: 'listener:extra', order: 999, text: 'listener text' })
|
||||
return result
|
||||
})
|
||||
|
||||
const scoped = await ctx.systemPrompt.assemble({ scope: scopeKeyOf(scope) })
|
||||
const global = await ctx.systemPrompt.assemble()
|
||||
expect(scoped.sections.some(s => s.name === 'listener:extra')).toBe(true)
|
||||
expect(global.sections.some(s => s.name === 'listener:extra')).toBe(false)
|
||||
expect(shaped).toHaveLength(1)
|
||||
})
|
||||
|
||||
})
|
||||
@@ -52,7 +52,7 @@ describe('SystemPrompt', () => {
|
||||
|
||||
ctx.systemPrompt.section({ name: 'cwd', order: 20, text: () => 'cwd: /tmp' })
|
||||
ctx.systemPrompt.section({ name: 'rules', order: 10, text: 'Be precise.' })
|
||||
ctx.systemPrompt.tools(() => [{ name: 'echo', description: 'echo back', parameters: {} }])
|
||||
ctx.systemPrompt.tools(() => ({ schemas: [{ name: 'echo', description: 'echo back', parameters: {} }] }))
|
||||
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
expect(assembly.sections.map(s => s.name)).toEqual(['harness:identity', 'deployment:persona', 'rules', 'cwd'])
|
||||
@@ -84,7 +84,7 @@ describe('SystemPrompt', () => {
|
||||
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
inner.systemPrompt.section({ name: 'scoped', order: 0, text: 'scoped section' })
|
||||
inner.systemPrompt.tools(() => [{ name: 'scoped-tool', description: '', parameters: {} }])
|
||||
inner.systemPrompt.tools(() => ({ schemas: [{ name: 'scoped-tool', description: '', parameters: {} }] }))
|
||||
inner.systemPrompt.variable('scoped_var', () => 'v')
|
||||
}, { inject: ['systemPrompt'] }))
|
||||
|
||||
@@ -111,6 +111,14 @@ describe('SystemPrompt', () => {
|
||||
expect(contributed(assembly).map(s => s.text)).toEqual(['first'])
|
||||
})
|
||||
|
||||
it('rejects a non-finite section order', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
expect(() => ctx.systemPrompt.section({ name: 'bad-order', order: Number.NaN, text: 'x' }))
|
||||
.toThrow('order must be a finite number')
|
||||
expect(contributed(await ctx.systemPrompt.assemble())).toEqual([])
|
||||
})
|
||||
|
||||
it('rolls back a section when a system-prompt/change listener throws (P1-1)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
@@ -141,11 +149,11 @@ describe('SystemPrompt', () => {
|
||||
if (!threw) { threw = true; throw new Error('boom change listener') }
|
||||
})
|
||||
|
||||
expect(() => ctx.systemPrompt.tools(() => [{ name: 't', description: '', parameters: {} }])).toThrow('boom change listener')
|
||||
expect(() => ctx.systemPrompt.tools(() => ({ schemas: [{ name: 't', description: '', parameters: {} }] }))).toThrow('boom change listener')
|
||||
expect((await ctx.systemPrompt.assemble()).tools).toHaveLength(0) // nothing leaked
|
||||
|
||||
off()
|
||||
ctx.systemPrompt.tools(() => [{ name: 't', description: '', parameters: {} }])
|
||||
ctx.systemPrompt.tools(() => ({ schemas: [{ name: 't', description: '', parameters: {} }] }))
|
||||
expect((await ctx.systemPrompt.assemble()).tools.map(t => t.name)).toEqual(['t'])
|
||||
})
|
||||
|
||||
@@ -209,7 +217,7 @@ describe('SystemPrompt', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
ctx.systemPrompt.section({ name: 'base', order: 0, text: 'base' })
|
||||
ctx.systemPrompt.tools(() => [{ name: 't', description: 'tool', parameters: { type: 'object', properties: {} } }])
|
||||
ctx.systemPrompt.tools(() => ({ schemas: [{ name: 't', description: 'tool', parameters: { type: 'object', properties: {} } }] }))
|
||||
|
||||
const first = await ctx.systemPrompt.assemble()
|
||||
first.sections[0]!.name = 'mutated'
|
||||
@@ -243,7 +251,7 @@ describe('SystemPrompt', () => {
|
||||
let changeCount = 0
|
||||
ctx.on('system-prompt/change', () => void changeCount++)
|
||||
|
||||
const dispose = ctx.systemPrompt.tools(() => [])
|
||||
const dispose = ctx.systemPrompt.tools(() => ({ schemas: [] }))
|
||||
// registration emits change
|
||||
expect(changeCount).toBe(1)
|
||||
|
||||
@@ -257,7 +265,7 @@ describe('SystemPrompt', () => {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
inner.systemPrompt.tools(() => [{ name: 'fiber-tool', description: '', parameters: {} }])
|
||||
inner.systemPrompt.tools(() => ({ schemas: [{ name: 'fiber-tool', description: '', parameters: {} }] }))
|
||||
}, { inject: ['systemPrompt'] }))
|
||||
|
||||
expect((await ctx.systemPrompt.assemble()).tools).toHaveLength(1)
|
||||
@@ -280,7 +288,7 @@ describe('SystemPrompt', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
|
||||
const dispose = ctx.systemPrompt.tools(() => [{ name: 'direct-tool', description: '', parameters: {} }])
|
||||
const dispose = ctx.systemPrompt.tools(() => ({ schemas: [{ name: 'direct-tool', description: '', parameters: {} }] }))
|
||||
expect((await ctx.systemPrompt.assemble()).tools).toHaveLength(1)
|
||||
|
||||
dispose()
|
||||
|
||||
@@ -26,39 +26,39 @@ describe('SystemPrompt tool order', () => {
|
||||
|
||||
it('assembles tools in lexicographic name order when no toolOrder is configured', async () => {
|
||||
const ctx = await mount()
|
||||
ctx.systemPrompt.tools(() => [tool('charlie'), tool('alpha')])
|
||||
ctx.systemPrompt.tools(() => [tool('bravo')])
|
||||
ctx.systemPrompt.tools(() => ({ schemas: [tool('charlie'), tool('alpha')] }))
|
||||
ctx.systemPrompt.tools(() => ({ schemas: [tool('bravo')] }))
|
||||
expect(names(await ctx.systemPrompt.assemble())).toEqual(['alpha', 'bravo', 'charlie'])
|
||||
})
|
||||
|
||||
it('assembles the same order regardless of provider registration order', async () => {
|
||||
const forward = await mount()
|
||||
forward.systemPrompt.tools(() => [tool('alpha')])
|
||||
forward.systemPrompt.tools(() => [tool('zulu')])
|
||||
forward.systemPrompt.tools(() => ({ schemas: [tool('alpha')] }))
|
||||
forward.systemPrompt.tools(() => ({ schemas: [tool('zulu')] }))
|
||||
const backward = await mount()
|
||||
backward.systemPrompt.tools(() => [tool('zulu')])
|
||||
backward.systemPrompt.tools(() => [tool('alpha')])
|
||||
backward.systemPrompt.tools(() => ({ schemas: [tool('zulu')] }))
|
||||
backward.systemPrompt.tools(() => ({ schemas: [tool('alpha')] }))
|
||||
expect(names(await forward.systemPrompt.assemble())).toEqual(['alpha', 'zulu'])
|
||||
expect(names(await backward.systemPrompt.assemble())).toEqual(['alpha', 'zulu'])
|
||||
})
|
||||
|
||||
it('applies a configured toolOrder: listed positions, rest at the rest entry lexicographically', async () => {
|
||||
const ctx = await mount({ toolOrder: ['todo_write', TOOL_ORDER_REST, 'bash'] })
|
||||
ctx.systemPrompt.tools(() => [tool('bash'), tool('echo_b'), tool('todo_write'), tool('echo_a')])
|
||||
ctx.systemPrompt.tools(() => ({ schemas: [tool('bash'), tool('echo_b'), tool('todo_write'), tool('echo_a')] }))
|
||||
expect(names(await ctx.systemPrompt.assemble())).toEqual(['todo_write', 'echo_a', 'echo_b', 'bash'])
|
||||
})
|
||||
|
||||
it('rejects the assembly when toolOrder names a tool that is not registered (misconfiguration blocks work)', async () => {
|
||||
const ctx = await mount({ toolOrder: ['todo_write', 'ghost', TOOL_ORDER_REST, 'wraith'] })
|
||||
ctx.systemPrompt.tools(() => [tool('bash'), tool('todo_write')])
|
||||
ctx.systemPrompt.tools(() => ({ schemas: [tool('bash'), tool('todo_write')] }))
|
||||
await expect(ctx.systemPrompt.assemble()).rejects.toThrow(
|
||||
'toolOrder lists unregistered tools "ghost", "wraith"; registered tools: bash, todo_write')
|
||||
'toolOrder lists unregistered tools "ghost", "wraith"; known tools: bash, todo_write')
|
||||
})
|
||||
|
||||
it('names the single unregistered tool when no tools are registered at all', async () => {
|
||||
const ctx = await mount({ toolOrder: ['ghost', TOOL_ORDER_REST] })
|
||||
await expect(ctx.systemPrompt.assemble()).rejects.toThrow(
|
||||
'toolOrder lists unregistered tool "ghost"; registered tools: (none)')
|
||||
'toolOrder lists unregistered tool "ghost"; known tools: (none)')
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -66,21 +66,21 @@ describe('SystemPrompt tool order', () => {
|
||||
['with only the rest entry configured', [TOOL_ORDER_REST]],
|
||||
])('rejects a provider tool named like the reserved rest entry %s', async (_case, toolOrder) => {
|
||||
const ctx = await mount(toolOrder === undefined ? {} : { toolOrder })
|
||||
ctx.systemPrompt.tools(() => [tool(TOOL_ORDER_REST)])
|
||||
ctx.systemPrompt.tools(() => ({ schemas: [tool(TOOL_ORDER_REST)] }))
|
||||
await expect(ctx.systemPrompt.assemble()).rejects.toThrow(
|
||||
`tool provider returned reserved tool name "${TOOL_ORDER_REST}"`)
|
||||
})
|
||||
|
||||
it('keeps collection order between tools that share a name (stable sort)', async () => {
|
||||
const ctx = await mount()
|
||||
ctx.systemPrompt.tools(() => [tool('dup', 'first'), tool('anchor'), tool('dup', 'second')])
|
||||
ctx.systemPrompt.tools(() => ({ schemas: [tool('dup', 'first'), tool('anchor'), tool('dup', 'second')] }))
|
||||
const assembly = await ctx.systemPrompt.assemble()
|
||||
expect(assembly.tools.map(t => t.description)).toEqual(['anchor', 'first', 'second'])
|
||||
})
|
||||
|
||||
it('canonicalizes BEFORE the assemble waterfall: listeners see the ordered list and own their own edits', async () => {
|
||||
const ctx = await mount()
|
||||
ctx.systemPrompt.tools(() => [tool('zulu'), tool('alpha')])
|
||||
ctx.systemPrompt.tools(() => ({ schemas: [tool('zulu'), tool('alpha')] }))
|
||||
let seen: string[] | undefined
|
||||
ctx.on('system-prompt/assemble', function (assembly, _context, next) {
|
||||
seen = assembly.tools.map(t => t.name)
|
||||
|
||||
@@ -19,6 +19,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/scope"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# dsh-tools
|
||||
|
||||
Tool registry and execution pipeline. Tool plugins register their schemas and executors; the agent loop executes each call through `tools/pre-execute` (the allow/deny gate) → `tools/execute` (an around-dispatch wrapper for timeout/retry/metrics plugins) → `tools/post-execute` (inspect/replace the result, attach context). The registry also owns HOW its tools are presented to the model — its `mode` config selects native function calling, [Code Mode](#code-mode), or both.
|
||||
Tool registry and execution pipeline. Tool plugins register their schemas and executors; the agent loop executes each call through `tools/pre-execute` (the extensible allow/deny gate) → monotonic registered guards → `tools/execute` (an around-dispatch wrapper for timeout/retry/metrics plugins) → `tools/post-execute` (inspect/replace the result, attach context) → the observe-only `tools/result` notification. The registry also owns HOW its tools are presented to the model — its `mode` config selects native function calling, [Code Mode](#code-mode), or both.
|
||||
|
||||
## Service: `ToolRegistry` (ctx key: `tools`)
|
||||
|
||||
@@ -11,41 +11,41 @@ tools:
|
||||
mode: native # native (default) | code | both
|
||||
```
|
||||
|
||||
`native` contributes every registered tool as a wire function definition — the default, byte-for-byte the pre-config behavior. `code` contributes exactly ONE wire tool, `run_code`, plus the generated `tools:sdk` prompt section (see [Code Mode](#code-mode)). `both` contributes every native definition AND `run_code` + the SDK section. Non-native modes require a loaded `ctx.codeRuntime` with `language: 'typescript'`; a missing or mismatched runtime rejects every prompt assembly with an actionable error, and a `systemPrompt.toolOrder` naming tools the mode no longer contributes rejects the assembly the same way.
|
||||
`native` contributes the calling agent's visible end capabilities as wire function definitions. Under `code`, this registry contributes the reserved `run_code` transport plus the generated `tools:sdk` prompt section (see [Code Mode](#code-mode)); `both` contributes the visible native definitions and both infrastructure pieces. Restrictions cannot remove `run_code`, and registering, shadowing, or explicitly filtering that reserved name fails loudly. An expert `system-prompt/assemble` listener may replace any prompt or schema contribution; its returned assembly is authoritative, so the listener owns preserving Code Mode when the protocol should remain active. Non-native modes require a loaded `ctx.codeRuntime` with `language: 'typescript'`; a missing or mismatched runtime rejects every prompt assembly with an actionable error, and a `systemPrompt.toolOrder` naming tools the mode no longer contributes rejects the assembly the same way.
|
||||
|
||||
### Public API
|
||||
|
||||
- `ctx.tools.register(definition: ToolDefinition): () => void` Register a tool. Disposed with the calling fiber.
|
||||
- `ctx.tools.get(name: string): ToolDefinition | undefined`
|
||||
- `ctx.tools.schemas(): ToolSchema[]` Schemas of all registered tools (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog RFC](../../../docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md)).
|
||||
- `ctx.tools.execute(exec: ToolExecution): Promise<ToolExecutionResult>` Execute one tool call through the `tools/pre-execute` → `tools/execute` → `tools/post-execute` pipeline.
|
||||
- `ctx.tools.register(definition: ToolDefinition): () => void` Register a trusted typed same-process definition. The layer is the calling context's scope: a plain plugin context registers globally; an agent's `agent.ctx` registers for that agent alone, shadowing a same-named global tool there. Duplicate names within one layer throw; non-native modes also reject the reserved `run_code` transport name. `timeoutMs`, when present, must be positive and finite. Disposed with the calling fiber.
|
||||
- `ctx.tools.restrict(filter: ToolRestriction): () => void` Scoped-only (throws on a plain context): mask the global end-capability surface for the calling agent — `allow` keeps only the listed global tools, `deny` removes them; multiple restrictions intersect; scope-local registrations are merged afterward. The readonly arrays compile once into private sets. Every listed name must exist in the current pre-restriction global registry; scope-local, unknown, and reserved `run_code` names fail loudly. A deny-list admits a later global tool unless it names that tool; an allow-list excludes later names; neither filters a later scope-local registration. `restrict({})` rejects. This is live registration composition, not a parent-derived authority ceiling; see the [agent-scope security non-goal](../../../docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md#security-and-authority-are-explicit-non-goals).
|
||||
- `ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined` Resolution as one scope sees it (shadowing applied; a restricted-away global reads as absent) — presenters pass the calling agent so the card matches what executed.
|
||||
- `ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]` Schemas of everything the scope can see (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog RFC](../../../docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md)).
|
||||
- `ctx.tools.guard(guard: ToolGuard): () => void` Register a monotonic synchronous execution guard after `tools/pre-execute`: returning a reason denies the call, while `undefined` leaves it unchanged. A plain-context guard applies globally; an `agent.ctx` guard applies only to that agent. Later waterfall listeners cannot turn a guard denial back into permission. Disposed with the calling fiber.
|
||||
- `ctx.tools.execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>` Assign a fresh opaque correlation token, losslessly materialize and deep-freeze arguments once at the model/tool boundary, then run the call through `tools/pre-execute` → guards → `tools/execute` → `tools/post-execute`. Invalid arguments normalize through the same authoritative result path without reaching policy or the body. The final outcome is independently materialized and deep-frozen once before `tools/result`. Optional `signal` remains the operational field an around-dispatch wrapper may replace.
|
||||
|
||||
### Injected services
|
||||
|
||||
`SystemPrompt` — the registry automatically feeds its tool schemas into the system-prompt assembly via `ctx.systemPrompt.tools()`. The approval seam is consumed opportunistically instead (`ctx.get('approval')`, no static inject): a deployment without it keeps the ask→deny degrade, and the registry stays active either way.
|
||||
|
||||
### Events
|
||||
### Live events
|
||||
|
||||
| Event | Mode | Purpose |
|
||||
|---|---|---|
|
||||
| `tools/pre-execute` | waterfall | Allow/deny gate BEFORE a tool runs (sandbox, permission, hooks); returns `PreToolDecision` |
|
||||
| `tools/execute` | waterfall | Around-dispatch wrapper (timeout, retry, metrics): `(exec, next)` → the dispatched `ToolExecutionResult`; `next()` is dispatch-with-normalization |
|
||||
| `tools/post-execute` | waterfall | Inspect/replace the result AFTER a tool runs, attach context; returns `PostToolDecision` |
|
||||
| `tools/change` | emit | A tool was registered or unregistered |
|
||||
The live registry pipeline has three transformable waterfalls followed by the observe-only `tools/result` boundary; registry changes are deliberately unfiltered shared-state notifications. Exact signatures, dispatch modes, scope filtering, and failure-containment contracts live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md), while the complete ordering is visualized in the generated [tool execution pipeline](../../../docs/tool-execution-pipeline.md). `tools/result` is live; the similarly named `tool/result` is the durable session event the agent loop appends afterwards.
|
||||
|
||||
### Key types
|
||||
|
||||
- `ToolDefinition` — `ToolSchema` + `execute(args, exec): Promise<ContentBlock[] | { content: ContentBlock[]; meta? }>` (the bare array is the model-facing content; the object form additionally attaches an opaque, JSON-serializable `meta` presentation payload persisted on the `tool/result` event and handed back to `presentResult`), plus optional `presentCall(args)` / `presentResult(args, result)` for tool-owned UI presentation (see below). It also carries an optional cooperative timeout budget `timeoutMs?: number` (ms) enforced by `@deepseek-ai/dsh-timeout-policy`, never sent to the model.
|
||||
- `ToolExecution` — one pending tool call: `{ callId, name, arguments, agent?, signal? }`.
|
||||
- `ToolExecutionResult` — outcome: `{ callId, content, isError, error?, additionalContext?, meta? }`. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text (the loop forwards it onto the `tool/result` session event for retry/sandbox plugins and replay). `additionalContext` (a `HookContext`) ferries any `tools/post-execute` context up to the loop, which buffers it and appends it as a `context/message` after all `tool/result`s in the step. `meta` is the tool's opaque presentation payload from a successful `execute` (the object return form); the loop forwards it onto the `tool/result` session event for result-card rendering.
|
||||
- `PreToolDecision` — `{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite (changing `arguments`) is deliberately NOT offered (it would desync the pre-execution audit/history/UI from what ran — its own proposed RFC); `ask` is serviced by [`ctx.approval`](../../ui/user-approval/README.md) when a deployment mounts it (`allowed-once` proceeds to dispatch; `rejected`/`cancelled`/`unavailable` deny with distinct reasons) and degrades to `deny` when none is mounted or the execution carries no agent.
|
||||
- `ToolDefinition` — `ToolSchema` + `execute(args, exec)`, optional presentation callbacks, and cooperative `timeoutMs`.
|
||||
- `ToolExecutionInput` — the caller-supplied call description: `{ callId, name, arguments, agent?, parent?, signal? }`; callers may pass an enclosing execution's opaque token as `parent` but never choose the new execution's own token.
|
||||
- `ToolExecutionToken` — a fresh branded `Symbol` assigned by the registry. It supports equality correlation only and never crosses a model, log, or worker boundary.
|
||||
- `ToolExecution` — the pipeline-owned call: immutable `{ token, callId, name, arguments, agent?, parent? }` identity plus optional operational `signal`, which an around wrapper may add, replace, remove, and restore. A nested call's `parent` is a `ToolExecutionToken`, not an execution object.
|
||||
- `ToolExecutionResult` — losslessly JSON-serializable outcome: `{ callId, content, isError, error?, additionalContext?, meta? }`. The registry materializes and freezes the complete post-policy value before final observation. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text.
|
||||
- `PreToolDecision` — `{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite is deliberately not offered; `ask` is serviced by [`ctx.approval`](../../ui/user-approval/README.md) when mounted and otherwise degrades to deny.
|
||||
- `PostToolDecision` — `{kind:'accept', content?, additionalContext?}` (keep the call successful, optionally replacing the model-facing content) | `{kind:'block', feedback, additionalContext?}` (turn it into an `isError` whose content is the corrective feedback). Output replacement is clean because `tool/result` is logged AFTER `execute()` returns.
|
||||
- `ToolGuard` — `(execution) => string | undefined`; the returned string is a final monotonic denial reason evaluated after the reorderable pre-execute waterfall and before dispatch.
|
||||
- `ToolCallView` / `ToolResultView` — provider-neutral `card`-tagged render intents a tool returns from `presentCall` / `presentResult` to own how a UI renders ITS calls (see "Tool-owned UI presentation").
|
||||
|
||||
### Extension points
|
||||
|
||||
- Tool plugins call `ctx.tools.register()` — schemas flow into the assembly automatically.
|
||||
- `tools/pre-execute` is the allow/deny gate (sandbox, permission, hooks): listeners receive `(exec, next)` and call `next()` to delegate to the default (allow) or return a `PreToolDecision` to short-circuit; a `deny` skips dispatch and yields an `isError` result, and an `ask` resolves through the approval seam first — only a grant dispatches (see `PreToolDecision` above). `tools/execute` is the around-dispatch seam (timeout, retry, metrics): listeners receive `(exec, next)` and call `next()` to delegate to core dispatch (returning its `ToolExecutionResult`, optionally wrapped), or return a replacement result to short-circuit dispatch; the base `next()` IS dispatch-with-normalization, so `await next()` already yields an `isError` result for a thrown/unknown tool (never a raw throw). A wrapper mutates `exec` in place before `next()` — e.g. replacing `exec.signal` with a per-call deadline — because cordis `next()` ignores passed arguments. `tools/post-execute` is the inspect/transform seam: `(exec, result, next)` → a `PostToolDecision` that can replace content, block with feedback, or attach `additionalContext`. Core dispatch is the base of the `tools/execute` waterfall; the tool body keeps its own try/catch so a thrown tool still reaches `post-execute` as an `isError`. All follow the typed-Decision idiom shared with the `agent/*` interception seams (see [`dsh-agent`](../agent/README.md)); `@deepseek-ai/dsh-timeout-policy` is the reference `tools/execute` wrapper.
|
||||
- `tools/pre-execute` is the reorderable allow/deny/ask gate (sandbox, permission, hooks): listeners receive `(exec, next)` and call `next()` to delegate to the default (allow) or return a `PreToolDecision` to short-circuit; a `deny` skips dispatch, while an `ask` resolves through the approval seam and dispatches only after a grant. Either non-grant path yields an `isError` result. `ctx.tools.guard()` installs scope-aware monotonic policy after that waterfall when a denial must not be overridable by listener ordering. `tools/execute` is the around-dispatch seam (timeout, retry, metrics): listeners receive `(exec, next)` and call `next()` to delegate to core dispatch (returning its `ToolExecutionResult`, optionally wrapped), or return a replacement result to short-circuit dispatch; the base `next()` is dispatch-with-normalization, so `await next()` already yields an `isError` result for a thrown or unknown tool. A wrapper may change only `exec.signal` before `next()`—adding a per-call deadline, replacing a caller signal, or restoring absence afterwards—because call identity is protected before policy begins. `tools/post-execute` is the inspect/transform seam: `(exec, result, next)` → a `PostToolDecision` that can replace content, block with feedback, or attach `additionalContext`. Core dispatch is the base of the `tools/execute` waterfall; the tool body keeps its own error boundary so a thrown tool still reaches `post-execute` as an `isError`. Finally, `tools/result` observes the immutable authoritative result after every transform and error boundary. All follow the typed-decision idiom shared with the `agent/*` seams (see [`dsh-agent`](../agent/README.md)); `@deepseek-ai/dsh-timeout-policy` is the reference `tools/execute` wrapper.
|
||||
- MCP servers: one plugin per server, discover tools, call `ctx.tools.register()` with the server's schemas.
|
||||
|
||||
### Typed tool parameter schemas
|
||||
@@ -75,7 +75,7 @@ ctx.tools.register(defineTool({
|
||||
}))
|
||||
```
|
||||
|
||||
The helper converts the author-facing `SchemaSpec` (with `required: true` as a per-property boolean) to standard JSON Schema for the wire format. Raw JSON-Schema tool definitions (from MCP servers) are still accepted by the registry directly.
|
||||
The helper converts the author-facing `SchemaSpec` (with `required: true` as a per-property boolean) to standard JSON Schema for the wire format and uses the same typed spec for execute/presentation validation. Raw JSON-Schema tool definitions (from MCP servers) are still accepted by the registry directly.
|
||||
|
||||
A `defineTool` tool also **validates the model-generated arguments against its `SchemaSpec` before `execute` runs** (`validateArgs`). The model's JSON is untrusted — `InferArgs<S>` is a compile-time claim, not a runtime guarantee — so on a mismatch (missing required key, wrong primitive, bad enum member, nested violation) the tool throws a `ToolArgsError` (`code: 'INVALID_ARGS'`); the registry turns it into an `isError` result whose text lists the violations, which the model sees and self-corrects from. Validation mirrors the JSON Schema conversion exactly: extra keys are allowed, `default` is not applied, and an `object`/`array` prop without `properties`/`items` only type-checks. Raw-registered tools (MCP) are **not** validated by the harness — they validate their own input.
|
||||
|
||||
@@ -130,15 +130,15 @@ const bash = defineTool({
|
||||
|
||||
### Code Mode
|
||||
|
||||
Under `mode: code` (or `both`) the registry turns the tool surface into a programming API, per the [Code Mode RFC](../../../docs/rfc/implemented/feature/2026-06-15-code-mode.md): the model writes a TypeScript program (the body of an async function) and passes it to the ONE wire tool `run_code`; the program runs in `ctx.codeRuntime` (the [code-execution seam](../../code-runtime/README.md) — the shipped backend is a worker thread) with one async binding per registered tool (`await tools.bash({...})`), and ONLY what it prints or returns re-enters the model's context.
|
||||
Under `mode: code` (or `both`) the registry turns the tool surface into a programming API, per the [Code Mode RFC](../../../docs/rfc/implemented/feature/2026-06-15-code-mode.md): the model writes a TypeScript program (the body of an async function) and passes it to the reserved wire transport `run_code`; the program runs in `ctx.codeRuntime` (the [code-execution seam](../../code-runtime/README.md) — the shipped backend is a worker thread) with one async binding per visible end-capability tool (`await tools.bash({...})`), and ONLY what it prints or returns re-enters the model's context. Scope restrictions change those SDK bindings but cannot remove or replace the transport itself.
|
||||
|
||||
- **The SDK section** (`tools:sdk`, order 150): a lazy prompt section regenerating, at each assembly, a `declare const tools: {...}` TypeScript declaration of every registered tool except `run_code` (exotic names via quoted keys), plus fixed usage instructions. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). The codegen (`jsonSchemaToTs`, exported) is TOTAL: constructs outside the `defineTool` subset degrade to `unknown`, never throw.
|
||||
- **The dispatch bridge** (`run_code`'s execute): every binding call is JSON-normalized BEFORE dispatch (a value that does not survive — `BigInt`, circulars — rejects that one call, so the dispatched form and the logged form are the same JSON value by construction), serialized through a per-run queue (even `Promise.all` executes the underlying `ctx.tools.execute()` calls one at a time in submission order — the tool contract carries no concurrency-safety metadata yet), gated by `tools/pre-execute`/`tools/post-execute` like any native call (a deny reaches the program as a binding rejection), and logged as one `tool/code-dispatch` session event (log-only: `deriveMessages()` never surfaces it) with the deterministic sub-id `<parent>:code:<n>`. A failed sub-call REJECTS the program-side promise with the tool's error text — real code error handling, no bespoke envelope. A sub-call's `additionalContext` is deliberately DROPPED (no safe outlet mid-run without breaking tool-call/result adjacency; deferred until a real hook needs it through Code Mode).
|
||||
- **The SDK section** (`tools:sdk`, order 150): a lazy prompt section regenerating, at each assembly, a `declare const tools: {...}` TypeScript declaration of the calling scope's visible end capabilities (exotic names via quoted keys), plus fixed usage instructions. Deterministic — lexicographic tool order, byte-identical text for an unchanged tool set (prefix-cache-friendly). The codegen (`jsonSchemaToTs`, exported) is total: constructs outside the `defineTool` subset degrade to `unknown`, never throw.
|
||||
- **The dispatch bridge** (`run_code`'s execute): every binding call is JSON-normalized before dispatch (a value that does not survive — `BigInt`, circulars — rejects that one call, so the dispatched form and logged form are the same JSON value by construction), serialized through a per-run queue (even `Promise.all` executes underlying calls one at a time in submission order), given the outer execution's opaque token as `parent`, and run through the complete pre-execute → guards → execute → post-execute → result pipeline. A denial reaches the program as a binding rejection, and each sub-call is logged as a `tool/code-dispatch` session event with deterministic id `<parent>:code:<n>`; `deriveMessages()` does not surface that event. Token correlation lets commit-style observers defer an inner success until the final `run_code` result without exposing the live outer execution; ordinary tool side effects are not rolled back. A sub-call's `additionalContext` is deliberately dropped because inserting it inside a running parent call would break tool-call/result adjacency.
|
||||
- **Settlement discipline**: the bridge owns a run-scoped abort that follows the outer signal in and fires when the run settles for any reason, so a budget expiry aborts an in-flight sub-tool instead of orphaning it; the bridge then drains its queue BEFORE returning, so every `tool/code-dispatch` lands inside the open turn. A failed run throws `CodeRunFailedError` (`code: 'CODE_RUN_FAILED'`, message = the failure kind + captured logs), which the pipeline converts to a structured `isError` the model self-corrects from.
|
||||
|
||||
The wire collapse is the registry's own contribution (`systemPrompt.tools()` is mode-aware), so the logged `request/header` records it for free — under `code`, the assembled tool list is exactly `[run_code]`, pinned by tests and the snapshot goldens. Try it: `pnpm run demo:code-mode` ([the coding-agent example's Code Mode overlay](../../../examples/coding-agent/README.md#code-mode)); `pnpm run demo:code-mode acp` serves the same mode over ACP instead of the REPL.
|
||||
The wire collapse is the registry's own contribution (`systemPrompt.tools()` is mode-aware), so the logged `request/header` records it for free. When no assembly listener changes the registry's prompt or schema contributions, `code` assembles exactly `[run_code]`, pinned by tests and the snapshot goldens. Try it: `pnpm run demo:code-mode` ([the coding-agent example's Code Mode overlay](../../../examples/coding-agent/README.md#code-mode)); `pnpm run demo:code-mode acp` serves the same mode over ACP instead of the REPL.
|
||||
|
||||
### What is NOT here (TODO)
|
||||
|
||||
- **Tool shapes review** — when real tools land (e.g. a concurrency-safety hint for parallel execution); phase 1 executes tool calls sequentially.
|
||||
- **Parallel execution** — the loop currently iterates tool calls sequentially.
|
||||
- **Concurrency metadata** — tool definitions do not declare whether executions are safe to overlap.
|
||||
- **Parallel execution** — the loop and Code Mode bridge execute tool calls sequentially until that metadata exists.
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"@deepseek-ai/dsh-user-approval": "^0.0.1",
|
||||
"@deepseek-ai/dsh-code-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-scope": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
@@ -38,6 +39,7 @@
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:^",
|
||||
"@deepseek-ai/dsh-code-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
/**
|
||||
* Code Mode: the `run_code` tool and its dispatch bridge. The model writes a
|
||||
* TypeScript program; the bridge hands it to `ctx.codeRuntime` with one
|
||||
* async binding per registered tool, serializes every binding call through a
|
||||
* per-run queue onto `ToolRegistry.execute()` (so `tools/pre-execute` /
|
||||
* `tools/post-execute` gate sub-calls exactly like native ones), logs each
|
||||
* sub-dispatch as a `tool/code-dispatch` session event, and returns only the
|
||||
* program's curated output. The registry itself decides WHEN this tool
|
||||
* exists (its `mode` config); this module owns only the tool and the bridge.
|
||||
* TypeScript program; the bridge hands it to `ctx.codeRuntime` with one async
|
||||
* binding per end capability visible to the calling agent, then serializes
|
||||
* every binding call through a per-run queue onto `ToolRegistry.execute()`.
|
||||
* Sub-calls therefore traverse the complete pre/guard/around/post/final-result
|
||||
* pipeline exactly like native calls and carry the outer execution's opaque
|
||||
* token for correlation. The bridge logs each sub-dispatch as a
|
||||
* `tool/code-dispatch` session event and returns only the program's curated
|
||||
* output. The registry itself decides WHEN this tool exists (its `mode`
|
||||
* config); this module owns only the tool and the bridge.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tools/src/code-mode
|
||||
*/
|
||||
@@ -138,7 +140,8 @@ function asRunCodeMeta(meta: unknown): RunCodeMeta | undefined {
|
||||
/**
|
||||
* Build the `run_code` {@link ToolDefinition}: one required `code` parameter,
|
||||
* executed through the dispatch bridge described in the module doc. The
|
||||
* registry registers it under non-native modes.
|
||||
* registry reserves it as presentation infrastructure under non-native modes,
|
||||
* outside the filterable global/scoped capability layers.
|
||||
* @param registry - the owning registry (sub-calls go through its `execute`,
|
||||
* bindings cover its registered tools).
|
||||
* @param requireRuntime - resolves `ctx.codeRuntime` or throws the loud
|
||||
@@ -204,6 +207,7 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
|
||||
name,
|
||||
arguments: normalized.dispatched,
|
||||
...exec.agent ? { agent: exec.agent } : {},
|
||||
parent: exec.token,
|
||||
signal: runController.signal,
|
||||
})
|
||||
const text = textOf(result.content)
|
||||
@@ -244,7 +248,11 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
|
||||
// silently dropping the binding), and the runtime host resolves
|
||||
// binding names as own properties only.
|
||||
const functions: Record<string, CodeBindingFunction> = Object.create(null) as Record<string, CodeBindingFunction>
|
||||
for (const schema of registry.schemas()) {
|
||||
// Enumerate the CALLING AGENT's visible set (scoped tools join,
|
||||
// restricted globals vanish) — the same view the SDK section declared,
|
||||
// so a program can bind exactly what its prompt promised; sub-dispatch
|
||||
// re-resolves per call through the same view (exec.agent threads down).
|
||||
for (const schema of registry.schemas(exec.agent)) {
|
||||
if (schema.name === RUN_CODE_NAME) continue
|
||||
Object.defineProperty(functions, schema.name, { enumerable: true, value: binding(schema.name) })
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
/**
|
||||
* Tool registry and execution pipeline. Plugins register tools; the registry
|
||||
* feeds schemas into the system prompt, and `execute()` dispatches each call
|
||||
* through `tools/pre-execute` (the allow/deny gate) → `tools/execute` (an
|
||||
* around-dispatch wrapper for timeout/retry/metrics plugins) → `tools/post-execute`
|
||||
* (inspect/replace the result, attach context) for sandbox, permission, and hook
|
||||
* plugins to gate or transform a call.
|
||||
* through `tools/pre-execute` (the extensible allow/deny gate) → monotonic
|
||||
* registered guards → `tools/execute` (an around-dispatch wrapper for
|
||||
* timeout/retry/metrics plugins) → `tools/post-execute` (inspect/replace the
|
||||
* result, attach context) → the observe-only `tools/result` notification.
|
||||
*
|
||||
* The registry also owns HOW its tools are presented to the model — its
|
||||
* `mode` config: `'native'` (every tool as a wire function definition,
|
||||
* today's behavior and the default), `'code'` (the wire carries exactly one
|
||||
* tool, `run_code`, plus a generated TypeScript SDK prompt section), or
|
||||
* today's behavior and the default), `'code'` (the registry's canonical wire
|
||||
* contribution is one tool, `run_code`, plus a generated TypeScript SDK prompt section), or
|
||||
* `'both'`. See `code-mode.ts` (the tool + dispatch bridge) and
|
||||
* `ts-types.ts` (the SDK codegen); design in the Code Mode RFC.
|
||||
*
|
||||
@@ -18,10 +18,13 @@
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
|
||||
import type { ScopeKey, Scoped } from '@deepseek-ai/dsh-scope'
|
||||
import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import { assertNever, deepFreeze, HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent, HookContext } from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
|
||||
import type { ToolProviderResult } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
|
||||
// Type-only: makes `ctx.get('approval')` resolve to the ApprovalService
|
||||
// augmentation. The seam stays optional at runtime — see `serviceAsk`.
|
||||
@@ -88,10 +91,14 @@ declare module 'cordis' {
|
||||
* tool body never runs. Input rewrite is deliberately NOT offered here (see
|
||||
* {@link PreToolDecision}); `ask` is serviced by the `ctx.approval` seam
|
||||
* when one is mounted, and degrades to deny otherwise.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) keys the carrier by `exec.agent`: a
|
||||
* listener registered through `agent.ctx` fires only for that agent's
|
||||
* calls, while a plain plugin listener fires for every call (including
|
||||
* agent-less ones, which dispatch subject-less).
|
||||
* @param exec - the pending call (name, parsed arguments, caller agent).
|
||||
* @mode waterfall
|
||||
*/
|
||||
'tools/pre-execute'(this: ToolRegistry, exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision>
|
||||
'tools/pre-execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision>
|
||||
/**
|
||||
* Around-dispatch waterfall wrapping the registry's core tool dispatch,
|
||||
* between the `tools/pre-execute` gate and the `tools/post-execute` seam. A
|
||||
@@ -102,16 +109,23 @@ declare module 'cordis' {
|
||||
* unknown tool) is already normalized to an `isError` result by the time a
|
||||
* listener's `await next()` returns, so a wrapper never sees a raw throw from
|
||||
* the tool body. This is the seam a timeout/retry/metrics plugin wraps: it can
|
||||
* mutate `exec` (e.g. replace `exec.signal` with a per-call deadline) BEFORE
|
||||
* `next()` and inspect the result AFTER. (Cordis `next()` ignores any passed
|
||||
* arguments and re-invokes downstream with the shared payload, so a wrapper
|
||||
* mutates `exec` in place rather than passing a new object to `next()`.)
|
||||
* set or replace the one mutable field, `exec.signal` (e.g. with a per-call
|
||||
* deadline), BEFORE `next()`, restore/delete it afterward, and inspect the result AFTER. Call identity
|
||||
* (`token`, `callId`, `name`, `arguments`, `agent`, and `parent`) is immutable throughout the
|
||||
* pipeline so a wrapper cannot change which tool and scope the pipeline
|
||||
* accepted. (Cordis `next()` ignores passed arguments and re-invokes
|
||||
* downstream with the shared payload, so a wrapper changes `exec.signal` in
|
||||
* place rather than passing a new object to `next()`.)
|
||||
* Multiple listeners compose by registration order — an outer one wraps the
|
||||
* inner ones plus dispatch.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by
|
||||
* `exec.agent` — a listener registered through `agent.ctx` wraps only that
|
||||
* agent's calls; a plain plugin listener wraps every call (including
|
||||
* agent-less ones, which dispatch subject-less).
|
||||
* @param exec - the allowed call about to dispatch (name, parsed arguments, caller agent, signal).
|
||||
* @mode waterfall
|
||||
*/
|
||||
'tools/execute'(this: ToolRegistry, exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult>
|
||||
'tools/execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult>
|
||||
/**
|
||||
* Waterfall AFTER a tool runs — where hook plugins inspect the result and
|
||||
* accept it (optionally REPLACING the model-facing content, and/or attaching
|
||||
@@ -123,23 +137,45 @@ declare module 'cordis' {
|
||||
* waterfall, all inside `execute`'s outer try/catch (and the tool body keeps
|
||||
* its own inner try/catch, so a thrown tool still reaches `post-execute` as an
|
||||
* `isError` result).
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): the carrier is keyed by
|
||||
* `exec.agent` — a listener registered through `agent.ctx` fires only for
|
||||
* that agent's calls; a plain plugin listener fires for every call
|
||||
* (including agent-less ones, which dispatch subject-less).
|
||||
* @param exec - the call that just ran (name, parsed arguments, caller agent).
|
||||
* @param result - the dispatch outcome a listener may accept, replace, or block.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'tools/post-execute'(this: ToolRegistry, exec: ToolExecution, result: ToolExecutionResult, next: () => Promise<PostToolDecision>): Promise<PostToolDecision>
|
||||
'tools/post-execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, result: Readonly<ToolExecutionResult>, next: () => Promise<PostToolDecision>): Promise<PostToolDecision>
|
||||
/**
|
||||
* A tool was registered or unregistered (the available tool set changed).
|
||||
* Synchronous notification of the authoritative FINAL tool outcome, after the
|
||||
* complete pre/execute/post pipeline, final lossless-JSON validation, and
|
||||
* outer error normalization.
|
||||
* Unlike the three waterfalls, this seam cannot transform the result: each
|
||||
* listener receives the now-frozen execution object and a deep-frozen result
|
||||
* snapshot; listener failures are contained and logged, and
|
||||
* {@link ToolRegistry.execute} still returns the outcome.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): keyed by
|
||||
* `exec.agent`, using the same carrier as the pipeline.
|
||||
* @param exec - the execution object that traversed the pipeline.
|
||||
* @param result - a deep-frozen snapshot of the final returned result.
|
||||
* @mode emit
|
||||
*/
|
||||
'tools/result'(this: Scoped<ToolRegistry>, exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): undefined
|
||||
/**
|
||||
* A tool was registered or unregistered, or a scoped restriction changed
|
||||
* (the available tool set changed — possibly for one scope only). An
|
||||
* UNFILTERED registry-subject notification, deliberately not scope-filtered
|
||||
* dispatch: a global change concerns every agent's next assembly, so a
|
||||
* scoped listener subscribing here sees every change, not just its own
|
||||
* scope's.
|
||||
* @mode emit
|
||||
*/
|
||||
'tools/change'(): void
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(review): revisit these shapes when the first real tools and
|
||||
// sandbox/permission plugins land (e.g. a concurrency-safety hint for
|
||||
// parallel execution — Claude Code partitions read-only tools; phase 1
|
||||
// executes sequentially).
|
||||
// TODO(review): revisit these shapes when concurrency metadata becomes useful
|
||||
// (for example, a read-only hint that would permit safe parallel execution).
|
||||
|
||||
/**
|
||||
* What a tool's `execute` returns. The bare {@link ContentBlock}`[]` form is the
|
||||
@@ -198,17 +234,49 @@ export interface ToolResult {
|
||||
meta?: unknown
|
||||
}
|
||||
|
||||
/** One pending tool call, as it flows through the execution pipeline (`tools/pre-execute` → dispatch → `tools/post-execute`). */
|
||||
export interface ToolExecution {
|
||||
callId: CallId
|
||||
name: string
|
||||
/** Parsed JSON arguments (unknown — tools validate their own input). */
|
||||
arguments: unknown
|
||||
declare const toolExecutionTokenBrand: unique symbol
|
||||
|
||||
/**
|
||||
* Opaque identity for one trip through the tool pipeline. Nested
|
||||
* transports carry the enclosing execution's token instead of its live object,
|
||||
* so observe-only result listeners can correlate calls without gaining a
|
||||
* mutation path into an outer around-dispatch wrapper.
|
||||
*/
|
||||
export type ToolExecutionToken = symbol & { readonly [toolExecutionTokenBrand]: true }
|
||||
|
||||
/**
|
||||
* Caller-supplied description of one tool call. {@link ToolRegistry.execute}
|
||||
* adds the registry-owned token to form a pipeline {@link ToolExecution};
|
||||
* callers do not choose that token.
|
||||
*/
|
||||
export interface ToolExecutionInput {
|
||||
readonly callId: CallId
|
||||
readonly name: string
|
||||
/** Losslessly JSON-serializable parsed arguments (tools validate their own schema). */
|
||||
readonly arguments: unknown
|
||||
/** The agent on whose behalf the call runs (set by the agent loop). */
|
||||
agent?: Agent
|
||||
readonly agent?: Agent
|
||||
/**
|
||||
* Opaque token of the enclosing transport execution, when one exists. Code
|
||||
* Mode sets this on SDK sub-dispatches so commit-style observers can wait for
|
||||
* the outer `run_code` outcome without receiving its live mutable execution.
|
||||
*/
|
||||
readonly parent?: ToolExecutionToken
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
/**
|
||||
* One pending tool call inside the registry pipeline. Parsed arguments cross
|
||||
* one lossless-JSON materialization boundary before policy and are deep-frozen;
|
||||
* call identity and the registry-assigned {@link token} are readonly. An
|
||||
* around-dispatch wrapper may set, replace, or remove `signal`. The registry
|
||||
* freezes the complete object before `tools/result` observers run.
|
||||
*/
|
||||
export interface ToolExecution extends ToolExecutionInput {
|
||||
/** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */
|
||||
readonly token: ToolExecutionToken
|
||||
}
|
||||
|
||||
/** Structured error metadata for a failed tool call (alongside the model-facing text). */
|
||||
export interface ToolErrorInfo {
|
||||
name: string
|
||||
@@ -239,7 +307,6 @@ export interface ToolExecutionResult {
|
||||
* text in `content` is always present; this is extra structure for code.
|
||||
*/
|
||||
error?: ToolErrorInfo
|
||||
/**
|
||||
/**
|
||||
* Extra model-facing context a `tools/post-execute` listener attached for the
|
||||
* NEXT request (Claude Code's PostToolUse `additionalContext`). It is NOT part
|
||||
@@ -303,17 +370,28 @@ export type PostToolDecision =
|
||||
* is stringified.
|
||||
*/
|
||||
function errorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message
|
||||
if (typeof error === 'object' && error !== null
|
||||
&& 'message' in error && typeof error.message === 'string') {
|
||||
return error.message
|
||||
try {
|
||||
if (error instanceof Error) return error.message
|
||||
if (typeof error === 'object' && error !== null
|
||||
&& 'message' in error && typeof error.message === 'string') {
|
||||
return error.message
|
||||
}
|
||||
return String(error)
|
||||
} catch {
|
||||
// A hostile thrown value can trap `instanceof`, property access, or string
|
||||
// coercion. Error normalization is the outermost safety boundary, so its
|
||||
// fallback must itself be total.
|
||||
return '<unprintable thrown value>'
|
||||
}
|
||||
return String(error)
|
||||
}
|
||||
|
||||
/** Structured `{ name, code }` for a thrown HarnessError, else undefined. */
|
||||
function errorInfo(error: unknown): ToolErrorInfo | undefined {
|
||||
return error instanceof HarnessError ? { name: error.name, code: error.code } : undefined
|
||||
try {
|
||||
return error instanceof HarnessError ? { name: error.name, code: error.code } : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** How the registry presents its tools to the model (see {@link Config.mode}). */
|
||||
@@ -323,9 +401,9 @@ export type ToolPresentationMode = 'native' | 'code' | 'both'
|
||||
export interface Config {
|
||||
/**
|
||||
* The presentation mode. `'native'` (the default) contributes every
|
||||
* registered tool as a wire function definition — byte-for-byte today's
|
||||
* behavior. `'code'` contributes exactly ONE wire tool, `run_code`, plus
|
||||
* the generated `tools:sdk` prompt section declaring every other tool as a
|
||||
* visible end capability as a native wire function definition. Under
|
||||
* `'code'` this registry contributes exactly ONE wire tool,
|
||||
* `run_code`, plus the generated `tools:sdk` prompt section declaring every other tool as a
|
||||
* TypeScript API the program calls. `'both'` contributes every native
|
||||
* definition AND `run_code` + the SDK section. Non-native modes require a
|
||||
* loaded `ctx.codeRuntime` whose `language` is `'typescript'` — a missing
|
||||
@@ -338,13 +416,79 @@ export interface Config {
|
||||
mode?: ToolPresentationMode
|
||||
}
|
||||
|
||||
/**
|
||||
* A per-scope restriction over the GLOBAL tool surface, registered via
|
||||
* {@link ToolRegistry.restrict}. `allow` keeps only the listed global tools;
|
||||
* `deny` removes the listed ones; both present = allow first, then deny.
|
||||
* Restrictions never touch scoped registrations — a tool registered through
|
||||
* the same scope is merged after the global filter (which is what keeps e.g. a
|
||||
* structured-output capture tool alive under an allow-list). The readonly
|
||||
* filter values compile to private sets at registration, but resolution uses the live global registry:
|
||||
* a later global name passes a deny-only filter unless explicitly denied and
|
||||
* fails an allow-list unless explicitly allowed. The
|
||||
* reserved `run_code` presentation transport is likewise outside capability
|
||||
* filtering, and naming it explicitly is rejected. Multiple restrictions on
|
||||
* one scope compose by intersection: every one must admit.
|
||||
*/
|
||||
export interface ToolRestriction {
|
||||
/** Global tool names that stay visible; everything else is removed. */
|
||||
readonly allow?: readonly string[]
|
||||
/** Global tool names removed from visibility. */
|
||||
readonly deny?: readonly string[]
|
||||
}
|
||||
|
||||
/** One restriction compiled at registration for repeated live-global lookup. */
|
||||
interface CompiledToolRestriction {
|
||||
readonly allow?: ReadonlySet<string>
|
||||
readonly deny?: ReadonlySet<string>
|
||||
}
|
||||
|
||||
/** One scope's complete registry view, derived in a single layer traversal. */
|
||||
interface ToolView {
|
||||
/** Visible definitions after restrictions, scoped shadowing, and transport insertion. */
|
||||
readonly visible: ReadonlyMap<string, ToolDefinition>
|
||||
/** Pre-restriction capability names used by prompt-order validation. */
|
||||
readonly knownNames: ReadonlySet<string>
|
||||
/** Current global names that a scoped restriction may name. */
|
||||
readonly restrictableNames: ReadonlySet<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* A monotonic execution guard evaluated after every `tools/pre-execute`
|
||||
* listener and before the tool body. Returning a reason denies the call;
|
||||
* returning `undefined` leaves it unchanged. Because guards have no allow
|
||||
* result, listener ordering cannot turn a denial back into permission.
|
||||
* @param execution - the identity-protected call after extensible pre-execute policy completed.
|
||||
* @returns a final denial reason, or `undefined` to leave the call allowed.
|
||||
*/
|
||||
export type ToolGuard = (execution: Readonly<ToolExecution>) => string | undefined
|
||||
|
||||
/** One guard registration; the wrapper preserves independent duplicate registrations. */
|
||||
interface ToolGuardRegistration {
|
||||
guard: ToolGuard
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool registry (`ctx.tools`): tool plugins register definitions; the agent
|
||||
* loop executes calls through the `tools/pre-execute` → `tools/execute` →
|
||||
* `tools/post-execute` pipeline. The registry contributes its schemas into the
|
||||
* system-prompt assembly — WHICH schemas is governed by its `mode` config
|
||||
* (see {@link Config.mode}); under a non-native mode it also registers the
|
||||
* `run_code` tool and the `tools:sdk` prompt section itself.
|
||||
* loop executes calls through the `tools/pre-execute` → guards →
|
||||
* `tools/execute` → `tools/post-execute` → `tools/result` pipeline. The
|
||||
* registry contributes its schemas into the system-prompt assembly — WHICH
|
||||
* schemas is governed by its `mode` config
|
||||
* (see {@link Config.mode}); under a non-native mode it also owns the reserved
|
||||
* `run_code` presentation transport and the `tools:sdk` prompt section.
|
||||
*
|
||||
* Two registration layers (`@deepseek-ai/dsh-scope`): a registration through a
|
||||
* plain plugin context is GLOBAL (visible to every agent); one through a
|
||||
* scoped context (`agent.ctx`) is filed in that scope's layer — visible to
|
||||
* that agent alone, disposed with the scope, and SHADOWING a global tool of
|
||||
* the same name for that agent (most-specific-wins; within one layer a
|
||||
* duplicate name still throws). {@link restrict} masks the global layer per
|
||||
* scope. One private visibility resolver feeds the registry's prompt
|
||||
* contribution, {@link get}, and {@link execute} — and, under a non-native
|
||||
* mode, the SDK section and `run_code`'s bindings — so those registry-owned
|
||||
* presentation and dispatch paths agree. An expert `system-prompt/assemble`
|
||||
* listener may deliberately replace the final wire composition and owns any
|
||||
* resulting divergence.
|
||||
*/
|
||||
export class ToolRegistry extends Service {
|
||||
static inject = ['systemPrompt']
|
||||
@@ -353,44 +497,82 @@ export class ToolRegistry extends Service {
|
||||
mode: z.union(['native', 'code', 'both'] as const).default('native'),
|
||||
})
|
||||
|
||||
private store = new Map<string, ToolDefinition>()
|
||||
private global = new Map<string, ToolDefinition>()
|
||||
private scoped = new Map<ScopeKey, Map<string, ToolDefinition>>()
|
||||
/** Compiled restriction filters, per scope (see {@link restrict}). */
|
||||
private restrictions = new Map<ScopeKey, CompiledToolRestriction[]>()
|
||||
/** Monotonic post-policy guards, split into global and per-agent layers. */
|
||||
private globalGuards = new Set<ToolGuardRegistration>()
|
||||
private scopedGuards = new Map<ScopeKey, Set<ToolGuardRegistration>>()
|
||||
private readonly mode: ToolPresentationMode
|
||||
/** Reserved presentation transport, kept outside the filterable registration layers. */
|
||||
private readonly codeTransport: ToolDefinition | undefined
|
||||
|
||||
constructor(ctx: Context, config: Config = {}) {
|
||||
super(ctx, 'tools')
|
||||
// The schema already defaulted an omitted mode; the ?? narrows the
|
||||
// optional-input type for direct (non-Loader) construction in tests.
|
||||
this.mode = config.mode ?? 'native'
|
||||
ctx.systemPrompt.tools(() => this.wireSchemas())
|
||||
// `run_code` is presentation infrastructure, not an end capability. It
|
||||
// therefore does not enter the global layer: per-agent restrictions must
|
||||
// not remove it, and a scoped registration must not shadow it. The
|
||||
// visibility resolver appends this reserved definition after resolving
|
||||
// the filterable global/scoped capability layers.
|
||||
this.codeTransport = this.mode === 'native'
|
||||
? undefined
|
||||
: createRunCodeTool(this, () => this.requireCodeRuntime())
|
||||
ctx.systemPrompt.tools(context => this.wireSchemas(context.scope))
|
||||
if (this.mode !== 'native') {
|
||||
this.register(createRunCodeTool(this, () => this.requireCodeRuntime()))
|
||||
ctx.systemPrompt.section({
|
||||
name: 'tools:sdk',
|
||||
order: SDK_SECTION_ORDER,
|
||||
// A lazy thunk over the live store: regenerated at each assembly, in
|
||||
// lexicographic tool order, so an unchanged tool set renders
|
||||
// byte-identical text (prefix-cache-friendly) and a mid-session
|
||||
// registration surfaces exactly like a native-mode tool change.
|
||||
text: () => {
|
||||
// A lazy thunk over the live registry, per assembly CONTEXT:
|
||||
// regenerated at each assembly over the CALLING SCOPE's visible set
|
||||
// (scoped tools join, restricted globals vanish — the SDK declares
|
||||
// exactly what that agent's programs can call), in lexicographic
|
||||
// tool order, so an unchanged tool set renders byte-identical text
|
||||
// (prefix-cache-friendly) and a mid-session registration surfaces
|
||||
// exactly like a native-mode tool change.
|
||||
text: (context) => {
|
||||
this.requireCodeRuntime()
|
||||
return renderToolsSdk(this.schemas().filter(schema => schema.name !== RUN_CODE_NAME))
|
||||
return renderToolsSdk(this.schemas(context.scope).filter(schema => schema.name !== RUN_CODE_NAME))
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The registry's contribution to the wire tool list, per {@link Config.mode}.
|
||||
* Because `PromptAssembly.tools` is what the loop's request header
|
||||
* snapshots, the mode's collapse is logged and reconstructable for free.
|
||||
* Under a non-native mode this is also the loud misconfiguration gate: no
|
||||
* usable code runtime → every assembly rejects before any model request.
|
||||
* The registry's contribution to the wire tool list, per {@link Config.mode},
|
||||
* as ONE SCOPE sees it (scoped layer joins, shadowing and restrictions
|
||||
* applied — {@link schemas}). Because `PromptAssembly.tools` is what the
|
||||
* loop's request header snapshots, the mode's collapse is logged and
|
||||
* reconstructable for free. Under a non-native mode this is also the loud
|
||||
* misconfiguration gate: no usable code runtime → every assembly rejects
|
||||
* before any model request.
|
||||
*
|
||||
* The `knownNames` universe distinguishes the two ways a tool can be off
|
||||
* the wire: a per-scope RESTRICTION is runtime state, so `knownNames` stays
|
||||
* pre-restriction and a restricted-away tool in `toolOrder` is a normal
|
||||
* absence — while the MODE collapse is deployment config, so under
|
||||
* `mode: 'code'` the universe is `[run_code]` and a `toolOrder` naming a
|
||||
* native tool is dead configuration that fails every assembly loud. Under
|
||||
* `mode: 'both'`, the provider adds the reserved transport to the
|
||||
* capability-only known-name universe for `toolOrder` validation.
|
||||
*/
|
||||
private wireSchemas(): ToolSchema[] {
|
||||
if (this.mode === 'native') return this.schemas()
|
||||
private wireSchemas(scope?: ScopeKey): ToolProviderResult {
|
||||
const view = this.view(scope)
|
||||
const schemas = [...view.visible.values()].map(definition => this.schemaOf(definition, false))
|
||||
if (this.mode === 'native') {
|
||||
return { schemas, knownNames: [...view.knownNames] }
|
||||
}
|
||||
this.requireCodeRuntime()
|
||||
const all = this.schemas()
|
||||
return this.mode === 'code' ? all.filter(schema => schema.name === RUN_CODE_NAME) : all
|
||||
if (this.mode === 'code') {
|
||||
return {
|
||||
schemas: schemas.filter(schema => schema.name === RUN_CODE_NAME),
|
||||
knownNames: [RUN_CODE_NAME],
|
||||
}
|
||||
}
|
||||
return { schemas, knownNames: [...view.knownNames, RUN_CODE_NAME] }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -413,129 +595,422 @@ export class ToolRegistry extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a tool. Throws if a tool with the same name is already
|
||||
* registered. The tool's schema (minus the `execute` function) is
|
||||
* automatically contributed to the system-prompt assembly. Disposed
|
||||
* with the calling fiber. Emits `tools/change` on register/unregister.
|
||||
* Register a tool. The layer is decided by the CALLING context: a plain
|
||||
* plugin context registers globally; a scoped context (`agent.ctx`)
|
||||
* registers into that scope's layer — visible to that agent alone, disposed
|
||||
* with the scope, and shadowing a same-named global tool for that agent.
|
||||
* Throws if the SAME layer already has the name (cross-layer name twins are
|
||||
* the shadowing feature, not an error; the global-duplicate message names
|
||||
* `agent.ctx` as the per-agent alternative), or if a non-native mode reserves
|
||||
* the `run_code` name for its presentation transport. The visible schema set
|
||||
* flows into prompt assembly automatically. Definitions are trusted typed
|
||||
* same-process contributions; JSON materialization happens when the schema or
|
||||
* result reaches its model/log boundary. Emits `tools/change` on
|
||||
* register/unregister.
|
||||
* @param definition - the tool's schema plus its execute (and optional
|
||||
* presentation) functions.
|
||||
* @returns the disposer that unregisters the tool.
|
||||
* @returns the disposer that unregisters the tool. The exact
|
||||
* Cordis effect disposer (single-shot): composite (generator) effects may
|
||||
* yield it directly — exact identity nests the teardown in order.
|
||||
*/
|
||||
register(definition: ToolDefinition): () => void {
|
||||
const scope = scopeOf(this.ctx)
|
||||
const name = definition.name
|
||||
const timeoutMs = definition.timeoutMs
|
||||
if (timeoutMs !== undefined
|
||||
&& (!Number.isFinite(timeoutMs) || timeoutMs <= 0)) {
|
||||
throw new TypeError(`tool "${name}" timeoutMs must be a positive finite number`)
|
||||
}
|
||||
if (this.codeTransport !== undefined && name === RUN_CODE_NAME) {
|
||||
throw new Error(`tool name "${RUN_CODE_NAME}" is reserved for the Code Mode presentation transport and cannot be registered or shadowed`)
|
||||
}
|
||||
const dispose = this.ctx.effect(function* (this: ToolRegistry) {
|
||||
if (this.store.has(definition.name)) {
|
||||
throw new Error(`tool "${definition.name}" is already registered`)
|
||||
const layer = scope === undefined ? this.global : this.layerFor(scope)
|
||||
if (layer.has(name)) {
|
||||
throw new Error(scope === undefined
|
||||
? `tool "${name}" is already registered (for a per-agent variant, register through that agent's \`agent.ctx\` instead)`
|
||||
: `tool "${name}" is already registered in this scope`)
|
||||
}
|
||||
this.store.set(definition.name, definition)
|
||||
layer.set(name, definition)
|
||||
// Yield the rollback BEFORE emitting `tools/change`: a generator effect
|
||||
// collects each yielded disposer before the next step runs, so a throwing
|
||||
// `tools/change` listener removes the tool instead of leaking it (a leak
|
||||
// would wedge the duplicate-name check until restart). The duplicate
|
||||
// throw above fires before any mutation — it leaks nothing.
|
||||
yield () => {
|
||||
this.store.delete(definition.name)
|
||||
layer.delete(name)
|
||||
// An emptied scope layer is dropped so a disposed scope leaves no
|
||||
// residue keyed by its (dead) key.
|
||||
if (scope !== undefined && layer.size === 0) this.scoped.delete(scope)
|
||||
this.ctx.emit('tools/change')
|
||||
}
|
||||
this.ctx.emit('tools/change')
|
||||
}.bind(this), 'tools.register()')
|
||||
// ctx.effect's disposer returns Promise<void>; our disposer API is
|
||||
// synchronous fire-and-forget — discard the (always-resolved) promise.
|
||||
return () => void dispose()
|
||||
// The EXACT cordis effect disposer, not a wrapper: a composite (generator)
|
||||
// effect that owns a teardown ORDER must be able to yield THIS function —
|
||||
// cordis nests a disposer out of the fiber's concurrent sibling list by
|
||||
// exact function identity, so a wrapper would silently break the nesting
|
||||
// (the agents.register() lesson). Cleanup is synchronous because this
|
||||
// registration installs only synchronous state and notifications.
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||
return dispose
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a registered tool.
|
||||
* @param name - the tool name as registered.
|
||||
* @returns the definition, or undefined when no tool has that name.
|
||||
* Restrict the GLOBAL tool surface for the calling scope. Must be called
|
||||
* through a scoped context (`agent.ctx`) — restricting "everyone" is not a
|
||||
* thing (throw), and an empty filter (neither `allow` nor `deny`) is a no-op
|
||||
* that can only be a bug (throw — the materialized-empty-config trap).
|
||||
* Validates every listed name against the CURRENT global end-capability
|
||||
* universe and throws on an unknown or scope-local name (fail loud
|
||||
* beats a typo silently filtering nothing) — register restrictions after the
|
||||
* global tools they mask exist (the agent-creation `setup` window satisfies
|
||||
* this). A non-native mode's reserved `run_code` presentation transport is
|
||||
* not a filterable capability; naming it explicitly throws, while omitting
|
||||
* it from an allow-list cannot remove it. The readonly arrays are compiled to
|
||||
* private sets at registration. Resolution still uses the live global registry, so a later
|
||||
* global name passes a deny-only filter unless named and fails an allow-list
|
||||
* unless named. Multiple restrictions compose by intersection. Scoped
|
||||
* registrations are merged after restrictions and therefore remain visible.
|
||||
* Disposed with the calling fiber (revocable independently); emits
|
||||
* `tools/change`.
|
||||
* @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).
|
||||
* @returns the disposer that lifts this restriction. The exact
|
||||
* Cordis effect disposer (single-shot): composite (generator) effects may
|
||||
* yield it directly — exact identity nests the teardown in order.
|
||||
*/
|
||||
get(name: string): ToolDefinition | undefined {
|
||||
return this.store.get(name)
|
||||
restrict(filter: ToolRestriction): () => void {
|
||||
const scope = scopeOf(this.ctx)
|
||||
if (scope === undefined) {
|
||||
throw new Error('tools.restrict() requires a scoped context (agent.ctx): a context-global restriction would mask every agent — deny the tool for the intended agent instead')
|
||||
}
|
||||
const allow = filter.allow
|
||||
const deny = filter.deny
|
||||
if (allow === undefined && deny === undefined) {
|
||||
throw new Error('tools.restrict({}) is a no-op: pass `allow` and/or `deny` (an empty filter is almost always a materialized-empty-config bug)')
|
||||
}
|
||||
const compiled: CompiledToolRestriction = {
|
||||
...allow !== undefined ? { allow: new Set(allow) } : {},
|
||||
...deny !== undefined ? { deny: new Set(deny) } : {},
|
||||
}
|
||||
if (this.codeTransport !== undefined
|
||||
&& [...allow ?? [], ...deny ?? []].includes(RUN_CODE_NAME)) {
|
||||
throw new Error(`tools.restrict() cannot name reserved Code Mode presentation transport "${RUN_CODE_NAME}"; restrict end-capability tools instead`)
|
||||
}
|
||||
const known = this.view(scope).restrictableNames
|
||||
const unknown = [...allow ?? [], ...deny ?? []].filter(name => !known.has(name))
|
||||
if (unknown.length > 0) {
|
||||
throw new Error(`tools.restrict() names unknown global tool${unknown.length > 1 ? 's' : ''} ${unknown.map(n => `"${n}"`).join(', ')}; known global tools: ${[...known].sort().join(', ') || '(none)'}`)
|
||||
}
|
||||
const dispose = this.ctx.effect(function* (this: ToolRegistry) {
|
||||
const list = this.restrictions.get(scope) ?? []
|
||||
this.restrictions.set(scope, list)
|
||||
list.push(compiled)
|
||||
yield () => {
|
||||
const index = list.indexOf(compiled)
|
||||
/* v8 ignore next 3 -- defensive: the compiled restriction was pushed, so indexOf is guaranteed >= 0 */
|
||||
if (index >= 0) list.splice(index, 1)
|
||||
if (list.length === 0) this.restrictions.delete(scope)
|
||||
this.ctx.emit('tools/change')
|
||||
}
|
||||
this.ctx.emit('tools/change')
|
||||
}.bind(this), 'tools.restrict()')
|
||||
// The EXACT cordis effect disposer, not a wrapper: a composite (generator)
|
||||
// effect that owns a teardown ORDER must be able to yield THIS function —
|
||||
// cordis nests a disposer out of the fiber's concurrent sibling list by
|
||||
// exact function identity, so a wrapper would silently break the nesting
|
||||
// (the agents.register() lesson). Cleanup is synchronous because this
|
||||
// registration installs only synchronous state and notifications.
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||
return dispose
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all registered tool schemas — exactly the model-facing fields
|
||||
* (`name`, `description`, `parameters`), as sent to the model via the
|
||||
* system-prompt assembly. Constructed EXPLICITLY rather than by stripping
|
||||
* Register a monotonic guard after the extensible `tools/pre-execute`
|
||||
* waterfall. A plain-context guard applies globally; one registered through
|
||||
* `agent.ctx` applies only to that agent. Any matching guard may deny by
|
||||
* returning a reason, while no guard can force-allow a call another guard
|
||||
* denied. The exact effect disposer is returned for ordered ownership and
|
||||
* HMR cleanup.
|
||||
* @param guard - synchronous check; a returned string denies the execution.
|
||||
* @returns the exact disposer that unregisters the guard.
|
||||
*/
|
||||
guard(guard: ToolGuard): () => void {
|
||||
const scope = scopeOf(this.ctx)
|
||||
const registration = { guard }
|
||||
const dispose = this.ctx.effect(function* (this: ToolRegistry) {
|
||||
const layer = scope === undefined ? this.globalGuards : this.guardLayerFor(scope)
|
||||
layer.add(registration)
|
||||
yield () => {
|
||||
layer.delete(registration)
|
||||
if (scope !== undefined && layer.size === 0) this.scopedGuards.delete(scope)
|
||||
}
|
||||
}.bind(this), 'tools.guard()')
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
|
||||
return dispose
|
||||
}
|
||||
|
||||
/** The (created-on-demand) scoped layer for `scope`. */
|
||||
private layerFor(scope: ScopeKey): Map<string, ToolDefinition> {
|
||||
let layer = this.scoped.get(scope)
|
||||
if (!layer) {
|
||||
layer = new Map()
|
||||
this.scoped.set(scope, layer)
|
||||
}
|
||||
return layer
|
||||
}
|
||||
|
||||
/** Get or create the guard layer for one agent scope. */
|
||||
private guardLayerFor(scope: ScopeKey): Set<ToolGuardRegistration> {
|
||||
let layer = this.scopedGuards.get(scope)
|
||||
if (layer === undefined) {
|
||||
layer = new Set()
|
||||
this.scopedGuards.set(scope, layer)
|
||||
}
|
||||
return layer
|
||||
}
|
||||
|
||||
/** First monotonic denial from the global then matching scoped guard layers. */
|
||||
private guardReason(exec: ToolExecution): string | undefined {
|
||||
for (const { guard } of this.globalGuards) {
|
||||
const reason = guard(exec)
|
||||
if (reason !== undefined) return reason
|
||||
}
|
||||
if (exec.agent !== undefined) {
|
||||
for (const { guard } of this.scopedGuards.get(exec.agent) ?? []) {
|
||||
const reason = guard(exec)
|
||||
if (reason !== undefined) return reason
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Whether every restriction registered for `scope` admits the global tool `name` (intersection semantics). */
|
||||
private admits(scope: ScopeKey | undefined, name: string): boolean {
|
||||
if (scope === undefined) return true
|
||||
const filters = this.restrictions.get(scope)
|
||||
if (!filters) return true
|
||||
return filters.every(filter =>
|
||||
(filter.allow === undefined || filter.allow.has(name))
|
||||
&& (filter.deny === undefined || !filter.deny.has(name)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve every registry fact one scope needs in one layer traversal. The
|
||||
* visible map applies global restrictions, scoped shadowing, and the reserved
|
||||
* presentation transport; the other sets retain the pre-restriction facts
|
||||
* needed by restriction and prompt-order validation.
|
||||
* @param scope - the viewing scope (the agent), or undefined for the global view.
|
||||
* @returns the complete derived view for that scope.
|
||||
*/
|
||||
private view(scope?: ScopeKey): ToolView {
|
||||
const layer = scope === undefined ? undefined : this.scoped.get(scope)
|
||||
const visible = new Map<string, ToolDefinition>()
|
||||
const knownNames = new Set<string>()
|
||||
const restrictableNames = new Set<string>()
|
||||
for (const [name, definition] of this.global) {
|
||||
knownNames.add(name)
|
||||
restrictableNames.add(name)
|
||||
if (this.admits(scope, name)) visible.set(name, definition)
|
||||
}
|
||||
// Scoped layer second: same-name entries REPLACE (shadow) the global ones,
|
||||
// and scope-local registrations are never part of the global filter above.
|
||||
for (const [name, definition] of layer ?? []) {
|
||||
knownNames.add(name)
|
||||
visible.set(name, definition)
|
||||
}
|
||||
// Presentation infrastructure is resolved last and outside capability
|
||||
// filtering. Registration rejects this reserved name, so the insertion is
|
||||
// an invariant assertion as well as protection against future layer changes.
|
||||
if (this.codeTransport !== undefined) {
|
||||
visible.set(RUN_CODE_NAME, this.codeTransport)
|
||||
}
|
||||
return { visible, knownNames, restrictableNames }
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a tool as one scope sees it (scoped
|
||||
* shadows global; a restricted-away global reads as absent). Presenters pass
|
||||
* the calling agent so the rendered card matches the definition that
|
||||
* actually executed.
|
||||
* @param name - the tool name as registered.
|
||||
* @param scope - the viewing scope (the agent); omitted = the global view.
|
||||
* @returns the definition the scope resolves, or undefined when none is visible.
|
||||
*/
|
||||
get(name: string, scope?: ScopeKey): ToolDefinition | undefined {
|
||||
return this.view(scope).visible.get(name)
|
||||
}
|
||||
|
||||
/**
|
||||
* The model-facing schemas of everything `scope` can see — exactly the
|
||||
* fields (`name`, `description`, `parameters`) this registry contributes to
|
||||
* system-prompt assembly before its expert transformation waterfall.
|
||||
* Constructed EXPLICITLY rather than by stripping
|
||||
* known non-schema members: a `ToolDefinition` also carries `execute` and the
|
||||
* optional `presentCall`/`presentResult` UI callbacks, and those (especially
|
||||
* the functions) must never leak into a model request. An allowlist can't
|
||||
* drift when a new non-schema member is added to the definition; a denylist
|
||||
* (rest-destructure) would silently leak it.
|
||||
* @returns one deep-cloned schema per registered tool, in registration order.
|
||||
* @param scope - the viewing scope (the agent); omitted = the global view.
|
||||
* @returns one deep-cloned schema per visible tool.
|
||||
*/
|
||||
schemas(): ToolSchema[] {
|
||||
return [...this.store.values()].map(({ name, description, parameters }): ToolSchema => ({
|
||||
schemas(scope?: ScopeKey): ToolSchema[] {
|
||||
return [...this.view(scope).visible.values()].map(definition => this.schemaOf(definition, true))
|
||||
}
|
||||
|
||||
/** Project one definition onto the model-facing schema fields. */
|
||||
private schemaOf(definition: ToolDefinition, detachParameters: boolean): ToolSchema {
|
||||
const { name, description, parameters } = definition
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
parameters: structuredClone(parameters),
|
||||
}))
|
||||
parameters: detachParameters ? structuredClone(parameters) : parameters,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute one tool call through the `tools/pre-execute` → `tools/execute`
|
||||
* (around dispatch) → `tools/post-execute` pipeline. `pre-execute` is the gate
|
||||
* (allow/deny), `tools/execute` wraps core dispatch (a timeout/retry/metrics
|
||||
* Execute one tool call through the `tools/pre-execute` → guards →
|
||||
* `tools/execute` (around dispatch) → `tools/post-execute` → `tools/result`
|
||||
* pipeline. `pre-execute` is the extensible gate
|
||||
* (allow/deny/ask), `tools/execute` wraps core dispatch (a timeout/retry/metrics
|
||||
* seam), and `post-execute` is the inspect/transform seam; core dispatch sits
|
||||
* as the base `next()` of the `tools/execute` waterfall. The whole thing is
|
||||
* wrapped in one outer try/catch so a throwing listener (in any waterfall)
|
||||
* becomes an `isError` result instead of failing the turn; the tool body ALSO
|
||||
* keeps its own inner try/catch, so a thrown tool becomes an `isError` result
|
||||
* that `tools/execute` and `post-execute` listeners can still inspect. If the
|
||||
* tool is not registered, the result is an `isError` carrying a `UNKNOWN_TOOL`
|
||||
* structured error. A thrown {@link HarnessError} surfaces its `{ name, code }`
|
||||
* on the result.
|
||||
* @param exec - the call to run (name, parsed arguments, caller agent, signal).
|
||||
* @returns the final result after every waterfall; failures resolve as
|
||||
* `isError` results, never rejections.
|
||||
* tool is not registered (or not visible to the calling agent — a
|
||||
* restricted-away global is exactly as absent as a nonexistent one), the
|
||||
* result is an `isError` carrying a `UNKNOWN_TOOL` structured error. A thrown
|
||||
* {@link HarnessError} surfaces its `{ name, code }` on the result. Before
|
||||
* the final observe-only notification, the authoritative outcome is
|
||||
* materialized as a detached lossless-JSON snapshot; an invalid outcome is
|
||||
* normalized to an error.
|
||||
* @param exec - the typed same-process call input. The registry assigns its
|
||||
* correlation token before policy begins.
|
||||
* @returns the materialized final result after every waterfall; listener and
|
||||
* tool failures resolve as `isError` results rather than rejections.
|
||||
*/
|
||||
async execute(exec: ToolExecution): Promise<ToolExecutionResult> {
|
||||
async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult> {
|
||||
const token = createExecutionToken()
|
||||
const callId = exec.callId
|
||||
const name = exec.name
|
||||
const agent = exec.agent
|
||||
const parent = exec.parent
|
||||
const signal = exec.signal
|
||||
const base = {
|
||||
token,
|
||||
callId,
|
||||
name,
|
||||
...agent !== undefined ? { agent } : {},
|
||||
...parent !== undefined ? { parent } : {},
|
||||
...signal !== undefined ? { signal } : {},
|
||||
}
|
||||
let execution: ToolExecution
|
||||
try {
|
||||
// --- Gate: tools/pre-execute. An `ask` resolves through the approval
|
||||
// seam (or degrades) to allow/deny before the shared deny path. ---
|
||||
const gate = await this.ctx.waterfall(
|
||||
this, 'tools/pre-execute', exec,
|
||||
() => Promise.resolve<PreToolDecision>({ kind: 'allow' }),
|
||||
)
|
||||
const decision = gate.kind === 'ask' ? await this.serviceAsk(exec, gate) : gate
|
||||
if (decision.kind !== 'allow') {
|
||||
const denied: ToolExecutionResult = {
|
||||
callId: exec.callId,
|
||||
content: [{ type: 'text', text: `Error: ${decision.reason}` }],
|
||||
isError: true,
|
||||
}
|
||||
return await this.postExecute(exec, denied)
|
||||
const detached = snapshotJsonValue(exec.arguments)
|
||||
if (detached === undefined) {
|
||||
throw new TypeError('tool execution arguments must be losslessly JSON-serializable')
|
||||
}
|
||||
execution = {
|
||||
...base,
|
||||
arguments: deepFreeze(detached),
|
||||
}
|
||||
|
||||
// --- Around-dispatch: tools/execute. The base `next` is the dispatch-
|
||||
// with-normalization thunk — the tool body's own try/catch turns a throw
|
||||
// into an isError result so a wrapper (and post-execute) can inspect it;
|
||||
// an unknown tool routes through the same catch. A `tools/execute` listener
|
||||
// (e.g. a timeout plugin) wraps this thunk: it may mutate `exec` before
|
||||
// delegating and inspect the normalized result after. ---
|
||||
const result = await this.ctx.waterfall(
|
||||
this, 'tools/execute', exec,
|
||||
async (): Promise<ToolExecutionResult> => {
|
||||
try {
|
||||
const tool = this.store.get(exec.name)
|
||||
if (!tool) throw new ToolNotFoundError(exec.name)
|
||||
// Normalize the two `execute` return shapes: a bare ContentBlock[] (no
|
||||
// meta) or a { content, meta } object (a tool attaching a private
|
||||
// presentation payload). An array IS the content; the object carries it.
|
||||
const returned = await tool.execute(exec.arguments, exec)
|
||||
const content = Array.isArray(returned) ? returned : returned.content
|
||||
const meta = Array.isArray(returned) ? undefined : returned.meta
|
||||
return { callId: exec.callId, content, isError: false, ...meta !== undefined ? { meta } : {} }
|
||||
} catch (error: unknown) {
|
||||
return toolErrorResult(exec.callId, error)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return await this.postExecute(exec, result)
|
||||
} catch (error: unknown) {
|
||||
// Outer backstop: a throwing pre/post-execute listener (or the waterfall
|
||||
// machinery) becomes an isError result, never a turn failure.
|
||||
return toolErrorResult(exec.callId, error)
|
||||
execution = { ...base, arguments: undefined }
|
||||
const result = this.materializeFinalResult(toolErrorResult(callId, error))
|
||||
this.notifyResult(execution, result)
|
||||
return result
|
||||
}
|
||||
let result: ToolExecutionResult
|
||||
try {
|
||||
result = this.materializeFinalResult(await this.executePipeline(execution))
|
||||
} catch (error: unknown) {
|
||||
// Outer backstop: a throwing pre/post-execute listener, guard, or the
|
||||
// waterfall machinery becomes an isError result, never a turn failure.
|
||||
result = this.materializeFinalResult(toolErrorResult(execution.callId, error))
|
||||
}
|
||||
this.notifyResult(execution, result)
|
||||
return result
|
||||
}
|
||||
|
||||
/** Run the transformable pipeline; {@link execute} owns final normalization and notification. */
|
||||
private async executePipeline(exec: ToolExecution): Promise<ToolExecutionResult> {
|
||||
// --- Gate: tools/pre-execute. An `ask` resolves through the optional
|
||||
// approval seam (or degrades to deny) before the monotonic guards run. The
|
||||
// carrier keys dispatch by exec.agent, so an `agent.ctx` listener gates only
|
||||
// its own agent's calls (agent-less calls are subject-less).
|
||||
const carrier = scopeTarget(this, exec.agent)
|
||||
const gate = await this.ctx.waterfall(
|
||||
carrier, 'tools/pre-execute', exec,
|
||||
() => Promise.resolve<PreToolDecision>({ kind: 'allow' }),
|
||||
)
|
||||
const decision = gate.kind === 'ask' ? await this.serviceAsk(exec, gate) : gate
|
||||
const denialReason = decision.kind === 'allow'
|
||||
? this.guardReason(exec)
|
||||
: decision.reason
|
||||
if (denialReason !== undefined) {
|
||||
// Every non-grant, including a failed/unavailable approval request, takes
|
||||
// the same deny path and still reaches post-policy plus result observers.
|
||||
const denied: ToolExecutionResult = {
|
||||
callId: exec.callId,
|
||||
content: [{ type: 'text', text: `Error: ${denialReason}` }],
|
||||
isError: true,
|
||||
}
|
||||
return await this.postExecute(exec, denied)
|
||||
}
|
||||
|
||||
// --- Around-dispatch: tools/execute. The base `next` is the dispatch-
|
||||
// with-normalization thunk — the tool body's own try/catch turns a throw
|
||||
// into an isError result so a wrapper (and post-execute) can inspect it;
|
||||
// an unknown tool routes through the same catch. A `tools/execute` listener
|
||||
// (e.g. a timeout plugin) wraps this thunk: it may replace `exec.signal`
|
||||
// before delegating and inspect the normalized result after. Dispatched with the
|
||||
// same carrier as the gate, so an `agent.ctx` wrapper wraps only its own
|
||||
// agent's calls. ---
|
||||
const result = await this.ctx.waterfall(
|
||||
carrier, 'tools/execute', exec,
|
||||
async (): Promise<ToolExecutionResult> => {
|
||||
try {
|
||||
// Resolve through the CALLER's visible view ({@link get}): a scoped
|
||||
// tool shadows its global name-twin for that agent, and a
|
||||
// restricted-away global tool is exactly as absent as a nonexistent
|
||||
// one — same UNKNOWN_TOOL result, no capability leak in the error.
|
||||
const tool = this.get(exec.name, exec.agent)
|
||||
if (!tool) throw new ToolNotFoundError(exec.name)
|
||||
// Normalize the two `execute` return shapes: a bare ContentBlock[] (no
|
||||
// meta) or a { content, meta } object (a tool attaching a private
|
||||
// presentation payload). An array IS the content; the object carries it.
|
||||
const returned = await tool.execute(exec.arguments, exec)
|
||||
const content = Array.isArray(returned) ? returned : returned.content
|
||||
const meta = Array.isArray(returned) ? undefined : returned.meta
|
||||
return { callId: exec.callId, content, isError: false, ...meta !== undefined ? { meta } : {} }
|
||||
} catch (error: unknown) {
|
||||
return toolErrorResult(exec.callId, error)
|
||||
}
|
||||
},
|
||||
)
|
||||
if (result.callId !== exec.callId) {
|
||||
throw new TypeError(`tools/execute returned callId "${String(result.callId)}" for authoritative call "${exec.callId}"`)
|
||||
}
|
||||
|
||||
return await this.postExecute(exec, result)
|
||||
}
|
||||
|
||||
/** Notify final-result observers without giving them a mutation/error channel into the outcome. */
|
||||
private notifyResult(exec: ToolExecution, result: ToolExecutionResult): void {
|
||||
// The pipeline is over: freeze the remaining mutable signal slot so every
|
||||
// observer sees the SAME WeakMap-keyable execution without a mutation race.
|
||||
Object.freeze(exec)
|
||||
const callbacks = this.ctx.events.dispatch('emit', [
|
||||
scopeTarget(this, exec.agent), 'tools/result', exec, result,
|
||||
])
|
||||
for (const callback of callbacks) {
|
||||
try {
|
||||
callback(exec, result)
|
||||
} catch (error: unknown) {
|
||||
this.ctx.logger.warn(`tool "${exec.name}" (${exec.callId}): tools/result observer failed: ${errorMessage(error)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -586,43 +1061,40 @@ export class ToolRegistry extends Service {
|
||||
* Runs inside `execute`'s outer try/catch (a throwing listener → isError).
|
||||
*/
|
||||
private async postExecute(exec: ToolExecution, result: ToolExecutionResult): Promise<ToolExecutionResult> {
|
||||
// Snapshot the protected outcome BEFORE the waterfall. A listener receives
|
||||
// the same `result` reference, so a post-waterfall read of `result.callId`/
|
||||
// `.isError`/`.error` could carry a listener's mutation — violating the
|
||||
// authoritative-call-id requirement and the "preserve the dispatched
|
||||
// isError/error" contract. The decision is the ONLY sanctioned channel for a
|
||||
// listener to change the outcome (block, or accept-with-replacement); the
|
||||
// call id is always the authoritative `exec.callId`. `content` is copied into
|
||||
// a fresh array so a listener's in-place `push`/`splice` on `result.content`
|
||||
// cannot leak into the returned content either (the elements are the same
|
||||
// references — the snapshot guards the array structure, not deep immutability).
|
||||
const dispatched = {
|
||||
callId: exec.callId,
|
||||
content: [...result.content],
|
||||
isError: result.isError,
|
||||
...result.error ? { error: result.error } : {},
|
||||
...result.meta !== undefined ? { meta: result.meta } : {},
|
||||
}
|
||||
const decision = await this.ctx.waterfall(
|
||||
this, 'tools/post-execute', exec, result,
|
||||
scopeTarget(this, exec.agent), 'tools/post-execute', exec, result,
|
||||
() => Promise.resolve<PostToolDecision>({ kind: 'accept' }),
|
||||
)
|
||||
const additionalContext = decision.additionalContext
|
||||
if (decision.kind === 'block') {
|
||||
return {
|
||||
callId: dispatched.callId,
|
||||
callId: result.callId,
|
||||
content: decision.feedback,
|
||||
isError: true,
|
||||
...additionalContext ? { additionalContext } : {},
|
||||
}
|
||||
}
|
||||
// accept: replace content if supplied, preserve the dispatched isError/error.
|
||||
// Accept: replace content if supplied and preserve the dispatched outcome.
|
||||
return {
|
||||
...dispatched,
|
||||
...result,
|
||||
...decision.content ? { content: decision.content } : {},
|
||||
...additionalContext ? { additionalContext } : {},
|
||||
}
|
||||
}
|
||||
|
||||
/** Materialize the authoritative commit outcome once, immediately before `tools/result`. */
|
||||
private materializeFinalResult(result: ToolExecutionResult): ToolExecutionResult {
|
||||
const detached = snapshotJsonValue(result)
|
||||
if (detached === undefined) {
|
||||
throw new TypeError('tool result must be losslessly JSON-serializable')
|
||||
}
|
||||
return deepFreeze(detached)
|
||||
}
|
||||
}
|
||||
|
||||
/** Mint a same-process correlation token whose identity is its value. */
|
||||
function createExecutionToken(): ToolExecutionToken {
|
||||
return Symbol('dsh.tool.execution') as ToolExecutionToken
|
||||
}
|
||||
|
||||
function toolErrorResult(callId: ToolExecution['callId'], error: unknown): ToolExecutionResult {
|
||||
|
||||
@@ -287,21 +287,21 @@ export function validateArgs(spec: SchemaSpec, args: unknown): string[] {
|
||||
/** Options for {@link defineTool}. */
|
||||
export interface DefineToolOptions<S extends SchemaSpec> {
|
||||
/** Tool name (must be unique). */
|
||||
name: string
|
||||
readonly name: string
|
||||
/** Human-readable description sent to the model. */
|
||||
description: string
|
||||
readonly description: string
|
||||
/**
|
||||
* Parameter schema using the per-property-required DSL. Converted to
|
||||
* standard JSON Schema at runtime.
|
||||
*/
|
||||
parameters: S
|
||||
readonly parameters: S
|
||||
/**
|
||||
* Optional cooperative tool-call timeout budget in milliseconds. When given it
|
||||
* must be a positive finite number; it is attached to the produced
|
||||
* {@link ToolDefinition} for `@deepseek-ai/dsh-timeout-policy` to enforce and
|
||||
* is never sent to the model.
|
||||
*/
|
||||
timeoutMs?: number
|
||||
readonly timeoutMs?: number
|
||||
/**
|
||||
* Tool execution function. `args` is typed as {@link InferArgs<S>} — zero
|
||||
* casts needed. Returns either a bare {@link ContentBlock}`[]` (model-facing
|
||||
@@ -353,6 +353,7 @@ export interface DefineToolOptions<S extends SchemaSpec> {
|
||||
* Raw JSON-Schema tool definitions (from MCP servers) are still accepted
|
||||
* by `ToolRegistry.register()` directly — `defineTool` is sugar for
|
||||
* first-party plugin authors.
|
||||
*
|
||||
* @param options - the tool's name, description, typed parameter schema,
|
||||
* execute body, and optional presenters.
|
||||
* @returns a registry-ready {@link ToolDefinition}: its `execute` validates the
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { createScope } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scope } from '@deepseek-ai/dsh-scope'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
|
||||
import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
|
||||
import ToolRegistry, { CodeRunFailedError, RUN_CODE_NAME, defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { Config, PostToolDecision, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEventMap } from '@deepseek-ai/dsh-session'
|
||||
@@ -54,6 +57,15 @@ async function setup(options: SetupOptions = {}) {
|
||||
return { ctx, tools: ctx.tools, systemPrompt: ctx.systemPrompt, runtime: runtime! }
|
||||
}
|
||||
|
||||
/** Mint one production-shaped agent scope that can register scoped tool policy. */
|
||||
async function mintAgentScope(ctx: Context, name = 'scoped'): Promise<{ scope: Scope; agent: Agent }> {
|
||||
const agent = { id: AgentId(name) } as Agent
|
||||
let scope!: Scope
|
||||
await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, agent) },
|
||||
{ inject: ['tools', 'systemPrompt'] }))
|
||||
return { scope, agent }
|
||||
}
|
||||
|
||||
/** Register a trivial echo tool; returns the calls it received. */
|
||||
function registerEcho(ctx: Context, name = 'echo'): unknown[] {
|
||||
const calls: unknown[] = []
|
||||
@@ -111,6 +123,35 @@ describe('mode-aware wire contribution', () => {
|
||||
expect(sdk?.text).not.toContain('run_code(args:')
|
||||
})
|
||||
|
||||
it.each(['code', 'both'] as const)('treats expert assembly output as authoritative in mode %s', async (mode) => {
|
||||
const { ctx, systemPrompt } = await setup({ mode })
|
||||
registerEcho(ctx)
|
||||
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
|
||||
const assembly = await next()
|
||||
return {
|
||||
...assembly,
|
||||
sections: assembly.sections.filter(section => section.name !== 'tools:sdk'),
|
||||
tools: assembly.tools.filter(tool => tool.name !== RUN_CODE_NAME),
|
||||
}
|
||||
}, { prepend: true })
|
||||
|
||||
const assembly = await systemPrompt.assemble()
|
||||
expect(assembly.sections.some(section => section.name === 'tools:sdk')).toBe(false)
|
||||
expect(assembly.tools.some(tool => tool.name === RUN_CODE_NAME)).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['code', 'both'] as const)('lets one scope shadow the default SDK section in mode %s', async (mode) => {
|
||||
const { ctx, systemPrompt } = await setup({ mode })
|
||||
registerEcho(ctx)
|
||||
const { scope, agent } = await mintAgentScope(ctx)
|
||||
scope.ctx.systemPrompt.section({ name: 'tools:sdk', order: 150, text: 'SCOPED SDK' })
|
||||
|
||||
const scoped = await systemPrompt.assemble({ scope: agent })
|
||||
const global = await systemPrompt.assemble()
|
||||
expect(scoped.sections.find(section => section.name === 'tools:sdk')?.text).toBe('SCOPED SDK')
|
||||
expect(global.sections.find(section => section.name === 'tools:sdk')?.text).toContain('declare const tools:')
|
||||
})
|
||||
|
||||
it("mode 'both' contributes every native schema plus run_code, and the SDK section", async () => {
|
||||
const { ctx, systemPrompt } = await setup({ mode: 'both' })
|
||||
registerEcho(ctx)
|
||||
@@ -119,6 +160,107 @@ describe('mode-aware wire contribution', () => {
|
||||
expect(assembly.sections.some(section => section.name === 'tools:sdk')).toBe(true)
|
||||
})
|
||||
|
||||
it.each(['code', 'both'] as const)('keeps the run_code transport outside scoped allow-list filtering in mode %s', async (mode) => {
|
||||
const { ctx, systemPrompt, runtime } = await setup({ mode })
|
||||
registerEcho(ctx, 'echo')
|
||||
registerEcho(ctx, 'hidden')
|
||||
const { scope, agent } = await mintAgentScope(ctx)
|
||||
const lift = scope.ctx.tools.restrict({ allow: ['echo'] })
|
||||
|
||||
const assembly = await systemPrompt.assemble({ scope: agent })
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual(mode === 'code'
|
||||
? [RUN_CODE_NAME]
|
||||
: ['echo', RUN_CODE_NAME])
|
||||
const sdk = assembly.sections.find(section => section.name === 'tools:sdk')?.text
|
||||
expect(sdk).toContain('echo(args:')
|
||||
expect(sdk).not.toContain('hidden(args:')
|
||||
|
||||
runtime.behavior = request => Promise.resolve({
|
||||
logs: [],
|
||||
value: Object.keys(request.bindings[0]!.functions).sort().join(','),
|
||||
})
|
||||
const result = await runCode(ctx, 'return Object.keys(tools)', { agent })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'echo' }])
|
||||
|
||||
lift()
|
||||
const unrestricted = await systemPrompt.assemble({ scope: agent })
|
||||
expect(unrestricted.tools.map(tool => tool.name)).toEqual(mode === 'code'
|
||||
? [RUN_CODE_NAME]
|
||||
: ['echo', 'hidden', RUN_CODE_NAME])
|
||||
})
|
||||
|
||||
it.each(['code', 'both'] as const)('keeps the run_code transport outside scoped deny-list filtering in mode %s', async (mode) => {
|
||||
const { ctx, systemPrompt, runtime } = await setup({ mode })
|
||||
registerEcho(ctx, 'denied')
|
||||
registerEcho(ctx, 'kept')
|
||||
const { scope, agent } = await mintAgentScope(ctx)
|
||||
scope.ctx.tools.restrict({ deny: ['denied'] })
|
||||
|
||||
const assembly = await systemPrompt.assemble({ scope: agent })
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual(mode === 'code'
|
||||
? [RUN_CODE_NAME]
|
||||
: ['kept', RUN_CODE_NAME])
|
||||
const sdk = assembly.sections.find(section => section.name === 'tools:sdk')?.text
|
||||
expect(sdk).not.toContain('denied(args:')
|
||||
expect(sdk).toContain('kept(args:')
|
||||
|
||||
runtime.behavior = request => Promise.resolve({
|
||||
logs: [],
|
||||
value: Object.keys(request.bindings[0]!.functions).sort().join(','),
|
||||
})
|
||||
const result = await runCode(ctx, 'return Object.keys(tools)', { agent })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'kept' }])
|
||||
})
|
||||
|
||||
it.each(['code', 'both'] as const)('reserves run_code against scoped shadows and explicit restrictions in mode %s', async (mode) => {
|
||||
const { ctx, systemPrompt } = await setup({ mode })
|
||||
const { scope, agent } = await mintAgentScope(ctx)
|
||||
const impostor = defineTool({
|
||||
name: RUN_CODE_NAME,
|
||||
description: 'Scoped impostor.',
|
||||
parameters: {},
|
||||
execute: () => Promise.resolve([{ type: 'text' as const, text: 'impostor' }]),
|
||||
})
|
||||
|
||||
expect(() => scope.ctx.tools.register(impostor)).toThrow(/reserved for the Code Mode presentation transport/)
|
||||
expect(() => ctx.tools.register(impostor)).toThrow(/reserved for the Code Mode presentation transport/)
|
||||
expect(() => scope.ctx.tools.restrict({ allow: [RUN_CODE_NAME] })).toThrow(/cannot name reserved Code Mode presentation transport/)
|
||||
expect(() => scope.ctx.tools.restrict({ deny: [RUN_CODE_NAME] })).toThrow(/cannot name reserved Code Mode presentation transport/)
|
||||
scope.ctx.systemPrompt.section({ name: 'scoped-note', order: 149, text: 'safe note' })
|
||||
scope.ctx.tools.register(defineTool({
|
||||
name: 'scoped_safe',
|
||||
description: 'Safe scoped tool.',
|
||||
parameters: {},
|
||||
execute: () => Promise.resolve([{ type: 'text' as const, text: 'safe' }]),
|
||||
}))
|
||||
|
||||
const assembly = await systemPrompt.assemble({ scope: agent })
|
||||
const transports = assembly.tools.filter(tool => tool.name === RUN_CODE_NAME)
|
||||
expect(transports).toHaveLength(1)
|
||||
expect(transports[0]?.description).toContain('Execute a TypeScript program')
|
||||
expect(assembly.sections.find(section => section.name === 'scoped-note')?.text).toBe('safe note')
|
||||
expect(assembly.sections.find(section => section.name === 'tools:sdk')?.text).toContain('scoped_safe(args:')
|
||||
expect(ctx.tools.get(RUN_CODE_NAME, agent)).toBe(ctx.tools.get(RUN_CODE_NAME))
|
||||
const result = await runCode(ctx, 'return 1', { agent })
|
||||
expect(result.content).toEqual([{ type: 'text', text: '(run_code completed with no output)' }])
|
||||
})
|
||||
|
||||
it.each(['code', 'both'] as const)('keeps run_code in the toolOrder universe without exposing it as a restriction target in mode %s', async (mode) => {
|
||||
const { ctx, systemPrompt } = await setup({
|
||||
mode,
|
||||
toolOrder: [RUN_CODE_NAME, '<unlisted-tools>'],
|
||||
})
|
||||
registerEcho(ctx)
|
||||
const { agent } = await mintAgentScope(ctx)
|
||||
|
||||
const assembly = await systemPrompt.assemble({ scope: agent })
|
||||
expect(assembly.tools.map(tool => tool.name)).toEqual(mode === 'code'
|
||||
? [RUN_CODE_NAME]
|
||||
: [RUN_CODE_NAME, 'echo'])
|
||||
})
|
||||
|
||||
it("never exposes run_code to programs, even under mode 'both' (no recursive dispatch path)", async () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'both' })
|
||||
registerEcho(ctx)
|
||||
@@ -200,6 +342,36 @@ describe('the run_code dispatch bridge', () => {
|
||||
expect(result.meta).toEqual({ logs: [{ source: 'console', level: 'log', text: 'saw echo:one' }], dispatches: 2 })
|
||||
})
|
||||
|
||||
it('exposes only an opaque parent token to nested result observers', async () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
registerEcho(ctx)
|
||||
runtime.behavior = async (request) => {
|
||||
await request.bindings[0]!.functions.echo!({ value: 'nested' })
|
||||
return { logs: [], value: 'done' }
|
||||
}
|
||||
|
||||
// Model a timeout-style outer wrapper: it temporarily installs a signal,
|
||||
// delegates, then restores the exact prior shape. A nested result observer
|
||||
// is observe-only and must not receive the live outer execution object;
|
||||
// freezing the correlation value it sees therefore cannot break restore.
|
||||
ctx.on('tools/execute', async (exec, next) => {
|
||||
if (exec.name !== RUN_CODE_NAME) return next()
|
||||
const previous = exec.signal
|
||||
exec.signal = new AbortController().signal
|
||||
const result = await next()
|
||||
if (previous === undefined) delete exec.signal
|
||||
else exec.signal = previous
|
||||
return result
|
||||
})
|
||||
ctx.on('tools/result', (exec) => {
|
||||
if (exec.parent !== undefined) Object.freeze(exec.parent)
|
||||
})
|
||||
|
||||
const result = await runCode(ctx, 'await tools.echo({ value: "nested" })')
|
||||
expect(result.isError).toBe(false)
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'done' }])
|
||||
})
|
||||
|
||||
it('serializes Promise.all dispatches: tool executions never overlap, in submission order', async () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
const intervals: [string, string][] = []
|
||||
@@ -533,16 +705,17 @@ describe('the run_code dispatch bridge', () => {
|
||||
expect(events.filter(event => event.type === 'tool/code-dispatch')).toEqual([])
|
||||
})
|
||||
|
||||
it('logs the value the tool RECEIVED even when the tool mutates its arguments', async () => {
|
||||
it('gives the tool and durable log the same immutable argument value', async () => {
|
||||
const { ctx, runtime } = await setup({ mode: 'code' })
|
||||
const { agent, events } = fakeAgent()
|
||||
let mutationSucceeded: boolean | undefined
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'mutator',
|
||||
description: 'Mutates its own args object.',
|
||||
description: 'Attempts to mutate its args object.',
|
||||
parameters: { list: { type: 'array', required: true } },
|
||||
execute(args) {
|
||||
args.list.push('injected-by-tool')
|
||||
return Promise.resolve([{ type: 'text' as const, text: 'mutated' }])
|
||||
mutationSucceeded = Reflect.set(args.list, 1, 'injected-by-tool')
|
||||
return Promise.resolve([{ type: 'text' as const, text: 'protected' }])
|
||||
},
|
||||
}))
|
||||
runtime.behavior = async (request) => {
|
||||
@@ -551,6 +724,7 @@ describe('the run_code dispatch bridge', () => {
|
||||
}
|
||||
const result = await runCode(ctx, 'program', { agent })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(mutationSucceeded).toBe(false)
|
||||
const dispatch = events.find(event => event.type === 'tool/code-dispatch')?.data as SessionEventMap['tool/code-dispatch']
|
||||
expect(dispatch.arguments).toEqual({ list: ['original'] })
|
||||
})
|
||||
|
||||
594
packages/core/tools/tests/scoped.spec.ts
Normal file
594
packages/core/tools/tests/scoped.spec.ts
Normal file
@@ -0,0 +1,594 @@
|
||||
import { describe, expect, expectTypeOf, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { Events } from 'cordis'
|
||||
import { createScope } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scope } from '@deepseek-ai/dsh-scope'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import type { PreToolDecision, ToolDefinition, ToolExecution, ToolExecutionInput, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
|
||||
import type { Agent, AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
/** Mount the registry (with its systemPrompt dependency) on a fresh context. */
|
||||
async function mount(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt, {})
|
||||
await ctx.plugin(ToolRegistry)
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Mint a scope whose key doubles as a minimal Agent-like object. */
|
||||
async function mintAgentScope(ctx: Context, name: string): Promise<{ scope: Scope; key: Agent }> {
|
||||
const key = { id: name as AgentId } as Agent
|
||||
let scope!: Scope
|
||||
// The scoped context resolves services through the MINTING plugin's
|
||||
// dependency chain — the minter must inject what scope holders will reach
|
||||
// (in production the agent loop's inject list plays this role).
|
||||
await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, key) },
|
||||
{ inject: ['tools', 'systemPrompt'] }))
|
||||
return { scope, key }
|
||||
}
|
||||
|
||||
function tool(name: string, reply = `ran:${name}`): ToolDefinition {
|
||||
return {
|
||||
name,
|
||||
description: `tool ${name}`,
|
||||
parameters: { type: 'object', properties: {} },
|
||||
execute: (): Promise<ContentBlock[]> => Promise.resolve([{ type: 'text', text: reply }]),
|
||||
}
|
||||
}
|
||||
|
||||
async function run(ctx: Context, name: string, agent?: Agent): Promise<string> {
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('c1'),
|
||||
name,
|
||||
arguments: {},
|
||||
...agent ? { agent } : {},
|
||||
})
|
||||
const first = result.content[0]
|
||||
return first?.type === 'text' ? first.text : JSON.stringify(result.content)
|
||||
}
|
||||
|
||||
describe('scoped tool registration', () => {
|
||||
it('keeps final-result observers synchronous', () => {
|
||||
type ToolResultListener = Events['tools/result']
|
||||
type AsyncToolResultListener = () => Promise<void>
|
||||
|
||||
expectTypeOf<AsyncToolResultListener>().not.toExtend<ToolResultListener>()
|
||||
expectTypeOf<ReturnType<ToolResultListener>>().toEqualTypeOf<undefined>()
|
||||
})
|
||||
|
||||
it('files a scoped tool in its layer: visible/executable for that scope only', async () => {
|
||||
const ctx = await mount()
|
||||
const { scope, key } = await mintAgentScope(ctx, 'a')
|
||||
const other = { id: 'other' as AgentId } as Agent
|
||||
ctx.tools.register(tool('shared'))
|
||||
scope.ctx.tools.register(tool('mine'))
|
||||
|
||||
expect(ctx.tools.schemas(key).map(t => t.name).sort()).toEqual(['mine', 'shared'])
|
||||
expect(ctx.tools.schemas().map(t => t.name)).toEqual(['shared'])
|
||||
expect(ctx.tools.schemas(other).map(t => t.name)).toEqual(['shared'])
|
||||
|
||||
expect(await run(ctx, 'mine', key)).toBe('ran:mine')
|
||||
// Out-of-view execution is indistinguishable from a nonexistent tool.
|
||||
expect(await run(ctx, 'mine', other)).toBe('Error: unknown tool "mine"')
|
||||
expect(await run(ctx, 'mine')).toBe('Error: unknown tool "mine"')
|
||||
})
|
||||
|
||||
it('scoped shadows global on a name conflict, in either registration order', async () => {
|
||||
const ctx = await mount()
|
||||
const { scope, key } = await mintAgentScope(ctx, 'a')
|
||||
// scoped-then-global
|
||||
scope.ctx.tools.register(tool('bash', 'restricted-bash'))
|
||||
ctx.tools.register(tool('bash', 'global-bash'))
|
||||
expect(await run(ctx, 'bash', key)).toBe('restricted-bash')
|
||||
expect(await run(ctx, 'bash')).toBe('global-bash')
|
||||
expect(ctx.tools.get('bash', key)?.description).toBe(ctx.tools.get('bash', key)?.description)
|
||||
// Exactly one 'bash' in the scope's schema view (the shadow, not a double).
|
||||
expect(ctx.tools.schemas(key).filter(t => t.name === 'bash')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('rejects a duplicate name within one layer, naming agent.ctx for the global case', async () => {
|
||||
const ctx = await mount()
|
||||
const { scope } = await mintAgentScope(ctx, 'a')
|
||||
ctx.tools.register(tool('x'))
|
||||
expect(() => ctx.tools.register(tool('x'))).toThrow(/agent\.ctx/)
|
||||
scope.ctx.tools.register(tool('y'))
|
||||
expect(() => scope.ctx.tools.register(tool('y'))).toThrow(/already registered in this scope/)
|
||||
})
|
||||
|
||||
it('disposing the scope unwinds its registrations and leaves no residue', async () => {
|
||||
const ctx = await mount()
|
||||
const { scope, key } = await mintAgentScope(ctx, 'a')
|
||||
scope.ctx.tools.register(tool('mine'))
|
||||
expect(ctx.tools.get('mine', key)).toBeDefined()
|
||||
await scope.dispose()
|
||||
expect(ctx.tools.get('mine', key)).toBeUndefined()
|
||||
expect(ctx.tools.schemas(key)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('restrict()', () => {
|
||||
it('masks global tools, merges scope-local tools afterward, and keeps assembly with execution', async () => {
|
||||
const ctx = await mount()
|
||||
const { scope, key } = await mintAgentScope(ctx, 'a')
|
||||
ctx.tools.register(tool('read'))
|
||||
ctx.tools.register(tool('bash'))
|
||||
scope.ctx.tools.register(tool('capture'))
|
||||
scope.ctx.tools.restrict({ allow: ['read'] })
|
||||
|
||||
// The scope-local registration survives the allow-list; the unlisted global is gone.
|
||||
expect(ctx.tools.schemas(key).map(t => t.name).sort()).toEqual(['capture', 'read'])
|
||||
expect(await run(ctx, 'bash', key)).toBe('Error: unknown tool "bash"')
|
||||
expect(await run(ctx, 'read', key)).toBe('ran:read')
|
||||
expect(await run(ctx, 'capture', key)).toBe('ran:capture')
|
||||
// Other scopes and the global view are untouched.
|
||||
expect(ctx.tools.schemas().map(t => t.name).sort()).toEqual(['bash', 'read'])
|
||||
})
|
||||
|
||||
it('applies snapshotted filters to the live global registry before merging later scope-local tools', async () => {
|
||||
const ctx = await mount()
|
||||
const denied = await mintAgentScope(ctx, 'denied')
|
||||
const allowed = await mintAgentScope(ctx, 'allowed')
|
||||
ctx.tools.register(tool('read'))
|
||||
ctx.tools.register(tool('bash'))
|
||||
denied.scope.ctx.tools.restrict({ deny: ['bash'] })
|
||||
allowed.scope.ctx.tools.restrict({ allow: ['read'] })
|
||||
|
||||
ctx.tools.register(tool('web'))
|
||||
denied.scope.ctx.tools.register(tool('denied-local'))
|
||||
allowed.scope.ctx.tools.register(tool('allowed-local'))
|
||||
|
||||
expect(ctx.tools.schemas(denied.key).map(t => t.name).sort())
|
||||
.toEqual(['denied-local', 'read', 'web'])
|
||||
expect(ctx.tools.schemas(allowed.key).map(t => t.name).sort())
|
||||
.toEqual(['allowed-local', 'read'])
|
||||
expect(await run(ctx, 'web', denied.key)).toBe('ran:web')
|
||||
expect(await run(ctx, 'web', allowed.key)).toBe('Error: unknown tool "web"')
|
||||
expect(await run(ctx, 'denied-local', denied.key)).toBe('ran:denied-local')
|
||||
expect(await run(ctx, 'allowed-local', allowed.key)).toBe('ran:allowed-local')
|
||||
})
|
||||
|
||||
it('composes multiple restrictions by intersection and lifts each independently', async () => {
|
||||
const ctx = await mount()
|
||||
const { scope, key } = await mintAgentScope(ctx, 'a')
|
||||
for (const name of ['a', 'b', 'c']) ctx.tools.register(tool(name))
|
||||
const liftAllow = scope.ctx.tools.restrict({ allow: ['a', 'b'] })
|
||||
scope.ctx.tools.restrict({ deny: ['b'] })
|
||||
expect(ctx.tools.schemas(key).map(t => t.name)).toEqual(['a'])
|
||||
liftAllow()
|
||||
// The deny remains after the allow-list is lifted.
|
||||
expect(ctx.tools.schemas(key).map(t => t.name).sort()).toEqual(['a', 'c'])
|
||||
})
|
||||
|
||||
it('compiles the readonly filter values at registration', async () => {
|
||||
const ctx = await mount()
|
||||
const { scope, key } = await mintAgentScope(ctx, 'a')
|
||||
ctx.tools.register(tool('a'))
|
||||
ctx.tools.register(tool('b'))
|
||||
const filter = { deny: ['a'] }
|
||||
scope.ctx.tools.restrict(filter)
|
||||
filter.deny.push('b')
|
||||
expect(ctx.tools.schemas(key).map(t => t.name)).toEqual(['b'])
|
||||
})
|
||||
|
||||
it('fails loud on an unscoped call, an empty filter, and non-global names', async () => {
|
||||
const ctx = await mount()
|
||||
const { scope } = await mintAgentScope(ctx, 'a')
|
||||
ctx.tools.register(tool('real'))
|
||||
scope.ctx.tools.register(tool('local'))
|
||||
expect(() => ctx.tools.restrict({ deny: ['real'] })).toThrow(/requires a scoped context/)
|
||||
expect(() => scope.ctx.tools.restrict({})).toThrow(/no-op/)
|
||||
expect(() => scope.ctx.tools.restrict({ allow: ['local'] })).toThrow(/unknown global tool "local"/)
|
||||
expect(() => scope.ctx.tools.restrict({ allow: ['reall'] })).toThrow(/unknown global tool "reall"; known global tools: real/)
|
||||
expect(() => scope.ctx.tools.restrict({ deny: ['ghost', 'wraith'] })).toThrow(/unknown global tools "ghost", "wraith"/)
|
||||
|
||||
const emptyCtx = await mount()
|
||||
const { scope: emptyScope } = await mintAgentScope(emptyCtx, 'empty')
|
||||
expect(() => emptyScope.ctx.tools.restrict({ deny: ['ghost'] }))
|
||||
.toThrow(/known global tools: \(none\)/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('scoped execution dispatch', () => {
|
||||
it('an agent.ctx pre-execute listener gates only its own agent (and never subject-less calls)', async () => {
|
||||
const ctx = await mount()
|
||||
const { scope, key } = await mintAgentScope(ctx, 'a')
|
||||
const other = { id: 'other' as AgentId } as Agent
|
||||
ctx.tools.register(tool('t'))
|
||||
|
||||
const seen: (string | undefined)[] = []
|
||||
scope.ctx.on('tools/pre-execute', (exec: ToolExecution, _next: () => Promise<PreToolDecision>) => {
|
||||
seen.push(exec.agent?.id)
|
||||
return Promise.resolve<PreToolDecision>({ kind: 'deny', reason: 'scoped veto' })
|
||||
})
|
||||
|
||||
expect(await run(ctx, 't', key)).toBe('Error: scoped veto')
|
||||
expect(await run(ctx, 't', other)).toBe('ran:t')
|
||||
expect(await run(ctx, 't')).toBe('ran:t')
|
||||
expect(seen).toEqual(['a'])
|
||||
})
|
||||
|
||||
it('applies scoped guards after pre-execute and unwinds duplicate registrations independently', async () => {
|
||||
const ctx = await mount()
|
||||
const { scope, key } = await mintAgentScope(ctx, 'a')
|
||||
const other = { id: 'other' as AgentId } as Agent
|
||||
let bodyCalls = 0
|
||||
ctx.tools.register({
|
||||
...tool('t'),
|
||||
execute: () => {
|
||||
bodyCalls += 1
|
||||
return Promise.resolve([{ type: 'text', text: 'ran:t' }])
|
||||
},
|
||||
})
|
||||
const guard = (execution: Readonly<ToolExecution>): string => {
|
||||
expect(Object.isFrozen(execution.arguments)).toBe(true)
|
||||
return 'terminal policy'
|
||||
}
|
||||
const liftFirst = scope.ctx.tools.guard(guard)
|
||||
scope.ctx.tools.guard(guard)
|
||||
// Registered later and prepended outside every existing waterfall listener:
|
||||
// it can force the extensible pre decision to allow, but cannot bypass the
|
||||
// owner-level monotonic guard that runs after the waterfall.
|
||||
scope.ctx.on('tools/pre-execute', () => Promise.resolve({ kind: 'allow' }), { prepend: true })
|
||||
|
||||
expect(await run(ctx, 't', key)).toBe('Error: terminal policy')
|
||||
expect(await run(ctx, 't', other)).toBe('ran:t')
|
||||
expect(bodyCalls).toBe(1)
|
||||
|
||||
liftFirst()
|
||||
expect(await run(ctx, 't', key)).toBe('Error: terminal policy')
|
||||
await scope.dispose()
|
||||
expect(await run(ctx, 't', key)).toBe('ran:t')
|
||||
expect(bodyCalls).toBe(2)
|
||||
})
|
||||
|
||||
it('composes global guards monotonically when one abstains and a later one denies', async () => {
|
||||
const ctx = await mount()
|
||||
let bodyCalls = 0
|
||||
ctx.tools.register({
|
||||
...tool('t'),
|
||||
execute: () => {
|
||||
bodyCalls += 1
|
||||
return Promise.resolve([])
|
||||
},
|
||||
})
|
||||
ctx.tools.guard(() => undefined)
|
||||
ctx.tools.guard(() => 'global denial')
|
||||
|
||||
expect(await run(ctx, 't')).toBe('Error: global denial')
|
||||
expect(bodyCalls).toBe(0)
|
||||
})
|
||||
|
||||
it('shares one token and materialized argument value across the pipeline', async () => {
|
||||
const ctx = await mount()
|
||||
const { scope, key } = await mintAgentScope(ctx, 'a')
|
||||
let safeCalls = 0
|
||||
let dangerCalls = 0
|
||||
let scopedResults = 0
|
||||
let safeArguments: unknown
|
||||
const tokens = new Set<ToolExecutionToken>()
|
||||
ctx.tools.register({
|
||||
...tool('safe'),
|
||||
execute: (args) => {
|
||||
safeCalls += 1
|
||||
safeArguments = args
|
||||
return Promise.resolve([{ type: 'text', text: 'safe' }])
|
||||
},
|
||||
})
|
||||
ctx.tools.register({
|
||||
...tool('danger'),
|
||||
execute: () => {
|
||||
dangerCalls += 1
|
||||
return Promise.resolve([{ type: 'text', text: 'danger' }])
|
||||
},
|
||||
})
|
||||
scope.ctx.tools.guard(exec => exec.name === 'danger' ? 'danger denied' : undefined)
|
||||
ctx.on('tools/pre-execute', (exec, next) => {
|
||||
tokens.add(exec.token)
|
||||
expect(Object.isFrozen(exec.arguments)).toBe(true)
|
||||
return next()
|
||||
})
|
||||
ctx.on('tools/execute', (exec, next) => {
|
||||
tokens.add(exec.token)
|
||||
return next()
|
||||
})
|
||||
ctx.on('tools/post-execute', (exec, _result, next) => {
|
||||
tokens.add(exec.token)
|
||||
return next()
|
||||
})
|
||||
scope.ctx.on('tools/result', () => { scopedResults += 1 })
|
||||
|
||||
expect(await run(ctx, 'danger', key)).toBe('Error: danger denied')
|
||||
const callerArguments = { source: true }
|
||||
const safeResult = await ctx.tools.execute({
|
||||
callId: CallId('safe-call'),
|
||||
name: 'safe',
|
||||
arguments: callerArguments,
|
||||
agent: key,
|
||||
})
|
||||
expect(safeResult.content[0]).toMatchObject({ text: 'safe' })
|
||||
expect(Object.isFrozen(callerArguments)).toBe(false)
|
||||
expect(safeArguments).not.toBe(callerArguments)
|
||||
expect(Object.isFrozen(safeArguments)).toBe(true)
|
||||
expect(callerArguments).toEqual({ source: true })
|
||||
// One token for danger and one shared by every phase of safe.
|
||||
expect(tokens.size).toBe(2)
|
||||
expect({ safeCalls, dangerCalls, scopedResults }).toEqual({
|
||||
safeCalls: 1,
|
||||
dangerCalls: 0,
|
||||
scopedResults: 2,
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes non-cloneable arguments and still publishes one scoped final outcome', async () => {
|
||||
const ctx = await mount()
|
||||
const { scope, key } = await mintAgentScope(ctx, 'a')
|
||||
let policyCalls = 0
|
||||
let bodyCalls = 0
|
||||
let scopedObserved = 0
|
||||
let globalObserved = 0
|
||||
ctx.tools.register({
|
||||
...tool('t'),
|
||||
execute: () => {
|
||||
bodyCalls += 1
|
||||
return Promise.resolve([])
|
||||
},
|
||||
})
|
||||
ctx.on('tools/pre-execute', (_exec, next) => {
|
||||
policyCalls += 1
|
||||
return next()
|
||||
})
|
||||
let parent!: ToolExecutionToken
|
||||
ctx.tools.register(tool('parent'))
|
||||
const stopCapture = ctx.on('tools/pre-execute', (exec, next) => {
|
||||
if (exec.name === 'parent') parent = exec.token
|
||||
return next()
|
||||
})
|
||||
await ctx.tools.execute({ callId: CallId('parent'), name: 'parent', arguments: {} })
|
||||
stopCapture()
|
||||
policyCalls = 0
|
||||
const signal = new AbortController().signal
|
||||
scope.ctx.on('tools/result', (exec, result) => {
|
||||
scopedObserved += 1
|
||||
expect(exec.arguments).toBeUndefined()
|
||||
expect(exec.parent).toBe(parent)
|
||||
expect(exec.signal).toBe(signal)
|
||||
expect(Object.isFrozen(exec)).toBe(true)
|
||||
expect(result.isError).toBe(true)
|
||||
})
|
||||
ctx.on('tools/result', () => { globalObserved += 1 })
|
||||
const callerArguments = { invalid: () => undefined }
|
||||
|
||||
const scopedResult = await ctx.tools.execute({
|
||||
callId: CallId('non-cloneable'),
|
||||
name: 't',
|
||||
arguments: callerArguments,
|
||||
agent: key,
|
||||
parent,
|
||||
signal,
|
||||
})
|
||||
const subjectlessResult = await ctx.tools.execute({
|
||||
callId: CallId('non-cloneable-subjectless'),
|
||||
name: 't',
|
||||
arguments: { invalid: () => undefined },
|
||||
})
|
||||
expect(scopedResult.isError).toBe(true)
|
||||
expect(scopedResult.content[0]?.type === 'text' && scopedResult.content[0].text).toContain('losslessly JSON-serializable')
|
||||
expect(subjectlessResult.isError).toBe(true)
|
||||
expect({ policyCalls, bodyCalls, scopedObserved, globalObserved }).toEqual({
|
||||
policyCalls: 0,
|
||||
bodyCalls: 0,
|
||||
scopedObserved: 1,
|
||||
globalObserved: 2,
|
||||
})
|
||||
expect(Object.isFrozen(callerArguments)).toBe(false)
|
||||
expect(callerArguments.invalid).toBeTypeOf('function')
|
||||
})
|
||||
|
||||
it('reads a stateful parent accessor once before policy, dispatch, and result observation', async () => {
|
||||
const ctx = await mount()
|
||||
const observed: (ToolExecutionToken | undefined)[] = []
|
||||
ctx.tools.register({
|
||||
...tool('t'),
|
||||
execute: (_args, exec) => {
|
||||
observed.push(exec.parent)
|
||||
return Promise.resolve([{ type: 'text', text: 'ran:t' }])
|
||||
},
|
||||
})
|
||||
ctx.on('tools/pre-execute', (exec, next) => {
|
||||
observed.push(exec.parent)
|
||||
return next()
|
||||
})
|
||||
ctx.on('tools/execute', (exec, next) => {
|
||||
observed.push(exec.parent)
|
||||
return next()
|
||||
})
|
||||
ctx.on('tools/result', (exec) => { observed.push(exec.parent) })
|
||||
const forged = { fake: true } as unknown as ToolExecutionToken
|
||||
let parentReads = 0
|
||||
const input = {
|
||||
callId: CallId('stateful-parent'),
|
||||
name: 't',
|
||||
arguments: {},
|
||||
get parent(): ToolExecutionToken | undefined {
|
||||
parentReads += 1
|
||||
return parentReads === 1 ? undefined : forged
|
||||
},
|
||||
} as ToolExecutionInput
|
||||
|
||||
const result = await ctx.tools.execute(input)
|
||||
|
||||
expect(result.isError).toBe(false)
|
||||
expect(parentReads).toBe(1)
|
||||
expect(observed).toEqual([undefined, undefined, undefined, undefined])
|
||||
})
|
||||
|
||||
it('uses one input snapshot for the normalized error shell', async () => {
|
||||
const ctx = await mount()
|
||||
const { scope, key } = await mintAgentScope(ctx, 'accepted')
|
||||
const driftAgent = { id: 'drift' as AgentId } as Agent
|
||||
ctx.tools.register(tool('parent'))
|
||||
ctx.tools.register(tool('t'))
|
||||
let parent!: ToolExecutionToken
|
||||
const stopCapture = ctx.on('tools/pre-execute', (exec, next) => {
|
||||
if (exec.name === 'parent') parent = exec.token
|
||||
return next()
|
||||
})
|
||||
await ctx.tools.execute({ callId: CallId('parent'), name: 'parent', arguments: {} })
|
||||
stopCapture()
|
||||
const acceptedSignal = new AbortController().signal
|
||||
const driftSignal = new AbortController().signal
|
||||
const forged = { fake: true } as unknown as ToolExecutionToken
|
||||
const reads = { callId: 0, name: 0, arguments: 0, agent: 0, parent: 0, signal: 0 }
|
||||
const input = {
|
||||
get callId() { reads.callId += 1; return CallId('unstable-error') },
|
||||
get name() { reads.name += 1; return 't' },
|
||||
get arguments(): unknown { reads.arguments += 1; return { invalid: () => undefined } },
|
||||
get agent() { reads.agent += 1; return reads.agent === 1 ? key : driftAgent },
|
||||
get parent() { reads.parent += 1; return reads.parent <= 2 ? parent : forged },
|
||||
get signal() { reads.signal += 1; return reads.signal === 1 ? acceptedSignal : driftSignal },
|
||||
} as ToolExecutionInput
|
||||
let observed: Readonly<ToolExecution> | undefined
|
||||
let scopedObserved = 0
|
||||
ctx.on('tools/result', (exec) => { observed = exec })
|
||||
scope.ctx.on('tools/result', () => { scopedObserved += 1 })
|
||||
|
||||
const result = await ctx.tools.execute(input)
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(reads).toEqual({ callId: 1, name: 1, arguments: 1, agent: 1, parent: 1, signal: 1 })
|
||||
expect(scopedObserved).toBe(1)
|
||||
expect(observed).toMatchObject({
|
||||
callId: CallId('unstable-error'),
|
||||
name: 't',
|
||||
agent: key,
|
||||
parent,
|
||||
signal: acceptedSignal,
|
||||
})
|
||||
expect(Object.isFrozen(observed)).toBe(true)
|
||||
})
|
||||
|
||||
it('normalizes a throwing arguments accessor without rereading it or losing the final notification', async () => {
|
||||
const ctx = await mount()
|
||||
ctx.tools.register(tool('t'))
|
||||
let argumentReads = 0
|
||||
let observed = 0
|
||||
ctx.on('tools/result', (exec, result) => {
|
||||
observed += 1
|
||||
expect(exec.arguments).toBeUndefined()
|
||||
expect(result.isError).toBe(true)
|
||||
})
|
||||
const input = {
|
||||
callId: CallId('throwing-arguments'),
|
||||
name: 't',
|
||||
get arguments(): unknown {
|
||||
argumentReads += 1
|
||||
throw new Error('getter exploded')
|
||||
},
|
||||
} as ToolExecutionInput
|
||||
|
||||
const result = await ctx.tools.execute(input)
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'Error: getter exploded' }])
|
||||
expect(argumentReads).toBe(1)
|
||||
expect(observed).toBe(1)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Map', new Map([['mutable', true]])],
|
||||
['class instance', new (class Arguments { value = 1 })()],
|
||||
])('rejects cloneable non-JSON arguments (%s) before policy or dispatch', async (_kind, argumentsValue) => {
|
||||
const ctx = await mount()
|
||||
let policyCalls = 0
|
||||
let bodyCalls = 0
|
||||
let observed = 0
|
||||
ctx.tools.register({
|
||||
...tool('t'),
|
||||
execute: () => {
|
||||
bodyCalls += 1
|
||||
return Promise.resolve([])
|
||||
},
|
||||
})
|
||||
ctx.on('tools/pre-execute', (_exec, next) => {
|
||||
policyCalls += 1
|
||||
return next()
|
||||
})
|
||||
ctx.on('tools/result', (exec, result) => {
|
||||
observed += 1
|
||||
expect(exec.arguments).toBeUndefined()
|
||||
expect(result.isError).toBe(true)
|
||||
})
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('bad-arguments'), name: 't', arguments: argumentsValue,
|
||||
})
|
||||
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content).toEqual([{
|
||||
type: 'text', text: 'Error: tool execution arguments must be losslessly JSON-serializable',
|
||||
}])
|
||||
expect({ policyCalls, bodyCalls, observed }).toEqual({ policyCalls: 0, bodyCalls: 0, observed: 1 })
|
||||
})
|
||||
|
||||
it('reads nested arguments once into the executed snapshot', async () => {
|
||||
const ctx = await mount()
|
||||
ctx.tools.register(tool('t'))
|
||||
let reads = 0
|
||||
const argumentsValue = Object.defineProperty({}, 'value', {
|
||||
enumerable: true,
|
||||
get: () => ++reads === 1 ? 'safe' : new Map([['mutable', true]]),
|
||||
})
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('unstable-arguments'), name: 't', arguments: argumentsValue,
|
||||
})
|
||||
|
||||
expect(reads).toBe(1)
|
||||
expect(result).toEqual({
|
||||
callId: CallId('unstable-arguments'),
|
||||
content: [{ type: 'text', text: 'ran:t' }],
|
||||
isError: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('notifies every tools/result observer with the frozen final outcome and contains failures', async () => {
|
||||
const ctx = await mount()
|
||||
const { scope, key } = await mintAgentScope(ctx, 'a')
|
||||
ctx.tools.register(tool('t'))
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => ctx.logger)
|
||||
const seen: boolean[] = []
|
||||
const dispatchModes: string[] = []
|
||||
ctx.on('internal/dispatch', (mode, name) => {
|
||||
if (name === 'tools/result') dispatchModes.push(mode)
|
||||
})
|
||||
ctx.on('tools/execute', async (exec, next) => {
|
||||
await next()
|
||||
return {
|
||||
callId: exec.callId,
|
||||
content: [{ type: 'text', text: 'outer failure' }],
|
||||
isError: true,
|
||||
}
|
||||
}, { prepend: true })
|
||||
scope.ctx.on('tools/result', (_exec, result) => {
|
||||
expect(Object.isFrozen(_exec)).toBe(true)
|
||||
expect(Object.isFrozen(_exec.arguments)).toBe(true)
|
||||
expect(Object.isFrozen(result)).toBe(true)
|
||||
expect(Object.isFrozen(result.content)).toBe(true)
|
||||
seen.push(result.isError)
|
||||
})
|
||||
ctx.on('tools/result', () => {
|
||||
throw { toString: () => { throw new Error('coercion trap') } }
|
||||
})
|
||||
ctx.on('tools/result', (_exec, result) => { seen.push(result.isError) })
|
||||
|
||||
const result = await ctx.tools.execute({ callId: CallId('final'), name: 't', arguments: {}, agent: key })
|
||||
expect(result).toMatchObject({ isError: true, content: [{ type: 'text', text: 'outer failure' }] })
|
||||
expect(seen).toEqual([true, true])
|
||||
expect(dispatchModes).toEqual(['emit'])
|
||||
expect(warn).toHaveBeenCalledOnce()
|
||||
expect(String(warn.mock.calls[0]?.[0])).toContain('<unprintable thrown value>')
|
||||
})
|
||||
})
|
||||
@@ -115,6 +115,26 @@ describe('ToolRegistry', () => {
|
||||
expect('meta' in result).toBe(false)
|
||||
})
|
||||
|
||||
it('normalizes a contract-violating non-cloneable result before final notification', async () => {
|
||||
const ctx = await setup()
|
||||
let observedError: boolean | undefined
|
||||
ctx.on('tools/result', (_exec, result) => { observedError = result.isError })
|
||||
ctx.tools.register({
|
||||
...echoTool,
|
||||
name: 'bad-meta',
|
||||
async execute() {
|
||||
return { content: [], meta: () => undefined }
|
||||
},
|
||||
})
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('bad-meta'), name: 'bad-meta', arguments: {},
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0]?.type === 'text' && result.content[0].text).toContain('Error:')
|
||||
expect(observedError).toBe(true)
|
||||
})
|
||||
|
||||
it('returns isError results for unknown tools and throwing tools', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register({
|
||||
@@ -136,6 +156,28 @@ describe('ToolRegistry', () => {
|
||||
expect(thrown.content[0]).toMatchObject({ text: 'Error: exploded' })
|
||||
})
|
||||
|
||||
it('normalizes a hostile thrown value whose inspection and coercion both throw', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register({
|
||||
...echoTool,
|
||||
name: 'hostile-throw',
|
||||
async execute() {
|
||||
throw new Proxy({}, {
|
||||
getPrototypeOf: () => { throw new Error('prototype trap') },
|
||||
has: () => { throw new Error('has trap') },
|
||||
get: () => { throw new Error('get trap') },
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
await expect(ctx.tools.execute({
|
||||
callId: CallId('hostile'), name: 'hostile-throw', arguments: {},
|
||||
})).resolves.toMatchObject({
|
||||
isError: true,
|
||||
content: [{ type: 'text', text: 'Error: <unprintable thrown value>' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('ToolNotFoundError carries the tool name and a stable code', async () => {
|
||||
const { HarnessError } = await import('@deepseek-ai/dsh-llm')
|
||||
const err = new ToolNotFoundError('ghost')
|
||||
@@ -336,33 +378,6 @@ describe('ToolRegistry', () => {
|
||||
expect(result.additionalContext).toMatchObject({ content: [{ text: 'fyi' }], source: { kind: 'plugin', plugin: 'test' } })
|
||||
})
|
||||
|
||||
it('a post-execute listener mutating the result object cannot corrupt callId/isError/error', async () => {
|
||||
// The decision is the ONLY sanctioned channel to change the outcome. A
|
||||
// listener that reaches in and mutates the passed result reference (flipping
|
||||
// isError, rewriting callId, attaching a bogus error) must NOT affect what
|
||||
// execute() returns — the registry snapshots the authoritative fields before
|
||||
// the waterfall and rebuilds from the snapshot + decision.
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
|
||||
ctx.on('tools/post-execute', async (_exec, result, next) => {
|
||||
const mutable = result as { callId: string; isError: boolean; error?: unknown; content: unknown[] }
|
||||
mutable.callId = 'hijacked'
|
||||
mutable.isError = true
|
||||
mutable.error = { name: 'Evil', code: 'EVIL' }
|
||||
mutable.content.push({ type: 'text', text: 'INJECTED' }) // in-place array mutation
|
||||
return next() // delegate to the default accept — no decision-level override
|
||||
})
|
||||
|
||||
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
|
||||
expect(result.callId).toBe(CallId('c1')) // authoritative exec.callId, not 'hijacked'
|
||||
expect(result.isError).toBe(false) // the real (successful) dispatch outcome
|
||||
expect(result.error).toBeUndefined() // no listener-injected error
|
||||
expect(result.content).toHaveLength(1) // the in-place push did not leak in
|
||||
expect(result.content[0]).toMatchObject({ text: 'hi' })
|
||||
expect(result.content.some(b => (b as { text?: string }).text === 'INJECTED')).toBe(false)
|
||||
})
|
||||
|
||||
it('composes pre + post waterfalls around dispatch (sandbox-wrap pattern)', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
@@ -519,6 +534,42 @@ describe('ToolRegistry', () => {
|
||||
expect(result.content[0]).toMatchObject({ text: 'short-circuited' })
|
||||
})
|
||||
|
||||
it('preserves additionalContext supplied by an around-dispatch result', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
ctx.on('tools/execute', async exec => ({
|
||||
callId: exec.callId,
|
||||
content: [{ type: 'text', text: 'short-circuited with context' }],
|
||||
isError: false,
|
||||
additionalContext: {
|
||||
content: [{ type: 'text', text: 'from around dispatch' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
},
|
||||
}))
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('around-context'), name: 'echo', arguments: {},
|
||||
})
|
||||
expect(result.additionalContext).toEqual({
|
||||
content: [{ type: 'text', text: 'from around dispatch' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes a tools/execute result with the wrong call id', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
ctx.on('tools/execute', async () => ({ callId: CallId('other'), content: [], isError: false }))
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('malformed-shape'), name: 'echo', arguments: {},
|
||||
})
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.content[0]).toMatchObject({
|
||||
text: 'Error: tools/execute returned callId "other" for authoritative call "malformed-shape"',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns an isError result when a tools/execute listener throws', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
@@ -596,6 +647,14 @@ describe('ToolRegistry', () => {
|
||||
}])
|
||||
})
|
||||
|
||||
it('rejects a non-positive or non-finite registration timeout', async () => {
|
||||
const ctx = await setup()
|
||||
expect(() => ctx.tools.register({ ...echoTool, name: 'zero-timeout', timeoutMs: 0 }))
|
||||
.toThrow('timeoutMs must be a positive finite number')
|
||||
expect(() => ctx.tools.register({ ...echoTool, name: 'infinite-timeout', timeoutMs: Number.POSITIVE_INFINITY }))
|
||||
.toThrow('timeoutMs must be a positive finite number')
|
||||
})
|
||||
|
||||
it('rejects duplicate names and unregisters on fiber dispose (HMR safety)', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
@@ -642,6 +701,35 @@ describe('ToolRegistry', () => {
|
||||
dispose()
|
||||
expect(ctx.tools.get('echo')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('register() returns the EXACT effect disposer: a composite yield nests the teardown in order', async () => {
|
||||
// The registry-disposer convention (set by agents.register): the returned
|
||||
// function IS the cordis effect disposer, so a composite (generator)
|
||||
// effect that yields it has the unregistration run at that yield's LIFO
|
||||
// position on owner unload. A wrapper would leave the inner effect
|
||||
// disposing as a CONCURRENT SIBLING of the composite; the async probe
|
||||
// below (disposed first, LIFO) yields the event loop exactly like the
|
||||
// agent factory's stop-and-drain link, and a sibling unregistration fires
|
||||
// in that window — the probe would observe the tool already gone. Pins
|
||||
// the convention for the whole register-method family (system-prompt
|
||||
// registrars, registerProvider, setFactory share the same return).
|
||||
const ctx = await setup()
|
||||
const order: string[] = []
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
inner.effect(function* () {
|
||||
yield () => { order.push('disposed-last') }
|
||||
yield inner.tools.register({ ...echoTool, name: 'nested' })
|
||||
order.push('registered')
|
||||
yield async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
order.push(inner.tools.get('nested') ? 'first: still registered' : 'first: already gone')
|
||||
}
|
||||
})
|
||||
}, { inject: ['tools'] }))
|
||||
await fiber.dispose()
|
||||
expect(order).toEqual(['registered', 'first: still registered', 'disposed-last'])
|
||||
expect(ctx.tools.get('nested')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('defineTool / schema DSL', () => {
|
||||
|
||||
@@ -29,6 +29,9 @@
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/scope"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/user-approval"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user