Document the codebase thoroughly and tighten type safety
Docs: per-folder README.md for packages/ (family overview + one per
package: service, events, API, extension points, TODOs), examples/,
and examples/echo-agent/; folder-level AGENTS.md (+ CLAUDE.md
symlinks) for packages/ and vendor/; module-level doc comments in
every packages/*/src file; richer JSDoc on all exported API
(event side effects, disposal contracts, error behavior). Root
AGENTS.md gains a "Type Safety and Documentation" policy section:
the codebase aims to be very type-safe and well documented; type
gymnastics are acceptable in core packages when they improve
plugin-author DX; verbose docs are fine as long as they stay strictly
in sync with the code.
Type safety: removed the upstream-inherited "noImplicitAny": false
from tsconfig.base.json — packages/* now compile under full strict
mode; vendor/loader and vendor/include set it locally (vendor/cordis
already did). Eliminated every `: any` / `as any` from packages and
examples (catch clauses use unknown + a CodedError narrowing type;
event data access uses discriminated-union narrowing).
Typed tool schemas: new @deepseek-ai/dsh-tools schema DSL —
SchemaSpec with per-property `required: true` booleans, type-level
InferArgs<S>, a runtime SchemaSpec → JSON Schema converter, and
defineTool() so first-party tools get typed execute(args) with zero
casts (raw JSON Schema still accepted for MCP interop; chosen over
schemastery because it targets JSON Schema generation directly).
echo-tool and all test tools migrated; +7 tests.
2026-06-11 12:39:27 +08:00
|
|
|
/**
|
2026-07-12 22:36:04 +08:00
|
|
|
* Concrete agent-loop plugin: creates scoped ReactLoopAgents, publishes them
|
|
|
|
|
* through the agent/session registries, and owns their ordered teardown.
|
Document the codebase thoroughly and tighten type safety
Docs: per-folder README.md for packages/ (family overview + one per
package: service, events, API, extension points, TODOs), examples/,
and examples/echo-agent/; folder-level AGENTS.md (+ CLAUDE.md
symlinks) for packages/ and vendor/; module-level doc comments in
every packages/*/src file; richer JSDoc on all exported API
(event side effects, disposal contracts, error behavior). Root
AGENTS.md gains a "Type Safety and Documentation" policy section:
the codebase aims to be very type-safe and well documented; type
gymnastics are acceptable in core packages when they improve
plugin-author DX; verbose docs are fine as long as they stay strictly
in sync with the code.
Type safety: removed the upstream-inherited "noImplicitAny": false
from tsconfig.base.json — packages/* now compile under full strict
mode; vendor/loader and vendor/include set it locally (vendor/cordis
already did). Eliminated every `: any` / `as any` from packages and
examples (catch clauses use unknown + a CodedError narrowing type;
event data access uses discriminated-union narrowing).
Typed tool schemas: new @deepseek-ai/dsh-tools schema DSL —
SchemaSpec with per-property `required: true` booleans, type-level
InferArgs<S>, a runtime SchemaSpec → JSON Schema converter, and
defineTool() so first-party tools get typed execute(args) with zero
casts (raw JSON Schema still accepted for MCP interop; chosen over
schemastery because it targets JSON Schema generation directly).
echo-tool and all test tools migrated; +7 tests.
2026-06-11 12:39:27 +08:00
|
|
|
*
|
|
|
|
|
* @module @deepseek-ai/dsh-agent-loop
|
|
|
|
|
*/
|
|
|
|
|
|
2026-07-12 22:36:04 +08:00
|
|
|
import { Context, FiberState, Service } from 'cordis'
|
2026-06-15 21:05:46 +08:00
|
|
|
import { randomUUID } from 'node:crypto'
|
Implement the agent loop plugin
@deepseek-ai/dsh-agent-loop: LoopAgent (inbox with queued + steering
FIFOs, per-step AbortController) and the streaming-first
session/turn/step loop. Extension seams: agent/request,
agent/step-result, agent/turn-continuation waterfalls; raw chunks
logged for replay while BlockAssembler builds the assembled message;
steering drains between steps; session/flush awaited at turn end.
16 tests with a scripted mock adapter cover turn lifecycle ordering,
tool round-trips, steering, inject(), continuation override/veto,
mid-stream abort, queued turn chaining, replay equivalence, and
mid-turn fiber disposal (HMR safety).
2026-06-11 10:54:31 +08:00
|
|
|
import z from 'schemastery'
|
2026-07-24 11:46:06 +08:00
|
|
|
import { emitAgentEvent } from '@deepseek-ai/dsh-agent'
|
2026-07-12 22:36:04 +08:00
|
|
|
import type {
|
2026-07-14 02:32:35 +08:00
|
|
|
Agent,
|
2026-07-12 22:36:04 +08:00
|
|
|
AgentFactory,
|
|
|
|
|
AgentHandle,
|
|
|
|
|
AgentOptions,
|
2026-08-04 23:26:37 +08:00
|
|
|
AgentSetup,
|
2026-07-12 22:36:04 +08:00
|
|
|
CreateAgentOptions,
|
|
|
|
|
ResumeAgentOptions,
|
|
|
|
|
SessionStartSource,
|
|
|
|
|
} from '@deepseek-ai/dsh-agent'
|
2026-07-20 11:17:09 +08:00
|
|
|
import { errorChain } from '@deepseek-ai/dsh-llm'
|
2026-07-12 22:36:04 +08:00
|
|
|
import { SessionId } from '@deepseek-ai/dsh-session'
|
|
|
|
|
import type { Session, SessionHeader } from '@deepseek-ai/dsh-session'
|
Implement the agent loop plugin
@deepseek-ai/dsh-agent-loop: LoopAgent (inbox with queued + steering
FIFOs, per-step AbortController) and the streaming-first
session/turn/step loop. Extension seams: agent/request,
agent/step-result, agent/turn-continuation waterfalls; raw chunks
logged for replay while BlockAssembler builds the assembled message;
steering drains between steps; session/flush awaited at turn end.
16 tests with a scripted mock adapter cover turn lifecycle ordering,
tool round-trips, steering, inject(), continuation override/veto,
mid-stream abort, queued turn chaining, replay equivalence, and
mid-turn fiber disposal (HMR safety).
2026-06-11 10:54:31 +08:00
|
|
|
import type {} from '@deepseek-ai/dsh-system-prompt'
|
|
|
|
|
import type {} from '@deepseek-ai/dsh-tools'
|
2026-06-16 22:28:01 +08:00
|
|
|
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
2026-07-24 17:00:42 +08:00
|
|
|
import { ReactLoopAgent } from './agent.ts'
|
2026-07-16 14:36:16 +08:00
|
|
|
import { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from './constants.ts'
|
Implement the agent loop plugin
@deepseek-ai/dsh-agent-loop: LoopAgent (inbox with queued + steering
FIFOs, per-step AbortController) and the streaming-first
session/turn/step loop. Extension seams: agent/request,
agent/step-result, agent/turn-continuation waterfalls; raw chunks
logged for replay while BlockAssembler builds the assembled message;
steering drains between steps; session/flush awaited at turn end.
16 tests with a scripted mock adapter cover turn lifecycle ordering,
tool round-trips, steering, inject(), continuation override/veto,
mid-stream abort, queued turn chaining, replay equivalence, and
mid-turn fiber disposal (HMR safety).
2026-06-11 10:54:31 +08:00
|
|
|
|
2026-07-12 22:36:04 +08:00
|
|
|
/** Fiber states that cannot own or serve a new lifecycle. */
|
|
|
|
|
const INACTIVE_STATES: ReadonlySet<FiberState> = new Set([
|
|
|
|
|
FiberState.UNLOADING,
|
|
|
|
|
FiberState.DISPOSED,
|
|
|
|
|
FiberState.FAILED,
|
|
|
|
|
])
|
|
|
|
|
|
2026-07-24 11:46:06 +08:00
|
|
|
/** Factory-level ownership: live agent teardowns plus config startup work. */
|
2026-07-12 22:36:04 +08:00
|
|
|
class FactoryOwnership {
|
|
|
|
|
private accepting = true
|
2026-07-24 11:46:06 +08:00
|
|
|
private readonly teardown = new AbortController()
|
2026-07-14 14:24:21 +08:00
|
|
|
private readonly inactive = Promise.withResolvers<void>()
|
2026-07-24 11:46:06 +08:00
|
|
|
private readonly liveAgents = new Set<() => Promise<void>>()
|
2026-07-14 10:47:47 +08:00
|
|
|
private startupTasks = new Set<Promise<void>>()
|
2026-07-12 22:36:04 +08:00
|
|
|
|
|
|
|
|
constructor(private readonly fiber: Context['fiber']) {}
|
|
|
|
|
|
2026-07-24 11:46:06 +08:00
|
|
|
/** Aborts (reason: `agent loop is not active` error) when factory teardown begins. */
|
|
|
|
|
get signal(): AbortSignal {
|
|
|
|
|
return this.teardown.signal
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 22:36:04 +08:00
|
|
|
isActive(): boolean {
|
|
|
|
|
return this.accepting && !INACTIVE_STATES.has(this.fiber.state)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-24 11:46:06 +08:00
|
|
|
/** Track one live agent's shared teardown until it has run. */
|
|
|
|
|
track(dispose: () => Promise<void>): () => void {
|
|
|
|
|
this.liveAgents.add(dispose)
|
|
|
|
|
return () => { this.liveAgents.delete(dispose) }
|
2026-07-12 22:36:04 +08:00
|
|
|
}
|
|
|
|
|
|
2026-07-24 11:46:06 +08:00
|
|
|
/** Join config startup work that begins before an agent exists. */
|
2026-07-14 10:47:47 +08:00
|
|
|
trackStartup(task: Promise<void>): void {
|
|
|
|
|
this.startupTasks.add(task)
|
|
|
|
|
const forget = () => { this.startupTasks.delete(task) }
|
|
|
|
|
void task.then(forget, forget)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-24 11:46:06 +08:00
|
|
|
/** Join one public create/resume continuation; factory dispose awaits its settlement. */
|
|
|
|
|
trackWrapper(task: Promise<unknown>): void {
|
|
|
|
|
this.trackStartup(task.then(() => undefined, () => undefined))
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-14 14:24:21 +08:00
|
|
|
/** Resolve `task`, or stop waiting when factory teardown begins. */
|
|
|
|
|
async waitWhileActive(task: Promise<void>): Promise<void> {
|
|
|
|
|
await Promise.race([task, this.inactive.promise])
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 22:36:04 +08:00
|
|
|
async dispose(): Promise<void> {
|
|
|
|
|
this.accepting = false
|
2026-07-24 11:46:06 +08:00
|
|
|
this.teardown.abort(new Error('agent loop is not active'))
|
2026-07-14 14:24:21 +08:00
|
|
|
this.inactive.resolve()
|
2026-07-14 10:47:47 +08:00
|
|
|
await Promise.all([
|
2026-07-24 11:46:06 +08:00
|
|
|
...[...this.liveAgents].map(dispose => dispose()),
|
2026-07-14 10:47:47 +08:00
|
|
|
...this.startupTasks,
|
|
|
|
|
])
|
2026-07-12 22:36:04 +08:00
|
|
|
}
|
2026-07-12 05:13:17 +08:00
|
|
|
}
|
|
|
|
|
|
2026-07-24 11:46:06 +08:00
|
|
|
/** Await `operation`, or throw the signal's reason as soon as it aborts. */
|
|
|
|
|
async function raceAbort<T>(operation: PromiseLike<T> | T, signal: AbortSignal, id: SessionId): Promise<T> {
|
|
|
|
|
const toAbortError = (): Error => signal.reason instanceof Error
|
|
|
|
|
? signal.reason
|
|
|
|
|
: new Error(`agent "${id}" creation aborted`, { cause: signal.reason })
|
|
|
|
|
if (signal.aborted) throw toAbortError()
|
|
|
|
|
const aborted = Promise.withResolvers<never>()
|
|
|
|
|
const listener = (): void => { aborted.reject(toAbortError()) }
|
|
|
|
|
signal.addEventListener('abort', listener, { once: true })
|
|
|
|
|
try {
|
|
|
|
|
return await Promise.race([Promise.resolve(operation), aborted.promise])
|
|
|
|
|
} finally {
|
|
|
|
|
signal.removeEventListener('abort', listener)
|
|
|
|
|
}
|
2026-07-12 08:57:05 +08:00
|
|
|
}
|
|
|
|
|
|
2026-07-16 14:36:16 +08:00
|
|
|
/** Resolve the deployment-wide scheduler cap at the owning config boundary. */
|
|
|
|
|
function resolveMaxParallelToolCalls(value: number | undefined): number {
|
|
|
|
|
const maxParallelToolCalls = value ?? DEFAULT_MAX_PARALLEL_TOOL_CALLS
|
|
|
|
|
if (!Number.isInteger(maxParallelToolCalls) || maxParallelToolCalls < 1) {
|
2026-07-13 14:28:32 +08:00
|
|
|
throw new Error('maxParallelToolCalls must be a positive integer')
|
|
|
|
|
}
|
2026-07-16 14:36:16 +08:00
|
|
|
return maxParallelToolCalls
|
2026-07-13 14:28:32 +08:00
|
|
|
}
|
|
|
|
|
|
2026-07-28 17:36:44 +08:00
|
|
|
/** Reject an output-token cap that cannot be represented exactly on the request wire. */
|
|
|
|
|
function assertAgentOptions(options: AgentOptions): void {
|
|
|
|
|
if (options.maxTokens !== undefined
|
|
|
|
|
&& (!Number.isSafeInteger(options.maxTokens) || options.maxTokens <= 0)) {
|
|
|
|
|
throw new TypeError('agent maxTokens must be a positive safe integer')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-24 11:46:06 +08:00
|
|
|
/** Prepared-but-unpublished agent resources sharing one memoized teardown. */
|
|
|
|
|
interface PreparedAgent {
|
|
|
|
|
agent: ReactLoopAgent
|
|
|
|
|
/** Aborts when the factory unloads, the caller cancels, or teardown begins — ends any setup await. */
|
|
|
|
|
signal: AbortSignal
|
|
|
|
|
/** Enter registries, announce, notify session-start, and start the machine. */
|
|
|
|
|
publish(source: SessionStartSource): AgentHandle
|
|
|
|
|
/** Reverse teardown: stop the machine, unregister, unwind the scope. Memoized. */
|
|
|
|
|
dispose(): Promise<void>
|
2026-07-12 08:57:05 +08:00
|
|
|
}
|
|
|
|
|
|
Implement the agent loop plugin
@deepseek-ai/dsh-agent-loop: LoopAgent (inbox with queued + steering
FIFOs, per-step AbortController) and the streaming-first
session/turn/step loop. Extension seams: agent/request,
agent/step-result, agent/turn-continuation waterfalls; raw chunks
logged for replay while BlockAssembler builds the assembled message;
steering drains between steps; session/flush awaited at turn end.
16 tests with a scripted mock adapter cover turn lifecycle ordering,
tool round-trips, steering, inject(), continuation override/veto,
mid-stream abort, queued turn chaining, replay equivalence, and
mid-turn fiber disposal (HMR safety).
2026-06-11 10:54:31 +08:00
|
|
|
declare module 'cordis' {
|
|
|
|
|
interface Context {
|
|
|
|
|
agentLoop: AgentLoop
|
refactor(cli)!: one shared base config with per-surface overlays
`dsh` shipped two config trees that were 43 rows the same: apps/cli/cordis.yml
composed web as 74 flat rows, while the TUI booted examples/tui-agent/cordis.yml
whose single `@deepseek-ai/dsh-tui-demo` row mounted twelve plugins behind a
twenty-key pass-through Config. Neither file was what its location claimed —
apps/cli hardcoded the "example" as the product default and the "demo" bundle
was the application — and every capability change had to be made twice.
- apps/cli/base.cordis.yml holds the 43 shared rows; tui.cordis.yml and
web.cordis.yml are patch lists stating only what differs per surface
- overlays apply as SIBLING patch lists at one include level, because include
patches never cross an include boundary. Precedence: base < surface <
(--config | personal ~/.dsh/config.yaml) < launcher flag/profile patches
- `--config` now applies an overlay INSTEAD OF the personal one, so a demo or
test tree never inherits the user's route; new `--config-replace` boots a file
as the entire tree (the old `--config` behaviour). Both survive /resume
- vendor/include: index each `insert`ed row as it is added so a later patch can
configure or disable it. Upstream built the id index once before the patch
loop, leaving every surface-only row — the whole TUI front door — silently
unpatchable from user config. Logged as local modification 8
- session identity moves to dsh-agent-loop's CONFIGURED_AGENT_IDENTITIES_KEY;
dsh-tui's MAIN_SESSION_ID_KEY is deleted (only the bundle read it)
- delete examples/tui-agent, examples/cordis-agent, packages/examples/tui-demo;
TUI tests → apps/cli/tests, cordis e2e → packages/cordis/tool-cordis/tests,
examples/code-mode survives as an overlay leaf
- `dsh web` gains --config, threaded into AppCLIEntry as an extra overlay
Three latent defects surfaced and are fixed here: the TUI captured the optional
sessionQuery service once at construction and could permanently disable /resume
when it won the mount race; the session-store root silently reverted to a
project-local ./.sessions; --config-replace was dropped by the resume handoff.
Verified by booting each tree through the real Loader (TUI 55 entries, web 75,
zero unsettled) rather than reading YAML. All eight terminal snapshots replay
byte-identically; 14/14 PTY smoke, 112/112 snapshots, 25/25 doc-sync, hygiene
and lint clean.
2026-07-29 13:58:21 +08:00
|
|
|
/**
|
|
|
|
|
* Launcher-owned exact session identities for configured agents, keyed by
|
|
|
|
|
* the agent's config `id` and set with `ctx.provide()` before any Loader
|
|
|
|
|
* entry mounts (see {@link CONFIGURED_AGENT_IDENTITIES_KEY}). A launcher
|
|
|
|
|
* owns identity because only it knows whether the session already exists,
|
|
|
|
|
* while the `cordis.yml` row keeps the model route as ordinary patchable
|
|
|
|
|
* config. An entry with no matching key keeps its configured identity.
|
|
|
|
|
*/
|
|
|
|
|
configuredAgentIdentities?: ConfiguredAgentIdentities
|
Implement the agent loop plugin
@deepseek-ai/dsh-agent-loop: LoopAgent (inbox with queued + steering
FIFOs, per-step AbortController) and the streaming-first
session/turn/step loop. Extension seams: agent/request,
agent/step-result, agent/turn-continuation waterfalls; raw chunks
logged for replay while BlockAssembler builds the assembled message;
steering drains between steps; session/flush awaited at turn end.
16 tests with a scripted mock adapter cover turn lifecycle ordering,
tool round-trips, steering, inject(), continuation override/veto,
mid-stream abort, queued turn chaining, replay equivalence, and
mid-turn fiber disposal (HMR safety).
2026-06-11 10:54:31 +08:00
|
|
|
}
|
2026-07-14 11:23:38 +08:00
|
|
|
interface Events {
|
|
|
|
|
/**
|
|
|
|
|
* A declarative agent entry failed before it could publish a live agent.
|
|
|
|
|
* Consumers that buffer work for the configured identity use this
|
2026-07-14 12:45:12 +08:00
|
|
|
* transient signal to reject that work instead of waiting forever. Normal
|
|
|
|
|
* factory teardown suppresses failures from the cancelled startup attempt.
|
2026-07-14 11:23:38 +08:00
|
|
|
* @param sessionId - exact shared agent/session identity that failed startup.
|
|
|
|
|
* @param error - persistence, setup, or publication failure.
|
|
|
|
|
* @mode emit
|
|
|
|
|
*/
|
|
|
|
|
'agent-loop/config-start-failed'(sessionId: SessionId, error: unknown): void
|
|
|
|
|
}
|
Implement the agent loop plugin
@deepseek-ai/dsh-agent-loop: LoopAgent (inbox with queued + steering
FIFOs, per-step AbortController) and the streaming-first
session/turn/step loop. Extension seams: agent/request,
agent/step-result, agent/turn-continuation waterfalls; raw chunks
logged for replay while BlockAssembler builds the assembled message;
steering drains between steps; session/flush awaited at turn end.
16 tests with a scripted mock adapter cover turn lifecycle ordering,
tool round-trips, steering, inject(), continuation override/veto,
mid-stream abort, queued turn chaining, replay equivalence, and
mid-turn fiber disposal (HMR safety).
2026-06-11 10:54:31 +08:00
|
|
|
}
|
|
|
|
|
|
2026-07-16 14:36:16 +08:00
|
|
|
export { DEFAULT_MAX_PARALLEL_TOOL_CALLS }
|
2026-07-13 11:02:21 +08:00
|
|
|
|
refactor(cli)!: one shared base config with per-surface overlays
`dsh` shipped two config trees that were 43 rows the same: apps/cli/cordis.yml
composed web as 74 flat rows, while the TUI booted examples/tui-agent/cordis.yml
whose single `@deepseek-ai/dsh-tui-demo` row mounted twelve plugins behind a
twenty-key pass-through Config. Neither file was what its location claimed —
apps/cli hardcoded the "example" as the product default and the "demo" bundle
was the application — and every capability change had to be made twice.
- apps/cli/base.cordis.yml holds the 43 shared rows; tui.cordis.yml and
web.cordis.yml are patch lists stating only what differs per surface
- overlays apply as SIBLING patch lists at one include level, because include
patches never cross an include boundary. Precedence: base < surface <
(--config | personal ~/.dsh/config.yaml) < launcher flag/profile patches
- `--config` now applies an overlay INSTEAD OF the personal one, so a demo or
test tree never inherits the user's route; new `--config-replace` boots a file
as the entire tree (the old `--config` behaviour). Both survive /resume
- vendor/include: index each `insert`ed row as it is added so a later patch can
configure or disable it. Upstream built the id index once before the patch
loop, leaving every surface-only row — the whole TUI front door — silently
unpatchable from user config. Logged as local modification 8
- session identity moves to dsh-agent-loop's CONFIGURED_AGENT_IDENTITIES_KEY;
dsh-tui's MAIN_SESSION_ID_KEY is deleted (only the bundle read it)
- delete examples/tui-agent, examples/cordis-agent, packages/examples/tui-demo;
TUI tests → apps/cli/tests, cordis e2e → packages/cordis/tool-cordis/tests,
examples/code-mode survives as an overlay leaf
- `dsh web` gains --config, threaded into AppCLIEntry as an extra overlay
Three latent defects surfaced and are fixed here: the TUI captured the optional
sessionQuery service once at construction and could permanently disable /resume
when it won the mount race; the session-store root silently reverted to a
project-local ./.sessions; --config-replace was dropped by the resume handoff.
Verified by booting each tree through the real Loader (TUI 55 entries, web 75,
zero unsettled) rather than reading YAML. All eight terminal snapshots replay
byte-identically; 14/14 PTY smoke, 112/112 snapshots, 25/25 doc-sync, hygiene
and lint clean.
2026-07-29 13:58:21 +08:00
|
|
|
/**
|
|
|
|
|
* One launcher-selected session identity for a configured agent. `resume`
|
|
|
|
|
* distinguishes rehydrating existing persisted history from creating the
|
|
|
|
|
* session fresh under that exact id, which the two config keys express as
|
|
|
|
|
* `resumeSessionId` and `sessionId`.
|
|
|
|
|
*/
|
|
|
|
|
export interface LauncherAgentIdentity {
|
|
|
|
|
/** Exact session id to create fresh or resume. */
|
|
|
|
|
id: SessionId
|
|
|
|
|
/** Resume existing persisted history instead of creating the session fresh. */
|
|
|
|
|
resume: boolean
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Launcher-selected identities keyed by the configured agent's `id`. */
|
2026-07-30 10:34:21 +08:00
|
|
|
export interface ConfiguredAgentIdentities extends Readonly<Record<string, LauncherAgentIdentity>> {}
|
refactor(cli)!: one shared base config with per-surface overlays
`dsh` shipped two config trees that were 43 rows the same: apps/cli/cordis.yml
composed web as 74 flat rows, while the TUI booted examples/tui-agent/cordis.yml
whose single `@deepseek-ai/dsh-tui-demo` row mounted twelve plugins behind a
twenty-key pass-through Config. Neither file was what its location claimed —
apps/cli hardcoded the "example" as the product default and the "demo" bundle
was the application — and every capability change had to be made twice.
- apps/cli/base.cordis.yml holds the 43 shared rows; tui.cordis.yml and
web.cordis.yml are patch lists stating only what differs per surface
- overlays apply as SIBLING patch lists at one include level, because include
patches never cross an include boundary. Precedence: base < surface <
(--config | personal ~/.dsh/config.yaml) < launcher flag/profile patches
- `--config` now applies an overlay INSTEAD OF the personal one, so a demo or
test tree never inherits the user's route; new `--config-replace` boots a file
as the entire tree (the old `--config` behaviour). Both survive /resume
- vendor/include: index each `insert`ed row as it is added so a later patch can
configure or disable it. Upstream built the id index once before the patch
loop, leaving every surface-only row — the whole TUI front door — silently
unpatchable from user config. Logged as local modification 8
- session identity moves to dsh-agent-loop's CONFIGURED_AGENT_IDENTITIES_KEY;
dsh-tui's MAIN_SESSION_ID_KEY is deleted (only the bundle read it)
- delete examples/tui-agent, examples/cordis-agent, packages/examples/tui-demo;
TUI tests → apps/cli/tests, cordis e2e → packages/cordis/tool-cordis/tests,
examples/code-mode survives as an overlay leaf
- `dsh web` gains --config, threaded into AppCLIEntry as an extra overlay
Three latent defects surfaced and are fixed here: the TUI captured the optional
sessionQuery service once at construction and could permanently disable /resume
when it won the mount race; the session-store root silently reverted to a
project-local ./.sessions; --config-replace was dropped by the resume handoff.
Verified by booting each tree through the real Loader (TUI 55 entries, web 75,
zero unsettled) rather than reading YAML. All eight terminal snapshots replay
byte-identically; 14/14 PTY smoke, 112/112 snapshots, 25/25 doc-sync, hygiene
and lint clean.
2026-07-29 13:58:21 +08:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Context key a launcher sets before any Loader entry mounts
|
|
|
|
|
* (`ctx.provide(CONFIGURED_AGENT_IDENTITIES_KEY, identities)`) to fix
|
|
|
|
|
* configured agents' session identities without a config key, so an overlay
|
|
|
|
|
* repointing the row's model route cannot drop them.
|
|
|
|
|
*/
|
|
|
|
|
export const CONFIGURED_AGENT_IDENTITIES_KEY = 'configuredAgentIdentities'
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Apply launcher-owned identities over the configured agents, replacing both
|
|
|
|
|
* identity keys for every entry the launcher named so a config-supplied
|
|
|
|
|
* identity can never survive alongside a launcher-supplied one.
|
|
|
|
|
* @param agents - the configured agent entries.
|
|
|
|
|
* @param identities - launcher identities keyed by configured agent `id`, or `undefined`.
|
|
|
|
|
* @returns the entries with launcher-owned identities applied.
|
|
|
|
|
*/
|
|
|
|
|
function applyLauncherIdentities(
|
|
|
|
|
agents: Config['agents'],
|
|
|
|
|
identities: ConfiguredAgentIdentities | undefined,
|
|
|
|
|
): Config['agents'] {
|
|
|
|
|
if (identities === undefined) return agents
|
|
|
|
|
return agents.map((agent) => {
|
|
|
|
|
const identity = identities[agent.id]
|
|
|
|
|
if (identity === undefined) return agent
|
|
|
|
|
const { sessionId: _sessionId, resumeSessionId: _resumeSessionId, ...rest } = agent
|
|
|
|
|
return identity.resume
|
|
|
|
|
? { ...rest, resumeSessionId: identity.id }
|
|
|
|
|
: { ...rest, sessionId: identity.id }
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-16 14:36:16 +08:00
|
|
|
/** Agent-loop plugin configuration. */
|
Implement the agent loop plugin
@deepseek-ai/dsh-agent-loop: LoopAgent (inbox with queued + steering
FIFOs, per-step AbortController) and the streaming-first
session/turn/step loop. Extension seams: agent/request,
agent/step-result, agent/turn-continuation waterfalls; raw chunks
logged for replay while BlockAssembler builds the assembled message;
steering drains between steps; session/flush awaited at turn end.
16 tests with a scripted mock adapter cover turn lifecycle ordering,
tool round-trips, steering, inject(), continuation override/veto,
mid-stream abort, queued turn chaining, replay equivalence, and
mid-turn fiber disposal (HMR safety).
2026-06-11 10:54:31 +08:00
|
|
|
export interface Config {
|
2026-07-16 11:36:16 +08:00
|
|
|
/**
|
2026-07-18 14:59:26 +08:00
|
|
|
* Maximum parallel-safe calls in flight per agent step. `1` is serial;
|
|
|
|
|
* omission defaults to {@link DEFAULT_MAX_PARALLEL_TOOL_CALLS}.
|
2026-07-16 11:36:16 +08:00
|
|
|
*/
|
|
|
|
|
maxParallelToolCalls?: number
|
2026-07-12 22:36:04 +08:00
|
|
|
/** Agents created or resumed at plugin startup. */
|
2026-06-16 22:28:01 +08:00
|
|
|
agents: (AgentOptions & {
|
2026-07-14 01:59:21 +08:00
|
|
|
/** Stable config label used in logs and as the fresh combined-id prefix. */
|
|
|
|
|
id: string
|
2026-07-14 10:25:49 +08:00
|
|
|
/** Optional stable identity; remounts resume its materialized history, while first use creates it fresh. */
|
2026-07-14 09:54:34 +08:00
|
|
|
sessionId?: SessionId
|
2026-07-12 22:36:04 +08:00
|
|
|
/** Optional workspace for a fresh session. */
|
2026-06-25 23:35:13 +08:00
|
|
|
cwd?: string
|
2026-07-12 22:36:04 +08:00
|
|
|
/** Persisted session to resume instead of creating a fresh session. */
|
feat(types): brand bash ids + stop brand erosion; extract Branded to dsh-brand
Type-only change (brands are zero-cost casts; no runtime/wire impact). Closes
the two gaps in the "brand ids that cross package boundaries" policy and fixes
the dependency direction so a capability package never pulls in an unrelated one.
- Extract the `Branded<B>` primitive into a new standalone type-only package
`@deepseek-ai/dsh-brand` (packages/util/brand) with no harness-package deps.
dsh-llm keeps its owned CallId but imports Branded from dsh-brand; dsh-session,
dsh-agent, and dsh-bash all import Branded from there. dsh-bash depends on
dsh-brand ALONE — never on dsh-llm or dsh-session (the architectural fix: a
generic execution backend must not couple to the LLM or session vocabulary).
- Mint BashTaskId + OwnerToken in dsh-bash and thread them through BashTask.id,
the get/ownerOf/list/readOutput/kill seam, the bash-local generation site, and
the dsh-tool-bash validate/access surface. OwnerToken is a DISTINCT brand from
SessionId so the seam stays decoupled; dsh-tool-bash is the single boundary
that casts SessionId -> OwnerToken.
- Brand at the SOURCE, not via mid-pipeline casts: agent-loop's Config types
agents[].id as AgentId and resumeSessionId as SessionId, so the brand enters
at the config boundary and the inner create()/resume casts disappear (only the
genuinely-new per-run session-id string is cast).
- Stop brand erosion: propagate CallId/SessionId/AgentId to the registry/store
Map keys and public params/exports (SessionStore, AgentRegistry + factory
options, the ACP session-id surface + ToolPresenter CallId map, the
persistence coordinator, invariants pendingCalls, the pi-ai tool-call maps).
- Docs: document BashTaskId/OwnerToken in bash.md (type-equiv re-pasted), point
the Branded type-equiv at dsh-brand, fix stale param types in the session/
agent/bash READMEs, regenerate the cordis catalog + module graph.
Implements docs/rfc/proposed/architecture/2026-06-20-branded-ids.md
2026-06-21 07:17:25 +08:00
|
|
|
resumeSessionId?: SessionId
|
2026-06-16 22:28:01 +08:00
|
|
|
})[]
|
Implement the agent loop plugin
@deepseek-ai/dsh-agent-loop: LoopAgent (inbox with queued + steering
FIFOs, per-step AbortController) and the streaming-first
session/turn/step loop. Extension seams: agent/request,
agent/step-result, agent/turn-continuation waterfalls; raw chunks
logged for replay while BlockAssembler builds the assembled message;
steering drains between steps; session/flush awaited at turn end.
16 tests with a scripted mock adapter cover turn lifecycle ordering,
tool round-trips, steering, inject(), continuation override/veto,
mid-stream abort, queued turn chaining, replay equivalence, and
mid-turn fiber disposal (HMR safety).
2026-06-11 10:54:31 +08:00
|
|
|
}
|
|
|
|
|
|
2026-07-24 11:46:06 +08:00
|
|
|
/** Agent-loop configuration after defaults and load-time validation. */
|
|
|
|
|
type ResolvedConfig = Config & { maxParallelToolCalls: number }
|
|
|
|
|
|
2026-07-16 02:26:57 +08:00
|
|
|
/** Reject self-contained identity conflicts before any configured agent starts. */
|
|
|
|
|
function validateConfiguredAgents(agents: Config['agents']): void {
|
|
|
|
|
const exactIdentities = new Map<SessionId, string>()
|
|
|
|
|
for (const { id, sessionId, resumeSessionId } of agents) {
|
|
|
|
|
const hasResumeId = resumeSessionId !== undefined && resumeSessionId !== ''
|
|
|
|
|
if (sessionId !== undefined && hasResumeId) {
|
|
|
|
|
throw new Error(`agent "${id}": sessionId and resumeSessionId are mutually exclusive`)
|
|
|
|
|
}
|
|
|
|
|
const exactIdentity = hasResumeId ? resumeSessionId : sessionId
|
|
|
|
|
if (exactIdentity === undefined) continue
|
|
|
|
|
const firstId = exactIdentities.get(exactIdentity)
|
|
|
|
|
if (firstId !== undefined) {
|
|
|
|
|
throw new Error(`agents "${firstId}" and "${id}" use duplicate exact session identity "${exactIdentity}"`)
|
|
|
|
|
}
|
|
|
|
|
exactIdentities.set(exactIdentity, id)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-14 02:32:35 +08:00
|
|
|
/** Concrete agent factory and driver service. */
|
2026-06-15 21:12:14 +08:00
|
|
|
export class AgentLoop extends Service implements AgentFactory {
|
Implement the agent loop plugin
@deepseek-ai/dsh-agent-loop: LoopAgent (inbox with queued + steering
FIFOs, per-step AbortController) and the streaming-first
session/turn/step loop. Extension seams: agent/request,
agent/step-result, agent/turn-continuation waterfalls; raw chunks
logged for replay while BlockAssembler builds the assembled message;
steering drains between steps; session/flush awaited at turn end.
16 tests with a scripted mock adapter cover turn lifecycle ordering,
tool round-trips, steering, inject(), continuation override/veto,
mid-stream abort, queued turn chaining, replay equivalence, and
mid-turn fiber disposal (HMR safety).
2026-06-11 10:54:31 +08:00
|
|
|
static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt']
|
|
|
|
|
|
2026-07-12 22:36:04 +08:00
|
|
|
/** Runtime schema for declarative agents. */
|
feat(types): brand bash ids + stop brand erosion; extract Branded to dsh-brand
Type-only change (brands are zero-cost casts; no runtime/wire impact). Closes
the two gaps in the "brand ids that cross package boundaries" policy and fixes
the dependency direction so a capability package never pulls in an unrelated one.
- Extract the `Branded<B>` primitive into a new standalone type-only package
`@deepseek-ai/dsh-brand` (packages/util/brand) with no harness-package deps.
dsh-llm keeps its owned CallId but imports Branded from dsh-brand; dsh-session,
dsh-agent, and dsh-bash all import Branded from there. dsh-bash depends on
dsh-brand ALONE — never on dsh-llm or dsh-session (the architectural fix: a
generic execution backend must not couple to the LLM or session vocabulary).
- Mint BashTaskId + OwnerToken in dsh-bash and thread them through BashTask.id,
the get/ownerOf/list/readOutput/kill seam, the bash-local generation site, and
the dsh-tool-bash validate/access surface. OwnerToken is a DISTINCT brand from
SessionId so the seam stays decoupled; dsh-tool-bash is the single boundary
that casts SessionId -> OwnerToken.
- Brand at the SOURCE, not via mid-pipeline casts: agent-loop's Config types
agents[].id as AgentId and resumeSessionId as SessionId, so the brand enters
at the config boundary and the inner create()/resume casts disappear (only the
genuinely-new per-run session-id string is cast).
- Stop brand erosion: propagate CallId/SessionId/AgentId to the registry/store
Map keys and public params/exports (SessionStore, AgentRegistry + factory
options, the ACP session-id surface + ToolPresenter CallId map, the
persistence coordinator, invariants pendingCalls, the pi-ai tool-call maps).
- Docs: document BashTaskId/OwnerToken in bash.md (type-equiv re-pasted), point
the Branded type-equiv at dsh-brand, fix stale param types in the session/
agent/bash READMEs, regenerate the cordis catalog + module graph.
Implements docs/rfc/proposed/architecture/2026-06-20-branded-ids.md
2026-06-21 07:17:25 +08:00
|
|
|
static Config = z.object({
|
2026-07-16 14:36:16 +08:00
|
|
|
maxParallelToolCalls: z.number().step(1).min(1).default(DEFAULT_MAX_PARALLEL_TOOL_CALLS),
|
Implement the agent loop plugin
@deepseek-ai/dsh-agent-loop: LoopAgent (inbox with queued + steering
FIFOs, per-step AbortController) and the streaming-first
session/turn/step loop. Extension seams: agent/request,
agent/step-result, agent/turn-continuation waterfalls; raw chunks
logged for replay while BlockAssembler builds the assembled message;
steering drains between steps; session/flush awaited at turn end.
16 tests with a scripted mock adapter cover turn lifecycle ordering,
tool round-trips, steering, inject(), continuation override/veto,
mid-stream abort, queued turn chaining, replay equivalence, and
mid-turn fiber disposal (HMR safety).
2026-06-11 10:54:31 +08:00
|
|
|
agents: z.array(z.object({
|
|
|
|
|
id: z.string().required(),
|
2026-07-14 23:36:04 +08:00
|
|
|
sessionId: z.string().min(1),
|
2026-07-14 21:57:52 +08:00
|
|
|
provider: z.string(),
|
Implement the agent loop plugin
@deepseek-ai/dsh-agent-loop: LoopAgent (inbox with queued + steering
FIFOs, per-step AbortController) and the streaming-first
session/turn/step loop. Extension seams: agent/request,
agent/step-result, agent/turn-continuation waterfalls; raw chunks
logged for replay while BlockAssembler builds the assembled message;
steering drains between steps; session/flush awaited at turn end.
16 tests with a scripted mock adapter cover turn lifecycle ordering,
tool round-trips, steering, inject(), continuation override/veto,
mid-stream abort, queued turn chaining, replay equivalence, and
mid-turn fiber disposal (HMR safety).
2026-06-11 10:54:31 +08:00
|
|
|
model: z.string(),
|
2026-07-28 17:36:44 +08:00
|
|
|
maxTokens: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER),
|
2026-06-25 23:35:13 +08:00
|
|
|
cwd: z.string(),
|
2026-06-16 22:28:01 +08:00
|
|
|
resumeSessionId: z.string(),
|
Implement the agent loop plugin
@deepseek-ai/dsh-agent-loop: LoopAgent (inbox with queued + steering
FIFOs, per-step AbortController) and the streaming-first
session/turn/step loop. Extension seams: agent/request,
agent/step-result, agent/turn-continuation waterfalls; raw chunks
logged for replay while BlockAssembler builds the assembled message;
steering drains between steps; session/flush awaited at turn end.
16 tests with a scripted mock adapter cover turn lifecycle ordering,
tool round-trips, steering, inject(), continuation override/veto,
mid-stream abort, queued turn chaining, replay equivalence, and
mid-turn fiber disposal (HMR safety).
2026-06-11 10:54:31 +08:00
|
|
|
})).default([]),
|
2026-07-24 11:46:06 +08:00
|
|
|
}) as z<Config>
|
Implement the agent loop plugin
@deepseek-ai/dsh-agent-loop: LoopAgent (inbox with queued + steering
FIFOs, per-step AbortController) and the streaming-first
session/turn/step loop. Extension seams: agent/request,
agent/step-result, agent/turn-continuation waterfalls; raw chunks
logged for replay while BlockAssembler builds the assembled message;
steering drains between steps; session/flush awaited at turn end.
16 tests with a scripted mock adapter cover turn lifecycle ordering,
tool round-trips, steering, inject(), continuation override/veto,
mid-stream abort, queued turn chaining, replay equivalence, and
mid-turn fiber disposal (HMR safety).
2026-06-11 10:54:31 +08:00
|
|
|
|
2026-07-24 11:46:06 +08:00
|
|
|
/** Validated configuration owned by the agent-loop service. */
|
|
|
|
|
readonly config: ResolvedConfig
|
2026-07-12 22:36:04 +08:00
|
|
|
private readonly ownership: FactoryOwnership
|
|
|
|
|
/** Plain holder prevents Cordis from re-tracing the factory's dependency context through a caller shadow. */
|
|
|
|
|
private readonly runtime: { ctx: Context }
|
|
|
|
|
|
2026-07-24 11:46:06 +08:00
|
|
|
constructor(ctx: Context, config: Config) {
|
Implement the agent loop plugin
@deepseek-ai/dsh-agent-loop: LoopAgent (inbox with queued + steering
FIFOs, per-step AbortController) and the streaming-first
session/turn/step loop. Extension seams: agent/request,
agent/step-result, agent/turn-continuation waterfalls; raw chunks
logged for replay while BlockAssembler builds the assembled message;
steering drains between steps; session/flush awaited at turn end.
16 tests with a scripted mock adapter cover turn lifecycle ordering,
tool round-trips, steering, inject(), continuation override/veto,
mid-stream abort, queued turn chaining, replay equivalence, and
mid-turn fiber disposal (HMR safety).
2026-06-11 10:54:31 +08:00
|
|
|
super(ctx, 'agentLoop')
|
2026-07-24 11:46:06 +08:00
|
|
|
this.config = {
|
|
|
|
|
...config,
|
refactor(cli)!: one shared base config with per-surface overlays
`dsh` shipped two config trees that were 43 rows the same: apps/cli/cordis.yml
composed web as 74 flat rows, while the TUI booted examples/tui-agent/cordis.yml
whose single `@deepseek-ai/dsh-tui-demo` row mounted twelve plugins behind a
twenty-key pass-through Config. Neither file was what its location claimed —
apps/cli hardcoded the "example" as the product default and the "demo" bundle
was the application — and every capability change had to be made twice.
- apps/cli/base.cordis.yml holds the 43 shared rows; tui.cordis.yml and
web.cordis.yml are patch lists stating only what differs per surface
- overlays apply as SIBLING patch lists at one include level, because include
patches never cross an include boundary. Precedence: base < surface <
(--config | personal ~/.dsh/config.yaml) < launcher flag/profile patches
- `--config` now applies an overlay INSTEAD OF the personal one, so a demo or
test tree never inherits the user's route; new `--config-replace` boots a file
as the entire tree (the old `--config` behaviour). Both survive /resume
- vendor/include: index each `insert`ed row as it is added so a later patch can
configure or disable it. Upstream built the id index once before the patch
loop, leaving every surface-only row — the whole TUI front door — silently
unpatchable from user config. Logged as local modification 8
- session identity moves to dsh-agent-loop's CONFIGURED_AGENT_IDENTITIES_KEY;
dsh-tui's MAIN_SESSION_ID_KEY is deleted (only the bundle read it)
- delete examples/tui-agent, examples/cordis-agent, packages/examples/tui-demo;
TUI tests → apps/cli/tests, cordis e2e → packages/cordis/tool-cordis/tests,
examples/code-mode survives as an overlay leaf
- `dsh web` gains --config, threaded into AppCLIEntry as an extra overlay
Three latent defects surfaced and are fixed here: the TUI captured the optional
sessionQuery service once at construction and could permanently disable /resume
when it won the mount race; the session-store root silently reverted to a
project-local ./.sessions; --config-replace was dropped by the resume handoff.
Verified by booting each tree through the real Loader (TUI 55 entries, web 75,
zero unsettled) rather than reading YAML. All eight terminal snapshots replay
byte-identically; 14/14 PTY smoke, 112/112 snapshots, 25/25 doc-sync, hygiene
and lint clean.
2026-07-29 13:58:21 +08:00
|
|
|
agents: applyLauncherIdentities(config.agents, ctx.get(CONFIGURED_AGENT_IDENTITIES_KEY)),
|
2026-07-24 11:46:06 +08:00
|
|
|
maxParallelToolCalls: resolveMaxParallelToolCalls(config.maxParallelToolCalls),
|
|
|
|
|
}
|
|
|
|
|
validateConfiguredAgents(this.config.agents)
|
2026-07-12 22:36:04 +08:00
|
|
|
this.ownership = new FactoryOwnership(ctx.fiber)
|
|
|
|
|
this.runtime = { ctx }
|
|
|
|
|
ctx.effect(() => () => this.ownership.dispose(), 'agentLoop.transactions()')
|
|
|
|
|
ctx.effect(() => ctx.agents.setFactory(this), 'agentLoop.setFactory()')
|
2026-07-14 21:57:52 +08:00
|
|
|
ctx.systemPrompt.variable('provider', context => context.agent?.options.provider)
|
2026-07-05 01:54:46 +08:00
|
|
|
ctx.systemPrompt.variable('model', context => context.agent?.options.model)
|
|
|
|
|
ctx.systemPrompt.variable('cwd', context => context.agent?.session.header.cwd)
|
2026-07-12 22:36:04 +08:00
|
|
|
|
2026-07-24 11:46:06 +08:00
|
|
|
for (const { id, sessionId, cwd, resumeSessionId, ...options } of this.config.agents) {
|
2026-07-14 10:25:49 +08:00
|
|
|
const meta = cwd === undefined ? {} : { cwd }
|
2026-07-12 22:36:04 +08:00
|
|
|
if (resumeSessionId === undefined || resumeSessionId === '') {
|
2026-07-14 10:25:49 +08:00
|
|
|
const configuredId = sessionId ?? SessionId(`${id}-session-${randomUUID()}`)
|
|
|
|
|
const persistence = sessionId === undefined ? undefined : ctx.get('sessionPersistence')
|
|
|
|
|
if (persistence === undefined) {
|
|
|
|
|
this.create(configuredId, options, meta)
|
|
|
|
|
} else {
|
2026-07-14 10:47:47 +08:00
|
|
|
const startup = this.restoreOrCreateConfigured(ctx, persistence, configuredId, options, meta).catch((error: unknown) => {
|
2026-07-14 11:23:38 +08:00
|
|
|
this.reportConfiguredStartupFailure(id, 'restore', configuredId, error)
|
2026-07-14 10:25:49 +08:00
|
|
|
})
|
2026-07-14 10:47:47 +08:00
|
|
|
this.ownership.trackStartup(startup)
|
2026-07-14 10:25:49 +08:00
|
|
|
}
|
2026-07-12 22:36:04 +08:00
|
|
|
continue
|
2026-06-16 22:28:01 +08:00
|
|
|
}
|
2026-07-12 22:36:04 +08:00
|
|
|
ctx.effect(() => {
|
|
|
|
|
const fiber = ctx.inject(['sessionPersistence'], (childCtx: Context) => {
|
|
|
|
|
void this.resumeWith(ctx, childCtx.sessionPersistence, {
|
|
|
|
|
resumeSessionId,
|
|
|
|
|
agentOptions: options,
|
|
|
|
|
}).catch((error: unknown) => {
|
2026-07-14 11:23:38 +08:00
|
|
|
this.reportConfiguredStartupFailure(id, 'resume', resumeSessionId, error)
|
2026-07-12 22:36:04 +08:00
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
return fiber.dispose
|
|
|
|
|
}, `agentLoop.resume(${id})`)
|
Implement the agent loop plugin
@deepseek-ai/dsh-agent-loop: LoopAgent (inbox with queued + steering
FIFOs, per-step AbortController) and the streaming-first
session/turn/step loop. Extension seams: agent/request,
agent/step-result, agent/turn-continuation waterfalls; raw chunks
logged for replay while BlockAssembler builds the assembled message;
steering drains between steps; session/flush awaited at turn end.
16 tests with a scripted mock adapter cover turn lifecycle ordering,
tool round-trips, steering, inject(), continuation override/veto,
mid-stream abort, queued turn chaining, replay equivalence, and
mid-turn fiber disposal (HMR safety).
2026-06-11 10:54:31 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-14 11:23:38 +08:00
|
|
|
/** Report a contained declarative-start failure to identity-bound consumers. */
|
|
|
|
|
private reportConfiguredStartupFailure(
|
|
|
|
|
configId: string,
|
|
|
|
|
action: 'restore' | 'resume',
|
|
|
|
|
sessionId: SessionId,
|
|
|
|
|
error: unknown,
|
|
|
|
|
): void {
|
2026-07-14 12:45:12 +08:00
|
|
|
if (!this.ownership.isActive()) return
|
2026-07-20 11:17:09 +08:00
|
|
|
this.ctx.logger.warn(`agent "${configId}": config-driven ${action} of "${sessionId}" failed: ${errorChain(error)}`)
|
2026-07-14 11:33:36 +08:00
|
|
|
const args: unknown[] = ['agent-loop/config-start-failed', sessionId, error]
|
|
|
|
|
for (const callback of this.ctx.events.dispatch('emit', args)) {
|
|
|
|
|
try {
|
|
|
|
|
const returned: unknown = callback(...args)
|
|
|
|
|
void Promise.resolve(returned).catch((listenerError: unknown) => {
|
2026-07-20 11:17:09 +08:00
|
|
|
this.ctx.logger.warn(`agent "${configId}": config-start-failed listener rejected: ${errorChain(listenerError)}`)
|
2026-07-14 11:33:36 +08:00
|
|
|
})
|
|
|
|
|
} catch (listenerError: unknown) {
|
2026-07-20 11:17:09 +08:00
|
|
|
this.ctx.logger.warn(`agent "${configId}": config-start-failed listener threw: ${errorChain(listenerError)}`)
|
2026-07-14 11:33:36 +08:00
|
|
|
}
|
2026-07-14 11:23:38 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-14 10:25:49 +08:00
|
|
|
/** Restore a materialized exact config identity on remount, or create it on first use. */
|
|
|
|
|
private async restoreOrCreateConfigured(
|
|
|
|
|
ownerCtx: Context,
|
|
|
|
|
persistence: SessionPersistence,
|
|
|
|
|
sessionId: SessionId,
|
|
|
|
|
agentOptions: AgentOptions,
|
|
|
|
|
meta: Pick<SessionHeader, 'cwd'>,
|
|
|
|
|
): Promise<void> {
|
2026-07-14 14:24:21 +08:00
|
|
|
await this.waitForDrainingConfiguredIdentity(ownerCtx, sessionId)
|
|
|
|
|
if (!this.ownership.isActive()) return
|
2026-07-24 21:18:48 +08:00
|
|
|
try {
|
2026-07-14 10:25:49 +08:00
|
|
|
await this.resumeWith(ownerCtx, persistence, { resumeSessionId: sessionId, agentOptions })
|
|
|
|
|
return
|
2026-07-24 21:18:48 +08:00
|
|
|
} catch (error: unknown) {
|
|
|
|
|
if (!this.ownership.isActive()) return
|
|
|
|
|
// A load is the per-id serialization barrier for eager write-behind and
|
|
|
|
|
// lifecycle retirement. Only a genuinely absent artifact falls back to
|
|
|
|
|
// first creation; corruption and backend failures stay loud.
|
|
|
|
|
const exists = (await persistence.list()).some(header => header.id === sessionId)
|
|
|
|
|
if (exists) throw error
|
2026-07-14 10:25:49 +08:00
|
|
|
}
|
|
|
|
|
this.create(sessionId, agentOptions, meta)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-24 11:46:06 +08:00
|
|
|
/** Wait for a draining same-id lifecycle to finish registry teardown. */
|
2026-07-14 14:24:21 +08:00
|
|
|
private async waitForDrainingConfiguredIdentity(ownerCtx: Context, sessionId: SessionId): Promise<void> {
|
2026-07-24 11:46:06 +08:00
|
|
|
// Only an id still occupying a registry needs waiting for; a live healthy
|
|
|
|
|
// occupant is a collision the create/resume below will surface itself.
|
|
|
|
|
if (ownerCtx.agents.get(sessionId) === undefined && ownerCtx.sessions.get(sessionId) === undefined) return
|
2026-07-14 14:24:21 +08:00
|
|
|
|
|
|
|
|
const released = Promise.withResolvers<void>()
|
|
|
|
|
const checkReleased = (): void => {
|
|
|
|
|
if (ownerCtx.agents.get(sessionId) === undefined && ownerCtx.sessions.get(sessionId) === undefined) {
|
|
|
|
|
released.resolve()
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-14 15:31:00 +08:00
|
|
|
const disposeAgentListener = ownerCtx.on('agent/disposed', checkReleased)
|
|
|
|
|
const disposeSessionListener = ownerCtx.on('session/disposed', checkReleased)
|
2026-07-14 14:24:21 +08:00
|
|
|
try {
|
|
|
|
|
checkReleased()
|
|
|
|
|
await this.ownership.waitWhileActive(released.promise)
|
|
|
|
|
} finally {
|
|
|
|
|
disposeAgentListener()
|
|
|
|
|
disposeSessionListener()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-24 11:46:06 +08:00
|
|
|
/**
|
|
|
|
|
* Construct the driver, scope, and one memoized reverse teardown for a new
|
|
|
|
|
* agent. The teardown is registered with the factory and the owner fiber
|
|
|
|
|
* BEFORE publication, so a mid-setup unload rolls everything back; `signal`
|
|
|
|
|
* fuses caller cancellation with lifecycle teardown for setup awaits.
|
|
|
|
|
*/
|
|
|
|
|
private prepare(ownerCtx: Context, id: SessionId, options: AgentOptions, session: Session, callerSignal?: AbortSignal): PreparedAgent {
|
2026-07-28 17:36:44 +08:00
|
|
|
assertAgentOptions(options)
|
2026-07-24 11:46:06 +08:00
|
|
|
ownerCtx.fiber.assertActive()
|
test: restore 100% per-file coverage for agent-loop and the acp bridge
agent-loop: behavior tests for retry-while-busy, cancelled recovery
windows, no-facts stream failures, idle-listener preemption, rejected
driver promises under whenIdle, finish-chunk failures after step close,
presentationMeta persistence, pre-aborted and torn-down create/resume
signals, and configured-start failures over existing artifacts or after
teardown. The remaining guards that no public path can reach carry
justified v8 ignore annotations naming the invariant that starves them.
acp bridge: cover the retry-adoption path (a retry turn resolves the
prompt the failed turn deferred), the no-retry quiescence rejection, and
the admission-blocked cancelled settlement; the synchronous send-throw
catch is annotated as a future-proofing guard since the machine's send()
contains listener failures.
2026-07-26 14:57:24 +08:00
|
|
|
// Every caller reaches prepare() synchronously from a service method
|
|
|
|
|
// whose Cordis dispatch already requires the live factory fiber, or
|
|
|
|
|
// re-checks ownership itself after its awaits (resume's load barrier).
|
|
|
|
|
/* v8 ignore next -- unreachable backstop, see above */
|
2026-07-24 11:46:06 +08:00
|
|
|
if (!this.ownership.isActive()) throw new Error('agent loop is not active')
|
|
|
|
|
if (callerSignal?.aborted) {
|
|
|
|
|
throw callerSignal.reason instanceof Error
|
|
|
|
|
? callerSignal.reason
|
|
|
|
|
: new Error(`agent "${id}" creation aborted`, { cause: callerSignal.reason })
|
|
|
|
|
}
|
|
|
|
|
const loopCtx = this.runtime.ctx
|
|
|
|
|
|
|
|
|
|
// Deactivation fuses three owners, each with its own reason: the caller's
|
|
|
|
|
// cancellation signal, the owner fiber's unload, and factory teardown.
|
|
|
|
|
// It is registered BEFORE any resource exists, over mutable slots, so an
|
|
|
|
|
// unload arriving while the scope is still minting finds a working
|
|
|
|
|
// disposer instead of a leak.
|
|
|
|
|
const abort = new AbortController()
|
|
|
|
|
const onCallerAbort = (): void => {
|
|
|
|
|
abort.abort(callerSignal?.reason instanceof Error
|
|
|
|
|
? callerSignal.reason
|
|
|
|
|
: new Error(`agent "${id}" creation aborted`, { cause: callerSignal?.reason }))
|
|
|
|
|
}
|
|
|
|
|
const onFactoryTeardown = (): void => { abort.abort(this.ownership.signal.reason) }
|
|
|
|
|
callerSignal?.addEventListener('abort', onCallerAbort, { once: true })
|
|
|
|
|
this.ownership.signal.addEventListener('abort', onFactoryTeardown, { once: true })
|
|
|
|
|
|
|
|
|
|
let machine: ReactLoopAgent | undefined
|
|
|
|
|
let detachSession: (() => void) | undefined
|
|
|
|
|
let detachAgent: (() => void) | undefined
|
|
|
|
|
let disposing: Promise<void> | undefined
|
2026-07-24 21:18:48 +08:00
|
|
|
const machineReady = Promise.withResolvers<void>()
|
2026-07-24 11:46:06 +08:00
|
|
|
// Reverse teardown, memoized so every racing owner awaits one quiescence:
|
|
|
|
|
// stop the machine, leave the registries, unwind the scope, release
|
|
|
|
|
// bookkeeping.
|
2026-07-24 21:18:48 +08:00
|
|
|
const dispose = (ownerTriggered = false): Promise<void> => (disposing ??= (async () => {
|
2026-07-24 11:46:06 +08:00
|
|
|
abort.abort(new Error(`agent "${id}" lifecycle disposed`))
|
|
|
|
|
callerSignal?.removeEventListener('abort', onCallerAbort)
|
|
|
|
|
this.ownership.signal.removeEventListener('abort', onFactoryTeardown)
|
|
|
|
|
try {
|
|
|
|
|
// Disposal IS a disposed-cause cancel followed by quiescence. New work
|
|
|
|
|
// sent after this point is the sender's bug — the registries are about
|
|
|
|
|
// to drop the agent, so nothing should still hold it.
|
2026-07-24 21:18:48 +08:00
|
|
|
if (machine === undefined) await machineReady.promise
|
2026-07-24 11:46:06 +08:00
|
|
|
if (machine !== undefined) {
|
2026-07-24 17:00:42 +08:00
|
|
|
machine.cancel({ kind: 'disposed' })
|
2026-07-30 13:49:57 +08:00
|
|
|
await machine.whenIdle()
|
2026-07-24 11:46:06 +08:00
|
|
|
await machine.scope.dispose()
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
try {
|
|
|
|
|
detachAgent?.()
|
|
|
|
|
detachSession?.()
|
|
|
|
|
} finally {
|
|
|
|
|
untrack()
|
2026-07-24 21:18:48 +08:00
|
|
|
if (!ownerTriggered) await unfollowOwner()
|
2026-07-24 11:46:06 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})())
|
|
|
|
|
const untrack = this.ownership.track(dispose)
|
|
|
|
|
let unfollowOwner: () => Promise<void> | void
|
|
|
|
|
try {
|
|
|
|
|
unfollowOwner = ownerCtx.effect(() => () => {
|
2026-07-24 21:18:48 +08:00
|
|
|
// Owner disposal owns the same quiescence boundary. Its teardown skips
|
|
|
|
|
// unregistering this already-running owner effect from inside itself.
|
|
|
|
|
if (disposing !== undefined) return
|
|
|
|
|
abort.abort(new Error(`agent "${id}" setup aborted: owner disposed during setup`))
|
|
|
|
|
return dispose(true)
|
2026-07-24 11:46:06 +08:00
|
|
|
}, `agentLoop.lifecycle(${id})`)
|
test: restore 100% per-file coverage for agent-loop and the acp bridge
agent-loop: behavior tests for retry-while-busy, cancelled recovery
windows, no-facts stream failures, idle-listener preemption, rejected
driver promises under whenIdle, finish-chunk failures after step close,
presentationMeta persistence, pre-aborted and torn-down create/resume
signals, and configured-start failures over existing artifacts or after
teardown. The remaining guards that no public path can reach carry
justified v8 ignore annotations naming the invariant that starves them.
acp bridge: cover the retry-adoption path (a retry turn resolves the
prompt the failed turn deferred), the no-retry quiescence rejection, and
the admission-blocked cancelled settlement; the synchronous send-throw
catch is annotated as a future-proofing guard since the machine's send()
contains listener failures.
2026-07-26 14:57:24 +08:00
|
|
|
/* v8 ignore start -- ctx.effect throws only on an inactive fiber, which assertActive() above already rejected */
|
2026-07-24 11:46:06 +08:00
|
|
|
} catch (error: unknown) {
|
|
|
|
|
untrack()
|
|
|
|
|
callerSignal?.removeEventListener('abort', onCallerAbort)
|
|
|
|
|
this.ownership.signal.removeEventListener('abort', onFactoryTeardown)
|
|
|
|
|
throw error
|
|
|
|
|
}
|
test: restore 100% per-file coverage for agent-loop and the acp bridge
agent-loop: behavior tests for retry-while-busy, cancelled recovery
windows, no-facts stream failures, idle-listener preemption, rejected
driver promises under whenIdle, finish-chunk failures after step close,
presentationMeta persistence, pre-aborted and torn-down create/resume
signals, and configured-start failures over existing artifacts or after
teardown. The remaining guards that no public path can reach carry
justified v8 ignore annotations naming the invariant that starves them.
acp bridge: cover the retry-adoption path (a retry turn resolves the
prompt the failed turn deferred), the no-retry quiescence rejection, and
the admission-blocked cancelled settlement; the synchronous send-throw
catch is annotated as a future-proofing guard since the machine's send()
contains listener failures.
2026-07-26 14:57:24 +08:00
|
|
|
/* v8 ignore stop */
|
2026-07-24 11:46:06 +08:00
|
|
|
|
|
|
|
|
const assertLive = (): void => {
|
|
|
|
|
if (!abort.signal.aborted) return
|
test: restore 100% per-file coverage for agent-loop and the acp bridge
agent-loop: behavior tests for retry-while-busy, cancelled recovery
windows, no-facts stream failures, idle-listener preemption, rejected
driver promises under whenIdle, finish-chunk failures after step close,
presentationMeta persistence, pre-aborted and torn-down create/resume
signals, and configured-start failures over existing artifacts or after
teardown. The remaining guards that no public path can reach carry
justified v8 ignore annotations naming the invariant that starves them.
acp bridge: cover the retry-adoption path (a retry turn resolves the
prompt the failed turn deferred), the no-retry quiescence rejection, and
the admission-blocked cancelled settlement; the synchronous send-throw
catch is annotated as a future-proofing guard since the machine's send()
contains listener failures.
2026-07-26 14:57:24 +08:00
|
|
|
// Every fused abort source carries an Error reason: onCallerAbort and
|
|
|
|
|
// raceAbort wrap non-Error caller reasons, and the factory/lifecycle
|
|
|
|
|
// owners abort with constructed Errors.
|
|
|
|
|
/* v8 ignore next -- unreachable String() arm, see above */
|
2026-07-24 11:46:06 +08:00
|
|
|
throw abort.signal.reason instanceof Error ? abort.signal.reason : new Error(String(abort.signal.reason))
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
const agent = machine = new ReactLoopAgent(loopCtx, id, options, session)
|
2026-07-24 21:18:48 +08:00
|
|
|
machineReady.resolve()
|
2026-07-24 11:46:06 +08:00
|
|
|
assertLive()
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
agent,
|
|
|
|
|
signal: abort.signal,
|
|
|
|
|
publish: (source) => {
|
|
|
|
|
assertLive()
|
|
|
|
|
detachSession = agent.ctx.sessions.enter(session)
|
|
|
|
|
detachAgent = loopCtx.agents.enter(agent, ownerCtx.agent)
|
|
|
|
|
agent.ctx.sessions.announce(session)
|
|
|
|
|
assertLive()
|
|
|
|
|
loopCtx.agents.announce(agent)
|
|
|
|
|
assertLive()
|
|
|
|
|
// A synchronous announce/session-start listener may have started
|
2026-07-30 13:49:57 +08:00
|
|
|
// teardown; the machine is already live (delivery works from the
|
2026-07-24 11:46:06 +08:00
|
|
|
// session-start seam), so only the liveness recheck is owed.
|
|
|
|
|
emitAgentEvent(loopCtx, agent, 'agent/session-start', source)
|
|
|
|
|
assertLive()
|
|
|
|
|
return { agent, dispose }
|
|
|
|
|
},
|
|
|
|
|
dispose,
|
|
|
|
|
}
|
|
|
|
|
} catch (error: unknown) {
|
2026-07-24 21:18:48 +08:00
|
|
|
machineReady.resolve()
|
2026-07-24 11:46:06 +08:00
|
|
|
void dispose()
|
|
|
|
|
throw error
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
Implement the agent loop plugin
@deepseek-ai/dsh-agent-loop: LoopAgent (inbox with queued + steering
FIFOs, per-step AbortController) and the streaming-first
session/turn/step loop. Extension seams: agent/request,
agent/step-result, agent/turn-continuation waterfalls; raw chunks
logged for replay while BlockAssembler builds the assembled message;
steering drains between steps; session/flush awaited at turn end.
16 tests with a scripted mock adapter cover turn lifecycle ordering,
tool round-trips, steering, inject(), continuation override/veto,
mid-stream abort, queued turn chaining, replay equivalence, and
mid-turn fiber disposal (HMR safety).
2026-06-11 10:54:31 +08:00
|
|
|
/**
|
2026-07-14 01:59:21 +08:00
|
|
|
* Create an agent and session under one caller-supplied identity, owned by
|
|
|
|
|
* the accessing fiber. Constructor-driven config calls mint a fresh combined
|
|
|
|
|
* id before entering this boundary.
|
|
|
|
|
* @param id - shared agent/session identity.
|
2026-07-12 22:36:04 +08:00
|
|
|
* @param options - concrete loop options.
|
|
|
|
|
* @param meta - optional fresh-session workspace metadata.
|
|
|
|
|
* @returns the published running agent.
|
Implement the agent loop plugin
@deepseek-ai/dsh-agent-loop: LoopAgent (inbox with queued + steering
FIFOs, per-step AbortController) and the streaming-first
session/turn/step loop. Extension seams: agent/request,
agent/step-result, agent/turn-continuation waterfalls; raw chunks
logged for replay while BlockAssembler builds the assembled message;
steering drains between steps; session/flush awaited at turn end.
16 tests with a scripted mock adapter cover turn lifecycle ordering,
tool round-trips, steering, inject(), continuation override/veto,
mid-stream abort, queued turn chaining, replay equivalence, and
mid-turn fiber disposal (HMR safety).
2026-06-11 10:54:31 +08:00
|
|
|
*/
|
2026-07-14 02:32:35 +08:00
|
|
|
create(id: SessionId, options: AgentOptions = {}, meta: Pick<SessionHeader, 'cwd'> = {}): Agent {
|
2026-07-24 11:46:06 +08:00
|
|
|
const session = this.runtime.ctx.sessions.prepare(id, { meta })
|
|
|
|
|
const prepared = this.prepare(this.ctx, id, options, session)
|
2026-07-12 05:13:17 +08:00
|
|
|
try {
|
2026-07-24 11:46:06 +08:00
|
|
|
return prepared.publish('startup').agent
|
2026-07-12 08:57:05 +08:00
|
|
|
} catch (error: unknown) {
|
2026-07-24 11:46:06 +08:00
|
|
|
void prepared.dispose()
|
2026-07-12 08:57:05 +08:00
|
|
|
throw error
|
2026-07-12 05:13:17 +08:00
|
|
|
}
|
2026-06-15 21:12:14 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-07-12 22:36:04 +08:00
|
|
|
* Create an owned agent on a caller-supplied session id.
|
2026-07-24 11:46:06 +08:00
|
|
|
* @param ownerCtx - caller context that structurally owns the lifecycle.
|
2026-07-12 22:36:04 +08:00
|
|
|
* @param options - identities, session seed/metadata, loop options, setup, and cancellation.
|
|
|
|
|
* @returns the published handle.
|
2026-06-15 21:12:14 +08:00
|
|
|
*/
|
2026-07-12 08:57:05 +08:00
|
|
|
async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle> {
|
2026-07-24 11:46:06 +08:00
|
|
|
const session = this.runtime.ctx.sessions.prepare(options.sessionId, {
|
|
|
|
|
...options.seed === undefined ? {} : { seed: options.seed },
|
|
|
|
|
...options.meta === undefined ? {} : { meta: options.meta },
|
|
|
|
|
})
|
2026-08-04 23:26:37 +08:00
|
|
|
const published = this.setupAndPublish(
|
|
|
|
|
ownerCtx,
|
|
|
|
|
options.sessionId,
|
|
|
|
|
session,
|
|
|
|
|
options.agentOptions ?? {},
|
|
|
|
|
options.setup,
|
|
|
|
|
options.signal,
|
|
|
|
|
'startup',
|
|
|
|
|
)
|
2026-07-24 11:46:06 +08:00
|
|
|
this.ownership.trackWrapper(published)
|
|
|
|
|
return published
|
2026-06-15 21:12:14 +08:00
|
|
|
}
|
|
|
|
|
|
2026-08-04 23:26:37 +08:00
|
|
|
/** Prepare one Agent around an acquired Session, run setup, and publish it. */
|
|
|
|
|
private async setupAndPublish(
|
|
|
|
|
ownerCtx: Context,
|
|
|
|
|
id: SessionId,
|
|
|
|
|
session: Session,
|
|
|
|
|
agentOptions: AgentOptions,
|
|
|
|
|
setup: AgentSetup | undefined,
|
|
|
|
|
signal: AbortSignal | undefined,
|
|
|
|
|
source: SessionStartSource,
|
|
|
|
|
): Promise<AgentHandle> {
|
|
|
|
|
const prepared = this.prepare(ownerCtx, id, agentOptions, session, signal)
|
|
|
|
|
try {
|
|
|
|
|
const setupCommit = await raceAbort(setup?.(prepared.agent.ctx), prepared.signal, id)
|
|
|
|
|
setupCommit?.commit()
|
|
|
|
|
return prepared.publish(source)
|
|
|
|
|
} catch (error: unknown) {
|
|
|
|
|
await prepared.dispose()
|
|
|
|
|
throw error
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-15 21:12:14 +08:00
|
|
|
/**
|
2026-07-12 22:36:04 +08:00
|
|
|
* Resume an owned agent from the configured persistence service.
|
|
|
|
|
* @param ownerCtx - caller context that owns load, setup, and the live lifecycle.
|
|
|
|
|
* @param options - persisted identity, loop options, setup, and cancellation.
|
|
|
|
|
* @returns the published handle.
|
2026-06-15 21:12:14 +08:00
|
|
|
*/
|
2026-07-12 08:57:05 +08:00
|
|
|
async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle> {
|
2026-07-12 22:36:04 +08:00
|
|
|
const persistence = this.runtime.ctx.get('sessionPersistence')
|
2026-06-15 21:12:14 +08:00
|
|
|
if (persistence === undefined) {
|
|
|
|
|
throw new Error('cannot resume: session persistence is not configured (load a dsh-session-persistence backend)')
|
|
|
|
|
}
|
2026-07-12 08:57:05 +08:00
|
|
|
return this.resumeWith(ownerCtx, persistence, options)
|
2026-06-16 22:28:01 +08:00
|
|
|
}
|
|
|
|
|
|
2026-07-12 22:36:04 +08:00
|
|
|
/** Resume through an explicit persistence handle used by the deferred config path. */
|
2026-07-24 11:46:06 +08:00
|
|
|
private resumeWith(
|
2026-07-12 08:57:05 +08:00
|
|
|
ownerCtx: Context,
|
2026-07-12 22:36:04 +08:00
|
|
|
persistence: SessionPersistence,
|
|
|
|
|
options: ResumeAgentOptions,
|
2026-07-11 22:55:26 +08:00
|
|
|
): Promise<AgentHandle> {
|
2026-07-24 11:46:06 +08:00
|
|
|
const id = options.resumeSessionId
|
|
|
|
|
const published = (async () => {
|
|
|
|
|
// The load may outlive its owner: race it against caller cancellation,
|
|
|
|
|
// owner-fiber unload, and factory teardown so a never-settling backend
|
|
|
|
|
// cannot pin the identity.
|
2026-07-24 21:18:48 +08:00
|
|
|
const ownerAbort = new AbortController()
|
|
|
|
|
const unfollowOwner = ownerCtx.effect(() => () => {
|
|
|
|
|
ownerAbort.abort(new Error(`agent "${id}" setup aborted: owner disposed during setup`))
|
|
|
|
|
}, `agentLoop.resume-load(${id})`)
|
2026-07-24 11:46:06 +08:00
|
|
|
const fused = AbortSignal.any([
|
|
|
|
|
...options.signal === undefined ? [] : [options.signal],
|
2026-07-24 21:18:48 +08:00
|
|
|
ownerAbort.signal,
|
2026-07-24 11:46:06 +08:00
|
|
|
this.ownership.signal,
|
|
|
|
|
])
|
2026-07-24 21:18:48 +08:00
|
|
|
let loaded: Awaited<ReturnType<SessionPersistence['load']>>
|
|
|
|
|
try {
|
|
|
|
|
loaded = await raceAbort(persistence.load(id), fused, id)
|
|
|
|
|
} finally {
|
|
|
|
|
await unfollowOwner()
|
|
|
|
|
}
|
2026-07-24 11:46:06 +08:00
|
|
|
ownerCtx.fiber.assertActive()
|
|
|
|
|
if (!this.ownership.isActive()) throw new Error('agent loop is not active')
|
|
|
|
|
const session = this.runtime.ctx.sessions.prepare(id, {
|
2026-07-12 22:36:04 +08:00
|
|
|
seed: loaded.events,
|
2026-07-20 18:26:19 +08:00
|
|
|
meta: loaded.meta,
|
2026-07-12 22:36:04 +08:00
|
|
|
})
|
2026-07-24 11:46:06 +08:00
|
|
|
const prepared = this.prepare(ownerCtx, id, options.agentOptions ?? {}, session, options.signal)
|
|
|
|
|
try {
|
fix(agent): commit mutable setup at publication
Agent setup may await while a mutable contribution registry changes. The previous subagent path validated and committed its provisioning batch inside the setup callback. A revocation queued after that callback returned therefore treated the installation as resident and released it, even though AgentLoop had not published the child yet. AgentLoop could then admit and announce a child whose required capability had already disappeared.
Introduce AgentSetupCommit as the optional synchronous result of create and resume setup. AgentLoop now awaits setup, invokes that commit with no intervening asynchronous boundary, and only then enters the Session and Agent registries. A commit failure follows the existing private-transaction rollback, so neither identity is published and the caller can reuse the id.
Keep continuable-subagent installations provisional until this publication commit. Contribution removal still releases every installation immediately, but now marks an unpublished batch invalid so its commit rejects with ACTIVATION_SETUP_REVOKED. Once the commit succeeds, later removal remains ordinary live revocation.
Cover create and resume ordering, resume commit rejection and identity reuse, and an assembled microtask revocation that leaves only the parent Agent and Session. Update the public JSDoc, architecture flow, package contracts, current Agent Notes, Chinese counterparts, pairing records, and generated Cordis API to describe the new boundary.
Validated with the four focused Agent/subagent test files (91 tests), the isolated assembled regression, targeted TypeScript project builds, generated Cordis API freshness, export JSDoc verification, scoped translation pairing, Markdown wrapping, and Mermaid parsing.
2026-08-02 20:09:05 +08:00
|
|
|
const setupCommit = await raceAbort(options.setup?.(prepared.agent.ctx), prepared.signal, id)
|
|
|
|
|
setupCommit?.commit()
|
2026-07-24 11:46:06 +08:00
|
|
|
return prepared.publish('resume')
|
|
|
|
|
} catch (error: unknown) {
|
|
|
|
|
await prepared.dispose()
|
|
|
|
|
throw error
|
|
|
|
|
}
|
|
|
|
|
})()
|
|
|
|
|
this.ownership.trackWrapper(published)
|
|
|
|
|
return published
|
Implement the agent loop plugin
@deepseek-ai/dsh-agent-loop: LoopAgent (inbox with queued + steering
FIFOs, per-step AbortController) and the streaming-first
session/turn/step loop. Extension seams: agent/request,
agent/step-result, agent/turn-continuation waterfalls; raw chunks
logged for replay while BlockAssembler builds the assembled message;
steering drains between steps; session/flush awaited at turn end.
16 tests with a scripted mock adapter cover turn lifecycle ordering,
tool round-trips, steering, inject(), continuation override/veto,
mid-stream abort, queued turn chaining, replay equivalence, and
mid-turn fiber disposal (HMR safety).
2026-06-11 10:54:31 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default AgentLoop
|