Merge remote-tracking branch 'origin/master' into feat/plan-mode
This commit is contained in:
@@ -36,6 +36,7 @@ python/ Python SDK and bundled runtime (see python/README.md)
|
||||
examples/ Runnable cordis.yml leaves over packages/examples bundles (see examples/AGENTS.md)
|
||||
docs/ architecture, generated catalogs, RFCs, postmortems, cookbook (see docs/AGENTS.md)
|
||||
scripts/ repo gates and generators
|
||||
website/ VitePress docs site (zh-CN); api/ pages generated from source
|
||||
```
|
||||
|
||||
Package groups: [packages/README.md](packages/README.md).
|
||||
@@ -55,6 +56,7 @@ pnpm run duplication # cross-file TypeScript clone detection
|
||||
pnpm run build # tsc emits lib/types, tsdown bundles runtime
|
||||
pnpm run hygiene # knip + publint + workspace constraints + NodeNext consumer check
|
||||
pnpm run doc-sync # all documentation gates; see the doc-sync script in package.json
|
||||
pnpm run website:build # VitePress build (doubles as the site's dead-link check)
|
||||
pnpm run demo:echo # mock-model REPL, no key needed
|
||||
pnpm run demo:repl # real REPL coding agent (needs DEEPSEEK_API_KEY)
|
||||
pnpm run demo:cordis # self-referential demo: the agent modifies its own runtime (needs key)
|
||||
@@ -77,6 +79,7 @@ pnpm run duplication
|
||||
pnpm run test:coverage
|
||||
pnpm run test:snapshot
|
||||
pnpm run doc-sync
|
||||
pnpm run website:build
|
||||
pnpm run verify-module-graph
|
||||
pnpm run build
|
||||
pnpm run hygiene
|
||||
|
||||
@@ -259,7 +259,7 @@ Creation announcement during session publication. A synchronous throw vetoes and
|
||||
'session/created'(this: Scoped<Session>, session: Session): void
|
||||
```
|
||||
|
||||
Source: [`packages/core/session/src/index.ts:47`](../../packages/core/session/src/index.ts)
|
||||
Source: [`packages/core/session/src/index.ts:46`](../../packages/core/session/src/index.ts)
|
||||
|
||||
### `session/disposed` — emit
|
||||
|
||||
@@ -269,7 +269,7 @@ Emitted once when an announced session leaves the store, including publication r
|
||||
'session/disposed'(this: Scoped<Session>, session: Session): void
|
||||
```
|
||||
|
||||
Source: [`packages/core/session/src/index.ts:57`](../../packages/core/session/src/index.ts)
|
||||
Source: [`packages/core/session/src/index.ts:56`](../../packages/core/session/src/index.ts)
|
||||
|
||||
### `session/event` — emit
|
||||
|
||||
@@ -281,7 +281,7 @@ Post-commit, fire-and-forget append feed. The listener snapshot resolves before
|
||||
|
||||
Types: [SessionEvent](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/session/src/index.ts:69`](../../packages/core/session/src/index.ts)
|
||||
Source: [`packages/core/session/src/index.ts:68`](../../packages/core/session/src/index.ts)
|
||||
|
||||
### `session/flush` — parallel
|
||||
|
||||
@@ -291,7 +291,7 @@ Awaited parallel durability checkpoint: every listener runs and the caller await
|
||||
'session/flush'(this: Scoped<Session>, session: Session): Promise<void> | void
|
||||
```
|
||||
|
||||
Source: [`packages/core/session/src/index.ts:79`](../../packages/core/session/src/index.ts)
|
||||
Source: [`packages/core/session/src/index.ts:78`](../../packages/core/session/src/index.ts)
|
||||
|
||||
## `subagent/*`
|
||||
|
||||
@@ -483,14 +483,14 @@ Source: [`packages/workflow/workflow/src/index.ts:45`](../../packages/workflow/w
|
||||
|
||||
The framework events every plugin also sees, beyond the harness vocabulary above. This is pinned vendor source ([vendoring policy](../../vendor/README.md)); it is summarized here so the page is a complete picture of the event bus, without elevating framework internals to the harness tier's prominence.
|
||||
|
||||
- `internal/plugin` — A plugin fiber was created. ([`vendor/cordis/src/events.ts:197`](../../vendor/cordis/src/events.ts))
|
||||
- `internal/status` — A fiber changed lifecycle state. ([`vendor/cordis/src/events.ts:198`](../../vendor/cordis/src/events.ts))
|
||||
- `internal/service` — Interception hook for a service binding (no core producer). ([`vendor/cordis/src/events.ts:199`](../../vendor/cordis/src/events.ts))
|
||||
- `internal/update` — Waterfall: a fiber config update is being applied. ([`vendor/cordis/src/events.ts:200`](../../vendor/cordis/src/events.ts))
|
||||
- `internal/get` — Waterfall: a service is being read from the store. ([`vendor/cordis/src/events.ts:201`](../../vendor/cordis/src/events.ts))
|
||||
- `internal/set` — Waterfall: a service is being written to the store. ([`vendor/cordis/src/events.ts:202`](../../vendor/cordis/src/events.ts))
|
||||
- `internal/listener` — A listener was registered. ([`vendor/cordis/src/events.ts:203`](../../vendor/cordis/src/events.ts))
|
||||
- `internal/dispatch` — An event is being dispatched to listeners. ([`vendor/cordis/src/events.ts:204`](../../vendor/cordis/src/events.ts))
|
||||
- `internal/plugin` — A plugin fiber was created. ([`vendor/cordis/src/events.ts:328`](../../vendor/cordis/src/events.ts))
|
||||
- `internal/status` — A fiber changed lifecycle state. ([`vendor/cordis/src/events.ts:330`](../../vendor/cordis/src/events.ts))
|
||||
- `internal/service` — Interception hook for a service binding (no core producer). ([`vendor/cordis/src/events.ts:332`](../../vendor/cordis/src/events.ts))
|
||||
- `internal/update` — Waterfall: a fiber config update is being applied. ([`vendor/cordis/src/events.ts:334`](../../vendor/cordis/src/events.ts))
|
||||
- `internal/get` — Waterfall: a service is being read from the store. ([`vendor/cordis/src/events.ts:336`](../../vendor/cordis/src/events.ts))
|
||||
- `internal/set` — Waterfall: a service is being written to the store. ([`vendor/cordis/src/events.ts:338`](../../vendor/cordis/src/events.ts))
|
||||
- `internal/listener` — A listener was registered. ([`vendor/cordis/src/events.ts:340`](../../vendor/cordis/src/events.ts))
|
||||
- `internal/dispatch` — An event is being dispatched to listeners. ([`vendor/cordis/src/events.ts:342`](../../vendor/cordis/src/events.ts))
|
||||
- `hmr/change` — A watched source file changed on disk. ([`vendor/hmr/src/index.ts:20`](../../vendor/hmr/src/index.ts))
|
||||
- `hmr/reload` — Plugins are being reloaded after a change. ([`vendor/hmr/src/index.ts:21`](../../vendor/hmr/src/index.ts))
|
||||
- `exit` — The process is exiting on a signal. ([`vendor/loader/src/index.ts:23`](../../vendor/loader/src/index.ts))
|
||||
|
||||
@@ -111,7 +111,7 @@ abstract compactRegion( session: Session, start: number, end: number, agent: Com
|
||||
|
||||
Types: [Message](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/compact/compact/src/index.ts:36`](../../packages/compact/compact/src/index.ts)
|
||||
Source: [`packages/compact/compact/src/index.ts:37`](../../packages/compact/compact/src/index.ts)
|
||||
|
||||
## `ctx.fs` — `FileSystem` (abstract seam)
|
||||
|
||||
@@ -234,7 +234,7 @@ list(): Session[]
|
||||
fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session
|
||||
```
|
||||
|
||||
Source: [`packages/core/session/src/index.ts:540`](../../packages/core/session/src/index.ts)
|
||||
Source: [`packages/core/session/src/index.ts:539`](../../packages/core/session/src/index.ts)
|
||||
|
||||
## `ctx.skills` — `SkillService`
|
||||
|
||||
@@ -374,12 +374,12 @@ Source: [`packages/workflow/workflow/src/index.ts:159`](../../packages/workflow/
|
||||
|
||||
The framework `ctx` surface every plugin also sees, beyond the harness services above. This is pinned vendor source ([vendoring policy](../../vendor/README.md)); it is summarized here so the page is a complete picture of what `ctx` offers, without elevating framework internals to the harness tier's prominence.
|
||||
|
||||
- `ctx.on / ctx.once` — Register an event listener (disposable). ([`vendor/cordis/src/events.ts:29`](../../vendor/cordis/src/events.ts))
|
||||
- `ctx.emit / ctx.parallel / ctx.serial / ctx.bail / ctx.waterfall` — Dispatch an event (sync / awaited / first-bail / veto-chain). ([`vendor/cordis/src/events.ts:29`](../../vendor/cordis/src/events.ts))
|
||||
- `ctx.plugin / ctx.inject` — Load a plugin / declare required services. ([`vendor/cordis/src/registry.ts:144`](../../vendor/cordis/src/registry.ts))
|
||||
- `ctx.on / ctx.once` — Register an event listener (disposable). ([`vendor/cordis/src/events.ts:34`](../../vendor/cordis/src/events.ts))
|
||||
- `ctx.emit / ctx.parallel / ctx.serial / ctx.bail / ctx.waterfall` — Dispatch an event (sync / awaited / first-bail / veto-chain). ([`vendor/cordis/src/events.ts:34`](../../vendor/cordis/src/events.ts))
|
||||
- `ctx.plugin / ctx.inject` — Load a plugin / declare required services. ([`vendor/cordis/src/registry.ts:164`](../../vendor/cordis/src/registry.ts))
|
||||
- `ctx.effect` — Register a disposable side effect tied to the fiber. ([`vendor/cordis/src/fiber.ts:9`](../../vendor/cordis/src/fiber.ts))
|
||||
- `ctx.get / ctx.set / ctx.provide / ctx.accessor / ctx.mixin` — Low-level service-store access and binding. ([`vendor/cordis/src/reflect.ts:7`](../../vendor/cordis/src/reflect.ts))
|
||||
- `ctx.extend / ctx.isolate / ctx.intercept` — Derive a child context (scoped services / isolation / interception). ([`vendor/cordis/src/context.ts:35`](../../vendor/cordis/src/context.ts))
|
||||
- `ctx.extend / ctx.isolate / ctx.intercept` — Derive a child context (scoped services / isolation / interception). ([`vendor/cordis/src/context.ts:42`](../../vendor/cordis/src/context.ts))
|
||||
- `ctx.root / ctx.scope / ctx.fiber / ctx.registry / ctx.reflect / ctx.events / ctx.logger` — Ambient handles onto the running context graph. ([`vendor/cordis/src/context.ts:16`](../../vendor/cordis/src/context.ts))
|
||||
- `ctx.timer (+ interval / timeout / throttle / debounce / setTimeout / setInterval)` — Disposable timer helpers. The `timer` key is provided at runtime; the six helpers are mixed onto ctx directly (declared via Pick). ([`vendor/timer/src/index.ts:4`](../../vendor/timer/src/index.ts))
|
||||
- `ctx.loader` — The config Loader that booted the app (present under the loader). ([`vendor/loader/src/index.ts:30`](../../vendor/loader/src/index.ts))
|
||||
|
||||
@@ -53,3 +53,5 @@ interface CompactionResult {
|
||||
`CompactService` exposes `compactIfNeeded(...)` for pressure-triggered compaction, returning `null` when no compaction is needed, and `compactRegion(...)` for an explicit inclusive surface range. The pre-step caller supplies the agent, full prompt, session prefix, and abort signal; implementations must forward that signal to summarization. Estimation, retention, event sequencing, and summarization remain backend policy.
|
||||
|
||||
Auto-compaction runs at serial `agent/pre-step`, before the step and request derivation, so it can replace surface nodes while keeping trace events outside the step. Region boundaries preserve tool-call/result pairing but do not preserve whole turns, allowing early closed steps of one oversized turn to compact. `dsh-compact-basic` owns the retention and failure details.
|
||||
|
||||
The seam exports `toolPairingBalancedBefore(session, node)` and `toolPairingBalancedAfter(session, node)` for those edge checks. Both validate current surface membership, reject stale or missing seqs and orphan results, and ignore a caller-retained `node.next`; the [package contract](../../packages/compact/compact/README.md#tool-pairing-boundaries) owns their cache semantics.
|
||||
@@ -211,6 +211,8 @@ export interface SurfaceNode {
|
||||
}
|
||||
```
|
||||
|
||||
`SurfaceNode` is positional state, not durable identity. A replacement can remove a caller-retained node or make a copied `next` stale; consumers that cross a surface mutation validate membership and answer positional queries from `Session.surface.nodes`. `SurfaceManager.replaceGeneration` increments for each replacement so incremental consumers can distinguish pure tail growth from a rewrite.
|
||||
|
||||
### `SurfaceFoldReplacement` and `SurfaceFoldResult` — a complete surface replay
|
||||
|
||||
`foldSurface(events)` returns detached current nodes together with the actual node seqs shadowed by each declared replacement range. `SurfaceManager` uses the same transition functions for its incremental cache.
|
||||
|
||||
@@ -26,10 +26,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:70`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) |
|
||||
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:53`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
|
||||
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:39`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:57`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | - |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:69`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio`](../packages/ui/stdio), [`workspace-context`](../packages/context/workspace-context) |
|
||||
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:46`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:56`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:68`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio`](../packages/ui/stdio), [`workspace-context`](../packages/context/workspace-context) |
|
||||
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:78`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) |
|
||||
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:108`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) |
|
||||
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:82`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:88`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
|
||||
+4
-2
@@ -12,6 +12,8 @@ Extract a backend-agnostic `PersistenceCoordinator` into `dsh-session-persistenc
|
||||
|
||||
Composition, not inheritance. The coordinator is a concrete class the backend holds, not a base class the backend extends. The RFC's risk — "a coordinator must not make unusual backends fight an inheritance hierarchy" — is avoided: a backend exposes only the hooks; it cannot reach the coordinator's private orchestration state, and the public `SessionPersistence` service shape is unchanged, so a third-party backend MAY still implement the abstract service directly without the coordinator at all.
|
||||
|
||||
The coordinator retires each live session from its `session/disposed` notification: it waits for that exact Session object's initialization, serializes a final drain, and then removes the owned state, buffer, and init entries. Failed drains retain their buffers for backend teardown to retry. Settled per-id chain tails remove themselves only when they are still the current tail, so a completion cannot erase a newer operation for the same id. Backend teardown unregisters the write-path listeners before awaiting all admitted retirements, remaining buffers, and chains, then closes the backend.
|
||||
|
||||
### The hook interface (`PersistenceBackend<TornMarker>`)
|
||||
|
||||
Six methods (five required + an optional lifecycle hook) — the only seam between the coordinator and storage:
|
||||
@@ -30,7 +32,7 @@ The single design choice that keeps the seam clean: the crash-repair "where is t
|
||||
|
||||
## Testing
|
||||
|
||||
The shared `runPersistenceContract` (public-API contract) keeps running for every backend. A new `runCoordinatorContract` (`tests/coordinator-contract.ts`) holds the write-path orchestration — adoption, HMR, collision, dispose-drain, crash-tail repair — and runs once per backend through a `CoordinatorFixture` (an in-memory reference + jsonl + sqlite). The per-backend specs shrank to storage mechanics only (JSONL: path safety, fsync rollback, bucket listing; SQLite: schema version, `scanRows`, transaction rollback). A through-coordinator torn-tail→load→`commitRepair` test per real backend (via a `corruptTail` fixture hook) keeps the coordinator's torn-marker repair branch covered under the 100% per-file gate — the contract crash test only produces synthetic closers, never a torn marker, so it could not reach that branch.
|
||||
The shared `runPersistenceContract` (public-API contract) keeps running for every backend. `runCoordinatorContract` (`tests/coordinator-contract.ts`) holds the write-path orchestration — adoption, HMR, collision, session and backend disposal drains, and crash-tail repair — and runs once per backend through a `CoordinatorFixture` (an in-memory reference + jsonl + sqlite). Coordinator-specific tests pin retirement map cleanup, same-id chain-tail races, failed-drain retry, and close ordering. The per-backend specs retain storage mechanics only (JSONL: path safety, fsync rollback, bucket listing; SQLite: schema version, `scanRows`, transaction rollback). A through-coordinator torn-tail→load→`commitRepair` test per real backend (via a `corruptTail` fixture hook) keeps the coordinator's torn-marker repair branch covered under the 100% per-file gate — the contract crash test only produces synthetic closers, never a torn marker, so it could not reach that branch.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -39,4 +41,4 @@ The shared `runPersistenceContract` (public-API contract) keeps running for ever
|
||||
|
||||
## Consequences
|
||||
|
||||
The coordinator adds one indirection and an opaque torn marker, but centralizes correctness-heavy orchestration previously duplicated by every backend. Its hook surface stays narrow: collision checks reuse `loadStored`, materialization stays atomic inside `appendBatch`, and listing bypasses the coordinator. New backends implement storage primitives rather than copy the event-buffer-flush lifecycle.
|
||||
The coordinator adds one indirection, an opaque torn marker, and detached session-retirement tasks, but centralizes correctness-heavy orchestration previously duplicated by every backend. Session disposal remains an observe-only event, so the session owner does not await persistence retirement; the coordinator contains failures, preserves uncommitted buffers, and makes backend teardown the quiescence boundary. Its hook surface stays narrow: collision checks reuse `loadStored`, materialization stays atomic inside `appendBatch`, and listing bypasses the coordinator. New backends implement storage primitives rather than copy the event-buffer-flush lifecycle.
|
||||
@@ -52,7 +52,7 @@ The loop derives messages once after `agent/pre-step`. Running before `step/star
|
||||
|
||||
Auto-compaction fires before **every** step, not once per turn. This is **load-bearing for runaway-turn survival**: a tool-heavy ReAct turn appends an `assistant/message` + a `tool/result` per step, so the surface grows *within* a turn. A single turn can grow past the window on its own (a "runaway turn") — and the only moment to rescue it before the next model call overflows is the next step's `pre-step` checkpoint. Gating compaction to a turn's first step (or, worse, retaining the whole in-flight turn verbatim) re-opens exactly the hole compaction exists to close: the harness would die when compaction is most needed.
|
||||
|
||||
`compactIfNeeded` retains the smallest tail of whole surface units whose estimated size reaches `retainTokens` and compacts older nodes. A unit is a complete closed step or one no-step message. If the token cutoff lands inside a step, retention expands until the cut is tool-pairing balanced. Balance is checked on surface order, not log sequence, because replacement summaries have new sequence numbers at old surface positions. `compactRegion` rejects boundaries that split a tool call from its result. The in-flight turn receives no special retention.
|
||||
`compactIfNeeded` retains the smallest tail of whole surface units whose estimated size reaches `retainTokens` and compacts older nodes. A unit is a complete closed step or one no-step message. If the token cutoff lands inside a step, retention expands until the cut is tool-pairing balanced. Balance is checked on surface order, not log sequence, because replacement summaries have new sequence numbers at old surface positions. `dsh-compact` exports the before/after edge helpers; their per-session cache folds only appended surface-tail nodes while `replaceGeneration` is unchanged, does no event reads for log-only growth, and rebuilds current membership and balances after replacement. `compactRegion` rejects boundaries that split a tool call from its result. The in-flight turn receives no special retention.
|
||||
|
||||
A runaway turn thus compacts exactly like any other history: its early *closed* steps get summarized while its recent steps stay verbatim. When the only compactable content left is an un-splittable open tail step (its tool-calls have no results yet), compaction declines (`null`) and retries once that step closes.
|
||||
|
||||
@@ -113,7 +113,7 @@ Two failure paths, both documented:
|
||||
- **New packages**: `packages/compact/compact` (interface) and a sibling `compact-basic` (backend) under `packages/compact/`, wired into the root tsconfigs. The consumer tier is deferred.
|
||||
- **New loop seam**: `agent/pre-step` (`@mode serial`) declared in `dsh-agent` and emitted by `dsh-agent-loop` after system assembly and before `step/start`. This is a documented change to the loop — `docs/architecture.md` records it and the generated cordis catalog carries its signature.
|
||||
- **`SessionEventMap`** gains `compact/start` / `compact/summary` / `compact/end` by declaration merging (merge-extensible); `SurfaceEventType` is **not** touched. These are session events, not cordis `Events`, so the event-taxonomy gate needs no entry.
|
||||
- **`dsh-session`** gains the tool-pairing balance predicate (`isToolPairingBalanced`, in `tool-pairing.ts`, exported from the package index) that `compactRegion`/`compactIfNeeded` use to keep a collapsed region from splitting a step's tool-call/result pair. The surface `replace` op and the surface-metadata runtime guard already existed and are reused.
|
||||
- **`dsh-compact`** owns `toolPairingBalancedBefore(session, node)` and `toolPairingBalancedAfter(session, node)`, the cached surface-edge checks that `compactRegion` and `compactIfNeeded` use to avoid splitting a tool-call/result pair. The cache validates current membership by seq and answers both edges from one per-cut balance sequence instead of trusting a caller-retained `node.next`; stale or missing seqs and orphan results reject. `dsh-session` continues to own the surface `replace` operation, positional nodes, and rewrite generation.
|
||||
- **`dsh-invariants`** drops its `surface replace: start must be <= end` assertion: a head-anchored compaction lands a high-seq replacement node at an older range's *position*, so `start > end` numerically is normal and valid (the range is positional, validated by the surface's `indexOf` checks that remain). The turn-enclosure invariant is reused unchanged.
|
||||
- **Wiring**: `dsh-compact-basic` is loaded in `examples/coding-agent`'s `cordis.yml`, so the seam ships in the real demo (it was previously loaded nowhere).
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ Status: proposed
|
||||
|
||||
The session log maintains two representations that cost more machinery than their consumers require: a pseudo-linked surface and custom request-header deltas.
|
||||
|
||||
`SurfaceManager` stores the same order in an array, a seq map, and mutable `prev`/`next` links. Production never reads `prev`; compact's sole `next` read is the successor of an array position. Replacement already uses `indexOf`, so the links do not make its dominant operation constant-time. A seq array with linear replacement lookup has the same asymptotic replacement cost and one representation to validate.
|
||||
`SurfaceManager` stores the same order in an array, a seq map, and mutable `prev`/`next` links. Production never reads either link: compact's tool-pairing balance answers from per-cut balances cached in surface order. Replacement already uses `indexOf`, so the links do not make its dominant operation constant-time. A seq array with linear replacement lookup has the same asymptotic replacement cost and one representation to validate.
|
||||
|
||||
The request-header subsystem implements a custom system/tool delta codec and transmission-decision layer even though its contract says deltas are an encoding optimization, not a reconstructability requirement. Retaining the initial/resume full snapshot at each loop-instance boundary, then writing a canonical full `request/header` whenever that instance's assembled header changes, preserves replay while deleting `SystemDelta`, `ToolsDelta`, round-trip fallback, and the durable `request/header-delta` variant. Codec-only vocabulary disappears with the codec, not because its individual arms were invalid.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://unpkg.com/knip@5/schema.json",
|
||||
"exclude": ["duplicates"],
|
||||
"ignoreBinaries": ["bwrap", "sandbox-exec"],
|
||||
"ignoreWorkspaces": ["vendor/*", "python/sdk-runtime"],
|
||||
"ignoreWorkspaces": ["vendor/*", "python/sdk-runtime", "website"],
|
||||
"workspaces": {
|
||||
".": {
|
||||
"entry": [
|
||||
|
||||
+8
-2
@@ -9,7 +9,8 @@
|
||||
},
|
||||
"workspaces": [
|
||||
"vendor/*",
|
||||
"packages/*/*"
|
||||
"packages/*/*",
|
||||
"website"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc -b tsconfig.build.json && tsdown",
|
||||
@@ -69,8 +70,13 @@
|
||||
"gen-scoped-events": "tsx scripts/gen-scoped-events.ts",
|
||||
"verify-scoped-events": "tsx scripts/gen-scoped-events.ts --check",
|
||||
"verify-module-graph": "tsx scripts/gen-module-graph.ts --check",
|
||||
"gen-website-api": "tsx scripts/gen-website-api.ts",
|
||||
"verify-website-api": "tsx scripts/gen-website-api.ts --check",
|
||||
"verify-website-yaml": "tsx scripts/verify-website-yaml.ts",
|
||||
"website:dev": "pnpm --filter @deepseek-ai/website run dev",
|
||||
"website:build": "pnpm --filter @deepseek-ai/website run build",
|
||||
"constraints": "tsx scripts/check-workspace-constraints.ts",
|
||||
"doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-cordis-api && pnpm run verify-export-jsdoc && pnpm run verify-tool-catalog && pnpm run verify-config-catalog && pnpm run verify-persistence-catalog && pnpm run verify-doc-graphs && pnpm run verify-scoped-events && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-package-readme-model-experience && pnpm run verify-mermaid && pnpm run verify-rfc-classification && pnpm run verify-rfc-format && pnpm run verify-type-equiv && pnpm run verify-translation-prompt && pnpm run verify-translation-pairing && pnpm run verify-doc-budgets && pnpm run verify-package-readme-limitations",
|
||||
"doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-cordis-api && pnpm run verify-export-jsdoc && pnpm run verify-tool-catalog && pnpm run verify-config-catalog && pnpm run verify-persistence-catalog && pnpm run verify-doc-graphs && pnpm run verify-scoped-events && pnpm run verify-website-api && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-package-readme-model-experience && pnpm run verify-mermaid && pnpm run verify-rfc-classification && pnpm run verify-rfc-format && pnpm run verify-type-equiv && pnpm run verify-translation-prompt && pnpm run verify-translation-pairing && pnpm run verify-doc-budgets && pnpm run verify-package-readme-limitations && pnpm run verify-website-yaml",
|
||||
"hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-runtime-closure",
|
||||
"demo:echo": "node --expose-internals --import tsx packages/examples/stdio-demo/src/bin.ts examples/echo-agent/cordis.yml",
|
||||
"demo:repl": "node --expose-internals --import tsx packages/examples/stdio-demo/src/bin.ts examples/coding-agent/cordis.yml",
|
||||
|
||||
@@ -9,7 +9,7 @@ This is the implementation tier of the compaction capability — see the [interf
|
||||
This backend owns the compaction policy:
|
||||
|
||||
- **Estimation** — a configurable characters-per-token heuristic counts the current session prefix supplied to pre-step, derived history, and system prompt, matching the next request rather than stale logged prefix state.
|
||||
- **Retention** — compact the oldest whole surface units while preserving a recent tail and balanced tool-call/result cuts. Turn boundaries do not protect old steps inside a runaway turn. An open indivisible tail declines until it closes; a single unit larger than the budget remains out of scope.
|
||||
- **Retention** — compact the oldest whole surface units while preserving a recent tail and balanced tool-call/result cuts through the [`dsh-compact` boundary helpers](../compact/README.md#tool-pairing-boundaries). Turn boundaries do not protect old steps inside a runaway turn. An open indivisible tail declines until it closes; a single unit larger than the budget remains out of scope.
|
||||
- **Convergence** — retry head-checkpoint compaction up to `compactionRetries`; reject a summary that does not shrink its source, and throw if retries cannot return below threshold.
|
||||
- **Summarization** — a direct `llm/stream` call uses the configured model and cap without running the loop-only `agent/request` seam. The input transcript preserves non-text blocks as tagged placeholders; only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call.
|
||||
- **Framing** — the replacement user message marks established checkpoint context with `<compacted-summary>` tags. The raw summary remains on the provenance event, and later automatic cycles merge the prior checkpoint.
|
||||
|
||||
@@ -7,12 +7,11 @@
|
||||
*/
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import { CompactService, renderTranscript } from '@deepseek-ai/dsh-compact'
|
||||
import { CompactService, renderTranscript, toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact'
|
||||
import type { CompactionResult } from '@deepseek-ai/dsh-compact'
|
||||
import { BlockAssembler } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, FinishReason, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { isToolPairingBalanced } from '@deepseek-ai/dsh-session'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { BasicCompactConfig, ResolvedConfig } from './types.ts'
|
||||
import { resolveConfig } from './types.ts'
|
||||
@@ -348,15 +347,14 @@ export class BasicCompactService extends CompactService {
|
||||
}
|
||||
|
||||
// Both range edges must preserve assistant tool-call/result pairing.
|
||||
const events = session.events
|
||||
if (!isToolPairingBalanced(nodes, events, start)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const startNode = nodes[startIdx]!
|
||||
if (!toolPairingBalancedBefore(session, startNode)) {
|
||||
throw new Error(`compactRegion: start seq ${start} is not a balanced boundary (would split a step's tool-call/result pair)`)
|
||||
}
|
||||
// The cut after `end` is named by `end`'s surface successor, or `null` when
|
||||
// `end` is the tail.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const afterEnd: number | null = nodes[endIdx]!.next
|
||||
if (!isToolPairingBalanced(nodes, events, afterEnd)) {
|
||||
const endNode = nodes[endIdx]!
|
||||
if (!toolPairingBalancedAfter(session, endNode)) {
|
||||
throw new Error(`compactRegion: end seq ${end} is not a balanced boundary (would split a step, or the step is still open)`)
|
||||
}
|
||||
|
||||
@@ -511,7 +509,7 @@ export class BasicCompactService extends CompactService {
|
||||
// splitting an assistant↔result pair.
|
||||
while (keepFromIdx > 0) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
if (isToolPairingBalanced(nodes, events, nodes[keepFromIdx]!.seq)) break
|
||||
if (toolPairingBalancedBefore(session, nodes[keepFromIdx]!)) break
|
||||
keepFromIdx -= 1
|
||||
}
|
||||
if (keepFromIdx === 0) return null
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact'
|
||||
import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import { isToolPairingBalanced } from '@deepseek-ai/dsh-session'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
@@ -118,9 +118,9 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', ()
|
||||
for (const cp of checkpoints) {
|
||||
const node = nodes.find(n => n.seq === cp.seq)
|
||||
if (!node) continue // shadowed by a later checkpoint — no longer an edge.
|
||||
expect(isToolPairingBalanced(nodes, events, node.seq),
|
||||
expect(toolPairingBalancedBefore(agent.session, node),
|
||||
`checkpoint seq ${node.seq} must be a balanced region START`).toBe(true)
|
||||
expect(isToolPairingBalanced(nodes, events, node.next),
|
||||
expect(toolPairingBalancedAfter(agent.session, node),
|
||||
`checkpoint seq ${node.seq} must be a balanced region END`).toBe(true)
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -6,7 +6,7 @@ This package is the interface tier of the compaction capability, split so each c
|
||||
|
||||
| Package | Role |
|
||||
|---|---|
|
||||
| `@deepseek-ai/dsh-compact` (this) | the interface: abstract service + `compact/*` events + `CompactionResult` + the shared transcript renderer (`renderTranscript`/`renderContentBlocks`) |
|
||||
| `@deepseek-ai/dsh-compact` (this) | the interface: abstract service + `compact/*` events + `CompactionResult` + tool-pairing boundary helpers + the shared transcript renderer (`renderTranscript`/`renderContentBlocks`) |
|
||||
| `@deepseek-ai/dsh-compact-basic` | a backend: chars-per-token estimation (`charsPerToken`, default 4) + token-budget retention + `llm.stream()` summarization |
|
||||
| `@deepseek-ai/dsh-tool-compact` (deferred) | the model-facing `/compact` tool over `ctx.compact` |
|
||||
|
||||
@@ -23,6 +23,12 @@ Both methods are **abstract** — the backend owns the entire strategy (token es
|
||||
|
||||
`compactIfNeeded` takes a required `signal`; `compactRegion`'s is optional. A backend that summarizes via `ctx.llm.stream()` **must** forward it into the call's `GenerateOptions.signal`, so an abort or fiber dispose tears down the in-flight summarization instead of leaving an orphaned model call running past the cancellation. The session being compacted comes from the agent context; the turn that the `compact/*` events belong to is recoverable from the log (the currently-open turn), so the backend stamps it from the log rather than trusting a caller-supplied value.
|
||||
|
||||
## Tool-pairing boundaries
|
||||
|
||||
The interface exports `toolPairingBalancedBefore(session, node)` and `toolPairingBalancedAfter(session, node)` for snapping and validating compaction edges. A safe edge has no unanswered assistant tool call crossing it. Each helper identifies the node by seq alone and answers from balances cached per cut in current surface order, so a stale caller-held `node.next` cannot choose the cut.
|
||||
|
||||
The private per-session cache is keyed by `session.surface.replaceGeneration` and the processed surface-node count. An unchanged generation extends the fold with unseen tail nodes only; a log-only append with no new surface node does no event reads, while a replacement generation rebuilds current membership and balances. Missing event seqs and a `tool/result` without a preceding open call reject as corrupt surface state.
|
||||
|
||||
## Surface contract
|
||||
|
||||
`SurfaceEventType` is a closed union — only `user/message`, `assistant/message`, `tool/result`, `context/message`, and `steering/message` may carry `surfaceOp`. A `compact/*` event therefore **cannot** appear on the surface. A successful compaction instead:
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { CompactionResult } from './types.ts'
|
||||
|
||||
export type { CompactionResult } from './types.ts'
|
||||
export { renderContentBlocks, renderTranscript } from './render.ts'
|
||||
export { toolPairingBalancedAfter, toolPairingBalancedBefore } from './tool-pairing.ts'
|
||||
|
||||
/** Minimal agent context compaction needs without depending on the agent package. */
|
||||
export interface CompactAgentContext {
|
||||
@@ -67,6 +68,8 @@ export abstract class CompactService extends Service {
|
||||
* balanced so assistant tool calls remain paired with their results. A model-
|
||||
* backed implementation forwards cancellation and rejects active, missing,
|
||||
* reversed, or unbalanced ranges.
|
||||
* Use {@link toolPairingBalancedBefore} and {@link toolPairingBalancedAfter}
|
||||
* for the edge checks.
|
||||
*
|
||||
* @param session - session to mutate.
|
||||
* @param start - first surface seq, inclusive.
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Tool-pairing balance over a session surface. Compaction changes surface
|
||||
* positions, so safe cuts are derived from tool-call/result content in current
|
||||
* surface order rather than step markers or linked-list fields supplied by a
|
||||
* caller.
|
||||
* @module @deepseek-ai/dsh-compact/tool-pairing
|
||||
*/
|
||||
|
||||
import type { Session, SessionEvent, SurfaceNode } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/** Incremental balance state for one session surface generation. */
|
||||
interface BalanceCache {
|
||||
/** Surface rewrite generation this state describes. */
|
||||
generation: number
|
||||
/**
|
||||
* Balance of every surface cut in current order: a surface of N nodes has
|
||||
* N + 1 cuts, entry `i` being the cut before node `i` and the final entry
|
||||
* the cut after the surface tail.
|
||||
*/
|
||||
cutBalanced: readonly boolean[]
|
||||
/** Current surface position of each node seq, indexing {@link cutBalanced}. */
|
||||
indexBySeq: Map<number, number>
|
||||
/** In-progress tool-call count after the processed surface tail. */
|
||||
inProgressToolCalls: number
|
||||
}
|
||||
|
||||
const balanceCacheBySession = new WeakMap<Session, BalanceCache>()
|
||||
|
||||
/** Return how one surface event changes the in-progress tool-call count. */
|
||||
function nodeDelta(event: SessionEvent): number {
|
||||
switch (event.type) {
|
||||
case 'assistant/message':
|
||||
return event.data.content.filter(block => block.type === 'tool-call').length
|
||||
case 'tool/result':
|
||||
return -1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/** Read and validate the event named by a surface node. */
|
||||
function eventForNode(events: readonly SessionEvent[], node: SurfaceNode): SessionEvent {
|
||||
const event = events[node.seq]
|
||||
if (event === undefined || event.seq !== node.seq) {
|
||||
throw new Error(`tool-pairing balance: surface seq ${node.seq} has no matching session event (corrupt surface)`)
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
/** Fold surface nodes not yet in the cache into its balance state. */
|
||||
function extendCache(
|
||||
session: Session,
|
||||
cache: BalanceCache,
|
||||
nodes: readonly SurfaceNode[],
|
||||
): BalanceCache {
|
||||
const processed = cache.cutBalanced.length - 1
|
||||
const tail = nodes.slice(processed)
|
||||
// Validate the unseen tail before mutating the live cache, so a corrupt
|
||||
// append cannot leave a partially advanced state behind.
|
||||
const events = session.events
|
||||
const pendingCuts: boolean[] = []
|
||||
let inProgressToolCalls = cache.inProgressToolCalls
|
||||
for (const node of tail) {
|
||||
inProgressToolCalls += nodeDelta(eventForNode(events, node))
|
||||
if (inProgressToolCalls < 0) {
|
||||
throw new Error(`tool-pairing balance: tool/result at surface seq ${node.seq} has no matching tool-call (corrupt surface)`)
|
||||
}
|
||||
pendingCuts.push(inProgressToolCalls === 0)
|
||||
}
|
||||
|
||||
tail.forEach((node, offset) => cache.indexBySeq.set(node.seq, processed + offset))
|
||||
cache.cutBalanced = cache.cutBalanced.concat(pendingCuts)
|
||||
cache.inProgressToolCalls = inProgressToolCalls
|
||||
return cache
|
||||
}
|
||||
|
||||
/** Return balance state synchronized with the current session surface. */
|
||||
function balanceCache(session: Session): BalanceCache {
|
||||
const surface = session.surface
|
||||
const nodes = surface.nodes
|
||||
const generation = surface.replaceGeneration
|
||||
const cached = balanceCacheBySession.get(session)
|
||||
|
||||
if (cached === undefined || cached.generation !== generation || cached.cutBalanced.length - 1 > nodes.length) {
|
||||
// A rebuild is the same fold started from the empty-surface state, whose
|
||||
// single leading cut is trivially balanced.
|
||||
const rebuilt = extendCache(session, {
|
||||
generation,
|
||||
cutBalanced: [true],
|
||||
indexBySeq: new Map(),
|
||||
inProgressToolCalls: 0,
|
||||
}, nodes)
|
||||
balanceCacheBySession.set(session, rebuilt)
|
||||
return rebuilt
|
||||
}
|
||||
if (cached.cutBalanced.length - 1 < nodes.length) return extendCache(session, cached, nodes)
|
||||
return cached
|
||||
}
|
||||
|
||||
/** Balance of the cut at a node's position plus offset, rejecting seqs outside current membership. */
|
||||
function cutBalance(cache: BalanceCache, seq: number, offset: 0 | 1): boolean {
|
||||
const index = cache.indexBySeq.get(seq)
|
||||
const balanced = index === undefined ? undefined : cache.cutBalanced[index + offset]
|
||||
if (balanced === undefined) {
|
||||
throw new Error(`tool-pairing balance: surface seq ${seq} not found`)
|
||||
}
|
||||
return balanced
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the cut immediately before a current surface node is tool-pairing balanced.
|
||||
* @param session - session whose surface is checked.
|
||||
* @param node - surface node whose leading cut is checked; only its seq identifies it.
|
||||
* @returns true when no unanswered tool call crosses the cut.
|
||||
* @throws when the seq is absent from the current surface, a surface node has no
|
||||
* matching log event, or a tool result has no preceding open call.
|
||||
*/
|
||||
export function toolPairingBalancedBefore(session: Session, node: SurfaceNode): boolean {
|
||||
return cutBalance(balanceCache(session), node.seq, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the cut immediately after a current surface node is tool-pairing balanced.
|
||||
* @param session - session whose surface is checked.
|
||||
* @param node - surface node whose trailing cut is checked; only its seq identifies it.
|
||||
* @returns true when no unanswered tool call crosses the cut.
|
||||
* @throws when the seq is absent from the current surface, a surface node has no
|
||||
* matching log event, or a tool result has no preceding open call.
|
||||
*/
|
||||
export function toolPairingBalancedAfter(session: Session, node: SurfaceNode): boolean {
|
||||
return cutBalance(balanceCache(session), node.seq, 1)
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SurfaceNode } from '@deepseek-ai/dsh-session'
|
||||
|
||||
const SURFACE = { surfaceOp: 'append' as const }
|
||||
|
||||
function seqOf(session: Session, type: SessionEvent['type'], nth = 0): number {
|
||||
return session.events.filter(event => event.type === type)[nth]!.seq
|
||||
}
|
||||
|
||||
function nodeAt(session: Session, seq: number): SurfaceNode {
|
||||
const node = session.surface.nodes.find(candidate => candidate.seq === seq)
|
||||
if (node === undefined) throw new Error(`seq ${seq} is not a surface node`)
|
||||
return node
|
||||
}
|
||||
|
||||
function before(session: Session, type: SessionEvent['type'], nth = 0): boolean {
|
||||
return toolPairingBalancedBefore(session, nodeAt(session, seqOf(session, type, nth)))
|
||||
}
|
||||
|
||||
function after(session: Session, type: SessionEvent['type'], nth = 0): boolean {
|
||||
return toolPairingBalancedAfter(session, nodeAt(session, seqOf(session, type, nth)))
|
||||
}
|
||||
|
||||
function closedToolStep(): Session {
|
||||
const session = new Session(SessionId('closed-tool-step'))
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'go' }],
|
||||
source: { kind: 'user' },
|
||||
}, SURFACE)
|
||||
session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }],
|
||||
}, SURFACE)
|
||||
session.append('tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: CallId('c1'),
|
||||
content: [{ type: 'text', text: 'done' }],
|
||||
isError: false,
|
||||
}, SURFACE)
|
||||
return session
|
||||
}
|
||||
|
||||
describe('tool-pairing boundaries', () => {
|
||||
it('classifies closed and open single-call steps', () => {
|
||||
const closed = closedToolStep()
|
||||
expect(before(closed, 'user/message')).toBe(true)
|
||||
expect(after(closed, 'user/message')).toBe(true)
|
||||
expect(before(closed, 'assistant/message')).toBe(true)
|
||||
expect(after(closed, 'assistant/message')).toBe(false)
|
||||
expect(before(closed, 'tool/result')).toBe(false)
|
||||
expect(after(closed, 'tool/result')).toBe(true)
|
||||
|
||||
const open = new Session(SessionId('open-tool-step'))
|
||||
open.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
content: [{ type: 'tool-call', id: CallId('open'), name: 'bash', arguments: '{}' }],
|
||||
}, SURFACE)
|
||||
expect(toolPairingBalancedAfter(open, open.surface.nodes[0]!)).toBe(false)
|
||||
})
|
||||
|
||||
it('requires every result from a multiple-call assistant message', () => {
|
||||
const session = new Session(SessionId('multiple-calls'))
|
||||
session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
content: [
|
||||
{ type: 'tool-call', id: CallId('c1'), name: 'one', arguments: '{}' },
|
||||
{ type: 'tool-call', id: CallId('c2'), name: 'two', arguments: '{}' },
|
||||
],
|
||||
}, SURFACE)
|
||||
session.append('tool/result', {
|
||||
turn: 1, step: 1, callId: CallId('c1'), content: [], isError: false,
|
||||
}, SURFACE)
|
||||
session.append('tool/result', {
|
||||
turn: 1, step: 1, callId: CallId('c2'), content: [], isError: false,
|
||||
}, SURFACE)
|
||||
|
||||
expect(after(session, 'tool/result', 0)).toBe(false)
|
||||
expect(after(session, 'tool/result', 1)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps neutral nodes inside an open pair unbalanced and free nodes balanced', () => {
|
||||
const midStep = new Session(SessionId('neutral-mid-step'))
|
||||
midStep.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }],
|
||||
}, SURFACE)
|
||||
midStep.append('context/message', {
|
||||
content: [{ type: 'text', text: 'background update' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}, SURFACE)
|
||||
midStep.append('tool/result', {
|
||||
turn: 1, step: 1, callId: CallId('c1'), content: [], isError: false,
|
||||
}, SURFACE)
|
||||
expect(before(midStep, 'context/message')).toBe(false)
|
||||
expect(after(midStep, 'context/message')).toBe(false)
|
||||
|
||||
const free = new Session(SessionId('neutral-free'))
|
||||
free.append('context/message', {
|
||||
content: [{ type: 'text', text: 'idle injection' }],
|
||||
source: { kind: 'user' },
|
||||
}, SURFACE)
|
||||
expect(before(free, 'context/message')).toBe(true)
|
||||
expect(after(free, 'context/message')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('tool-pairing surface identity', () => {
|
||||
it('rebuilds after replace and rejects nodes removed from current membership', () => {
|
||||
const session = closedToolStep()
|
||||
const staleTail = nodeAt(session, seqOf(session, 'tool/result'))
|
||||
expect(toolPairingBalancedAfter(session, staleTail)).toBe(true)
|
||||
|
||||
const nodes = session.surface.nodes
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'checkpoint' }],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
}, {
|
||||
surfaceOp: { op: 'replace', start: nodes[0]!.seq, end: nodes.at(-1)!.seq },
|
||||
sourceEventSeqs: nodes.map(node => node.seq),
|
||||
})
|
||||
|
||||
const checkpoint = session.surface.nodes[0]!
|
||||
expect(toolPairingBalancedBefore(session, checkpoint)).toBe(true)
|
||||
expect(toolPairingBalancedAfter(session, checkpoint)).toBe(true)
|
||||
expect(() => toolPairingBalancedBefore(session, staleTail)).toThrow(/surface seq .* not found/)
|
||||
expect(() => toolPairingBalancedAfter(session, staleTail)).toThrow(/surface seq .* not found/)
|
||||
})
|
||||
|
||||
it('ignores a caller-held node next field and answers from cached balances', () => {
|
||||
const session = closedToolStep()
|
||||
const assistant = nodeAt(session, seqOf(session, 'assistant/message'))
|
||||
expect(toolPairingBalancedAfter(session, { ...assistant, next: null })).toBe(false)
|
||||
expect(toolPairingBalancedAfter(session, { ...assistant, next: 999 })).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects missing seqs before and after, including an empty surface', () => {
|
||||
const session = new Session(SessionId('missing-membership'))
|
||||
const missing: SurfaceNode = { seq: 999, prev: null, next: null }
|
||||
expect(() => toolPairingBalancedBefore(session, missing)).toThrow(/surface seq 999 not found/)
|
||||
expect(() => toolPairingBalancedAfter(session, missing)).toThrow(/surface seq 999 not found/)
|
||||
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'first node after empty cache' }],
|
||||
source: { kind: 'user' },
|
||||
}, SURFACE)
|
||||
expect(toolPairingBalancedAfter(session, session.surface.nodes[0]!)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('tool-pairing cache refresh', () => {
|
||||
it('does no event reads for unchanged or log-only growth, folds only appended nodes, and rebuilds on replace', () => {
|
||||
const events: SessionEvent[] = [
|
||||
{
|
||||
type: 'user/message', seq: 0, time: 0,
|
||||
data: { content: [{ type: 'text', text: 'user' }], source: { kind: 'user' } },
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
{
|
||||
type: 'assistant/message', seq: 1, time: 1,
|
||||
data: { turn: 1, step: 1, content: [{ type: 'tool-call', id: CallId('c1'), name: 'one', arguments: '{}' }] },
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
{
|
||||
type: 'tool/result', seq: 2, time: 2,
|
||||
data: { turn: 1, step: 1, callId: CallId('c1'), content: [], isError: false },
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
]
|
||||
const nodes: SurfaceNode[] = [
|
||||
{ seq: 0, prev: null, next: 1 },
|
||||
{ seq: 1, prev: 0, next: 2 },
|
||||
{ seq: 2, prev: 1, next: null },
|
||||
]
|
||||
let generation = 0
|
||||
let eventCollectionReads = 0
|
||||
let eventIndexReads = 0
|
||||
const trackedEvents = new Proxy(events, {
|
||||
get(target, property, receiver) {
|
||||
if (typeof property === 'string' && /^\d+$/.test(property)) eventIndexReads += 1
|
||||
return Reflect.get(target, property, receiver) as unknown
|
||||
},
|
||||
})
|
||||
const surface = {
|
||||
get nodes() { return nodes },
|
||||
get replaceGeneration() { return generation },
|
||||
}
|
||||
const session = {
|
||||
surface,
|
||||
get events() {
|
||||
eventCollectionReads += 1
|
||||
return trackedEvents
|
||||
},
|
||||
} as unknown as Session
|
||||
|
||||
expect(toolPairingBalancedAfter(session, nodes[2]!)).toBe(true)
|
||||
expect(eventCollectionReads).toBe(1)
|
||||
expect(eventIndexReads).toBe(3)
|
||||
|
||||
expect(toolPairingBalancedBefore(session, nodes[0]!)).toBe(true)
|
||||
expect(toolPairingBalancedAfter(session, nodes[1]!)).toBe(false)
|
||||
expect(eventCollectionReads).toBe(1)
|
||||
expect(eventIndexReads).toBe(3)
|
||||
|
||||
events.push({
|
||||
type: 'turn/end', seq: 3, time: 3,
|
||||
data: { turn: 1, reason: { kind: 'completed' } },
|
||||
})
|
||||
expect(toolPairingBalancedAfter(session, nodes[2]!)).toBe(true)
|
||||
expect(eventCollectionReads).toBe(1)
|
||||
expect(eventIndexReads).toBe(3)
|
||||
|
||||
events.push({
|
||||
type: 'user/message', seq: 4, time: 4,
|
||||
data: { content: [{ type: 'text', text: 'tail' }], source: { kind: 'user' } },
|
||||
surfaceOp: 'append',
|
||||
})
|
||||
nodes.push({ seq: 4, prev: 2, next: null })
|
||||
expect(toolPairingBalancedAfter(session, nodes[3]!)).toBe(true)
|
||||
expect(eventCollectionReads).toBe(2)
|
||||
expect(eventIndexReads).toBe(4)
|
||||
|
||||
events.push(
|
||||
{
|
||||
type: 'assistant/message', seq: 5, time: 5,
|
||||
data: { turn: 2, step: 1, content: [{ type: 'tool-call', id: CallId('c2'), name: 'two', arguments: '{}' }] },
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
{
|
||||
type: 'tool/result', seq: 6, time: 6,
|
||||
data: { turn: 2, step: 1, callId: CallId('c2'), content: [], isError: false },
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
)
|
||||
nodes.push(
|
||||
{ seq: 5, prev: 4, next: 6 },
|
||||
{ seq: 6, prev: 5, next: null },
|
||||
)
|
||||
expect(toolPairingBalancedAfter(session, nodes[5]!)).toBe(true)
|
||||
expect(eventCollectionReads).toBe(3)
|
||||
expect(eventIndexReads).toBe(6)
|
||||
|
||||
events.push({
|
||||
type: 'user/message', seq: 7, time: 7,
|
||||
data: { content: [{ type: 'text', text: 'replacement' }], source: { kind: 'user' } },
|
||||
surfaceOp: { op: 'replace', start: 0, end: 6 },
|
||||
})
|
||||
nodes.splice(0, nodes.length, { seq: 7, prev: null, next: null })
|
||||
generation += 1
|
||||
expect(toolPairingBalancedAfter(session, nodes[0]!)).toBe(true)
|
||||
expect(eventCollectionReads).toBe(4)
|
||||
expect(eventIndexReads).toBe(7)
|
||||
})
|
||||
|
||||
it('rebuilds defensively when a same-generation surface node count regresses', () => {
|
||||
const events: SessionEvent[] = [
|
||||
{
|
||||
type: 'user/message', seq: 0, time: 0,
|
||||
data: { content: [], source: { kind: 'user' } }, surfaceOp: 'append',
|
||||
},
|
||||
{
|
||||
type: 'user/message', seq: 1, time: 1,
|
||||
data: { content: [], source: { kind: 'user' } }, surfaceOp: 'append',
|
||||
},
|
||||
]
|
||||
const nodes: SurfaceNode[] = [
|
||||
{ seq: 0, prev: null, next: 1 },
|
||||
{ seq: 1, prev: 0, next: null },
|
||||
]
|
||||
const session = {
|
||||
events,
|
||||
surface: { nodes, replaceGeneration: 0 },
|
||||
} as unknown as Session
|
||||
expect(toolPairingBalancedAfter(session, nodes[1]!)).toBe(true)
|
||||
nodes.pop()
|
||||
expect(toolPairingBalancedAfter(session, nodes[0]!)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('tool-pairing corrupt surfaces', () => {
|
||||
it('throws for an orphan result during a rebuild', () => {
|
||||
const session = new Session(SessionId('orphan-rebuild'))
|
||||
session.append('tool/result', {
|
||||
turn: 1, step: 1, callId: CallId('orphan'), content: [], isError: false,
|
||||
}, SURFACE)
|
||||
expect(() => toolPairingBalancedAfter(session, session.surface.nodes[0]!)).toThrow(/no matching tool-call/)
|
||||
})
|
||||
|
||||
it('retries an orphan result in an appended tail without committing partial cache state', () => {
|
||||
const session = new Session(SessionId('orphan-tail'))
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'safe head' }], source: { kind: 'user' },
|
||||
}, SURFACE)
|
||||
expect(toolPairingBalancedAfter(session, session.surface.nodes[0]!)).toBe(true)
|
||||
session.append('tool/result', {
|
||||
turn: 1, step: 1, callId: CallId('orphan'), content: [], isError: false,
|
||||
}, SURFACE)
|
||||
expect(() => toolPairingBalancedAfter(session, session.surface.nodes[1]!)).toThrow(/no matching tool-call/)
|
||||
expect(() => toolPairingBalancedAfter(session, session.surface.nodes[1]!)).toThrow(/no matching tool-call/)
|
||||
})
|
||||
|
||||
it('throws when a current surface seq has no matching event or indexes the wrong event', () => {
|
||||
const missingNode: SurfaceNode = { seq: 1, prev: null, next: null }
|
||||
const missing = {
|
||||
events: [{
|
||||
type: 'user/message', seq: 0, time: 0,
|
||||
data: { content: [], source: { kind: 'user' } }, surfaceOp: 'append',
|
||||
} satisfies SessionEvent],
|
||||
surface: { nodes: [missingNode], replaceGeneration: 0 },
|
||||
} as unknown as Session
|
||||
expect(() => toolPairingBalancedBefore(missing, missingNode)).toThrow(/no matching session event/)
|
||||
|
||||
const mismatchedNode: SurfaceNode = { seq: 0, prev: null, next: null }
|
||||
const mismatched = {
|
||||
events: [{
|
||||
type: 'user/message', seq: 99, time: 0,
|
||||
data: { content: [], source: { kind: 'user' } }, surfaceOp: 'append',
|
||||
} satisfies SessionEvent],
|
||||
surface: { nodes: [mismatchedNode], replaceGeneration: 0 },
|
||||
} as unknown as Session
|
||||
expect(() => toolPairingBalancedBefore(mismatched, mismatchedNode)).toThrow(/no matching session event/)
|
||||
})
|
||||
})
|
||||
@@ -79,7 +79,7 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata)
|
||||
|
||||
- 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: `create(id, { seed })` validates and freezes a contiguous log and rebuilds its surface. `fork(source, boundary?, childSessionId?)` selects a completed-turn prefix and records lineage.
|
||||
- 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.
|
||||
- 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. Tool-pairing boundary policy and its cache belong to the [`dsh-compact` seam](../../compact/compact/README.md), while this package owns surface membership, positional links, and `replaceGeneration`.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ export type { JsonValue } from './json.ts'
|
||||
export { interruptedTurnClosers } from './repair.ts'
|
||||
export type { SurfaceFoldReplacement, SurfaceFoldResult, SurfaceNode } from './surface.ts'
|
||||
export { foldSurface, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
|
||||
export { isToolPairingBalanced } from './tool-pairing.ts'
|
||||
export { applyHeaderDelta, canonicalHeader, diffHeader, foldRequestHeader, headerEquals } from './request-header.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
/**
|
||||
* Tool-pairing balance over a session surface. Compaction changes surface
|
||||
* positions, so safe cuts are derived from tool-call/result content on the
|
||||
* surface rather than step markers in the append-only log.
|
||||
* @module @deepseek-ai/dsh-session/tool-pairing
|
||||
*/
|
||||
|
||||
import type { SessionEvent } from './types.ts'
|
||||
import type { SurfaceNode } from './surface.ts'
|
||||
|
||||
/**
|
||||
* The tool-pairing delta of a surface node: how it shifts the count of
|
||||
* unanswered tool calls. An `assistant/message` opens one bracket per
|
||||
* `tool-call` block; a `tool/result` closes one; every other surface node
|
||||
* (`user/message`, `context/message`, `steering/message`, a usage-only
|
||||
* `assistant/message` with no tool-call blocks) is pairing-neutral.
|
||||
*/
|
||||
function nodeDelta(event: SessionEvent): number {
|
||||
switch (event.type) {
|
||||
case 'assistant/message':
|
||||
return event.data.content.filter(block => block.type === 'tool-call').length
|
||||
case 'tool/result':
|
||||
return -1
|
||||
// Non-pairing surface nodes and every non-surface event contribute nothing.
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that a surface cut does not split a tool call from its result. A region
|
||||
* is safe to collapse only when the cuts before its first node and after its
|
||||
* last node both return `true`.
|
||||
* @param nodes - the surface linked list in head→tail order.
|
||||
* @param events - the session log each node's `seq` indexes into.
|
||||
* @param beforeSeq - node immediately after the cut; `null` or a seq absent from the surface means after-tail.
|
||||
* @returns whether every call before the cut has its result before the cut.
|
||||
* @throws if a result appears without a preceding open call.
|
||||
*/
|
||||
export function isToolPairingBalanced(
|
||||
nodes: readonly SurfaceNode[],
|
||||
events: readonly SessionEvent[],
|
||||
beforeSeq: number | null,
|
||||
): boolean {
|
||||
let depth = 0
|
||||
for (const node of nodes) {
|
||||
if (node.seq === beforeSeq) return depth === 0
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
depth += nodeDelta(events[node.seq]!)
|
||||
if (depth < 0) {
|
||||
throw new Error(`tool-pairing balance: tool/result at surface seq ${node.seq} has no matching tool-call (corrupt surface)`)
|
||||
}
|
||||
}
|
||||
// A missing cut node means the after-tail boundary.
|
||||
return depth === 0
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { Session, SessionId, isToolPairingBalanced } from '../src/index.ts'
|
||||
import type { SessionEvent, SurfaceNode } from '../src/index.ts'
|
||||
|
||||
/**
|
||||
* Unit coverage for compaction-cut safety: a cut is balanced only when it
|
||||
* separates no assistant tool call from its result. Non-step nodes are neutral,
|
||||
* and replace operations prove surface order—not raw log order—is authoritative.
|
||||
*/
|
||||
|
||||
const SURFACE = { surfaceOp: 'append' as const }
|
||||
|
||||
/** Surface nodes + log for a session, the two args the balance check takes. */
|
||||
function surfaceOf(session: Session): { nodes: readonly SurfaceNode[]; events: readonly SessionEvent[] } {
|
||||
return { nodes: session.surface.nodes, events: session.events }
|
||||
}
|
||||
|
||||
/** The cut BEFORE the surface node at `seq` is balanced (safe region start). */
|
||||
function startBalanced(session: Session, seq: number): boolean {
|
||||
const { nodes, events } = surfaceOf(session)
|
||||
return isToolPairingBalanced(nodes, events, seq)
|
||||
}
|
||||
|
||||
/** The cut AFTER the surface node at `seq` is balanced (safe region end). */
|
||||
function endBalanced(session: Session, seq: number): boolean {
|
||||
const { nodes, events } = surfaceOf(session)
|
||||
const node = nodes.find(n => n.seq === seq)
|
||||
if (!node) throw new Error(`seq ${seq} is not a surface node`)
|
||||
return isToolPairingBalanced(nodes, events, node.next)
|
||||
}
|
||||
|
||||
/** Surface seq of the nth (0-based) event of a given type. */
|
||||
function seqOf(s: Session, type: SessionEvent['type'], nth = 0): number {
|
||||
return s.events.filter(e => e.type === type)[nth]!.seq
|
||||
}
|
||||
|
||||
/** A closed turn with one closed step holding an assistant + its tool result. */
|
||||
function toolStepSession(): Session {
|
||||
const s = new Session(SessionId('tool-step'))
|
||||
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }, SURFACE)
|
||||
s.append('step/start', { turn: 1, step: 1 })
|
||||
s.append('assistant/message', {
|
||||
turn: 1, step: 1,
|
||||
content: [
|
||||
{ type: 'text', text: 'calling' },
|
||||
{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' },
|
||||
],
|
||||
}, SURFACE)
|
||||
s.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: '{}' })
|
||||
s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'out' }], isError: false }, SURFACE)
|
||||
s.append('step/end', { turn: 1, step: 1 })
|
||||
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
return s
|
||||
}
|
||||
|
||||
describe('isToolPairingBalanced — region START (cut before a node)', () => {
|
||||
it('is true for a pre-step user/message (belongs to no step)', () => {
|
||||
const s = toolStepSession()
|
||||
expect(startBalanced(s, seqOf(s, 'user/message'))).toBe(true)
|
||||
})
|
||||
|
||||
it('is true for the first surface node of a step (the assistant/message)', () => {
|
||||
// The cut before the assistant is balanced — nothing unanswered precedes it.
|
||||
const s = toolStepSession()
|
||||
expect(startBalanced(s, seqOf(s, 'assistant/message'))).toBe(true)
|
||||
})
|
||||
|
||||
it('is false for a tool/result whose assistant/message precedes it in the same step', () => {
|
||||
// The cut before the tool/result has one unanswered tool-call (the
|
||||
// assistant's) → starting the region here would orphan that call.
|
||||
const s = toolStepSession()
|
||||
expect(startBalanced(s, seqOf(s, 'tool/result'))).toBe(false)
|
||||
})
|
||||
|
||||
it('is true at the surface head (nothing precedes)', () => {
|
||||
const s = new Session(SessionId('lone'))
|
||||
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, SURFACE)
|
||||
expect(startBalanced(s, seqOf(s, 'user/message'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isToolPairingBalanced — region END (cut after a node)', () => {
|
||||
it('is true for the last surface node of a closed step (the tool/result)', () => {
|
||||
// After the tool/result the assistant's single call is answered → balanced.
|
||||
const s = toolStepSession()
|
||||
expect(endBalanced(s, seqOf(s, 'tool/result'))).toBe(true)
|
||||
})
|
||||
|
||||
it('is false for an assistant/message with a later tool/result in the same step', () => {
|
||||
// After the assistant its tool-call is still unanswered → ending here strands
|
||||
// the result.
|
||||
const s = toolStepSession()
|
||||
expect(endBalanced(s, seqOf(s, 'assistant/message'))).toBe(false)
|
||||
})
|
||||
|
||||
it('is true for a pre-step user/message', () => {
|
||||
const s = toolStepSession()
|
||||
expect(endBalanced(s, seqOf(s, 'user/message'))).toBe(true)
|
||||
})
|
||||
|
||||
it('is false at the tail when the node is inside an open (unclosed) step', () => {
|
||||
// step/start then an assistant tool-call, but no tool/result yet (mid-flight).
|
||||
// The after-tail cut still has one unanswered call → not balanced.
|
||||
const s = new Session(SessionId('open-step'))
|
||||
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
s.append('step/start', { turn: 1, step: 1 })
|
||||
s.append('assistant/message', {
|
||||
turn: 1, step: 1,
|
||||
content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }],
|
||||
}, SURFACE)
|
||||
expect(endBalanced(s, seqOf(s, 'assistant/message'))).toBe(false)
|
||||
})
|
||||
|
||||
it('is true at the tail when the node is a trailing inter-step node (step already closed)', () => {
|
||||
// A steering message appended after step/end, at the tail. The prior step's
|
||||
// pair is balanced and steering is neutral → the after-tail cut is balanced.
|
||||
const s = new Session(SessionId('trailing-steer'))
|
||||
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
s.append('step/start', { turn: 1, step: 1 })
|
||||
s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'a' }] }, SURFACE)
|
||||
s.append('step/end', { turn: 1, step: 1 })
|
||||
s.append('steering/message', { turn: 1, content: [{ type: 'text', text: 's' }], source: { kind: 'user' } }, SURFACE)
|
||||
expect(endBalanced(s, seqOf(s, 'steering/message'))).toBe(true)
|
||||
})
|
||||
|
||||
it('is true at the tail when no step ever opened', () => {
|
||||
const s = new Session(SessionId('no-step'))
|
||||
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, SURFACE)
|
||||
expect(endBalanced(s, seqOf(s, 'user/message'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isToolPairingBalanced — multiple tool calls in one assistant message', () => {
|
||||
// An assistant message with two tool-calls needs BOTH results before the cut
|
||||
// after it is balanced — depth +2, then -1, -1.
|
||||
function twoCallStep(): Session {
|
||||
const s = new Session(SessionId('two-call'))
|
||||
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
s.append('step/start', { turn: 1, step: 1 })
|
||||
s.append('assistant/message', {
|
||||
turn: 1, step: 1,
|
||||
content: [
|
||||
{ type: 'tool-call', id: CallId('c1'), name: 'a', arguments: '{}' },
|
||||
{ type: 'tool-call', id: CallId('c2'), name: 'b', arguments: '{}' },
|
||||
],
|
||||
}, SURFACE)
|
||||
s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: '1' }], isError: false }, SURFACE)
|
||||
s.append('tool/result', { turn: 1, step: 1, callId: CallId('c2'), content: [{ type: 'text', text: '2' }], isError: false }, SURFACE)
|
||||
s.append('step/end', { turn: 1, step: 1 })
|
||||
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
return s
|
||||
}
|
||||
|
||||
it('is unbalanced after the first of two results (one call still open)', () => {
|
||||
const s = twoCallStep()
|
||||
expect(endBalanced(s, seqOf(s, 'tool/result', 0))).toBe(false)
|
||||
})
|
||||
|
||||
it('is balanced after the second result (both calls answered)', () => {
|
||||
const s = twoCallStep()
|
||||
expect(endBalanced(s, seqOf(s, 'tool/result', 1))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isToolPairingBalanced — a mid-step injection context/message', () => {
|
||||
// The injected context is pairing-neutral, but both adjacent cuts remain
|
||||
// unbalanced because the tool call is still open across them.
|
||||
function midStepInjection(): Session {
|
||||
const s = new Session(SessionId('mid-inject'))
|
||||
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
s.append('step/start', { turn: 1, step: 1 })
|
||||
s.append('assistant/message', {
|
||||
turn: 1, step: 1,
|
||||
content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }],
|
||||
}, SURFACE)
|
||||
s.append('context/message', { content: [{ type: 'text', text: 'bg task done' }], source: { kind: 'plugin', plugin: 'tool-bash' } }, SURFACE)
|
||||
s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'out' }], isError: false }, SURFACE)
|
||||
s.append('step/end', { turn: 1, step: 1 })
|
||||
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
return s
|
||||
}
|
||||
|
||||
it('start cut before the mid-step context/message is unbalanced (call still open)', () => {
|
||||
const s = midStepInjection()
|
||||
expect(startBalanced(s, seqOf(s, 'context/message'))).toBe(false)
|
||||
})
|
||||
|
||||
it('end cut after the mid-step context/message is unbalanced (call still open)', () => {
|
||||
const s = midStepInjection()
|
||||
expect(endBalanced(s, seqOf(s, 'context/message'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isToolPairingBalanced on an injection turn (no step)', () => {
|
||||
// An idle inject() wraps a context/message in a bare turn/start →
|
||||
// context/message → turn/end with NO step. The context node is a free boundary
|
||||
// both ways (pairing-neutral, nothing open around it).
|
||||
function injectionSession(): Session {
|
||||
const s = new Session(SessionId('injection'))
|
||||
s.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: { kind: 'user' } } })
|
||||
s.append('context/message', { content: [{ type: 'text', text: 'ctx' }], source: { kind: 'user' } }, SURFACE)
|
||||
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
return s
|
||||
}
|
||||
|
||||
it('start: balanced', () => {
|
||||
const s = injectionSession()
|
||||
expect(startBalanced(s, seqOf(s, 'context/message'))).toBe(true)
|
||||
})
|
||||
|
||||
it('end: balanced', () => {
|
||||
const s = injectionSession()
|
||||
expect(endBalanced(s, seqOf(s, 'context/message'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isToolPairingBalanced — CBR-001: a head checkpoint left by a replace op', () => {
|
||||
// A replacement checkpoint has a high log seq but sits at the surface head;
|
||||
// its cuts are balanced regardless of later raw-log neighbors.
|
||||
function checkpointHeadedSession(): Session {
|
||||
const s = new Session(SessionId('checkpoint'))
|
||||
// A closed turn with a tool step → surface [u1, asst(call), result].
|
||||
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
s.append('step/start', { turn: 1, step: 1 })
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'u1' }], source: { kind: 'user' } }, SURFACE)
|
||||
s.append('assistant/message', {
|
||||
turn: 1, step: 1,
|
||||
content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }],
|
||||
}, SURFACE)
|
||||
s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'out' }], isError: false }, SURFACE)
|
||||
s.append('step/end', { turn: 1, step: 1 })
|
||||
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
// An OPEN turn whose step is in progress (loop fires compaction here).
|
||||
s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
s.append('step/start', { turn: 2, step: 1 })
|
||||
// Compaction replaces the whole turn-1 surface ([u1, asst, result]) with one
|
||||
// summary user/message — appended now, so it carries a high log seq.
|
||||
const u1 = seqOf(s, 'user/message')
|
||||
const result = s.events.find(e => e.type === 'tool/result')!.seq
|
||||
const shadowedSeqs = s.surface.nodes.map(node => node.seq)
|
||||
s.append('user/message', {
|
||||
content: [{ type: 'text', text: 'CHECKPOINT' }],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
}, { surfaceOp: { op: 'replace', start: u1, end: result }, sourceEventSeqs: shadowedSeqs })
|
||||
// The step's own assistant/message lands AFTER the checkpoint in the log,
|
||||
// still inside the open step.
|
||||
s.append('assistant/message', { turn: 2, step: 1, content: [{ type: 'text', text: 'a2' }] }, SURFACE)
|
||||
return s
|
||||
}
|
||||
|
||||
it('the head checkpoint sits at the surface head while a later surface node follows it in the log', () => {
|
||||
const s = checkpointHeadedSession()
|
||||
const nodes = s.surface.nodes
|
||||
const checkpointSeq = nodes[0]!.seq
|
||||
// The checkpoint heads the surface, yet a surface node (the open step's
|
||||
// assistant) follows it in LOG order — the exact split between surface
|
||||
// position and log position that the log-position scan tripped on.
|
||||
const laterSurfaceInLog = s.events.find(
|
||||
e => e.seq > checkpointSeq && nodes.some(n => n.seq === e.seq),
|
||||
)
|
||||
expect(laterSurfaceInLog).toBeDefined()
|
||||
expect(nodes[0]!.seq).toBe(checkpointSeq)
|
||||
})
|
||||
|
||||
it('start cut before the head checkpoint is balanced (it is the head)', () => {
|
||||
const s = checkpointHeadedSession()
|
||||
expect(startBalanced(s, s.surface.nodes[0]!.seq)).toBe(true)
|
||||
})
|
||||
|
||||
it('end cut after the head checkpoint is balanced (it carries no tool pair)', () => {
|
||||
// This is the exact assertion the log-position scan failed: the forward log scan from the
|
||||
// checkpoint reached the open step's assistant/message and wrongly reported mid-step.
|
||||
const s = checkpointHeadedSession()
|
||||
expect(endBalanced(s, s.surface.nodes[0]!.seq)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isToolPairingBalanced — corrupt surface guard', () => {
|
||||
it('throws when a tool/result has no preceding tool-call (depth goes negative)', () => {
|
||||
// A surface that opens with a tool/result (no assistant call before it) is
|
||||
// structurally corrupt — surfaced loudly rather than mis-classified.
|
||||
const s = new Session(SessionId('corrupt'))
|
||||
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
s.append('step/start', { turn: 1, step: 1 })
|
||||
s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'x' }], isError: false }, SURFACE)
|
||||
const { nodes, events } = surfaceOf(s)
|
||||
expect(() => isToolPairingBalanced(nodes, events, null)).toThrow(/no matching tool-call/)
|
||||
})
|
||||
})
|
||||
@@ -25,6 +25,8 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l
|
||||
|
||||
`PersistenceCoordinator` owns per-id state, write-behind buffers and serialization, the `session/event` → `session/flush` drain, lazy materialization, crash-tail repair, session adoption, and quiescent disposal. A first-party backend composes one, implements the small `PersistenceBackend` storage hook interface, and delegates its four public service methods. JSONL and SQLite therefore share lifecycle correctness while retaining different storage primitives; see the [coordinator RFC](../../../docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md).
|
||||
|
||||
When a live session emits `session/disposed`, the coordinator waits for its initialization, serializes a final buffer drain, then releases every map entry owned by that exact `Session` object. A failed final drain keeps the pending buffer for backend teardown to retry. Backend teardown stops event admission first, awaits all in-flight session retirements and remaining per-id operations, drains any retained buffers, and only then closes the storage handle.
|
||||
|
||||
The side-effect-free `locate` query remains backend-owned because it describes storage topology rather than write orchestration.
|
||||
|
||||
The `PersistenceBackend<TornMarker>` hooks (the only seam between the coordinator and storage):
|
||||
|
||||
@@ -126,7 +126,8 @@ function seedCoversPrefix(seed: readonly SessionEvent[], prefix: readonly Sessio
|
||||
*
|
||||
* All per-id operations are serialized (a per-id promise chain) so concurrent
|
||||
* flushes / a flush racing a load never interleave storage writes. The
|
||||
* constructor installs the write-path listeners and the dispose effect.
|
||||
* constructor installs the write-path listeners, per-session retirement, and
|
||||
* the backend dispose effect.
|
||||
*
|
||||
* @typeParam TornMarker - the backend's opaque torn-tail repair token.
|
||||
*/
|
||||
@@ -146,6 +147,8 @@ export class PersistenceCoordinator<TornMarker = unknown> {
|
||||
* observation boundary; callers do not inspect this bookkeeping directly.
|
||||
*/
|
||||
private inits = new Map<Session, Promise<void>>()
|
||||
/** Final drains started by fire-and-forget session disposal notifications. */
|
||||
private retirements = new Set<Promise<void>>()
|
||||
|
||||
constructor(private ctx: Context, private backend: PersistenceBackend<TornMarker>) {
|
||||
this.installWritePath()
|
||||
@@ -267,7 +270,13 @@ export class PersistenceCoordinator<TornMarker = unknown> {
|
||||
const next = prior.then(op, op)
|
||||
// Keep the chain alive but swallow this op's rejection for the NEXT waiter
|
||||
// (the caller still sees the real rejection via `next`).
|
||||
this.chains.set(id, next.then(() => undefined, () => undefined))
|
||||
const tail = next.then(() => undefined, () => undefined)
|
||||
this.chains.set(id, tail)
|
||||
// Settled tails carry no serialization value. Delete only the exact tail
|
||||
// installed above: a later operation may already have replaced it.
|
||||
void tail.then(() => {
|
||||
if (this.chains.get(id) === tail) this.chains.delete(id)
|
||||
})
|
||||
return next
|
||||
}
|
||||
|
||||
@@ -293,27 +302,12 @@ export class PersistenceCoordinator<TornMarker = unknown> {
|
||||
private installWritePath(): void {
|
||||
const ctx = this.ctx
|
||||
|
||||
// Capture the header on creation; persist a fork's seed once. Record the init
|
||||
// promise so flush/dispose can await it (onCreated is async).
|
||||
ctx.on('session/created', (session) => { void this.initFor(session) })
|
||||
|
||||
// Session emits an owned frozen event. Keep a persistence-owned copy anyway
|
||||
// so the write-behind queue owns exactly the record it will flush rather than
|
||||
// retaining a product-layer record by identity. Serializability is guaranteed
|
||||
// at the source, so structuredClone is safe.
|
||||
ctx.on('session/event', (session, event) => {
|
||||
let buffer = this.buffers.get(session)
|
||||
if (!buffer) this.buffers.set(session, buffer = [])
|
||||
buffer.push(structuredClone(event))
|
||||
})
|
||||
|
||||
// Drain to the backend at the durability checkpoint.
|
||||
ctx.on('session/flush', session => this.flush(session))
|
||||
|
||||
// Dispose must reach quiescence: await every init + final drain BEFORE
|
||||
// returning, then close the backend's own resources (AFTER the drain), so no
|
||||
// write lands after teardown and a close failure never MASKS a drain error.
|
||||
// Register the disposer BEFORE the listeners. Cordis tears effects down in
|
||||
// reverse registration order, so event admission closes before this final
|
||||
// drain reaches quiescence and closes the backend.
|
||||
ctx.effect(() => async () => {
|
||||
await this.awaitRetirements()
|
||||
|
||||
let disposeError: unknown
|
||||
try {
|
||||
const errors = [
|
||||
@@ -341,11 +335,63 @@ export class PersistenceCoordinator<TornMarker = unknown> {
|
||||
}
|
||||
}, `${this.backend.name} write path`)
|
||||
|
||||
// Capture the header on creation; persist a fork's seed once. Record the init
|
||||
// promise so flush/dispose can await it (onCreated is async).
|
||||
ctx.on('session/created', (session) => { void this.initFor(session) })
|
||||
|
||||
// Session emits an owned frozen event. Keep a persistence-owned copy anyway
|
||||
// so the write-behind queue owns exactly the record it will flush rather than
|
||||
// retaining a product-layer record by identity. Serializability is guaranteed
|
||||
// at the source, so structuredClone is safe.
|
||||
ctx.on('session/event', (session, event) => {
|
||||
let buffer = this.buffers.get(session)
|
||||
if (!buffer) this.buffers.set(session, buffer = [])
|
||||
buffer.push(structuredClone(event))
|
||||
})
|
||||
|
||||
// Drain to the backend at the durability checkpoint.
|
||||
ctx.on('session/flush', session => this.flush(session))
|
||||
|
||||
// Session disposal is observe-only, so the coordinator observes the
|
||||
// detached task itself and backend teardown awaits quiescence.
|
||||
ctx.on('session/disposed', (session) => { this.retire(session) })
|
||||
|
||||
// HMR: a hot reload does not replay session/created, so seed existing live
|
||||
// sessions (mirrors dsh-invariants).
|
||||
for (const session of ctx.sessions.list()) void this.initFor(session)
|
||||
}
|
||||
|
||||
/** Start, observe, and track one disposed session's final drain. */
|
||||
private retire(session: Session): void {
|
||||
const task = this.retireCore(session)
|
||||
this.retirements.add(task)
|
||||
const settled = (): void => { this.retirements.delete(task) }
|
||||
void task.then(settled, (error: unknown) => {
|
||||
settled()
|
||||
this.ctx.logger.warn(`${this.backend.name}: session "${session.id}" retirement failed: ${String(error)}`)
|
||||
})
|
||||
}
|
||||
|
||||
/** Drain and release state owned by one exact disposed Session lifecycle. */
|
||||
private async retireCore(session: Session): Promise<void> {
|
||||
await this.inits.get(session)
|
||||
|
||||
const id = session.header.id
|
||||
await this.serialize(id, async () => {
|
||||
await this.drain(session)
|
||||
this.buffers.delete(session)
|
||||
this.inits.delete(session)
|
||||
if (this.states.get(id)?.owner === session) this.states.delete(id)
|
||||
})
|
||||
}
|
||||
|
||||
/** Await every retirement admitted before listener teardown. */
|
||||
private async awaitRetirements(): Promise<void> {
|
||||
while (this.retirements.size > 0) {
|
||||
await Promise.allSettled([...this.retirements])
|
||||
}
|
||||
}
|
||||
|
||||
/** Start (once) the async init for a session and remember its promise. */
|
||||
private initFor(session: Session): Promise<void> {
|
||||
const existing = this.inits.get(session)
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* @module @deepseek-ai/dsh-session-persistence/tests/coordinator-contract
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context, type Fiber } from 'cordis'
|
||||
import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
@@ -401,7 +401,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
}
|
||||
})
|
||||
|
||||
it('does NOT reclaim an id whose abandoned owner still has buffered (unflushed) events', async () => {
|
||||
it('session disposal drains buffered events before retiring ownership', async () => {
|
||||
const fix = await makeFixture()
|
||||
const { ctx, fiber } = await freshCtx(fix)
|
||||
try {
|
||||
@@ -413,13 +413,20 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
// Append a turn but do NOT flush — events sit in the write-behind buffer.
|
||||
first.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
first.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
await firstFiber.dispose() // disposed before flush; not materialized, buffer pending
|
||||
await firstFiber.dispose()
|
||||
|
||||
// Disposal is an observe-only notification. Poll storage rather than
|
||||
// assuming the owning fiber awaits the coordinator's detached drain.
|
||||
await vi.waitFor(async () => {
|
||||
expect((await ctx.sessionPersistence.list()).map(meta => meta.id)).toContain(SessionId('buffered'))
|
||||
})
|
||||
expect((await ctx.sessionPersistence.load(SessionId('buffered'))).events.map(event => event.seq)).toEqual([0, 1])
|
||||
|
||||
let reuse!: Session
|
||||
await ctx.plugin(Object.assign((inner: Context) => {
|
||||
reuse = inner.sessions.create(SessionId('buffered'), { meta: { cwd: WORK } })
|
||||
}, { inject: ['sessions'] }))
|
||||
await expect(ctx.sessions.flush(reuse)).rejects.toThrow(/already bound to a different live session/)
|
||||
await expect(ctx.sessions.flush(reuse)).rejects.toThrow(/persisted log|id collision/)
|
||||
} finally {
|
||||
await fiber.dispose()
|
||||
await fix.cleanup()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SessionStore, { SessionId, isJsonValue } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
SessionPersistence, PersistenceCoordinator,
|
||||
type PersistenceBackend, type StoredPrefix,
|
||||
@@ -15,6 +15,15 @@ type MemoryStore = Map<string, { meta: SessionHeader; events: SessionEvent[] }>
|
||||
/** Optional plugin config: an EXTERNAL store shared across backend instances. */
|
||||
interface MemoryConfig { store?: MemoryStore }
|
||||
|
||||
/** Test-only view of the coordinator containers whose retirement is the contract under test. */
|
||||
interface CoordinatorInternals {
|
||||
states: Map<unknown, unknown>
|
||||
buffers: Map<unknown, unknown>
|
||||
chains: Map<unknown, unknown>
|
||||
inits: Map<unknown, unknown>
|
||||
retirements: Set<Promise<void>>
|
||||
}
|
||||
|
||||
/**
|
||||
* Reference {@link PersistenceCoordinator} vehicle and abstract-service coverage, backed by a
|
||||
* dependency-free map with atomic writes and no torn-tail marker. Supplying the map lets multiple
|
||||
@@ -101,6 +110,49 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend
|
||||
}
|
||||
}
|
||||
|
||||
/** Controllable storage primitive for serialization and retirement failure tests. */
|
||||
class ControlledBackend implements PersistenceBackend<never> {
|
||||
readonly name = 'session-persistence-controlled'
|
||||
readonly store: MemoryStore = new Map()
|
||||
readonly lifecycle: string[] = []
|
||||
appendAttempts = 0
|
||||
loadAttempts = 0
|
||||
beforeAppend?: (attempt: number) => Promise<void>
|
||||
beforeLoadStored?: (attempt: number) => Promise<void>
|
||||
|
||||
async loadStored(id: SessionId): Promise<StoredPrefix<never> | undefined> {
|
||||
await this.beforeLoadStored?.(++this.loadAttempts)
|
||||
const entry = this.store.get(id)
|
||||
if (entry === undefined) return undefined
|
||||
return { meta: structuredClone(entry.meta), events: structuredClone(entry.events) }
|
||||
}
|
||||
|
||||
loadLive(id: SessionId, _cwd: string | undefined): Promise<StoredPrefix<never> | undefined> {
|
||||
return this.loadStored(id)
|
||||
}
|
||||
|
||||
async appendBatch(m: SessionHeader, events: readonly SessionEvent[], _isMaterialized: boolean): Promise<void> {
|
||||
const attempt = ++this.appendAttempts
|
||||
await this.beforeAppend?.(attempt)
|
||||
const entry = this.store.get(m.id)
|
||||
if (entry === undefined) {
|
||||
this.store.set(m.id, { meta: structuredClone(m), events: structuredClone(events) as SessionEvent[] })
|
||||
} else {
|
||||
entry.events.push(...structuredClone(events) as SessionEvent[])
|
||||
}
|
||||
}
|
||||
|
||||
async commitRepair(_m: SessionHeader, _tornMarker: undefined, _closers: readonly SessionEvent[]): Promise<void> {}
|
||||
|
||||
async list(): Promise<SessionHeader[]> {
|
||||
return [...this.store.values()].map(entry => structuredClone(entry.meta))
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.lifecycle.push('close')
|
||||
}
|
||||
}
|
||||
|
||||
// Run the shared contract against the in-memory backend.
|
||||
runPersistenceContract('memory', async () => {
|
||||
const ctx = new Context()
|
||||
@@ -122,6 +174,230 @@ runCoordinatorContract('memory', async (): Promise<CoordinatorFixture> => {
|
||||
}
|
||||
})
|
||||
|
||||
describe('PersistenceCoordinator retirement', () => {
|
||||
it('a retiring unmaterialized owner without buffered events releases its id', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const backend = new ControlledBackend()
|
||||
let coordinator!: PersistenceCoordinator<never>
|
||||
const backendFiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
coordinator = new PersistenceCoordinator(inner, backend)
|
||||
}, { inject: ['sessions'] }))
|
||||
const loadGate = Promise.withResolvers<boolean>()
|
||||
|
||||
try {
|
||||
const id = SessionId('retiring-lazy-owner')
|
||||
let first!: Session
|
||||
const firstFiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
first = inner.sessions.create(id)
|
||||
}, { inject: ['sessions'] }))
|
||||
await ctx.sessions.flush(first)
|
||||
|
||||
const baselineLoads = backend.loadAttempts
|
||||
backend.beforeLoadStored = async () => { await loadGate.promise }
|
||||
const blockingLoad = coordinator.load(id)
|
||||
await vi.waitFor(() => { expect(backend.loadAttempts).toBe(baselineLoads + 1) })
|
||||
await firstFiber.dispose()
|
||||
|
||||
let reuse!: Session
|
||||
await ctx.plugin(Object.assign((inner: Context) => {
|
||||
reuse = inner.sessions.create(id)
|
||||
}, { inject: ['sessions'] }))
|
||||
await vi.waitFor(() => { expect(backend.loadAttempts).toBe(baselineLoads + 2) })
|
||||
|
||||
loadGate.resolve(true)
|
||||
await expect(blockingLoad).rejects.toThrow(/not found/)
|
||||
await expect(ctx.sessions.flush(reuse)).resolves.toBeUndefined()
|
||||
} finally {
|
||||
loadGate.resolve(true)
|
||||
await backendFiber.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('a retiring owner with buffered events still rejects same-id reuse', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const backend = new ControlledBackend()
|
||||
let coordinator!: PersistenceCoordinator<never>
|
||||
const backendFiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
coordinator = new PersistenceCoordinator(inner, backend)
|
||||
}, { inject: ['sessions'] }))
|
||||
const loadGate = Promise.withResolvers<boolean>()
|
||||
|
||||
try {
|
||||
const id = SessionId('retiring-buffered-owner')
|
||||
let first!: Session
|
||||
const firstFiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
first = inner.sessions.create(id)
|
||||
}, { inject: ['sessions'] }))
|
||||
await ctx.sessions.flush(first)
|
||||
first.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
first.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
const baselineLoads = backend.loadAttempts
|
||||
backend.beforeLoadStored = async () => { await loadGate.promise }
|
||||
const blockingLoad = coordinator.load(id)
|
||||
await vi.waitFor(() => { expect(backend.loadAttempts).toBe(baselineLoads + 1) })
|
||||
await firstFiber.dispose()
|
||||
|
||||
let reuse!: Session
|
||||
await ctx.plugin(Object.assign((inner: Context) => {
|
||||
reuse = inner.sessions.create(id)
|
||||
}, { inject: ['sessions'] }))
|
||||
await expect(ctx.sessions.flush(reuse)).rejects.toThrow(/bound to a different live session/)
|
||||
|
||||
loadGate.resolve(true)
|
||||
await expect(blockingLoad).rejects.toThrow(/not found/)
|
||||
await vi.waitFor(() => {
|
||||
expect(backend.store.get(id)?.events.map(event => event.seq)).toEqual([0, 1])
|
||||
})
|
||||
} finally {
|
||||
loadGate.resolve(true)
|
||||
await backendFiber.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('a settled chain tail cannot delete a newer operation for the same id', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const backend = new ControlledBackend()
|
||||
let coordinator!: PersistenceCoordinator<never>
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
coordinator = new PersistenceCoordinator(inner, backend)
|
||||
}, { inject: ['sessions'] }))
|
||||
const internals = coordinator as unknown as CoordinatorInternals
|
||||
const first = Promise.withResolvers<boolean>()
|
||||
const second = Promise.withResolvers<boolean>()
|
||||
backend.beforeAppend = async (attempt) => {
|
||||
if (attempt === 1) await first.promise
|
||||
if (attempt === 2) await second.promise
|
||||
}
|
||||
|
||||
try {
|
||||
const id = SessionId('chain-tail')
|
||||
await coordinator.create(meta(id))
|
||||
const firstAppend = coordinator.append(id, [{
|
||||
type: 'turn/start',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
}])
|
||||
const secondAppend = coordinator.append(id, [{
|
||||
type: 'turn/end',
|
||||
seq: 1,
|
||||
time: 2,
|
||||
data: { turn: 1, reason: { kind: 'completed' } },
|
||||
}])
|
||||
|
||||
await vi.waitFor(() => { expect(backend.appendAttempts).toBe(1) })
|
||||
first.resolve(true)
|
||||
await vi.waitFor(() => { expect(backend.appendAttempts).toBe(2) })
|
||||
expect(internals.chains.size).toBe(1)
|
||||
second.resolve(true)
|
||||
await Promise.all([firstAppend, secondAppend])
|
||||
await vi.waitFor(() => { expect(internals.chains.size).toBe(0) })
|
||||
expect(backend.store.get(id)?.events.map(event => event.seq)).toEqual([0, 1])
|
||||
} finally {
|
||||
first.resolve(true)
|
||||
second.resolve(true)
|
||||
await fiber.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('backend teardown retries a failed session retirement before close', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const backend = new ControlledBackend()
|
||||
let coordinator!: PersistenceCoordinator<never>
|
||||
const backendFiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
coordinator = new PersistenceCoordinator(inner, backend)
|
||||
}, { inject: ['sessions'] }))
|
||||
const internals = coordinator as unknown as CoordinatorInternals
|
||||
backend.beforeAppend = async (attempt) => {
|
||||
if (attempt === 1) {
|
||||
backend.lifecycle.push('append-failed')
|
||||
throw new Error('transient append failure')
|
||||
}
|
||||
backend.lifecycle.push('append-committed')
|
||||
}
|
||||
|
||||
try {
|
||||
let session!: Session
|
||||
const sessionFiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
session = inner.sessions.create(SessionId('retry-retirement'))
|
||||
}, { inject: ['sessions'] }))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
await sessionFiber.dispose()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(backend.appendAttempts).toBe(1)
|
||||
expect(internals.retirements.size).toBe(0)
|
||||
})
|
||||
expect([...internals.buffers.values()]).toEqual([expect.arrayContaining([
|
||||
expect.objectContaining({ seq: 0 }),
|
||||
expect.objectContaining({ seq: 1 }),
|
||||
])])
|
||||
|
||||
await backendFiber.dispose()
|
||||
expect(backend.store.get(SessionId('retry-retirement'))?.events.map(event => event.seq)).toEqual([0, 1])
|
||||
expect(backend.lifecycle).toEqual(['append-failed', 'append-committed', 'close'])
|
||||
} finally {
|
||||
await backendFiber.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('backend teardown waits for an in-flight session retirement before close', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const backend = new ControlledBackend()
|
||||
let coordinator!: PersistenceCoordinator<never>
|
||||
const backendFiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
coordinator = new PersistenceCoordinator(inner, backend)
|
||||
}, { inject: ['sessions'] }))
|
||||
const internals = coordinator as unknown as CoordinatorInternals
|
||||
const appendGate = Promise.withResolvers<boolean>()
|
||||
backend.beforeAppend = async () => {
|
||||
backend.lifecycle.push('append-started')
|
||||
await appendGate.promise
|
||||
backend.lifecycle.push('append-committed')
|
||||
}
|
||||
|
||||
try {
|
||||
let session!: Session
|
||||
const sessionFiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
session = inner.sessions.create(SessionId('inflight-retirement'))
|
||||
}, { inject: ['sessions'] }))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
await sessionFiber.dispose()
|
||||
await vi.waitFor(() => {
|
||||
expect(backend.appendAttempts).toBe(1)
|
||||
expect(internals.retirements.size).toBe(1)
|
||||
})
|
||||
|
||||
let disposed = false
|
||||
const teardown = backendFiber.dispose().then(() => { disposed = true })
|
||||
await Promise.resolve()
|
||||
expect(disposed).toBe(false)
|
||||
expect(backend.lifecycle).toEqual(['append-started'])
|
||||
|
||||
appendGate.resolve(true)
|
||||
await teardown
|
||||
expect(backend.store.get(SessionId('inflight-retirement'))?.events.map(event => event.seq)).toEqual([0, 1])
|
||||
expect(backend.lifecycle).toEqual(['append-started', 'append-committed', 'close'])
|
||||
} finally {
|
||||
appendGate.resolve(true)
|
||||
await backendFiber.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('SessionPersistence service registration', () => {
|
||||
it('registers as ctx.sessionPersistence and is removed on fiber dispose (HMR safety)', async () => {
|
||||
const ctx = new Context()
|
||||
@@ -155,4 +431,37 @@ describe('SessionPersistence service registration', () => {
|
||||
.rejects.toThrow('session metadata must be losslessly JSON-serializable')
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('retires all coordinator bookkeeping for disposed sessions', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const fiber = await ctx.plugin(MemoryPersistence)
|
||||
const { coordinator } = ctx.sessionPersistence as unknown as { coordinator: CoordinatorInternals }
|
||||
|
||||
try {
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
let session!: Session
|
||||
const sessionFiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
session = inner.sessions.create(SessionId(`disposed-${index}`))
|
||||
}, { inject: ['sessions'] }))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
await ctx.sessions.flush(session)
|
||||
await sessionFiber.dispose()
|
||||
}
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(ctx.sessions.list()).toHaveLength(0)
|
||||
expect({
|
||||
states: coordinator.states.size,
|
||||
buffers: coordinator.buffers.size,
|
||||
chains: coordinator.chains.size,
|
||||
inits: coordinator.inits.size,
|
||||
retirements: coordinator.retirements.size,
|
||||
}).toEqual({ states: 0, buffers: 0, chains: 0, inits: 0, retirements: 0 })
|
||||
})
|
||||
} finally {
|
||||
await fiber.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
Generated
+1737
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
packages:
|
||||
- vendor/*
|
||||
- packages/*/*
|
||||
- website
|
||||
# Deploy root of the single-exe build: a pure dependency manifest whose
|
||||
# closure is what the exe bundles and what the Python runtime distributes.
|
||||
- python/sdk-runtime
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Shared AST walkers for the cordis documentation generators
|
||||
* (`gen-cordis-catalog.ts`, `gen-website-api.ts`): locating the cordis module
|
||||
* merge in a source file, enumerating its `interface Events` members, and
|
||||
* resolving the `interface Context` service keys to their service classes.
|
||||
* One walk, two renderers — the catalog and the website page carry different
|
||||
* prose but must agree on WHAT exists.
|
||||
*/
|
||||
|
||||
import ts from 'typescript'
|
||||
import { parseJsDoc, pointer, rawJsDoc } from './jsdoc.ts'
|
||||
|
||||
/** The body of the cordis module merge in `sf`: `declare module 'cordis'`
|
||||
* (harness packages) or `declare module './context.ts'` (vendor core), or
|
||||
* null when the file has neither. */
|
||||
export function cordisModuleBody(sf: ts.SourceFile): ts.ModuleBlock | null {
|
||||
for (const stmt of sf.statements) {
|
||||
if (!ts.isModuleDeclaration(stmt) || !ts.isStringLiteral(stmt.name)) continue
|
||||
if (stmt.name.text !== 'cordis' && stmt.name.text !== './context.ts') continue
|
||||
if (stmt.body && ts.isModuleBlock(stmt.body)) return stmt.body
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Every `interface Events` method member of a cordis module merge, with the
|
||||
* event name resolved from its (possibly string-literal) property name. */
|
||||
export function eventMembers(body: ts.ModuleBlock, sf: ts.SourceFile): { name: string; member: ts.MethodSignature }[] {
|
||||
const out: { name: string; member: ts.MethodSignature }[] = []
|
||||
for (const stmt of body.statements) {
|
||||
if (!ts.isInterfaceDeclaration(stmt) || stmt.name.text !== 'Events') continue
|
||||
for (const member of stmt.members) {
|
||||
if (!ts.isMethodSignature(member)) continue
|
||||
const name = ts.isStringLiteral(member.name) ? member.name.text : member.name.getText(sf)
|
||||
out.push({ name, member })
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** The `ctx.<key> → type name` map declared by a merge's `interface Context`. */
|
||||
function contextKeyMap(body: ts.ModuleBlock, sf: ts.SourceFile): Map<string, string> {
|
||||
const keyToType = new Map<string, string>()
|
||||
for (const stmt of body.statements) {
|
||||
if (!ts.isInterfaceDeclaration(stmt) || stmt.name.text !== 'Context') continue
|
||||
for (const member of stmt.members) {
|
||||
if (!ts.isPropertySignature(member) || !member.type) continue
|
||||
keyToType.set(member.name.getText(sf), member.type.getText(sf))
|
||||
}
|
||||
}
|
||||
return keyToType
|
||||
}
|
||||
|
||||
/** One `ctx.<key>` service class resolved from a Context merge. */
|
||||
export interface ServiceClass {
|
||||
key: string
|
||||
type: string
|
||||
cls: ts.ClassDeclaration
|
||||
abstract: boolean
|
||||
/** Class-level JSDoc prose (empty string when missing — also reported). */
|
||||
doc: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve each `ctx.<key>` of a merge to the service class declared in the
|
||||
* same file. A key whose type is not a class here (a Pick-mixin member, e.g.
|
||||
* timer helpers) is skipped. A class without JSDoc prose is reported into
|
||||
* `violations` (named `where` by the caller's gate).
|
||||
*
|
||||
* @param body — the cordis module merge body.
|
||||
* @param sf — the source file containing the merge.
|
||||
* @param rel — repo-relative path of `sf`, for violation pointers.
|
||||
* @param violations — sink for JSDoc-completeness violations.
|
||||
* @returns the resolved service classes, in Context-declaration order.
|
||||
*/
|
||||
export function serviceClasses(
|
||||
body: ts.ModuleBlock,
|
||||
sf: ts.SourceFile,
|
||||
rel: string,
|
||||
violations: string[],
|
||||
): ServiceClass[] {
|
||||
const text = sf.getFullText()
|
||||
const out: ServiceClass[] = []
|
||||
for (const [key, type] of contextKeyMap(body, sf)) {
|
||||
const cls = sf.statements.find(
|
||||
(s): s is ts.ClassDeclaration => ts.isClassDeclaration(s) && s.name?.text === type,
|
||||
)
|
||||
if (!cls) continue // a Pick-mixin member, not a class here
|
||||
const abstract = cls.modifiers?.some(m => m.kind === ts.SyntaxKind.AbstractKeyword) ?? false
|
||||
const doc = parseJsDoc(rawJsDoc(text, cls)).doc
|
||||
if (!doc) violations.push(`service ctx.${key} (${pointer(rel, sf, cls)}): class ${type} has no JSDoc.`)
|
||||
out.push({ key, type, cls, abstract, doc })
|
||||
}
|
||||
return out
|
||||
}
|
||||
+14
-31
@@ -8,6 +8,7 @@ import { execFileSync } from 'node:child_process'
|
||||
import { globSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { join, relative, resolve } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { extractFences } from './md-fences.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
@@ -27,39 +28,21 @@ interface Block {
|
||||
code: string
|
||||
}
|
||||
|
||||
/** The info-string → kind table this gate tracks. */
|
||||
const KIND_BY_INFO: Record<string, BlockKind> = {
|
||||
'ts': 'check',
|
||||
'ts ignore-check': 'ignore',
|
||||
'ts type-equiv': 'type-equiv',
|
||||
'ts cordis-catalog': 'cordis-catalog',
|
||||
'ts persistence-catalog': 'persistence-catalog',
|
||||
'ts config-catalog': 'config-catalog',
|
||||
}
|
||||
|
||||
/** Extract every recognized TypeScript fence from one Markdown file. */
|
||||
function extractBlocks(absPath: string): Block[] {
|
||||
const text = readFileSync(absPath, 'utf8')
|
||||
const lines = text.split('\n')
|
||||
const file = relative(root, absPath)
|
||||
const blocks: Block[] = []
|
||||
let open: { line: number; kind: BlockKind; body: string[] } | null = null
|
||||
|
||||
lines.forEach((raw, i) => {
|
||||
const fence = /^```(\s*)(\S.*)?$/.exec(raw)
|
||||
if (!fence) {
|
||||
if (open) open.body.push(raw)
|
||||
return
|
||||
}
|
||||
if (open) {
|
||||
// closing fence
|
||||
blocks.push({ file, line: open.line, kind: open.kind, code: open.body.join('\n') })
|
||||
open = null
|
||||
return
|
||||
}
|
||||
// Ignore non-TypeScript fences.
|
||||
const info = (fence[2] ?? '').trim()
|
||||
const kind: BlockKind | null =
|
||||
info === 'ts' ? 'check'
|
||||
: info === 'ts ignore-check' ? 'ignore'
|
||||
: info === 'ts type-equiv' ? 'type-equiv'
|
||||
: info === 'ts cordis-catalog' ? 'cordis-catalog'
|
||||
: info === 'ts persistence-catalog' ? 'persistence-catalog'
|
||||
: info === 'ts config-catalog' ? 'config-catalog'
|
||||
: null
|
||||
if (kind) open = { line: i + 1, kind, body: [] }
|
||||
})
|
||||
return blocks
|
||||
return extractFences(absPath, info => KIND_BY_INFO[info] ?? null)
|
||||
.map(f => ({ file, line: f.line, kind: f.kind, code: f.code }))
|
||||
}
|
||||
|
||||
const configHost: ts.ParseConfigFileHost = {
|
||||
@@ -208,7 +191,7 @@ function remapBlockPaths(output: string, blocks: Block[]): string {
|
||||
})
|
||||
}
|
||||
|
||||
const markdownGlobs = ['README.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md']
|
||||
const markdownGlobs = ['README.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md', 'website/zh-CN/**/*.md']
|
||||
|
||||
const files: string[] = []
|
||||
for (const pattern of markdownGlobs) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { globSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { resolve, sep } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { checkParams, checkReturns, parseJsDoc, parseTags, pointer, rawJsDoc, reportViolations, type Mode } from './jsdoc.ts'
|
||||
import { cordisModuleBody, eventMembers, serviceClasses } from './cordis-walk.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const OUT_EVENTS = 'docs/cordis-catalog/events.md'
|
||||
@@ -102,15 +103,8 @@ interface InheritedEntry {
|
||||
source: string
|
||||
}
|
||||
|
||||
/** Find the `declare module 'cordis'` body in a source file, or null. */
|
||||
function cordisModuleBody(sf: ts.SourceFile): ts.ModuleBlock | null {
|
||||
for (const stmt of sf.statements) {
|
||||
if (ts.isModuleDeclaration(stmt) && ts.isStringLiteral(stmt.name) && stmt.name.text === 'cordis') {
|
||||
if (stmt.body && ts.isModuleBlock(stmt.body)) return stmt.body
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
// cordisModuleBody / eventMembers / serviceClasses live in cordis-walk.ts,
|
||||
// shared with gen-website-api.ts — one walk, two renderers.
|
||||
|
||||
/** The signature text of a method-signature member (everything but a body). */
|
||||
function memberSignature(member: ts.TypeElement | ts.ClassElement, sf: ts.SourceFile): string {
|
||||
@@ -134,38 +128,33 @@ export function collectEvents(scanRoot: string = root): EventEntry[] {
|
||||
const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
|
||||
const body = cordisModuleBody(sf)
|
||||
if (!body) continue
|
||||
for (const stmt of body.statements) {
|
||||
if (!ts.isInterfaceDeclaration(stmt) || stmt.name.text !== 'Events') continue
|
||||
for (const member of stmt.members) {
|
||||
if (!ts.isMethodSignature(member)) continue
|
||||
const name = ts.isStringLiteral(member.name) ? member.name.text : member.name.getText(sf)
|
||||
const signature = memberSignature(member, sf)
|
||||
const raw = rawJsDoc(text, member)
|
||||
const { doc, mode } = parseJsDoc(raw)
|
||||
const src = pointer(rel, sf, member)
|
||||
const where = `event '${name}' (${src})`
|
||||
if (!mode) {
|
||||
violations.push(`${where} is missing an @mode tag. Add '@mode emit|waterfall|parallel|serial' to its JSDoc (see AGENTS.md).`)
|
||||
}
|
||||
// Conclusive structural check: a trailing `next: () => …` parameter is a
|
||||
// waterfall. (emit vs parallel vs serial is not structurally
|
||||
// distinguishable, so it is trusted from the tag.)
|
||||
const last = member.parameters.at(-1)
|
||||
const hasNext = !!last && last.name.getText(sf) === 'next'
|
||||
if (mode && hasNext && mode !== 'waterfall') {
|
||||
violations.push(`${where} has a trailing 'next' parameter (structurally a waterfall) but is tagged '@mode ${mode}'. Fix the tag or the signature.`)
|
||||
}
|
||||
if (mode && !hasNext && mode === 'waterfall') {
|
||||
violations.push(`${where} is tagged '@mode waterfall' but has no trailing 'next' parameter. A waterfall delegates via next().`)
|
||||
}
|
||||
if (!doc) violations.push(`${where} has no description prose. Say what happened / what a listener may do, above the block tags.`)
|
||||
// Payload parameters need a non-empty @param. The `this` receiver is not
|
||||
// payload, and a waterfall's trailing `next` is covered by its mode.
|
||||
const { params } = parseTags(raw)
|
||||
checkParams(where, 'event', member.parameters, params, sf,
|
||||
p => (ts.isIdentifier(p.name) && p.name.text === 'this') || (hasNext && p === last), violations)
|
||||
if (mode) entries.push({ name, scope: name.split('/')[0] ?? name, signature, mode, doc, source: src })
|
||||
for (const { name, member } of eventMembers(body, sf)) {
|
||||
const signature = memberSignature(member, sf)
|
||||
const raw = rawJsDoc(text, member)
|
||||
const { doc, mode } = parseJsDoc(raw)
|
||||
const src = pointer(rel, sf, member)
|
||||
const where = `event '${name}' (${src})`
|
||||
if (!mode) {
|
||||
violations.push(`${where} is missing an @mode tag. Add '@mode emit|waterfall|parallel|serial' to its JSDoc (see AGENTS.md).`)
|
||||
}
|
||||
// Conclusive structural check: a trailing `next: () => …` parameter is a
|
||||
// waterfall. (emit vs parallel vs serial is not structurally
|
||||
// distinguishable, so it is trusted from the tag.)
|
||||
const last = member.parameters.at(-1)
|
||||
const hasNext = !!last && last.name.getText(sf) === 'next'
|
||||
if (mode && hasNext && mode !== 'waterfall') {
|
||||
violations.push(`${where} has a trailing 'next' parameter (structurally a waterfall) but is tagged '@mode ${mode}'. Fix the tag or the signature.`)
|
||||
}
|
||||
if (mode && !hasNext && mode === 'waterfall') {
|
||||
violations.push(`${where} is tagged '@mode waterfall' but has no trailing 'next' parameter. A waterfall delegates via next().`)
|
||||
}
|
||||
if (!doc) violations.push(`${where} has no description prose. Say what happened / what a listener may do, above the block tags.`)
|
||||
// Payload parameters need a non-empty @param. The `this` receiver is not
|
||||
// payload, and a waterfall's trailing `next` is covered by its mode.
|
||||
const { params } = parseTags(raw)
|
||||
checkParams(where, 'event', member.parameters, params, sf,
|
||||
p => (ts.isIdentifier(p.name) && p.name.text === 'this') || (hasNext && p === last), violations)
|
||||
if (mode) entries.push({ name, scope: name.split('/')[0] ?? name, signature, mode, doc, source: src })
|
||||
}
|
||||
}
|
||||
reportViolations('gen-cordis-catalog', violations)
|
||||
@@ -188,26 +177,8 @@ export function collectServices(scanRoot: string = root): ServiceEntry[] {
|
||||
const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
|
||||
const body = cordisModuleBody(sf)
|
||||
if (!body) continue
|
||||
// The ctx key → type mapping(s) declared in this file's interface Context.
|
||||
const keyToType = new Map<string, string>()
|
||||
for (const stmt of body.statements) {
|
||||
if (!ts.isInterfaceDeclaration(stmt) || stmt.name.text !== 'Context') continue
|
||||
for (const member of stmt.members) {
|
||||
if (!ts.isPropertySignature(member) || !member.type) continue
|
||||
const key = member.name.getText(sf)
|
||||
keyToType.set(key, member.type.getText(sf))
|
||||
}
|
||||
}
|
||||
if (keyToType.size === 0) continue
|
||||
// Find each service class declared in the same file and emit an entry.
|
||||
for (const [key, type] of keyToType) {
|
||||
const cls = sf.statements.find(
|
||||
(s): s is ts.ClassDeclaration => ts.isClassDeclaration(s) && s.name?.text === type,
|
||||
)
|
||||
if (!cls) continue // a Pick-mixin member (e.g. timer helpers), not a class here
|
||||
const abstract = cls.modifiers?.some(m => m.kind === ts.SyntaxKind.AbstractKeyword) ?? false
|
||||
const clsDoc = parseJsDoc(rawJsDoc(text, cls)).doc
|
||||
if (!clsDoc) violations.push(`service ctx.${key} (${pointer(rel, sf, cls)}): class ${type} has no JSDoc.`)
|
||||
// Resolve each ctx key to its service class (shared walk) and emit an entry.
|
||||
for (const { key, type, cls, abstract, doc: clsDoc } of serviceClasses(body, sf, rel, violations)) {
|
||||
const methods: string[] = []
|
||||
for (const member of cls.members) {
|
||||
if (!ts.isMethodDeclaration(member)) continue
|
||||
@@ -258,14 +229,14 @@ export function collectServices(scanRoot: string = root): ServiceEntry[] {
|
||||
* sibling check is N/A; keep them current on a vendor bump.
|
||||
*/
|
||||
const INHERITED_EVENTS: InheritedEntry[] = [
|
||||
{ name: 'internal/plugin', summary: 'A plugin fiber was created.', source: 'vendor/cordis/src/events.ts:197' },
|
||||
{ name: 'internal/status', summary: 'A fiber changed lifecycle state.', source: 'vendor/cordis/src/events.ts:198' },
|
||||
{ name: 'internal/service', summary: 'Interception hook for a service binding (no core producer).', source: 'vendor/cordis/src/events.ts:199' },
|
||||
{ name: 'internal/update', summary: 'Waterfall: a fiber config update is being applied.', source: 'vendor/cordis/src/events.ts:200' },
|
||||
{ name: 'internal/get', summary: 'Waterfall: a service is being read from the store.', source: 'vendor/cordis/src/events.ts:201' },
|
||||
{ name: 'internal/set', summary: 'Waterfall: a service is being written to the store.', source: 'vendor/cordis/src/events.ts:202' },
|
||||
{ name: 'internal/listener', summary: 'A listener was registered.', source: 'vendor/cordis/src/events.ts:203' },
|
||||
{ name: 'internal/dispatch', summary: 'An event is being dispatched to listeners.', source: 'vendor/cordis/src/events.ts:204' },
|
||||
{ name: 'internal/plugin', summary: 'A plugin fiber was created.', source: 'vendor/cordis/src/events.ts:328' },
|
||||
{ name: 'internal/status', summary: 'A fiber changed lifecycle state.', source: 'vendor/cordis/src/events.ts:330' },
|
||||
{ name: 'internal/service', summary: 'Interception hook for a service binding (no core producer).', source: 'vendor/cordis/src/events.ts:332' },
|
||||
{ name: 'internal/update', summary: 'Waterfall: a fiber config update is being applied.', source: 'vendor/cordis/src/events.ts:334' },
|
||||
{ name: 'internal/get', summary: 'Waterfall: a service is being read from the store.', source: 'vendor/cordis/src/events.ts:336' },
|
||||
{ name: 'internal/set', summary: 'Waterfall: a service is being written to the store.', source: 'vendor/cordis/src/events.ts:338' },
|
||||
{ name: 'internal/listener', summary: 'A listener was registered.', source: 'vendor/cordis/src/events.ts:340' },
|
||||
{ name: 'internal/dispatch', summary: 'An event is being dispatched to listeners.', source: 'vendor/cordis/src/events.ts:342' },
|
||||
{ name: 'hmr/change', summary: 'A watched source file changed on disk.', source: 'vendor/hmr/src/index.ts:20' },
|
||||
{ name: 'hmr/reload', summary: 'Plugins are being reloaded after a change.', source: 'vendor/hmr/src/index.ts:21' },
|
||||
{ name: 'exit', summary: 'The process is exiting on a signal.', source: 'vendor/loader/src/index.ts:23' },
|
||||
@@ -276,12 +247,12 @@ const INHERITED_EVENTS: InheritedEntry[] = [
|
||||
]
|
||||
|
||||
export const INHERITED_SERVICES: InheritedEntry[] = [
|
||||
{ name: 'ctx.on / ctx.once', summary: 'Register an event listener (disposable).', source: 'vendor/cordis/src/events.ts:29' },
|
||||
{ name: 'ctx.emit / ctx.parallel / ctx.serial / ctx.bail / ctx.waterfall', summary: 'Dispatch an event (sync / awaited / first-bail / veto-chain).', source: 'vendor/cordis/src/events.ts:29' },
|
||||
{ name: 'ctx.plugin / ctx.inject', summary: 'Load a plugin / declare required services.', source: 'vendor/cordis/src/registry.ts:144' },
|
||||
{ name: 'ctx.on / ctx.once', summary: 'Register an event listener (disposable).', source: 'vendor/cordis/src/events.ts:34' },
|
||||
{ name: 'ctx.emit / ctx.parallel / ctx.serial / ctx.bail / ctx.waterfall', summary: 'Dispatch an event (sync / awaited / first-bail / veto-chain).', source: 'vendor/cordis/src/events.ts:34' },
|
||||
{ name: 'ctx.plugin / ctx.inject', summary: 'Load a plugin / declare required services.', source: 'vendor/cordis/src/registry.ts:164' },
|
||||
{ name: 'ctx.effect', summary: 'Register a disposable side effect tied to the fiber.', source: 'vendor/cordis/src/fiber.ts:9' },
|
||||
{ name: 'ctx.get / ctx.set / ctx.provide / ctx.accessor / ctx.mixin', summary: 'Low-level service-store access and binding.', source: 'vendor/cordis/src/reflect.ts:7' },
|
||||
{ name: 'ctx.extend / ctx.isolate / ctx.intercept', summary: 'Derive a child context (scoped services / isolation / interception).', source: 'vendor/cordis/src/context.ts:35' },
|
||||
{ name: 'ctx.extend / ctx.isolate / ctx.intercept', summary: 'Derive a child context (scoped services / isolation / interception).', source: 'vendor/cordis/src/context.ts:42' },
|
||||
{ name: 'ctx.root / ctx.scope / ctx.fiber / ctx.registry / ctx.reflect / ctx.events / ctx.logger', summary: 'Ambient handles onto the running context graph.', source: 'vendor/cordis/src/context.ts:16' },
|
||||
{ name: 'ctx.timer (+ interval / timeout / throttle / debounce / setTimeout / setInterval)', summary: 'Disposable timer helpers. The `timer` key is provided at runtime; the six helpers are mixed onto ctx directly (declared via Pick).', source: 'vendor/timer/src/index.ts:4' },
|
||||
{ name: 'ctx.loader', summary: 'The config Loader that booted the app (present under the loader).', source: 'vendor/loader/src/index.ts:30' },
|
||||
|
||||
@@ -0,0 +1,721 @@
|
||||
/**
|
||||
* Generate (and verify) the website API reference under `website/zh-CN/api/`.
|
||||
*
|
||||
* The website's API section is FULLY GENERATED from source — never hand-edit
|
||||
* it. The hand-written hub `api/index.md` sits OUTSIDE the generated subdirs
|
||||
* (`api/cordis/`, `api/harness/`), so the orphan sweep never touches it. Two tiers:
|
||||
*
|
||||
* - `api/cordis/*` — the vendored cordis framework surface (Context, Events,
|
||||
* Fiber, Registry, Service), driven by the CORDIS_PAGES manifest below.
|
||||
* Members come from the real class declarations and the `declare module
|
||||
* './context.ts'` interface merges (the typed `ctx.*` surface a plugin
|
||||
* author actually sees).
|
||||
* - `api/harness/*` — one page per `ctx.<key>` harness service (walked from
|
||||
* every `declare module 'cordis'` Context merge under `packages/<group>/<pkg>/src`),
|
||||
* plus `events.md` listing every harness event grouped by scope.
|
||||
*
|
||||
* Prose comes from the JSDoc; the generator HARD-ERRORS (aggregated) when a
|
||||
* rendered member lacks a summary, a parameter lacks `@param`, or a non-void
|
||||
* annotated return lacks `@returns` — so a vendor sync or a new service method
|
||||
* cannot land undocumented without CI going red. Pages are English (the
|
||||
* planned zh translation flow arrives separately; see docs/i18n/README.md).
|
||||
*
|
||||
* Signature fences use the ` ```ts website-api ` info string: doc-typecheck
|
||||
* only processes its known info strings, so these bare (non-compilable)
|
||||
* signature fragments are skipped there, while VitePress still highlights the
|
||||
* `ts` token. The sidebar fragment `website/.vitepress/config/api-sidebar.json`
|
||||
* is generated alongside so navigation can never drift from the page set.
|
||||
*
|
||||
* `tsx scripts/gen-website-api.ts` → write pages + sidebar
|
||||
* `tsx scripts/gen-website-api.ts --check` → exit 1 if committed copies are
|
||||
* stale (doc-sync / CI gate)
|
||||
*/
|
||||
|
||||
import { globSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { checkParams, checkReturns, parseJsDoc, parseTags, pointer, rawJsDoc, reportViolations, type Mode } from './jsdoc.ts'
|
||||
import { cordisModuleBody, eventMembers, serviceClasses } from './cordis-walk.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
/** Output roots: generated pages and the generated sidebar fragment. */
|
||||
const PAGES_DIR = 'website/zh-CN/api'
|
||||
const SIDEBAR_OUT = 'website/.vitepress/config/api-sidebar.json'
|
||||
|
||||
/** GitHub blob base for source links on the public site (repo-relative paths
|
||||
* do not resolve on the built site, unlike the in-repo catalogs). */
|
||||
const GITHUB = 'https://github.com/deepseek-harness/deepseek-harness/blob/master'
|
||||
|
||||
/** Signature-fence info string (skipped by doc-typecheck, highlighted as ts). */
|
||||
const FENCE = 'ts website-api'
|
||||
|
||||
/** Return sorted repository-relative glob matches with stable URL separators. */
|
||||
function repoGlob(pattern: string): string[] {
|
||||
return globSync(pattern, { cwd: root }).map(rel => rel.replaceAll('\\', '/')).sort()
|
||||
}
|
||||
|
||||
/** One rendered member: a method/property plus its parsed JSDoc. */
|
||||
interface MemberDoc {
|
||||
/** Display name, e.g. `on` or `agent/pre-step`. */
|
||||
name: string
|
||||
/** Heading suffix with parameter names, e.g. `(name, listener, options?)`;
|
||||
* empty for properties. */
|
||||
heading: string
|
||||
/** All overload signature lines (bodies stripped). */
|
||||
signatures: string[]
|
||||
/** Description prose, one paragraph per line. */
|
||||
doc: string
|
||||
/** Parameter name → `@param` text, in declaration order. */
|
||||
params: { name: string; text: string }[]
|
||||
/** `@returns` text, or null for void/undocumented. */
|
||||
returns: string | null
|
||||
/** Repo-relative `file:line` of the (first) declaration. */
|
||||
source: string
|
||||
}
|
||||
|
||||
/** A cordis-page section: which declarations it renders. */
|
||||
type Section =
|
||||
| { kind: 'class'; file: string; symbol: string; prefix?: string; heading?: string }
|
||||
| { kind: 'context-merge'; file: string; heading?: string }
|
||||
| { kind: 'decl'; file: string; symbol: string }
|
||||
|
||||
/** One generated cordis page. */
|
||||
interface CordisPage {
|
||||
out: string
|
||||
title: string
|
||||
intro: string
|
||||
sections: Section[]
|
||||
}
|
||||
|
||||
/**
|
||||
* The cordis tier manifest. Deliberately explicit (not a blind walk): the
|
||||
* vendor `Context` mixes true plugin-author surface with internals, and page
|
||||
* grouping is an editorial choice — but every member listed here is still
|
||||
* EXTRACTED, never transcribed, so signatures and docs cannot drift.
|
||||
*/
|
||||
const CORDIS_PAGES: CordisPage[] = [
|
||||
{
|
||||
out: 'cordis/context.md',
|
||||
title: 'Context',
|
||||
intro: 'The context is the core cordis object: every service, event, and lifecycle API is reached through `ctx`. Event methods (`ctx.on`, `ctx.emit`, …) are documented on [Events](./events.md); `ctx.effect` and `ctx.fiber` on [Fiber](./fiber.md); `ctx.plugin` and `ctx.inject` on [Registry](./registry.md).',
|
||||
sections: [
|
||||
{ kind: 'class', file: 'vendor/cordis/src/context.ts', symbol: 'Context', prefix: 'ctx.' },
|
||||
{ kind: 'context-merge', file: 'vendor/cordis/src/reflect.ts', heading: 'Service store and mixins' },
|
||||
],
|
||||
},
|
||||
{
|
||||
out: 'cordis/events.md',
|
||||
title: 'Events',
|
||||
intro: 'The event system mixed into every context. Harness-defined events are cataloged on [Harness events](../harness/events.md).',
|
||||
sections: [
|
||||
{ kind: 'context-merge', file: 'vendor/cordis/src/events.ts' },
|
||||
{ kind: 'decl', file: 'vendor/cordis/src/events.ts', symbol: 'EventOptions' },
|
||||
{ kind: 'decl', file: 'vendor/cordis/src/events.ts', symbol: 'DispatchMode' },
|
||||
],
|
||||
},
|
||||
{
|
||||
out: 'cordis/fiber.md',
|
||||
title: 'Fiber',
|
||||
intro: 'A fiber is one loaded plugin instance: its lifecycle state, validated config, and registered effects. `ctx.fiber` is the current fiber; `ctx.effect()` delegates to it.',
|
||||
sections: [
|
||||
{ kind: 'context-merge', file: 'vendor/cordis/src/fiber.ts' },
|
||||
{ kind: 'class', file: 'vendor/cordis/src/fiber.ts', symbol: 'Fiber', heading: 'The Fiber class' },
|
||||
{ kind: 'decl', file: 'vendor/cordis/src/fiber.ts', symbol: 'Effect' },
|
||||
{ kind: 'decl', file: 'vendor/cordis/src/fiber.ts', symbol: 'Disposable' },
|
||||
{ kind: 'decl', file: 'vendor/cordis/src/fiber.ts', symbol: 'EffectMeta' },
|
||||
{ kind: 'decl', file: 'vendor/cordis/src/fiber.ts', symbol: 'CordisError' },
|
||||
{ kind: 'decl', file: 'vendor/cordis/src/fiber.ts', symbol: 'ValidationError' },
|
||||
],
|
||||
},
|
||||
{
|
||||
out: 'cordis/registry.md',
|
||||
title: 'Registry',
|
||||
intro: 'Plugin loading and dependency injection.',
|
||||
sections: [
|
||||
{ kind: 'context-merge', file: 'vendor/cordis/src/registry.ts' },
|
||||
{ kind: 'decl', file: 'vendor/cordis/src/registry.ts', symbol: 'Plugin' },
|
||||
{ kind: 'decl', file: 'vendor/cordis/src/registry.ts', symbol: 'Inject' },
|
||||
],
|
||||
},
|
||||
{
|
||||
out: 'cordis/service.md',
|
||||
title: 'Service',
|
||||
intro: 'Base class for context services: subclass it and load the subclass as a plugin to register `ctx.<name>`.',
|
||||
sections: [
|
||||
{ kind: 'class', file: 'vendor/cordis/src/service.ts', symbol: 'Service' },
|
||||
],
|
||||
},
|
||||
]
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extraction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const sfCache = new Map<string, { sf: ts.SourceFile; text: string }>()
|
||||
|
||||
/** Parse (and cache) one repo-relative source file. */
|
||||
function load(rel: string): { sf: ts.SourceFile; text: string } {
|
||||
const cached = sfCache.get(rel)
|
||||
if (cached) return cached
|
||||
const text = readFileSync(resolve(root, rel), 'utf8')
|
||||
const sf = ts.createSourceFile(rel, text, ts.ScriptTarget.Latest, true)
|
||||
const entry = { sf, text }
|
||||
sfCache.set(rel, entry)
|
||||
return entry
|
||||
}
|
||||
// The module-merge walk (cordisModuleBody / eventMembers / serviceClasses) is
|
||||
// shared with gen-cordis-catalog.ts via cordis-walk.ts.
|
||||
|
||||
/** Signature text of a member: full text minus body/initializer, whitespace
|
||||
* collapsed, trailing semicolon stripped. */
|
||||
function signatureOf(member: ts.Node, sf: ts.SourceFile): string {
|
||||
const full = member.getText(sf)
|
||||
const tail = (member as { body?: ts.Node; initializer?: ts.Node }).body
|
||||
?? (member as { initializer?: ts.Node }).initializer
|
||||
const sig = tail ? full.slice(0, full.length - tail.getText(sf).length).replace(/[=\s]+$/, '') : full
|
||||
return sig.replace(/\s*;?\s*$/, '').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
/** `(a, b?, ...rest)` heading suffix from a parameter list, `this` dropped. */
|
||||
function headingParams(parameters: readonly ts.ParameterDeclaration[], sf: ts.SourceFile): string {
|
||||
const names = parameters
|
||||
.filter(p => !(ts.isIdentifier(p.name) && p.name.text === 'this'))
|
||||
.map((p) => {
|
||||
const dots = p.dotDotDotToken ? '...' : ''
|
||||
const opt = p.questionToken || p.initializer ? '?' : ''
|
||||
return `${dots}${p.name.getText(sf)}${opt}`
|
||||
})
|
||||
return `(${names.join(', ')})`
|
||||
}
|
||||
|
||||
/** Whether a class member is renderable public API (non-static half). */
|
||||
function isPublicInstance(member: ts.ClassElement): boolean {
|
||||
const mods = ts.getCombinedModifierFlags(member)
|
||||
if (mods & (ts.ModifierFlags.Private | ts.ModifierFlags.Protected | ts.ModifierFlags.Static)) return false
|
||||
if (!member.name) return false
|
||||
if (ts.isComputedPropertyName(member.name) || ts.isPrivateIdentifier(member.name)) return false
|
||||
return !member.name.getText().startsWith('_')
|
||||
}
|
||||
|
||||
/** Whether a class member is renderable public STATIC API. */
|
||||
function isPublicStatic(member: ts.ClassElement): boolean {
|
||||
const mods = ts.getCombinedModifierFlags(member)
|
||||
if (mods & (ts.ModifierFlags.Private | ts.ModifierFlags.Protected)) return false
|
||||
if (!(mods & ts.ModifierFlags.Static)) return false
|
||||
if (!member.name || ts.isComputedPropertyName(member.name) || ts.isPrivateIdentifier(member.name)) return false
|
||||
return !member.name.getText().startsWith('_')
|
||||
}
|
||||
|
||||
/** Build a MemberDoc from a declaration group (overloads share one entry),
|
||||
* collecting completeness violations for everything rendered. */
|
||||
function memberDoc(
|
||||
where: string,
|
||||
name: string,
|
||||
group: (ts.MethodDeclaration | ts.MethodSignature | ts.PropertyDeclaration | ts.PropertySignature | ts.GetAccessorDeclaration)[],
|
||||
rel: string,
|
||||
violations: string[],
|
||||
): MemberDoc {
|
||||
const { sf, text } = load(rel)
|
||||
const first = group[0]
|
||||
if (!first) throw new Error(`gen-website-api: empty member group for ${name}`)
|
||||
// Doc from the first overload that carries JSDoc prose.
|
||||
const rawDocs = group.map(m => rawJsDoc(text, m))
|
||||
const docIndex = rawDocs.findIndex(r => parseJsDoc(r).doc !== '')
|
||||
const raw = docIndex === -1 ? '' : (rawDocs[docIndex] ?? '')
|
||||
const doc = parseJsDoc(raw).doc
|
||||
if (!doc) violations.push(`${where} has no JSDoc prose.`)
|
||||
const { params: tags, returns } = parseTags(raw)
|
||||
const params: { name: string; text: string }[] = []
|
||||
let returnsText: string | null = null
|
||||
const funcLike = group.filter((m): m is ts.MethodDeclaration | ts.MethodSignature => ts.isMethodDeclaration(m) || ts.isMethodSignature(m))
|
||||
const docCarrier = funcLike[docIndex === -1 ? 0 : docIndex]
|
||||
if (docCarrier) {
|
||||
checkParams(where, 'website-api', docCarrier.parameters, tags, sf,
|
||||
p => ts.isIdentifier(p.name) && p.name.text === 'this', violations)
|
||||
if (docCarrier.type) {
|
||||
checkReturns(where, docCarrier.type, returns, sf, violations)
|
||||
} else if (!returns && ts.isMethodDeclaration(docCarrier)) {
|
||||
// Comment-only vendor policy: we cannot add a return type annotation to
|
||||
// pinned upstream source, so an unannotated rendered method must carry
|
||||
// an explicit @returns describing the result instead.
|
||||
violations.push(`${where} has no return type annotation; document the result with @returns.`)
|
||||
}
|
||||
for (const p of docCarrier.parameters) {
|
||||
if (ts.isIdentifier(p.name) && p.name.text === 'this') continue
|
||||
const pname = p.name.getText(sf)
|
||||
const tag = tags.get(pname)
|
||||
if (tag) params.push({ name: pname, text: tag })
|
||||
}
|
||||
returnsText = returns
|
||||
}
|
||||
const headingSource = docCarrier ?? funcLike[0]
|
||||
return {
|
||||
name,
|
||||
heading: headingSource ? headingParams(headingSource.parameters, sf) : '',
|
||||
signatures: (ts.isMethodDeclaration(first) && funcLike.length > 1
|
||||
? funcLike.filter(m => ts.isMethodDeclaration(m) && !m.body)
|
||||
: group).map(m => signatureOf(m, sf)),
|
||||
doc,
|
||||
params,
|
||||
returns: returnsText,
|
||||
source: pointer(rel, sf, first),
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve an `extends Pick<Class, 'a' | 'b'>` heritage clause on the Context
|
||||
* merge to the named members of `Class` declared in the same file — the fiber
|
||||
* merge (`interface Context extends Pick<Fiber, 'effect'>`) is the motivating
|
||||
* case: without this, `ctx.effect` had no documented signature anywhere. */
|
||||
function heritageMembers(
|
||||
stmt: ts.InterfaceDeclaration,
|
||||
sf: ts.SourceFile,
|
||||
groups: Map<string, (ts.MethodSignature | ts.PropertySignature | ts.MethodDeclaration)[]>,
|
||||
): void {
|
||||
for (const clause of stmt.heritageClauses ?? []) {
|
||||
for (const type of clause.types) {
|
||||
if (!ts.isIdentifier(type.expression) || type.expression.text !== 'Pick') continue
|
||||
const [target, keys] = type.typeArguments ?? []
|
||||
if (!target || !keys || !ts.isTypeReferenceNode(target)) continue
|
||||
const targetName = target.typeName.getText(sf)
|
||||
const cls = sf.statements.find(
|
||||
(s): s is ts.ClassDeclaration => ts.isClassDeclaration(s) && s.name?.text === targetName,
|
||||
)
|
||||
if (!cls) continue
|
||||
const picked = new Set<string>()
|
||||
const collect = (node: ts.TypeNode): void => {
|
||||
if (ts.isLiteralTypeNode(node) && ts.isStringLiteral(node.literal)) picked.add(node.literal.text)
|
||||
if (ts.isUnionTypeNode(node)) node.types.forEach(collect)
|
||||
}
|
||||
collect(keys)
|
||||
for (const member of cls.members) {
|
||||
if (!ts.isMethodDeclaration(member)) continue
|
||||
const name = member.name.getText(sf)
|
||||
if (!picked.has(name)) continue
|
||||
const group = groups.get(name) ?? []
|
||||
group.push(member)
|
||||
groups.set(name, group)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Members of the `interface Context` merge in `rel`, overloads grouped;
|
||||
* `Pick<…>` heritage resolved to the picked class members. */
|
||||
function contextMergeMembers(rel: string, violations: string[]): MemberDoc[] {
|
||||
const { sf } = load(rel)
|
||||
const body = cordisModuleBody(sf)
|
||||
if (!body) throw new Error(`gen-website-api: ${rel} has no context module merge`)
|
||||
const groups = new Map<string, (ts.MethodSignature | ts.PropertySignature | ts.MethodDeclaration)[]>()
|
||||
for (const stmt of body.statements) {
|
||||
if (!ts.isInterfaceDeclaration(stmt) || stmt.name.text !== 'Context') continue
|
||||
heritageMembers(stmt, sf, groups)
|
||||
for (const member of stmt.members) {
|
||||
if (!ts.isMethodSignature(member) && !ts.isPropertySignature(member)) continue
|
||||
if (ts.isComputedPropertyName(member.name)) continue
|
||||
const name = member.name.getText(sf)
|
||||
const group = groups.get(name) ?? []
|
||||
group.push(member)
|
||||
groups.set(name, group)
|
||||
}
|
||||
}
|
||||
return [...groups.entries()].map(([name, group]) =>
|
||||
memberDoc(`ctx.${name} (${rel})`, name, group, rel, violations))
|
||||
}
|
||||
|
||||
/** Instance + static members of one class, as two rendered lists. The class's
|
||||
* same-named top-level interface half (declaration merging — vendor Context
|
||||
* declares `root`/`events`/`logger`/… on the interface) is folded into the
|
||||
* instance list, so neither half of a merged symbol goes undocumented. */
|
||||
function classMembers(rel: string, className: string, violations: string[]): {
|
||||
doc: string
|
||||
instance: MemberDoc[]
|
||||
statics: MemberDoc[]
|
||||
source: string
|
||||
} {
|
||||
const { sf, text } = load(rel)
|
||||
const cls = sf.statements.find(
|
||||
(s): s is ts.ClassDeclaration => ts.isClassDeclaration(s) && s.name?.text === className,
|
||||
)
|
||||
if (!cls) throw new Error(`gen-website-api: class ${className} not found in ${rel}`)
|
||||
const clsDoc = parseJsDoc(rawJsDoc(text, cls)).doc
|
||||
if (!clsDoc) violations.push(`class ${className} (${pointer(rel, sf, cls)}) has no JSDoc.`)
|
||||
type Renderable = ts.MethodDeclaration | ts.PropertyDeclaration | ts.GetAccessorDeclaration | ts.PropertySignature
|
||||
const instance = new Map<string, Renderable[]>()
|
||||
const statics = new Map<string, (ts.MethodDeclaration | ts.PropertyDeclaration)[]>()
|
||||
for (const member of cls.members) {
|
||||
const renderable = ts.isMethodDeclaration(member) || ts.isPropertyDeclaration(member) || ts.isGetAccessorDeclaration(member)
|
||||
if (!renderable) continue
|
||||
const name = member.name.getText(sf)
|
||||
if (isPublicInstance(member)) {
|
||||
const group = instance.get(name) ?? []
|
||||
group.push(member)
|
||||
instance.set(name, group)
|
||||
} else if (isPublicStatic(member) && !ts.isGetAccessorDeclaration(member)) {
|
||||
const group = statics.get(name) ?? []
|
||||
group.push(member)
|
||||
statics.set(name, group)
|
||||
}
|
||||
}
|
||||
const iface = sf.statements.find(
|
||||
(s): s is ts.InterfaceDeclaration => ts.isInterfaceDeclaration(s) && s.name.text === className,
|
||||
)
|
||||
for (const member of iface?.members ?? []) {
|
||||
if (!ts.isPropertySignature(member)) continue
|
||||
if (ts.isComputedPropertyName(member.name)) continue
|
||||
const name = member.name.getText(sf)
|
||||
const group = instance.get(name) ?? []
|
||||
group.push(member)
|
||||
instance.set(name, group)
|
||||
}
|
||||
const toDocs = (groups: Map<string, Renderable[]>, prefix: string): MemberDoc[] =>
|
||||
[...groups.entries()].map(([name, group]) =>
|
||||
memberDoc(`${prefix}${name} (${rel})`, name, group, rel, violations))
|
||||
return {
|
||||
doc: clsDoc,
|
||||
instance: toDocs(instance, `${className}#`),
|
||||
statics: toDocs(statics, `${className}.`),
|
||||
source: pointer(rel, sf, cls),
|
||||
}
|
||||
}
|
||||
|
||||
/** Splice every function-like BODY out of a declaration's text, leaving the
|
||||
* signature (`) {` → `)`). A reference paste shows shapes, not implementation;
|
||||
* property initializers (e.g. an `as const` code table) are data and stay. */
|
||||
function stripBodies(node: ts.Node, sf: ts.SourceFile): string {
|
||||
const cuts: { start: number; end: number }[] = []
|
||||
const visit = (n: ts.Node): void => {
|
||||
const funcLike = ts.isMethodDeclaration(n) || ts.isConstructorDeclaration(n)
|
||||
|| ts.isFunctionDeclaration(n) || ts.isGetAccessorDeclaration(n) || ts.isSetAccessorDeclaration(n)
|
||||
if (funcLike && n.body) {
|
||||
// Cut from just after the parameter close (or return-type end) through
|
||||
// the body, so `foo(a: string) { … }` renders as `foo(a: string)`.
|
||||
const sigEnd = (n.type ?? n.parameters[n.parameters.length - 1] ?? n).getEnd()
|
||||
// Find the `)` (and optional `: Type`) boundary: body start is exact.
|
||||
cuts.push({ start: sigEnd, end: n.body.getEnd() })
|
||||
return // nothing renderable inside the body
|
||||
}
|
||||
n.forEachChild(visit)
|
||||
}
|
||||
visit(node)
|
||||
const base = node.getStart(sf)
|
||||
let out = node.getText(sf)
|
||||
for (const cut of cuts.sort((a, b) => b.start - a.start)) {
|
||||
const head = out.slice(0, cut.start - base)
|
||||
// Keep everything of the signature up to the closing paren / return type,
|
||||
// drop ` { … }`. The head may end mid-signature (last param), so retain
|
||||
// the source between sigEnd and the body's `{` MINUS trailing space.
|
||||
const between = out.slice(cut.start - base, cut.end - base)
|
||||
const bodyBrace = between.indexOf('{')
|
||||
out = head + between.slice(0, bodyBrace).trimEnd() + out.slice(cut.end - base)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Verbatim declaration paste: every top-level statement named `symbol`
|
||||
* (class + merged namespace both), with leading JSDoc prose extracted and
|
||||
* function bodies stripped (a reference shows shapes, not implementation). */
|
||||
function declPaste(rel: string, symbol: string): { doc: string; code: string; source: string } {
|
||||
const { sf, text } = load(rel)
|
||||
const matches = sf.statements.filter((s) => {
|
||||
const named = ts.isInterfaceDeclaration(s) || ts.isTypeAliasDeclaration(s)
|
||||
|| ts.isClassDeclaration(s) || ts.isEnumDeclaration(s) || ts.isModuleDeclaration(s)
|
||||
return named && s.name?.getText(sf) === symbol
|
||||
})
|
||||
if (matches.length === 0) throw new Error(`gen-website-api: declaration ${symbol} not found in ${rel}`)
|
||||
const first = matches[0]
|
||||
if (!first) throw new Error(`gen-website-api: declaration ${symbol} not found in ${rel}`)
|
||||
const doc = parseJsDoc(rawJsDoc(text, first)).doc
|
||||
const code = matches.map(s => stripBodies(s, sf).replace(/^export\s+(default\s+)?/, '')).join('\n\n')
|
||||
return { doc, code, source: pointer(rel, sf, first) }
|
||||
}
|
||||
|
||||
/** One harness service with member-level detail. */
|
||||
interface HarnessService {
|
||||
key: string
|
||||
type: string
|
||||
abstract: boolean
|
||||
doc: string
|
||||
members: MemberDoc[]
|
||||
source: string
|
||||
/** Owning npm package name (from the package.json beside the entry). */
|
||||
pkg: string
|
||||
}
|
||||
|
||||
/** Walk every harness `declare module 'cordis'` Context merge → services. */
|
||||
function collectHarnessServices(violations: string[]): HarnessService[] {
|
||||
const services: HarnessService[] = []
|
||||
for (const rel of repoGlob('packages/*/*/src/index.ts')) {
|
||||
const { sf, text } = load(rel)
|
||||
if (!text.includes('interface Context')) continue
|
||||
const body = cordisModuleBody(sf)
|
||||
if (!body) continue
|
||||
const pkgJson = resolve(root, dirname(dirname(rel)), 'package.json')
|
||||
// Manifest shape is repo-owned; `name` is the one field read here.
|
||||
const manifest = JSON.parse(readFileSync(pkgJson, 'utf8')) as { name: string }
|
||||
const pkg = manifest.name
|
||||
for (const { key, type, cls, abstract, doc: clsDoc } of serviceClasses(body, sf, rel, violations)) {
|
||||
const groups = new Map<string, (ts.MethodDeclaration | ts.PropertyDeclaration | ts.GetAccessorDeclaration)[]>()
|
||||
for (const member of cls.members) {
|
||||
// Public properties are API too: ctx.codeRuntime.language/isolation
|
||||
// are readonly descriptors consumers key presentation off.
|
||||
const renderable = ts.isMethodDeclaration(member) || ts.isPropertyDeclaration(member) || ts.isGetAccessorDeclaration(member)
|
||||
if (!renderable) continue
|
||||
if (!isPublicInstance(member)) continue
|
||||
const name = member.name.getText(sf)
|
||||
const group = groups.get(name) ?? []
|
||||
group.push(member)
|
||||
groups.set(name, group)
|
||||
}
|
||||
const members = [...groups.entries()].map(([name, group]) =>
|
||||
memberDoc(`ctx.${key}.${name} (${rel})`, name, group, rel, violations))
|
||||
services.push({ key, type, abstract, doc: clsDoc, members, source: pointer(rel, sf, cls), pkg })
|
||||
}
|
||||
}
|
||||
return services.sort((a, b) => a.key.localeCompare(b.key))
|
||||
}
|
||||
|
||||
/** One harness event with member-level detail. */
|
||||
interface HarnessEvent {
|
||||
name: string
|
||||
scope: string
|
||||
mode: Mode | null
|
||||
signature: string
|
||||
doc: string
|
||||
params: { name: string; text: string }[]
|
||||
source: string
|
||||
}
|
||||
|
||||
/** Walk every harness `interface Events` merge → events. */
|
||||
function collectHarnessEvents(violations: string[]): HarnessEvent[] {
|
||||
const events: HarnessEvent[] = []
|
||||
for (const rel of repoGlob('packages/*/*/src/*.ts')) {
|
||||
const { sf, text } = load(rel)
|
||||
if (!text.includes('interface Events')) continue
|
||||
const body = cordisModuleBody(sf)
|
||||
if (!body) continue
|
||||
for (const { name, member } of eventMembers(body, sf)) {
|
||||
const raw = rawJsDoc(text, member)
|
||||
const { doc, mode } = parseJsDoc(raw)
|
||||
if (!mode) violations.push(`event '${name}' (${pointer(rel, sf, member)}) is missing @mode.`)
|
||||
if (!doc) violations.push(`event '${name}' (${pointer(rel, sf, member)}) has no JSDoc prose.`)
|
||||
const { params: tags } = parseTags(raw)
|
||||
const last = member.parameters.at(-1)
|
||||
const hasNext = !!last && last.name.getText(sf) === 'next'
|
||||
checkParams(`event '${name}' (${pointer(rel, sf, member)})`, 'website-api', member.parameters, tags, sf,
|
||||
p => (ts.isIdentifier(p.name) && p.name.text === 'this') || (hasNext && p === last), violations)
|
||||
const params: { name: string; text: string }[] = []
|
||||
for (const p of member.parameters) {
|
||||
const pname = p.name.getText(sf)
|
||||
const tag = tags.get(pname)
|
||||
if (tag) params.push({ name: pname, text: tag })
|
||||
}
|
||||
events.push({ name, scope: name.split('/')[0] ?? name, mode, signature: signatureOf(member, sf), doc, params, source: pointer(rel, sf, member) })
|
||||
}
|
||||
}
|
||||
return events.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const BANNER = '<!-- Generated by scripts/gen-website-api.ts — do not edit by hand. Run `pnpm run gen-website-api` to regenerate. -->'
|
||||
|
||||
/** GitHub source link for a `file:line` pointer. */
|
||||
function sourceLink(source: string): string {
|
||||
const [file, line] = source.split(':')
|
||||
return `[Source](${GITHUB}/${file}#L${line})`
|
||||
}
|
||||
|
||||
/** Normalize JSDoc inline `{@link X}` / `{@link X|label}` / `{@link X label}`
|
||||
* tags to plain Markdown code spans — left verbatim they leak into the built
|
||||
* page as literal `{@link …}` text. */
|
||||
function unlink(text: string): string {
|
||||
return text.replace(/\{@link\s+([^}|\s]+)\s*(?:[|\s]\s*([^}]*))?\}/g, (_m, target: string, label?: string) => {
|
||||
const name = label?.trim()
|
||||
return name && name !== '' ? name : `\`${target}\``
|
||||
})
|
||||
}
|
||||
|
||||
/** Render prose paragraphs (one per line of `doc`), JSDoc links normalized. */
|
||||
function prose(doc: string): string[] {
|
||||
return unlink(doc).split('\n').filter(l => l.trim() !== '')
|
||||
}
|
||||
|
||||
/** Render one member section at heading depth 3. */
|
||||
function renderMember(prefix: string, m: MemberDoc): string[] {
|
||||
const lines: string[] = []
|
||||
const call = m.heading === '' ? '' : m.heading
|
||||
lines.push(`### ${prefix}${m.name}${call}`, '')
|
||||
lines.push('```' + FENCE)
|
||||
for (const sig of m.signatures) lines.push(sig)
|
||||
lines.push('```', '')
|
||||
lines.push(...prose(m.doc), '')
|
||||
if (m.params.length > 0) {
|
||||
for (const p of m.params) lines.push(`- \`${p.name}\` — ${unlink(p.text)}`)
|
||||
lines.push('')
|
||||
}
|
||||
if (m.returns) lines.push(`**Returns** ${unlink(m.returns)}`, '')
|
||||
lines.push(sourceLink(m.source), '')
|
||||
return lines
|
||||
}
|
||||
|
||||
/** Render one cordis-tier page from its manifest entry. */
|
||||
function renderCordisPage(page: CordisPage, violations: string[]): string {
|
||||
const lines: string[] = [BANNER, '', `# ${page.title}`, '', page.intro, '']
|
||||
for (const section of page.sections) {
|
||||
if (section.kind !== 'decl' && section.heading) lines.push(`## ${section.heading}`, '')
|
||||
if (section.kind === 'context-merge') {
|
||||
for (const m of contextMergeMembers(section.file, violations)) {
|
||||
lines.push(...renderMember('ctx.', m))
|
||||
}
|
||||
} else if (section.kind === 'class') {
|
||||
const cls = classMembers(section.file, section.symbol, violations)
|
||||
lines.push(...prose(cls.doc), '', sourceLink(cls.source), '')
|
||||
const instancePrefix = section.prefix ?? `${section.symbol.toLowerCase()}.`
|
||||
for (const m of cls.instance) lines.push(...renderMember(instancePrefix, m))
|
||||
if (cls.statics.length > 0) {
|
||||
lines.push('## Static members', '')
|
||||
for (const m of cls.statics) lines.push(...renderMember(`${section.symbol}.`, m))
|
||||
}
|
||||
} else {
|
||||
const decl = declPaste(section.file, section.symbol)
|
||||
lines.push(`## ${section.symbol}`, '')
|
||||
if (decl.doc) lines.push(...prose(decl.doc), '')
|
||||
lines.push('```' + FENCE, decl.code, '```', '', sourceLink(decl.source), '')
|
||||
}
|
||||
}
|
||||
return `${lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd()}\n`
|
||||
}
|
||||
|
||||
/** kebab-case a ctx key: `agentLoop` → `agent-loop`. */
|
||||
function kebab(key: string): string {
|
||||
return key.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`)
|
||||
}
|
||||
|
||||
/** Render one harness service page. */
|
||||
function renderServicePage(svc: HarnessService): string {
|
||||
const seam = svc.abstract ? ' (abstract seam)' : ''
|
||||
const lines: string[] = [
|
||||
BANNER, '',
|
||||
`# ctx.${svc.key}`, '',
|
||||
`\`${svc.type}\`${seam} — provided by \`${svc.pkg}\`.`, '',
|
||||
...prose(svc.doc), '',
|
||||
sourceLink(svc.source), '',
|
||||
]
|
||||
for (const m of svc.members) lines.push(...renderMember(`ctx.${svc.key}.`, m))
|
||||
return `${lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd()}\n`
|
||||
}
|
||||
|
||||
/** Render the harness events page, grouped by scope. */
|
||||
function renderEventsPage(events: HarnessEvent[]): string {
|
||||
const lines: string[] = [
|
||||
BANNER, '',
|
||||
'# Harness events', '',
|
||||
`Every event the harness packages declare on the cordis event bus (${events.length} total), grouped by scope. The **mode** is the dispatch semantics (\`emit\` fire-and-forget, \`parallel\` awaited, \`serial\` first-bail, \`waterfall\` veto-chain — a waterfall listener MUST call \`next()\` to delegate).`, '',
|
||||
]
|
||||
const scopes = [...new Set(events.map(e => e.scope))].sort()
|
||||
for (const scope of scopes) {
|
||||
lines.push(`## ${scope}/*`, '')
|
||||
for (const e of events.filter(ev => ev.scope === scope)) {
|
||||
lines.push(`### ${e.name}`, '')
|
||||
lines.push(`**Mode:** \`${e.mode ?? 'unknown'}\``, '')
|
||||
lines.push('```' + FENCE, e.signature, '```', '')
|
||||
lines.push(...prose(e.doc), '')
|
||||
if (e.params.length > 0) {
|
||||
for (const p of e.params) lines.push(`- \`${p.name}\` — ${unlink(p.text)}`)
|
||||
lines.push('')
|
||||
}
|
||||
lines.push(sourceLink(e.source), '')
|
||||
}
|
||||
}
|
||||
return `${lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd()}\n`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Assembly + CLI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Build every generated file as `relPath → content`. */
|
||||
export function generate(): Map<string, string> {
|
||||
const violations: string[] = []
|
||||
const files = new Map<string, string>()
|
||||
|
||||
for (const page of CORDIS_PAGES) {
|
||||
files.set(`${PAGES_DIR}/${page.out}`, renderCordisPage(page, violations))
|
||||
}
|
||||
|
||||
const services = collectHarnessServices(violations)
|
||||
for (const svc of services) {
|
||||
files.set(`${PAGES_DIR}/harness/${kebab(svc.key)}.md`, renderServicePage(svc))
|
||||
}
|
||||
|
||||
const events = collectHarnessEvents(violations)
|
||||
files.set(`${PAGES_DIR}/harness/events.md`, renderEventsPage(events))
|
||||
|
||||
reportViolations('gen-website-api', violations)
|
||||
|
||||
const sidebar = {
|
||||
cordis: CORDIS_PAGES.map(p => ({
|
||||
text: p.title,
|
||||
link: `/zh-CN/api/${p.out.replace(/\.md$/, '')}`,
|
||||
})),
|
||||
harness: [
|
||||
...services.map(s => ({ text: `ctx.${s.key}`, link: `/zh-CN/api/harness/${kebab(s.key)}` })),
|
||||
{ text: 'Events', link: '/zh-CN/api/harness/events' },
|
||||
],
|
||||
}
|
||||
files.set(SIDEBAR_OUT, `${JSON.stringify(sidebar, null, 2)}\n`)
|
||||
return files
|
||||
}
|
||||
|
||||
/** CLI entry: default writes, `--check` fails on stale/orphan files. Guarded
|
||||
* behind an entry-point check so tests can import `generate()`. */
|
||||
function main(): void {
|
||||
const check = process.argv.includes('--check')
|
||||
const files = generate()
|
||||
|
||||
// Orphan detection: a generated-dir page that generate() no longer emits
|
||||
// (e.g. a service was renamed) must be deleted, not left to rot.
|
||||
const expected = new Set([...files.keys()])
|
||||
// Orphans live in the generated subdirs only; the hand-written api/index.md
|
||||
// is one level up and never matches this glob.
|
||||
const onDisk = repoGlob(`${PAGES_DIR}/{cordis,harness}/*.md`)
|
||||
const orphans = onDisk.filter(rel => !expected.has(rel))
|
||||
|
||||
if (check) {
|
||||
const stale: string[] = []
|
||||
for (const [rel, content] of files) {
|
||||
let current: string | null = null
|
||||
try {
|
||||
current = readFileSync(resolve(root, rel), 'utf8')
|
||||
} catch {
|
||||
// Missing file: reported as stale below; readFileSync is the probe.
|
||||
}
|
||||
if (current !== content) stale.push(rel)
|
||||
}
|
||||
if (stale.length > 0 || orphans.length > 0) {
|
||||
console.error('gen-website-api: website API reference is stale. Run `pnpm run gen-website-api` and commit the result.')
|
||||
for (const rel of stale) console.error(` stale: ${rel}`)
|
||||
for (const rel of orphans) console.error(` orphan (delete): ${rel}`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`gen-website-api: ${files.size} generated file(s) fresh.`)
|
||||
return
|
||||
}
|
||||
|
||||
for (const [rel, content] of files) {
|
||||
const abs = resolve(root, rel)
|
||||
mkdirSync(dirname(abs), { recursive: true })
|
||||
writeFileSync(abs, content)
|
||||
}
|
||||
for (const rel of orphans) {
|
||||
console.log(`gen-website-api: orphan page ${rel} — delete it (no longer generated).`)
|
||||
}
|
||||
console.log(`gen-website-api: wrote ${files.size} file(s).`)
|
||||
}
|
||||
|
||||
// Run only when invoked as a script, not when imported by a test.
|
||||
if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) {
|
||||
main()
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Shared fenced-code-block extractor for the Markdown doc gates
|
||||
* (`doc-typecheck.ts`, `verify-website-yaml.ts`). One scanner, per-gate
|
||||
* classification: each gate maps a fence info string (` ```ts `,
|
||||
* ` ```yaml ignore-check `, …) to its own kind tag and receives every
|
||||
* classified block with its 1-based opening-fence line.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
/** One extracted fenced block, classified by the caller's `classify`. */
|
||||
export interface Fence<K> {
|
||||
/** 1-based line of the opening fence. */
|
||||
line: number
|
||||
kind: K
|
||||
code: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract every fenced block of `absPath` whose info string `classify` maps
|
||||
* to a kind. Blocks classified `null` are skipped (their bodies are still
|
||||
* consumed, so an unrelated fence can never leak into a tracked one).
|
||||
*
|
||||
* @param absPath — absolute path of the Markdown file.
|
||||
* @param classify — info string (trimmed, e.g. `ts ignore-check`) → kind, or
|
||||
* null for fences this gate does not track.
|
||||
* @returns the classified blocks in document order.
|
||||
*/
|
||||
export function extractFences<K>(absPath: string, classify: (info: string) => K | null): Fence<K>[] {
|
||||
const lines = readFileSync(absPath, 'utf8').split('\n')
|
||||
const blocks: Fence<K>[] = []
|
||||
let open: { line: number; kind: K; body: string[] } | null = null
|
||||
let skipping = false
|
||||
|
||||
lines.forEach((raw, i) => {
|
||||
const fence = /^```(\s*)(\S.*)?$/.exec(raw)
|
||||
if (!fence) {
|
||||
if (open) open.body.push(raw)
|
||||
return
|
||||
}
|
||||
if (open) {
|
||||
blocks.push({ line: open.line, kind: open.kind, code: open.body.join('\n') })
|
||||
open = null
|
||||
return
|
||||
}
|
||||
if (skipping) {
|
||||
skipping = false
|
||||
return
|
||||
}
|
||||
const kind = classify((fence[2] ?? '').trim())
|
||||
if (kind !== null) open = { line: i + 1, kind, body: [] }
|
||||
else skipping = true
|
||||
})
|
||||
return blocks
|
||||
}
|
||||
@@ -212,6 +212,7 @@ function ciPrimaryGates(): Gate[] {
|
||||
...docSyncLeafGates(),
|
||||
pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }),
|
||||
pnpmScript('knip', 'knip'),
|
||||
pnpmScript('website-build', 'website:build', { label: 'website build' }),
|
||||
pnpmScript('build', 'build', { needs: ['typecheck'] }),
|
||||
pnpmScript('publint', 'publint', { needs: ['build'] }),
|
||||
pnpmScript('node-next-types', 'verify-node-next-types', {
|
||||
@@ -231,6 +232,7 @@ function ciStaticGates(): Gate[] {
|
||||
...docSyncLeafGates(),
|
||||
pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }),
|
||||
pnpmScript('knip', 'knip'),
|
||||
pnpmScript('website-build', 'website:build', { label: 'website build' }),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -321,6 +323,7 @@ function docSyncLeafGates(options: {
|
||||
pnpmScript('persistence-catalog', 'verify-persistence-catalog', { label: 'persistence catalog' }),
|
||||
pnpmScript('doc-graphs', 'verify-doc-graphs', { label: 'doc graphs' }),
|
||||
pnpmScript('scoped-events', 'verify-scoped-events', { label: 'scoped events' }),
|
||||
pnpmScript('website-api', 'verify-website-api', { label: 'website api' }),
|
||||
pnpmScript('markdown-wrap', 'verify-md-wrap', { label: 'markdown wrap' }),
|
||||
pnpmScript('markdown-links', 'verify-md-links', { label: 'markdown links' }),
|
||||
pnpmScript('doc-refs', 'verify-doc-refs', { label: 'doc refs' }),
|
||||
@@ -334,6 +337,7 @@ function docSyncLeafGates(options: {
|
||||
pnpmScript('translation-pairing', 'verify-translation-pairing', { label: 'translation pairing' }),
|
||||
pnpmScript('doc-budgets', 'verify-doc-budgets', { label: 'doc budgets' }),
|
||||
pnpmScript('package-readme-limitations', 'verify-package-readme-limitations', { label: 'package README limitations' }),
|
||||
pnpmScript('website-yaml', 'verify-website-yaml', { label: 'website yaml' }),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import ts from 'typescript'
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
/** Scan doc-typecheck's full Markdown scope so unmanifested blocks also fail. */
|
||||
const MARKDOWN_GLOBS = ['README.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md']
|
||||
const MARKDOWN_GLOBS = ['README.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md', 'website/zh-CN/**/*.md']
|
||||
|
||||
/** One manifest entry: a documented type-equiv block and its source symbol. */
|
||||
interface ManifestEntry {
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* Doc-sync gate: verify the fenced ```yaml examples in the website against
|
||||
* the loader and the workspace truth. A `cordis.yml` example that names a
|
||||
* plugin that does not exist, or passes a config key the plugin never
|
||||
* declared, is worse than no example — it fails silently for the reader.
|
||||
*
|
||||
* Scope: `website/zh-CN/**/*.md`, EXCLUDING `website/zh-CN/api/**` (the api
|
||||
* pages are generator-owned — their yaml examples are verified at generation
|
||||
* time by a later stream, not re-checked here). Blocks opt out with
|
||||
* ` ```yaml ignore-check ` (same philosophy as doc-typecheck's opt-out: the
|
||||
* count is reported, an unchecked block is a visible decision, not a silent
|
||||
* hole — placeholder plugin names in tutorials are the legitimate case).
|
||||
*
|
||||
* Each checked block is parsed with the loader's REAL schema —
|
||||
* `JSON_SCHEMA` extended with the `!!js` scalar type exactly as
|
||||
* vendor/include/src/index.ts declares it — so `!!js process.env.X` parses
|
||||
* here iff it parses at runtime. Then:
|
||||
*
|
||||
* - Root is an ARRAY → a cordis.yml entry list. Every item must be a mapping
|
||||
* with a string `name` and only the keys `EntryOptions` declares
|
||||
* (vendor/loader/src/config/entry.ts plus the isolate.ts merge:
|
||||
* id, name, config, group, disabled, inject, intercept, isolate).
|
||||
* - `./` / `../` names are illustrative local plugins — existence is not
|
||||
* checkable, skip. `group:*` names are loader built-ins; their `config`
|
||||
* is itself an entry list and is recursed into.
|
||||
* - Any other name must be a real workspace package (`packages/*/*` and
|
||||
* `vendor/*` package.json names).
|
||||
* - For `@deepseek-ai/dsh-*` names the config-catalog generator is the
|
||||
* truth: kind `config` → the yaml `config`'s top-level keys must be
|
||||
* properties of the declared config type (member names of the first
|
||||
* catalog paste ∪ top-level segments of the runtime schema keys);
|
||||
* config-free kinds → a non-empty `config` mapping is a violation;
|
||||
* seam/library kinds → name existence only (loading one directly is
|
||||
* dubious, but that is a docs-prose concern, not this gate's).
|
||||
* - Root is a MAPPING or scalar → a fragment (e.g. a bare `config:` excerpt):
|
||||
* syntax check only.
|
||||
*
|
||||
* This is a checker, not a fixer: it reports `file:line message` and exits 1.
|
||||
*
|
||||
* Run: `tsx scripts/verify-website-yaml.ts`.
|
||||
*/
|
||||
|
||||
import { globSync, readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import * as yaml from 'js-yaml'
|
||||
import ts from 'typescript'
|
||||
import { collectConfigCatalog, type CatalogEntry } from './gen-config-catalog.ts'
|
||||
import { extractFences } from './md-fences.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
/** Mirror of the loader's yaml schema (vendor/include/src/index.ts): the
|
||||
* `!!js` tag parses to an expression wrapper, everything else is JSON. */
|
||||
const JsExpr = new yaml.Type('tag:yaml.org,2002:js', {
|
||||
kind: 'scalar',
|
||||
resolve: data => typeof data === 'string',
|
||||
construct: (data: string) => ({ __jsExpr: data }),
|
||||
})
|
||||
const schema = yaml.JSON_SCHEMA.extend(JsExpr)
|
||||
|
||||
/** The exact key set an entry mapping may carry: `EntryOptions` in
|
||||
* vendor/loader/src/config/entry.ts plus the isolate.ts interface merge. */
|
||||
const ENTRY_KEYS = ['id', 'name', 'config', 'group', 'disabled', 'inject', 'intercept', 'isolate'] as const
|
||||
|
||||
/** One `file:line message` finding. */
|
||||
interface Violation {
|
||||
file: string
|
||||
/** 1-based line of the block's opening fence. */
|
||||
line: number
|
||||
message: string
|
||||
}
|
||||
|
||||
/** One extracted ```yaml block. */
|
||||
interface Block {
|
||||
file: string
|
||||
/** 1-based line of the opening fence. */
|
||||
line: number
|
||||
kind: 'check' | 'ignore'
|
||||
code: string
|
||||
}
|
||||
|
||||
/** Extract every ```yaml / ```yaml ignore-check block from one Markdown file. */
|
||||
function extractBlocks(file: string): Block[] {
|
||||
return extractFences(resolve(root, file), info =>
|
||||
info === 'yaml' ? 'check' : info === 'yaml ignore-check' ? 'ignore' : null)
|
||||
.map(f => ({ file, line: f.line, kind: f.kind, code: f.code }))
|
||||
}
|
||||
|
||||
/** Every workspace package name: `packages/<group>/<pkg>` and `vendor/<pkg>`. */
|
||||
function knownPackages(): Set<string> {
|
||||
const names = new Set<string>()
|
||||
for (const pattern of ['packages/*/*/package.json', 'vendor/*/package.json']) {
|
||||
for (const match of globSync(pattern, { cwd: root })) {
|
||||
const pkg: unknown = JSON.parse(readFileSync(resolve(root, match), 'utf8'))
|
||||
if (typeof pkg === 'object' && pkg !== null && 'name' in pkg && typeof pkg.name === 'string') {
|
||||
names.add(pkg.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
/** The catalog, built once on first `@deepseek-ai/dsh-*` name, keyed by pkg. */
|
||||
let catalogByPkg: Map<string, CatalogEntry> | null = null
|
||||
function catalogFor(pkg: string): CatalogEntry | undefined {
|
||||
catalogByPkg ??= new Map(collectConfigCatalog().map(e => [e.pkg, e]))
|
||||
return catalogByPkg.get(pkg)
|
||||
}
|
||||
|
||||
/** Top-level property names of the first catalog paste (the verbatim config
|
||||
* type declaration), parsed as source text. */
|
||||
function pasteKeys(paste: string): Set<string> {
|
||||
const sf = ts.createSourceFile('paste.ts', paste, ts.ScriptTarget.Latest, true)
|
||||
const keys = new Set<string>()
|
||||
const addMembers = (members: ts.NodeArray<ts.TypeElement>): void => {
|
||||
for (const m of members) {
|
||||
if (ts.isPropertySignature(m) || ts.isMethodSignature(m)) {
|
||||
const name = m.name
|
||||
keys.add(ts.isIdentifier(name) || ts.isStringLiteral(name) ? name.text : name.getText(sf))
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const stmt of sf.statements) {
|
||||
if (ts.isInterfaceDeclaration(stmt)) addMembers(stmt.members)
|
||||
else if (ts.isTypeAliasDeclaration(stmt) && ts.isTypeLiteralNode(stmt.type)) addMembers(stmt.type.members)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
/** The allowed top-level config keys of a kind-`config` catalog entry: the
|
||||
* first paste's member names ∪ the schema keys' top-level segments
|
||||
* (`agents[].id` → `agents`). Cached per entry. */
|
||||
const allowedKeysCache = new Map<string, Set<string>>()
|
||||
function allowedConfigKeys(entry: CatalogEntry): Set<string> {
|
||||
const cached = allowedKeysCache.get(entry.pkg)
|
||||
if (cached) return cached
|
||||
const keys = pasteKeys(entry.pastes?.[0]?.text ?? '')
|
||||
for (const path of entry.schemaKeys ?? []) {
|
||||
const top = path.split('.')[0]?.replace(/\[\]$/, '')
|
||||
if (top) keys.add(top)
|
||||
}
|
||||
allowedKeysCache.set(entry.pkg, keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
/** A parsed yaml mapping (arrays and `!!js` wrappers excluded). */
|
||||
function asMapping(value: unknown): Record<string, unknown> | null {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) return null
|
||||
if ('__jsExpr' in value) return null
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
/** Check one cordis.yml entry list (recursing into `group:` sub-lists). */
|
||||
function checkEntryList(
|
||||
items: unknown[],
|
||||
known: Set<string>,
|
||||
block: Block,
|
||||
violations: Violation[],
|
||||
): void {
|
||||
const flag = (message: string): void => {
|
||||
violations.push({ file: block.file, line: block.line, message })
|
||||
}
|
||||
items.forEach((item, index) => {
|
||||
const at = `entry ${index + 1}`
|
||||
const entry = asMapping(item)
|
||||
if (!entry) {
|
||||
flag(`${at}: not a mapping`)
|
||||
return
|
||||
}
|
||||
const name = entry['name']
|
||||
if (typeof name !== 'string') {
|
||||
flag(`${at}: missing string \`name\``)
|
||||
return
|
||||
}
|
||||
for (const key of Object.keys(entry)) {
|
||||
if (!(ENTRY_KEYS as readonly string[]).includes(key)) {
|
||||
flag(`${at} (${name}): unknown entry key \`${key}\` (EntryOptions allows: ${[...ENTRY_KEYS].join(', ')})`)
|
||||
}
|
||||
}
|
||||
// Illustrative local plugin — nothing on disk to check against.
|
||||
if (name.startsWith('./') || name.startsWith('../')) return
|
||||
// A `group:`-style pseudo-name is NOT loadable: tree.import() only
|
||||
// special-cases the `cordis:` prefix, and nothing in this repo registers
|
||||
// loader builtins — reject it and point at the real group plugin.
|
||||
if (name.startsWith('group:')) {
|
||||
flag(`${at}: \`${name}\` is not loadable (no loader builtin is registered); use \`@cordisjs/plugin-group\` with \`group: true\``)
|
||||
return
|
||||
}
|
||||
// The vendored group plugin: its config is a nested entry list.
|
||||
if (name === '@cordisjs/plugin-group') {
|
||||
if (Array.isArray(entry['config'])) checkEntryList(entry['config'], known, block, violations)
|
||||
return
|
||||
}
|
||||
if (!known.has(name)) {
|
||||
flag(`${at}: unknown plugin \`${name}\` (not a workspace package)`)
|
||||
return
|
||||
}
|
||||
if (!name.startsWith('@deepseek-ai/dsh-')) return
|
||||
const catalog = catalogFor(name)
|
||||
if (!catalog) return
|
||||
const config = asMapping(entry['config'])
|
||||
if (catalog.kind === 'config') {
|
||||
if (!config) return
|
||||
const allowed = allowedConfigKeys(catalog)
|
||||
for (const key of Object.keys(config)) {
|
||||
if (!allowed.has(key)) {
|
||||
flag(`${at}: \`${name}\` has no config key \`${key}\` (known keys: ${[...allowed].sort().join(', ')})`)
|
||||
}
|
||||
}
|
||||
} else if (catalog.kind === 'no-config') {
|
||||
if (config && Object.keys(config).length > 0) {
|
||||
flag(`${at}: \`${name}\` declares no config, but the example passes one`)
|
||||
}
|
||||
}
|
||||
// seam / library: loading one directly is dubious, but that is a prose
|
||||
// concern — this gate only vouches for name existence.
|
||||
})
|
||||
}
|
||||
|
||||
const files = globSync('website/zh-CN/**/*.md', { cwd: root })
|
||||
.filter(f => !f.startsWith('website/zh-CN/api/'))
|
||||
.sort()
|
||||
|
||||
const violations: Violation[] = []
|
||||
const known = knownPackages()
|
||||
let entryLists = 0
|
||||
let fragments = 0
|
||||
let ignored = 0
|
||||
let scanned = 0
|
||||
|
||||
for (const file of files) {
|
||||
for (const block of extractBlocks(file)) {
|
||||
scanned++
|
||||
if (block.kind === 'ignore') {
|
||||
ignored++
|
||||
continue
|
||||
}
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = yaml.load(block.code, { schema })
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message.split('\n')[0] ?? 'parse error' : String(error)
|
||||
violations.push({ file: block.file, line: block.line, message: `yaml parse error: ${message}` })
|
||||
continue
|
||||
}
|
||||
if (Array.isArray(parsed)) {
|
||||
entryLists++
|
||||
checkEntryList(parsed, known, block, violations)
|
||||
} else {
|
||||
// Mapping or scalar root: a fragment (e.g. a bare `config:` excerpt) —
|
||||
// syntax is all there is to check.
|
||||
fragments++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (violations.length === 0) {
|
||||
console.log(
|
||||
`verify-website-yaml: ${scanned} yaml block(s) in ${files.length} file(s): `
|
||||
+ `${entryLists} entry list(s) + ${fragments} fragment(s) checked, ${ignored} ignore-check skipped.`,
|
||||
)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
console.error('verify-website-yaml: invalid yaml examples found:')
|
||||
for (const v of violations) {
|
||||
console.error(` ${v.file}:${v.line} ${v.message}`)
|
||||
}
|
||||
process.exit(1)
|
||||
Vendored
+1
@@ -36,6 +36,7 @@ Keep this log exhaustive — every divergence from upstream must be listed.
|
||||
4. **Vendored TypeScript source internal specifiers**: changed local relative imports/exports from upstream's specifier shape to explicit `.ts` specifiers so TypeScript rewrites emitted JS to `.js` while declarations keep explicit, NodeNext-safe `.ts` specifiers. This includes `loader/src/config/isolate.ts` using `declare module './entry.ts'`.
|
||||
5. **`schemastery/tsdown.config.ts` and `logger-console/tsdown.config.ts`**: ours, not upstream files — per-package build-shape overrides (dual ESM+CJS output; separate node/browser entries) for the repo-root tsdown build. They read the JS emitted under `lib/types` and then write the publish runtime entries under `lib/`. Like the regenerated tsconfigs, they are not part of the upstream sync surface.
|
||||
6. **`cordis/src/fiber.ts` lifecycle hardening**: locally closes three reentrant disposal gaps. An effect's owner-list wrapper is registered before its setup body runs, so an unload begun from inside setup awaits setup and every collected cleanup; synchronous setup failure removes the wrapper and rolls back collected cleanup. Async cleanup stays owner-visible until quiescence, and Cordis's internal effect composition joins an already-running cleanup while repeated public disposer calls retain their upstream single-shot result. Effect creation is rejected while the owner is `UNLOADING` (while `PENDING` and `LOADING` remain legal), preventing cleanup-time registrations from escaping the unload snapshot. Child fibers register and receive their parent-owned disposer before `internal/plugin` publication, resolve dependency declarations added by that notification before activation, drain effects attached while pending, skip plugin execution when reentrant disposal invalidates the load epoch before its first checkpoint, and contain teardown-notification failures per observer so one callback cannot starve peers or interrupt ownership cleanup.
|
||||
7. **`cordis/src/*.ts` JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork.
|
||||
|
||||
## Sync procedure
|
||||
|
||||
|
||||
Vendored
+54
-5
@@ -14,14 +14,21 @@ import { Fiber } from './fiber.ts'
|
||||
* be read from `ctx`.
|
||||
*/
|
||||
export interface Context {
|
||||
/** Isolation map: service name → scope label. Lookups for a name resolve within its label. */
|
||||
[symbols.isolate]: Dict<symbol>
|
||||
/** Intercept map: service name → config merged into that service's per-plugin config. */
|
||||
[symbols.intercept]: Dict
|
||||
/** @experimental */
|
||||
/** The root context of the application (every child context shares it). @experimental */
|
||||
root: this
|
||||
/** Base URL used to resolve relative plugin/module specifiers, if the runtime sets one. */
|
||||
baseUrl?: string
|
||||
/** The event bus. Its methods are also mixed onto `ctx` (`ctx.on`, `ctx.emit`, ...). */
|
||||
events: EventsService
|
||||
/** The logging service. Call `ctx.logger(name)` for a named logger. */
|
||||
logger: LoggerService
|
||||
/** The reflection layer backing the context proxy (`ctx.get`, `ctx.provide`, ...). */
|
||||
reflect: ReflectService
|
||||
/** The plugin registry. Its methods are mixed onto `ctx` (`ctx.plugin`, `ctx.inject`). */
|
||||
registry: RegistryService
|
||||
}
|
||||
|
||||
@@ -33,12 +40,24 @@ export interface Context {
|
||||
* contexts without mutating their parent.
|
||||
*/
|
||||
export class Context {
|
||||
/** Symbol key under which a disposer exposes its {@link EffectMeta} diagnostics tree. */
|
||||
static readonly effect: unique symbol = symbols.effect
|
||||
/** Symbol key for a context's listener filter, consulted on every event dispatch. */
|
||||
static readonly filter: unique symbol = symbols.filter
|
||||
/** Symbol key of the isolation map (see the `Context[symbols.isolate]` property). */
|
||||
static readonly isolate: unique symbol = symbols.isolate
|
||||
/** Symbol key of the intercept map (see the `Context[symbols.intercept]` property). */
|
||||
static readonly intercept: unique symbol = symbols.intercept
|
||||
|
||||
/** Returns true for Cordis context proxies and context prototypes. */
|
||||
/**
|
||||
* Returns true for Cordis context proxies and context prototypes.
|
||||
*
|
||||
* Works across realms and across multiple copies of cordis, because the
|
||||
* brand is keyed by a global symbol rather than by `instanceof`.
|
||||
*
|
||||
* @param value — the value to test.
|
||||
* @returns `true` if `value` is a Cordis context, narrowing its type.
|
||||
*/
|
||||
static is(value: any): value is Context {
|
||||
return !!value?.[Context.is as any]
|
||||
}
|
||||
@@ -68,7 +87,15 @@ export class Context {
|
||||
return `Context <${this.fiber.name}>`
|
||||
}
|
||||
|
||||
/** Create a child context with extra metadata on top of the current scope. */
|
||||
/**
|
||||
* Create a child context with extra metadata on top of the current scope.
|
||||
*
|
||||
* The child prototypally inherits every property of this context; own
|
||||
* properties of `meta` shadow the inherited ones. The parent is not mutated.
|
||||
*
|
||||
* @param meta — own properties (including symbol keys) to define on the child.
|
||||
* @returns a child context inheriting from this one.
|
||||
*/
|
||||
extend(meta = {}): this {
|
||||
const shadow = Reflect.getOwnPropertyDescriptor(this, symbols.shadow)?.value
|
||||
const self = Object.create(getTraceable(this, this))
|
||||
@@ -79,14 +106,36 @@ export class Context {
|
||||
return Object.assign(Object.create(self), { [symbols.shadow]: shadow })
|
||||
}
|
||||
|
||||
/** Create a child context with an independent service scope for `name`. */
|
||||
/**
|
||||
* Create a child context with an independent service scope for `name`.
|
||||
*
|
||||
* Below the returned context, reads and writes of the service `name`
|
||||
* resolve against the new label instead of the parent's, so a different
|
||||
* implementation can be provided without affecting the parent scope.
|
||||
* Passing the same `label` to two `isolate()` calls joins their scopes.
|
||||
*
|
||||
* @param name — the service name to isolate.
|
||||
* @param label — scope label to join; defaults to a fresh unique symbol.
|
||||
* @returns a child context whose `name` service resolves in the new scope.
|
||||
*/
|
||||
isolate(name: string, label?: symbol) {
|
||||
const shadow = Object.create(this[symbols.isolate])
|
||||
shadow[name] = label ?? Symbol(name)
|
||||
return this.extend({ [symbols.isolate]: shadow })
|
||||
}
|
||||
|
||||
/** Add service-specific intercept config for plugins started below this context. */
|
||||
/**
|
||||
* Add service-specific intercept config for plugins started below this
|
||||
* context.
|
||||
*
|
||||
* Plugins loaded under the returned context see `config` merged into the
|
||||
* service's resolved config (ancestor entries first; see
|
||||
* `Service[symbols.resolveConfig]`). The parent context is not affected.
|
||||
*
|
||||
* @param name — the service name whose config to intercept.
|
||||
* @param config — the intercept config to merge for that service.
|
||||
* @returns a child context carrying the additional intercept entry.
|
||||
*/
|
||||
intercept<K extends InjectKey>(name: K, config: Context[K] extends { [symbols.config]: infer T } ? T : never): this
|
||||
intercept(name: string, config: any): this
|
||||
intercept(name: string, config: any) {
|
||||
|
||||
Vendored
+147
-9
@@ -3,7 +3,12 @@ import { Context } from './context.ts'
|
||||
import { Fiber, FiberState } from './fiber.ts'
|
||||
import { DisposableList, symbols } from './utils.ts'
|
||||
|
||||
/** Return whether an event result should stop a bail-style dispatch. */
|
||||
/**
|
||||
* Return whether an event result should stop a bail-style dispatch.
|
||||
*
|
||||
* @param value — a listener's return value.
|
||||
* @returns `true` unless `value` is `null`, `false`, or `undefined`.
|
||||
*/
|
||||
export function isBailed(value: any) {
|
||||
return value !== null && value !== false && value !== undefined
|
||||
}
|
||||
@@ -28,17 +33,75 @@ export type DispatchMode = 'emit' | 'parallel' | 'serial' | 'bail' | 'waterfall'
|
||||
declare module './context.ts' {
|
||||
export interface Context {
|
||||
/* eslint-disable max-len */
|
||||
/**
|
||||
* Dispatch an event, running all listeners concurrently.
|
||||
*
|
||||
* @param name — the event name.
|
||||
* @param args — arguments passed to every listener.
|
||||
* @returns a promise resolving once every listener has settled.
|
||||
*/
|
||||
parallel<K extends keyof Events>(name: K, ...args: Parameters<Events[K]>): Promise<void>
|
||||
/** Same as above, with an explicit `this` for listeners (also used for filtering). */
|
||||
parallel<K extends keyof Events>(thisArg: NoInfer<ThisType<Events[K]>>, name: K, ...args: Parameters<Events[K]>): Promise<void>
|
||||
/**
|
||||
* Dispatch an event synchronously, ignoring listener return values.
|
||||
*
|
||||
* @param name — the event name.
|
||||
* @param args — arguments passed to every listener.
|
||||
*/
|
||||
emit<K extends keyof Events>(name: K, ...args: Parameters<Events[K]>): void
|
||||
/** Same as above, with an explicit `this` for listeners (also used for filtering). */
|
||||
emit<K extends keyof Events>(thisArg: NoInfer<ThisType<Events[K]>>, name: K, ...args: Parameters<Events[K]>): void
|
||||
/**
|
||||
* Dispatch an event, awaiting listeners in order until one bails.
|
||||
*
|
||||
* @param name — the event name.
|
||||
* @param args — arguments passed to each listener.
|
||||
* @returns the first bail value (non-null, non-false, non-undefined), if any.
|
||||
*/
|
||||
serial<K extends keyof Events>(name: K, ...args: Parameters<Events[K]>): Promisify<ReturnType<Events[K]>>
|
||||
/** Same as above, with an explicit `this` for listeners (also used for filtering). */
|
||||
serial<K extends keyof Events>(thisArg: NoInfer<ThisType<Events[K]>>, name: K, ...args: Parameters<Events[K]>): Promisify<ReturnType<Events[K]>>
|
||||
/**
|
||||
* Dispatch an event, calling listeners in order until one bails.
|
||||
*
|
||||
* @param name — the event name.
|
||||
* @param args — arguments passed to each listener.
|
||||
* @returns the first bail value (non-null, non-false, non-undefined), if any.
|
||||
*/
|
||||
bail<K extends keyof Events>(name: K, ...args: Parameters<Events[K]>): ReturnType<Events[K]>
|
||||
/** Same as above, with an explicit `this` for listeners (also used for filtering). */
|
||||
bail<K extends keyof Events>(thisArg: NoInfer<ThisType<Events[K]>>, name: K, ...args: Parameters<Events[K]>): ReturnType<Events[K]>
|
||||
/**
|
||||
* Dispatch an event whose last argument is a `next` continuation.
|
||||
*
|
||||
* Each listener wraps the rest of the chain: calling `next()` invokes the
|
||||
* next listener (finally the built-in behavior); not calling it vetoes.
|
||||
*
|
||||
* @param name — the event name.
|
||||
* @param args — listener arguments; the final one is the innermost `next`.
|
||||
* @returns the outermost listener's return value.
|
||||
*/
|
||||
waterfall<K extends keyof Events>(name: K, ...args: Parameters<Events[K]>): ReturnType<Events[K]>
|
||||
/** Same as above, with an explicit `this` for listeners (also used for filtering). */
|
||||
waterfall<K extends keyof Events>(thisArg: NoInfer<ThisType<Events[K]>>, name: K, ...args: Parameters<Events[K]>): ReturnType<Events[K]>
|
||||
/**
|
||||
* Register an event listener owned by the current fiber.
|
||||
*
|
||||
* @param name — the event name to listen for.
|
||||
* @param listener — called with the dispatch arguments.
|
||||
* @param options — listener options; a boolean is shorthand for `prepend`.
|
||||
* @returns a disposer removing the listener; `true` if it was still registered.
|
||||
*/
|
||||
on<K extends keyof Events>(name: K, listener: Events[K], options?: boolean | EventOptions): () => boolean
|
||||
/**
|
||||
* Same as `on()`, but the listener disposes itself after its first call.
|
||||
*
|
||||
* @param name — the event name to listen for.
|
||||
* @param listener — called at most once with the dispatch arguments.
|
||||
* @param options — listener options; a boolean is shorthand for `prepend`.
|
||||
* @returns a disposer removing the listener; `true` if it was still registered.
|
||||
*/
|
||||
once<K extends keyof Events>(name: K, listener: Events[K], options?: boolean | EventOptions): () => boolean
|
||||
/* eslint-enable max-len */
|
||||
}
|
||||
@@ -91,7 +154,13 @@ export class EventsService {
|
||||
}, { global: true, prepend: true })
|
||||
}
|
||||
|
||||
/** Resolve listeners for one dispatch and apply context filtering. */
|
||||
/**
|
||||
* Resolve listeners for one dispatch and apply context filtering.
|
||||
*
|
||||
* @param type — the dispatch mode, reported on `internal/dispatch`.
|
||||
* @param args — the raw dispatch arguments; consumed up to the event name.
|
||||
* @returns the matching listener callbacks, bound to the dispatch `this`.
|
||||
*/
|
||||
dispatch(type: string, args: any[]) {
|
||||
const thisArg = typeof args[0] === 'object' || typeof args[0] === 'function' ? args.shift() : null
|
||||
const name: string = args.shift()
|
||||
@@ -104,19 +173,33 @@ export class EventsService {
|
||||
.map(hook => hook.callback.bind(thisArg))
|
||||
}
|
||||
|
||||
/** Run listeners concurrently and wait for all of them. */
|
||||
/**
|
||||
* Run listeners concurrently and wait for all of them.
|
||||
*
|
||||
* @param args — optional `this`, the event name, then listener arguments.
|
||||
* @returns a promise resolving once every listener has settled.
|
||||
*/
|
||||
async parallel(...args: any[]) {
|
||||
const results = await Promise.allSettled(this.dispatch('emit', args).map(async cb => cb(...args)))
|
||||
const errors = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
|
||||
if (errors.length) throw new AggregateError(errors.map(error => error.reason))
|
||||
}
|
||||
|
||||
/** Run listeners synchronously without waiting for returned promises. */
|
||||
/**
|
||||
* Run listeners synchronously without waiting for returned promises.
|
||||
*
|
||||
* @param args — optional `this`, the event name, then listener arguments.
|
||||
*/
|
||||
emit(...args: any[]) {
|
||||
this.dispatch('emit', args).map(cb => cb(...args))
|
||||
}
|
||||
|
||||
/** Run listeners in order until one returns a bail value. */
|
||||
/**
|
||||
* Run listeners in order, awaiting each, until one returns a bail value.
|
||||
*
|
||||
* @param args — optional `this`, the event name, then listener arguments.
|
||||
* @returns the first bail value (see {@link isBailed}), if any.
|
||||
*/
|
||||
async serial(...args: any[]) {
|
||||
for (const cb of this.dispatch('serial', args)) {
|
||||
const result = await cb(...args)
|
||||
@@ -124,7 +207,12 @@ export class EventsService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Run listeners synchronously until one returns a bail value. */
|
||||
/**
|
||||
* Run listeners synchronously until one returns a bail value.
|
||||
*
|
||||
* @param args — optional `this`, the event name, then listener arguments.
|
||||
* @returns the first bail value (see {@link isBailed}), if any.
|
||||
*/
|
||||
bail(...args: any[]) {
|
||||
for (const cb of this.dispatch('bail', args)) {
|
||||
const result = cb(...args)
|
||||
@@ -132,7 +220,16 @@ export class EventsService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Compose listeners around the final `next` callback. */
|
||||
/**
|
||||
* Compose listeners around the final `next` callback.
|
||||
*
|
||||
* The last dispatch argument is treated as the innermost `next`. Listeners
|
||||
* run outermost-first; a listener that does not call `next()` vetoes the
|
||||
* rest of the chain, including the built-in behavior.
|
||||
*
|
||||
* @param args — optional `this`, the event name, listener arguments, then `next`.
|
||||
* @returns the outermost listener's return value.
|
||||
*/
|
||||
waterfall(...args: any[]) {
|
||||
const cbs = this.dispatch('waterfall', args)
|
||||
const inner = args.pop()
|
||||
@@ -144,6 +241,15 @@ export class EventsService {
|
||||
return next()
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a listener record as an effect on the current fiber.
|
||||
*
|
||||
* @param label — effect label shown in fiber diagnostics.
|
||||
* @param hooks — the listener list for one event.
|
||||
* @param callback — the listener to store.
|
||||
* @param options — placement and filtering options.
|
||||
* @returns a disposer that unregisters the listener.
|
||||
*/
|
||||
register(label: string, hooks: Hook[], callback: any, options: EventOptions): () => void {
|
||||
const method = options.prepend ? 'unshift' : 'push'
|
||||
return this.ctx.fiber.effect(() => {
|
||||
@@ -152,6 +258,13 @@ export class EventsService {
|
||||
}, label)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a stored listener record.
|
||||
*
|
||||
* @param hooks — the listener list for one event.
|
||||
* @param callback — the listener to remove.
|
||||
* @returns `true` if the listener was found and removed.
|
||||
*/
|
||||
unregister(hooks: Hook[], callback: any) {
|
||||
const index = hooks.findIndex(hook => hook.callback === callback)
|
||||
if (index >= 0) {
|
||||
@@ -160,7 +273,17 @@ export class EventsService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Register an event listener owned by the current fiber. */
|
||||
/**
|
||||
* Register an event listener owned by the current fiber.
|
||||
*
|
||||
* The listener is removed automatically when the fiber unloads. Throws
|
||||
* `CordisError('INACTIVE_EFFECT')` if the fiber is already disposed.
|
||||
*
|
||||
* @param name — the event name to listen for.
|
||||
* @param listener — called with the dispatch arguments.
|
||||
* @param options — listener options; a boolean is shorthand for `prepend`.
|
||||
* @returns a disposer removing the listener; `true` if it was still registered.
|
||||
*/
|
||||
on(name: string | symbol, listener: (...args: any) => any, options?: boolean | EventOptions) {
|
||||
if (typeof options !== 'object') {
|
||||
options = { prepend: options }
|
||||
@@ -177,7 +300,14 @@ export class EventsService {
|
||||
return this.register(label, hooks, listener, options)
|
||||
}
|
||||
|
||||
/** Register an event listener that disposes itself after the first call. */
|
||||
/**
|
||||
* Register an event listener that disposes itself after the first call.
|
||||
*
|
||||
* @param name — the event name to listen for.
|
||||
* @param listener — called at most once with the dispatch arguments.
|
||||
* @param options — listener options; a boolean is shorthand for `prepend`.
|
||||
* @returns a disposer removing the listener; `true` if it was still registered.
|
||||
*/
|
||||
once(name: string, listener: (...args: any) => any, options?: boolean | EventOptions) {
|
||||
const dispose = this.on(name, function (...args: any[]) {
|
||||
dispose()
|
||||
@@ -196,12 +326,20 @@ export class EventsService {
|
||||
* diagnostics before public events are delivered.
|
||||
*/
|
||||
export interface Events {
|
||||
/** A plugin fiber was created or its uid was cleared on disposal. */
|
||||
'internal/plugin'(fiber: Fiber): void
|
||||
/** A fiber changed lifecycle state; receives the fiber and its previous state. */
|
||||
'internal/status'(fiber: Fiber, oldValue: FiberState): void
|
||||
/** Interception hook for a service binding (no core producer). */
|
||||
'internal/service'(this: Context, name: string, value: any): void
|
||||
/** Waterfall: a fiber config update is being applied; skip `next()` to veto. */
|
||||
'internal/update'(this: Fiber, config: any, noSave: boolean, next: () => void): void
|
||||
/** Waterfall: a service is being read through the context proxy. */
|
||||
'internal/get'(ctx: Context, name: string, error: Error, next: () => any): any
|
||||
/** Waterfall: a service is being written through the context proxy. */
|
||||
'internal/set'(ctx: Context, name: string, value: any, error: Error, next: () => boolean): boolean
|
||||
/** Bail: a listener is being registered; a non-null result replaces registration. */
|
||||
'internal/listener'(this: Context, name: string, listener: any, prepend: boolean): void
|
||||
/** An event is being dispatched to listeners (fired for non-internal events only). */
|
||||
'internal/dispatch'(mode: DispatchMode, name: string, args: any[], thisArg: any): void
|
||||
}
|
||||
Vendored
+107
-10
@@ -7,6 +7,7 @@ import { StandardSchemaV1 } from '@standard-schema/spec'
|
||||
|
||||
declare module './context.ts' {
|
||||
export interface Context extends Pick<Fiber, 'effect'> {
|
||||
/** The fiber (plugin runtime instance) that owns this context. */
|
||||
fiber: Fiber
|
||||
}
|
||||
}
|
||||
@@ -17,6 +18,11 @@ const kValidationError = Symbol.for('ValidationError')
|
||||
export class ValidationError extends TypeError {
|
||||
name = 'ValidationError'
|
||||
|
||||
/**
|
||||
* Build the aggregated message from schema issues.
|
||||
*
|
||||
* @param issues — the standard-schema issues, one message line each.
|
||||
*/
|
||||
constructor(issues: readonly StandardSchemaV1.Issue[]) {
|
||||
super(`invalid config:\n` + issues.map(issue => {
|
||||
if (issue.path) {
|
||||
@@ -32,7 +38,14 @@ Object.defineProperty(ValidationError.prototype, kValidationError, {
|
||||
value: true,
|
||||
})
|
||||
|
||||
/** Validate and normalize config for a plugin runtime before it starts. */
|
||||
/**
|
||||
* Validate and normalize config for a plugin runtime before it starts.
|
||||
*
|
||||
* @param runtime — the plugin runtime whose `Config` schema to apply.
|
||||
* @param config — the raw user config.
|
||||
* @returns the validated config, or `config` unchanged if the runtime has no schema.
|
||||
* @throws {ValidationError} when validation reports issues.
|
||||
*/
|
||||
export function resolveConfig(runtime: Plugin.Runtime, config: any) {
|
||||
if (!runtime.Config) return config
|
||||
// TODO: async validation
|
||||
@@ -51,10 +64,21 @@ interface AsyncDisposable<T extends Awaitable<void> = Awaitable<void>> extends P
|
||||
(): T
|
||||
}
|
||||
|
||||
/** Function returned by an effect to release resources during disposal. */
|
||||
/**
|
||||
* Function returned by an effect to release resources during disposal.
|
||||
*
|
||||
* Disposers run in reverse registration order when the owning fiber unloads;
|
||||
* they may be async, in which case unloading awaits them.
|
||||
*/
|
||||
export type Disposable<T = any> = () => T
|
||||
|
||||
/** Effect body result accepted by `ctx.effect()` and plugin startup. */
|
||||
/**
|
||||
* Effect body result accepted by `ctx.effect()` and plugin startup.
|
||||
*
|
||||
* Either a single disposer, a promise of one, or a (possibly async) iterable
|
||||
* yielding several — generator effects register each yielded disposer as it
|
||||
* is produced.
|
||||
*/
|
||||
export type Effect<T = any> =
|
||||
| SyncEffect<T>
|
||||
| AsyncEffect<T>
|
||||
@@ -69,7 +93,9 @@ type AsyncEffect<T = any> =
|
||||
|
||||
/** Tree node used to expose nested effect labels for diagnostics. */
|
||||
export interface EffectMeta {
|
||||
/** Human-readable effect label, e.g. `ctx.on("event")` or `ctx.provide("name")`. */
|
||||
label: string
|
||||
/** Metadata of nested effects registered while this effect ran. */
|
||||
children: EffectMeta[]
|
||||
}
|
||||
|
||||
@@ -109,7 +135,14 @@ function emitPluginDisposed(context: Context, fiber: Fiber) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Lifecycle state for one plugin fiber. */
|
||||
/**
|
||||
* Lifecycle state for one plugin fiber.
|
||||
*
|
||||
* `PENDING` — waiting for required services; `LOADING` — the plugin callback
|
||||
* is running; `ACTIVE` — loaded and providing; `FAILED` — the callback or its
|
||||
* config threw; `UNLOADING` — disposers are running; `DISPOSED` — the fiber
|
||||
* was removed and cannot restart.
|
||||
*/
|
||||
export const enum FiberState {
|
||||
PENDING,
|
||||
LOADING,
|
||||
@@ -121,6 +154,10 @@ export const enum FiberState {
|
||||
|
||||
/** Framework error with a stable machine-readable code. */
|
||||
export class CordisError extends Error {
|
||||
/**
|
||||
* @param code — the stable error code; also the default message.
|
||||
* @param message — optional human-readable override.
|
||||
*/
|
||||
constructor(public code: CordisError.Code, message?: string) {
|
||||
super(message ?? CordisError.Code[code])
|
||||
}
|
||||
@@ -144,12 +181,19 @@ const INACTIVE = '__INACTIVE__'
|
||||
* cleanup for the plugin context returned by `ctx.plugin()`.
|
||||
*/
|
||||
export class Fiber {
|
||||
/** Unique id within the registry; 0 for the root fiber, `null` once disposed. */
|
||||
public uid: number | null
|
||||
/** The context this fiber's plugin runs in (extends the parent context). */
|
||||
public readonly ctx: Context
|
||||
/** The validated plugin config (updated by `update()`). */
|
||||
public config: any
|
||||
/** Current lifecycle state; transitions emit `internal/status`. */
|
||||
public state = FiberState.PENDING
|
||||
/** Dispose this fiber: unload the plugin, then settle once cleanup finished. */
|
||||
public readonly dispose: () => Promise<void>
|
||||
/** Snapshot of required service implementations while loaded; `undefined` otherwise. */
|
||||
public store: Dict<Impl> | undefined
|
||||
/** The in-flight load/unload transition, if one is currently running. */
|
||||
public inertia: Promise<void> | undefined
|
||||
|
||||
public readonly _hooks: Dict<DisposableList<Function>> = Object.create(null)
|
||||
@@ -162,6 +206,16 @@ export class Fiber {
|
||||
private _runner: EffectRunner<string>
|
||||
private _store: Dict<Impl> = Object.create(null)
|
||||
|
||||
/**
|
||||
* Create a fiber. Plugin authors normally obtain fibers from `ctx.plugin()`
|
||||
* rather than constructing them directly.
|
||||
*
|
||||
* @param parent — the context the plugin was loaded from.
|
||||
* @param config — raw config, validated against the runtime's schema.
|
||||
* @param inject — resolved dependency map (service name → intercept config).
|
||||
* @param runtime — the shared plugin runtime, or `null` for the root fiber.
|
||||
* @param getOuterStack — captures the caller stack for effect diagnostics.
|
||||
*/
|
||||
constructor(
|
||||
public parent: Context,
|
||||
config: any,
|
||||
@@ -282,6 +336,7 @@ export class Fiber {
|
||||
}
|
||||
}
|
||||
|
||||
/** The plugin's display name, inherited from the nearest named ancestor, else `'root'`. */
|
||||
get name() {
|
||||
let fiber: Fiber = this
|
||||
do {
|
||||
@@ -291,7 +346,12 @@ export class Fiber {
|
||||
return 'root'
|
||||
}
|
||||
|
||||
/** Throw if the fiber has already been disposed. */
|
||||
/**
|
||||
* Throw if the fiber has already been disposed.
|
||||
*
|
||||
* @returns nothing when the fiber is still active.
|
||||
* @throws {CordisError} `INACTIVE_EFFECT` when the fiber's uid has been cleared.
|
||||
*/
|
||||
assertActive() {
|
||||
if (this.uid !== null) return
|
||||
throw new CordisError('INACTIVE_EFFECT')
|
||||
@@ -343,8 +403,21 @@ export class Fiber {
|
||||
}, runner.getOuterStack)
|
||||
}
|
||||
|
||||
/** Register a cleanup-aware effect on this fiber. */
|
||||
/**
|
||||
* Register a cleanup-aware effect on this fiber.
|
||||
*
|
||||
* `execute` runs immediately; the disposers it produces are collected and
|
||||
* run (in reverse order) either when the returned disposer is called or
|
||||
* when the fiber unloads, whichever comes first. Calling the disposer twice
|
||||
* is a no-op. Throws `CordisError('INACTIVE_EFFECT')` if the fiber is
|
||||
* already disposed, and `TypeError` if `execute` returns an invalid shape.
|
||||
*
|
||||
* @param execute — the effect body; see {@link Effect} for accepted shapes.
|
||||
* @param label — effect label shown in `getEffects()` diagnostics.
|
||||
* @returns a disposer that tears the effect down and settles once done.
|
||||
*/
|
||||
effect(execute: () => SyncEffect, label?: string): Disposable<Promise<void>>
|
||||
/** Same as above for async effects; the disposer is also awaitable. */
|
||||
effect(execute: () => Effect, label?: string): AsyncDisposable<Promise<void>>
|
||||
effect(execute: () => Effect, label = 'anonymous'): any {
|
||||
this.assertActive()
|
||||
@@ -491,7 +564,11 @@ export class Fiber {
|
||||
return wrapper
|
||||
}
|
||||
|
||||
/** Return metadata for currently registered effects. */
|
||||
/**
|
||||
* Return metadata for currently registered effects.
|
||||
*
|
||||
* @returns one {@link EffectMeta} tree per labeled live effect.
|
||||
*/
|
||||
getEffects() {
|
||||
return [...this._disposables]
|
||||
.map<EffectMeta>(dispose => dispose[symbols.effect])
|
||||
@@ -615,7 +692,12 @@ export class Fiber {
|
||||
})
|
||||
}
|
||||
|
||||
/** Wait for current lifecycle work and rethrow startup errors. */
|
||||
/**
|
||||
* Wait for current lifecycle work and rethrow startup errors.
|
||||
*
|
||||
* @returns this fiber, once it has settled into a stable state.
|
||||
* @throws the config-validation or plugin-startup error, if any.
|
||||
*/
|
||||
async await() {
|
||||
while (this.inertia) {
|
||||
await this.inertia
|
||||
@@ -624,7 +706,12 @@ export class Fiber {
|
||||
return this
|
||||
}
|
||||
|
||||
/** Dispose and immediately reload this plugin with its current config. */
|
||||
/**
|
||||
* Dispose and immediately reload this plugin with its current config.
|
||||
*
|
||||
* @returns a promise resolving once the reload settled.
|
||||
* @throws {CordisError} `INACTIVE_EFFECT` when the fiber is already disposed.
|
||||
*/
|
||||
async restart() {
|
||||
this.assertActive()
|
||||
this._setEpoch(INACTIVE)
|
||||
@@ -632,7 +719,17 @@ export class Fiber {
|
||||
await this.await()
|
||||
}
|
||||
|
||||
/** Validate and apply new config, then restart the plugin. */
|
||||
/**
|
||||
* Validate and apply new config, then restart the plugin.
|
||||
*
|
||||
* Runs the `internal/update` waterfall first, so update hooks (and HMR)
|
||||
* can veto or replace the restart.
|
||||
*
|
||||
* @param config — the new raw config; validated before anything restarts.
|
||||
* @param noSave — hint for persistence hooks not to write the change back.
|
||||
* @returns nothing; the restart runs behind the `internal/update` waterfall.
|
||||
* @throws {ValidationError} when the new config fails validation.
|
||||
*/
|
||||
update(config: any, noSave = false) {
|
||||
this.assertActive()
|
||||
config = resolveConfig(this.runtime!, config)
|
||||
|
||||
Vendored
+9
-1
@@ -62,8 +62,11 @@ export const defaultFormatters: Record<string, Formatter> = {
|
||||
|
||||
/** Options used when creating a named logger facade. */
|
||||
export interface LoggerOptions {
|
||||
/** The logger name shown with each message. */
|
||||
name: string
|
||||
/** Message fields merged into every record from this logger. */
|
||||
meta?: Partial<Message>
|
||||
/** Default maximum level exported when an exporter has no own threshold. */
|
||||
level?: number
|
||||
}
|
||||
|
||||
@@ -220,7 +223,12 @@ export class LoggerService {
|
||||
return self
|
||||
}
|
||||
|
||||
/** Register an exporter and dispose it with the current fiber. */
|
||||
/**
|
||||
* Register an exporter and dispose it with the current fiber.
|
||||
*
|
||||
* @param exporter — the sink that receives structured log messages.
|
||||
* @returns a disposer that removes the exporter.
|
||||
*/
|
||||
exporter(exporter: Exporter) {
|
||||
return this.ctx.effect(() => {
|
||||
this.exporters.set(++this._snExporter, exporter)
|
||||
|
||||
Vendored
+125
@@ -5,14 +5,66 @@ import { Fiber, FiberState } from './fiber.ts'
|
||||
|
||||
declare module './context.ts' {
|
||||
interface Context {
|
||||
/**
|
||||
* Read a service from the store without the inject requirement.
|
||||
*
|
||||
* @param name — the service name.
|
||||
* @param strict — when `true` (default), only return implementations
|
||||
* whose providing fiber is currently active.
|
||||
* @returns the service value, or `undefined` when not (yet) provided.
|
||||
*/
|
||||
get<K extends string & keyof this>(name: K, strict?: boolean): undefined | this[K]
|
||||
/** Same as above for service names outside the typed `Context` surface. */
|
||||
get(name: string, strict?: boolean): any
|
||||
/**
|
||||
* Overwrite a provided service's value.
|
||||
*
|
||||
* Only the fiber that provided the service may set it; setting an
|
||||
* unprovided name throws.
|
||||
*
|
||||
* @param name — the service name.
|
||||
* @param value — the new service value.
|
||||
*/
|
||||
set<K extends string & keyof this>(name: K, value: undefined | this[K]): void
|
||||
/** Same as above for service names outside the typed `Context` surface. */
|
||||
set(name: string, value: any): void
|
||||
/**
|
||||
* Register a service implementation owned by the current fiber.
|
||||
*
|
||||
* The service becomes visible to dependents in the same isolation scope
|
||||
* once the fiber is active; it is unregistered (waking dependents) when
|
||||
* the returned disposer runs or the fiber unloads. Throws if the name is
|
||||
* already provided in this scope or declared as an accessor.
|
||||
*
|
||||
* @param name — the service name.
|
||||
* @param value — the service value.
|
||||
* @returns a disposer that unregisters the service.
|
||||
*/
|
||||
provide<K extends string & keyof this>(name: K, value: undefined | this[K]): () => void
|
||||
/** Same as above for service names outside the typed `Context` surface. */
|
||||
provide(name: string, value?: any): () => void
|
||||
/**
|
||||
* Define a computed context property backed by get/set hooks.
|
||||
*
|
||||
* The accessor is removed when the current fiber unloads. Throws if the
|
||||
* name is already declared.
|
||||
*
|
||||
* @param name — the context property name.
|
||||
* @param options — the `get` hook and optional `set` hook.
|
||||
*/
|
||||
accessor(name: string, options: Omit<Property.Accessor, 'type'>): void
|
||||
/**
|
||||
* Expose selected members of a service directly on `ctx`.
|
||||
*
|
||||
* Each mixed-in key becomes an accessor that forwards to the service
|
||||
* (binding methods to it), so e.g. `ctx.on` forwards to `ctx.events.on`.
|
||||
* Mixins are removed when the current fiber unloads.
|
||||
*
|
||||
* @param name — the context property holding the source service.
|
||||
* @param mixins — keys to forward, or a source-key → ctx-key map.
|
||||
*/
|
||||
mixin<K extends string & keyof this>(name: K, mixins: (keyof this & keyof this[K])[] | Dict<string>): void
|
||||
/** Same as above with a source object instead of a context property name. */
|
||||
mixin<T extends {}>(source: T, mixins: (keyof this & keyof T)[] | Dict<string>): void
|
||||
}
|
||||
}
|
||||
@@ -44,22 +96,30 @@ export type Property = Property.Service | Property.Accessor
|
||||
export namespace Property {
|
||||
/** Service property backed by a provided implementation. */
|
||||
export interface Service {
|
||||
/** Discriminator. */
|
||||
type: 'service'
|
||||
}
|
||||
|
||||
/** Computed context property backed by custom get/set hooks. */
|
||||
export interface Accessor {
|
||||
/** Discriminator. */
|
||||
type: 'accessor'
|
||||
/** Compute the property value; `error` carries the caller stack for diagnostics. */
|
||||
get: (this: Context, receiver: any, error: Error) => any
|
||||
/** Optional setter; return `false` to reject the write. */
|
||||
set?: (this: Context, value: any, receiver: any, error: Error) => boolean
|
||||
}
|
||||
}
|
||||
|
||||
/** Concrete service implementation record stored in the root reflect service. */
|
||||
export interface Impl {
|
||||
/** The service name. */
|
||||
name: string
|
||||
/** The fiber that provided the service (owns its lifetime). */
|
||||
fiber: Fiber
|
||||
/** The current service value. */
|
||||
value?: any
|
||||
/** Optional availability predicate consulted before dependents may load. */
|
||||
check?: () => boolean
|
||||
}
|
||||
|
||||
@@ -70,6 +130,7 @@ export interface Impl {
|
||||
* the mixins that expose core service methods directly on `ctx`.
|
||||
*/
|
||||
export class ReflectService {
|
||||
/** Proxy traps implementing service resolution for every context object. */
|
||||
static handler: ProxyHandler<Context> = {
|
||||
get: (target, prop, ctx: Context) => {
|
||||
if (isSpecialProperty(prop)) {
|
||||
@@ -143,7 +204,9 @@ export class ReflectService {
|
||||
},
|
||||
}
|
||||
|
||||
/** Service implementations, keyed by isolation label. */
|
||||
public store: Dict<Impl, symbol> = Object.create(null)
|
||||
/** Declared context properties (services and accessors), by name. */
|
||||
public props: Dict<Property> = Object.create(null)
|
||||
|
||||
constructor(public ctx: Context) {
|
||||
@@ -158,6 +221,14 @@ export class ReflectService {
|
||||
this.mixin('events', ['on', 'once', 'parallel', 'emit', 'serial', 'bail', 'waterfall'])
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a service from the store without the inject requirement.
|
||||
*
|
||||
* @param name — the service name.
|
||||
* @param strict — when `true`, only return implementations whose providing
|
||||
* fiber is currently active.
|
||||
* @returns the service value, or `undefined` when not (yet) provided.
|
||||
*/
|
||||
get(name: string, strict = true) {
|
||||
return getTraceable(this.ctx, this._getImpl(name, strict)?.value)
|
||||
}
|
||||
@@ -170,6 +241,15 @@ export class ReflectService {
|
||||
return impl
|
||||
}
|
||||
|
||||
/**
|
||||
* Overwrite a provided service's value.
|
||||
*
|
||||
* @param name — the service name.
|
||||
* @param value — the new service value.
|
||||
* @param error — carrier for the caller stack in diagnostics.
|
||||
* @returns `true` on success.
|
||||
* @throws when `name` was never provided, or was provided by another fiber.
|
||||
*/
|
||||
set(name: string, value: any, error?: Error) {
|
||||
const key = this.ctx[symbols.isolate][name]
|
||||
const impl = this.store[key]
|
||||
@@ -183,6 +263,16 @@ export class ReflectService {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a service implementation owned by the current fiber.
|
||||
*
|
||||
* See the `ctx.provide()` overload above for the full contract.
|
||||
*
|
||||
* @param name — the service name.
|
||||
* @param value — the service value.
|
||||
* @param check — optional availability predicate for dependents.
|
||||
* @returns a disposer that unregisters the service.
|
||||
*/
|
||||
provide(name: string, value?: any, check?: () => boolean) {
|
||||
return this.ctx.fiber.effect(() => {
|
||||
if (!this.props[name]) {
|
||||
@@ -213,6 +303,13 @@ export class ReflectService {
|
||||
}, `ctx.provide(${JSON.stringify(name)})`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-evaluate every fiber that requires one of the given services.
|
||||
*
|
||||
* @param names — the service names that changed.
|
||||
* @param filter — restricts notification to matching isolation scopes.
|
||||
* @returns the fibers whose dependency state was refreshed.
|
||||
*/
|
||||
notify(names: string[], filter = (ctx: Context, name: string) => ctx[symbols.isolate][name] === this.ctx[symbols.isolate][name]) {
|
||||
const fibers: Fiber[] = []
|
||||
for (const runtime of this.ctx.registry.values()) {
|
||||
@@ -237,6 +334,13 @@ export class ReflectService {
|
||||
return fibers
|
||||
}
|
||||
|
||||
/**
|
||||
* Define a computed context property backed by get/set hooks.
|
||||
*
|
||||
* @param name — the context property name.
|
||||
* @param options — the `get` hook and optional `set` hook.
|
||||
* @returns a disposer that removes the accessor.
|
||||
*/
|
||||
accessor(name: string, options: Omit<Property.Accessor, 'type'>) {
|
||||
return this.ctx.fiber.effect(() => {
|
||||
if (name in this.props) {
|
||||
@@ -247,6 +351,15 @@ export class ReflectService {
|
||||
}, `ctx.accessor(${JSON.stringify(name)})`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Expose selected members of a service directly on `ctx`.
|
||||
*
|
||||
* See the `ctx.mixin()` overload above for the full contract.
|
||||
*
|
||||
* @param source — a context property name or a source object.
|
||||
* @param mixins — keys to forward, or a source-key → ctx-key map.
|
||||
* @returns a disposer that removes all created accessors.
|
||||
*/
|
||||
mixin(source: any, mixins: string[] | Dict<string>) {
|
||||
const self = this
|
||||
return this.ctx.fiber.effect(function* () {
|
||||
@@ -275,10 +388,22 @@ export class ReflectService {
|
||||
}, `ctx.mixin(${JSON.stringify(source)})`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach this context's tracing wrapper to a value.
|
||||
*
|
||||
* @param value — the value to wrap.
|
||||
* @returns the traceable wrapper (or the value itself when not applicable).
|
||||
*/
|
||||
trace<T>(value: T) {
|
||||
return getTraceable(this.ctx, value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a callback so calls trace `this` and arguments to this context.
|
||||
*
|
||||
* @param callback — the function to wrap.
|
||||
* @returns a proxy delegating to `callback` with traced values.
|
||||
*/
|
||||
bind<T extends Function>(callback: T) {
|
||||
return new Proxy(callback, {
|
||||
apply: (target, thisArg, args) => {
|
||||
|
||||
Vendored
+92
-5
@@ -28,6 +28,11 @@ export type InjectKey = keyof {
|
||||
* On classes it contributes to the plugin's static `inject` map. On methods it
|
||||
* delays the method call until the declared services are available.
|
||||
*/
|
||||
/**
|
||||
* @param name — the required service name.
|
||||
* @param config — optional intercept config applied for that service.
|
||||
* @returns the class or method decorator.
|
||||
*/
|
||||
export function Inject<K extends InjectKey>(name: K, config?: Context[K] extends { [symbols.config]: infer T } ? T : never) {
|
||||
return function (value: any, decorator: ClassDecoratorContext<any> | ClassMethodDecoratorContext<any>) {
|
||||
if (decorator.kind === 'class') {
|
||||
@@ -55,7 +60,13 @@ export function Inject<K extends InjectKey>(name: K, config?: Context[K] extends
|
||||
|
||||
/** Utilities for normalizing plugin dependency declarations. */
|
||||
export namespace Inject {
|
||||
/** Convert array/object/class-inherited inject metadata into a plain map. */
|
||||
/**
|
||||
* Convert array/object/class-inherited inject metadata into a plain map.
|
||||
*
|
||||
* @param inject — the declaration to normalize; `null`/`undefined` add nothing.
|
||||
* @param result — the map to fill (service name → intercept config or `null`).
|
||||
* @returns `result`.
|
||||
*/
|
||||
export function resolve(inject: Inject | null | undefined, result: Dict = Object.create(null)) {
|
||||
if (!inject) return result
|
||||
if (Array.isArray(inject)) {
|
||||
@@ -86,10 +97,15 @@ export type Plugin<T = any> =
|
||||
export namespace Plugin {
|
||||
/** Shared metadata understood by the plugin registry and related tooling. */
|
||||
export interface Base<T = any> {
|
||||
/** Display name used for fiber diagnostics and logger names. */
|
||||
name?: string
|
||||
/** Standard-schema validator applied to config before the plugin starts. */
|
||||
Config?: StandardSchemaV1<any, T>
|
||||
/** Services the plugin requires; it only loads while all are available. */
|
||||
inject?: Inject
|
||||
/** Service name(s) the plugin provides (read by `Service` and by loaders). */
|
||||
provide?: string | string[]
|
||||
/** Service names whose intercept config the plugin declares it consumes. */
|
||||
intercept?: Dict<boolean>
|
||||
}
|
||||
|
||||
@@ -117,9 +133,13 @@ export namespace Plugin {
|
||||
|
||||
/** Mutable registry record shared by all fibers of one plugin callback. */
|
||||
export interface Runtime {
|
||||
/** Display name copied from the first registered plugin shape. */
|
||||
name?: string
|
||||
/** Every live fiber of this plugin (one per `ctx.plugin()` call). */
|
||||
fibers: DisposableList<Fiber>
|
||||
/** The executable entrypoint all fibers share (registry identity key). */
|
||||
callback: globalThis.Function
|
||||
/** Standard-schema validator applied to each fiber's config. */
|
||||
Config?: StandardSchemaV1
|
||||
}
|
||||
}
|
||||
@@ -142,7 +162,25 @@ type GetPluginConfig<P> =
|
||||
|
||||
declare module './context.ts' {
|
||||
export interface Context {
|
||||
/**
|
||||
* Run a callback once the requested services are available.
|
||||
*
|
||||
* Shorthand for `ctx.plugin({ inject, apply: callback })`: the callback
|
||||
* is unloaded and re-run whenever a required service changes.
|
||||
*
|
||||
* @param deps — required services, as an array or a name → config map.
|
||||
* @param callback — plugin body called with `(ctx, config)`.
|
||||
* @returns the fiber; awaiting it settles once loading finished.
|
||||
*/
|
||||
inject(deps: Inject, callback: Plugin.Function<void>): Fiber & PromiseLike<Fiber>
|
||||
/**
|
||||
* Load a plugin in the current context.
|
||||
*
|
||||
* @param plugin — a function, class, or `{ apply }` object plugin.
|
||||
* @param args — the plugin config, validated against its `Config` schema.
|
||||
* @returns the fiber; awaiting it settles once loading finished
|
||||
* (rejecting on config or startup errors).
|
||||
*/
|
||||
plugin<P extends Plugin>(plugin: P, ...args: Spread<GetPluginConfig<P>>): Fiber & PromiseLike<Fiber>
|
||||
}
|
||||
}
|
||||
@@ -164,15 +202,22 @@ export class RegistryService {
|
||||
})
|
||||
}
|
||||
|
||||
/** Allocate the next fiber uid (increments on every read). */
|
||||
get counter() {
|
||||
return ++this._counter
|
||||
}
|
||||
|
||||
/** Number of registered plugin runtimes. */
|
||||
get size() {
|
||||
return this._internal.size
|
||||
}
|
||||
|
||||
/** Resolve a supported plugin shape to its executable callback. */
|
||||
/**
|
||||
* Resolve a supported plugin shape to its executable callback.
|
||||
*
|
||||
* @param plugin — a function, class, or `{ apply }` object plugin.
|
||||
* @returns the callback identifying the plugin, or `undefined` if invalid.
|
||||
*/
|
||||
resolve(plugin: Plugin): Function | undefined {
|
||||
// plugin.apply may throw
|
||||
try {
|
||||
@@ -181,17 +226,34 @@ export class RegistryService {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up the runtime record for a plugin.
|
||||
*
|
||||
* @param plugin — any supported plugin shape.
|
||||
* @returns the runtime, or `undefined` when the plugin is not registered.
|
||||
*/
|
||||
get(plugin: Plugin) {
|
||||
const key = this.resolve(plugin)
|
||||
return key && this._internal.get(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a plugin has a registered runtime.
|
||||
*
|
||||
* @param plugin — any supported plugin shape.
|
||||
* @returns `true` when at least one fiber of the plugin exists.
|
||||
*/
|
||||
has(plugin: Plugin) {
|
||||
const key = this.resolve(plugin)
|
||||
return !!key && this._internal.has(key)
|
||||
}
|
||||
|
||||
/** Dispose every running fiber for a plugin and remove its runtime record. */
|
||||
/**
|
||||
* Dispose every running fiber for a plugin and remove its runtime record.
|
||||
*
|
||||
* @param plugin — any supported plugin shape.
|
||||
* @returns the removed runtime, or `undefined` when none was registered.
|
||||
*/
|
||||
delete(plugin: Plugin) {
|
||||
const key = this.resolve(plugin)
|
||||
const runtime = key && this._internal.get(key)
|
||||
@@ -203,28 +265,53 @@ export class RegistryService {
|
||||
return runtime
|
||||
}
|
||||
|
||||
/** Iterate the registered plugin callbacks. */
|
||||
keys() {
|
||||
return this._internal.keys()
|
||||
}
|
||||
|
||||
/** Iterate the registered plugin runtimes. */
|
||||
values() {
|
||||
return this._internal.values()
|
||||
}
|
||||
|
||||
/** Iterate `[callback, runtime]` pairs. */
|
||||
entries() {
|
||||
return this._internal.entries()
|
||||
}
|
||||
|
||||
/**
|
||||
* Visit every registered runtime.
|
||||
*
|
||||
* @param callback — receives each runtime and its identifying callback.
|
||||
*/
|
||||
forEach(callback: (value: Plugin.Runtime, key: Function) => void) {
|
||||
return this._internal.forEach(callback)
|
||||
}
|
||||
|
||||
/** Start a callback once the requested dependencies are available. */
|
||||
/**
|
||||
* Start a callback once the requested dependencies are available.
|
||||
*
|
||||
* @param inject — required services, as an array or a name → config map.
|
||||
* @param callback — plugin body called with `(ctx, config)`.
|
||||
* @returns the fiber; awaiting it settles once loading finished.
|
||||
*/
|
||||
inject(inject: Inject, callback: Plugin.Function<void>) {
|
||||
return this.plugin({ inject, apply: callback, name: callback.name })
|
||||
}
|
||||
|
||||
/** Start a plugin in the current context and return its fiber. */
|
||||
/**
|
||||
* Start a plugin in the current context and return its fiber.
|
||||
*
|
||||
* Creates (or reuses) the plugin's runtime record, then starts a new fiber
|
||||
* under the current context. Throws if `plugin` is not a supported shape or
|
||||
* if the current fiber is already disposed.
|
||||
*
|
||||
* @param plugin — a function, class, or `{ apply }` object plugin.
|
||||
* @param config — the plugin config, validated against its `Config` schema.
|
||||
* @param getOuterStack — captures the caller stack for effect diagnostics.
|
||||
* @returns the fiber; awaiting it settles once loading finished.
|
||||
*/
|
||||
plugin(plugin: Plugin, config?: any, getOuterStack = buildOuterStack()) {
|
||||
// check if it's a valid plugin
|
||||
const callback = this.resolve(plugin)
|
||||
|
||||
Vendored
+29
-2
@@ -9,19 +9,36 @@ import { createCallable, joinPrototype, symbols, Tracker } from './utils.ts'
|
||||
* registered immediately and is automatically removed with the owning fiber.
|
||||
*/
|
||||
export abstract class Service<out T = never> {
|
||||
/** Symbol key of an instance method run after construction (class plugins). */
|
||||
static readonly init: unique symbol = symbols.init
|
||||
/** Symbol key of the availability predicate passed to `ctx.provide()`. */
|
||||
static readonly check: unique symbol = symbols.check
|
||||
/** Symbol key of the phantom intercept-config type parameter. */
|
||||
static readonly config: unique symbol = symbols.config
|
||||
/** Symbol key of the call body making a service callable (e.g. `ctx.logger()`). */
|
||||
static readonly invoke: unique symbol = symbols.invoke
|
||||
/** Symbol key of the helper deriving an extended service instance. */
|
||||
static readonly extend: unique symbol = symbols.extend
|
||||
/** Symbol key of the tracker metadata used for context tracing. */
|
||||
static readonly tracker: unique symbol = symbols.tracker
|
||||
/** Symbol key of the intercept-config resolution helper below. */
|
||||
static readonly resolveConfig: unique symbol = symbols.resolveConfig
|
||||
|
||||
declare [symbols.config]: T
|
||||
|
||||
/** The service name this instance is registered under. */
|
||||
public name!: string
|
||||
|
||||
/** Register this instance as `name` in the current context. */
|
||||
/**
|
||||
* Register this instance as `name` in the current context.
|
||||
*
|
||||
* Calls `ctx.reflect.provide(name, this, this[Service.check])`, so the
|
||||
* service is unregistered automatically when the owning fiber unloads.
|
||||
* Services with a `[Service.invoke]` body return a callable instance.
|
||||
*
|
||||
* @param ctx — the context to register in (stored as `this.ctx`).
|
||||
* @param name — the service name; defaults to the static `provide` field.
|
||||
*/
|
||||
constructor(protected ctx: Context, name: string) {
|
||||
name ??= this.constructor['provide'] as string
|
||||
|
||||
@@ -55,7 +72,17 @@ export abstract class Service<out T = never> {
|
||||
return Object.assign(self, props)
|
||||
}
|
||||
|
||||
/** Merge intercept config from ancestors with optional base and head values. */
|
||||
/**
|
||||
* Merge intercept config from ancestors with optional base and head values.
|
||||
*
|
||||
* Entries added closer to the root apply first; `base` is prepended and
|
||||
* `head` appended. Uses `Config.merge` when the service declares one,
|
||||
* otherwise a shallow `Object.assign`.
|
||||
*
|
||||
* @param base — lowest-precedence config merged before all intercepts.
|
||||
* @param head — highest-precedence config merged after all intercepts.
|
||||
* @returns the merged config.
|
||||
*/
|
||||
[symbols.resolveConfig](base?: T, head?: T): T {
|
||||
let intercept = this.ctx[Context.intercept]
|
||||
const configs: any[] = []
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
.vitepress/dist/
|
||||
.vitepress/cache/
|
||||
@@ -0,0 +1,122 @@
|
||||
{
|
||||
"cordis": [
|
||||
{
|
||||
"text": "Context",
|
||||
"link": "/zh-CN/api/cordis/context"
|
||||
},
|
||||
{
|
||||
"text": "Events",
|
||||
"link": "/zh-CN/api/cordis/events"
|
||||
},
|
||||
{
|
||||
"text": "Fiber",
|
||||
"link": "/zh-CN/api/cordis/fiber"
|
||||
},
|
||||
{
|
||||
"text": "Registry",
|
||||
"link": "/zh-CN/api/cordis/registry"
|
||||
},
|
||||
{
|
||||
"text": "Service",
|
||||
"link": "/zh-CN/api/cordis/service"
|
||||
}
|
||||
],
|
||||
"harness": [
|
||||
{
|
||||
"text": "ctx.agentLoop",
|
||||
"link": "/zh-CN/api/harness/agent-loop"
|
||||
},
|
||||
{
|
||||
"text": "ctx.agents",
|
||||
"link": "/zh-CN/api/harness/agents"
|
||||
},
|
||||
{
|
||||
"text": "ctx.approval",
|
||||
"link": "/zh-CN/api/harness/approval"
|
||||
},
|
||||
{
|
||||
"text": "ctx.bash",
|
||||
"link": "/zh-CN/api/harness/bash"
|
||||
},
|
||||
{
|
||||
"text": "ctx.bashEnv",
|
||||
"link": "/zh-CN/api/harness/bash-env"
|
||||
},
|
||||
{
|
||||
"text": "ctx.codeRuntime",
|
||||
"link": "/zh-CN/api/harness/code-runtime"
|
||||
},
|
||||
{
|
||||
"text": "ctx.compact",
|
||||
"link": "/zh-CN/api/harness/compact"
|
||||
},
|
||||
{
|
||||
"text": "ctx.fs",
|
||||
"link": "/zh-CN/api/harness/fs"
|
||||
},
|
||||
{
|
||||
"text": "ctx.llm",
|
||||
"link": "/zh-CN/api/harness/llm"
|
||||
},
|
||||
{
|
||||
"text": "ctx.permission",
|
||||
"link": "/zh-CN/api/harness/permission"
|
||||
},
|
||||
{
|
||||
"text": "ctx.sandbox",
|
||||
"link": "/zh-CN/api/harness/sandbox"
|
||||
},
|
||||
{
|
||||
"text": "ctx.sessionPersistence",
|
||||
"link": "/zh-CN/api/harness/session-persistence"
|
||||
},
|
||||
{
|
||||
"text": "ctx.sessionQuery",
|
||||
"link": "/zh-CN/api/harness/session-query"
|
||||
},
|
||||
{
|
||||
"text": "ctx.sessions",
|
||||
"link": "/zh-CN/api/harness/sessions"
|
||||
},
|
||||
{
|
||||
"text": "ctx.skills",
|
||||
"link": "/zh-CN/api/harness/skills"
|
||||
},
|
||||
{
|
||||
"text": "ctx.spillStore",
|
||||
"link": "/zh-CN/api/harness/spill-store"
|
||||
},
|
||||
{
|
||||
"text": "ctx.subagents",
|
||||
"link": "/zh-CN/api/harness/subagents"
|
||||
},
|
||||
{
|
||||
"text": "ctx.systemPrompt",
|
||||
"link": "/zh-CN/api/harness/system-prompt"
|
||||
},
|
||||
{
|
||||
"text": "ctx.tasks",
|
||||
"link": "/zh-CN/api/harness/tasks"
|
||||
},
|
||||
{
|
||||
"text": "ctx.tools",
|
||||
"link": "/zh-CN/api/harness/tools"
|
||||
},
|
||||
{
|
||||
"text": "ctx.userInteraction",
|
||||
"link": "/zh-CN/api/harness/user-interaction"
|
||||
},
|
||||
{
|
||||
"text": "ctx.web",
|
||||
"link": "/zh-CN/api/harness/web"
|
||||
},
|
||||
{
|
||||
"text": "ctx.workflows",
|
||||
"link": "/zh-CN/api/harness/workflows"
|
||||
},
|
||||
{
|
||||
"text": "Events",
|
||||
"link": "/zh-CN/api/harness/events"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { defineConfig } from 'vitepress'
|
||||
import { zhCN } from './zh-CN'
|
||||
|
||||
export default defineConfig({
|
||||
title: 'DeepSeek Harness',
|
||||
description: '插件化 Agent 开发框架',
|
||||
|
||||
// The design essays (design/revertible-effects, design/context-model) carry
|
||||
// real TeX; math: true wires markdown-it-mathjax3 into the pipeline.
|
||||
// markdown-it-mathjax3 is pinned to ^4 (NOT 5.x): v5 injects a <style> tag
|
||||
// per formula, which Vue's template compiler rejects ("Tags with side
|
||||
// effect … are ignored in client component templates"); v4 emits pure SVG.
|
||||
markdown: { math: true },
|
||||
|
||||
locales: {
|
||||
'zh-CN': zhCN,
|
||||
},
|
||||
|
||||
themeConfig: {
|
||||
socialLinks: [
|
||||
{ icon: 'github', link: 'https://github.com/deepseek-harness/deepseek-harness' },
|
||||
],
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { DefaultTheme, LocaleSpecificConfig } from 'vitepress'
|
||||
import apiSidebarData from './api-sidebar.json'
|
||||
|
||||
const guideSidebar: DefaultTheme.SidebarItem[] = [
|
||||
{
|
||||
text: '入门',
|
||||
items: [
|
||||
{ text: '介绍', link: '/zh-CN/guide/' },
|
||||
{ text: '快速开始', link: '/zh-CN/guide/quickstart' },
|
||||
{ text: '配置文件', link: '/zh-CN/guide/config' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const developSidebar: DefaultTheme.SidebarItem[] = [
|
||||
{
|
||||
text: '基础',
|
||||
items: [
|
||||
{ text: '第一个插件', link: '/zh-CN/develop/basic/' },
|
||||
{ text: '开发一个 Tool', link: '/zh-CN/develop/basic/tool' },
|
||||
{ text: '插件配置', link: '/zh-CN/develop/basic/config' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: '框架能力',
|
||||
items: [
|
||||
{ text: '插件与生命周期', link: '/zh-CN/develop/framework/' },
|
||||
{ text: '服务与依赖', link: '/zh-CN/develop/framework/service' },
|
||||
{ text: '事件系统', link: '/zh-CN/develop/framework/events' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: '实战',
|
||||
items: [
|
||||
{ text: '能力的三层拆分', link: '/zh-CN/develop/practice/' },
|
||||
{ text: 'LLM 适配器', link: '/zh-CN/develop/practice/llm-adapter' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// The API section sidebar is GENERATED (scripts/gen-website-api.ts writes
|
||||
// api-sidebar.json alongside the pages), so navigation can never drift from
|
||||
// the generated page set. Only the hand-written hub link lives here.
|
||||
const apiSidebar: DefaultTheme.SidebarItem[] = [
|
||||
{
|
||||
text: '框架 API',
|
||||
items: [
|
||||
{ text: '总览', link: '/zh-CN/api/' },
|
||||
...apiSidebarData.cordis,
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Harness API',
|
||||
items: apiSidebarData.harness,
|
||||
},
|
||||
]
|
||||
|
||||
const designSidebar: DefaultTheme.SidebarItem[] = [
|
||||
{
|
||||
text: '系统设计',
|
||||
items: [
|
||||
{ text: '概述', link: '/zh-CN/design/' },
|
||||
{ text: '可组合性与插件系统', link: '/zh-CN/design/composability' },
|
||||
{ text: '作用与余作用', link: '/zh-CN/design/effects-coeffects' },
|
||||
{ text: '可逆作用', link: '/zh-CN/design/revertible-effects' },
|
||||
{ text: '响应式余作用', link: '/zh-CN/design/reactive-coeffects' },
|
||||
{ text: '上下文模型', link: '/zh-CN/design/context-model' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export const zhCN: LocaleSpecificConfig<DefaultTheme.Config> = {
|
||||
label: '简体中文',
|
||||
lang: 'zh-CN',
|
||||
themeConfig: {
|
||||
nav: [
|
||||
{ text: '入门', link: '/zh-CN/guide/', activeMatch: '/zh-CN/guide/' },
|
||||
{ text: '开发', link: '/zh-CN/develop/basic/', activeMatch: '/zh-CN/develop/' },
|
||||
{ text: 'API', link: '/zh-CN/api/', activeMatch: '/zh-CN/api/' },
|
||||
{ text: '设计', link: '/zh-CN/design/', activeMatch: '/zh-CN/design/' },
|
||||
],
|
||||
sidebar: {
|
||||
'/zh-CN/guide/': guideSidebar,
|
||||
'/zh-CN/develop/': developSidebar,
|
||||
'/zh-CN/api/': apiSidebar,
|
||||
'/zh-CN/design/': designSidebar,
|
||||
},
|
||||
// level [2,3]: the generated API pages put each member at h3 (### ctx.foo)
|
||||
// under an h2 scope/statics group — both belong in the page outline.
|
||||
outline: { label: '本页目录', level: [2, 3] },
|
||||
docFooter: { prev: '上一篇', next: '下一篇' },
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "@deepseek-ai/website",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vitepress dev . --port 5173 --open",
|
||||
"build": "vitepress build .",
|
||||
"preview": "vitepress preview ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"markdown-it-mathjax3": "^4.3.2",
|
||||
"vitepress": "^1.6.3",
|
||||
"vue": "^3.5.13"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
<!-- Generated by scripts/gen-website-api.ts — do not edit by hand. Run `pnpm run gen-website-api` to regenerate. -->
|
||||
|
||||
# Context
|
||||
|
||||
The context is the core cordis object: every service, event, and lifecycle API is reached through `ctx`. Event methods (`ctx.on`, `ctx.emit`, …) are documented on [Events](./events.md); `ctx.effect` and `ctx.fiber` on [Fiber](./fiber.md); `ctx.plugin` and `ctx.inject` on [Registry](./registry.md).
|
||||
|
||||
Root and child dependency containers for Cordis plugins.
|
||||
A context is a proxy: normal property reads go through the service resolver, while `extend()`, `isolate()`, and `intercept()` create scoped child contexts without mutating their parent.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/context.ts#L42)
|
||||
|
||||
### ctx.extend(meta?)
|
||||
|
||||
```ts website-api
|
||||
extend(meta = {}): this
|
||||
```
|
||||
|
||||
Create a child context with extra metadata on top of the current scope.
|
||||
The child prototypally inherits every property of this context; own properties of `meta` shadow the inherited ones. The parent is not mutated.
|
||||
|
||||
- `meta` — own properties (including symbol keys) to define on the child.
|
||||
|
||||
**Returns** a child context inheriting from this one.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/context.ts#L99)
|
||||
|
||||
### ctx.isolate(name, label?)
|
||||
|
||||
```ts website-api
|
||||
isolate(name: string, label?: symbol)
|
||||
```
|
||||
|
||||
Create a child context with an independent service scope for `name`.
|
||||
Below the returned context, reads and writes of the service `name` resolve against the new label instead of the parent's, so a different implementation can be provided without affecting the parent scope. Passing the same `label` to two `isolate()` calls joins their scopes.
|
||||
|
||||
- `name` — the service name to isolate.
|
||||
- `label` — scope label to join; defaults to a fresh unique symbol.
|
||||
|
||||
**Returns** a child context whose `name` service resolves in the new scope.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/context.ts#L121)
|
||||
|
||||
### ctx.intercept(name, config)
|
||||
|
||||
```ts website-api
|
||||
intercept<K extends InjectKey>(name: K, config: Context[K] extends { [symbols.config]: infer T } ? T : never): this
|
||||
intercept(name: string, config: any): this
|
||||
```
|
||||
|
||||
Add service-specific intercept config for plugins started below this context.
|
||||
Plugins loaded under the returned context see `config` merged into the service's resolved config (ancestor entries first; see `Service[symbols.resolveConfig]`). The parent context is not affected.
|
||||
|
||||
- `name` — the service name whose config to intercept.
|
||||
- `config` — the intercept config to merge for that service.
|
||||
|
||||
**Returns** a child context carrying the additional intercept entry.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/context.ts#L139)
|
||||
|
||||
### ctx.root
|
||||
|
||||
```ts website-api
|
||||
root: this
|
||||
```
|
||||
|
||||
The root context of the application (every child context shares it). @experimental
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/context.ts#L22)
|
||||
|
||||
### ctx.baseUrl
|
||||
|
||||
```ts website-api
|
||||
baseUrl?: string
|
||||
```
|
||||
|
||||
Base URL used to resolve relative plugin/module specifiers, if the runtime sets one.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/context.ts#L24)
|
||||
|
||||
### ctx.events
|
||||
|
||||
```ts website-api
|
||||
events: EventsService
|
||||
```
|
||||
|
||||
The event bus. Its methods are also mixed onto `ctx` (`ctx.on`, `ctx.emit`, ...).
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/context.ts#L26)
|
||||
|
||||
### ctx.logger
|
||||
|
||||
```ts website-api
|
||||
logger: LoggerService
|
||||
```
|
||||
|
||||
The logging service. Call `ctx.logger(name)` for a named logger.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/context.ts#L28)
|
||||
|
||||
### ctx.reflect
|
||||
|
||||
```ts website-api
|
||||
reflect: ReflectService
|
||||
```
|
||||
|
||||
The reflection layer backing the context proxy (`ctx.get`, `ctx.provide`, ...).
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/context.ts#L30)
|
||||
|
||||
### ctx.registry
|
||||
|
||||
```ts website-api
|
||||
registry: RegistryService
|
||||
```
|
||||
|
||||
The plugin registry. Its methods are mixed onto `ctx` (`ctx.plugin`, `ctx.inject`).
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/context.ts#L32)
|
||||
|
||||
## Static members
|
||||
|
||||
### Context.effect
|
||||
|
||||
```ts website-api
|
||||
static readonly effect: unique symbol
|
||||
```
|
||||
|
||||
Symbol key under which a disposer exposes its EffectMeta diagnostics tree.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/context.ts#L44)
|
||||
|
||||
### Context.filter
|
||||
|
||||
```ts website-api
|
||||
static readonly filter: unique symbol
|
||||
```
|
||||
|
||||
Symbol key for a context's listener filter, consulted on every event dispatch.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/context.ts#L46)
|
||||
|
||||
### Context.isolate
|
||||
|
||||
```ts website-api
|
||||
static readonly isolate: unique symbol
|
||||
```
|
||||
|
||||
Symbol key of the isolation map (see the `Context[symbols.isolate]` property).
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/context.ts#L48)
|
||||
|
||||
### Context.intercept
|
||||
|
||||
```ts website-api
|
||||
static readonly intercept: unique symbol
|
||||
```
|
||||
|
||||
Symbol key of the intercept map (see the `Context[symbols.intercept]` property).
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/context.ts#L50)
|
||||
|
||||
### Context.is(value)
|
||||
|
||||
```ts website-api
|
||||
static is(value: any): value is Context
|
||||
```
|
||||
|
||||
Returns true for Cordis context proxies and context prototypes.
|
||||
Works across realms and across multiple copies of cordis, because the brand is keyed by a global symbol rather than by `instanceof`.
|
||||
|
||||
- `value` — the value to test.
|
||||
|
||||
**Returns** `true` if `value` is a Cordis context, narrowing its type.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/context.ts#L61)
|
||||
|
||||
## Service store and mixins
|
||||
|
||||
### ctx.get(name, strict?)
|
||||
|
||||
```ts website-api
|
||||
get<K extends string & keyof this>(name: K, strict?: boolean): undefined | this[K]
|
||||
get(name: string, strict?: boolean): any
|
||||
```
|
||||
|
||||
Read a service from the store without the inject requirement.
|
||||
|
||||
- `name` — the service name.
|
||||
- `strict` — when `true` (default), only return implementations whose providing fiber is currently active.
|
||||
|
||||
**Returns** the service value, or `undefined` when not (yet) provided.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/reflect.ts#L16)
|
||||
|
||||
### ctx.set(name, value)
|
||||
|
||||
```ts website-api
|
||||
set<K extends string & keyof this>(name: K, value: undefined | this[K]): void
|
||||
set(name: string, value: any): void
|
||||
```
|
||||
|
||||
Overwrite a provided service's value.
|
||||
Only the fiber that provided the service may set it; setting an unprovided name throws.
|
||||
|
||||
- `name` — the service name.
|
||||
- `value` — the new service value.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/reflect.ts#L28)
|
||||
|
||||
### ctx.provide(name, value)
|
||||
|
||||
```ts website-api
|
||||
provide<K extends string & keyof this>(name: K, value: undefined | this[K]): () => void
|
||||
provide(name: string, value?: any): () => void
|
||||
```
|
||||
|
||||
Register a service implementation owned by the current fiber.
|
||||
The service becomes visible to dependents in the same isolation scope once the fiber is active; it is unregistered (waking dependents) when the returned disposer runs or the fiber unloads. Throws if the name is already provided in this scope or declared as an accessor.
|
||||
|
||||
- `name` — the service name.
|
||||
- `value` — the service value.
|
||||
|
||||
**Returns** a disposer that unregisters the service.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/reflect.ts#L43)
|
||||
|
||||
### ctx.accessor(name, options)
|
||||
|
||||
```ts website-api
|
||||
accessor(name: string, options: Omit<Property.Accessor, 'type'>): void
|
||||
```
|
||||
|
||||
Define a computed context property backed by get/set hooks.
|
||||
The accessor is removed when the current fiber unloads. Throws if the name is already declared.
|
||||
|
||||
- `name` — the context property name.
|
||||
- `options` — the `get` hook and optional `set` hook.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/reflect.ts#L55)
|
||||
|
||||
### ctx.mixin(name, mixins)
|
||||
|
||||
```ts website-api
|
||||
mixin<K extends string & keyof this>(name: K, mixins: (keyof this & keyof this[K])[] | Dict<string>): void
|
||||
mixin<T extends {}>(source: T, mixins: (keyof this & keyof T)[] | Dict<string>): void
|
||||
```
|
||||
|
||||
Expose selected members of a service directly on `ctx`.
|
||||
Each mixed-in key becomes an accessor that forwards to the service (binding methods to it), so e.g. `ctx.on` forwards to `ctx.events.on`. Mixins are removed when the current fiber unloads.
|
||||
|
||||
- `name` — the context property holding the source service.
|
||||
- `mixins` — keys to forward, or a source-key → ctx-key map.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/reflect.ts#L66)
|
||||
@@ -0,0 +1,142 @@
|
||||
<!-- Generated by scripts/gen-website-api.ts — do not edit by hand. Run `pnpm run gen-website-api` to regenerate. -->
|
||||
|
||||
# Events
|
||||
|
||||
The event system mixed into every context. Harness-defined events are cataloged on [Harness events](../harness/events.md).
|
||||
|
||||
### ctx.parallel(name, ...args)
|
||||
|
||||
```ts website-api
|
||||
parallel<K extends keyof Events>(name: K, ...args: Parameters<Events[K]>): Promise<void>
|
||||
parallel<K extends keyof Events>(thisArg: NoInfer<ThisType<Events[K]>>, name: K, ...args: Parameters<Events[K]>): Promise<void>
|
||||
```
|
||||
|
||||
Dispatch an event, running all listeners concurrently.
|
||||
|
||||
- `name` — the event name.
|
||||
- `args` — arguments passed to every listener.
|
||||
|
||||
**Returns** a promise resolving once every listener has settled.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/events.ts#L43)
|
||||
|
||||
### ctx.emit(name, ...args)
|
||||
|
||||
```ts website-api
|
||||
emit<K extends keyof Events>(name: K, ...args: Parameters<Events[K]>): void
|
||||
emit<K extends keyof Events>(thisArg: NoInfer<ThisType<Events[K]>>, name: K, ...args: Parameters<Events[K]>): void
|
||||
```
|
||||
|
||||
Dispatch an event synchronously, ignoring listener return values.
|
||||
|
||||
- `name` — the event name.
|
||||
- `args` — arguments passed to every listener.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/events.ts#L52)
|
||||
|
||||
### ctx.serial(name, ...args)
|
||||
|
||||
```ts website-api
|
||||
serial<K extends keyof Events>(name: K, ...args: Parameters<Events[K]>): Promisify<ReturnType<Events[K]>>
|
||||
serial<K extends keyof Events>(thisArg: NoInfer<ThisType<Events[K]>>, name: K, ...args: Parameters<Events[K]>): Promisify<ReturnType<Events[K]>>
|
||||
```
|
||||
|
||||
Dispatch an event, awaiting listeners in order until one bails.
|
||||
|
||||
- `name` — the event name.
|
||||
- `args` — arguments passed to each listener.
|
||||
|
||||
**Returns** the first bail value (non-null, non-false, non-undefined), if any.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/events.ts#L62)
|
||||
|
||||
### ctx.bail(name, ...args)
|
||||
|
||||
```ts website-api
|
||||
bail<K extends keyof Events>(name: K, ...args: Parameters<Events[K]>): ReturnType<Events[K]>
|
||||
bail<K extends keyof Events>(thisArg: NoInfer<ThisType<Events[K]>>, name: K, ...args: Parameters<Events[K]>): ReturnType<Events[K]>
|
||||
```
|
||||
|
||||
Dispatch an event, calling listeners in order until one bails.
|
||||
|
||||
- `name` — the event name.
|
||||
- `args` — arguments passed to each listener.
|
||||
|
||||
**Returns** the first bail value (non-null, non-false, non-undefined), if any.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/events.ts#L72)
|
||||
|
||||
### ctx.waterfall(name, ...args)
|
||||
|
||||
```ts website-api
|
||||
waterfall<K extends keyof Events>(name: K, ...args: Parameters<Events[K]>): ReturnType<Events[K]>
|
||||
waterfall<K extends keyof Events>(thisArg: NoInfer<ThisType<Events[K]>>, name: K, ...args: Parameters<Events[K]>): ReturnType<Events[K]>
|
||||
```
|
||||
|
||||
Dispatch an event whose last argument is a `next` continuation.
|
||||
Each listener wraps the rest of the chain: calling `next()` invokes the next listener (finally the built-in behavior); not calling it vetoes.
|
||||
|
||||
- `name` — the event name.
|
||||
- `args` — listener arguments; the final one is the innermost `next`.
|
||||
|
||||
**Returns** the outermost listener's return value.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/events.ts#L85)
|
||||
|
||||
### ctx.on(name, listener, options?)
|
||||
|
||||
```ts website-api
|
||||
on<K extends keyof Events>(name: K, listener: Events[K], options?: boolean | EventOptions): () => boolean
|
||||
```
|
||||
|
||||
Register an event listener owned by the current fiber.
|
||||
|
||||
- `name` — the event name to listen for.
|
||||
- `listener` — called with the dispatch arguments.
|
||||
- `options` — listener options; a boolean is shorthand for `prepend`.
|
||||
|
||||
**Returns** a disposer removing the listener; `true` if it was still registered.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/events.ts#L96)
|
||||
|
||||
### ctx.once(name, listener, options?)
|
||||
|
||||
```ts website-api
|
||||
once<K extends keyof Events>(name: K, listener: Events[K], options?: boolean | EventOptions): () => boolean
|
||||
```
|
||||
|
||||
Same as `on()`, but the listener disposes itself after its first call.
|
||||
|
||||
- `name` — the event name to listen for.
|
||||
- `listener` — called at most once with the dispatch arguments.
|
||||
- `options` — listener options; a boolean is shorthand for `prepend`.
|
||||
|
||||
**Returns** a disposer removing the listener; `true` if it was still registered.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/events.ts#L105)
|
||||
|
||||
## EventOptions
|
||||
|
||||
Options accepted by `ctx.on()` and `ctx.once()`.
|
||||
|
||||
```ts website-api
|
||||
interface EventOptions {
|
||||
/** Add the listener before existing listeners for the same event. */
|
||||
prepend?: boolean
|
||||
/** Receive the event regardless of context filter checks. */
|
||||
global?: boolean
|
||||
}
|
||||
```
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/events.ts#L111)
|
||||
|
||||
## DispatchMode
|
||||
|
||||
Event dispatch strategy used by the event service.
|
||||
`emit` runs synchronous listeners without awaiting them, `parallel` awaits all listeners together, `serial` awaits them in order until one bails, `bail` stops on the first synchronous bail value, and `waterfall` composes listeners around a final `next` callback.
|
||||
|
||||
```ts website-api
|
||||
type DispatchMode = 'emit' | 'parallel' | 'serial' | 'bail' | 'waterfall'
|
||||
```
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/events.ts#L31)
|
||||
@@ -0,0 +1,282 @@
|
||||
<!-- Generated by scripts/gen-website-api.ts — do not edit by hand. Run `pnpm run gen-website-api` to regenerate. -->
|
||||
|
||||
# Fiber
|
||||
|
||||
A fiber is one loaded plugin instance: its lifecycle state, validated config, and registered effects. `ctx.fiber` is the current fiber; `ctx.effect()` delegates to it.
|
||||
|
||||
### ctx.effect(execute, label?)
|
||||
|
||||
```ts website-api
|
||||
effect(execute: () => SyncEffect, label?: string): Disposable<Promise<void>>
|
||||
effect(execute: () => Effect, label?: string): AsyncDisposable<Promise<void>>
|
||||
```
|
||||
|
||||
Register a cleanup-aware effect on this fiber.
|
||||
`execute` runs immediately; the disposers it produces are collected and run (in reverse order) either when the returned disposer is called or when the fiber unloads, whichever comes first. Calling the disposer twice is a no-op. Throws `CordisError('INACTIVE_EFFECT')` if the fiber is already disposed, and `TypeError` if `execute` returns an invalid shape.
|
||||
|
||||
- `execute` — the effect body; see `Effect` for accepted shapes.
|
||||
- `label` — effect label shown in `getEffects()` diagnostics.
|
||||
|
||||
**Returns** a disposer that tears the effect down and settles once done.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/fiber.ts#L419)
|
||||
|
||||
### ctx.fiber
|
||||
|
||||
```ts website-api
|
||||
fiber: Fiber
|
||||
```
|
||||
|
||||
The fiber (plugin runtime instance) that owns this context.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/fiber.ts#L11)
|
||||
|
||||
## The Fiber class
|
||||
|
||||
Runtime instance of one plugin application.
|
||||
A fiber tracks dependency state, validated config, lifecycle effects, and cleanup for the plugin context returned by `ctx.plugin()`.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/fiber.ts#L183)
|
||||
|
||||
### fiber.uid
|
||||
|
||||
```ts website-api
|
||||
public uid: number | null
|
||||
```
|
||||
|
||||
Unique id within the registry; 0 for the root fiber, `null` once disposed.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/fiber.ts#L185)
|
||||
|
||||
### fiber.ctx
|
||||
|
||||
```ts website-api
|
||||
public readonly ctx: Context
|
||||
```
|
||||
|
||||
The context this fiber's plugin runs in (extends the parent context).
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/fiber.ts#L187)
|
||||
|
||||
### fiber.config
|
||||
|
||||
```ts website-api
|
||||
public config: any
|
||||
```
|
||||
|
||||
The validated plugin config (updated by `update()`).
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/fiber.ts#L189)
|
||||
|
||||
### fiber.state
|
||||
|
||||
```ts website-api
|
||||
public state
|
||||
```
|
||||
|
||||
Current lifecycle state; transitions emit `internal/status`.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/fiber.ts#L191)
|
||||
|
||||
### fiber.dispose
|
||||
|
||||
```ts website-api
|
||||
public readonly dispose: () => Promise<void>
|
||||
```
|
||||
|
||||
Dispose this fiber: unload the plugin, then settle once cleanup finished.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/fiber.ts#L193)
|
||||
|
||||
### fiber.store
|
||||
|
||||
```ts website-api
|
||||
public store: Dict<Impl> | undefined
|
||||
```
|
||||
|
||||
Snapshot of required service implementations while loaded; `undefined` otherwise.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/fiber.ts#L195)
|
||||
|
||||
### fiber.inertia
|
||||
|
||||
```ts website-api
|
||||
public inertia: Promise<void> | undefined
|
||||
```
|
||||
|
||||
The in-flight load/unload transition, if one is currently running.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/fiber.ts#L197)
|
||||
|
||||
### fiber.name
|
||||
|
||||
```ts website-api
|
||||
get name()
|
||||
```
|
||||
|
||||
The plugin's display name, inherited from the nearest named ancestor, else `'root'`.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/fiber.ts#L340)
|
||||
|
||||
### fiber.assertActive()
|
||||
|
||||
```ts website-api
|
||||
assertActive()
|
||||
```
|
||||
|
||||
Throw if the fiber has already been disposed.
|
||||
|
||||
**Returns** nothing when the fiber is still active.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/fiber.ts#L355)
|
||||
|
||||
### fiber.effect(execute, label?)
|
||||
|
||||
```ts website-api
|
||||
effect(execute: () => SyncEffect, label?: string): Disposable<Promise<void>>
|
||||
effect(execute: () => Effect, label?: string): AsyncDisposable<Promise<void>>
|
||||
```
|
||||
|
||||
Register a cleanup-aware effect on this fiber.
|
||||
`execute` runs immediately; the disposers it produces are collected and run (in reverse order) either when the returned disposer is called or when the fiber unloads, whichever comes first. Calling the disposer twice is a no-op. Throws `CordisError('INACTIVE_EFFECT')` if the fiber is already disposed, and `TypeError` if `execute` returns an invalid shape.
|
||||
|
||||
- `execute` — the effect body; see `Effect` for accepted shapes.
|
||||
- `label` — effect label shown in `getEffects()` diagnostics.
|
||||
|
||||
**Returns** a disposer that tears the effect down and settles once done.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/fiber.ts#L419)
|
||||
|
||||
### fiber.getEffects()
|
||||
|
||||
```ts website-api
|
||||
getEffects()
|
||||
```
|
||||
|
||||
Return metadata for currently registered effects.
|
||||
|
||||
**Returns** one `EffectMeta` tree per labeled live effect.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/fiber.ts#L572)
|
||||
|
||||
### fiber.await()
|
||||
|
||||
```ts website-api
|
||||
async await()
|
||||
```
|
||||
|
||||
Wait for current lifecycle work and rethrow startup errors.
|
||||
|
||||
**Returns** this fiber, once it has settled into a stable state.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/fiber.ts#L701)
|
||||
|
||||
### fiber.restart()
|
||||
|
||||
```ts website-api
|
||||
async restart()
|
||||
```
|
||||
|
||||
Dispose and immediately reload this plugin with its current config.
|
||||
|
||||
**Returns** a promise resolving once the reload settled.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/fiber.ts#L715)
|
||||
|
||||
### fiber.update(config, noSave?)
|
||||
|
||||
```ts website-api
|
||||
update(config: any, noSave = false)
|
||||
```
|
||||
|
||||
Validate and apply new config, then restart the plugin.
|
||||
Runs the `internal/update` waterfall first, so update hooks (and HMR) can veto or replace the restart.
|
||||
|
||||
- `config` — the new raw config; validated before anything restarts.
|
||||
- `noSave` — hint for persistence hooks not to write the change back.
|
||||
|
||||
**Returns** nothing; the restart runs behind the `internal/update` waterfall.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/fiber.ts#L733)
|
||||
|
||||
## Effect
|
||||
|
||||
Effect body result accepted by `ctx.effect()` and plugin startup.
|
||||
Either a single disposer, a promise of one, or a (possibly async) iterable yielding several — generator effects register each yielded disposer as it is produced.
|
||||
|
||||
```ts website-api
|
||||
type Effect<T = any> =
|
||||
| SyncEffect<T>
|
||||
| AsyncEffect<T>
|
||||
```
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/fiber.ts#L82)
|
||||
|
||||
## Disposable
|
||||
|
||||
Function returned by an effect to release resources during disposal.
|
||||
Disposers run in reverse registration order when the owning fiber unloads; they may be async, in which case unloading awaits them.
|
||||
|
||||
```ts website-api
|
||||
type Disposable<T = any> = () => T
|
||||
```
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/fiber.ts#L73)
|
||||
|
||||
## EffectMeta
|
||||
|
||||
Tree node used to expose nested effect labels for diagnostics.
|
||||
|
||||
```ts website-api
|
||||
interface EffectMeta {
|
||||
/** Human-readable effect label, e.g. `ctx.on("event")` or `ctx.provide("name")`. */
|
||||
label: string
|
||||
/** Metadata of nested effects registered while this effect ran. */
|
||||
children: EffectMeta[]
|
||||
}
|
||||
```
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/fiber.ts#L95)
|
||||
|
||||
## CordisError
|
||||
|
||||
Framework error with a stable machine-readable code.
|
||||
|
||||
```ts website-api
|
||||
class CordisError extends Error {
|
||||
/**
|
||||
* @param code — the stable error code; also the default message.
|
||||
* @param message — optional human-readable override.
|
||||
*/
|
||||
constructor(public code: CordisError.Code, message?: string)
|
||||
}
|
||||
|
||||
namespace CordisError {
|
||||
export type Code = keyof typeof Code
|
||||
|
||||
export const Code = {
|
||||
INACTIVE_EFFECT: 'cannot create effect on inactive context',
|
||||
} as const
|
||||
}
|
||||
```
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/fiber.ts#L156)
|
||||
|
||||
## ValidationError
|
||||
|
||||
Error raised when plugin configuration fails standard-schema validation.
|
||||
|
||||
```ts website-api
|
||||
class ValidationError extends TypeError {
|
||||
name = 'ValidationError'
|
||||
|
||||
/**
|
||||
* Build the aggregated message from schema issues.
|
||||
*
|
||||
* @param issues — the standard-schema issues, one message line each.
|
||||
*/
|
||||
constructor(issues: readonly StandardSchemaV1.Issue[])
|
||||
}
|
||||
```
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/fiber.ts#L18)
|
||||
@@ -0,0 +1,121 @@
|
||||
<!-- Generated by scripts/gen-website-api.ts — do not edit by hand. Run `pnpm run gen-website-api` to regenerate. -->
|
||||
|
||||
# Registry
|
||||
|
||||
Plugin loading and dependency injection.
|
||||
|
||||
### ctx.inject(deps, callback)
|
||||
|
||||
```ts website-api
|
||||
inject(deps: Inject, callback: Plugin.Function<void>): Fiber & PromiseLike<Fiber>
|
||||
```
|
||||
|
||||
Run a callback once the requested services are available.
|
||||
Shorthand for `ctx.plugin({ inject, apply: callback })`: the callback is unloaded and re-run whenever a required service changes.
|
||||
|
||||
- `deps` — required services, as an array or a name → config map.
|
||||
- `callback` — plugin body called with `(ctx, config)`.
|
||||
|
||||
**Returns** the fiber; awaiting it settles once loading finished.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/registry.ts#L175)
|
||||
|
||||
### ctx.plugin(plugin, ...args)
|
||||
|
||||
```ts website-api
|
||||
plugin<P extends Plugin>(plugin: P, ...args: Spread<GetPluginConfig<P>>): Fiber & PromiseLike<Fiber>
|
||||
```
|
||||
|
||||
Load a plugin in the current context.
|
||||
|
||||
- `plugin` — a function, class, or `{ apply }` object plugin.
|
||||
- `args` — the plugin config, validated against its `Config` schema.
|
||||
|
||||
**Returns** the fiber; awaiting it settles once loading finished (rejecting on config or startup errors).
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/registry.ts#L184)
|
||||
|
||||
## Plugin
|
||||
|
||||
Supported plugin entrypoint shapes.
|
||||
|
||||
```ts website-api
|
||||
type Plugin<T = any> =
|
||||
| Plugin.Function<T>
|
||||
| Plugin.Constructor<T>
|
||||
| Plugin.Object<T>
|
||||
|
||||
namespace Plugin {
|
||||
/** Shared metadata understood by the plugin registry and related tooling. */
|
||||
export interface Base<T = any> {
|
||||
/** Display name used for fiber diagnostics and logger names. */
|
||||
name?: string
|
||||
/** Standard-schema validator applied to config before the plugin starts. */
|
||||
Config?: StandardSchemaV1<any, T>
|
||||
/** Services the plugin requires; it only loads while all are available. */
|
||||
inject?: Inject
|
||||
/** Service name(s) the plugin provides (read by `Service` and by loaders). */
|
||||
provide?: string | string[]
|
||||
/** Service names whose intercept config the plugin declares it consumes. */
|
||||
intercept?: Dict<boolean>
|
||||
}
|
||||
|
||||
export interface Transform<S, T> {
|
||||
/** Marks the transform object as a schema/config transform. */
|
||||
schema?: true
|
||||
/** Convert user-facing config to runtime config. */
|
||||
Config: (config: S) => T
|
||||
}
|
||||
|
||||
/** Function plugin called with `(ctx, config)`. */
|
||||
export interface Function<T = any> extends Base<T> {
|
||||
(ctx: Context, config: T): any
|
||||
}
|
||||
|
||||
/** Class plugin constructed with `(ctx, config)`. */
|
||||
export interface Constructor<T = any> extends Base<T> {
|
||||
new (ctx: Context, config: T): any
|
||||
}
|
||||
|
||||
/** Object plugin with an `apply(ctx, config)` method. */
|
||||
export interface Object<T = any> extends Base<T> {
|
||||
apply(ctx: Context, config: T): any
|
||||
}
|
||||
|
||||
/** Mutable registry record shared by all fibers of one plugin callback. */
|
||||
export interface Runtime {
|
||||
/** Display name copied from the first registered plugin shape. */
|
||||
name?: string
|
||||
/** Every live fiber of this plugin (one per `ctx.plugin()` call). */
|
||||
fibers: DisposableList<Fiber>
|
||||
/** The executable entrypoint all fibers share (registry identity key). */
|
||||
callback: globalThis.Function
|
||||
/** Standard-schema validator applied to each fiber's config. */
|
||||
Config?: StandardSchemaV1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/registry.ts#L91)
|
||||
|
||||
## Inject
|
||||
|
||||
Service dependency declaration accepted by plugins and the `@Inject` decorator.
|
||||
Array form requests services without intercept config. Object form maps each service name to optional intercept config for the plugin context.
|
||||
|
||||
```ts website-api
|
||||
type Inject<M = Dict> = (keyof M)[] | { [K in keyof M]?: M[K] }
|
||||
|
||||
namespace Inject {
|
||||
/**
|
||||
* Convert array/object/class-inherited inject metadata into a plain map.
|
||||
*
|
||||
* @param inject — the declaration to normalize; `null`/`undefined` add nothing.
|
||||
* @param result — the map to fill (service name → intercept config or `null`).
|
||||
* @returns `result`.
|
||||
*/
|
||||
export function resolve(inject: Inject | null | undefined, result: Dict = Object.create(null))
|
||||
}
|
||||
```
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/registry.ts#L18)
|
||||
@@ -0,0 +1,92 @@
|
||||
<!-- Generated by scripts/gen-website-api.ts — do not edit by hand. Run `pnpm run gen-website-api` to regenerate. -->
|
||||
|
||||
# Service
|
||||
|
||||
Base class for context services: subclass it and load the subclass as a plugin to register `ctx.<name>`.
|
||||
|
||||
Base class for services that expose a named API on `ctx`.
|
||||
Subclasses call `super(ctx, name)` from their constructor. The service is registered immediately and is automatically removed with the owning fiber.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/service.ts#L11)
|
||||
|
||||
### service.name
|
||||
|
||||
```ts website-api
|
||||
public name!: string
|
||||
```
|
||||
|
||||
The service name this instance is registered under.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/service.ts#L30)
|
||||
|
||||
## Static members
|
||||
|
||||
### Service.init
|
||||
|
||||
```ts website-api
|
||||
static readonly init: unique symbol
|
||||
```
|
||||
|
||||
Symbol key of an instance method run after construction (class plugins).
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/service.ts#L13)
|
||||
|
||||
### Service.check
|
||||
|
||||
```ts website-api
|
||||
static readonly check: unique symbol
|
||||
```
|
||||
|
||||
Symbol key of the availability predicate passed to `ctx.provide()`.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/service.ts#L15)
|
||||
|
||||
### Service.config
|
||||
|
||||
```ts website-api
|
||||
static readonly config: unique symbol
|
||||
```
|
||||
|
||||
Symbol key of the phantom intercept-config type parameter.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/service.ts#L17)
|
||||
|
||||
### Service.invoke
|
||||
|
||||
```ts website-api
|
||||
static readonly invoke: unique symbol
|
||||
```
|
||||
|
||||
Symbol key of the call body making a service callable (e.g. `ctx.logger()`).
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/service.ts#L19)
|
||||
|
||||
### Service.extend
|
||||
|
||||
```ts website-api
|
||||
static readonly extend: unique symbol
|
||||
```
|
||||
|
||||
Symbol key of the helper deriving an extended service instance.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/service.ts#L21)
|
||||
|
||||
### Service.tracker
|
||||
|
||||
```ts website-api
|
||||
static readonly tracker: unique symbol
|
||||
```
|
||||
|
||||
Symbol key of the tracker metadata used for context tracing.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/service.ts#L23)
|
||||
|
||||
### Service.resolveConfig
|
||||
|
||||
```ts website-api
|
||||
static readonly resolveConfig: unique symbol
|
||||
```
|
||||
|
||||
Symbol key of the intercept-config resolution helper below.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/vendor/cordis/src/service.ts#L25)
|
||||
@@ -0,0 +1,55 @@
|
||||
<!-- Generated by scripts/gen-website-api.ts — do not edit by hand. Run `pnpm run gen-website-api` to regenerate. -->
|
||||
|
||||
# ctx.agentLoop
|
||||
|
||||
`AgentLoop` — provided by `@deepseek-ai/dsh-agent-loop`.
|
||||
|
||||
Concrete ReactLoopAgent factory and driver service.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L335)
|
||||
|
||||
### ctx.agentLoop.create(id, options?, meta?)
|
||||
|
||||
```ts website-api
|
||||
create(id: AgentId, options: AgentOptions = {}, meta: Pick<SessionHeader, 'cwd'> = {}): ReactLoopAgent
|
||||
```
|
||||
|
||||
Create an agent on a fresh per-run session, owned by the accessing fiber. Constructor-driven config calls use the loop fiber itself.
|
||||
|
||||
- `id` — agent registry id.
|
||||
- `options` — concrete loop options.
|
||||
- `meta` — optional fresh-session workspace metadata.
|
||||
|
||||
**Returns** the published running agent.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L389)
|
||||
|
||||
### ctx.agentLoop.createAgent(ownerCtx, options)
|
||||
|
||||
```ts website-api
|
||||
async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle>
|
||||
```
|
||||
|
||||
Create an owned agent on a caller-supplied session id.
|
||||
|
||||
- `ownerCtx` — caller context that structurally owns the transaction.
|
||||
- `options` — identities, session seed/metadata, loop options, setup, and cancellation.
|
||||
|
||||
**Returns** the published handle.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L412)
|
||||
|
||||
### ctx.agentLoop.resume(ownerCtx, options)
|
||||
|
||||
```ts website-api
|
||||
async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle>
|
||||
```
|
||||
|
||||
Resume an owned agent from the configured persistence service.
|
||||
|
||||
- `ownerCtx` — caller context that owns load, setup, and the live lifecycle.
|
||||
- `options` — persisted identity, loop options, setup, and cancellation.
|
||||
|
||||
**Returns** the published handle.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L443)
|
||||
@@ -0,0 +1,117 @@
|
||||
<!-- Generated by scripts/gen-website-api.ts — do not edit by hand. Run `pnpm run gen-website-api` to regenerate. -->
|
||||
|
||||
# ctx.agents
|
||||
|
||||
`AgentRegistry` — provided by `@deepseek-ai/dsh-agent`.
|
||||
|
||||
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 AgentFactory (`@deepseek-ai/dsh-agent-loop`), registered via setFactory.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L133)
|
||||
|
||||
### ctx.agents.setFactory(factory)
|
||||
|
||||
```ts website-api
|
||||
setFactory(factory: AgentFactory): () => void
|
||||
```
|
||||
|
||||
Register the effect-scoped creation factory, rejecting a duplicate. Service factories are retraced through each create/resume caller for ownership.
|
||||
|
||||
- `factory` — the loop-owned factory `create`/`resume` delegate to.
|
||||
|
||||
**Returns** the exact Cordis effect disposer.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L152)
|
||||
|
||||
### ctx.agents.create(options)
|
||||
|
||||
```ts website-api
|
||||
async create(options: CreateAgentOptions): Promise<AgentHandle>
|
||||
```
|
||||
|
||||
Create and publish an owned agent and session through the active factory. Rejects if no factory is registered or creation, setup, or publication fails.
|
||||
|
||||
- `options` — agent id, session id/seed/metadata, and agent options.
|
||||
|
||||
**Returns** the handle after setup, rollback-covered publication, and loop start complete.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L177)
|
||||
|
||||
### ctx.agents.resume(options)
|
||||
|
||||
```ts website-api
|
||||
async resume(options: ResumeAgentOptions): Promise<AgentHandle>
|
||||
```
|
||||
|
||||
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 or persistence/setup fails.
|
||||
|
||||
- `options` — persisted identity, configuration, and optional setup.
|
||||
|
||||
**Returns** the handle after setup, rollback-covered publication, and loop start complete.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L193)
|
||||
|
||||
### ctx.agents.register(agent)
|
||||
|
||||
```ts website-api
|
||||
register(agent: Agent): () => void
|
||||
```
|
||||
|
||||
Register a live agent in the calling effect scope, with scope-filtered creation and disposal events. Duplicate ids throw.
|
||||
|
||||
- `agent` — the already-constructed agent to record in the store.
|
||||
|
||||
**Returns** the exact Cordis effect disposer for nested teardown ordering.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L207)
|
||||
|
||||
### ctx.agents.enter(agent)
|
||||
|
||||
```ts website-api
|
||||
enter(agent: Agent): () => void
|
||||
```
|
||||
|
||||
Insert an unpublished agent for an ordered factory transaction.
|
||||
|
||||
- `agent` — the prepared, unpublished agent.
|
||||
|
||||
**Returns** an idempotent closure that removes this exact entry and emits the paired disposal edge; detachment during creation dispatch is deferred.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L222)
|
||||
|
||||
### ctx.agents.announce(agent)
|
||||
|
||||
```ts website-api
|
||||
announce(agent: Agent): void
|
||||
```
|
||||
|
||||
Announce an agent previously inserted with enter.
|
||||
|
||||
- `agent` — the live inserted agent to announce.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L290)
|
||||
|
||||
### ctx.agents.get(id)
|
||||
|
||||
```ts website-api
|
||||
get(id: AgentId): Agent | undefined
|
||||
```
|
||||
|
||||
Look up a live agent.
|
||||
|
||||
- `id` — the agent id to look up.
|
||||
|
||||
**Returns** the agent, or undefined when no live agent has that id.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L324)
|
||||
|
||||
### ctx.agents.list()
|
||||
|
||||
```ts website-api
|
||||
list(): Agent[]
|
||||
```
|
||||
|
||||
All live agents, in registration order.
|
||||
|
||||
**Returns** a fresh array; mutating it does not affect the registry.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L332)
|
||||
@@ -0,0 +1,23 @@
|
||||
<!-- Generated by scripts/gen-website-api.ts — do not edit by hand. Run `pnpm run gen-website-api` to regenerate. -->
|
||||
|
||||
# ctx.approval
|
||||
|
||||
`ApprovalService` — provided by `@deepseek-ai/dsh-user-approval`.
|
||||
|
||||
Approval service that applies session policy before answerers and logs every ask/outcome pair to the requesting session. It exposes deterministic policy changes to the model through prompt and pre-step notices.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/user-approval/src/index.ts#L229)
|
||||
|
||||
### ctx.approval.request(req)
|
||||
|
||||
```ts website-api
|
||||
async request(req: ApprovalRequest): Promise<ApprovalOutcome>
|
||||
```
|
||||
|
||||
Ask the composed answerers to decide one readonly same-process request. The service borrows the request, agent, session, and live signal directly. The request requires an open turn because the audit pair must be enclosed by the durable log's commit/replay boundary; an idle ask rejects before appending anything. The answerer phase always produces an outcome: an aborted signal yields `'cancelled'`, a missing or throwing answerer yields `'unavailable'` (fail closed), and a rogue non-vocabulary return value is normalized to `'unavailable'`. A failure that prevents either audit append from committing still rejects because returning an unlogged decision would violate the pair. Session contains post-commit observer failures, so an authoritative append cannot reject the request or suppress its matching audit event.
|
||||
|
||||
- `req` — the pending decision (agent, tool identity, reason, signal).
|
||||
|
||||
**Returns** the closed outcome; `'allowed-once'` is the only grant.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/user-approval/src/index.ts#L313)
|
||||
@@ -0,0 +1,49 @@
|
||||
<!-- Generated by scripts/gen-website-api.ts — do not edit by hand. Run `pnpm run gen-website-api` to regenerate. -->
|
||||
|
||||
# ctx.bashEnv
|
||||
|
||||
`BashEnvRegistry` — provided by `@deepseek-ai/dsh-tool-bash`.
|
||||
|
||||
Registry (`ctx.bashEnv`) for trusted, per-execution `DSH_*` variables. The namespace is rebuilt for every model bash call: ambient `DSH_*` values are discarded by the executor, then the registry's current snapshot is injected. Built-in shell facts remain owned by the registry itself while plugins can register additional, enumerable facts with effect-scoped disposal.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/bash/tool-bash/src/index.ts#L102)
|
||||
|
||||
### ctx.bashEnv.register(contributor)
|
||||
|
||||
```ts website-api
|
||||
register(contributor: BashEnvContributor): () => void
|
||||
```
|
||||
|
||||
Register one environment contributor. Names and keys are unique; built-in keys are reserved. Registration is disposed with the calling plugin fiber.
|
||||
|
||||
- `contributor` — declared key ownership and per-execution resolver.
|
||||
|
||||
**Returns** the disposer that unregisters the contribution.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/bash/tool-bash/src/index.ts#L123)
|
||||
|
||||
### ctx.bashEnv.collect(execution)
|
||||
|
||||
```ts website-api
|
||||
collect(execution: ToolExecution): DshEnvironment
|
||||
```
|
||||
|
||||
Build the trusted `DSH_*` snapshot for one bash tool execution.
|
||||
|
||||
- `execution` — the current tool execution.
|
||||
|
||||
**Returns** an immutable environment overlay containing built-ins and current contributions.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/bash/tool-bash/src/index.ts#L165)
|
||||
|
||||
### ctx.bashEnv.list()
|
||||
|
||||
```ts website-api
|
||||
list(): BashEnvVariableInfo[]
|
||||
```
|
||||
|
||||
Enumerate plugin-contributed variables without executing their resolvers.
|
||||
|
||||
**Returns** declarations sorted by environment variable name.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/bash/tool-bash/src/index.ts#L197)
|
||||
@@ -0,0 +1,66 @@
|
||||
<!-- Generated by scripts/gen-website-api.ts — do not edit by hand. Run `pnpm run gen-website-api` to regenerate. -->
|
||||
|
||||
# ctx.bash
|
||||
|
||||
`BashExecutor` (abstract seam) — provided by `@deepseek-ai/dsh-bash`.
|
||||
|
||||
Abstract bash execution service. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.bash` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior).
|
||||
Implementations must honor these semantics:
|
||||
- run rejects only for infrastructure failures. Nonzero exits, timeout kills, and abort kills resolve with a BashRunResult.
|
||||
- start returns immediately; no timeout applies to background processes. `done` settles at process close and never rejects; spawn failures settle as `killed` with the error on stderr.
|
||||
- BashProcess.readOutput is incremental: consecutive reads never repeat output. Lossy reads report truncation and available spill files.
|
||||
- Disposal kills all running background processes and awaits their exit.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/bash/bash/src/index.ts#L49)
|
||||
|
||||
### ctx.bash.sandboxMode
|
||||
|
||||
```ts website-api
|
||||
get sandboxMode(): SandboxMode | undefined
|
||||
```
|
||||
|
||||
The sandbox mode this executor applies by default, or `undefined` when it does not sandbox commands.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/bash/bash/src/index.ts#L59)
|
||||
|
||||
### ctx.bash.resolve(request)
|
||||
|
||||
```ts website-api
|
||||
abstract resolve(request: BashExecRequest): BashExecSpec
|
||||
```
|
||||
|
||||
Apply implementation-owned defaults and caps to a request before execution.
|
||||
|
||||
- `request` — the caller's request; omitted fields get this implementation's defaults, capped fields are clamped.
|
||||
|
||||
**Returns** the fully-specified spec to hand to `run`/`start`.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/bash/bash/src/index.ts#L69)
|
||||
|
||||
### ctx.bash.run(spec)
|
||||
|
||||
```ts website-api
|
||||
abstract run(spec: BashExecSpec): Promise<BashRunResult>
|
||||
```
|
||||
|
||||
Run a command in the foreground; resolves when it finishes.
|
||||
|
||||
- `spec` — a resolved spec from `resolve`, never a raw request.
|
||||
|
||||
**Returns** the outcome; nonzero exits, timeout kills, and abort kills resolve with a descriptive result rather than reject.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/bash/bash/src/index.ts#L77)
|
||||
|
||||
### ctx.bash.start(spec)
|
||||
|
||||
```ts website-api
|
||||
abstract start(spec: BashExecSpec): BashProcess
|
||||
```
|
||||
|
||||
Start a background process and return its handle immediately.
|
||||
|
||||
- `spec` — a resolved spec from `resolve`, never a raw request.
|
||||
|
||||
**Returns** the live process handle (reads, kill, quiescence promise).
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/bash/bash/src/index.ts#L84)
|
||||
@@ -0,0 +1,43 @@
|
||||
<!-- Generated by scripts/gen-website-api.ts — do not edit by hand. Run `pnpm run gen-website-api` to regenerate. -->
|
||||
|
||||
# ctx.codeRuntime
|
||||
|
||||
`CodeRuntime` (abstract seam) — provided by `@deepseek-ai/dsh-code-runtime`.
|
||||
|
||||
Registers one `ctx.codeRuntime` implementation. Program, budget, abort, and substrate failures resolve in CodeRunResult; only seam misuse rejects. Implementations bridge structured-cloneable bindings while treating programs as hostile peers, isolate runs from one another, and terminate and await in-flight runs during disposal.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/code-runtime/code-runtime/src/index.ts#L30)
|
||||
|
||||
### ctx.codeRuntime.language
|
||||
|
||||
```ts website-api
|
||||
abstract readonly language: string
|
||||
```
|
||||
|
||||
The source language run expects `program` to be written in, as a lowercase identifier. Informational, not gating — a consumer that generates language-specific presentation (typed SDK stubs, usage instructions) switches on it and fails loud on a language it cannot present. Well-known value: `'typescript'`.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/code-runtime/code-runtime/src/index.ts#L38)
|
||||
|
||||
### ctx.codeRuntime.isolation
|
||||
|
||||
```ts website-api
|
||||
abstract readonly isolation: string
|
||||
```
|
||||
|
||||
The execution substrate, as a lowercase identifier. Informational, not gating — a descriptor so deployments and diagnostics can tell backends apart, not a security claim. Well-known values: `'worker-thread'`, `'process'`, `'container'`.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/code-runtime/code-runtime/src/index.ts#L46)
|
||||
|
||||
### ctx.codeRuntime.run(request)
|
||||
|
||||
```ts website-api
|
||||
abstract run(request: CodeRunRequest): Promise<CodeRunResult>
|
||||
```
|
||||
|
||||
Execute one program against the request's bindings and capture what it emitted. See the class doc for the resolution contract (error is a result field; rejection means seam misuse only).
|
||||
|
||||
- `request` — the program, its bindings, and the abort signal; the request carries everything the runtime acts on, with no hidden defaults.
|
||||
|
||||
**Returns** the run's outcome: completion value (when transferable), the ordered log capture, and the failure (if any).
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/code-runtime/code-runtime/src/index.ts#L61)
|
||||
@@ -0,0 +1,44 @@
|
||||
<!-- Generated by scripts/gen-website-api.ts — do not edit by hand. Run `pnpm run gen-website-api` to regenerate. -->
|
||||
|
||||
# ctx.compact
|
||||
|
||||
`CompactService` (abstract seam) — provided by `@deepseek-ai/dsh-compact`.
|
||||
|
||||
Abstract compaction service. Implementations own token estimation, retention, and summarization, but a successful run must replace the selected surface span with one summary node and prevent concurrent compaction of the same session. Load one implementation per context as `ctx.compact`.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/compact/compact/src/index.ts#L37)
|
||||
|
||||
### ctx.compact.compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal)
|
||||
|
||||
```ts website-api
|
||||
abstract compactIfNeeded( agent: CompactAgentContext, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal, ): Promise<CompactionResult | null>
|
||||
```
|
||||
|
||||
Check token pressure and compact if the conversation is too large. Estimate the next request, including its session prefix, derived history, and system prompt. Above threshold, compact a head-anchored range ending at a balanced tool boundary and reconsolidate any prior automatic checkpoint. Return `null` when no compaction is needed or an open tail leaves no safe cutoff. A single oversized retained unit or prefix cannot be repaired here.
|
||||
|
||||
- `agent` — agent context owning the session surface and model options.
|
||||
- `fullSystemPrompt` — assembled system prompt, counted toward the estimate.
|
||||
- `sessionPrefix` — the instance's composed session prefix, counted toward the estimate.
|
||||
- `signal` — cancellation signal; model-backed implementations must forward it.
|
||||
|
||||
**Returns** the compaction result, or `null` if no compaction was needed.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/compact/compact/src/index.ts#L57)
|
||||
|
||||
### ctx.compact.compactRegion(session, start, end, agent, signal?)
|
||||
|
||||
```ts website-api
|
||||
abstract compactRegion( session: Session, start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise<CompactionResult>
|
||||
```
|
||||
|
||||
Forcibly compact a range of surface nodes into a single summary node. `start` and `end` name an inclusive span by surface position, not numeric seq order; replacements can make visible seqs non-monotonic. Both edges must be balanced so assistant tool calls remain paired with their results. A model- backed implementation forwards cancellation and rejects active, missing, reversed, or unbalanced ranges. Use toolPairingBalancedBefore and toolPairingBalancedAfter for the edge checks.
|
||||
|
||||
- `session` — session to mutate.
|
||||
- `start` — first surface seq, inclusive.
|
||||
- `end` — last surface seq, inclusive.
|
||||
- `agent` — summarizer context.
|
||||
- `signal` — optional cancellation; model-backed implementations must forward it.
|
||||
|
||||
**Returns** the replaced range and summary.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/compact/compact/src/index.ts#L82)
|
||||
@@ -0,0 +1,603 @@
|
||||
<!-- Generated by scripts/gen-website-api.ts — do not edit by hand. Run `pnpm run gen-website-api` to regenerate. -->
|
||||
|
||||
# Harness events
|
||||
|
||||
Every event the harness packages declare on the cordis event bus (39 total), grouped by scope. The **mode** is the dispatch semantics (`emit` fire-and-forget, `parallel` awaited, `serial` first-bail, `waterfall` veto-chain — a waterfall listener MUST call `next()` to delegate).
|
||||
|
||||
## agent/*
|
||||
|
||||
### agent/created
|
||||
|
||||
**Mode:** `emit`
|
||||
|
||||
```ts website-api
|
||||
'agent/created'(this: Scoped<Agent>, agent: Agent): void
|
||||
```
|
||||
|
||||
A fully configured agent and live session were published. Setup is composition-only; `agent/session-start` is the first startup-driving seam. Synchronous listener failure vetoes publication, while returned-promise rejection is reported. Detach requested during dispatch waits until every creation listener has observed the stable entry.
|
||||
|
||||
- `agent` — the newly registered agent with its live session and completed setup. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L151)
|
||||
|
||||
### agent/disposed
|
||||
|
||||
**Mode:** `emit`
|
||||
|
||||
```ts website-api
|
||||
'agent/disposed'(this: Scoped<Agent>, agent: Agent): void
|
||||
```
|
||||
|
||||
An agent left the registry; AgentLoop emits this after driver quiescence but before session detachment and scoped-registration unwind. Custom registry users own their driver-ordering contract.
|
||||
|
||||
- `agent` — the exact agent removed from the registry. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L160)
|
||||
|
||||
### agent/error
|
||||
|
||||
**Mode:** `emit`
|
||||
|
||||
```ts website-api
|
||||
'agent/error'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, error: Error): void
|
||||
```
|
||||
|
||||
A step or turn errored. The loop reports a failure here (plus the logger) even when the error has no in-turn position for a session `error` event.
|
||||
|
||||
- `agent` — the agent whose turn errored.
|
||||
- `turn` — the turn in which the failure surfaced.
|
||||
- `step` — the step at which the failure surfaced.
|
||||
- `error` — the failure, verbatim. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L295)
|
||||
|
||||
### agent/pre-step
|
||||
|
||||
**Mode:** `serial`
|
||||
|
||||
```ts website-api
|
||||
'agent/pre-step'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal): Promise<void> | void
|
||||
```
|
||||
|
||||
Awaited serial checkpoint for session-surface mutation after prompt assembly and before `step/start`; appends land outside the pending step. The loop derives history once afterward, so compaction records and replacements are included without rewriting an assembled request. The prompt and prefix are the exact pressure inputs for that request, and `signal` cancels listener work. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
|
||||
- `agent` — the agent opening the step.
|
||||
- `turn` — the open turn number.
|
||||
- `step` — the pending step number.
|
||||
- `fullSystemPrompt` — the assembled prompt.
|
||||
- `sessionPrefix` — the frozen request prefix.
|
||||
- `signal` — the turn abort signal.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L214)
|
||||
|
||||
### agent/prompt-submit
|
||||
|
||||
**Mode:** `waterfall`
|
||||
|
||||
```ts website-api
|
||||
'agent/prompt-submit'(this: Scoped<Agent>, agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise<PromptDecision>): Promise<PromptDecision>
|
||||
```
|
||||
|
||||
Allow, rewrite, or block one drained prompt before it becomes a user message. Call `next()` for the unchanged default.
|
||||
|
||||
- `agent` — the agent draining its inbox.
|
||||
- `content` — the drained message's blocks, as queued.
|
||||
- `source` — the message's resolved source. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L224)
|
||||
|
||||
### agent/queued
|
||||
|
||||
**Mode:** `emit`
|
||||
|
||||
```ts website-api
|
||||
'agent/queued'(this: Scoped<Agent>, agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void
|
||||
```
|
||||
|
||||
Detached, frozen content entered the agent's inbox. Source defaults have already been applied, so these are the exact values retained for the log.
|
||||
|
||||
- `agent` — the agent whose inbox received the message.
|
||||
- `content` — the accepted content blocks retained by the inbox.
|
||||
- `info` — the accepted source plus whether it entered as steering. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L179)
|
||||
|
||||
### agent/request
|
||||
|
||||
**Mode:** `waterfall`
|
||||
|
||||
```ts website-api
|
||||
'agent/request'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, config: LlmCallConfig, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>
|
||||
```
|
||||
|
||||
Replace the frozen call configuration. Model-visible content must use logged channels; this seam cannot mutate messages. Injection here joins the next request because the current step boundary is already fixed.
|
||||
|
||||
- `agent` — the agent making the model call.
|
||||
- `turn` — the open turn number.
|
||||
- `step` — the step whose request this is.
|
||||
- `config` — the config the loop would use (frozen); return a replacement to switch. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L236)
|
||||
|
||||
### agent/session-prefix
|
||||
|
||||
**Mode:** `waterfall`
|
||||
|
||||
```ts website-api
|
||||
'agent/session-prefix'(this: Scoped<Agent>, agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise<Message[]>): Promise<Message[]>
|
||||
```
|
||||
|
||||
Compose request-only messages placed before derived history. The frozen result is computed once per loop instance, logged on its anchoring request header, and reused so the provider prefix remains stable. Interrupted composition is discarded. Composition precedes the first `agent/pre-step` and request boundary, so listener appends join the current request and pressure accounting sees the composed prefix. Changing context belongs in history; contributors should prepend to `await next()` to preserve registration order. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
|
||||
- `agent` — the agent whose session prefix is being composed.
|
||||
- `prefix` — the frozen seed; return an extended replacement.
|
||||
- `signal` — aborts composition when the step is torn down.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L251)
|
||||
|
||||
### agent/session-start
|
||||
|
||||
**Mode:** `emit`
|
||||
|
||||
```ts website-api
|
||||
'agent/session-start'(this: Scoped<Agent>, agent: Agent, source: SessionStartSource): void
|
||||
```
|
||||
|
||||
The session lifecycle began, once before the first turn. Use `agent.inject()` to seed model-facing context. This is a notification, not a veto; disposal requested by a lifecycle owner is rechecked before the driver starts.
|
||||
|
||||
- `agent` — the agent whose session lifecycle began.
|
||||
- `source` — why the session started (fresh startup, resume, …). Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L192)
|
||||
|
||||
### agent/status
|
||||
|
||||
**Mode:** `emit`
|
||||
|
||||
```ts website-api
|
||||
'agent/status'(this: Scoped<Agent>, agent: Agent, status: AgentStatus): void
|
||||
```
|
||||
|
||||
Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does not enter `running` synchronously; drive lifecycle from this event.
|
||||
|
||||
- `agent` — the agent whose status flipped.
|
||||
- `status` — the status just entered (the transition's destination). Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L169)
|
||||
|
||||
### agent/step-result
|
||||
|
||||
**Mode:** `waterfall`
|
||||
|
||||
```ts website-api
|
||||
'agent/step-result'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, message: Message, next: () => Promise<Message>): Promise<Message>
|
||||
```
|
||||
|
||||
Waterfall: post-process the assembled assistant Message before tool dispatch (validation, content rewriting, …).
|
||||
|
||||
- `agent` — the agent that received the step's response.
|
||||
- `turn` — the open turn number.
|
||||
- `step` — the step that produced the message.
|
||||
- `message` — the assistant message as assembled from the stream. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L262)
|
||||
|
||||
### agent/turn-continuation
|
||||
|
||||
**Mode:** `waterfall`
|
||||
|
||||
```ts website-api
|
||||
'agent/turn-continuation'(this: Scoped<Agent>, agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise<ContinuationDecision>): Promise<ContinuationDecision>
|
||||
```
|
||||
|
||||
Override whether the turn continues. The default continues after tool calls or steering and stops otherwise; a continue reason becomes steering.
|
||||
|
||||
- `agent` — the agent deciding whether to run another step.
|
||||
- `turn` — the turn being continued or stopped.
|
||||
- `defaultDecision` — what the loop would do absent an override. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L272)
|
||||
|
||||
### agent/turn-stop
|
||||
|
||||
**Mode:** `serial`
|
||||
|
||||
```ts website-api
|
||||
'agent/turn-stop'(this: Scoped<Agent>, agent: Agent, turn: number): ContinuationStop | undefined
|
||||
```
|
||||
|
||||
Monotonic terminal-stop checkpoint after continuation and steering are folded; a stop remains authoritative through turn close and flush: steering queued in that window is discarded, while ordinary sends survive.
|
||||
|
||||
- `agent` — the agent whose composed continuation outcome may be stopped.
|
||||
- `turn` — the turn at its terminal-stop checkpoint. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/types.ts#L282)
|
||||
|
||||
## approval/*
|
||||
|
||||
### approval/request
|
||||
|
||||
**Mode:** `waterfall`
|
||||
|
||||
```ts website-api
|
||||
'approval/request'(this: Scoped<ApprovalService>, req: ApprovalRequest, next: () => Promise<ApprovalOutcome>): Promise<ApprovalOutcome>
|
||||
```
|
||||
|
||||
Ask composed answerers for one decision. Return an outcome to claim the request or call `next()`; failure yields the fail-closed default. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
|
||||
- `req` — the pending decision (agent, tool identity, reason, signal).
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/user-approval/src/index.ts#L31)
|
||||
|
||||
## fs/*
|
||||
|
||||
### fs/edit-intent
|
||||
|
||||
**Mode:** `waterfall`
|
||||
|
||||
```ts website-api
|
||||
'fs/edit-intent'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined>
|
||||
```
|
||||
|
||||
Single-slot decision for the next FileSystem.editText. Calling `next()` yields an unconditional edit; the first returned guard wins.
|
||||
|
||||
- `target` — the resolved target about to be edited.
|
||||
- `actor` — the opaque tool-execution context the decider keys off.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/fs/fs/src/index.ts#L61)
|
||||
|
||||
### fs/observed
|
||||
|
||||
**Mode:** `emit`
|
||||
|
||||
```ts website-api
|
||||
'fs/observed'(target: FsTarget, version: FsVersion, actor: object | undefined): void
|
||||
```
|
||||
|
||||
Record a successful observation. Listeners must be synchronous recorders: throws fail the tool call and returned promises are not awaited.
|
||||
|
||||
- `target` — the target that was read/written/edited.
|
||||
- `version` — the version the actor now holds as its observation.
|
||||
- `actor` — the observing tool-execution context; undefined records nothing useful.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/fs/fs/src/index.ts#L70)
|
||||
|
||||
### fs/write-intent
|
||||
|
||||
**Mode:** `waterfall`
|
||||
|
||||
```ts website-api
|
||||
'fs/write-intent'(target: FsTarget, actor: object | undefined, next: () => FsWriteIntent | undefined | Promise<FsWriteIntent | undefined>): Promise<FsWriteIntent | undefined>
|
||||
```
|
||||
|
||||
Single-slot decision for the next FileSystem.writeText. Calling `next()` yields the bare provider's unconditional write; the first listener that returns an intent owns the decision rather than composing with peers.
|
||||
|
||||
- `target` — the resolved target about to be written.
|
||||
- `actor` — the opaque tool-execution context the decider keys off.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/fs/fs/src/index.ts#L53)
|
||||
|
||||
## llm/*
|
||||
|
||||
### llm/stream
|
||||
|
||||
**Mode:** `waterfall`
|
||||
|
||||
```ts website-api
|
||||
'llm/stream'(this: LlmService, options: GenerateOptions, next: () => AsyncIterable<StreamChunk>): AsyncIterable<StreamChunk>
|
||||
```
|
||||
|
||||
Waterfall around every streaming model call (retry, replay, routing). Bound to the LlmService; call `next()` to reach the resolved adapter's stream, or yield your own chunks to short-circuit.
|
||||
|
||||
- `options` — the full request. A LOOP-built request arrives deep-frozen (mutation throws): its content is a pure function of the session log (the reconstructability RFC), so listeners read it, never rewrite it. A hand-built one-shot (compaction summarize) is the caller's own object and stays mutable here.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L39)
|
||||
|
||||
## session/*
|
||||
|
||||
### session/created
|
||||
|
||||
**Mode:** `emit`
|
||||
|
||||
```ts website-api
|
||||
'session/created'(this: Scoped<Session>, session: Session): void
|
||||
```
|
||||
|
||||
Creation announcement during session publication. A synchronous throw vetoes and rolls back with a paired disposal; detach requested during dispatch is deferred. A returned-promise rejection is logged but cannot retroactively veto this synchronous boundary. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only sessions entered through that agent's context.
|
||||
|
||||
- `session` — the session just entered and announced.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L46)
|
||||
|
||||
### session/disposed
|
||||
|
||||
**Mode:** `emit`
|
||||
|
||||
```ts website-api
|
||||
'session/disposed'(this: Scoped<Session>, session: Session): void
|
||||
```
|
||||
|
||||
Emitted once when an announced session leaves the store, including publication rollback, but never for an entry whose creation announcement did not begin. Listener failures are logged and contained. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) reuses the owner scope.
|
||||
|
||||
- `session` — the session that is no longer live in the store.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L56)
|
||||
|
||||
### session/event
|
||||
|
||||
**Mode:** `emit`
|
||||
|
||||
```ts website-api
|
||||
'session/event'(this: Scoped<Session>, session: Session, event: SessionEvent): void
|
||||
```
|
||||
|
||||
Post-commit, fire-and-forget append feed. The listener snapshot resolves before the log push, but callbacks run after it; observer failures are logged and contained without making the committed append fail. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only events from sessions entered through that agent's context.
|
||||
|
||||
- `session` — the session whose log grew.
|
||||
- `event` — the appended event, exactly as recorded.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L68)
|
||||
|
||||
### session/flush
|
||||
|
||||
**Mode:** `parallel`
|
||||
|
||||
```ts website-api
|
||||
'session/flush'(this: Scoped<Session>, session: Session): Promise<void> | void
|
||||
```
|
||||
|
||||
Awaited parallel durability checkpoint: every listener runs and the caller awaits all of them, with no waterfall veto. Dispatch through SessionStore.flush. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) reuses the session's owner scope.
|
||||
|
||||
- `session` — the session whose buffered events must reach durable storage.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L78)
|
||||
|
||||
## subagent/*
|
||||
|
||||
### subagent/end
|
||||
|
||||
**Mode:** `emit`
|
||||
|
||||
```ts website-api
|
||||
'subagent/end'(this: Scoped<SubagentService>, info: SubagentRunEndInfo): void
|
||||
```
|
||||
|
||||
A ready child settled. Scope-filtered dispatch uses the same delegating parent carrier as `subagent/start`, so the lifecycle pair reaches the same scoped audience.
|
||||
|
||||
- `info` — the run identity and terminal outcome.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L108)
|
||||
|
||||
### subagent/provider-added
|
||||
|
||||
**Mode:** `emit`
|
||||
|
||||
```ts website-api
|
||||
'subagent/provider-added'(provider: SubagentProvider): void
|
||||
```
|
||||
|
||||
A provider became resolvable in the registry.
|
||||
|
||||
- `provider` — the registered provider.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L82)
|
||||
|
||||
### subagent/provider-removed
|
||||
|
||||
**Mode:** `emit`
|
||||
|
||||
```ts website-api
|
||||
'subagent/provider-removed'(name: string): void
|
||||
```
|
||||
|
||||
A provider left the registry. Accepted runs remain holder-owned.
|
||||
|
||||
- `name` — the provider name that no longer resolves.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L88)
|
||||
|
||||
### subagent/start
|
||||
|
||||
**Mode:** `emit`
|
||||
|
||||
```ts website-api
|
||||
'subagent/start'(this: Scoped<SubagentService>, info: SubagentRunInfo): void
|
||||
```
|
||||
|
||||
A provider established a ready child. For in-process providers, `ctx.agents.get(info.id)` resolves during this notification. Scope-filtered dispatch keys the carrier by the delegating parent, so a parent-scoped listener observes only its own delegations. Paired with `subagent/end`.
|
||||
|
||||
- `info` — the provider and ready child identity.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L99)
|
||||
|
||||
## system-prompt/*
|
||||
|
||||
### system-prompt/assemble
|
||||
|
||||
**Mode:** `waterfall`
|
||||
|
||||
```ts website-api
|
||||
'system-prompt/assemble'(this: Scoped<SystemPrompt>, assembly: PromptAssembly, context: AssembleContext, next: () => Promise<PromptAssembly>): Promise<PromptAssembly>
|
||||
```
|
||||
|
||||
Expert waterfall over the assembled sections, tools, and variables. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners receive only that scope's assemblies. The returned value is authoritative.
|
||||
|
||||
- `assembly` — the mutable assembly built from registered providers.
|
||||
- `context` — the caller's per-assembly context.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/system-prompt/src/index.ts#L27)
|
||||
|
||||
### system-prompt/change
|
||||
|
||||
**Mode:** `emit`
|
||||
|
||||
```ts website-api
|
||||
'system-prompt/change'(): void
|
||||
```
|
||||
|
||||
Emitted when any prompt provider changes. This registry notification is unfiltered because a global change affects every scope.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/system-prompt/src/index.ts#L33)
|
||||
|
||||
## tools/*
|
||||
|
||||
### tools/change
|
||||
|
||||
**Mode:** `emit`
|
||||
|
||||
```ts website-api
|
||||
'tools/change'(): void
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L116)
|
||||
|
||||
### tools/execute
|
||||
|
||||
**Mode:** `waterfall`
|
||||
|
||||
```ts website-api
|
||||
'tools/execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult>
|
||||
```
|
||||
|
||||
Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a normalized result; wrappers may change only `exec.signal`, while call identity remains immutable. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.
|
||||
|
||||
- `exec` — the allowed call about to dispatch (name, parsed arguments, caller agent, signal).
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L89)
|
||||
|
||||
### tools/post-execute
|
||||
|
||||
**Mode:** `waterfall`
|
||||
|
||||
```ts website-api
|
||||
'tools/post-execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, result: Readonly<ToolExecutionResult>, next: () => Promise<PostToolDecision>): Promise<PostToolDecision>
|
||||
```
|
||||
|
||||
Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts it unchanged; thrown tools still reach this seam as errors. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.
|
||||
|
||||
- `exec` — the call that just ran (name, parsed arguments, caller agent).
|
||||
- `result` — the dispatch outcome a listener may accept, replace, or block.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L98)
|
||||
|
||||
### tools/pre-execute
|
||||
|
||||
**Mode:** `waterfall`
|
||||
|
||||
```ts website-api
|
||||
'tools/pre-execute'(this: Scoped<ToolRegistry>, exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision>
|
||||
```
|
||||
|
||||
Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approval support turns `ask` into denial. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.
|
||||
|
||||
- `exec` — the pending call (name, parsed arguments, caller agent).
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L80)
|
||||
|
||||
### tools/result
|
||||
|
||||
**Mode:** `emit`
|
||||
|
||||
```ts website-api
|
||||
'tools/result'(this: Scoped<ToolRegistry>, exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): undefined
|
||||
```
|
||||
|
||||
Observe the frozen, lossless-JSON final outcome. Listener failures are contained. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): keyed by `exec.agent`.
|
||||
|
||||
- `exec` — the execution object that traversed the pipeline.
|
||||
- `result` — a deep-frozen snapshot of the final returned result.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L106)
|
||||
|
||||
## workflow/*
|
||||
|
||||
### workflow/agent-end
|
||||
|
||||
**Mode:** `emit`
|
||||
|
||||
```ts website-api
|
||||
'workflow/agent-end'(info: WorkflowRunInfo, agent: WorkflowAgentEndInfo): void
|
||||
```
|
||||
|
||||
One `agent()` call settled (clean result, child failure, or run cancellation). Paired with Events['workflow/agent-start'] by `agent.seq`, exactly once per started call on every stop path — on an engine termination path (a worker killed past its grace) the end is engine-synthesized with outcome `'cancelled'`.
|
||||
|
||||
- `info` — the run's identity snapshot.
|
||||
- `agent` — the call identity plus its outcome.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/workflow/workflow/src/index.ts#L81)
|
||||
|
||||
### workflow/agent-start
|
||||
|
||||
**Mode:** `emit`
|
||||
|
||||
```ts website-api
|
||||
'workflow/agent-start'(info: WorkflowRunInfo, agent: WorkflowAgentInfo): void
|
||||
```
|
||||
|
||||
One `agent()` call established a ready child run. Paired with Events['workflow/agent-end'] by `agent.seq`. A call that never receives a ready run from the provider emits neither event in this pair.
|
||||
|
||||
- `info` — the run's identity snapshot.
|
||||
- `agent` — the call's sequence number, label, phase, and child id.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/workflow/workflow/src/index.ts#L70)
|
||||
|
||||
### workflow/end
|
||||
|
||||
**Mode:** `emit`
|
||||
|
||||
```ts website-api
|
||||
'workflow/end'(info: WorkflowRunInfo, result: WorkflowResultInfo): void
|
||||
```
|
||||
|
||||
A workflow run settled (any stop reason). Fired when WorkflowRun.result resolves. Paired with Events['workflow/start'].
|
||||
|
||||
- `info` — the run's identity snapshot.
|
||||
- `result` — the outcome data (stop reason, error, agent count) — deliberately WITHOUT the result value (see `WorkflowResultInfo`).
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/workflow/workflow/src/index.ts#L91)
|
||||
|
||||
### workflow/log
|
||||
|
||||
**Mode:** `emit`
|
||||
|
||||
```ts website-api
|
||||
'workflow/log'(info: WorkflowRunInfo, message: string): void
|
||||
```
|
||||
|
||||
The script emitted a narration line (a `log(message)` call).
|
||||
|
||||
- `info` — the run's identity snapshot.
|
||||
- `message` — the logged message, verbatim.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/workflow/workflow/src/index.ts#L60)
|
||||
|
||||
### workflow/phase
|
||||
|
||||
**Mode:** `emit`
|
||||
|
||||
```ts website-api
|
||||
'workflow/phase'(info: WorkflowRunInfo, title: string): void
|
||||
```
|
||||
|
||||
The script entered a phase (a `phase(title)` call) — progress grouping for observers; no execution semantics.
|
||||
|
||||
- `info` — the run's identity snapshot.
|
||||
- `title` — the phase title, verbatim.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/workflow/workflow/src/index.ts#L53)
|
||||
|
||||
### workflow/start
|
||||
|
||||
**Mode:** `emit`
|
||||
|
||||
```ts website-api
|
||||
'workflow/start'(info: WorkflowRunInfo): void
|
||||
```
|
||||
|
||||
A workflow run started — the script's meta block validated, the body about to execute. Paired with Events['workflow/end'].
|
||||
|
||||
- `info` — the run's identity snapshot (id + meta).
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/workflow/workflow/src/index.ts#L45)
|
||||
@@ -0,0 +1,135 @@
|
||||
<!-- Generated by scripts/gen-website-api.ts — do not edit by hand. Run `pnpm run gen-website-api` to regenerate. -->
|
||||
|
||||
# ctx.fs
|
||||
|
||||
`FileSystem` (abstract seam) — provided by `@deepseek-ai/dsh-fs`.
|
||||
|
||||
Abstract filesystem provider. Targets must preserve identity across aliases; reads expose regular UTF-8 text or typed errors, listings are stable and content-free, and mutations are atomic. Optional guards add stale protection without changing the unguarded provider contract.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/fs/fs/src/index.ts#L80)
|
||||
|
||||
### ctx.fs.resolve(path, opts?)
|
||||
|
||||
```ts website-api
|
||||
abstract resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise<FsTarget>
|
||||
```
|
||||
|
||||
Resolve a model/plugin-supplied path into a stable FsTarget. May perform I/O (a remote/sandboxed backend may need a round-trip to map a path to a stable identity), hence async even though the local backend only normalizes + realpaths.
|
||||
|
||||
- `path` — the path to resolve; relative paths resolve against `opts.cwd`.
|
||||
- `opts` — optional cwd override and cancellation signal.
|
||||
|
||||
**Returns** the stable target; the same file yields the same `targetKey`.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/fs/fs/src/index.ts#L94)
|
||||
|
||||
### ctx.fs.stat(target, signal?)
|
||||
|
||||
```ts website-api
|
||||
abstract stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined>
|
||||
```
|
||||
|
||||
Return target metadata, or `undefined` when the target does not exist.
|
||||
|
||||
- `target` — the resolved target to stat.
|
||||
- `signal` — aborts the metadata round-trip.
|
||||
|
||||
**Returns** metadata only, never content; undefined for an absent target.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/fs/fs/src/index.ts#L102)
|
||||
|
||||
### ctx.fs.lstat(path, opts?, signal?)
|
||||
|
||||
```ts website-api
|
||||
abstract lstat(path: string, opts?: { cwd?: string }, signal?: AbortSignal): Promise<FsPathInfo | undefined>
|
||||
```
|
||||
|
||||
Return path metadata without following the final path component when it is a symbolic link. This is intentionally path-shaped, not target-shaped: resolve follows symlinks to produce the stable identity used by normal reads/writes, while `lstat` lets a consumer reject the path itself before that follow happens.
|
||||
`opts.cwd` follows resolve's cwd rules. `undefined` means the path is absent.
|
||||
|
||||
- `path` — the path to inspect; relative paths resolve against `opts.cwd`.
|
||||
- `opts` — `cwd` overrides the backend's default base for relative paths.
|
||||
- `signal` — aborts the metadata round-trip.
|
||||
|
||||
**Returns** metadata only, never content; undefined for an absent path.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/fs/fs/src/index.ts#L118)
|
||||
|
||||
### ctx.fs.readText(target, signal?)
|
||||
|
||||
```ts website-api
|
||||
abstract readText(target: FsTarget, signal?: AbortSignal): Promise<string>
|
||||
```
|
||||
|
||||
Read the whole regular text file as a single decoded string.
|
||||
|
||||
- `target` — the resolved target to read.
|
||||
- `signal` — aborts the read.
|
||||
|
||||
**Returns** the full decoded UTF-8 content.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/fs/fs/src/index.ts#L126)
|
||||
|
||||
### ctx.fs.streamText(target, signal?)
|
||||
|
||||
```ts website-api
|
||||
abstract streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>>
|
||||
```
|
||||
|
||||
Stream the whole regular text file as decoded text chunks (same text semantics as readText, for large files). The backend owns cross-chunk UTF-8 decoding and binary rejection so the policy layer never touches raw bytes.
|
||||
|
||||
- `target` — the resolved target to read.
|
||||
- `signal` — aborts the stream, including between chunks.
|
||||
|
||||
**Returns** the chunk iterable, decoded and validated like `readText`.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/fs/fs/src/index.ts#L137)
|
||||
|
||||
### ctx.fs.listDir(target, signal?)
|
||||
|
||||
```ts website-api
|
||||
abstract listDir(target: FsTarget, signal?: AbortSignal): Promise<FsDirEntry[]>
|
||||
```
|
||||
|
||||
List direct children of a directory in stable name order. Returns resolved child targets plus cheap metadata only; never reads file contents.
|
||||
|
||||
- `target` — the resolved directory target.
|
||||
- `signal` — aborts the listing.
|
||||
|
||||
**Returns** one entry per direct child, in stable name order.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/fs/fs/src/index.ts#L146)
|
||||
|
||||
### ctx.fs.writeText(target, content, expected?, signal?)
|
||||
|
||||
```ts website-api
|
||||
abstract writeText(target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal): Promise<FsWriteOutcome>
|
||||
```
|
||||
|
||||
Atomically create or replace UTF-8 text. `expected` guards intent and staleness; omission allows unconditional overwrite.
|
||||
|
||||
- `target` — the resolved target to write.
|
||||
- `content` — the full new file content.
|
||||
- `expected` — the write intent guarding the write; omit for unconditional.
|
||||
- `signal` — aborts before the atomic rename takes effect.
|
||||
|
||||
**Returns** the outcome, including the version the write produced.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/fs/fs/src/index.ts#L157)
|
||||
|
||||
### ctx.fs.editText(target, edit, expected?, signal?)
|
||||
|
||||
```ts website-api
|
||||
abstract editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal): Promise<FsEditOutcome>
|
||||
```
|
||||
|
||||
Atomically edit literal text. When supplied, the version guard is checked before matching so stale content reports `FS_STALE_VERSION`; omission edits the current content without a freshness precondition.
|
||||
|
||||
- `target` — the resolved target to edit.
|
||||
- `edit` — the literal search/replace request.
|
||||
- `expected` — the version guard; omit for an unconditional edit.
|
||||
- `signal` — aborts before the atomic rename takes effect.
|
||||
|
||||
**Returns** the outcome, including the version the edit produced.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/fs/fs/src/index.ts#L169)
|
||||
@@ -0,0 +1,50 @@
|
||||
<!-- Generated by scripts/gen-website-api.ts — do not edit by hand. Run `pnpm run gen-website-api` to regenerate. -->
|
||||
|
||||
# ctx.llm
|
||||
|
||||
`LlmService` — provided by `@deepseek-ai/dsh-llm`.
|
||||
|
||||
The abstract `llm` service: an adapter registry plus a streaming model-call surface, interceptable via the `llm/stream` waterfall.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L75)
|
||||
|
||||
### ctx.llm.registerAdapter(models, adapter)
|
||||
|
||||
```ts website-api
|
||||
registerAdapter(models: string[], adapter: LlmAdapter): () => void
|
||||
```
|
||||
|
||||
Register an adapter for the given model names. Throws `LlmError` with code `DUPLICATE_ADAPTER` if any model already has an adapter (all-or-nothing). Disposed with the fiber.
|
||||
|
||||
- `models` — every model name this adapter should serve.
|
||||
- `adapter` — the adapter that streams calls for those models.
|
||||
|
||||
**Returns** the disposer that unregisters all of them.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L90)
|
||||
|
||||
### ctx.llm.models()
|
||||
|
||||
```ts website-api
|
||||
models(): string[]
|
||||
```
|
||||
|
||||
Model names with a registered adapter.
|
||||
|
||||
**Returns** the registered names, in registration order.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L111)
|
||||
|
||||
### ctx.llm.stream(options)
|
||||
|
||||
```ts website-api
|
||||
stream(options: GenerateOptions): AsyncIterable<StreamChunk>
|
||||
```
|
||||
|
||||
Stream one model call as raw chunks (token-level deltas). Throws `LlmError` with code `NO_ADAPTER` if no adapter is registered for `options.model`. Dispatches through the `llm/stream` waterfall.
|
||||
|
||||
- `options` — the full request; `options.model` selects the adapter.
|
||||
|
||||
**Returns** the chunk stream, possibly wrapped by `llm/stream` listeners.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L128)
|
||||
@@ -0,0 +1,74 @@
|
||||
<!-- Generated by scripts/gen-website-api.ts — do not edit by hand. Run `pnpm run gen-website-api` to regenerate. -->
|
||||
|
||||
# ctx.permission
|
||||
|
||||
`PermissionService` — provided by `@deepseek-ai/dsh-permission`.
|
||||
|
||||
Owns the deployment's permission presets and their write path. Requires a confining `ctx.bash` executor and `ctx.approval`; unmatched knob values are reported as CUSTOM_PRESET, not an error.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/permission/src/index.ts#L94)
|
||||
|
||||
### ctx.permission.names
|
||||
|
||||
```ts website-api
|
||||
get names(): readonly string[]
|
||||
```
|
||||
|
||||
The advertised preset names, in the preset table's declaration order.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/permission/src/index.ts#L134)
|
||||
|
||||
### ctx.permission.current(events)
|
||||
|
||||
```ts website-api
|
||||
current(events: readonly SessionEvent[]): string
|
||||
```
|
||||
|
||||
Resolve the preset matching the effective knob values. A still-matching last selection wins shared-bundle ties; otherwise the first table match wins, or CUSTOM_PRESET when no entry matches.
|
||||
|
||||
- `events` — the session's events in log order.
|
||||
|
||||
**Returns** the effective preset name, or `custom` when nothing matches.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/permission/src/index.ts#L145)
|
||||
|
||||
### ctx.permission.resolve(name)
|
||||
|
||||
```ts website-api
|
||||
resolve(name: string): PresetSpec
|
||||
```
|
||||
|
||||
Resolve a preset's knob bundle.
|
||||
|
||||
- `name` — the preset name to resolve.
|
||||
|
||||
**Returns** the configured bundle.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/permission/src/index.ts#L166)
|
||||
|
||||
### ctx.permission.optionOf(name)
|
||||
|
||||
```ts website-api
|
||||
optionOf(name: string): PresetOption
|
||||
```
|
||||
|
||||
Build the client option for a table entry or CUSTOM_PRESET. A missing label falls back to the table key.
|
||||
|
||||
- `name` — a table key, or `custom`.
|
||||
|
||||
**Returns** the option a client renders.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/permission/src/index.ts#L181)
|
||||
|
||||
### ctx.permission.set(session, name)
|
||||
|
||||
```ts website-api
|
||||
set(session: Session, name: string): void
|
||||
```
|
||||
|
||||
Record a changed preset, then update each changed knob through its own setter. Selecting the effective preset again appends nothing.
|
||||
|
||||
- `session` — the session the switch belongs to.
|
||||
- `name` — the preset to switch to; unknown names throw.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/permission/src/index.ts#L195)
|
||||
@@ -0,0 +1,24 @@
|
||||
<!-- Generated by scripts/gen-website-api.ts — do not edit by hand. Run `pnpm run gen-website-api` to regenerate. -->
|
||||
|
||||
# ctx.sandbox
|
||||
|
||||
`SandboxProvider` (abstract seam) — provided by `@deepseek-ai/dsh-sandbox`.
|
||||
|
||||
Abstract process-sandbox service. confine must return enforcing argv or fail closed at wrap or runner-execution time; silent unconfined passthrough is forbidden. Functional probes arbitrate multi-runner chains and may be skipped for a sole candidate, whose own refusal remains the fail-closed end.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/sandbox/sandbox/src/index.ts#L111)
|
||||
|
||||
### ctx.sandbox.confine(argv, policy)
|
||||
|
||||
```ts website-api
|
||||
abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv
|
||||
```
|
||||
|
||||
Wrap `argv` so it executes confined under `policy` on this host; the caller spawns the returned argv in place of its own.
|
||||
|
||||
- `argv` — the exact argv the caller is about to spawn (program plus arguments), NOT a shell string — a shell-shaped consumer passes `['bash', '-c', command]`.
|
||||
- `policy` — the file-effect policy this execution runs under, carried per call (see `SandboxPolicy`).
|
||||
|
||||
**Returns** the argv to spawn instead, plus the enforcement completeness the selected backend achieves for it.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/sandbox/sandbox/src/index.ts#L127)
|
||||
@@ -0,0 +1,74 @@
|
||||
<!-- Generated by scripts/gen-website-api.ts — do not edit by hand. Run `pnpm run gen-website-api` to regenerate. -->
|
||||
|
||||
# ctx.sessionPersistence
|
||||
|
||||
`SessionPersistence` (abstract seam) — provided by `@deepseek-ai/dsh-session-persistence`.
|
||||
|
||||
Durable append-only session storage. Implementations preserve contiguous, losslessly JSON-serializable events; append resolves only after durability, and load balances a complete interrupted tail without rewriting committed events.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/session-persistence/session-persistence/src/index.ts#L42)
|
||||
|
||||
### ctx.sessionPersistence.locate(meta)
|
||||
|
||||
```ts website-api
|
||||
abstract locate(meta: SessionHeader): SessionLocation | undefined
|
||||
```
|
||||
|
||||
Resolve this backend's independent local artifact for a session without reading, creating, flushing, or otherwise materializing it. Backends such as SQLite that do not own one artifact per session return `undefined`.
|
||||
|
||||
- `meta` — the immutable session header whose artifact is requested.
|
||||
|
||||
**Returns** the backend-specific absolute location, when one exists.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/session-persistence/session-persistence/src/index.ts#L54)
|
||||
|
||||
### ctx.sessionPersistence.create(meta)
|
||||
|
||||
```ts website-api
|
||||
abstract create(meta: SessionHeader): Promise<void>
|
||||
```
|
||||
|
||||
Register a new session's metadata. A backend MAY defer the physical write until the first append (lazy materialization), in which case a created-but-never-appended session is absent from list — abandoned sessions leave nothing behind.
|
||||
|
||||
- `meta` — the immutable header (id, version, cwd, lineage) to record.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/session-persistence/session-persistence/src/index.ts#L63)
|
||||
|
||||
### ctx.sessionPersistence.append(id, events)
|
||||
|
||||
```ts website-api
|
||||
abstract append(id: SessionId, events: readonly SessionEvent[]): Promise<void>
|
||||
```
|
||||
|
||||
Durably persist a batch of events (called from the write-behind drain at the `session/flush` checkpoint). Honors the append-only and contiguous-seq contracts: the first event's `seq` MUST equal the stored next-seq (after `load` has durably closed any interrupted turn). Rejects non-JSON- serializable `event.data` with an error naming the offending event type.
|
||||
|
||||
- `id` — the session the batch belongs to.
|
||||
- `events` — the contiguous batch to persist, in seq order.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/session-persistence/session-persistence/src/index.ts#L74)
|
||||
|
||||
### ctx.sessionPersistence.load(id)
|
||||
|
||||
```ts website-api
|
||||
abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }>
|
||||
```
|
||||
|
||||
Load a header and balanced contiguous log. A complete interrupted final turn is preserved and durably closed with missing tool errors plus any open step and turn boundaries; only a torn final record is discarded. Unknown versions and corruption in the committed prefix reject.
|
||||
|
||||
- `id` — the persisted session to reload.
|
||||
|
||||
**Returns** the header and a log ending on a balanced `turn/end`.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/session-persistence/session-persistence/src/index.ts#L84)
|
||||
|
||||
### ctx.sessionPersistence.list()
|
||||
|
||||
```ts website-api
|
||||
abstract list(): Promise<SessionHeader[]>
|
||||
```
|
||||
|
||||
Lightweight listing from metadata, without a full-log parse.
|
||||
|
||||
**Returns** one header per materialized session.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/session-persistence/session-persistence/src/index.ts#L90)
|
||||
@@ -0,0 +1,77 @@
|
||||
<!-- Generated by scripts/gen-website-api.ts — do not edit by hand. Run `pnpm run gen-website-api` to regenerate. -->
|
||||
|
||||
# ctx.sessionQuery
|
||||
|
||||
`SessionQueryService` — provided by `@deepseek-ai/dsh-session-query`.
|
||||
|
||||
Live-preferred logical-corpus exact-read and relationship-tracing service.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/session-query/session-query/src/index.ts#L38)
|
||||
|
||||
### ctx.sessionQuery.listSessions()
|
||||
|
||||
```ts website-api
|
||||
listSessions(): Promise<SessionRecord[]>
|
||||
```
|
||||
|
||||
List the complete logical corpus using live-preferred records.
|
||||
|
||||
**Returns** deterministic newest-first cloned session records.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/session-query/session-query/src/index.ts#L63)
|
||||
|
||||
### ctx.sessionQuery.listEvents(sessionId)
|
||||
|
||||
```ts website-api
|
||||
async listEvents(sessionId: SessionId): Promise<SessionEventRecord[]>
|
||||
```
|
||||
|
||||
List lightweight raw-log event records for one logical session.
|
||||
|
||||
- `sessionId` — live-preferred session id to read.
|
||||
|
||||
**Returns** event records in ascending seq order.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/session-query/session-query/src/index.ts#L72)
|
||||
|
||||
### ctx.sessionQuery.traceSession(sessionId)
|
||||
|
||||
```ts website-api
|
||||
async traceSession(sessionId: SessionId): Promise<SessionLineageTrace>
|
||||
```
|
||||
|
||||
Trace known ancestry and descendants from one corpus observation.
|
||||
|
||||
- `sessionId` — logical session id to trace.
|
||||
|
||||
**Returns** a complete lineage or an explicit unresolved parent boundary.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/session-query/session-query/src/index.ts#L83)
|
||||
|
||||
### ctx.sessionQuery.traceEvent(request)
|
||||
|
||||
```ts website-api
|
||||
async traceEvent(request: SessionEventTraceRequest): Promise<SessionEventTrace>
|
||||
```
|
||||
|
||||
Trace one event's direct positional and provenance relationships.
|
||||
|
||||
- `request` — target session id and event seq.
|
||||
|
||||
**Returns** direct links plus the target's positional replacement chain.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/session-query/session-query/src/index.ts#L94)
|
||||
|
||||
### ctx.sessionQuery.readEvent(request)
|
||||
|
||||
```ts website-api
|
||||
async readEvent(request: SessionEventReadRequest): Promise<SessionEventWindow>
|
||||
```
|
||||
|
||||
Read one full event plus a bounded raw-log context window.
|
||||
|
||||
- `request` — target session/seq and context sizes.
|
||||
|
||||
**Returns** cloned target and neighboring events.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/session-query/session-query/src/index.ts#L104)
|
||||
@@ -0,0 +1,124 @@
|
||||
<!-- Generated by scripts/gen-website-api.ts — do not edit by hand. Run `pnpm run gen-website-api` to regenerate. -->
|
||||
|
||||
# ctx.sessions
|
||||
|
||||
`SessionStore` — provided by `@deepseek-ai/dsh-session`.
|
||||
|
||||
In-memory session store (`ctx.sessions`).
|
||||
Persistence is intentionally not implemented here — persistence plugins subscribe to `session/event` and flush on `session/flush` / dispose.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L539)
|
||||
|
||||
### ctx.sessions.create(id?, options?)
|
||||
|
||||
```ts website-api
|
||||
create(id?: SessionId, options?: CreateSessionOptions): Session
|
||||
```
|
||||
|
||||
Create a session owned by the calling fiber: disposing that fiber stops event notification and removes the session from the store. `options.seed` populates the session with a copy of those events (replay/fork); `options.meta` attaches creation metadata (validated absolute `cwd`, `parentSession` lineage) as the immutable SessionHeader (the store fills `version`/`id`/`createdAt`).
|
||||
For an agent whose session must be torn down IN ORDER with its loop (so the loop's final flush is captured before the store attachment ends), do NOT use this — fold the session lifecycle into the agent's own effect via prepare + enter + announce (see `dsh-agent-loop`'s creation transaction).
|
||||
|
||||
- `id` — the session id; omitted, the store mints `session-<n>`.
|
||||
- `options` — seed events and/or creation metadata for the header.
|
||||
|
||||
**Returns** the live session, already entered and announced.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L568)
|
||||
|
||||
### ctx.sessions.prepare(id?, options?)
|
||||
|
||||
```ts website-api
|
||||
prepare(id?: SessionId, options?: CreateSessionOptions): Session
|
||||
```
|
||||
|
||||
Build a session WITHOUT entering it into the store — validate the id/cwd and construct the Session (with its immutable SessionHeader). Pairs with enter + 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 remove the publication hooks before the loop's closing `session/flush`, dropping the closing events.
|
||||
|
||||
- `id` — the session id; omitted, the store mints `session-<n>`.
|
||||
- `options` — seed events and/or creation metadata for the header.
|
||||
|
||||
**Returns** the constructed session, NOT yet in the store.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L597)
|
||||
|
||||
### ctx.sessions.enter(session)
|
||||
|
||||
```ts website-api
|
||||
enter(session: Session): () => void
|
||||
```
|
||||
|
||||
Enter a prepared 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 announce, so a throwing `session/created` listener rolls the attach back instead of leaking it.
|
||||
Re-checks the id for a duplicate: `prepare` and `enter` are public cross-package primitives and a caller may interleave arbitrary work (or another create) between them, so a stale prepared session must NOT overwrite a live store entry of the same id — its detach disposer would later delete the REAL session. The create convenience and the agent factory call the two back-to-back so they never trip this, but the public seam cannot assume that.
|
||||
|
||||
- `session` — a `prepare`d session not yet in the store.
|
||||
|
||||
**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.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L641)
|
||||
|
||||
### ctx.sessions.announce(session)
|
||||
|
||||
```ts website-api
|
||||
announce(session: Session): void
|
||||
```
|
||||
|
||||
Emit `session/created` exactly once for an entered session (with the carrier enter captured). Separate from enter so the caller can yield the detach disposer first (rollback safety — see enter).
|
||||
|
||||
- `session` — the entered session to announce to listeners.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L696)
|
||||
|
||||
### ctx.sessions.flush(session)
|
||||
|
||||
```ts website-api
|
||||
async flush(session: Session): Promise<void>
|
||||
```
|
||||
|
||||
Dispatch the awaited `session/flush` durability checkpoint for `session`, with the carrier captured at 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.
|
||||
|
||||
- `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.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L748)
|
||||
|
||||
### ctx.sessions.get(id)
|
||||
|
||||
```ts website-api
|
||||
get(id: SessionId): Session | undefined
|
||||
```
|
||||
|
||||
Look up a live session.
|
||||
|
||||
- `id` — the session id to look up.
|
||||
|
||||
**Returns** the session, or undefined when no live session has that id.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L780)
|
||||
|
||||
### ctx.sessions.list()
|
||||
|
||||
```ts website-api
|
||||
list(): Session[]
|
||||
```
|
||||
|
||||
All live sessions, in creation order.
|
||||
|
||||
**Returns** a fresh array; mutating it does not affect the store.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L788)
|
||||
|
||||
### ctx.sessions.fork(source, boundary?, childSessionId?)
|
||||
|
||||
```ts website-api
|
||||
fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session
|
||||
```
|
||||
|
||||
Create a live child session from a turn-enclosed prefix of a live source. `boundary` is an inclusive source event seq; omitted means the source's current last event. A non-empty selected slice must end at `turn/end`.
|
||||
|
||||
- `source` — Live source session object or id.
|
||||
- `boundary` — Inclusive source event seq to fork through; omitted means the source's current last event, and omitted on an empty source forks an empty child.
|
||||
- `childSessionId` — Optional child session id; omitted delegates to `SessionStore`'s id policy.
|
||||
|
||||
**Returns** The created live child session.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L805)
|
||||
@@ -0,0 +1,66 @@
|
||||
<!-- Generated by scripts/gen-website-api.ts — do not edit by hand. Run `pnpm run gen-website-api` to regenerate. -->
|
||||
|
||||
# ctx.skills
|
||||
|
||||
`SkillService` — provided by `@deepseek-ai/dsh-skill`.
|
||||
|
||||
Registry of skill providers. It merges provider catalogs with stable first-wins duplicate handling, exposes sorted model-visible summaries, and loads full skill bodies on demand.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/skill/skill/src/index.ts#L141)
|
||||
|
||||
### ctx.skills.registerProvider(provider)
|
||||
|
||||
```ts website-api
|
||||
registerProvider(provider: SkillProvider): () => void
|
||||
```
|
||||
|
||||
Register a borrowed same-process provider synchronously during plugin apply. Duplicate and reserved names throw; remote initialization belongs in `list()`. Fiber disposal unregisters the provider and invalidates catalog caches.
|
||||
|
||||
- `provider` — the provider to register by `provider.name`.
|
||||
|
||||
**Returns** the exact Cordis effect disposer that unregisters this provider; composite effects may yield it directly to preserve teardown ordering.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/skill/skill/src/index.ts#L168)
|
||||
|
||||
### ctx.skills.register(skill)
|
||||
|
||||
```ts website-api
|
||||
register(skill: SkillRegistration): () => void
|
||||
```
|
||||
|
||||
Register a borrowed readonly runtime skill. Project entries outrank runtime entries, which outrank user entries. Same-name runtime entries are first-wins; a duplicate logs a warning and receives a no-op disposer so it cannot remove the winner.
|
||||
|
||||
- `skill` — the complete skill definition to expose for discovery.
|
||||
|
||||
**Returns** the exact Cordis effect disposer, preserving composite teardown order and invalidating caches.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/skill/skill/src/index.ts#L199)
|
||||
|
||||
### ctx.skills.list(options?)
|
||||
|
||||
```ts website-api
|
||||
async list(options: SkillLookupOptions = {}): Promise<SkillSummary[]>
|
||||
```
|
||||
|
||||
List model-invocable skill summaries for a workspace. Lookup options and provider candidates are readonly same-process values borrowed throughout discovery.
|
||||
|
||||
- `options` — lookup options; `cwd` selects project roots and `signal` cancels discovery.
|
||||
|
||||
**Returns** sorted summaries, excluding skills disabled for model invocation.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/skill/skill/src/index.ts#L230)
|
||||
|
||||
### ctx.skills.get(name, options?)
|
||||
|
||||
```ts website-api
|
||||
async get(name: string, options: SkillLookupOptions = {}): Promise<SkillDefinition | undefined>
|
||||
```
|
||||
|
||||
Load and validate the winning candidate, passing its opaque discovery locator back to the provider. Cancellation is rechecked after selection, including cache hits, and raced against loading so an uncooperative provider cannot hang the caller.
|
||||
|
||||
- `name` — kebab-case skill name.
|
||||
- `options` — lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work.
|
||||
|
||||
**Returns** the full skill, including body content, or `undefined`.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/skill/skill/src/index.ts#L246)
|
||||
@@ -0,0 +1,27 @@
|
||||
<!-- Generated by scripts/gen-website-api.ts — do not edit by hand. Run `pnpm run gen-website-api` to regenerate. -->
|
||||
|
||||
# ctx.spillStore
|
||||
|
||||
`SpillStore` (abstract seam) — provided by `@deepseek-ai/dsh-spill`.
|
||||
|
||||
Abstract spill storage service. Subclass, implement saveText, and load the subclass as a plugin — it registers as `ctx.spillStore` (one implementation per context; loading a second throws, cordis' standard duplicate-service behavior).
|
||||
Semantics every implementation must honor:
|
||||
- saveText persists the FULL `content` verbatim and returns an opaque locator, exact byte length, and model-facing retrieval guidance.
|
||||
- Storage is scoped by the request's SaveTextSpill.owner session; the backend chooses a private (not world-readable) location and a collision-free name derived from — never equal to — the caller's `suggestedName`.
|
||||
- `saveText` REJECTS on a real storage failure (permissions, ENOSPC, backend unavailable); the caller decides how to degrade (the spill policy treats a rejection as best-effort and keeps the inline result).
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/spill/spill/src/index.ts#L45)
|
||||
|
||||
### ctx.spillStore.saveText(input)
|
||||
|
||||
```ts website-api
|
||||
abstract saveText(input: SaveTextSpill): Promise<SpillRef>
|
||||
```
|
||||
|
||||
Persist `input.content` to a session-scoped spill artifact.
|
||||
|
||||
- `input` — the owner, provenance, suggested name, and full text to save.
|
||||
|
||||
**Returns** the saved artifact's `SpillRef`; rejects on a storage failure.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/spill/spill/src/index.ts#L55)
|
||||
@@ -0,0 +1,64 @@
|
||||
<!-- Generated by scripts/gen-website-api.ts — do not edit by hand. Run `pnpm run gen-website-api` to regenerate. -->
|
||||
|
||||
# ctx.subagents
|
||||
|
||||
`SubagentService` — provided by `@deepseek-ai/dsh-subagent`.
|
||||
|
||||
Named provider registry and capability-checked start surface.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L141)
|
||||
|
||||
### ctx.subagents.registerProvider(provider)
|
||||
|
||||
```ts website-api
|
||||
registerProvider(provider: SubagentProvider): () => void
|
||||
```
|
||||
|
||||
Register a provider under its name. Registration is effect-scoped and HMR safe; removing a provider blocks new starts but does not revoke runs that were already returned to their holders.
|
||||
|
||||
- `provider` — the trusted provider implementation.
|
||||
|
||||
**Returns** the exact Cordis effect disposer.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L155)
|
||||
|
||||
### ctx.subagents.getProvider(name)
|
||||
|
||||
```ts website-api
|
||||
getProvider(name: string): SubagentProvider | undefined
|
||||
```
|
||||
|
||||
Look up a provider by name.
|
||||
|
||||
- `name` — the provider name.
|
||||
|
||||
**Returns** the provider, or undefined when absent.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L178)
|
||||
|
||||
### ctx.subagents.list()
|
||||
|
||||
```ts website-api
|
||||
list(): string[]
|
||||
```
|
||||
|
||||
List registered provider names in insertion order.
|
||||
|
||||
**Returns** the registered names.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L186)
|
||||
|
||||
### ctx.subagents.start(name, request)
|
||||
|
||||
```ts website-api
|
||||
async start(name: string, request: SubagentStartRequest): Promise<SubagentRun>
|
||||
```
|
||||
|
||||
Establish a ready child on the named provider. Capability and semantic checks run before delegation. Provider ownership lasts until its promise fulfills; a rejection therefore has no run for the caller to dispose and emits no run lifecycle events.
|
||||
|
||||
- `name` — the provider to use.
|
||||
- `request` — child prompt, parent, signal, and optional capabilities.
|
||||
|
||||
**Returns** the ready holder-owned run.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L199)
|
||||
@@ -0,0 +1,66 @@
|
||||
<!-- Generated by scripts/gen-website-api.ts — do not edit by hand. Run `pnpm run gen-website-api` to regenerate. -->
|
||||
|
||||
# ctx.systemPrompt
|
||||
|
||||
`SystemPrompt` — provided by `@deepseek-ai/dsh-system-prompt`.
|
||||
|
||||
Registry service for the prompt inputs assembled before each model step.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/system-prompt/src/index.ts#L209)
|
||||
|
||||
### ctx.systemPrompt.section(section)
|
||||
|
||||
```ts website-api
|
||||
section(section: PromptSection): () => void
|
||||
```
|
||||
|
||||
Register an ordered prompt section in the calling context's scope. A scoped section shadows a global section with the same name; duplicates within one layer and non-finite orders throw. Registration and disposal emit `system-prompt/change`.
|
||||
|
||||
- `section` — the section to register.
|
||||
|
||||
**Returns** the exact Cordis effect disposer.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/system-prompt/src/index.ts#L250)
|
||||
|
||||
### ctx.systemPrompt.tools(provider)
|
||||
|
||||
```ts website-api
|
||||
tools(provider: (context: AssembleContext) => ToolProviderResult): () => void
|
||||
```
|
||||
|
||||
Register a tool-schema provider in the calling context's scope. Global and matching scoped providers both contribute; returning the reserved TOOL_ORDER_REST name makes assembly fail.
|
||||
|
||||
- `provider` — evaluated for each assembly with its context.
|
||||
|
||||
**Returns** the exact Cordis effect disposer.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/system-prompt/src/index.ts#L291)
|
||||
|
||||
### ctx.systemPrompt.variable(name, provider)
|
||||
|
||||
```ts website-api
|
||||
variable(name: string, provider: (context: AssembleContext) => string | undefined): () => void
|
||||
```
|
||||
|
||||
Register a prompt variable in the calling context's scope. Scoped values shadow globals; invalid or duplicate names throw. A provider may return `undefined`, but rendering a section that references that value then fails.
|
||||
|
||||
- `name` — the `[a-z][a-z0-9_]*` reference name.
|
||||
- `provider` — evaluated for each assembly.
|
||||
|
||||
**Returns** the exact Cordis effect disposer.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/system-prompt/src/index.ts#L325)
|
||||
|
||||
### ctx.systemPrompt.assemble(context?)
|
||||
|
||||
```ts website-api
|
||||
async assemble(context: AssembleContext = {}): Promise<PromptAssembly>
|
||||
```
|
||||
|
||||
Assemble global and scoped providers, detach tool parameters, apply canonical ordering, then run the assembly waterfall. Scoped sections and variables shadow globals; the returned waterfall value is authoritative.
|
||||
|
||||
- `context` — the optional scope and plugin-defined assembly fields.
|
||||
|
||||
**Returns** the authoritative post-waterfall assembly.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/system-prompt/src/index.ts#L365)
|
||||
@@ -0,0 +1,128 @@
|
||||
<!-- Generated by scripts/gen-website-api.ts — do not edit by hand. Run `pnpm run gen-website-api` to regenerate. -->
|
||||
|
||||
# ctx.tasks
|
||||
|
||||
`TaskService` — provided by `@deepseek-ai/dsh-tasks`.
|
||||
|
||||
The `tasks` service: the runtime-global background task registry. See the module doc for the ownership, isolation, and lifecycle contracts.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/tasks/tasks/src/index.ts#L76)
|
||||
|
||||
### ctx.tasks.start(spec)
|
||||
|
||||
```ts website-api
|
||||
start(spec: TaskStart): TaskId
|
||||
```
|
||||
|
||||
Preflight access, validation, and owner cleanup before starting and atomically registering work. A throwing starter leaves nothing registered; after it returns, registration cannot fail. Settlement records the outcome, notifies listeners, and releases waiters.
|
||||
|
||||
- `spec` — task identity, owner, and synchronous starter.
|
||||
|
||||
**Returns** the registry-issued `<kind>-N` id.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/tasks/tasks/src/index.ts#L101)
|
||||
|
||||
### ctx.tasks.list(caller?)
|
||||
|
||||
```ts website-api
|
||||
list(caller?: Agent): TaskSnapshot[]
|
||||
```
|
||||
|
||||
List caller-owned and unowned tasks in registration order without exposing another session's labels.
|
||||
|
||||
- `caller` — reading agent; a non-agent caller sees only unowned tasks.
|
||||
|
||||
**Returns** fresh snapshots.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/tasks/tasks/src/index.ts#L153)
|
||||
|
||||
### ctx.tasks.get(id, caller?)
|
||||
|
||||
```ts website-api
|
||||
get(id: TaskId, caller?: Agent): TaskSnapshot
|
||||
```
|
||||
|
||||
Return a non-consuming snapshot without changing its read cursor or notice state. Throws for an unknown or foreign task.
|
||||
|
||||
- `id` — task to look up.
|
||||
- `caller` — reading agent checked against the owner.
|
||||
|
||||
**Returns** a fresh snapshot.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/tasks/tasks/src/index.ts#L167)
|
||||
|
||||
### ctx.tasks.read(id, caller?)
|
||||
|
||||
```ts website-api
|
||||
read(id: TaskId, caller?: Agent): TaskRead
|
||||
```
|
||||
|
||||
Read the next stream delta, or the idempotent final output after settlement. A terminal read marks the task reported. Throws for an unknown or foreign task.
|
||||
|
||||
- `id` — task to read.
|
||||
- `caller` — reading agent checked against the owner.
|
||||
|
||||
**Returns** output text and the post-read snapshot.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/tasks/tasks/src/index.ts#L181)
|
||||
|
||||
### ctx.tasks.kill(id, caller?, reason?)
|
||||
|
||||
```ts website-api
|
||||
kill(id: TaskId, caller?: Agent, reason?: string): 'requested' | 'already-finished'
|
||||
```
|
||||
|
||||
Request cancellation, then mark the task stopping and reported. A producer throw propagates without changing task state. Throws for an unknown or foreign task.
|
||||
|
||||
- `id` — task to cancel.
|
||||
- `caller` — killing agent checked against the owner.
|
||||
- `reason` — logged reason forwarded to the producer.
|
||||
|
||||
**Returns** `requested` for live work, otherwise `already-finished`.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/tasks/tasks/src/index.ts#L200)
|
||||
|
||||
### ctx.tasks.wait(id, timeoutMs, caller?, signal?)
|
||||
|
||||
```ts website-api
|
||||
async wait(id: TaskId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise<TaskSnapshot>
|
||||
```
|
||||
|
||||
Wait for settlement or timeout without cancelling the task. Caller abort rejects only while the task is live; after settlement it returns the terminal snapshot so a notice suppressed for this waiter is still delivered. Timed-out and aborted waits detach their resolvers. Throws for invalid, unknown, or foreign input.
|
||||
|
||||
- `id` — task to wait for.
|
||||
- `timeoutMs` — positive finite wait bound in milliseconds.
|
||||
- `caller` — waiting agent checked against the owner.
|
||||
- `signal` — optional cancellation of the wait itself.
|
||||
|
||||
**Returns** snapshot at settlement or timeout.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/tasks/tasks/src/index.ts#L226)
|
||||
|
||||
### ctx.tasks.onTaskDone(listener)
|
||||
|
||||
```ts website-api
|
||||
onTaskDone(listener: TaskDoneListener): () => void
|
||||
```
|
||||
|
||||
Register an effect-scoped completion listener. Each listener is contained; returned promises are observed but not awaited. No listener runs after service disposal.
|
||||
|
||||
- `listener` — receives each terminal snapshot and its exact owner.
|
||||
|
||||
**Returns** disposer that unregisters the listener.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/tasks/tasks/src/index.ts#L283)
|
||||
|
||||
### ctx.tasks.attachSurface(name)
|
||||
|
||||
```ts website-api
|
||||
attachSurface(name: string): () => void
|
||||
```
|
||||
|
||||
Attach an effect-scoped surface that can read and stop tasks. start refuses work while none is attached.
|
||||
|
||||
- `name` — diagnostic label; duplicate names remain independent.
|
||||
|
||||
**Returns** disposer that detaches this surface.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/tasks/tasks/src/index.ts#L297)
|
||||
@@ -0,0 +1,94 @@
|
||||
<!-- Generated by scripts/gen-website-api.ts — do not edit by hand. Run `pnpm run gen-website-api` to regenerate. -->
|
||||
|
||||
# ctx.tools
|
||||
|
||||
`ToolRegistry` — provided by `@deepseek-ai/dsh-tools`.
|
||||
|
||||
Tool registry and execution pipeline. Scoped registrations shadow globals; one visibility resolver feeds presentation, lookup, and dispatch.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L378)
|
||||
|
||||
### ctx.tools.register(definition)
|
||||
|
||||
```ts website-api
|
||||
register(definition: ToolDefinition): () => void
|
||||
```
|
||||
|
||||
Register globally or in the calling agent scope. Scoped tools shadow globals; duplicates within one layer and the reserved `run_code` name fail.
|
||||
|
||||
- `definition` — the tool schema, execution, and optional presentation functions.
|
||||
|
||||
**Returns** the exact disposer that unregisters the tool.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L468)
|
||||
|
||||
### ctx.tools.restrict(filter)
|
||||
|
||||
```ts website-api
|
||||
restrict(filter: ToolRestriction): () => void
|
||||
```
|
||||
|
||||
Restrict global tools for the calling agent scope. Empty filters, unknown names, scope-local names, and reserved transport names fail. Restrictions intersect; scoped registrations remain visible.
|
||||
|
||||
- `filter` — global-surface mask: `allow` (keep only) and/or `deny` (remove).
|
||||
|
||||
**Returns** the exact disposer that lifts this restriction.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L508)
|
||||
|
||||
### ctx.tools.guard(guard)
|
||||
|
||||
```ts website-api
|
||||
guard(guard: ToolGuard): () => void
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
- `guard` — synchronous check; a returned string denies the execution.
|
||||
|
||||
**Returns** the exact disposer that unregisters the guard.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L559)
|
||||
|
||||
### ctx.tools.get(name, scope?)
|
||||
|
||||
```ts website-api
|
||||
get(name: string, scope?: ScopeKey): ToolDefinition | undefined
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
- `name` — the tool name as registered.
|
||||
- `scope` — the viewing scope (the agent); omitted = the global view.
|
||||
|
||||
**Returns** the definition the scope resolves, or undefined when none is visible.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L661)
|
||||
|
||||
### ctx.tools.schemas(scope?)
|
||||
|
||||
```ts website-api
|
||||
schemas(scope?: ScopeKey): ToolSchema[]
|
||||
```
|
||||
|
||||
Project visible definitions onto the allowlisted model-facing schema fields, excluding execution and presentation callbacks.
|
||||
|
||||
- `scope` — the viewing scope (the agent); omitted = the global view.
|
||||
|
||||
**Returns** one deep-cloned schema per visible tool.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L671)
|
||||
|
||||
### ctx.tools.execute(exec)
|
||||
|
||||
```ts website-api
|
||||
async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>
|
||||
```
|
||||
|
||||
Execute through pre-policy, guards, around-dispatch, post-policy, and final notification. Tool and listener failures resolve as materialized error results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen snapshot final observers receive.
|
||||
|
||||
- `exec` — the typed same-process call input. The registry assigns its correlation token before policy begins.
|
||||
|
||||
**Returns** the materialized final result.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/tools/src/index.ts#L694)
|
||||
@@ -0,0 +1,37 @@
|
||||
<!-- Generated by scripts/gen-website-api.ts — do not edit by hand. Run `pnpm run gen-website-api` to regenerate. -->
|
||||
|
||||
# ctx.userInteraction
|
||||
|
||||
`UserInteractionService` — provided by `@deepseek-ai/dsh-user-interaction`.
|
||||
|
||||
`ctx.userInteraction`: one active UI provider plus an `ask()` surface.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/user-interaction/src/index.ts#L82)
|
||||
|
||||
### ctx.userInteraction.registerProvider(provider)
|
||||
|
||||
```ts website-api
|
||||
registerProvider(provider: UserInteractionProvider): () => void
|
||||
```
|
||||
|
||||
Register the UI provider. Only one provider may be active in a context.
|
||||
|
||||
- `provider` — UI-side implementation that collects answers.
|
||||
|
||||
**Returns** Disposer that unregisters this provider.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/user-interaction/src/index.ts#L95)
|
||||
|
||||
### ctx.userInteraction.ask(request)
|
||||
|
||||
```ts website-api
|
||||
async ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer>
|
||||
```
|
||||
|
||||
Ask the active UI provider and wait for the user's answer.
|
||||
|
||||
- `request` — Questions, owner agent, and abort signal.
|
||||
|
||||
**Returns** The answer chosen or typed by the human.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/ui/user-interaction/src/index.ts#L114)
|
||||
@@ -0,0 +1,74 @@
|
||||
<!-- Generated by scripts/gen-website-api.ts — do not edit by hand. Run `pnpm run gen-website-api` to regenerate. -->
|
||||
|
||||
# ctx.web
|
||||
|
||||
`WebService` — provided by `@deepseek-ai/dsh-web`.
|
||||
|
||||
The web access service. Registered as `ctx.web` (one instance per context).
|
||||
Selection semantics (resolved at execution time, never order-dependent):
|
||||
- A configured id that is registered and `available()` → that provider.
|
||||
- A configured id not registered → `WEB_PROVIDER_CONFIGURED_MISSING`.
|
||||
- A configured id registered but unavailable → `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`.
|
||||
- No id configured, exactly one registered usable provider → that provider.
|
||||
- No id configured, multiple usable providers → `WEB_PROVIDER_AMBIGUOUS`.
|
||||
- No id configured, no usable provider → `WEB_PROVIDER_UNAVAILABLE`.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/web/web/src/index.ts#L74)
|
||||
|
||||
### ctx.web.registerSearchProvider(provider)
|
||||
|
||||
```ts website-api
|
||||
registerSearchProvider(provider: WebSearchProvider): () => void
|
||||
```
|
||||
|
||||
Register a search provider. Throws WebError `WEB_DUPLICATE_PROVIDER` if its id is already registered for search. Returns a disposer; disposed with the calling fiber.
|
||||
|
||||
- `provider` — the provider; its `id` is the registry key.
|
||||
|
||||
**Returns** the disposer that unregisters the provider.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/web/web/src/index.ts#L103)
|
||||
|
||||
### ctx.web.registerFetchProvider(provider)
|
||||
|
||||
```ts website-api
|
||||
registerFetchProvider(provider: WebFetchProvider): () => void
|
||||
```
|
||||
|
||||
Register a fetch provider. Throws WebError `WEB_DUPLICATE_PROVIDER` if its id is already registered for fetch. Returns a disposer; disposed with the calling fiber.
|
||||
|
||||
- `provider` — the provider; its `id` is the registry key.
|
||||
|
||||
**Returns** the disposer that unregisters the provider.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/web/web/src/index.ts#L114)
|
||||
|
||||
### ctx.web.search(request, signal?)
|
||||
|
||||
```ts website-api
|
||||
async search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>
|
||||
```
|
||||
|
||||
Run one search through the selected provider. Resolves the provider at call time with the selection rules above; throws WebError when the capability cannot run. The seam enforces `request.maxResults` on the result: if the provider over-returns, `sources[]` is truncated and `truncated` set.
|
||||
|
||||
- `request` — the query plus result-shaping options.
|
||||
- `signal` — optional cancellation signal forwarded to the provider.
|
||||
|
||||
**Returns** the provider's results, capped to `request.maxResults`.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/web/web/src/index.ts#L140)
|
||||
|
||||
### ctx.web.fetch(request, signal?)
|
||||
|
||||
```ts website-api
|
||||
async fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult>
|
||||
```
|
||||
|
||||
Retrieve one URL through the selected provider. Resolves the provider at call time with the selection rules above; throws WebError when the capability cannot run. A non-2xx response is a result, not a throw.
|
||||
|
||||
- `request` — the URL plus retrieval options.
|
||||
- `signal` — optional cancellation signal forwarded to the provider.
|
||||
|
||||
**Returns** the retrieval outcome; non-2xx responses resolve descriptively.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/web/web/src/index.ts#L157)
|
||||
@@ -0,0 +1,23 @@
|
||||
<!-- Generated by scripts/gen-website-api.ts — do not edit by hand. Run `pnpm run gen-website-api` to regenerate. -->
|
||||
|
||||
# ctx.workflows
|
||||
|
||||
`WorkflowService` (abstract seam) — provided by `@deepseek-ai/dsh-workflow`.
|
||||
|
||||
Workflow execution seam. Invalid requests throw before publication; a live run is holder-owned, its result never rejects, cancellation and disposal are bounded, and disposal waits for child cleanup within that bound. Lifecycle listener failures are contained, and `workflow/end` fires exactly once as the result settles.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/workflow/workflow/src/index.ts#L159)
|
||||
|
||||
### ctx.workflows.start(request)
|
||||
|
||||
```ts website-api
|
||||
abstract start(request: WorkflowStartRequest): WorkflowRun
|
||||
```
|
||||
|
||||
Parse and execute a workflow script.
|
||||
|
||||
- `request` — the script, its `args`, the parent agent, and an optional cancel signal.
|
||||
|
||||
**Returns** the live run; its `result` resolves when the script settles.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/workflow/workflow/src/index.ts#L170)
|
||||
@@ -0,0 +1,43 @@
|
||||
# API 参考
|
||||
|
||||
本节是 DeepSeek Harness 的 API 参考。除本页外,`cordis/` 与 `harness/` 下的所有页面**由脚本从源码生成**(`pnpm run gen-website-api`,CI 校验新鲜度),签名与说明永远与代码一致;生成页目前为英文,中文版将随统一翻译流程提供。
|
||||
|
||||
## 框架 API
|
||||
|
||||
Cordis 微内核提供的基础能力,所有插件开发都建立在这些 API 之上:
|
||||
|
||||
- [Context](./cordis/context) — 上下文对象,所有服务和方法的入口
|
||||
- [Events](./cordis/events) — 事件系统 API(on / emit / bail / serial / waterfall)
|
||||
- [Fiber](./cordis/fiber) — 插件生命周期(状态机、effect、dispose)
|
||||
- [Registry](./cordis/registry) — 插件注册(plugin / inject)
|
||||
- [Service](./cordis/service) — 服务基类
|
||||
|
||||
## Harness API
|
||||
|
||||
每个 `ctx.*` 服务一页,按服务名索引:
|
||||
|
||||
- [ctx.agentLoop](./harness/agent-loop) — ReAct 循环的创建与恢复
|
||||
- [ctx.agents](./harness/agents) — Agent 注册表与工厂
|
||||
- [ctx.approval](./harness/approval) — 用户审批
|
||||
- [ctx.bash](./harness/bash) — Bash 执行接口(抽象缝)
|
||||
- [ctx.codeRuntime](./harness/code-runtime) — 代码执行接口(抽象缝)
|
||||
- [ctx.compact](./harness/compact) — 上下文压缩接口(抽象缝)
|
||||
- [ctx.fs](./harness/fs) — 文件系统接口(抽象缝)
|
||||
- [ctx.llm](./harness/llm) — LLM 服务与适配器注册
|
||||
- [ctx.permission](./harness/permission) — 权限策略
|
||||
- [ctx.sandbox](./harness/sandbox) — 沙箱执行接口(抽象缝)
|
||||
- [ctx.sessionPersistence](./harness/session-persistence) — 会话持久化接口(抽象缝)
|
||||
- [ctx.sessionQuery](./harness/session-query) — 会话检索
|
||||
- [ctx.sessions](./harness/sessions) — 会话存储
|
||||
- [ctx.skills](./harness/skills) — 技能加载
|
||||
- [ctx.subagents](./harness/subagents) — 子代理委派
|
||||
- [ctx.systemPrompt](./harness/system-prompt) — 系统提示词组装
|
||||
- [ctx.tasks](./harness/tasks) — 后台任务
|
||||
- [ctx.tools](./harness/tools) — Tool 注册表
|
||||
- [ctx.userInteraction](./harness/user-interaction) — 用户交互接口
|
||||
- [ctx.web](./harness/web) — Web 搜索与抓取
|
||||
- [ctx.workflows](./harness/workflows) — 动态工作流引擎(抽象缝)
|
||||
|
||||
事件总表:[Harness events](./harness/events) — 全部事件按作用域分组,含触发模式与载荷签名。
|
||||
|
||||
想学"怎么写一个 tool / 插件"?教程在[开发指南](../develop/basic/);本节只做精确的接口参考。
|
||||
@@ -0,0 +1,77 @@
|
||||
# 可组合性与插件系统
|
||||
|
||||
## 组合
|
||||
|
||||
编程的本质就是组合。将小的构建块拼装为更大的系统,再将大系统作为块继续拼装——这是从函数到模块到微服务一脉相承的思想。
|
||||
|
||||
组合可以分为两种:
|
||||
|
||||
- **静态组合**:编译期确定的组合,例如函数调用、模块导入。
|
||||
- **动态组合**:运行时确定的组合,例如热更新、插件加载/卸载。
|
||||
|
||||
静态组合是逻辑的组合;动态组合为可组合性引入了时间和空间两个新维度。
|
||||
|
||||
## 三种可组合性
|
||||
|
||||
| 维度 | 定义 | 对应问题 |
|
||||
|------|------|----------|
|
||||
| **逻辑可组合性** (Logical) | 功能能否被任意拆分和组装 | 接口设计是否正交 |
|
||||
| **时间可组合性** (Temporal) | 能否灵活、安全地控制组合的运行时序 | 能否热加载/卸载而不泄漏 |
|
||||
| **空间可组合性** (Spatial) | 能否灵活、安全地管理组合的依赖关系 | 依赖缺失时行为是否确定 |
|
||||
|
||||
一门编程语言或应用框架越多地使用组合范式,就称它的可组合性越好。
|
||||
|
||||
## 传统插件系统的问题
|
||||
|
||||
插件系统是动态组合的典型形式。浏览器扩展、IDE 插件、操作系统驱动,都是其实例。然而大多数插件系统并不可靠。
|
||||
|
||||
### 不可逆的插件化
|
||||
|
||||
以 VSCode 为例:
|
||||
|
||||
- 卸载或更新插件时需要重启整个系统。
|
||||
- 无法在运行时追踪和回收副作用,导致内存泄漏和非预期的资源占用。
|
||||
- 即便提供了 `deactivate` 钩子,也无法强制开发者正确实现清理逻辑。
|
||||
|
||||
**根本原因**:未做到时间可组合——系统不知道某个插件产生了哪些副作用、占用了哪些资源。
|
||||
|
||||
### 不完全的插件化
|
||||
|
||||
- 无法表达插件间的依赖关系,扩展能力受限。
|
||||
- 只有外围功能被下放给插件,核心功能依然通过修改主体代码来实现。
|
||||
|
||||
**根本原因**:未做到空间可组合——系统缺乏对依赖关系的建模和管理。
|
||||
|
||||
## Cordis 的解法
|
||||
|
||||
Cordis 同时解决了上述两个问题:
|
||||
|
||||
1. **可逆作用** (Revertible Effects) 实现时间可组合性——所有注册自动追踪、自动回收。
|
||||
2. **响应式余作用** (Reactive Coeffects) 实现空间可组合性——依赖声明驱动加载顺序。
|
||||
|
||||
两者通过**上下文模型** (Context Model) 统一为单一的编程范式:开发者只需通过 `ctx` 调用框架 API,可逆性和依赖管理由框架保证。
|
||||
|
||||
## 在 Harness 中的体现
|
||||
|
||||
DeepSeek Harness 将 Cordis 的可组合性应用到 Agent 开发领域:
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type {} from '@deepseek-ai/dsh-llm'
|
||||
|
||||
// 一个 Harness 插件天然是可逆的
|
||||
export const inject = ['tools', 'llm'] // 空间可组合:声明依赖
|
||||
|
||||
export function apply(ctx: Context) {
|
||||
// 时间可组合:注册会被自动追踪和回收
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'my-tool',
|
||||
description: '...',
|
||||
parameters: { /* ... */ },
|
||||
async execute(args) { return [] },
|
||||
}))
|
||||
}
|
||||
```
|
||||
|
||||
插件卸载时,tool 自动注销、事件监听自动移除——无需手动清理。依赖的服务(如 `llm`)消失时,插件自动挂起;恢复时自动重新加载。
|
||||
@@ -0,0 +1,152 @@
|
||||
# 上下文模型
|
||||
|
||||
上下文 (Context) 是 Cordis 将作用与余作用统一的运行时模型。它提供了一种编程范式,允许开发者无心智负担地编写时间、空间可组合的程序。
|
||||
|
||||
## 作用上下文 (Effect Context)
|
||||
|
||||
当副作用被记录到全局环境时,$\mathcal{C}\times\left(\mathcal{C}\to\mathcal{C}\right)$ 也就变成了一个更大的 $\mathcal{C}$。
|
||||
|
||||
递归地定义:
|
||||
|
||||
$$
|
||||
\begin{matrix}
|
||||
\mathcal{C}_1=\mathcal{C}_0\times\left(\mathcal{C}_0\to\mathcal{C}_0\right)\\
|
||||
\mathcal{C}_2=\mathcal{C}_1\times\left(\mathcal{C}_1\to\mathcal{C}_1\right)\\
|
||||
\cdots\\
|
||||
\mathcal{C}_{n+1}=\mathcal{C}_n\times\left(\mathcal{C}_n\to\mathcal{C}_n\right)\\
|
||||
\end{matrix}
|
||||
$$
|
||||
|
||||
每一层 $\mathcal{C}$ 包含上一层的状态,同时记录了上一层的副作用。
|
||||
|
||||
利用递归类型得到真正的作用上下文:
|
||||
|
||||
$$
|
||||
\mathcal{C}=\mathcal{C}\times\left(\mathcal{C}\to\mathcal{C}\right)
|
||||
$$
|
||||
|
||||
这就是 Cordis Context 的理论根基:**上下文既是状态容器,又是副作用追踪器。**
|
||||
|
||||
## 上下文的派生
|
||||
|
||||
当一个插件被加载时,从当前上下文派生出新的上下文实例:
|
||||
|
||||
```
|
||||
Root Context
|
||||
├── Plugin A Context ← 管理 A 的副作用
|
||||
│ └── Sub-plugin Context
|
||||
└── Plugin B Context ← 管理 B 的副作用
|
||||
```
|
||||
|
||||
- 子级上下文管理插件内部的全部副作用
|
||||
- 插件整体作为一个副作用被父级上下文收集
|
||||
- 父级 dispose 时,子级先被 dispose(保证依赖逆序)
|
||||
|
||||
## 余作用上下文 (Coeffect Context)
|
||||
|
||||
余作用由作用产生:
|
||||
|
||||
- **提供服务**本身是一种作用——它占用了服务命名空间资源
|
||||
- 因此服务的提供被记录在作用上下文中
|
||||
- 上下文将作用与余作用关联起来,提供了统一的时间、空间可组合性
|
||||
|
||||
```ts
|
||||
import { Service, type Context } from 'cordis'
|
||||
|
||||
// 提供服务 = 一个 effect(占用 ctx.llm 这个 "资源")
|
||||
class LlmService extends Service {
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'llm')
|
||||
}
|
||||
// 当此插件卸载时,ctx.llm 被回收(effect 的逆操作)
|
||||
// 所有依赖 llm 的插件因 coeffect 不满足而挂起
|
||||
}
|
||||
```
|
||||
|
||||
## 基于上下文的开发范式
|
||||
|
||||
上下文模型提供了两个关键优势:
|
||||
|
||||
### 无感性 (Transparent)
|
||||
|
||||
框架将领域中的所有方法都封装为 effect 版本。开发者只需调用 `ctx` 上的方法,就能自动获得时间/空间可组合性:
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
|
||||
import type { LlmAdapter, Message } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
declare function validateResult(agent: Agent, turn: number, step: number, message: Message, next: () => Promise<Message>): Promise<Message>
|
||||
declare const myTool: ToolDefinition
|
||||
declare const adapter: LlmAdapter
|
||||
|
||||
export function apply(ctx: Context) {
|
||||
// 以下每一行都是 effect——卸载时自动逆序回收
|
||||
ctx.on('agent/step-result', validateResult)
|
||||
ctx.tools.register(myTool)
|
||||
ctx.llm.registerAdapter(['my-model'], adapter)
|
||||
|
||||
// 开发者无需知道"可逆作用"的存在
|
||||
// 只需通过 ctx 调用,框架保证一切安全
|
||||
}
|
||||
```
|
||||
|
||||
### 渐进性 (Incremental)
|
||||
|
||||
可以逐步将现有框架中的 API 替换为可组合版本,无需一次性重写:
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
|
||||
declare const ctx: Context
|
||||
declare function handler(): void
|
||||
declare const legacySystem: {
|
||||
register(handler: () => void): object
|
||||
unregister(token: object): void
|
||||
}
|
||||
|
||||
// 第一步:用 ctx.effect 包装遗留 API
|
||||
ctx.effect(() => {
|
||||
const legacy = legacySystem.register(handler)
|
||||
return () => legacySystem.unregister(legacy)
|
||||
})
|
||||
|
||||
// 第二步:在未来将遗留 API 原生改造为 effect
|
||||
// 两种方式可以并存
|
||||
```
|
||||
|
||||
## 在 Harness 中的完整图景
|
||||
|
||||
DeepSeek Harness 的运行时是一个 Context 树:
|
||||
|
||||
```
|
||||
Root Context (Cordis 应用)
|
||||
├── dsh-session (提供 ctx.sessions)
|
||||
├── dsh-tools (提供 ctx.tools)
|
||||
├── dsh-llm (提供 ctx.llm)
|
||||
│ └── deepseek-adapter (注册模型适配器)
|
||||
├── dsh-agent-loop (提供 ctx.agentLoop)
|
||||
├── dsh-bash (提供 ctx.bash)
|
||||
│ └── bash-local (本地执行器实现)
|
||||
├── dsh-fs (提供 ctx.fs)
|
||||
│ └── fs-local (本地 FS 实现)
|
||||
├── dsh-system-prompt (提供 ctx.systemPrompt)
|
||||
└── Agent Context (由 agents.create() 派生)
|
||||
├── Agent 自己注册的 tools
|
||||
├── Agent 的 session
|
||||
└── Subagent Context (进一步派生)
|
||||
```
|
||||
|
||||
每个节点都是一个 Context 实例。插件加载/卸载、服务出现/消失、Agent 创建/销毁——这一切都在 Context 树上以统一的语义发生。
|
||||
|
||||
## 总结
|
||||
|
||||
| 概念 | 解决的问题 | Cordis 机制 |
|
||||
|------|-----------|-------------|
|
||||
| 作用上下文 | 副作用追踪与回收 | `ctx.effect()` / `fiber.dispose()` |
|
||||
| 上下文派生 | 副作用的层级隔离 | `ctx.plugin()` 创建子 Context |
|
||||
| 余作用上下文 | 依赖的动态管理 | `inject` 声明 + 服务生命周期 |
|
||||
| 统一范式 | 开发者无需关心底层机制 | 只需通过 `ctx` 调用 API |
|
||||
|
||||
这就是为什么 Harness 能在保持「一切皆插件」的同时,不给插件开发者增加心智负担——**上下文模型把复杂性封装在了框架内部**。
|
||||
@@ -0,0 +1,69 @@
|
||||
# 作用与余作用
|
||||
|
||||
## 作用 (Effects)
|
||||
|
||||
Effects 是程序中对系统状态或外部环境产生影响的操作:I/O、状态修改、资源占用等。
|
||||
|
||||
学术界对作用有两种主要建模方式:
|
||||
|
||||
### 单子作用 (Monadic Effects)
|
||||
|
||||
- 通过单子 (monad) 将副作用封装为类型安全的计算链。
|
||||
- 提供 `return`(纯值注入)和 `bind`(链式组合)两个基本操作。
|
||||
- 以纯函数式的方式处理带有副作用的计算。(Moggi 1991, Wadler 1992)
|
||||
- 代表语言:Haskell (IO Monad)、Rust (Result/Option)
|
||||
|
||||
### 代数作用 (Algebraic Effects)
|
||||
|
||||
- 允许在函数中"抛出"一个 effect,在调用栈的更高层次"捕获"并处理。
|
||||
- 类似异常处理,但更通用——处理后可以恢复执行。
|
||||
- 代表语言:Koka、Eff、OCaml 5+ (Kiselyov 2018, Kawahara 2020)
|
||||
|
||||
## 余作用 (Coeffects)
|
||||
|
||||
Coeffects 是程序执行时依赖的上下文信息:环境变量、系统资源、外部服务等。
|
||||
|
||||
- Coeffects 是 effects 的对偶 (dual) 概念,通常通过余单子 (comonad) 建模。(Petricek 2013, 2014; Brünnler 2014)
|
||||
- 更前沿的理论将带有资源的上下文建模为 **graded algebra**(有序半环加最大元):
|
||||
- 加法 = 并行组合;0 元 = 无资源
|
||||
- 乘法 = 串行组合;1 元 = 单位资源
|
||||
- 序 = 资源约束;最大元 = 无限资源
|
||||
- (Breuvart 2015, Gaboardi 2016, Dal Lago 2022)
|
||||
|
||||
## 现有理论的不足
|
||||
|
||||
这些理论主要面向**静态分析**和**短时程序**:
|
||||
|
||||
1. **缺乏运行时追踪**:类型系统能标记副作用的存在,但无法在运行时追踪和回收。对长时运行程序(服务端、Agent),这意味着资源泄漏不可避免。
|
||||
|
||||
2. **缺乏动态性**:面向编译期分析,无法处理运行时的加载/卸载需求。
|
||||
|
||||
3. **崩溃而非降级**:类型不满足时直接拒绝编译或运行时崩溃,而长时运行程序更希望安全降级——挂起不满足依赖的部分,而非停止整个系统。
|
||||
|
||||
## Cordis 的突破
|
||||
|
||||
Cordis 选择了不同的路径——在运行时层面解决可组合性问题:
|
||||
|
||||
| 现有理论 | Cordis 方案 |
|
||||
|----------|-------------|
|
||||
| 类型标记副作用 | 运行时追踪并自动回收副作用 |
|
||||
| 编译期拒绝 | 运行时挂起/恢复 |
|
||||
| 面向短时程序 | 面向长时运行程序设计 |
|
||||
|
||||
这由两个互补机制实现:
|
||||
|
||||
- **[可逆作用](./revertible-effects)** — 将副作用形式化为可逆的群操作
|
||||
- **[响应式余作用](./reactive-coeffects)** — 将依赖建模为具有生命周期的服务
|
||||
|
||||
## 在 Agent 开发中的意义
|
||||
|
||||
对 DeepSeek Harness 而言,作用/余作用模型直接支撑了以下能力:
|
||||
|
||||
| 作用 (Effect) | 余作用 (Coeffect) |
|
||||
|---------------|-------------------|
|
||||
| 注册一个 tool | 依赖 tool registry 服务 |
|
||||
| 注册一个 LLM adapter | 依赖 LLM 服务接口 |
|
||||
| 监听 session 事件 | 依赖 session 服务存在 |
|
||||
| 启动子进程 | 依赖 bash executor 实现 |
|
||||
|
||||
每一个 effect 都可逆(tool 可注销、adapter 可移除);每一个 coeffect 都有生命周期(服务消失则依赖者挂起)。这就是 Agent 能被安全热替换的根本原因。
|
||||
@@ -0,0 +1,39 @@
|
||||
# 系统设计
|
||||
|
||||
DeepSeek Harness 建立在 Cordis 微内核之上,采用「一切皆插件」的架构。本节阐述这套设计背后的理论基础和设计哲学。
|
||||
|
||||
## 核心思想
|
||||
|
||||
Harness 追求三种可组合性的统一:
|
||||
|
||||
| 维度 | 含义 | Cordis 对应机制 |
|
||||
|------|------|----------------|
|
||||
| 逻辑可组合性 | 功能能否自由拆分和拼装 | 插件系统、事件系统 |
|
||||
| 时间可组合性 | 运行时能否安全地加载/卸载功能 | 可逆作用、自动清理 |
|
||||
| 空间可组合性 | 依赖关系能否被安全地声明和管理 | 服务生命周期、依赖注入 |
|
||||
|
||||
这三种可组合性在上下文模型中统一为单一的编程范式。
|
||||
|
||||
## 目录
|
||||
|
||||
- [可组合性与插件系统](./composability) — 组合的本质,以及传统插件系统为什么不可靠
|
||||
- [作用与余作用](./effects-coeffects) — Cordis 效果系统的理论模型
|
||||
- [可逆作用](./revertible-effects) — 时间可组合性的形式化定义与证明
|
||||
- [响应式余作用](./reactive-coeffects) — 空间可组合性的服务语义
|
||||
- [上下文模型](./context-model) — Context 如何将作用与余作用统一
|
||||
|
||||
## 设计如何映射到 Harness
|
||||
|
||||
| 理论概念 | Harness 中的体现 |
|
||||
|----------|-----------------|
|
||||
| 可逆作用 | `ctx.tools.register()` 返回 disposer;插件卸载时工具自动注销 |
|
||||
| 响应式余作用 | `inject: ['llm']` 声明依赖;LLM 适配器不可用时插件自动挂起 |
|
||||
| 上下文派生 | 子 Agent 拥有独立 Context,继承父级服务但有独立生命周期 |
|
||||
| Waterfall 事件 | `agent/request` 链式拦截,任一监听器可决定最终请求参数 |
|
||||
| Capability seam | bash/fs/web 三层拆分:接口 → 实现 → 模型工具 |
|
||||
|
||||
## 进一步阅读
|
||||
|
||||
- [插件与生命周期](/zh-CN/develop/framework/) — 实践中的 Fiber 状态机
|
||||
- [服务与依赖](/zh-CN/develop/framework/service) — 服务声明与注入
|
||||
- [能力的三层拆分](/zh-CN/develop/practice/) — Capability seam 模式
|
||||
@@ -0,0 +1,100 @@
|
||||
# 响应式余作用
|
||||
|
||||
响应式余作用 (Reactive Coeffects) 是 Cordis 实现**空间可组合性**的核心机制。
|
||||
|
||||
- 将代码中的资源依赖抽象为服务 (service) 的概念
|
||||
- 通过运行时生命周期语义,实现自动、安全、高效的资源管理
|
||||
|
||||
## 依赖的本质是生命周期
|
||||
|
||||
传统的依赖注入(如 Angular DI、Spring IoC)解决的是"怎么拿到依赖"的问题,但忽略了一个关键问题:**依赖是有生命周期的**。
|
||||
|
||||
一个数据库连接池可能重启,一个 API 服务可能下线,一个 LLM adapter 可能被热替换。当依赖消失时,依赖者应当如何表现?
|
||||
|
||||
- 崩溃?——对长时运行程序不可接受。
|
||||
- 继续运行?——可能产生不一致状态。
|
||||
- **自动挂起,等待恢复?**——Cordis 的选择。
|
||||
|
||||
## 服务与生命周期
|
||||
|
||||
Cordis 将程序中的资源依赖抽象为**服务** (service):
|
||||
|
||||
- 任何插件都可以声明自己依赖的服务列表
|
||||
- 服务存在明确的生命周期(提供、撤销)
|
||||
- 运行时对依赖不满足的插件**等待**,而非拒绝
|
||||
- 服务生命周期结束前,依赖该服务的插件**先一步被回收**
|
||||
|
||||
```ts
|
||||
import { Service, type Context } from 'cordis'
|
||||
|
||||
// LLM 适配器插件:提供 llm 服务
|
||||
export class LlmService extends Service {
|
||||
static inject = ['http'] // 自身依赖 http
|
||||
// 当 http 不可用时,LlmService 自动挂起
|
||||
// 挂起导致 ctx.llm 不可用
|
||||
// 所有 inject: ['llm'] 的插件级联挂起
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'llm')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 与现有理论的对比
|
||||
|
||||
### 与 Comonad 余作用比较
|
||||
|
||||
基于 Comonad 的余作用(Petricek 2013)将上下文建模为静态结构,侧重于编译期分析。Cordis 的响应式余作用额外引入了**时序语义**:
|
||||
|
||||
- 服务可在运行时出现/消失
|
||||
- 依赖关系随之动态建立/解除
|
||||
- 效果的生命周期由依赖关系决定
|
||||
|
||||
### 与 Grade Algebra 余作用比较
|
||||
|
||||
基于 Grade Algebra 的余作用(Gaboardi 2016)用有序半环描述资源的组合规则。Cordis 的服务依赖可以建模为**交换半群**:
|
||||
|
||||
- 服务名构成依赖集合
|
||||
- 集合并(∪)对应并行依赖
|
||||
- 交换律:依赖 A + B ≡ 依赖 B + A(声明顺序无关)
|
||||
- 结合律:依赖分组方式不影响语义
|
||||
|
||||
但 Cordis 还增加了代数不具备的运行时行为:当集合中的某个服务不可用时,整个依赖集不满足,触发挂起。
|
||||
|
||||
## 在 Cordis 中的实现
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
import type {} from '@deepseek-ai/dsh-llm'
|
||||
|
||||
// 声明依赖
|
||||
export const inject = ['tools', 'llm']
|
||||
|
||||
export function apply(ctx: Context) {
|
||||
// 到这里时,ctx.tools 和 ctx.llm 一定可用
|
||||
// 如果任一服务消失,此插件自动卸载
|
||||
// 服务恢复后,自动重新执行 apply
|
||||
}
|
||||
```
|
||||
|
||||
服务生命周期变化时的行为:
|
||||
|
||||
```
|
||||
llm service 可用 → 依赖 llm 的插件 PENDING → ACTIVE
|
||||
llm service 消失 → 依赖 llm 的插件 ACTIVE → DISPOSED
|
||||
llm service 恢复 → 依赖 llm 的插件重新 PENDING → ACTIVE
|
||||
```
|
||||
|
||||
## 为什么 Agent 需要响应式余作用
|
||||
|
||||
在 Harness 场景下,响应式余作用直接支撑:
|
||||
|
||||
| 场景 | 行为 |
|
||||
|------|------|
|
||||
| LLM adapter 热替换 | 依赖 `llm` 的插件自动挂起/恢复,中间不丢状态 |
|
||||
| 按需加载 bash 执行器 | bash tool 只在 `bash` 服务就绪后注册 |
|
||||
| 子 Agent 独立服务空间 | 通过 `ctx.isolate()` 隔离服务实例,互不干扰 |
|
||||
| 可选能力降级 | 不声明 `inject`,用 `ctx.get('web')` 读取——服务不可用时返回 `undefined`,插件照常运行 |
|
||||
|
||||
这意味着 Harness 插件开发者无需编写防御性的 "if service exists" 检查——框架保证:当你的 `apply` 被调用时,声明的依赖一定已就绪。
|
||||
@@ -0,0 +1,141 @@
|
||||
# 可逆作用
|
||||
|
||||
可逆作用 (Revertible Effects) 是 Cordis 实现**时间可组合性**的核心机制。
|
||||
|
||||
- 在单子作用的基础上增加可逆性约束
|
||||
- 提供面向长时运行程序的作用系统
|
||||
- 确保程序可以在插件粒度上回到任意状态
|
||||
|
||||
## 副作用的封装
|
||||
|
||||
现实中的程序需要与各种副作用打交道。假设一个不纯函数:
|
||||
|
||||
$$
|
||||
f_\text{impure}: \text{X}\to\text{Y}
|
||||
$$
|
||||
|
||||
我们将所有可能的副作用用类型 $\mathcal{C}$ 封装,函数变为:
|
||||
|
||||
$$
|
||||
f: \mathcal{C}\times\text{X}\to\mathcal{C}\times\text{Y}
|
||||
$$
|
||||
|
||||
对于长时运行程序,忽略函数本身的入参和出参,$f$ 属于函数空间 $\mathfrak{F}=\mathcal{C}\to\mathcal{C}$。
|
||||
|
||||
## 从幺半群到群
|
||||
|
||||
任何函数 $f: \mathcal{C}\to\mathcal{C}$ 都是状态空间到自身的变换。在组合 $\circ$ 下构成**幺半群**:
|
||||
|
||||
1. 封闭性:$f\circ g$ 也是 $\mathcal{C}\to\mathcal{C}$
|
||||
2. 结合律:$(f\circ g)\circ h=f\circ (g\circ h)$
|
||||
3. 单位元:$\text{id}$,使得 $f\circ\text{id}=\text{id}\circ f=f$
|
||||
|
||||
如果额外要求每个 $f$ 存在逆元 $f^{-1}$(即副作用可回收),$\mathfrak{F}$ 升级为**群**。
|
||||
|
||||
## 副作用都可逆吗?
|
||||
|
||||
观察计算机中的副作用模式:
|
||||
|
||||
| 操作 | 占用资源 | 逆操作 |
|
||||
|------|----------|--------|
|
||||
| 打开文件 | 文件描述符 | 关闭文件 |
|
||||
| 创建子进程 | 进程号 | 杀死进程 |
|
||||
| 监听端口 | 端口 | 取消监听 |
|
||||
| 添加回调函数 | 事件槽位 | 删除回调 |
|
||||
| 分配内存 | 内存区块 | 回收内存 |
|
||||
|
||||
**副作用就是对资源的占用。** 计算机的资源天然设计为可重复使用,因此这些副作用一定是可逆的。
|
||||
|
||||
## 追踪和回收副作用
|
||||
|
||||
Cordis 通过 $\text{effect}$ 和 $\text{restore}$ 函子追踪和回收逆函数。
|
||||
|
||||
### effect 函子
|
||||
|
||||
$$
|
||||
\begin{array}{}
|
||||
\text{effect}&:&
|
||||
\left(\mathcal{C}\to\mathcal{C}\right)&\to&
|
||||
\mathcal{C}\times\left(\mathcal{C}\to\mathcal{C}\right)&\to&
|
||||
\mathcal{C}\times\left(\mathcal{C}\to\mathcal{C}\right)\\
|
||||
\text{effect}&=&f&\mapsto&\left(c, h\right)&\mapsto&\left(f(c), h\circ f^{-1}\right)
|
||||
\end{array}
|
||||
$$
|
||||
|
||||
直觉:执行 $f$ 产生的副作用记入状态 $c$,同时将逆操作 $f^{-1}$ 追加到回收链 $h$ 中。
|
||||
|
||||
### 同态性证明
|
||||
|
||||
$\text{effect}$ 是从 $\mathcal{C}\to\mathcal{C}$ 到 $\mathcal{C}\times(\mathcal{C}\to\mathcal{C})\to\mathcal{C}\times(\mathcal{C}\to\mathcal{C})$ 的同态:
|
||||
|
||||
$$
|
||||
\begin{aligned}
|
||||
\text{effect}\ (f\circ g) \left(c, h\right)
|
||||
&=\left((f\circ g)(c), h\circ (f\circ g)^{-1}\right)\\
|
||||
&=\left(f(g(c)), h\circ g^{-1}\circ f^{-1}\right)\\
|
||||
&=\left(\text{effect}\ f\right)\left(g(c), h\circ g^{-1}\right)\\
|
||||
&=\left(\text{effect}\ f\right)\circ\left(\text{effect}\ g\right) \left(c, h\right)
|
||||
\end{aligned}
|
||||
$$
|
||||
|
||||
这意味着:组合两个操作后再追踪 = 分别追踪后再组合。副作用追踪与执行顺序无关。
|
||||
|
||||
### restore 函子
|
||||
|
||||
$$
|
||||
\begin{array}{}
|
||||
\text{restore}&:&
|
||||
\mathcal{C}\times\left(\mathcal{C}\to\mathcal{C}\right)&\to&
|
||||
\mathcal{C}\times\left(\mathcal{C}\to\mathcal{C}\right)\\
|
||||
\text{restore}&=&\left(c, h\right)&\mapsto&\left(h(c),\text{id}\right)
|
||||
\end{array}
|
||||
$$
|
||||
|
||||
直觉:将回收链 $h$ 应用到当前状态,一次性回收所有已追踪的副作用。
|
||||
|
||||
## 在 Cordis 中的实现
|
||||
|
||||
理论映射到 API:
|
||||
|
||||
| 数学概念 | Cordis API | 说明 |
|
||||
|----------|-----------|------|
|
||||
| $\text{effect}(f)$ | `ctx.effect(() => { ...; return dispose })` | 注册副作用并返回清理函数 |
|
||||
| $\text{restore}$ | `fiber.dispose()` | 执行 Fiber 的整个回收链 |
|
||||
| $f^{-1}$ | dispose 返回值 / cleanup 函数 | 逆操作 |
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Events {
|
||||
'my-plugin/event'(): void
|
||||
}
|
||||
}
|
||||
|
||||
declare function startServer(port: number): { close(): void }
|
||||
declare function handler(): void
|
||||
declare const myTool: ToolDefinition
|
||||
|
||||
export function apply(ctx: Context) {
|
||||
// effect: 创建资源,返回其逆操作
|
||||
ctx.effect(() => {
|
||||
const server = startServer(8080) // f: 占用端口
|
||||
return () => server.close() // f⁻¹: 释放端口
|
||||
})
|
||||
|
||||
// 框架 API 内部已封装 effect
|
||||
ctx.on('my-plugin/event', handler) // 内部: effect(addListener, removeListener)
|
||||
ctx.tools.register(myTool) // 内部: effect(addTool, removeTool)
|
||||
}
|
||||
// 当此插件被卸载时,restore 自动按逆序执行所有 f⁻¹
|
||||
```
|
||||
|
||||
## 为什么 Agent 需要可逆作用
|
||||
|
||||
在 Harness 场景下,可逆作用直接支撑:
|
||||
|
||||
- **热替换 LLM 适配器**:卸载旧适配器(回收注册)、加载新适配器,无需重启
|
||||
- **动态 tool 管理**:根据对话上下文动态添加/移除 tool,不泄漏
|
||||
- **子 Agent 生命周期**:子 Agent 完成后,其注册的所有临时 tool 和监听器自动清理
|
||||
- **优雅关闭**:进程退出时所有插件按依赖逆序 dispose,确保资源完全释放
|
||||
@@ -0,0 +1,110 @@
|
||||
# 插件配置
|
||||
|
||||
让你的插件接受用户在 `cordis.yml` 中传入的配置。
|
||||
|
||||
## 定义 Config 类型
|
||||
|
||||
在插件中导出一个 `Config` 类型,`apply` 的第二个参数就是用户配置:
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
|
||||
export const name = 'my-plugin'
|
||||
|
||||
export interface Config {
|
||||
greeting?: string
|
||||
maxRetries?: number
|
||||
verbose?: boolean
|
||||
}
|
||||
|
||||
export function apply(ctx: Context, config: Config) {
|
||||
console.log(config.greeting ?? 'Hello') // 用户配置或默认值
|
||||
}
|
||||
```
|
||||
|
||||
用户在 `cordis.yml` 中这样使用:
|
||||
|
||||
```yaml
|
||||
- name: './src/my-plugin.ts'
|
||||
config:
|
||||
greeting: 'Hi there'
|
||||
maxRetries: 5
|
||||
```
|
||||
|
||||
只导出类型时,配置原样传入,默认值由代码自己兜底(如上面的 `??`)。想让框架代管默认值和校验,导出一个 schema(见下节)。
|
||||
|
||||
## Schema 校验
|
||||
|
||||
对于需要默认值和严格校验的场景,额外导出一个 Schemastery schema(仓库约定以 `z` 引入)。加载时框架先用它校验并填充默认值,再把结果传给 `apply`:
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
|
||||
export const name = 'validated-plugin'
|
||||
|
||||
export interface Config {
|
||||
apiKey: string
|
||||
timeout?: number
|
||||
mode?: 'fast' | 'accurate'
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
apiKey: z.string().required(),
|
||||
timeout: z.number().default(30000),
|
||||
mode: z.union(['fast', 'accurate'] as const).default('fast'),
|
||||
})
|
||||
|
||||
export function apply(ctx: Context, config: Config) {
|
||||
// config 已经过校验,类型安全,默认值已填充
|
||||
}
|
||||
```
|
||||
|
||||
Schema 在插件加载时执行校验。如果配置不合法,插件会加载失败并给出明确错误信息。
|
||||
|
||||
## 设计原则
|
||||
|
||||
### 无硬编码可调参数
|
||||
|
||||
Harness 的约定:**任何两个部署可能想要不同值的东西,都应该是配置字段**。
|
||||
|
||||
```ts
|
||||
// 错误 — 硬编码超时时间
|
||||
const TIMEOUT = 30000
|
||||
|
||||
// 正确 — 可配置
|
||||
export interface Config {
|
||||
/** 默认 30000 */
|
||||
timeoutMs?: number
|
||||
}
|
||||
```
|
||||
|
||||
检验标准:能否在 `cordis.yml` 中改变这个值,而不需要修改代码?
|
||||
|
||||
### 配置错误要响亮
|
||||
|
||||
如果配置引用了不存在的东西(比如一个不存在的模型名),应该尽早报错,而不是静默跳过:
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
import type {} from '@deepseek-ai/dsh-llm'
|
||||
|
||||
export interface Config {
|
||||
model: string
|
||||
}
|
||||
|
||||
export function apply(ctx: Context, config: Config) {
|
||||
if (!ctx.llm.models().includes(config.model)) {
|
||||
throw new Error(`Model "${config.model}" is not registered by any LLM adapter`)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 配合 HMR
|
||||
|
||||
配置变更会触发插件热替换:修改 `cordis.yml` 中某个插件的 `config`,框架会卸载旧实例、加载新实例。由于注册都是效果(自动清理),这个过程是安全的。
|
||||
|
||||
## 下一步
|
||||
|
||||
- [插件与生命周期](../framework/) — 深入了解插件的完整生命周期
|
||||
- [服务与依赖](../framework/service) — 让你的插件对外提供服务
|
||||
@@ -0,0 +1,163 @@
|
||||
# 第一个插件
|
||||
|
||||
本文带你编写一个最小的 Harness 插件并加载到 Agent 中。
|
||||
|
||||
## 插件是什么
|
||||
|
||||
在 Harness 中,插件是一个导出 `apply` 函数的 TypeScript 模块。框架在加载时调用 `apply`,传入一个 `ctx`(上下文对象),你通过 `ctx` 注册能力:
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
|
||||
export const name = 'my-plugin'
|
||||
|
||||
export function apply(ctx: Context) {
|
||||
// 在这里注册能力
|
||||
}
|
||||
```
|
||||
|
||||
就这么简单。
|
||||
|
||||
## 创建插件文件
|
||||
|
||||
在你的项目目录下创建 `src/my-plugin.ts`:
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
|
||||
export const name = 'hello-plugin'
|
||||
|
||||
export function apply(ctx: Context) {
|
||||
// apply 函数体在插件加载时执行
|
||||
console.log('[hello-plugin] 插件已加载!')
|
||||
}
|
||||
```
|
||||
|
||||
## 注册到 cordis.yml
|
||||
|
||||
在你的 `cordis.yml` 中添加一条:
|
||||
|
||||
```yaml
|
||||
- id: hello
|
||||
name: './src/my-plugin.ts'
|
||||
```
|
||||
|
||||
启动后你会在控制台看到 `[hello-plugin] 插件已加载!`。
|
||||
|
||||
## 自动清理
|
||||
|
||||
通过 `ctx` 注册的任何东西——事件监听、tool、定时器——在插件卸载时都会被自动清理。你不需要手动 removeListener 或 clearInterval。
|
||||
|
||||
如果你有需要手动清理的资源(比如一个网络连接),用 `ctx.effect()` 告诉框架怎么清理:
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
|
||||
export function apply(ctx: Context) {
|
||||
ctx.effect(() => {
|
||||
const timer = setInterval(() => {
|
||||
console.log('heartbeat')
|
||||
}, 5000)
|
||||
|
||||
// 返回的函数会在插件卸载时被调用
|
||||
return () => clearInterval(timer)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## 声明依赖
|
||||
|
||||
如果你的插件需要使用其他服务(如 `tools`、`llm`),需要声明 `inject`:
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
export const name = 'my-tool-plugin'
|
||||
export const inject = ['tools']
|
||||
|
||||
export function apply(ctx: Context) {
|
||||
// ctx.tools 现在可用
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'demo',
|
||||
description: 'Demo tool.',
|
||||
parameters: {},
|
||||
async execute() {
|
||||
return []
|
||||
},
|
||||
}))
|
||||
}
|
||||
```
|
||||
|
||||
框架会确保依赖的服务就绪后才加载你的插件。
|
||||
|
||||
## 插件的三种形态
|
||||
|
||||
除了函数形式,插件还支持对象形式和类形式:
|
||||
|
||||
### 对象形式
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
|
||||
export default {
|
||||
name: 'my-plugin',
|
||||
inject: ['tools'],
|
||||
apply(ctx: Context) {
|
||||
// ...
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### 类形式
|
||||
|
||||
```ts
|
||||
import { Service, type Context } from 'cordis'
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
|
||||
export default class MyService extends Service {
|
||||
static inject = ['tools']
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'myService')
|
||||
}
|
||||
|
||||
// 服务的公开方法
|
||||
greet(name: string) {
|
||||
return `Hello, ${name}!`
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
大多数情况下,函数形式足够了。类形式用于需要对外提供服务的插件(见 [服务与依赖](../framework/service))。
|
||||
|
||||
## 完整示例
|
||||
|
||||
参考仓库中的 `examples/echo-agent/src/echo-tool.ts`,这是一个注册 tool 的插件:
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
export const name = 'echo-tool'
|
||||
export const inject = ['tools']
|
||||
|
||||
export function apply(ctx: Context) {
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'echo',
|
||||
description: 'Echo the given text back, uppercased.',
|
||||
parameters: {
|
||||
text: { type: 'string', required: true },
|
||||
},
|
||||
async execute(args) {
|
||||
return [{ type: 'text', text: `ECHO: ${args.text.toUpperCase()}` }]
|
||||
},
|
||||
}))
|
||||
}
|
||||
```
|
||||
|
||||
## 下一步
|
||||
|
||||
- [开发一个 Tool](./tool) — 详细了解 tool 定义 DSL
|
||||
- [插件配置](./config) — 让插件接受用户配置
|
||||
@@ -0,0 +1,242 @@
|
||||
# 开发一个 Tool
|
||||
|
||||
Tool 是模型可以调用的能力。本文介绍如何用 `defineTool` 编写一个 tool。
|
||||
|
||||
## 最小示例
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
export const name = 'my-tool'
|
||||
export const inject = ['tools']
|
||||
|
||||
export function apply(ctx: Context) {
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'greet',
|
||||
description: 'Greet someone by name.',
|
||||
parameters: {
|
||||
name: { type: 'string', required: true, description: 'The name to greet' },
|
||||
},
|
||||
async execute(args) {
|
||||
// args 自动推导为 { name: string }
|
||||
return [{ type: 'text', text: `Hello, ${args.name}!` }]
|
||||
},
|
||||
}))
|
||||
}
|
||||
```
|
||||
|
||||
## 参数定义
|
||||
|
||||
`parameters` 用一种简洁的格式描述参数,框架会自动转换为模型需要的 JSON Schema。
|
||||
|
||||
### 基本类型
|
||||
|
||||
```ts
|
||||
import type { SchemaSpec } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
const parameters = {
|
||||
path: { type: 'string', required: true },
|
||||
limit: { type: 'number' },
|
||||
recursive: { type: 'boolean' },
|
||||
} satisfies SchemaSpec
|
||||
// 推导类型: { path: string; limit?: number; recursive?: boolean }
|
||||
```
|
||||
|
||||
### 枚举
|
||||
|
||||
```ts
|
||||
import type { SchemaSpec } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
const parameters = {
|
||||
mode: { type: 'string', required: true, enum: ['read', 'write', 'append'] },
|
||||
} satisfies SchemaSpec
|
||||
// 推导类型: { mode: string } (运行时校验 enum 值)
|
||||
```
|
||||
|
||||
### 嵌套对象
|
||||
|
||||
```ts
|
||||
import type { SchemaSpec } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
const parameters = {
|
||||
options: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
timeout: { type: 'number' },
|
||||
retries: { type: 'number' },
|
||||
},
|
||||
},
|
||||
} satisfies SchemaSpec
|
||||
// 推导类型: { options?: { timeout?: number; retries?: number } }
|
||||
```
|
||||
|
||||
### 数组
|
||||
|
||||
```ts
|
||||
import type { SchemaSpec } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
const parameters = {
|
||||
tags: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
},
|
||||
} satisfies SchemaSpec
|
||||
// 推导类型: { tags?: string[] }
|
||||
```
|
||||
|
||||
### 每个属性的字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `type` | `'string' \| 'number' \| 'boolean' \| 'object' \| 'array'` | 值类型 |
|
||||
| `required` | `true` | 标记为必填(影响类型推导) |
|
||||
| `description` | `string` | 发送给模型的描述 |
|
||||
| `enum` | `string[]` | 允许的枚举值 |
|
||||
| `properties` | `SchemaSpec` | 嵌套属性(type 为 object 时) |
|
||||
| `items` | `SchemaProp` | 数组元素 schema(type 为 array 时) |
|
||||
|
||||
## execute 函数
|
||||
|
||||
`execute` 接收经过校验的 `args`(类型自动推导)和一个 `exec` 上下文对象:
|
||||
|
||||
```ts
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
defineTool({
|
||||
name: 'demo',
|
||||
description: 'Demo tool.',
|
||||
parameters: {},
|
||||
async execute(args, exec) {
|
||||
// args: 根据 parameters 自动推导的类型
|
||||
// exec: ToolExecution 对象,提供执行上下文
|
||||
|
||||
// 返回 ContentBlock 数组
|
||||
return [{ type: 'text', text: 'result here' }]
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### 返回值
|
||||
|
||||
`execute` 必须返回一个 `ContentBlock[]`,告诉模型 tool 的执行结果:
|
||||
|
||||
```ts
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
declare const matchResults: string[]
|
||||
|
||||
// 文本结果
|
||||
function textResult(): ContentBlock[] {
|
||||
return [{ type: 'text', text: 'file content here...' }]
|
||||
}
|
||||
|
||||
// 多个 block
|
||||
function multiBlockResult(): ContentBlock[] {
|
||||
return [
|
||||
{ type: 'text', text: 'Found 3 matches:' },
|
||||
{ type: 'text', text: matchResults.join('\n') },
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 参数校验
|
||||
|
||||
`defineTool` 在调用 `execute` 之前会自动校验模型生成的参数。如果参数不合法,会抛出 `ToolArgsError`,框架将其转换为 `isError` 结果返回给模型,让模型自行修正。
|
||||
|
||||
你不需要在 `execute` 里手动校验参数类型。
|
||||
|
||||
## 展示层 (Presentation)
|
||||
|
||||
Tool 可以定义 UI 渲染方法,用于在终端或 ACP 客户端中展示 tool call 和 result:
|
||||
|
||||
```ts
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
defineTool({
|
||||
name: 'bash',
|
||||
description: 'Run a shell command.',
|
||||
parameters: {
|
||||
command: { type: 'string', required: true },
|
||||
},
|
||||
async execute(args) {
|
||||
return [{ type: 'text', text: `ran: ${args.command}` }]
|
||||
},
|
||||
presentCall(args) {
|
||||
return {
|
||||
card: 'terminal',
|
||||
title: args.command.slice(0, 60),
|
||||
}
|
||||
},
|
||||
presentResult(args, result) {
|
||||
return {
|
||||
card: 'terminal',
|
||||
output: result.content.map(b => b.type === 'text' ? b.text : '').join(''),
|
||||
}
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
`presentCall` 和 `presentResult` 是**纯函数**,不能有副作用——UI 可能在流式传输中和会话回放中多次调用它们。
|
||||
|
||||
## 注册与卸载
|
||||
|
||||
`ctx.tools.register()` 返回值就是 disposer。但由于你在 `ctx` 上调用,框架已经自动追踪了这个注册——插件卸载时会自动移除 tool。你不需要手动调用 disposer。
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
declare const ctx: Context
|
||||
|
||||
// 这样就够了:
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'noop',
|
||||
description: 'Do nothing.',
|
||||
parameters: {},
|
||||
async execute() {
|
||||
return []
|
||||
},
|
||||
}))
|
||||
|
||||
// 不需要:
|
||||
// const dispose = ctx.tools.register(...)
|
||||
// ctx.effect(() => dispose)
|
||||
```
|
||||
|
||||
## 完整实战示例
|
||||
|
||||
一个文件计数 tool:
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import { readdir } from 'node:fs/promises'
|
||||
|
||||
export const name = 'file-counter'
|
||||
export const inject = ['tools']
|
||||
|
||||
export function apply(ctx: Context) {
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'count_files',
|
||||
description: 'Count files in a directory.',
|
||||
parameters: {
|
||||
path: { type: 'string', required: true, description: 'Directory path' },
|
||||
extension: { type: 'string', description: 'Filter by extension (e.g. ".ts")' },
|
||||
},
|
||||
async execute(args) {
|
||||
const entries = await readdir(args.path, { withFileTypes: true })
|
||||
let files = entries.filter(e => e.isFile())
|
||||
if (args.extension) {
|
||||
files = files.filter(f => f.name.endsWith(args.extension!))
|
||||
}
|
||||
return [{ type: 'text', text: `Found ${files.length} files.` }]
|
||||
},
|
||||
}))
|
||||
}
|
||||
```
|
||||
|
||||
## 下一步
|
||||
|
||||
- [插件配置](./config) — 让你的 tool 可配置
|
||||
- [能力三件套](../practice/) — 了解 seam/impl/consumer 模式
|
||||
@@ -0,0 +1,228 @@
|
||||
# 事件系统
|
||||
|
||||
事件是 Cordis 插件间通信的核心机制。Harness 大量使用事件来实现松耦合的扩展点。
|
||||
|
||||
## 基本用法
|
||||
|
||||
### 监听事件
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Events {
|
||||
'event-name'(payload: string): void
|
||||
}
|
||||
}
|
||||
|
||||
declare const ctx: Context
|
||||
|
||||
ctx.on('event-name', (payload) => {
|
||||
// 处理事件
|
||||
})
|
||||
```
|
||||
|
||||
### 触发事件
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Events {
|
||||
'event-name'(payload: string): void
|
||||
}
|
||||
}
|
||||
|
||||
declare const ctx: Context
|
||||
declare const payload: string
|
||||
|
||||
ctx.emit('event-name', payload)
|
||||
```
|
||||
|
||||
## 事件模式
|
||||
|
||||
Cordis 提供多种事件触发模式,适用于不同场景:
|
||||
|
||||
### emit — 广播
|
||||
|
||||
同步依次调用所有监听器,不等待、不关心返回值(监听器如果是 async,其 Promise 被忽略):
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Events {
|
||||
'my-plugin/turn-end'(agentId: string, turnIndex: number): void
|
||||
}
|
||||
}
|
||||
|
||||
declare const ctx: Context
|
||||
declare const agentId: string
|
||||
declare const turnIndex: number
|
||||
|
||||
// 触发
|
||||
ctx.emit('my-plugin/turn-end', agentId, turnIndex)
|
||||
|
||||
// 监听
|
||||
ctx.on('my-plugin/turn-end', (agentId, turnIndex) => {
|
||||
console.log(`Turn ${turnIndex} ended`)
|
||||
})
|
||||
```
|
||||
|
||||
### bail — 短路
|
||||
|
||||
同步依次调用监听器,第一个返回**非 `undefined`/`null`/`false`** 值的监听器终止链并作为最终值(返回 `undefined`/`null`/`false` 则继续下一个):
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Events {
|
||||
'some-check'(input: string): string | undefined
|
||||
}
|
||||
}
|
||||
|
||||
declare const ctx: Context
|
||||
declare const input: string
|
||||
declare function shouldBlock(input: string): boolean
|
||||
|
||||
// 触发
|
||||
const result = ctx.bail('some-check', input)
|
||||
|
||||
// 监听(返回值阻止后续监听器)
|
||||
ctx.on('some-check', (input) => {
|
||||
if (shouldBlock(input)) return 'blocked'
|
||||
// 返回 undefined 继续传递给下一个监听器
|
||||
return undefined
|
||||
})
|
||||
```
|
||||
|
||||
### serial — 顺序执行
|
||||
|
||||
按注册顺序逐个 `await` 监听器,遇到第一个 bail 值(非 `undefined`/`null`/`false`)即停止并返回它;全部返回空值则执行到底。相当于 `bail` 的异步版:
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Events {
|
||||
'setup-phase'(context: object): Promise<void> | void
|
||||
}
|
||||
}
|
||||
|
||||
declare const ctx: Context
|
||||
declare const context: object
|
||||
|
||||
await ctx.serial('setup-phase', context)
|
||||
```
|
||||
|
||||
### waterfall — 管道
|
||||
|
||||
监听器围绕默认实现层层包裹,形成数据管道。**必须调用 `next()` 委托给下游**,不调用即为否决:
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
import type { Message } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Events {
|
||||
'my-plugin/messages'(messages: Message[], next: () => Promise<Message[]>): Promise<Message[]>
|
||||
}
|
||||
}
|
||||
|
||||
declare const ctx: Context
|
||||
declare const messages: Message[]
|
||||
declare const extraMessage: Message
|
||||
|
||||
// 触发:最后一个参数是默认实现(所有监听器都调用 next 时的最终值)
|
||||
const finalMessages = await ctx.waterfall('my-plugin/messages', messages, async () => messages)
|
||||
|
||||
// 监听(必须调用 next)
|
||||
ctx.on('my-plugin/messages', async (messages, next) => {
|
||||
// next() 委托给下游监听器(最终到达默认实现),返回值可以被加工
|
||||
const result = await next()
|
||||
return [...result, extraMessage]
|
||||
})
|
||||
```
|
||||
|
||||
::: warning
|
||||
Waterfall 监听器**必须调用 `next()`**。不调用 `next` 等于否决整个管道,这是故意为之的设计——用于实现拦截/网关逻辑。
|
||||
:::
|
||||
|
||||
## Typed Events
|
||||
|
||||
Harness 使用 TypeScript 声明合并来为事件提供类型安全:
|
||||
|
||||
```ts
|
||||
import type {} from 'cordis'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Events {
|
||||
'my-plugin/ready'(payload: { id: string }): void
|
||||
'my-plugin/check'(input: string): boolean | undefined
|
||||
}
|
||||
}
|
||||
|
||||
// 现在 ctx.on('my-plugin/ready', ...) 和 ctx.emit('my-plugin/ready', ...)
|
||||
// 都有正确的类型推导
|
||||
```
|
||||
|
||||
## 命名约定
|
||||
|
||||
Harness 事件遵循 `namespace/action` 命名:
|
||||
|
||||
```
|
||||
agent/pre-step — 每个 step 开始前的检查点(serial)
|
||||
agent/step-result — step 的 assistant 消息组装完成(waterfall)
|
||||
tools/pre-execute — tool 执行前的允许/拒绝门(waterfall)
|
||||
tools/post-execute — tool 执行后的检查/改写缝(waterfall)
|
||||
llm/stream — 每次流式模型调用的环绕点(waterfall)
|
||||
session/event — 会话事件被记录(emit)
|
||||
session/flush — 会话持久化检查点(parallel)
|
||||
```
|
||||
|
||||
完整的事件列表(含每个事件的签名与派发模式)见仓库中的 `docs/cordis-catalog/events.md`。
|
||||
|
||||
## 事件也是效果
|
||||
|
||||
通过 `ctx.on()` 注册的监听器会在插件卸载时自动移除:
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
declare function handler(agent: Agent, status: AgentStatus): void
|
||||
|
||||
export function apply(ctx: Context) {
|
||||
// 这个监听器在插件 dispose 时自动清理
|
||||
ctx.on('agent/status', handler)
|
||||
}
|
||||
```
|
||||
|
||||
## 实战示例:日志插件
|
||||
|
||||
一个记录所有 tool 调用的简单插件:
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
|
||||
export const name = 'tool-logger'
|
||||
|
||||
export function apply(ctx: Context) {
|
||||
ctx.on('tools/execute', async (exec, next) => {
|
||||
console.log(`[tool] ${exec.name}(${JSON.stringify(exec.arguments)})`)
|
||||
const result = await next()
|
||||
const text = result.content
|
||||
.map(b => b.type === 'text' ? b.text : '')
|
||||
.join('')
|
||||
console.log(`[tool result] ${text.slice(0, 100)}`)
|
||||
return result
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## 下一步
|
||||
|
||||
- [能力三件套](../practice/) — 事件在 capability seam 中的角色
|
||||
- [LLM 适配器](../practice/llm-adapter) — 实现一个完整的 LLM 后端
|
||||
@@ -0,0 +1,157 @@
|
||||
# 插件与生命周期
|
||||
|
||||
深入了解 Cordis 插件模型和生命周期状态机。
|
||||
|
||||
## Fiber 状态机
|
||||
|
||||
每个被加载的插件对应一个 **Fiber**(作用域)。Fiber 有以下状态:
|
||||
|
||||
```
|
||||
PENDING → LOADING → ACTIVE
|
||||
↘ FAILED
|
||||
ACTIVE → UNLOADING → DISPOSED
|
||||
```
|
||||
|
||||
| 状态 | 含义 |
|
||||
|------|------|
|
||||
| PENDING | 已声明但依赖未就绪 |
|
||||
| LOADING | 依赖就绪,正在执行 `apply` |
|
||||
| ACTIVE | 插件运行中 |
|
||||
| FAILED | `apply` 抛出异常 |
|
||||
| UNLOADING | 正在卸载,清理中 |
|
||||
| DISPOSED | 已完全卸载 |
|
||||
|
||||
## 依赖驱动的加载
|
||||
|
||||
声明了 `inject` 的插件不会立即加载,而是等待依赖的服务就绪:
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
import type {} from '@deepseek-ai/dsh-llm'
|
||||
|
||||
export const inject = ['tools', 'llm']
|
||||
|
||||
export function apply(ctx: Context) {
|
||||
// 到这里时,ctx.tools 和 ctx.llm 一定存在
|
||||
}
|
||||
```
|
||||
|
||||
如果依赖的服务消失(比如提供者被热替换),插件会被自动卸载(ACTIVE → DISPOSED),待服务恢复后重新加载。
|
||||
|
||||
## 自动清理机制
|
||||
|
||||
通过 `ctx` 做的任何注册,在插件卸载时都会自动撤销:
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Events {
|
||||
'my-plugin/some-event'(): void
|
||||
}
|
||||
}
|
||||
|
||||
declare function handler(): void
|
||||
declare function createConnection(): { close(): void }
|
||||
|
||||
export function apply(ctx: Context) {
|
||||
// 事件监听——卸载时自动移除
|
||||
ctx.on('my-plugin/some-event', handler)
|
||||
|
||||
// 自定义资源——卸载时调用返回的函数
|
||||
ctx.effect(() => {
|
||||
const connection = createConnection()
|
||||
return () => connection.close()
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
以下操作都会被自动追踪和清理:
|
||||
- `ctx.on(event, handler)` — 事件监听
|
||||
- `ctx.tools.register(tool)` — tool 注册
|
||||
- `ctx.llm.registerAdapter(names, adapter)` — LLM 适配器注册
|
||||
- `ctx.effect(() => cleanup)` — 自定义资源
|
||||
|
||||
插件卸载时,这些注册按倒序逐个撤销。
|
||||
|
||||
## 嵌套上下文
|
||||
|
||||
`ctx.plugin()` 创建子 Fiber,它继承父上下文但有独立的生命周期:
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
|
||||
declare function childPlugin(ctx: Context): void
|
||||
|
||||
export function apply(ctx: Context) {
|
||||
// 注册一个子插件
|
||||
ctx.plugin(childPlugin)
|
||||
|
||||
// 子插件有自己的 Fiber,父卸载时子也卸载
|
||||
}
|
||||
```
|
||||
|
||||
## dispose 语义
|
||||
|
||||
当你需要提前终止一个插件实例:
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
|
||||
declare const ctx: Context
|
||||
declare function myPlugin(ctx: Context): void
|
||||
|
||||
const fiber = ctx.plugin(myPlugin)
|
||||
|
||||
// 之后可以手动 dispose
|
||||
await fiber.dispose()
|
||||
```
|
||||
|
||||
`dispose` 保证:
|
||||
1. 该插件注册的所有东西被撤销
|
||||
2. 它的子插件也被递归卸载
|
||||
3. 所有异步清理完成后 Promise resolve
|
||||
|
||||
## 热替换 (HMR)
|
||||
|
||||
在开发环境中(`cordis.yml` 加载了 `@cordisjs/plugin-hmr`),修改插件源文件会自动触发:
|
||||
|
||||
1. 卸载旧插件(清理所有注册)
|
||||
2. 重新加载新代码
|
||||
3. 执行新的 `apply`
|
||||
|
||||
因为所有注册都会被自动清理,所以热替换天然安全——不会留下旧状态。
|
||||
|
||||
## 实战:理解生命周期
|
||||
|
||||
`apply` 函数体就是加载钩子;卸载没有专门的事件——把清理逻辑放进 `ctx.effect()` 的返回函数即可:
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
|
||||
export function apply(ctx: Context) {
|
||||
console.log('plugin loading')
|
||||
|
||||
ctx.effect(() => {
|
||||
console.log('effect registered')
|
||||
return () => console.log('effect cleaned up')
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
加载时输出:
|
||||
```
|
||||
plugin loading
|
||||
effect registered
|
||||
```
|
||||
|
||||
卸载时输出:
|
||||
```
|
||||
effect cleaned up
|
||||
```
|
||||
|
||||
## 下一步
|
||||
|
||||
- [服务与依赖](./service) — 让你的插件对外提供能力
|
||||
- [事件系统](./events) — 插件间通信的核心机制
|
||||
@@ -0,0 +1,181 @@
|
||||
# 服务与依赖
|
||||
|
||||
服务 (Service) 是插件对外暴露能力的方式。依赖 (inject) 是插件声明自己需要哪些服务。
|
||||
|
||||
## 什么是服务
|
||||
|
||||
在 Harness 中,`tools`、`llm`、`agents` 都是服务。服务是挂载在 `ctx` 上的命名能力:
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
import type {} from '@deepseek-ai/dsh-llm'
|
||||
import type {} from '@deepseek-ai/dsh-agent'
|
||||
|
||||
declare const ctx: Context
|
||||
|
||||
ctx.tools // ToolRegistry 服务
|
||||
ctx.llm // LLM 服务
|
||||
ctx.agents // Agent 注册表服务
|
||||
```
|
||||
|
||||
任何插件都可以提供一个新服务,供其他插件使用。
|
||||
|
||||
## 使用服务
|
||||
|
||||
声明 `inject` 来使用已有服务:
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
export const inject = ['tools']
|
||||
|
||||
export function apply(ctx: Context) {
|
||||
// ctx.tools 在这里一定存在且就绪
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'demo',
|
||||
description: 'Demo tool.',
|
||||
parameters: {},
|
||||
async execute() {
|
||||
return []
|
||||
},
|
||||
}))
|
||||
}
|
||||
```
|
||||
|
||||
框架保证:在 `apply` 执行时,`inject` 声明的服务已经全部就绪。如果服务还没准备好,你的插件会等着,不会执行。
|
||||
|
||||
## 提供服务
|
||||
|
||||
### 使用 Service 基类
|
||||
|
||||
```ts
|
||||
import { Service, type Context } from 'cordis'
|
||||
import type {} from '@deepseek-ai/dsh-llm'
|
||||
|
||||
export default class MetricsService extends Service {
|
||||
static inject = ['llm'] // 本服务也可以依赖其他服务
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'metrics') // 'metrics' 是服务名
|
||||
}
|
||||
|
||||
// 服务的公开方法
|
||||
record(event: string, value: number) {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
加载这个插件后,其他插件就可以通过 `ctx.metrics` 访问它:
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
|
||||
export const inject = ['metrics']
|
||||
|
||||
export function apply(ctx: Context) {
|
||||
ctx.metrics.record('tool_call', 1)
|
||||
}
|
||||
```
|
||||
|
||||
### 类型声明
|
||||
|
||||
使用 TypeScript 声明合并让 `ctx.metrics` 有正确类型:
|
||||
|
||||
```ts
|
||||
import { Service, type Context } from 'cordis'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
metrics: MetricsService
|
||||
}
|
||||
}
|
||||
|
||||
export default class MetricsService extends Service {
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'metrics')
|
||||
}
|
||||
|
||||
record(event: string, value: number) { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
## 依赖的行为
|
||||
|
||||
### 必选依赖 vs 可选读取
|
||||
|
||||
`inject` 声明的依赖都是必选的:服务不存在时,插件不会加载。如果只想"有则用之",用 `ctx.get()` 读取——服务不存在时返回 `undefined`,插件照常加载:
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
|
||||
// 必选:服务不存在时,插件不会加载
|
||||
export const inject = ['tools']
|
||||
|
||||
export function apply(ctx: Context) {
|
||||
// 可选读取:不声明 inject,服务不存在时返回 undefined
|
||||
const metrics = ctx.get('metrics')
|
||||
metrics?.record('plugin_loaded', 1)
|
||||
}
|
||||
```
|
||||
|
||||
### 服务消失时的行为
|
||||
|
||||
如果一个必选依赖的服务在运行时消失(比如提供者被卸载):
|
||||
|
||||
1. 依赖它的插件自动 dispose
|
||||
2. 当服务重新出现时,插件自动重新加载
|
||||
|
||||
这保证了不会出现"调用一个已不存在的服务"的情况。
|
||||
|
||||
## 服务隔离
|
||||
|
||||
`cordis.yml` 支持服务隔离——同一个服务可以有多个实例,不同插件组看到不同实例。用 `@cordisjs/plugin-group` 建组(`group: true` 标记组条目),并在组上声明 `isolate`,把该服务隔离进组内作用域:
|
||||
|
||||
```yaml
|
||||
- id: group-a
|
||||
name: '@cordisjs/plugin-group'
|
||||
group: true
|
||||
isolate:
|
||||
bash: true
|
||||
config:
|
||||
- name: '@deepseek-ai/dsh-bash-local'
|
||||
config:
|
||||
timeoutMs: 5000
|
||||
- name: './src/plugin-a.ts'
|
||||
|
||||
- id: group-b
|
||||
name: '@cordisjs/plugin-group'
|
||||
group: true
|
||||
isolate:
|
||||
bash: true
|
||||
config:
|
||||
- name: '@deepseek-ai/dsh-bash-local'
|
||||
config:
|
||||
timeoutMs: 60000
|
||||
- name: './src/plugin-b.ts'
|
||||
```
|
||||
|
||||
`plugin-a` 和 `plugin-b` 各自看到自己组内的 bash 实例,互不影响。`isolate: { bash: true }` 是必需的:不隔离的话,两个组在同一作用域注册同名服务,第二个会直接报重复注册错误。
|
||||
|
||||
## Harness 内置服务一览
|
||||
|
||||
| 服务名 | 提供者 | 用途 |
|
||||
|--------|--------|------|
|
||||
| `tools` | dsh-tools | Tool 注册表 |
|
||||
| `llm` | dsh-llm | LLM 调用 + 适配器注册 |
|
||||
| `agents` | dsh-agent | Agent 注册表 |
|
||||
| `agentLoop` | dsh-agent-loop | Agent 创建与循环执行 |
|
||||
| `sessions` | dsh-session | 会话存储与事件流 |
|
||||
| `systemPrompt` | dsh-system-prompt | 系统提示词组装 |
|
||||
| `bash` | dsh-bash(实现:dsh-bash-local) | Bash 命令执行 |
|
||||
| `fs` | dsh-fs(实现:dsh-fs-local) | 文件系统操作 |
|
||||
| `subagents` | dsh-subagent | 子代理委派 |
|
||||
| `sessionPersistence` | dsh-session-persistence(实现:-jsonl / -sqlite) | 会话持久化 |
|
||||
|
||||
## 下一步
|
||||
|
||||
- [事件系统](./events) — 插件间松耦合通信
|
||||
- [能力三件套](../practice/) — 服务在 seam 模式中的应用
|
||||
@@ -0,0 +1,156 @@
|
||||
# 能力的三层拆分
|
||||
|
||||
当一个能力(插件)足够通用(比如"执行 bash 命令"),Harness 会把它拆成三个包:**接口**、**实现**、**消费者**。这样可以独立替换其中任何一层。
|
||||
|
||||
## 以 Bash 为例
|
||||
|
||||
考虑 "Bash 执行" 这个能力:
|
||||
|
||||
- **接口** (`dsh-bash`) — 定义"bash 执行"长什么样:输入是什么、输出是什么
|
||||
- **实现** (`dsh-bash-local`) — 真正在本地跑命令的代码
|
||||
- **消费者** (`dsh-tool-bash`) — 把这个能力包装成模型能调用的 tool
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌──────────────────┐ ┌──────────────┐
|
||||
│ dsh-bash │────▶│ dsh-bash-local │ │ dsh-tool-bash│
|
||||
│ (接口) │ │ (实现) │ │ (消费者/tool)│
|
||||
└─────────────┘ └──────────────────┘ └──────────────┘
|
||||
▲ │
|
||||
└────────────────────────────────────────────┘
|
||||
inject: ['bash']
|
||||
```
|
||||
|
||||
## 拆分的好处
|
||||
|
||||
### 具体实现可替换
|
||||
|
||||
同一个接口可以有多种实现。用户通过 `cordis.yml` 选择:
|
||||
|
||||
```yaml
|
||||
# 本地执行
|
||||
- name: '@deepseek-ai/dsh-bash-local'
|
||||
|
||||
# 或:远程沙箱执行(未来)
|
||||
# - name: '@deepseek-ai/dsh-bash-remote'
|
||||
# config:
|
||||
# endpoint: 'https://sandbox.example.com'
|
||||
```
|
||||
|
||||
接口不变、tool 不变,只换实现。
|
||||
|
||||
### 独立演进
|
||||
|
||||
- 接口定义稳定后很少改动
|
||||
- 实现可以独立优化(性能、安全)
|
||||
- 消费者(tool)可以调整对模型的呈现方式
|
||||
|
||||
### 依赖解耦
|
||||
|
||||
- 实现 depend on 接口
|
||||
- 消费者 depend on 接口
|
||||
- 实现和消费者**互不依赖**
|
||||
|
||||
## Harness 中内置的三件套
|
||||
|
||||
| 能力 | 接口 (seam) | 实现 | 消费者 (tool) |
|
||||
|------|-------------|------|---------------|
|
||||
| Bash | `dsh-bash` | `dsh-bash-local` | `dsh-tool-bash` |
|
||||
| 文件系统 | `dsh-fs` | `dsh-fs-local` + `dsh-fs-policy` | `dsh-tool-fs` |
|
||||
| Web | `dsh-web` | `dsh-web-fetch-local` / `dsh-web-search-*` | `dsh-tool-web` |
|
||||
| 子代理 | `dsh-subagent` | `dsh-subagent-spawn` / `dsh-subagent-fork` | `dsh-tool-subagent` |
|
||||
| 压缩 | `dsh-compact` | `dsh-compact-basic` | (内置于 agent-loop) |
|
||||
|
||||
## 开发你自己的三件套
|
||||
|
||||
### 第一步:定义接口
|
||||
|
||||
```ts
|
||||
// packages/my-cap/my-cap/src/index.ts
|
||||
import { Service, type Context } from 'cordis'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
myCap: MyCapService
|
||||
}
|
||||
}
|
||||
|
||||
export abstract class MyCapService extends Service {
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'myCap')
|
||||
}
|
||||
|
||||
/** 执行能力的核心方法 */
|
||||
abstract execute(request: MyCapRequest): Promise<MyCapResult>
|
||||
}
|
||||
|
||||
export interface MyCapRequest {
|
||||
input: string
|
||||
}
|
||||
|
||||
export interface MyCapResult {
|
||||
output: string
|
||||
}
|
||||
```
|
||||
|
||||
### 第二步:编写实现
|
||||
|
||||
```ts ignore-check
|
||||
// packages/my-cap/my-cap-local/src/index.ts
|
||||
import type { Context } from 'cordis'
|
||||
import { MyCapService, type MyCapRequest, type MyCapResult } from '@deepseek-ai/dsh-my-cap'
|
||||
|
||||
class MyCapLocal extends MyCapService {
|
||||
async execute(request: MyCapRequest): Promise<MyCapResult> {
|
||||
// 具体实现
|
||||
return { output: request.input.toUpperCase() }
|
||||
}
|
||||
}
|
||||
|
||||
export const name = 'my-cap-local'
|
||||
|
||||
export function apply(ctx: Context) {
|
||||
ctx.plugin(MyCapLocal)
|
||||
}
|
||||
```
|
||||
|
||||
### 第三步:编写消费者 (tool)
|
||||
|
||||
```ts
|
||||
// packages/my-cap/tool-my-cap/src/index.ts
|
||||
import type { Context } from 'cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
export const name = 'tool-my-cap'
|
||||
export const inject = ['tools', 'myCap']
|
||||
|
||||
export function apply(ctx: Context) {
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'my_cap',
|
||||
description: 'Execute my capability.',
|
||||
parameters: {
|
||||
input: { type: 'string', required: true },
|
||||
},
|
||||
async execute(args) {
|
||||
const result = await ctx.myCap.execute({ input: args.input })
|
||||
return [{ type: 'text', text: result.output }]
|
||||
},
|
||||
}))
|
||||
}
|
||||
```
|
||||
|
||||
### 在 cordis.yml 中组合
|
||||
|
||||
```yaml ignore-check
|
||||
- name: '@deepseek-ai/dsh-my-cap-local'
|
||||
- name: '@deepseek-ai/dsh-tool-my-cap'
|
||||
```
|
||||
|
||||
## 设计要点
|
||||
|
||||
- **不要预防性拆分** — 只有当你确实需要可替换实现时才拆三件套。一个简单的 tool 插件不需要拆分。
|
||||
- **接口定义 Request/Result 类型** — 实现和消费者只依赖接口包。
|
||||
- **Explicit > Implicit** — 实现中的默认值处理应该是显式的 `resolve(request): Spec` 步骤,不是隐藏在 `run()` 中的 `?? default`。
|
||||
|
||||
## 下一步
|
||||
|
||||
- [LLM 适配器](./llm-adapter) — 实现一个 LLM 后端(最常见的 seam 扩展)
|
||||
@@ -0,0 +1,182 @@
|
||||
# LLM 适配器
|
||||
|
||||
本文介绍如何为 Harness 接入一个新的 LLM 提供方。
|
||||
|
||||
## 概述
|
||||
|
||||
LLM 适配器是一个继承 `LlmAdapter` 的类,实现 `stream()` 方法,将 Harness 的统一请求格式转换为具体 API 的调用。
|
||||
|
||||
## 最小实现
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
import { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
class MyAdapter extends LlmAdapter {
|
||||
private apiKey: string
|
||||
|
||||
constructor(apiKey: string) {
|
||||
super()
|
||||
this.apiKey = apiKey
|
||||
}
|
||||
|
||||
async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
// 1. 将 options.messages 转换为你的 API 格式
|
||||
// 2. 调用 API(流式)
|
||||
// 3. 将 API 响应转换为 StreamChunk 序列
|
||||
}
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
apiKey: string
|
||||
models: string[]
|
||||
}
|
||||
|
||||
export const name = 'my-llm-adapter'
|
||||
export const inject = ['llm']
|
||||
|
||||
export function apply(ctx: Context, config: Config) {
|
||||
const adapter = new MyAdapter(config.apiKey)
|
||||
ctx.llm.registerAdapter(config.models, adapter)
|
||||
}
|
||||
```
|
||||
|
||||
## StreamChunk 协议
|
||||
|
||||
`stream()` 必须按以下协议 yield chunk:
|
||||
|
||||
```ts
|
||||
import { CallId, type StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
async function* demo(): AsyncIterable<StreamChunk> {
|
||||
// 1. 每个内容块以 block-start 开始
|
||||
yield { type: 'block-start', index: 0, blockType: 'text' }
|
||||
|
||||
// 2. 文本块使用 text-delta
|
||||
yield { type: 'text-delta', index: 0, text: 'Hello' }
|
||||
yield { type: 'text-delta', index: 0, text: ' world' }
|
||||
|
||||
// 3. 每个内容块以 block-end 结束(携带完整 block)
|
||||
yield {
|
||||
type: 'block-end',
|
||||
index: 0,
|
||||
block: { type: 'text', text: 'Hello world' },
|
||||
}
|
||||
|
||||
// 4. Tool call 块
|
||||
yield { type: 'block-start', index: 1, blockType: 'tool-call' }
|
||||
yield {
|
||||
type: 'tool-call-delta',
|
||||
index: 1,
|
||||
id: CallId('call-123'),
|
||||
name: 'bash',
|
||||
argumentsDelta: '{"command":"ls"}',
|
||||
}
|
||||
yield {
|
||||
type: 'block-end',
|
||||
index: 1,
|
||||
block: {
|
||||
type: 'tool-call',
|
||||
id: CallId('call-123'),
|
||||
name: 'bash',
|
||||
arguments: '{"command":"ls"}',
|
||||
},
|
||||
}
|
||||
|
||||
// 5. Token 用量
|
||||
yield { type: 'usage', usage: { inputTokens: 100, outputTokens: 50 } }
|
||||
|
||||
// 6. 结束原因
|
||||
yield { type: 'finish', reason: { kind: 'stop' } }
|
||||
// 或: { kind: 'tool-calls' } 表示模型想调用 tool
|
||||
}
|
||||
```
|
||||
|
||||
### 关键规则
|
||||
|
||||
- 每个 `block-start` 必须有对应的 `block-end`
|
||||
- `index` 从 0 递增,标识内容块顺序
|
||||
- `tool-call-delta` 的 `argumentsDelta` 是 JSON 字符串的增量(可以一次 yield 全部,也可以分多次)
|
||||
- `finish` 必须是最后一个 chunk
|
||||
- `usage` 在 `finish` 之前 yield
|
||||
|
||||
## GenerateOptions
|
||||
|
||||
`stream()` 接收的请求包含:
|
||||
|
||||
```ts
|
||||
import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
declare const options: GenerateOptions
|
||||
|
||||
options.model // 模型名
|
||||
options.messages // 对话历史 (Message[])
|
||||
options.tools // 可用的 tool schema 列表 (ToolSchema[])
|
||||
options.system // 系统提示词
|
||||
options.maxTokens // 最大输出 token
|
||||
options.temperature // 温度
|
||||
options.signal // 取消信号(必须响应)
|
||||
```
|
||||
|
||||
你的适配器需要将这些映射到具体 API 的参数。
|
||||
|
||||
## 注册适配器
|
||||
|
||||
```ts
|
||||
import type { Context } from 'cordis'
|
||||
import type { LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
declare const ctx: Context
|
||||
declare const adapter: LlmAdapter
|
||||
|
||||
ctx.llm.registerAdapter(['model-name-1', 'model-name-2'], adapter)
|
||||
```
|
||||
|
||||
第一个参数是该适配器支持的模型名列表。当用户在 `cordis.yml` 中配置 `model: model-name-1` 时,框架会路由到这个适配器。
|
||||
|
||||
## 在 cordis.yml 中使用
|
||||
|
||||
```yaml
|
||||
- id: my-llm
|
||||
name: './src/my-llm-adapter.ts'
|
||||
config:
|
||||
apiKey: !!js process.env.MY_API_KEY
|
||||
models:
|
||||
- my-model-v1
|
||||
- my-model-v2
|
||||
|
||||
- id: stdio-agent
|
||||
name: '@deepseek-ai/dsh-stdio-demo'
|
||||
config:
|
||||
model: my-model-v1 # 引用上面注册的模型名
|
||||
```
|
||||
|
||||
## 实战参考
|
||||
|
||||
仓库中有两个完整实现可供参考:
|
||||
|
||||
- `packages/llm/llm-deepseek/` — DeepSeek API 适配器(OpenAI 兼容格式)
|
||||
- `packages/llm/llm-pi-ai/` — Pi AI 适配器(不同的 API 格式)
|
||||
- `examples/echo-agent/src/mock-llm.ts` — 最简 mock 适配器(教学用)
|
||||
|
||||
mock 适配器是学习 StreamChunk 协议的最佳起点——它用纯本地逻辑演示了完整的 chunk 序列。
|
||||
|
||||
## 错误处理
|
||||
|
||||
适配器中的异常会被 agent-loop 捕获并转化为 `LlmError`,告知上层。不需要在 `stream()` 内部做错误恢复——让异常冒泡即可。
|
||||
|
||||
```ts
|
||||
import { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
class HttpAdapter extends LlmAdapter {
|
||||
private endpoint = 'https://api.example.com/v1/chat'
|
||||
|
||||
async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
const response = await fetch(this.endpoint, { method: 'POST' })
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.status}`)
|
||||
}
|
||||
// ... 正常流式处理
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,365 @@
|
||||
# 配置文件
|
||||
|
||||
Harness 使用 `cordis.yml` 描述一个 Agent 加载哪些插件、以什么参数运行。
|
||||
|
||||
## 从例子开始
|
||||
|
||||
### echo-agent 的配置
|
||||
|
||||
这是一开始的第一个 Agent 的完整配置:
|
||||
|
||||
```yaml
|
||||
# 热替换:修改代码后自动重载,不用手动重启
|
||||
- id: hmr
|
||||
name: '@cordisjs/plugin-hmr'
|
||||
config:
|
||||
root: ['.']
|
||||
|
||||
# Mock 模型:从本地 `.ts` 文件加载,注册一个名为 `mock-llm` 的工具
|
||||
# 本地模拟 LLM 响应,不联网
|
||||
- id: mock-llm
|
||||
name: './src/mock-llm.ts'
|
||||
|
||||
# Echo 工具:收到文本后转大写返回
|
||||
- id: echo-tool
|
||||
name: './src/echo-tool.ts'
|
||||
|
||||
# Bash 执行器:从 npm 包 `@deepseek-ai/dsh-bash-local`加载,提供 bash 命令执行能力
|
||||
- id: bash
|
||||
name: '@deepseek-ai/dsh-bash-local'
|
||||
|
||||
# 应用主体:把 session 管理、tool 调度、agent loop 等组装成一个可交互的终端 Agent
|
||||
# 只需告诉它用哪个模型 (`model`)、什么人设 (`persona`)
|
||||
- id: stdio-agent
|
||||
name: '@deepseek-ai/dsh-stdio-demo'
|
||||
config:
|
||||
model: mock-echo
|
||||
persona: 'You are echo-agent, a demo agent.'
|
||||
welcome: 'echo-agent ready. Type a message ("echo <text>" triggers the tool).'
|
||||
persistenceRoot: './.sessions'
|
||||
```
|
||||
|
||||
### coding-agent 的配置
|
||||
|
||||
真实场景——接入 DeepSeek API,带完整工具链:
|
||||
|
||||
```yaml
|
||||
# 热替换:同上,开发时自动重载
|
||||
- id: hmr
|
||||
name: '@cordisjs/plugin-hmr'
|
||||
config:
|
||||
root: ['.']
|
||||
|
||||
# LLM 后端:从 npm 包加载,具备接入 DeepSeek API 能力
|
||||
# `!!js` 从环境变量读取密钥,不会写进配置文件
|
||||
# `models` 声明该适配器能处理哪些模型名
|
||||
- id: llm-deepseek
|
||||
name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
config:
|
||||
apiKey: !!js process.env.DEEPSEEK_API_KEY
|
||||
baseURL: !!js process.env.DEEPSEEK_BASE_URL
|
||||
models:
|
||||
- deepseek-v4-pro
|
||||
- deepseek-v4-flash
|
||||
|
||||
# Bash 执行器:让 Agent 能跑 shell 命令
|
||||
# timeoutMs 设置单条命令的超时时间
|
||||
- id: bash
|
||||
name: '@deepseek-ai/dsh-bash-local'
|
||||
config:
|
||||
timeoutMs: 60000
|
||||
|
||||
# 应用主体:和 echo-agent 一样的框架,只是配置不同
|
||||
# `model` 指定默认使用哪个模型(要和上面 models 列表里的名字对应)
|
||||
# `persona` 是系统提示词,{{model}} 会被替换为实际模型名
|
||||
# `resumeSessionId` 设了就恢复旧对话,没设就每次新建
|
||||
- id: stdio-agent
|
||||
name: '@deepseek-ai/dsh-stdio-demo'
|
||||
config:
|
||||
model: deepseek-v4-flash
|
||||
resumeSessionId: !!js process.env.RESUME_SESSION_ID
|
||||
persistenceRoot: './.sessions'
|
||||
welcome: 'agent REPL ready. Give it a coding task.'
|
||||
persona: |
|
||||
You are coding-agent, a coding assistant powered by the {{model}} model.
|
||||
Verify your work by running the code or tests. Keep answers brief and factual.
|
||||
|
||||
# 自动压缩:对话太长时自动总结旧内容,腾出上下文空间
|
||||
# contextWindow 是模型能看到的 token 上限
|
||||
# thresholdRatio 超过这个比例就触发压缩
|
||||
# compactionRetries 是压缩后仍超标时的额外重试次数
|
||||
- id: compact-basic
|
||||
name: '@deepseek-ai/dsh-compact-basic'
|
||||
config:
|
||||
contextWindow: 128000
|
||||
thresholdRatio: 0.8
|
||||
retainTokens: 20480
|
||||
maxTokens: 8192
|
||||
compactionRetries: 1
|
||||
|
||||
# 子代理:把子任务分配给独立的 Agent 去做
|
||||
# subagent 是服务注册,spawn/fork 是两种委派方式:
|
||||
# spawn — 全新子代理,不知道父级在聊什么
|
||||
# fork — 继承父级对话上下文的子代理
|
||||
# tool-subagent 把委派能力暴露给模型,toolName 是模型看到的工具名
|
||||
- id: subagent
|
||||
name: '@deepseek-ai/dsh-subagent'
|
||||
|
||||
- id: subagent-spawn
|
||||
name: '@deepseek-ai/dsh-subagent-spawn'
|
||||
config:
|
||||
providerName: spawn
|
||||
|
||||
- id: subagent-fork
|
||||
name: '@deepseek-ai/dsh-subagent-fork'
|
||||
config:
|
||||
providerName: fork
|
||||
|
||||
- id: tool-subagent
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
config:
|
||||
provider: spawn
|
||||
toolName: subagent
|
||||
|
||||
- id: tool-subagent-fork
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
config:
|
||||
provider: fork
|
||||
toolName: subagent_fork
|
||||
|
||||
# 动态工作流:模型编写一段编排脚本,引擎在独立 worker 线程里运行它,
|
||||
# 并通过上面的 spawn 后端把 agent() 调用分发为子代理
|
||||
- id: workflow-workerthread
|
||||
name: '@deepseek-ai/dsh-workflow-workerthread'
|
||||
config:
|
||||
provider: spawn
|
||||
|
||||
- id: tool-workflow
|
||||
name: '@deepseek-ai/dsh-tool-workflow'
|
||||
|
||||
# 任务追踪:模型可以用 todo_write 记录和更新任务清单
|
||||
- id: tool-todo
|
||||
name: '@deepseek-ai/dsh-tool-todo'
|
||||
|
||||
# 文件系统:让 Agent 能读写编辑文件
|
||||
# fs-local 提供本地文件操作能力,cwd 是工作目录
|
||||
# fs-policy 是安全策略——必须先读才能写,防止模型盲写
|
||||
# tool-fs 把能力暴露给模型(read / write / edit 三个工具)
|
||||
- id: fs-local
|
||||
name: '@deepseek-ai/dsh-fs-local'
|
||||
config:
|
||||
cwd: !!js process.cwd()
|
||||
|
||||
- id: fs-policy
|
||||
name: '@deepseek-ai/dsh-fs-policy'
|
||||
|
||||
- id: tool-fs
|
||||
name: '@deepseek-ai/dsh-tool-fs'
|
||||
```
|
||||
|
||||
和 echo-agent 对比:同一个 `dsh-stdio-demo` 应用主体,只是把 mock 换成了真实 API,加上了更多工具插件。
|
||||
|
||||
## 语法详解
|
||||
|
||||
### 插件声明字段
|
||||
|
||||
每个插件条目支持以下字段:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `name` | string | 是 | 插件来源(npm 包名或相对路径) |
|
||||
| `id` | string | 否 | 实例标识符,用于日志和调试。省略时由 loader 生成并写回 |
|
||||
| `config` | object | 否 | 传递给插件的配置 |
|
||||
| `disabled` | boolean | 否 | 设为 `true` 临时禁用该插件 |
|
||||
| `group` | boolean | 否 | 标记该条目为嵌套分组(`config` 为子条目列表) |
|
||||
| `inject` | array \| object | 否 | 声明该插件依赖的服务 |
|
||||
| `intercept` | object | 否 | 按服务名拦截并覆盖下游配置 |
|
||||
| `isolate` | object | 否 | 服务隔离:服务名 → `true` 或隔离标签 |
|
||||
|
||||
### 插件来源 (`name`)
|
||||
|
||||
**npm 包** — 已安装的 `@deepseek-ai/dsh-*` 包或第三方包:
|
||||
|
||||
```yaml
|
||||
- name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
```
|
||||
|
||||
**相对路径** — 本地 TypeScript 文件(相对于 `cordis.yml` 所在目录):
|
||||
|
||||
```yaml
|
||||
- name: './src/my-tool.ts'
|
||||
```
|
||||
|
||||
### 环境变量 (`!!js`)
|
||||
|
||||
用 `!!js` 标签在配置中引用运行时表达式:
|
||||
|
||||
```yaml
|
||||
config:
|
||||
apiKey: !!js process.env.DEEPSEEK_API_KEY
|
||||
cwd: !!js process.cwd()
|
||||
```
|
||||
|
||||
::: warning
|
||||
是 `!!js`(两个感叹号),不是 `!js`。写错了会静默失败。
|
||||
:::
|
||||
|
||||
环境变量从仓库根目录的 `.env` 文件自动加载(已被 gitignore)。
|
||||
|
||||
### 禁用插件
|
||||
|
||||
不想删配置但暂时不加载?加一行 `disabled`:
|
||||
|
||||
```yaml
|
||||
- id: compact-basic
|
||||
name: '@deepseek-ai/dsh-compact-basic'
|
||||
disabled: true
|
||||
config:
|
||||
contextWindow: 128000
|
||||
```
|
||||
|
||||
## 各插件配置参考
|
||||
|
||||
### stdio-agent(标准应用主体)
|
||||
|
||||
**包名:** `@deepseek-ai/dsh-stdio-demo`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `model` | string | **必填** | 使用的模型名,需与 LLM 适配器注册的名字一致 |
|
||||
| `persona` | string | `''` | 系统提示词。支持 `{{model}}` 等模板变量 |
|
||||
| `toolOrder` | string[] | — | 模型看到的工具顺序。省略则按字母排序 |
|
||||
| `persistenceRoot` | string | `'./.sessions'` | 会话日志存储目录 |
|
||||
| `welcome` | string | `'ready.'` | 启动时显示的欢迎信息 |
|
||||
| `resumeSessionId` | string | — | 恢复指定会话 ID。留空则每次新建 |
|
||||
|
||||
### llm-deepseek(DeepSeek 适配器)
|
||||
|
||||
**包名:** `@deepseek-ai/dsh-llm-deepseek`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `apiKey` | string | `$DEEPSEEK_API_KEY` | API 密钥。省略则从环境变量读取 |
|
||||
| `baseURL` | string | `$DEEPSEEK_BASE_URL` 或官方地址 | API 端点 |
|
||||
| `models` | string[] | `['deepseek-v4-flash', 'deepseek-v4-pro']` | 注册的模型名列表 |
|
||||
| `thinking` | `'enabled'` \| `'disabled'` | `'enabled'` | 是否开启思维链 |
|
||||
| `reasoningEffort` | `'high'` \| `'max'` | — | 思维链深度(仅 thinking 开启时有效) |
|
||||
|
||||
### bash-local(Bash 执行器)
|
||||
|
||||
**包名:** `@deepseek-ai/dsh-bash-local`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `cwd` | string | `process.cwd()` | 命令执行的工作目录 |
|
||||
| `timeoutMs` | number | `120000` | 单条命令的超时时间(毫秒) |
|
||||
| `maxTimeoutMs` | number | `600000` | 单条命令超时的上限(模型不能请求更久) |
|
||||
| `maxOutputBytes` | number | `64000` | 单次输出的内存上限(超出后溢出到临时文件) |
|
||||
| `graceMs` | number | `3000` | kill 时从 SIGTERM 到 SIGKILL 的等待时间 |
|
||||
|
||||
### compact-basic(自动压缩)
|
||||
|
||||
**包名:** `@deepseek-ai/dsh-compact-basic`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `contextWindow` | number | **必填** | 模型的上下文窗口大小(token) |
|
||||
| `thresholdRatio` | number | **必填** | token 占用超过此比例时触发压缩(0-1) |
|
||||
| `retainTokens` | number | **必填** | 压缩后至少保留多少 token 的近期内容 |
|
||||
| `maxTokens` | number | **必填** | 总结时的最大输出 token |
|
||||
| `summarizationModel` | string | `''`(用当前模型) | 专门用于总结的模型名 |
|
||||
| `compactionRetries` | number | **必填** | 首次压缩后仍超标时的额外重试次数 |
|
||||
| `auto` | boolean | `true` | 是否自动在每步前检查并触发压缩 |
|
||||
| `charsPerToken` | number | `4` | 每 token 估算字符数。中文应设 1-2 |
|
||||
|
||||
### fs-local(文件系统)
|
||||
|
||||
**包名:** `@deepseek-ai/dsh-fs-local`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `cwd` | string | `process.cwd()` | 工作目录,相对路径以此为基准 |
|
||||
|
||||
### fs-policy(文件系统策略)
|
||||
|
||||
**包名:** `@deepseek-ai/dsh-fs-policy`
|
||||
|
||||
无配置项。加载即启用"必须先读才能写"的安全策略。
|
||||
|
||||
### tool-fs(文件系统工具)
|
||||
|
||||
**包名:** `@deepseek-ai/dsh-tool-fs`
|
||||
|
||||
无配置项。加载后向模型暴露 `read`、`write`、`edit` 三个工具。
|
||||
|
||||
### tool-web(Web 工具)
|
||||
|
||||
**包名:** `@deepseek-ai/dsh-tool-web`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `search` | boolean | `true` | 是否注册 `web_search` 工具 |
|
||||
| `fetch` | boolean | `true` | 是否注册 `web_fetch` 工具 |
|
||||
| `searchMaxResults` | number | `8` | 单次搜索返回的最大结果数 |
|
||||
|
||||
### subagent-spawn / subagent-fork(子代理后端)
|
||||
|
||||
**包名:** `@deepseek-ai/dsh-subagent-spawn` / `@deepseek-ai/dsh-subagent-fork`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `providerName` | string | `'spawn'` / `'fork'` | 注册到子代理服务的 provider 名称 |
|
||||
|
||||
### tool-subagent(子代理工具)
|
||||
|
||||
**包名:** `@deepseek-ai/dsh-tool-subagent`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `provider` | string | **必填** | 使用哪个 provider(如 `spawn`、`fork`) |
|
||||
| `toolName` | string | `'subagent'` | 暴露给模型的工具名。多次加载时必须不同 |
|
||||
| `agentOptions.model` | string | — | 子代理使用的模型名(省略则继承父代理) |
|
||||
|
||||
### tool-todo(任务清单)
|
||||
|
||||
**包名:** `@deepseek-ai/dsh-tool-todo`
|
||||
|
||||
无配置项。加载后向模型暴露 `todo_write` 工具。
|
||||
|
||||
### hmr(热替换)
|
||||
|
||||
**包名:** `@cordisjs/plugin-hmr`
|
||||
|
||||
| 字段 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `root` | string[] | `['.']` | 监听文件变更的目录列表 |
|
||||
| `base` | string | — | 解析 `root` 的基准目录(默认取配置文件所在目录) |
|
||||
| `ignored` | string[] | `['**/node_modules', '**/.*', 'cache', 'data']` | 忽略的 glob 列表 |
|
||||
| `debounce` | number | `100` | 变更合并窗口(毫秒) |
|
||||
|
||||
其余字段透传给 chokidar(`Config` 继承 `ChokidarOptions`)。
|
||||
|
||||
::: tip
|
||||
hmr 仅用于开发环境。它需要 `node --expose-internals` 启动参数,`demo:*` 脚本已自动添加。
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## 加载顺序
|
||||
|
||||
`cordis.yml` 的条目是**并发启动**的(loader 对全部条目 `Promise.all`),文件顺序不决定加载顺序。真正的先后关系由依赖协调:插件声明的 `inject` 服务就绪之前,插件不会启动;服务出现后自动继续。所以**不要依赖书写顺序传递时序**——需要"先有 A 再有 B"就让 B `inject` A 提供的服务。
|
||||
|
||||
文件顺序只是给人读的。推荐按角色分组书写:
|
||||
|
||||
1. **hmr** — 热替换(仅开发时需要)
|
||||
2. **LLM 适配器** — 模型后端
|
||||
3. **执行器** — bash、fs 等能力提供者
|
||||
4. **应用主体** — `dsh-stdio-demo` 或 `dsh-acp-demo`
|
||||
5. **附加插件** — compact、subagent、todo 等
|
||||
|
||||
应用主体内部已经捆绑了核心能力(session、tools、agent-loop),不需要手动加载。
|
||||
|
||||
## 下一步
|
||||
|
||||
- [开发插件](../develop/basic/) — 编写自己的插件
|
||||
- [API 参考](../api/) — 查看各插件完整接口
|
||||
@@ -0,0 +1,47 @@
|
||||
# 介绍
|
||||
|
||||
DeepSeek Harness 是一个**插件化的 Agent 开发框架**,基于 [Cordis](https://github.com/cordiverse/cordis) 微内核构建。它的核心理念是:**一切皆插件**。
|
||||
|
||||
## 它是什么
|
||||
|
||||
Harness 将一个 AI Agent(智能体) 所需要的所有能力——LLM 调用、工具执行、会话管理、子任务分配——全部构建为可组合的插件。你通过一个 `cordis.yml` 配置文件来声明加载哪些插件、使用什么参数,就能组装出一个完整的 Agent。
|
||||
|
||||
```yaml
|
||||
# 选择 LLM 后端
|
||||
- name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
config:
|
||||
apiKey: !!js process.env.DEEPSEEK_API_KEY
|
||||
|
||||
# 选择应用模板
|
||||
- name: '@deepseek-ai/dsh-stdio-demo'
|
||||
config:
|
||||
model: deepseek-v4-flash
|
||||
```
|
||||
|
||||
## 适合谁
|
||||
|
||||
### 应用使用者
|
||||
|
||||
如果你只是想用一个现成的 Agent 应用(如编程助手、对话代理),你需要的全部操作就是:
|
||||
|
||||
1. 复制一个 example 模板
|
||||
2. 填写 API key
|
||||
3. 运行
|
||||
|
||||
不需要写任何代码。详见 [快速开始](./quickstart)。
|
||||
|
||||
### 插件开发者
|
||||
|
||||
如果你想为 Agent 添加新能力——一个自定义 tool、一个新的 LLM 适配器、一个新的执行后端——你需要编写一个插件。Harness 提供了清晰的扩展接口和类型安全的开发体验。详见 [开发](../develop/basic/)。
|
||||
|
||||
## 核心特性
|
||||
|
||||
- **只需要配置** — `cordis.yml` 决定能力集合,换模型、加工具只需改一行
|
||||
- **随时替换 (HMR)** — 开发时修改插件代码,无需重启进程
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **运行时**: Node.js >= 24
|
||||
- **语言**: TypeScript (ESM)
|
||||
- **框架**: Cordis
|
||||
- **包管理**: pnpm workspaces
|
||||
@@ -0,0 +1,98 @@
|
||||
# 快速开始
|
||||
|
||||
本指南带你在 5 分钟内跑起一个 Agent。
|
||||
|
||||
## 环境准备
|
||||
|
||||
- [Node.js](https://nodejs.org/) >= 24
|
||||
- [pnpm](https://pnpm.io/) >= 9
|
||||
|
||||
```sh
|
||||
# 确认版本
|
||||
node -v # v24.x 或更高
|
||||
pnpm -v # 9.x 或更高
|
||||
```
|
||||
|
||||
## 第一步:运行 echo-agent
|
||||
|
||||
echo-agent 不需要 API key,装好依赖就能跑。
|
||||
|
||||
```sh
|
||||
# 克隆仓库
|
||||
git clone https://github.com/deepseek-harness/deepseek-harness.git
|
||||
cd deepseek-harness
|
||||
|
||||
# 安装依赖
|
||||
pnpm install
|
||||
# 如果看到 ERR_PNPM_IGNORED_BUILDS,可以忽略——安装已经成功了。
|
||||
# 想消除这个提示可以跑一次: pnpm approve-builds
|
||||
|
||||
# 启动 echo-agent
|
||||
pnpm run demo:echo
|
||||
```
|
||||
|
||||
启动后你会看到:
|
||||
|
||||
```
|
||||
echo-agent ready. Type a message ("echo <text>" triggers the tool).
|
||||
>
|
||||
```
|
||||
|
||||
试着输入:
|
||||
|
||||
```
|
||||
> echo hello world
|
||||
```
|
||||
|
||||
你会看到模型发起了一次 tool call(工具调用),echo 工具将文本转为大写并返回:
|
||||
|
||||
```
|
||||
[tool call] echo({"text":"hello world"})
|
||||
[tool result] ECHO: HELLO WORLD
|
||||
```
|
||||
|
||||
恭喜!环境没问题。
|
||||
|
||||
## 第二步:使用真实模型调用
|
||||
|
||||
接下来接入真实的 DeepSeek 模型,跑一个完整的命令行 Agent。
|
||||
|
||||
### 获取 API Key
|
||||
|
||||
前往 [DeepSeek Platform](https://platform.deepseek.com/) 获取你的 API key。
|
||||
|
||||
### 配置环境变量
|
||||
|
||||
在仓库根目录创建 `.env` 文件(已被 gitignore):
|
||||
|
||||
```sh
|
||||
DEEPSEEK_API_KEY=sk-your-key-here
|
||||
```
|
||||
|
||||
### 启动 coding-agent
|
||||
|
||||
```sh
|
||||
pnpm run demo:repl
|
||||
```
|
||||
|
||||
```
|
||||
agent REPL ready. Give it a coding task.
|
||||
>
|
||||
```
|
||||
|
||||
这就是一个完整的编程助手,它能读写文件、跑命令、拆分子任务。
|
||||
|
||||
试着给它一个任务:
|
||||
|
||||
```
|
||||
> 在当前目录创建一个 hello.js,内容是打印 "Hello from Harness!",然后运行它
|
||||
```
|
||||
|
||||
## 回头看
|
||||
|
||||
echo-agent 和 coding-agent 用的是同一个应用框架(`@deepseek-ai/dsh-stdio-demo`),区别只在 `cordis.yml`——换了哪些插件、填了什么配置。你以后定制自己的 Agent 也是同样的方式。
|
||||
|
||||
## 下一步
|
||||
|
||||
- [配置文件](./config) — 了解 `cordis.yml` 的完整语法
|
||||
- [开发插件](../develop/basic/) — 编写你自己的 tool 或后端
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
layout: home
|
||||
hero:
|
||||
name: DeepSeek Harness
|
||||
text: 插件化 Agent 开发框架
|
||||
tagline: 基于 Cordis 微内核,一切皆插件
|
||||
actions:
|
||||
- theme: brand
|
||||
text: 快速开始
|
||||
link: /zh-CN/guide/quickstart
|
||||
- theme: alt
|
||||
text: 开发插件
|
||||
link: /zh-CN/develop/basic/
|
||||
features:
|
||||
- title: 插件化架构
|
||||
details: 基于 Cordis 效果系统,所有能力通过插件注册,加载即生效、卸载即还原。
|
||||
- title: 配置即组合
|
||||
details: 一个 cordis.yml 决定整个 Agent 的能力组合——换模型、加工具,只需改一行配置。
|
||||
- title: 开箱即用
|
||||
details: 内置 LLM 调用、文件读写、Bash 执行、子代理委派等完整工具链,复制模板即可运行。
|
||||
---
|
||||
Reference in New Issue
Block a user