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
|
|
|
/**
|
|
|
|
|
* The concrete Agent implementation: LoopAgent plus its inbox. Everything
|
|
|
|
|
* observable happens through session events and the agent/* event taxonomy —
|
|
|
|
|
* plugins never need this class.
|
|
|
|
|
*
|
|
|
|
|
* @module dsh-agent-loop/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
|
|
|
import type { Context } from 'cordis'
|
|
|
|
|
import type { AgentOptions, AgentStatus, SendOptions } from '@deepseek-ai/dsh-agent'
|
|
|
|
|
import type { Agent } from '@deepseek-ai/dsh-agent'
|
2026-06-11 12:18:52 +08:00
|
|
|
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
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 { Session } from '@deepseek-ai/dsh-session'
|
|
|
|
|
import { Inbox } from './inbox.ts'
|
|
|
|
|
import { runLoop } from './loop.ts'
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The concrete {@link Agent} implementation owned by the agent-loop plugin.
|
|
|
|
|
*
|
|
|
|
|
* Owns the inbox (queued + steering FIFOs), the per-step AbortController, and
|
|
|
|
|
* the loop driver. Everything observable happens through session events and
|
|
|
|
|
* the agent/* event taxonomy — plugins never need this class.
|
|
|
|
|
*/
|
|
|
|
|
export class LoopAgent implements Agent {
|
|
|
|
|
readonly inbox = new Inbox()
|
|
|
|
|
|
|
|
|
|
private _status: AgentStatus = 'idle'
|
|
|
|
|
private currentAbort: AbortController | undefined
|
|
|
|
|
private disposed: Promise<void>
|
|
|
|
|
private resolveDisposed!: () => void
|
|
|
|
|
/** Resolves when the driver loop has fully exited (tests/disposal). */
|
|
|
|
|
done: Promise<void> = Promise.resolve()
|
|
|
|
|
|
|
|
|
|
constructor(
|
|
|
|
|
private ctx: Context,
|
|
|
|
|
public readonly id: string,
|
|
|
|
|
public readonly options: AgentOptions,
|
|
|
|
|
public readonly session: Session,
|
|
|
|
|
) {
|
|
|
|
|
const { promise, resolve } = Promise.withResolvers<void>()
|
|
|
|
|
this.disposed = promise
|
|
|
|
|
this.resolveDisposed = resolve
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
get status(): AgentStatus {
|
|
|
|
|
return this._status
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private setStatus(status: AgentStatus): void {
|
|
|
|
|
if (this._status === status || this._status === 'disposed') return
|
|
|
|
|
this._status = status
|
|
|
|
|
this.ctx.emit('agent/status', this, status)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 12:18:52 +08:00
|
|
|
private resolveSource(options?: SendOptions): MessageSource {
|
|
|
|
|
return options?.source ?? { kind: 'user' }
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
send(content: ContentBlock[], options?: SendOptions): void {
|
|
|
|
|
if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
|
2026-06-11 12:18:52 +08:00
|
|
|
const source = this.resolveSource(options)
|
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
|
|
|
this.inbox.enqueue({ content, source })
|
2026-06-11 12:18:52 +08:00
|
|
|
this.ctx.emit('agent/queued', this, content, { source, steering: false })
|
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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
steer(content: ContentBlock[], options?: SendOptions): void {
|
|
|
|
|
if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
|
Add ESLint: typescript-eslint strict-type-checked + stylistic formatting
Flat config with two layers. Correctness (type-checked): the headline
rules for this codebase are no-floating-promises / no-misused-promises
(a lost promise in the agent loop is our primary bug class),
switch-exhaustiveness-check (we switch over merge-extensible unions
everywhere), no-unnecessary-condition, require-await, and
no-explicit-any. Style (@stylistic): 2-space, no semicolons, single
quotes, trailing commas, max-len 140 — the existing house style, now
enforced instead of drifting between agents. vendor/ is excluded
(vendored source keeps upstream style); tests relax the rules that
fight test ergonomics (non-null assertions after expects, async mock
signatures, non-Error throws).
Code adjusted to pass: registry disposers wrap ctx.effect's
promise-returning disposer behind a sync () => void (our public API),
BlockAssembler gains an invariant-checking mustGet instead of non-null
assertions, lastTurnNumber uses findLast, waterfall tails return
Promise.resolve instead of async-without-await arrows, and the two
deliberate suppressions (non-exhaustive derivation switch, unbound
execute pass-through) carry justification comments.
yarn lint / yarn lint:fix added.
2026-06-11 14:17:58 +08:00
|
|
|
if (this._status !== 'running') { this.send(content, options); return }
|
2026-06-11 12:18:52 +08:00
|
|
|
const source = this.resolveSource(options)
|
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
|
|
|
this.inbox.steer({ content, source })
|
2026-06-11 12:18:52 +08:00
|
|
|
this.ctx.emit('agent/queued', this, content, { source, steering: true })
|
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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
inject(content: ContentBlock[], options?: SendOptions): void {
|
|
|
|
|
if (this._status === 'disposed') throw new Error(`agent "${this.id}" is disposed`)
|
2026-06-11 12:18:52 +08:00
|
|
|
this.session.append('context/message', { content, source: this.resolveSource(options) })
|
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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
abort(reason?: string): void {
|
|
|
|
|
this.currentAbort?.abort(reason ?? 'aborted')
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
/**
|
|
|
|
|
* Start the driver loop. Returns a disposer: calling it sets status to
|
|
|
|
|
* `disposed`, emits `agent/status('disposed')`, resolves the disposed
|
|
|
|
|
* promise (unblocking the idle wait), and aborts the current request if
|
|
|
|
|
* any. The returned `agent.done` promise resolves once the loop exits.
|
|
|
|
|
*/
|
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
|
|
|
start(): () => void {
|
|
|
|
|
this.done = runLoop(this.ctx, this, {
|
Add ESLint: typescript-eslint strict-type-checked + stylistic formatting
Flat config with two layers. Correctness (type-checked): the headline
rules for this codebase are no-floating-promises / no-misused-promises
(a lost promise in the agent loop is our primary bug class),
switch-exhaustiveness-check (we switch over merge-extensible unions
everywhere), no-unnecessary-condition, require-await, and
no-explicit-any. Style (@stylistic): 2-space, no semicolons, single
quotes, trailing commas, max-len 140 — the existing house style, now
enforced instead of drifting between agents. vendor/ is excluded
(vendored source keeps upstream style); tests relax the rules that
fight test ergonomics (non-null assertions after expects, async mock
signatures, non-Error throws).
Code adjusted to pass: registry disposers wrap ctx.effect's
promise-returning disposer behind a sync () => void (our public API),
BlockAssembler gains an invariant-checking mustGet instead of non-null
assertions, lastTurnNumber uses findLast, waterfall tails return
Promise.resolve instead of async-without-await arrows, and the two
deliberate suppressions (non-exhaustive derivation switch, unbound
execute pass-through) carry justification comments.
yarn lint / yarn lint:fix added.
2026-06-11 14:17:58 +08:00
|
|
|
setStatus: (status) => { this.setStatus(status) },
|
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
|
|
|
setAbort: controller => void (this.currentAbort = controller),
|
|
|
|
|
disposed: this.disposed,
|
|
|
|
|
isDisposed: () => this._status === 'disposed',
|
|
|
|
|
})
|
2026-06-11 12:18:52 +08:00
|
|
|
// The disposer must be infallible: it runs inside the fiber's LIFO
|
|
|
|
|
// disposal chain, where a throw would skip later disposers (e.g. the
|
|
|
|
|
// registry unregistration) and leave `done` pending forever.
|
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
|
|
|
return () => {
|
2026-06-11 12:18:52 +08:00
|
|
|
if (this._status === 'disposed') return
|
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
|
|
|
this._status = 'disposed'
|
|
|
|
|
this.resolveDisposed()
|
|
|
|
|
this.currentAbort?.abort('disposed')
|
2026-06-11 12:18:52 +08:00
|
|
|
// setStatus refuses transitions out of 'disposed', so emit directly —
|
|
|
|
|
// 'disposed' is part of the agent/status contract. Guarded: a throwing
|
|
|
|
|
// listener must not break the disposal chain.
|
|
|
|
|
try {
|
|
|
|
|
this.ctx.emit('agent/status', this, 'disposed')
|
|
|
|
|
} catch {
|
|
|
|
|
// listener error during disposal — nothing safe left to do with it
|
|
|
|
|
}
|
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
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|