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 agent loop driver: one `runLoop()` invocation drives one agent for its
|
|
|
|
|
* whole lifetime. Error-contained at the turn level — a throwing plugin ends
|
|
|
|
|
* the turn, never kills the loop. See the JSDoc on `runLoop()` for the full
|
|
|
|
|
* lifecycle pseudo-code.
|
|
|
|
|
*
|
|
|
|
|
* @module dsh-agent-loop/loop
|
|
|
|
|
*/
|
|
|
|
|
|
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 { GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
|
|
|
|
|
import { BlockAssembler } from '@deepseek-ai/dsh-llm'
|
2026-06-11 12:18:52 +08:00
|
|
|
import type { Session, TurnEndReason, TurnTrigger } 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 { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
|
|
|
|
import type {} from '@deepseek-ai/dsh-tools'
|
|
|
|
|
import type { LoopAgent } from './agent.ts'
|
|
|
|
|
|
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
|
|
|
/** An Error with an optional machine-readable code (e.g., from LlmError or a throwing plugin). */
|
|
|
|
|
type CodedError = Error & { code?: string }
|
|
|
|
|
|
Enable maximum-strict TypeScript across our packages
tsconfig.base.json adds noUncheckedIndexedAccess,
exactOptionalPropertyTypes, noImplicitOverride,
noFallthroughCasesInSwitch, noUnusedLocals, and noUnusedParameters on
top of strict. Vendored packages opt out of the new flags locally
(their tsconfigs are ours to regenerate; their source is not), keeping
upstream-sync friendliness.
Our code fixed accordingly: index accesses acknowledge undefined
(assembler flush cursors, lastTurnNumber); optional properties are
omitted instead of set-to-undefined (GenerateResult.usage,
ToolDefinition.strict, GenerateOptions.system/tools, error payloads
via an errorData helper); Session.onAppend is explicitly
`(…) => void | undefined`; tests and examples updated for unused
parameters and indexed access.
2026-06-11 14:02:47 +08:00
|
|
|
/** Normalize an arbitrary thrown value into a (possibly coded) Error. */
|
|
|
|
|
function toError(error: unknown): CodedError {
|
|
|
|
|
return error instanceof Error ? error : new Error(String(error))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Build the `{ message, code? }` part of an error payload, omitting the
|
|
|
|
|
* `code` key entirely when absent (exactOptionalPropertyTypes-correct).
|
|
|
|
|
*/
|
|
|
|
|
function errorData(err: CodedError): { message: string; code?: string } {
|
|
|
|
|
return { message: err.message, ...typeof err.code === 'string' ? { code: err.code } : {} }
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
/**
|
|
|
|
|
* Ambient handles the loop driver receives from the agent. Decouples the
|
|
|
|
|
* pure function `runLoop` from the mutable LoopAgent fields, making the
|
|
|
|
|
* loop testable without a real 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
|
|
|
export interface LoopHandle {
|
|
|
|
|
setStatus(status: 'idle' | 'running'): void
|
|
|
|
|
setAbort(controller: AbortController | undefined): void
|
|
|
|
|
/** Resolves when the agent is disposed — unblocks the idle wait. */
|
|
|
|
|
disposed: Promise<void>
|
|
|
|
|
isDisposed(): boolean
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The agent loop. One invocation drives one agent for its whole lifetime:
|
|
|
|
|
*
|
|
|
|
|
* ```
|
|
|
|
|
* forever:
|
|
|
|
|
* wait for queued messages (idle)
|
2026-06-11 12:18:52 +08:00
|
|
|
* TURN (error-contained — a throwing plugin ends the turn, never the loop):
|
|
|
|
|
* drain queued → session('user/message'…) → 'turn/start' → emit agent/turn-start
|
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
|
|
|
* STEP loop:
|
2026-06-11 12:18:52 +08:00
|
|
|
* drain steering → session('steering/message') ⟵ catches late steering
|
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
|
|
|
* emit agent/step-start
|
2026-06-11 12:18:52 +08:00
|
|
|
* assembly = ctx.systemPrompt.assemble() ⟵ waterfall system-prompt/assemble
|
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
|
|
|
* req = {model, system, tools, messages: session.deriveMessages(), signal}
|
2026-06-11 12:18:52 +08:00
|
|
|
* req = waterfall agent/request ⟵ hooks/compaction/model-switch
|
|
|
|
|
* stream ctx.llm.stream(req) ⟵ waterfall llm/stream (raw chunks)
|
|
|
|
|
* session('assistant/chunk'); emit agent/stream-chunk
|
|
|
|
|
* msg = waterfall agent/step-result ⟵ BEFORE the log append, so the
|
|
|
|
|
* session('assistant/message','usage') session records what actually ran
|
|
|
|
|
* each tool-call in msg (sequential, abort-checked):
|
|
|
|
|
* session('tool/call'); ctx.tools.execute() ⟵ waterfall tools/execute
|
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
|
|
|
* session('tool/result')
|
|
|
|
|
* drain steering → session('steering/message'); emit agent/steering
|
|
|
|
|
* emit agent/step-end
|
|
|
|
|
* cont = waterfall agent/turn-continuation(default = hadToolCalls || steered)
|
2026-06-11 12:18:52 +08:00
|
|
|
* if !cont && steering arrived from step-end/continuation listeners: cont = true
|
|
|
|
|
* if !cont: break
|
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
|
|
|
* session('turn/end'); emit agent/turn-end
|
2026-06-11 12:18:52 +08:00
|
|
|
* await ctx.parallel('session/flush', session) ⟵ durability checkpoint
|
|
|
|
|
* re-enqueue leftover steering as queued ⟵ steering is never stranded
|
|
|
|
|
* idle (emit agent/status) unless more queued
|
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 async function runLoop(ctx: Context, agent: LoopAgent, handle: LoopHandle): Promise<void> {
|
|
|
|
|
const { session } = agent
|
2026-06-11 12:18:52 +08:00
|
|
|
let turn = lastTurnNumber(session) // seeded/forked sessions continue numbering
|
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
|
|
|
|
|
|
|
|
while (!handle.isDisposed()) {
|
|
|
|
|
await agent.inbox.waitForQueued(handle.disposed)
|
|
|
|
|
if (handle.isDisposed()) break
|
|
|
|
|
|
|
|
|
|
handle.setStatus('running')
|
2026-06-11 12:18:52 +08:00
|
|
|
turn += 1
|
|
|
|
|
try {
|
|
|
|
|
await runTurn(ctx, agent, handle, turn)
|
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
|
|
|
} catch (error: unknown) {
|
2026-06-11 12:18:52 +08:00
|
|
|
// Backstop: a throwing emit listener (turn boundaries) or a broken
|
|
|
|
|
// finalizer must not kill the driver. Record what we can and move on.
|
|
|
|
|
try {
|
Enable maximum-strict TypeScript across our packages
tsconfig.base.json adds noUncheckedIndexedAccess,
exactOptionalPropertyTypes, noImplicitOverride,
noFallthroughCasesInSwitch, noUnusedLocals, and noUnusedParameters on
top of strict. Vendored packages opt out of the new flags locally
(their tsconfigs are ours to regenerate; their source is not), keeping
upstream-sync friendliness.
Our code fixed accordingly: index accesses acknowledge undefined
(assembler flush cursors, lastTurnNumber); optional properties are
omitted instead of set-to-undefined (GenerateResult.usage,
ToolDefinition.strict, GenerateOptions.system/tools, error payloads
via an errorData helper); Session.onAppend is explicitly
`(…) => void | undefined`; tests and examples updated for unused
parameters and indexed access.
2026-06-11 14:02:47 +08:00
|
|
|
const err = toError(error)
|
|
|
|
|
session.append('error', { turn, step: 0, ...errorData(err) })
|
2026-06-11 12:18:52 +08:00
|
|
|
ctx.emit('agent/error', agent, turn, 0, err)
|
|
|
|
|
} catch { /* the error path itself is broken; nothing left to do */ }
|
|
|
|
|
}
|
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-06-11 12:18:52 +08:00
|
|
|
// Steering that arrived too late to join this turn (turn-end listeners,
|
|
|
|
|
// flush) becomes a queued message — it must never be stranded.
|
|
|
|
|
for (const message of agent.inbox.drainSteering()) {
|
|
|
|
|
agent.inbox.enqueue(message)
|
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-06-11 12:18:52 +08:00
|
|
|
if (!agent.inbox.hasQueued) handle.setStatus('idle')
|
|
|
|
|
}
|
|
|
|
|
}
|
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-06-11 12:18:52 +08:00
|
|
|
async function runTurn(ctx: Context, agent: LoopAgent, handle: LoopHandle, turn: number): Promise<void> {
|
|
|
|
|
const { session } = 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-06-11 12:18:52 +08:00
|
|
|
// Drain queued messages into the session — they trigger this turn.
|
|
|
|
|
const queued = agent.inbox.drainQueued()
|
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
|
|
|
const first = queued[0]
|
|
|
|
|
if (!first) throw new Error('runTurn invariant violated: no queued message at turn start')
|
|
|
|
|
const trigger: TurnTrigger = { kind: 'message', source: first.source }
|
2026-06-11 12:18:52 +08:00
|
|
|
for (const message of queued) {
|
|
|
|
|
session.append('user/message', { content: message.content, source: message.source })
|
|
|
|
|
}
|
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-06-11 12:18:52 +08:00
|
|
|
session.append('turn/start', { turn, trigger })
|
|
|
|
|
ctx.emit('agent/turn-start', agent, turn)
|
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-06-11 12:18:52 +08:00
|
|
|
let reason: TurnEndReason = { kind: 'completed' }
|
|
|
|
|
let step = 0
|
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-06-11 12:18:52 +08:00
|
|
|
while (true) {
|
|
|
|
|
step += 1
|
|
|
|
|
|
|
|
|
|
// Steering from the previous round's step-end/continuation listeners
|
|
|
|
|
// (or turn-start listeners on the first step) joins before the request.
|
|
|
|
|
drainSteering(ctx, agent, turn)
|
|
|
|
|
|
|
|
|
|
ctx.emit('agent/step-start', agent, turn, step)
|
|
|
|
|
session.append('step/start', { turn, step })
|
|
|
|
|
|
|
|
|
|
const abort = new AbortController()
|
|
|
|
|
handle.setAbort(abort)
|
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-06-11 12:18:52 +08:00
|
|
|
let stepOutcome: { hadToolCalls: boolean } | { error: Error }
|
|
|
|
|
try {
|
|
|
|
|
stepOutcome = await runStep(ctx, agent, turn, step, abort.signal)
|
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
|
|
|
} catch (error: unknown) {
|
2026-06-11 12:18:52 +08:00
|
|
|
stepOutcome = { error: error instanceof Error ? error : new Error(String(error)) }
|
|
|
|
|
} finally {
|
|
|
|
|
handle.setAbort(undefined)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ('error' in stepOutcome) {
|
|
|
|
|
// Steering that arrived during the failed step stays in the inbox —
|
|
|
|
|
// runLoop re-enqueues it as a queued message, so an abort-then-steer
|
|
|
|
|
// starts a fresh turn instead of being silently consumed.
|
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
|
|
|
session.append('step/end', { turn, step })
|
|
|
|
|
ctx.emit('agent/step-end', agent, turn, step)
|
2026-06-11 12:18:52 +08:00
|
|
|
const { error } = stepOutcome
|
|
|
|
|
if (handle.isDisposed()) {
|
|
|
|
|
reason = { kind: 'disposed' }
|
|
|
|
|
} else if (abort.signal.aborted) {
|
|
|
|
|
reason = { kind: 'aborted', reason: String(abort.signal.reason ?? 'aborted') }
|
|
|
|
|
} else {
|
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
|
|
|
const coded = error as CodedError
|
Enable maximum-strict TypeScript across our packages
tsconfig.base.json adds noUncheckedIndexedAccess,
exactOptionalPropertyTypes, noImplicitOverride,
noFallthroughCasesInSwitch, noUnusedLocals, and noUnusedParameters on
top of strict. Vendored packages opt out of the new flags locally
(their tsconfigs are ours to regenerate; their source is not), keeping
upstream-sync friendliness.
Our code fixed accordingly: index accesses acknowledge undefined
(assembler flush cursors, lastTurnNumber); optional properties are
omitted instead of set-to-undefined (GenerateResult.usage,
ToolDefinition.strict, GenerateOptions.system/tools, error payloads
via an errorData helper); Session.onAppend is explicitly
`(…) => void | undefined`; tests and examples updated for unused
parameters and indexed access.
2026-06-11 14:02:47 +08:00
|
|
|
session.append('error', { turn, step, ...errorData(coded) })
|
2026-06-11 12:18:52 +08:00
|
|
|
ctx.emit('agent/error', agent, turn, step, error)
|
Enable maximum-strict TypeScript across our packages
tsconfig.base.json adds noUncheckedIndexedAccess,
exactOptionalPropertyTypes, noImplicitOverride,
noFallthroughCasesInSwitch, noUnusedLocals, and noUnusedParameters on
top of strict. Vendored packages opt out of the new flags locally
(their tsconfigs are ours to regenerate; their source is not), keeping
upstream-sync friendliness.
Our code fixed accordingly: index accesses acknowledge undefined
(assembler flush cursors, lastTurnNumber); optional properties are
omitted instead of set-to-undefined (GenerateResult.usage,
ToolDefinition.strict, GenerateOptions.system/tools, error payloads
via an errorData helper); Session.onAppend is explicitly
`(…) => void | undefined`; tests and examples updated for unused
parameters and indexed access.
2026-06-11 14:02:47 +08:00
|
|
|
reason = { kind: 'error', ...errorData(coded) }
|
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-06-11 12:18:52 +08:00
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Steering that arrived during streaming/tool execution.
|
|
|
|
|
const steered = drainSteering(ctx, agent, turn)
|
|
|
|
|
|
|
|
|
|
session.append('step/end', { turn, step })
|
|
|
|
|
ctx.emit('agent/step-end', agent, turn, step)
|
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-06-11 12:18:52 +08:00
|
|
|
const defaultDecision = stepOutcome.hadToolCalls || steered
|
|
|
|
|
let shouldContinue: boolean
|
|
|
|
|
try {
|
|
|
|
|
shouldContinue = await ctx.waterfall(
|
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
|
|
|
'agent/turn-continuation', agent, turn, defaultDecision,
|
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
|
|
|
() => Promise.resolve(defaultDecision),
|
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
|
|
|
)
|
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
|
|
|
} catch (error: unknown) {
|
2026-06-11 12:18:52 +08:00
|
|
|
// A broken continuation plugin ends the turn, not the loop.
|
Enable maximum-strict TypeScript across our packages
tsconfig.base.json adds noUncheckedIndexedAccess,
exactOptionalPropertyTypes, noImplicitOverride,
noFallthroughCasesInSwitch, noUnusedLocals, and noUnusedParameters on
top of strict. Vendored packages opt out of the new flags locally
(their tsconfigs are ours to regenerate; their source is not), keeping
upstream-sync friendliness.
Our code fixed accordingly: index accesses acknowledge undefined
(assembler flush cursors, lastTurnNumber); optional properties are
omitted instead of set-to-undefined (GenerateResult.usage,
ToolDefinition.strict, GenerateOptions.system/tools, error payloads
via an errorData helper); Session.onAppend is explicitly
`(…) => void | undefined`; tests and examples updated for unused
parameters and indexed access.
2026-06-11 14:02:47 +08:00
|
|
|
const err = toError(error)
|
|
|
|
|
session.append('error', { turn, step, ...errorData(err) })
|
2026-06-11 12:18:52 +08:00
|
|
|
ctx.emit('agent/error', agent, turn, step, err)
|
Enable maximum-strict TypeScript across our packages
tsconfig.base.json adds noUncheckedIndexedAccess,
exactOptionalPropertyTypes, noImplicitOverride,
noFallthroughCasesInSwitch, noUnusedLocals, and noUnusedParameters on
top of strict. Vendored packages opt out of the new flags locally
(their tsconfigs are ours to regenerate; their source is not), keeping
upstream-sync friendliness.
Our code fixed accordingly: index accesses acknowledge undefined
(assembler flush cursors, lastTurnNumber); optional properties are
omitted instead of set-to-undefined (GenerateResult.usage,
ToolDefinition.strict, GenerateOptions.system/tools, error payloads
via an errorData helper); Session.onAppend is explicitly
`(…) => void | undefined`; tests and examples updated for unused
parameters and indexed access.
2026-06-11 14:02:47 +08:00
|
|
|
reason = { kind: 'error', ...errorData(err) }
|
2026-06-11 12:18:52 +08:00
|
|
|
break
|
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-06-11 12:18:52 +08:00
|
|
|
// Steering from step-end/continuation listeners (the /goal pattern)
|
|
|
|
|
// demands the model see it — it overrides a negative decision; the
|
|
|
|
|
// next iteration's drain records it.
|
|
|
|
|
if (!shouldContinue && agent.inbox.hasSteering) shouldContinue = true
|
|
|
|
|
|
|
|
|
|
if (!shouldContinue || handle.isDisposed()) {
|
|
|
|
|
if (handle.isDisposed()) reason = { kind: 'disposed' }
|
|
|
|
|
break
|
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-06-11 12:18:52 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
session.append('turn/end', { turn, reason })
|
|
|
|
|
ctx.emit('agent/turn-end', agent, turn, reason)
|
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-06-11 12:18:52 +08:00
|
|
|
// Durability checkpoint: persistence plugins drain write-behind buffers.
|
|
|
|
|
// A failing persistence plugin is reported but doesn't kill the agent.
|
|
|
|
|
try {
|
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
|
|
|
await ctx.parallel('session/flush', session)
|
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
|
|
|
} catch (error: unknown) {
|
Enable maximum-strict TypeScript across our packages
tsconfig.base.json adds noUncheckedIndexedAccess,
exactOptionalPropertyTypes, noImplicitOverride,
noFallthroughCasesInSwitch, noUnusedLocals, and noUnusedParameters on
top of strict. Vendored packages opt out of the new flags locally
(their tsconfigs are ours to regenerate; their source is not), keeping
upstream-sync friendliness.
Our code fixed accordingly: index accesses acknowledge undefined
(assembler flush cursors, lastTurnNumber); optional properties are
omitted instead of set-to-undefined (GenerateResult.usage,
ToolDefinition.strict, GenerateOptions.system/tools, error payloads
via an errorData helper); Session.onAppend is explicitly
`(…) => void | undefined`; tests and examples updated for unused
parameters and indexed access.
2026-06-11 14:02:47 +08:00
|
|
|
const err = toError(error)
|
|
|
|
|
session.append('error', { turn, step, ...errorData(err) })
|
2026-06-11 12:18:52 +08:00
|
|
|
ctx.emit('agent/error', agent, turn, step, err)
|
|
|
|
|
}
|
|
|
|
|
}
|
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-06-11 12:18:52 +08:00
|
|
|
/** Drain the steering queue into the session. Returns whether any arrived. */
|
|
|
|
|
function drainSteering(ctx: Context, agent: LoopAgent, turn: number): boolean {
|
|
|
|
|
const messages = agent.inbox.drainSteering()
|
|
|
|
|
for (const message of messages) {
|
|
|
|
|
agent.session.append('steering/message', { turn, content: message.content, source: message.source })
|
|
|
|
|
ctx.emit('agent/steering', agent, turn, message.content, message.source)
|
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-06-11 12:18:52 +08:00
|
|
|
return messages.length > 0
|
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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** One step: assemble request → stream model → record → execute tools. */
|
|
|
|
|
async function runStep(
|
|
|
|
|
ctx: Context,
|
|
|
|
|
agent: LoopAgent,
|
|
|
|
|
turn: number,
|
|
|
|
|
step: number,
|
|
|
|
|
signal: AbortSignal,
|
|
|
|
|
): Promise<{ hadToolCalls: boolean }> {
|
|
|
|
|
const { session, options } = agent
|
|
|
|
|
|
|
|
|
|
// --- Request assembly ---
|
|
|
|
|
const assembly = await ctx.systemPrompt.assemble()
|
|
|
|
|
const system = [renderPrompt(assembly), options.systemPrompt ?? '']
|
|
|
|
|
.filter(text => text.length > 0)
|
|
|
|
|
.join('\n\n')
|
|
|
|
|
|
|
|
|
|
let request: GenerateOptions = {
|
2026-06-11 12:18:52 +08:00
|
|
|
model: options.model ?? '',
|
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
|
|
|
messages: session.deriveMessages(),
|
Enable maximum-strict TypeScript across our packages
tsconfig.base.json adds noUncheckedIndexedAccess,
exactOptionalPropertyTypes, noImplicitOverride,
noFallthroughCasesInSwitch, noUnusedLocals, and noUnusedParameters on
top of strict. Vendored packages opt out of the new flags locally
(their tsconfigs are ours to regenerate; their source is not), keeping
upstream-sync friendliness.
Our code fixed accordingly: index accesses acknowledge undefined
(assembler flush cursors, lastTurnNumber); optional properties are
omitted instead of set-to-undefined (GenerateResult.usage,
ToolDefinition.strict, GenerateOptions.system/tools, error payloads
via an errorData helper); Session.onAppend is explicitly
`(…) => void | undefined`; tests and examples updated for unused
parameters and indexed access.
2026-06-11 14:02:47 +08:00
|
|
|
...system ? { system } : {},
|
|
|
|
|
...assembly.tools.length > 0 ? { tools: assembly.tools } : {},
|
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
|
|
|
signal,
|
|
|
|
|
}
|
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
|
|
|
request = await ctx.waterfall('agent/request', agent, turn, step, request, () => Promise.resolve(request))
|
2026-06-11 12:18:52 +08:00
|
|
|
if (!request.model) {
|
|
|
|
|
throw new Error(`agent "${agent.id}" has no model: set AgentOptions.model or supply one via the agent/request waterfall`)
|
|
|
|
|
}
|
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 call (streaming-first; raw chunks are the replay record) ---
|
|
|
|
|
const assembler = new BlockAssembler()
|
|
|
|
|
for await (const chunk of ctx.llm.stream(request)) {
|
|
|
|
|
if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
|
|
|
|
|
session.append('assistant/chunk', { turn, step, chunk })
|
|
|
|
|
ctx.emit('agent/stream-chunk', agent, turn, step, chunk)
|
|
|
|
|
assembler.push(chunk)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 12:18:52 +08:00
|
|
|
// The step-result waterfall runs BEFORE the session append so the log (the
|
|
|
|
|
// source of truth for derived history and replay) records the message that
|
|
|
|
|
// tool dispatch actually uses.
|
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
|
|
|
let message: Message = assembler.message()
|
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
|
|
|
message = await ctx.waterfall('agent/step-result', agent, turn, step, message, () => Promise.resolve(message))
|
2026-06-11 12:18:52 +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
|
|
|
session.append('assistant/message', { turn, step, content: message.content })
|
|
|
|
|
if (assembler.usage) {
|
|
|
|
|
session.append('usage', { turn, step, usage: assembler.usage })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Tool execution (sequential; parallel execution is a TODO) ---
|
2026-06-11 12:18:52 +08:00
|
|
|
// ToolRegistry.execute converts tool failures (including aborts) into
|
|
|
|
|
// isError results, so abort is re-checked around every call here.
|
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
|
|
|
const toolCalls = message.content.filter(block => block.type === 'tool-call')
|
|
|
|
|
for (const call of toolCalls) {
|
2026-06-11 12:18:52 +08:00
|
|
|
if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
|
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
|
|
|
session.append('tool/call', { turn, step, callId: call.id, name: call.name, arguments: call.arguments })
|
|
|
|
|
let parsedArguments: unknown
|
|
|
|
|
try {
|
|
|
|
|
parsedArguments = call.arguments ? JSON.parse(call.arguments) : {}
|
|
|
|
|
} catch {
|
|
|
|
|
parsedArguments = call.arguments
|
|
|
|
|
}
|
|
|
|
|
const result = await ctx.tools.execute({
|
|
|
|
|
callId: call.id,
|
|
|
|
|
name: call.name,
|
|
|
|
|
arguments: parsedArguments,
|
|
|
|
|
agent,
|
|
|
|
|
signal,
|
|
|
|
|
})
|
|
|
|
|
session.append('tool/result', {
|
|
|
|
|
turn, step,
|
|
|
|
|
callId: result.callId,
|
|
|
|
|
content: result.content,
|
|
|
|
|
isError: result.isError,
|
|
|
|
|
})
|
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
|
|
|
// signal CAN flip during the await above (abort() inside a tool);
|
|
|
|
|
// the analyzer can't see through the await boundary.
|
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
2026-06-11 12:18:52 +08:00
|
|
|
if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
|
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 { hadToolCalls: toolCalls.length > 0 }
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 12:18:52 +08:00
|
|
|
/** The last turn number in a (possibly seeded) session log, or 0. */
|
|
|
|
|
function lastTurnNumber(session: Session): number {
|
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
|
|
|
const lastStart = session.events.findLast(event => event.type === 'turn/start')
|
|
|
|
|
return lastStart?.data.turn ?? 0
|
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
|
|
|
}
|