refactor(agent-loop): rename LoopAgent to ReactLoopAgent

Rename the concrete Agent class to make its ReAct-style reasoning loop
explicit in the name. Package name, default-export plugin (`AgentLoop`),
and the `ctx.agentLoop` service key are unchanged.
This commit is contained in:
Tianyi Cui
2026-06-19 10:13:33 +08:00
parent c5049d1c3f
commit 224c6f029a
23 changed files with 91 additions and 91 deletions

View File

@@ -31,7 +31,7 @@ The rule: plugins depend on interfaces, never on the concrete loop. `dsh-agent-l
| `system-prompt/` | Prompt-section + tool-schema assembly registry | `ctx.systemPrompt` |
| `tools/` | Tool registry + `tools/execute` waterfall | `ctx.tools` |
| `agent/` | Agent interface, registry, `agent/*` event vocabulary | `ctx.agents` |
| `agent-loop/` | THE concrete loop plugin: `LoopAgent` + the loop driver | `ctx.agentLoop` |
| `agent-loop/` | THE concrete loop plugin: `ReactLoopAgent` + the loop driver | `ctx.agentLoop` |
| `bash/` | Abstract bash executor seam (interface + vocabulary) | `ctx.bash` |
| `bash-local/` | Local-subprocess `BashExecutor` implementation | (registers `ctx.bash`) |
| `tool-bash/` | Model-facing `bash`/`bash_output`/`bash_kill` tool schemas | (registers on `ctx.tools`) |

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-acp
The **Agent Client Protocol (ACP)** bridge: exposes the DeepSeek Harness coding agent as an ACP server over JSON-RPC stdio, so editors (Zed and other ACP clients) can drive it — streaming render, tool-call display, and resumable sessions. **N concurrent sessions per connection** (see [ACP multi-session](../../docs/rfc/proposed/2026-06-14-acp-multi-session.md)): each maps to its own `LoopAgent`, and every event is demuxed strictly by session id so two sessions streaming at once never interleave.
The **Agent Client Protocol (ACP)** bridge: exposes the DeepSeek Harness coding agent as an ACP server over JSON-RPC stdio, so editors (Zed and other ACP clients) can drive it — streaming render, tool-call display, and resumable sessions. **N concurrent sessions per connection** (see [ACP multi-session](../../docs/rfc/proposed/2026-06-14-acp-multi-session.md)): each maps to its own `ReactLoopAgent`, and every event is demuxed strictly by session id so two sessions streaming at once never interleave.
It is a **client-driver / UI plugin**, the structured analogue of the readline `stdio-chat` plugin — NOT a loop change and NOT a [capability seam](../../docs/rfc/implemented/2026-06-13-capability-seams.md). It consumes the existing `agent/*` event taxonomy, the `dsh-agent` create/resume factory, and `dsh-session-persistence`.

View File

@@ -16,7 +16,7 @@
* - `session/cancel` → `agent.abort()` + settle the in-flight prompt
*
* Multi-session (RFC 011): N concurrent sessions per connection, each mapped to
* its own `LoopAgent`. Sessions are keyed by id in `sessions` (forward) with an
* its own `ReactLoopAgent`. Sessions are keyed by id in `sessions` (forward) with an
* `agent→sessionId` reverse map for O(1) demux of `agent/*` events; every
* `session/event` and `agent/*` event is routed strictly to its owning session
* record, so two sessions streaming at once never interleave their

View File

@@ -1,6 +1,6 @@
# dsh-agent-loop
THE concrete agent plugin: `LoopAgent` and the loop driver. Implements the `Agent` interface and drives the session/turn/step lifecycle.
THE concrete agent plugin: `ReactLoopAgent` and the loop driver. Implements the `Agent` interface and drives the session/turn/step lifecycle.
This is the only package in the harness that contains concrete loop logic. Everything else is an abstract service or a plugin against extension seams — new behavior goes into plugins, not here.
@@ -8,7 +8,7 @@ This is the only package in the harness that contains concrete loop logic. Every
### Public API
- `ctx.agentLoop.create(id: string, options?: AgentOptions): LoopAgent` — config-driven create: an agent on a fresh per-run session id `${id}-session-<uuid>` (no cwd). Used for `cordis.yml`-configured agents. The per-run uuid avoids colliding with the on-disk log a prior run materialized once a durable persistence backend is loaded; each run is a new session (a deliberate demo simplification — a real resume-or-create policy is a TODO). Disposed with the calling fiber.
- `ctx.agentLoop.create(id: string, options?: AgentOptions): ReactLoopAgent` — config-driven create: an agent on a fresh per-run session id `${id}-session-<uuid>` (no cwd). Used for `cordis.yml`-configured agents. The per-run uuid avoids colliding with the on-disk log a prior run materialized once a durable persistence backend is loaded; each run is a new session (a deliberate demo simplification — a real resume-or-create policy is a TODO). Disposed with the calling fiber.
`AgentLoop` also implements the `AgentFactory` seam and registers itself via `ctx.agents.setFactory(this)`, so plugins create/resume agents through `ctx.agents` (the interface):
@@ -35,7 +35,7 @@ Agents listed in config are auto-created at startup.
### Classes
- `LoopAgent` — the concrete `Agent` implementation. Owns the inbox (`Inbox`), the per-step `AbortController`, and the loop driver. Everything observable happens through session events and the `agent/*` event taxonomy.
- `ReactLoopAgent` — the concrete `Agent` implementation. Owns the inbox (`Inbox`), the per-step `AbortController`, and the loop driver. Everything observable happens through session events and the `agent/*` event taxonomy.
- `Inbox` — per-agent queued + steering FIFOs (`enqueue`, `steer`, `drainQueued`, `drainSteering`, `waitForQueued`).
### Loop lifecycle (`loop.ts`)

View File

@@ -1,5 +1,5 @@
/**
* The concrete Agent implementation: LoopAgent plus its inbox. Everything
* The concrete Agent implementation: ReactLoopAgent plus its inbox. Everything
* observable happens through session events and the agent/* event taxonomy —
* plugins never need this class.
*
@@ -21,7 +21,7 @@ import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts'
* the loop driver. Everything observable happens through session events and
* the agent/* event taxonomy — plugins never need this class.
*/
export class LoopAgent implements Agent {
export class ReactLoopAgent implements Agent {
readonly inbox = new Inbox()
private _status: AgentStatus = 'idle'

View File

@@ -1,5 +1,5 @@
/**
* THE concrete agent plugin: creates LoopAgents, runs their loops, and
* THE concrete agent plugin: creates ReactLoopAgents, runs their loops, and
* registers them in ctx.agents. Deliberately thin — every behavior beyond
* "call the model, run the tools, repeat" belongs to plugins on the event
* taxonomy.
@@ -18,9 +18,9 @@ import type { Session } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tools'
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
import { LoopAgent } from './agent.ts'
import { ReactLoopAgent } from './agent.ts'
export { LoopAgent } from './agent.ts'
export { ReactLoopAgent } from './agent.ts'
export { Inbox, type InboxMessage } from './inbox.ts'
export { runLoop } from './loop.ts'
@@ -48,7 +48,7 @@ export interface Config {
}
/**
* The agent-loop plugin (`ctx.agentLoop`): creates {@link LoopAgent}s, runs
* The agent-loop plugin (`ctx.agentLoop`): creates {@link ReactLoopAgent}s, runs
* their loops, and registers them in `ctx.agents`. Also implements the
* {@link AgentFactory} seam, so plugins create/resume agents through
* `ctx.agents` (the interface) without depending on this concrete package.
@@ -118,7 +118,7 @@ export class AgentLoop extends Service implements AgentFactory {
* fork seeds the new Session with the parent's event log, spawn starts
* fresh; the child is returned as a regular Agent handle.
*/
create(id: string, options: AgentOptions = {}): LoopAgent {
create(id: string, options: AgentOptions = {}): ReactLoopAgent {
this.assertAgentIdFree(id)
const session = this.ctx.sessions.create(`${id}-session-${randomUUID()}`, { meta: {} })
return this.start(AgentId(id), options, session)
@@ -219,9 +219,9 @@ export class AgentLoop extends Service implements AgentFactory {
}
}
/** Shared: construct a LoopAgent, register it, and start its loop (LIFO). */
private start(id: AgentId, options: AgentOptions, session: Session): LoopAgent {
const agent = new LoopAgent(this.ctx, id, options, session)
/** Shared: construct a ReactLoopAgent, register it, and start its loop (LIFO). */
private start(id: AgentId, options: AgentOptions, session: Session): ReactLoopAgent {
const agent = new ReactLoopAgent(this.ctx, id, options, session)
// Generator effect: stop and unregister are independent disposables
// (LIFO), so a throwing stop() cannot leak the registry entry.
this.ctx.effect(function* (this: AgentLoop) {

View File

@@ -13,7 +13,7 @@ import { BlockAssembler, HarnessError } from '@deepseek-ai/dsh-llm'
import type { Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session'
import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tools'
import type { LoopAgent } from './agent.ts'
import type { ReactLoopAgent } from './agent.ts'
/** An Error with an optional machine-readable code (e.g., from LlmError or a throwing plugin). */
type CodedError = Error & { code?: string }
@@ -98,7 +98,7 @@ function stepFinishReason(finish: FinishReason): TurnEndReason | undefined {
/**
* Ambient handles the loop driver receives from the agent. Decouples the
* pure function `runLoop` from the mutable LoopAgent fields, making the
* pure function `runLoop` from the mutable ReactLoopAgent fields, making the
* loop testable without a real agent.
*/
export interface LoopHandle {
@@ -141,7 +141,7 @@ export interface LoopHandle {
* idle (emit agent/status) unless more queued
* ```
*/
export async function runLoop(ctx: Context, agent: LoopAgent, handle: LoopHandle): Promise<void> {
export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle): Promise<void> {
const { session } = agent
while (!handle.isDisposed()) {
@@ -180,7 +180,7 @@ export async function runLoop(ctx: Context, agent: LoopAgent, handle: LoopHandle
}
}
async function runTurn(ctx: Context, agent: LoopAgent, handle: LoopHandle, turn: number): Promise<void> {
async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, turn: number): Promise<void> {
const { session } = agent
// --- Pre-turn. A throw here (the invariant guard) is owed NO turn/end —
@@ -446,7 +446,7 @@ async function runTurn(ctx: Context, agent: LoopAgent, handle: LoopHandle, turn:
}
/** Drain the steering queue into the session. Returns whether any arrived. */
function drainSteering(ctx: Context, agent: LoopAgent, turn: number): boolean {
function drainSteering(ctx: Context, agent: ReactLoopAgent, 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 })
@@ -458,7 +458,7 @@ function drainSteering(ctx: Context, agent: LoopAgent, turn: number): boolean {
/** One step: assemble request → stream model → record → execute tools. */
async function runStep(
ctx: Context,
agent: LoopAgent,
agent: ReactLoopAgent,
turn: number,
step: number,
signal: AbortSignal,

View File

@@ -6,7 +6,7 @@ import SessionStore from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentLoop, { LoopAgent } from '@deepseek-ai/dsh-agent-loop'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse } from './mock-adapter.ts'
async function harness(adapter: MockAdapter) {
@@ -21,7 +21,7 @@ async function harness(adapter: MockAdapter) {
return ctx
}
function waitForIdle(ctx: Context, agent: LoopAgent): Promise<void> {
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') {
@@ -32,7 +32,7 @@ function waitForIdle(ctx: Context, agent: LoopAgent): Promise<void> {
})
}
function waitForStatus(ctx: Context, agent: LoopAgent, expected: LoopAgent['status']): Promise<void> {
function waitForStatus(ctx: Context, agent: ReactLoopAgent, expected: ReactLoopAgent['status']): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === expected) {
@@ -43,15 +43,15 @@ function waitForStatus(ctx: Context, agent: LoopAgent, expected: LoopAgent['stat
})
}
function send(agent: LoopAgent, text: string) {
function send(agent: ReactLoopAgent, text: string) {
agent.send([{ type: 'text', text }])
}
describe('LoopAgent', () => {
describe('ReactLoopAgent', () => {
it('send() throws after disposal', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
let agent!: LoopAgent
let agent!: ReactLoopAgent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create('scoped', { model: 'mock' })
}, { inject: ['agentLoop'] }))
@@ -66,7 +66,7 @@ describe('LoopAgent', () => {
it('steer() throws after disposal', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
let agent!: LoopAgent
let agent!: ReactLoopAgent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create('scoped', { model: 'mock' })
}, { inject: ['agentLoop'] }))
@@ -81,7 +81,7 @@ describe('LoopAgent', () => {
it('inject() throws after disposal', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
let agent!: LoopAgent
let agent!: ReactLoopAgent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create('scoped', { model: 'mock' })
}, { inject: ['agentLoop'] }))
@@ -226,12 +226,12 @@ describe('LoopAgent', () => {
})
it('disposer is idempotent (double-stop)', async () => {
// Create a bare LoopAgent and call start() directly to get the disposer.
// Create a bare ReactLoopAgent and call start() directly to get the disposer.
// Then call it twice — the second call hits the early-return branch.
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create('test')
const agent = new LoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session)
const agent = new ReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session)
// Start the loop to get the disposer; the agent waits for messages
// (idle, never-resolving cancel), so it will stay idle.
@@ -323,7 +323,7 @@ describe('LoopAgent', () => {
it('whenIdle() subscribed while running resolves via done when the agent is then disposed', async () => {
// Covers the waiter's disposed arm: whenIdle() queues an internal waiter
// while running (not the fast path), then the disposer settles it and chains
// `done` (loop exit), not an eager resolve. A bare LoopAgent + direct
// `done` (loop exit), not an eager resolve. A bare ReactLoopAgent + direct
// start() disposer keeps the emit synchronous.
const ctx = new Context()
await ctx.plugin(LlmService)
@@ -334,7 +334,7 @@ describe('LoopAgent', () => {
const adapter = new MockAdapter(['hang'])
ctx.llm.registerAdapter(['mock'], adapter)
const session = ctx.sessions.create('bare')
const agent = new LoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session)
const agent = new ReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session)
const dispose = agent.start()
agent.send([{ type: 'text', text: 'go' }])
await new Promise(r => setTimeout(r, 30))
@@ -355,7 +355,7 @@ describe('LoopAgent', () => {
// it. Regression for the round-3 whenIdle finding.
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
let agent!: LoopAgent
let agent!: ReactLoopAgent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create('scoped', { model: 'mock' })
}, { inject: ['agentLoop'] }))
@@ -376,7 +376,7 @@ describe('LoopAgent', () => {
// only after `done` — i.e. the loop has actually exited.
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
let agent!: LoopAgent
let agent!: ReactLoopAgent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create('scoped', { model: 'mock' })
}, { inject: ['agentLoop'] }))

View File

@@ -9,13 +9,13 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import AgentLoop, { LoopAgent } from '@deepseek-ai/dsh-agent-loop'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse } from './mock-adapter.ts'
const dirs: string[] = []
afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) })
function waitForIdle(ctx: Context, agent: LoopAgent): Promise<void> {
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') { dispose(); resolve() }
@@ -38,7 +38,7 @@ describe('config-driven session id', () => {
await ctx1.plugin(AgentLoop, { agents: [{ id: 'cfg', model: 'mock', systemPrompt: '' }] })
await ctx1.plugin(SessionPersistenceJsonl, { root })
ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg')]))
const a1 = ctx1.agents.get('cfg') as LoopAgent
const a1 = ctx1.agents.get('cfg') as ReactLoopAgent
expect(a1.session.id).toMatch(idPattern)
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
@@ -55,7 +55,7 @@ describe('config-driven session id', () => {
await ctx2.plugin(AgentLoop, { agents: [{ id: 'cfg', model: 'mock', systemPrompt: '' }] })
await ctx2.plugin(SessionPersistenceJsonl, { root })
ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg2')]))
const a2 = ctx2.agents.get('cfg') as LoopAgent
const a2 = ctx2.agents.get('cfg') as ReactLoopAgent
expect(a2.session.id).toMatch(idPattern)
expect(a2.session.id).not.toBe(a1.session.id)
a2.send([{ type: 'text', text: 'q2' }], { source: { kind: 'user' } })
@@ -78,7 +78,7 @@ describe('config-driven session id', () => {
await ctx1.plugin(AgentLoop, { agents: [] })
await ctx1.plugin(SessionPersistenceJsonl, { root })
ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('first')]))
const a1 = ctx1.agents.create({ agentId: 'main', sessionId: 'sticky-1' }) as LoopAgent
const a1 = ctx1.agents.create({ agentId: 'main', sessionId: 'sticky-1' }) as ReactLoopAgent
a1.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
await ctx1.fiber.dispose()
@@ -97,10 +97,10 @@ describe('config-driven session id', () => {
ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('second')]))
// The deferred resume runs on a microtask after the backend is available.
let resumed: LoopAgent | undefined
let resumed: ReactLoopAgent | undefined
for (let i = 0; i < 50 && !resumed; i++) {
await new Promise(r => setTimeout(r, 5))
resumed = ctx2.agents.get('main') as LoopAgent | undefined
resumed = ctx2.agents.get('main') as ReactLoopAgent | undefined
}
expect(resumed).toBeDefined()
// The live session id IS the resumed id (NOT a fresh ${id}-session-<uuid>),

View File

@@ -5,7 +5,7 @@ import SessionStore, { TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentLoop, { LoopAgent } from '@deepseek-ai/dsh-agent-loop'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
async function harness(adapter: MockAdapter) {
@@ -20,7 +20,7 @@ async function harness(adapter: MockAdapter) {
return ctx
}
function waitForIdle(ctx: Context, agent: LoopAgent): Promise<void> {
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') {
@@ -31,7 +31,7 @@ function waitForIdle(ctx: Context, agent: LoopAgent): Promise<void> {
})
}
function send(agent: LoopAgent, text: string) {
function send(agent: ReactLoopAgent, text: string) {
agent.send([{ type: 'text', text }])
}
@@ -281,7 +281,7 @@ describe('disposed vs aborted branching', () => {
it('handles dispose during model streaming producing reason "disposed"', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
let agent!: LoopAgent
let agent!: ReactLoopAgent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create('scoped', { model: 'mock' })
}, { inject: ['agentLoop'] }))

View File

@@ -5,7 +5,7 @@ import SessionStore, { TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentLoop, { LoopAgent } from '@deepseek-ai/dsh-agent-loop'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from './mock-adapter.ts'
async function harness(adapter: MockAdapter) {
@@ -25,7 +25,7 @@ async function harness(adapter: MockAdapter) {
* invoke this right after send(), when the loop hasn't woken yet (status is
* still 'idle' synchronously), so polling the current status would lie.
*/
function waitForIdle(ctx: Context, agent: LoopAgent): Promise<void> {
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') {
@@ -36,7 +36,7 @@ function waitForIdle(ctx: Context, agent: LoopAgent): Promise<void> {
})
}
function send(agent: LoopAgent, text: string) {
function send(agent: ReactLoopAgent, text: string) {
agent.send([{ type: 'text', text }])
}
@@ -570,7 +570,7 @@ describe('agent loop', () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
let agent!: LoopAgent
let agent!: ReactLoopAgent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create('scoped', { model: 'mock' })
}, { inject: ['agentLoop'] }))
@@ -601,7 +601,7 @@ describe('agent loop', () => {
})
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agents.get('config-agent')! as LoopAgent
const agent = ctx.agents.get('config-agent')! as ReactLoopAgent
expect(agent).toBeDefined()
expect(agent.id).toBe('config-agent')
expect(agent.options.model).toBe('mock')

View File

@@ -18,7 +18,7 @@ import SessionStore from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentLoop, { type LoopAgent } from '@deepseek-ai/dsh-agent-loop'
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import fc from 'fast-check'
/** A never-exhausting adapter: every model call returns the same short reply. */
@@ -47,7 +47,7 @@ async function harness() {
}
/** Resolve on the agent's next transition to idle (event-based, not polled). */
function nextIdle(ctx: Context, agent: LoopAgent): Promise<void> {
function nextIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') {
@@ -60,7 +60,7 @@ function nextIdle(ctx: Context, agent: LoopAgent): Promise<void> {
/** Record every status transition for the legal-machine assertion. Returns
* the seen list plus a disposer for the listener (per the registry convention). */
function recordStatus(ctx: Context, agent: LoopAgent): { seen: string[]; dispose: () => void } {
function recordStatus(ctx: Context, agent: ReactLoopAgent): { seen: string[]; dispose: () => void } {
const seen: string[] = []
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent) seen.push(status)
@@ -68,13 +68,13 @@ function recordStatus(ctx: Context, agent: LoopAgent): { seen: string[]; dispose
return { seen, dispose }
}
function userMessageTexts(agent: LoopAgent): string[] {
function userMessageTexts(agent: ReactLoopAgent): string[] {
return agent.session.events
.filter(e => e.type === 'user/message')
.map(e => (e.data as { content: { type: string; text?: string }[] }).content.map(b => b.text ?? '').join(''))
}
function turnNumbers(agent: LoopAgent): number[] {
function turnNumbers(agent: ReactLoopAgent): number[] {
return agent.session.events
.filter(e => e.type === 'turn/start')
.map(e => (e.data as { turn: number }).turn)

View File

@@ -10,7 +10,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import AgentLoop, { LoopAgent } from '@deepseek-ai/dsh-agent-loop'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse } from './mock-adapter.ts'
const dirs: string[] = []
@@ -31,7 +31,7 @@ async function persistentHarness(adapter: MockAdapter): Promise<{ ctx: Context;
return { ctx, root }
}
function waitForIdle(ctx: Context, agent: LoopAgent): Promise<void> {
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') { dispose(); resolve() }
@@ -73,7 +73,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
// Lifecycle 1: create a no-cwd session and run a turn.
const adapter1 = new MockAdapter([textResponse('a')])
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const a1 = ctx1.agents.create({ agentId: 'm', sessionId: 'nocwd-sess' }) as LoopAgent
const a1 = ctx1.agents.create({ agentId: 'm', sessionId: 'nocwd-sess' }) as ReactLoopAgent
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
await ctx1.fiber.dispose()
@@ -89,7 +89,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
await ctx2.plugin(AgentLoop, { agents: [] })
await ctx2.plugin(SessionPersistenceJsonl, { root })
ctx2.llm.registerAdapter(['mock'], adapter2)
const a2 = await ctx2.agents.resume({ agentId: 'm', resumeSessionId: 'nocwd-sess' }) as LoopAgent
const a2 = await ctx2.agents.resume({ agentId: 'm', resumeSessionId: 'nocwd-sess' }) as ReactLoopAgent
expect(a2.session.header.cwd).toBeUndefined()
await ctx2.fiber.dispose()
})
@@ -120,7 +120,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
await ctx2.plugin(AgentLoop, { agents: [] })
await ctx2.plugin(SessionPersistenceJsonl, { root })
ctx2.llm.registerAdapter(['mock'], adapter2)
const a2 = await ctx2.agents.resume({ agentId: 'm', resumeSessionId: 'forked-sess' }) as LoopAgent
const a2 = await ctx2.agents.resume({ agentId: 'm', resumeSessionId: 'forked-sess' }) as ReactLoopAgent
expect(a2.session.header.parentSession).toBe('parent-sess')
expect(a2.session.header.cwd).toBe('/w')
await ctx2.fiber.dispose()
@@ -133,7 +133,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
// disk, since a crash before the next turn would otherwise lose it.
const adapter1 = new MockAdapter([textResponse('answer')])
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const a1 = ctx1.agents.create({ agentId: 'm', sessionId: 'inject-sess', meta: { cwd: '/w' } }) as LoopAgent
const a1 = ctx1.agents.create({ agentId: 'm', sessionId: 'inject-sess', meta: { cwd: '/w' } }) as ReactLoopAgent
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } })
@@ -158,7 +158,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
// drop it on reload (the bug this guards).
const adapter1 = new MockAdapter([textResponse('answer')])
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const a1 = ctx1.agents.create({ agentId: 'm', sessionId: 'inject-sess', meta: { cwd: '/w' } }) as LoopAgent
const a1 = ctx1.agents.create({ agentId: 'm', sessionId: 'inject-sess', meta: { cwd: '/w' } }) as ReactLoopAgent
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } })
@@ -176,7 +176,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
await ctx2.plugin(AgentLoop, { agents: [] })
await ctx2.plugin(SessionPersistenceJsonl, { root })
ctx2.llm.registerAdapter(['mock'], adapter2)
const a2 = await ctx2.agents.resume({ agentId: 'm', resumeSessionId: 'inject-sess' }) as LoopAgent
const a2 = await ctx2.agents.resume({ agentId: 'm', resumeSessionId: 'inject-sess' }) as ReactLoopAgent
const flat = JSON.stringify(a2.session.deriveMessages())
expect(flat).toContain('background task 42 finished')
await ctx2.fiber.dispose()
@@ -186,7 +186,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
// Lifecycle 1: run one full turn, persisting it.
const adapter1 = new MockAdapter([textResponse('first answer')])
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const a1 = ctx1.agents.create({ agentId: 'main', sessionId: 'sess-resume', meta: { cwd: '/w' } }) as LoopAgent
const a1 = ctx1.agents.create({ agentId: 'main', sessionId: 'sess-resume', meta: { cwd: '/w' } }) as ReactLoopAgent
a1.send([{ type: 'text', text: 'first question' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
const events1 = [...a1.session.events]
@@ -206,7 +206,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
await ctx2.plugin(SessionPersistenceJsonl, { root })
ctx2.llm.registerAdapter(['mock'], adapter2)
const a2 = await ctx2.agents.resume({ agentId: 'main', resumeSessionId: 'sess-resume' }) as LoopAgent
const a2 = await ctx2.agents.resume({ agentId: 'main', resumeSessionId: 'sess-resume' }) as ReactLoopAgent
// The resumed session carries the prior history…
expect(a2.session.id).toBe('sess-resume')
expect(a2.session.events.length).toBe(events1.length)

View File

@@ -5,7 +5,7 @@ import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import AgentLoop, { LoopAgent } from '@deepseek-ai/dsh-agent-loop'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import * as Invariants from '@deepseek-ai/dsh-invariants'
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
@@ -26,7 +26,7 @@ async function harness(adapter: MockAdapter) {
return ctx
}
function waitForIdle(ctx: Context, agent: LoopAgent): Promise<void> {
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') {
@@ -37,7 +37,7 @@ function waitForIdle(ctx: Context, agent: LoopAgent): Promise<void> {
})
}
function send(agent: LoopAgent, text: string) {
function send(agent: ReactLoopAgent, text: string) {
agent.send([{ type: 'text', text }])
}
@@ -297,7 +297,7 @@ describe('MEDIUM: disposed status is part of the agent/status contract', () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
let agent!: LoopAgent
let agent!: ReactLoopAgent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create('scoped', { model: 'mock' })
}, { inject: ['agentLoop'] }))
@@ -320,7 +320,7 @@ describe('MEDIUM: disposed status is part of the agent/status contract', () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
let agent!: LoopAgent
let agent!: ReactLoopAgent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create('scoped', { model: 'mock' })
}, { inject: ['agentLoop'] }))
@@ -430,7 +430,7 @@ describe('MEDIUM: turn numbering continues across seeded (forked) sessions', ()
ctx2.llm.registerAdapter(['mock'], second)
const seeded = ctx2.sessions.create('forked', { seed: [...agent.session.events] })
const forked = new LoopAgent(ctx2, AgentId('forked-agent'), { model: 'mock' }, seeded)
const forked = new ReactLoopAgent(ctx2, AgentId('forked-agent'), { model: 'mock' }, seeded)
ctx2.effect(() => forked.start())
const turns: number[] = []
@@ -660,7 +660,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
}
/** Count turn/step boundary events for balance assertions. */
function boundaryCounts(agent: LoopAgent) {
function boundaryCounts(agent: ReactLoopAgent) {
const e = [...agent.session.events]
return {
turnStart: e.filter(x => x.type === 'turn/start').length,
@@ -757,7 +757,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
// balanced with reason disposed (no error event for a disposal).
const adapter = new MockAdapter(['hang'])
const ctx = await balancedHarness(adapter)
let agent!: LoopAgent
let agent!: ReactLoopAgent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create('a-dispose', { model: 'mock' })
}, { inject: ['agentLoop'] }))
@@ -788,7 +788,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
// throw. This is the only path that exercises that catch sub-branch.
const adapter = new MockAdapter(['hang'])
const ctx = await balancedHarness(adapter)
let agent!: LoopAgent
let agent!: ReactLoopAgent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create('a-dispose-emit-throw', { model: 'mock' })
}, { inject: ['agentLoop'] }))

View File

@@ -40,7 +40,7 @@ export type AgentStatus = 'idle' | 'running' | 'disposed'
/**
* The agent handle — the surface every plugin (UI, hooks, orchestrators)
* programs against. The concrete implementation lives in
* `@deepseek-ai/dsh-agent-loop` (class `LoopAgent`); nothing outside the loop
* `@deepseek-ai/dsh-agent-loop` (class `ReactLoopAgent`); nothing outside the loop
* package should depend on the implementation.
*/
export interface Agent {

View File

@@ -303,7 +303,7 @@ export function apply(ctx: Context): void {
)
} catch (error: unknown) {
// The ONE expected failure: the agent was disposed between task
// completion and this injection (LoopAgent.inject throws
// completion and this injection (ReactLoopAgent.inject throws
// `agent "<id>" is disposed`). That race is benign — drop the notice.
// Anything else is a real bug and must surface, not be swallowed.
if (error instanceof Error && error.message.includes('is disposed')) return

View File

@@ -6,7 +6,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentLoop, { LoopAgent } from '@deepseek-ai/dsh-agent-loop'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
import { MockAdapter, textResponse, toolCallResponse } from '../../agent-loop/tests/mock-adapter.ts'
@@ -30,7 +30,7 @@ async function harness(adapter: MockAdapter) {
return ctx
}
function waitForIdle(ctx: Context, agent: LoopAgent): Promise<void> {
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') {
@@ -41,7 +41,7 @@ function waitForIdle(ctx: Context, agent: LoopAgent): Promise<void> {
})
}
function events(agent: LoopAgent): SessionEvent[] {
function events(agent: ReactLoopAgent): SessionEvent[] {
return [...agent.session.events]
}