Merge pull request #287 from deepseek-harness/codex/simp-hide-concrete-agent-loop

refactor: hide the concrete agent loop
This commit is contained in:
Tianyi Cui
2026-07-19 10:36:23 +08:00
committed by GitHub
41 changed files with 246 additions and 203 deletions

View File

@@ -17,7 +17,7 @@ A harness is one [Cordis](cordis-primer.md) context. Packages contribute service
| `ctx.systemPrompt` | `dsh-system-prompt` | ordered prompt sections, tool schemas, and prompt variables |
| `ctx.tools` | `dsh-tools` | tool registry and [execution pipeline](tool-execution-pipeline.md) |
| `ctx.agents` | `dsh-agent` | live agent registry, public `Agent` handle, `agent/*` events |
| `ctx.agentLoop` | `dsh-agent-loop` | shipped `ReactLoopAgent` driver |
| `ctx.agentLoop` | `dsh-agent-loop` | concrete `Agent` driver |
### Capability Services

View File

@@ -101,7 +101,7 @@ export interface Config {
Depends on: [`AgentOptions`](../packages/core/agent/src/index.ts) · [`SessionId`](../packages/core/session/src/index.ts)
Source: [`packages/core/agent-loop/src/index.ts:370`](../packages/core/agent-loop/src/index.ts)
Source: [`packages/core/agent-loop/src/index.ts:369`](../packages/core/agent-loop/src/index.ts)
## `@deepseek-ai/dsh-agent-spine-demo`

View File

@@ -179,7 +179,7 @@ A declarative agent entry failed before it could publish a live agent. Consumers
'agent-loop/config-start-failed'(sessionId: SessionId, error: unknown): void
```
Source: [`packages/core/agent-loop/src/index.ts:363`](../../packages/core/agent-loop/src/index.ts)
Source: [`packages/core/agent-loop/src/index.ts:362`](../../packages/core/agent-loop/src/index.ts)
## `approval/*`

View File

@@ -11,15 +11,17 @@ The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary
## `ctx.agentLoop` — `AgentLoop`
Concrete ReactLoopAgent factory and driver service.
Concrete agent factory and driver service.
```ts cordis-catalog
create(id: SessionId, options: AgentOptions = {}, meta: Pick<SessionHeader, 'cwd'> = {}): ReactLoopAgent
create(id: SessionId, options: AgentOptions = {}, meta: Pick<SessionHeader, 'cwd'> = {}): Agent
async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle>
async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle>
```
Source: [`packages/core/agent-loop/src/index.ts:408`](../../packages/core/agent-loop/src/index.ts)
Types: [Agent](../core-data-structures/core.md)
Source: [`packages/core/agent-loop/src/index.ts:407`](../../packages/core/agent-loop/src/index.ts)
## `ctx.agents` — `AgentRegistry`

View File

@@ -288,7 +288,7 @@ The fourteen event variants (`turn/start`, `turn/end`, `step/start`, `step/end`,
## The agent handle
`Agent` is the surface every plugin (UI, hooks, orchestrators) programs against. The concrete implementation is `ReactLoopAgent` in dsh-agent-loop; nothing outside the loop depends on the implementation.
`Agent` is the surface every plugin (UI, hooks, orchestrators) programs against. The concrete implementation is package-internal to dsh-agent-loop; nothing outside the loop depends on it.
Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts)

View File

@@ -7,7 +7,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| Event | Mode | Declared in | Dispatchers | Listeners |
| --- | --- | --- | --- | --- |
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:363`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`stdio`](../packages/ui/stdio) |
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:362`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`stdio`](../packages/ui/stdio) |
| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:141`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio`](../packages/ui/stdio) |
| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:150`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`stdio`](../packages/ui/stdio) |
| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:285`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |

View File

@@ -10,7 +10,7 @@ The harness has a long-deferred seam for **subagents** — an agent delegating w
The distinctive requirement — the one that shapes the whole design — is that **multiple subagent implementations must coexist at runtime**. A parent may want a cheap in-process child for a scoped subtask AND an isolated out-of-process child (over ACP) in the same session. The transports we foresee:
- **in-process** — a child `ReactLoopAgent` on the same `Context` (the cheapest, and nearly free given the existing agent factory);
- **in-process** — a child concrete `Agent` on the same `Context` (the cheapest, and nearly free given the existing agent factory);
- **ACP** — act as an ACP *client* driving another agent process (which can be another instance of ourselves);
- later: **A2A**, the **Codex app-server**, and the **Claude Code Agent SDK** — each the same out-of-process "start a child, prompt it, stream updates, cancel" shape as the ACP backend.

View File

@@ -8,9 +8,9 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { RUN_CODE_NAME } from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
@@ -73,7 +73,7 @@ async function workspaceCodeModeHarness(): Promise<Context> {
return harness
}
function waitForIdle(harness: Context, agent: ReactLoopAgent): Promise<void> {
function waitForIdle(harness: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = harness.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') {
@@ -146,7 +146,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('Code Mode: real model writes a p
type: 'text',
text: 'Use one run_code program to call tools.read on pkg/deep/task.txt. After it finishes, answer: Code Mode workspace handshake?',
}])
await waitForIdle(ctx, handle.agent as ReactLoopAgent)
await waitForIdle(ctx, handle.agent)
const events: SessionEvent[] = [...handle.agent.session.events]
const dispatch = events.find(event => event.type === 'tool/code-dispatch' && event.data.name === 'read')

View File

@@ -1,6 +1,7 @@
import { Context } from 'cordis'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
@@ -71,7 +72,7 @@ export async function codingHarness(workdir: string, options: CodingHarnessOptio
return ctx
}
export function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
export function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') {

View File

@@ -3,7 +3,6 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import type { Context } from 'cordis'
import type { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { SessionId } from '@deepseek-ai/dsh-session'
import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.ts'
@@ -41,7 +40,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses
const first = (await ctx.agents.create({
sessionId: SESSION_ID,
agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' },
})).agent as ReactLoopAgent
})).agent
first.send([{ type: 'text', text: `Remember this code for later: ${SECRET}. Just acknowledge it.` }])
await waitForIdle(ctx, first)
await ctx.fiber.dispose()
@@ -54,7 +53,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses
const resumed = (await ctx.agents.resume({
resumeSessionId: SESSION_ID,
agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' },
})).agent as ReactLoopAgent
})).agent
expect(resumed.session.id).toBe(SESSION_ID)
// The prior user turn is in the rehydrated log before the model is asked.
expect(JSON.stringify(resumed.session.deriveMessages())).toContain(SECRET)

View File

@@ -1,5 +1,6 @@
import { Context } from 'cordis'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis'
@@ -28,7 +29,7 @@ export async function cordisHarness(): Promise<Context> {
return ctx
}
export function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
export function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') {

View File

@@ -5,7 +5,8 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import TaskService from '@deepseek-ai/dsh-tasks'
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
@@ -38,7 +39,7 @@ afterEach(() => {
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true })
})
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') {
@@ -49,7 +50,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
})
}
function events(agent: ReactLoopAgent): SessionEvent[] {
function events(agent: Agent): SessionEvent[] {
return [...agent.session.events]
}
@@ -102,7 +103,7 @@ describe('bash tool through the agent loop', () => {
sessionId: SessionId('session-env-id'),
agentOptions: { provider: 'mock', model: 'mock' },
})
const agent = handle.agent as ReactLoopAgent
const agent = handle.agent
const location = ctx.sessionPersistence.locate(agent.session.header)
expect(location?.kind).toBe('jsonl')

View File

@@ -4,7 +4,8 @@ import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-a
import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
import { defineTool } from '@deepseek-ai/dsh-tools'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import * as Invariants from '@deepseek-ai/dsh-invariants'
import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'
@@ -82,7 +83,7 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr
return { ctx, compact }
}
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') {

View File

@@ -54,9 +54,9 @@ export interface TypeApiEntry {
export const SERVICE_API: readonly ServiceApiEntry[] = [
{
key: 'agentLoop',
summary: 'Concrete ReactLoopAgent factory and driver service.',
summary: 'Concrete agent factory and driver service.',
methods: [
'create(id: SessionId, options: AgentOptions = {}, meta: Pick<SessionHeader, \'cwd\'> = {}): ReactLoopAgent',
'create(id: SessionId, options: AgentOptions = {}, meta: Pick<SessionHeader, \'cwd\'> = {}): Agent',
'async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle>',
'async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle>',
],

View File

@@ -1,7 +1,8 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { SessionId } from '@deepseek-ai/dsh-session'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import * as ToolCordis from '../src/index.ts'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
@@ -24,7 +25,7 @@ async function harness(adapter: MockAdapter): Promise<Context> {
return ctx
}
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') {

View File

@@ -9,7 +9,7 @@ The session log, system-prompt assembly, tool registry, agent vocabulary, and co
| `system-prompt/` | Prompt-section + tool-schema assembly registry | `ctx.systemPrompt` |
| `tools/` | Scoped tool registry + pre-policy, guards, around-dispatch, post-policy, and final-result observation | `ctx.tools` |
| `agent/` | Agent interface, registry, `agent/*` event vocabulary | `ctx.agents` |
| `agent-loop/` | The concrete loop plugin: `ReactLoopAgent` + the loop driver | `ctx.agentLoop` |
| `agent-loop/` | Concrete plugin implementing the public `Agent` contract and owning the loop driver | `ctx.agentLoop` |
`scope/` is the one non-service package here: a dependency-free library (`createScope`/`scopeOf`/`scopeTarget`) the registries and the loop build per-agent scoping on — it sits below `session/` and `system-prompt/` in the module graph precisely so they can consume it without a cycle.

View File

@@ -1,6 +1,6 @@
# dsh-agent-loop
THE concrete agent plugin: `ReactLoopAgent` and the loop driver. Implements the `Agent` interface and drives the session/turn/step lifecycle.
THE concrete agent plugin and loop driver. Its package-internal implementation satisfies 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.
@@ -14,7 +14,7 @@ The caller fiber and the AgentLoop provider are co-owners. `AgentFactory.createA
Each agent and its session share one caller-chosen `SessionId`, assumed globally unique; accidental UUID collisions are outside the supported model. Two concurrent operations with the same id may both prepare, but the final `enter()` calls arbitrate publication and every loser rolls its private resources back. Each detach is bound to the exact entered object, so a stale disposer cannot remove a later same-id replacement. A detach requested during a synchronous creation notification waits for that dispatch to unwind, preserving created/disposed pairing. Teardown runs stop and drain (including outstanding idle-injection flushes) → detach agent → detach session → unwind scope; the id becomes reusable at detach even if private scope cleanup is still finishing. Ordinary non-vetoing `agent/*` notifications go through `agentEvents(ctx, agent)`, per-step assembly goes through `assembleContextFor(agent)`, and turn-end durability checkpoints go through `ctx.sessions.flush(session)`.
- `ctx.agentLoop.create(id: SessionId, options?: AgentOptions, meta?: { cwd?: string }): ReactLoopAgent` — synchronous no-setup create under the exact shared agent/session id, disposed with the calling fiber. Declarative config treats `agents[].id` as a stable label and normally mints `${label}-session-<uuid>` before calling this boundary. An app may instead supply a stable exact `sessionId`: first use creates it, while a remount with persistence already present resumes its materialized history. `resumeSessionId` requires and loads an existing persisted id and is mutually exclusive with `sessionId`. This keeps default fresh restarts collision-free without retaining a second live routing identity.
- `ctx.agentLoop.create(id: SessionId, options?: AgentOptions, meta?: { cwd?: string }): Agent` — synchronous no-setup create under the exact shared agent/session id, disposed with the calling fiber. Declarative config treats `agents[].id` as a stable label and normally mints `${label}-session-<uuid>` before calling this boundary. An app may instead supply a stable exact `sessionId`: first use creates it, while a remount with persistence already present resumes its materialized history. `resumeSessionId` requires and loads an existing persisted id and is mutually exclusive with `sessionId`. This keeps default fresh restarts collision-free without retaining a second live routing identity.
`AgentLoop` also implements the `AgentFactory` seam and registers itself via `ctx.agents.setFactory(this)`, so plugins create/resume agents through `ctx.agents` (the interface):
@@ -44,11 +44,9 @@ interface Config {
Configured agents start automatically. A model call requires both `provider` and `model`; `agent/request` may supply a missing pair before dispatch. `maxParallelToolCalls` bounds every agent's rolling pool for parallel-safe calls and defaults to `10`. `cwd` applies only to fresh sessions, while `resumeSessionId` retains persisted metadata. Configured agents use the deployment persona, and programmatic setup can shadow it per agent. This plugin supplies the per-agent `provider`, `model`, and `cwd` prompt variables; harness identity and deployment persona belong to `dsh-system-prompt`.
### Exported concrete class
### Internal concrete driver
- `ReactLoopAgent` — the concrete `Agent` implementation. Its inbox is a JavaScript native-private field, and one prepared session can be claimed by only one concrete driver. Everything observable happens through session events and the `agent/*` event taxonomy.
`Inbox`, `runLoop`, and the instance-bound publication/start controls are package-internal. The package root does not export them, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than constructing or starting the driver internals. `ReactLoopAgent.send()`, running `steer()`, and open-turn `inject()` materialize content plus resolved source once as detached, deeply frozen lossless JSON; malformed data throws before enqueue or append. An injection that arrives while the current step executes assistant tool calls stays in a FIFO until the batch settles; successful batches place it after the complete result batch, and interrupted batches drain it before the turn closes.
The concrete `Agent` class, its `Inbox`, `runLoop`, and instance-bound publication/start controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. The concrete `send()`, running `steer()`, and open-turn `inject()` materialize content plus resolved source once as detached, deeply frozen lossless JSON; malformed data throws before enqueue or append. An injection that arrives while the current step executes assistant tool calls stays in a FIFO until the batch settles; successful batches place it after the complete result batch, and interrupted batches drain it before the turn closes. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy.
### Loop lifecycle (`loop.ts`)

View File

@@ -12,6 +12,7 @@ import { createScope } from '@deepseek-ai/dsh-scope'
import type { Scope } from '@deepseek-ai/dsh-scope'
import { agentEvents } from '@deepseek-ai/dsh-agent'
import type {
Agent,
AgentFactory,
AgentHandle,
AgentOptions,
@@ -33,8 +34,6 @@ import {
import type { PreparedReactLoopAgent } from './agent.ts'
import { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from './constants.ts'
export { ReactLoopAgent } from './agent.ts'
/** Fiber states that cannot own or serve a new lifecycle. */
const INACTIVE_STATES: ReadonlySet<FiberState> = new Set([
FiberState.UNLOADING,
@@ -404,7 +403,7 @@ function validateConfiguredAgents(agents: Config['agents']): void {
}
}
/** Concrete ReactLoopAgent factory and driver service. */
/** Concrete agent factory and driver service. */
export class AgentLoop extends Service implements AgentFactory {
static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt']
@@ -540,7 +539,7 @@ export class AgentLoop extends Service implements AgentFactory {
* @param meta - optional fresh-session workspace metadata.
* @returns the published running agent.
*/
create(id: SessionId, options: AgentOptions = {}, meta: Pick<SessionHeader, 'cwd'> = {}): ReactLoopAgent {
create(id: SessionId, options: AgentOptions = {}, meta: Pick<SessionHeader, 'cwd'> = {}): Agent {
const loopCtx = this.runtime.ctx
const transaction = new AgentCreationTransaction(loopCtx, this.ctx, this.ownership, id)
try {

View File

@@ -4,11 +4,15 @@ import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } 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, { DEFAULT_MAX_PARALLEL_TOOL_CALLS, ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { bindReactLoopAgentContext, prepareReactLoopAgent } from '../src/agent.ts'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from '@deepseek-ai/dsh-agent-loop'
import { bindReactLoopAgentContext, prepareReactLoopAgent, type ReactLoopAgent } from '../src/agent.ts'
import { MockAdapter, textResponse } from './mock-adapter.ts'
function driverDone(agent: Agent): Promise<void> {
return (agent as Agent & { done: Promise<void> }).done
}
async function harness(adapter: MockAdapter) {
const ctx = new Context()
await ctx.plugin(LlmService)
@@ -21,7 +25,7 @@ async function harness(adapter: MockAdapter) {
return ctx
}
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') {
@@ -32,7 +36,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
})
}
function waitForStatus(ctx: Context, agent: ReactLoopAgent, expected: ReactLoopAgent['status']): Promise<void> {
function waitForStatus(ctx: Context, agent: Agent, expected: Agent['status']): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === expected) {
@@ -43,11 +47,11 @@ function waitForStatus(ctx: Context, agent: ReactLoopAgent, expected: ReactLoopA
})
}
function send(agent: ReactLoopAgent, text: string) {
function send(agent: Agent, text: string) {
agent.send([{ type: 'text', text }])
}
describe('ReactLoopAgent', () => {
describe('Agent', () => {
it('rejects access before context binding and a second driver for one session', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -74,7 +78,7 @@ describe('ReactLoopAgent', () => {
expect(agent.options).toBe(options)
expect(agent.id).toBe('owned-bindings')
expect(agent.session.id).toBe(agent.id)
expect(() => { bindReactLoopAgentContext(agent, new Context()) }).toThrow(/context is already bound/)
expect(() => { bindReactLoopAgentContext(agent as ReactLoopAgent, new Context()) }).toThrow(/context is already bound/)
await ctx.fiber.dispose()
})
@@ -82,14 +86,14 @@ describe('ReactLoopAgent', () => {
it('send() throws after disposal', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
let agent!: ReactLoopAgent
let agent!: Agent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
}, { inject: ['agentLoop'] }))
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
await fiber.dispose()
await agent.done
await driverDone(agent)
expect(() => { agent.send([{ type: 'text', text: 'too late' }]) }).toThrow('disposed')
})
@@ -97,14 +101,14 @@ describe('ReactLoopAgent', () => {
it('steer() throws after disposal', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
let agent!: ReactLoopAgent
let agent!: Agent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
}, { inject: ['agentLoop'] }))
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
await fiber.dispose()
await agent.done
await driverDone(agent)
expect(() => { agent.steer([{ type: 'text', text: 'too late' }]) }).toThrow('disposed')
})
@@ -112,14 +116,14 @@ describe('ReactLoopAgent', () => {
it('inject() throws after disposal', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
let agent!: ReactLoopAgent
let agent!: Agent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
}, { inject: ['agentLoop'] }))
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
await fiber.dispose()
await agent.done
await driverDone(agent)
expect(() => { agent.inject([{ type: 'text', text: 'too late' }]) }).toThrow('disposed')
})
@@ -170,8 +174,10 @@ describe('ReactLoopAgent', () => {
let flushes = 0
ctx.on('session/flush', () => { flushes += 1 })
// Invalid injected content throws after turn/start. `finally` must still append turn/end and
// flush the balanced in-memory turn so a crash cannot lose it before the next checkpoint.
// Non-serializable injected content makes Session.append throw AFTER
// turn/start was recorded. The turn/end must still be appended (finally),
// AND the durability checkpoint must still fire — the balanced turn is in
// memory and a crash before the next turn/dispose would otherwise lose it.
expect(() => {
agent.inject([{ type: 'text', text: 'x', bad: 1n } as never], { source: { kind: 'plugin', plugin: 'p' } })
}).toThrow(/non-JSON-serializable/)
@@ -253,7 +259,9 @@ describe('ReactLoopAgent', () => {
})
it('disposer is idempotent (double-stop)', async () => {
// The internal start seam exposes one idle driver's disposer for repeated invocation.
// Create a bare Agent and start it through the package-internal
// test seam. Then call its disposer twice — the second call hits the
// early-return branch.
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('test'))
@@ -262,13 +270,17 @@ describe('ReactLoopAgent', () => {
)
const { agent } = prepared
// Start the loop to get the disposer; the agent waits for messages
// (idle, never-resolving cancel), so it will stay idle.
prepared.markPublished()
const dispose = prepared.startDriver()
// First dispose
const firstDisposal = dispose()
expect(agent.status).toBe('disposed')
await firstDisposal
// Second dispose — idempotent, no throw
await expect(dispose()).resolves.toBeUndefined()
expect(agent.status).toBe('disposed')
})
@@ -365,8 +377,10 @@ describe('ReactLoopAgent', () => {
})
it('whenIdle() subscribed while running resolves via done when the agent is then disposed', async () => {
// Queue the internal waiter while running, then dispose the bare driver. Its disposed branch
// must chain the loop's `done` promise rather than resolve before exit.
// 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 Agent + direct
// internal driver disposer keeps the emit synchronous.
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
@@ -394,11 +408,13 @@ describe('ReactLoopAgent', () => {
})
it('whenIdle() subscribed while running survives a FIBER dispose (no hung promise)', async () => {
// The waiter is agent-owned state, not an effect-scoped listener that owner disposal would
// remove before the disposed transition. Fiber teardown must still settle it.
// The waiter is internal agent state, NOT an effect-scoped ctx.on listener:
// disposing the OWNING fiber runs the agent's listener disposers, which would
// have dropped a ctx.on-based waiter before the 'disposed' transition and
// hung the promise. With internal waiters, the fiber disposer still settles it.
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
let agent!: ReactLoopAgent
let agent!: Agent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
}, { inject: ['agentLoop'] }))
@@ -413,11 +429,13 @@ describe('ReactLoopAgent', () => {
})
it('whenIdle() on a disposed agent awaits the loop exit (done), not just the status flip', async () => {
// Disposed status is emitted before the driver unwinds. `whenIdle()` must chain `done` so it
// resolves only after true loop exit.
// The disposer emits agent/status('disposed') BEFORE the driver loop
// unwinds, so whenIdle() must chain `done` (true quiescence) on the
// disposed path. Dispose a running agent, then assert whenIdle() resolves
// only after `done` — i.e. the loop has actually exited.
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
let agent!: ReactLoopAgent
let agent!: Agent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
}, { inject: ['agentLoop'] }))
@@ -425,7 +443,7 @@ describe('ReactLoopAgent', () => {
await new Promise(r => setTimeout(r, 30))
let doneResolved = false
void agent.done.then(() => { doneResolved = true })
void driverDone(agent).then(() => { doneResolved = true })
await fiber.dispose() // sets status disposed, aborts, drains the loop
expect(agent.status).toBe('disposed')

View File

@@ -13,11 +13,15 @@ import LlmService, { type Message } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, TurnEndReason } 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 AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse } from './mock-adapter.ts'
function driverDone(agent: Agent): Promise<void> {
return (agent as Agent & { done: Promise<void> }).done
}
async function harness(adapter: MockAdapter) {
const ctx = new Context()
await ctx.plugin(LlmService)
@@ -30,12 +34,12 @@ async function harness(adapter: MockAdapter) {
return ctx
}
function send(agent: ReactLoopAgent, text: string) {
function send(agent: Agent, text: string) {
agent.send([{ type: 'text', text }])
}
/** Resolve on the agent's next idle transition (event-based, not status poll). */
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') { dispose(); resolve() }
@@ -44,7 +48,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
}
/** All user-message texts recorded in the log (to assert what actually ran). */
function userTexts(agent: ReactLoopAgent): string[] {
function userTexts(agent: Agent): string[] {
return agent.session.events
.filter(e => e.type === 'user/message')
.flatMap(e => e.type === 'user/message' ? e.data.content : [])
@@ -202,7 +206,7 @@ describe('Agent.cancel()', () => {
sessionId: SessionId('dispose-prefix-session'),
agentOptions: { provider: 'mock', model: 'mock' },
})
const agent = handle.agent as ReactLoopAgent
const agent = handle.agent
let disposalDone: Promise<void> | undefined
let streamed = false
@@ -215,7 +219,7 @@ describe('Agent.cancel()', () => {
send(agent, 'go')
await new Promise(resolve => setTimeout(resolve, 0))
await disposalDone
await agent.done
await driverDone(agent)
// No step opened, no model call ran, and the turn closed disposed.
expect(streamed).toBe(false)
@@ -326,7 +330,7 @@ describe('Agent.cancel()', () => {
sessionId: SessionId('dispose-step-start-session'),
agentOptions: { provider: 'mock', model: 'mock' },
})
const agent = handle.agent as ReactLoopAgent
const agent = handle.agent
let disposalDone: Promise<void> | undefined
let streamed = false
@@ -337,7 +341,7 @@ describe('Agent.cancel()', () => {
send(agent, 'go')
await disposalDone
await agent.done
await driverDone(agent)
expect(streamed).toBe(false)
expect(adapter.requests).toHaveLength(0)

View File

@@ -7,16 +7,16 @@ import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } 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 AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import AgentLoop 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: ReactLoopAgent): Promise<void> {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') { dispose(); resolve() }
@@ -92,10 +92,10 @@ describe('config-driven session id', () => {
const config = { agents: [{ id: 'main', sessionId: SessionId('stdio-exact-reload'), model: 'mock' }] }
const firstLoop = await ctx.plugin(AgentLoop, config)
let first: ReactLoopAgent | undefined
let first: Agent | undefined
for (let i = 0; i < 50 && first === undefined; i++) {
await new Promise(resolve => setTimeout(resolve, 5))
first = ctx.agents.get(SessionId('stdio-exact-reload')) as ReactLoopAgent | undefined
first = ctx.agents.get(SessionId('stdio-exact-reload'))
}
expect(first).toBeDefined()
first!.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } })
@@ -103,10 +103,10 @@ describe('config-driven session id', () => {
await firstLoop.dispose()
const secondLoop = await ctx.plugin(AgentLoop, config)
let second: ReactLoopAgent | undefined
let second: Agent | undefined
for (let i = 0; i < 50 && second === undefined; i++) {
await new Promise(resolve => setTimeout(resolve, 5))
second = ctx.agents.get(SessionId('stdio-exact-reload')) as ReactLoopAgent | undefined
second = ctx.agents.get(SessionId('stdio-exact-reload'))
}
expect(second).toBeDefined()
expect(JSON.stringify(second!.session.deriveMessages())).toContain('remember me')
@@ -129,7 +129,7 @@ describe('config-driven session id', () => {
const config = { agents: [{ id: 'main', sessionId, model: 'mock' }] }
const firstLoop = await ctx.plugin(AgentLoop, config)
await expect.poll(() => ctx.agents.get(sessionId)).toBeDefined()
const first = ctx.agents.get(sessionId) as ReactLoopAgent
const first = ctx.agents.get(sessionId) as Agent
const flushGate = Promise.withResolvers<undefined>()
let flushStarted = false
@@ -155,7 +155,7 @@ describe('config-driven session id', () => {
flushGate.resolve(undefined)
await firstDisposal
await expect.poll(() => ctx.agents.get(sessionId)).toBeDefined()
const second = ctx.agents.get(sessionId) as ReactLoopAgent
const second = ctx.agents.get(sessionId) as Agent
expect(second).not.toBe(first)
expect(JSON.stringify(second.session.deriveMessages())).toContain('persist before replacement')
expect(failures).toEqual([])
@@ -173,7 +173,7 @@ describe('config-driven session id', () => {
const config = { agents: [{ id: 'main', sessionId, model: 'mock' }] }
const firstLoop = await ctx.plugin(AgentLoop, config)
await expect.poll(() => ctx.agents.get(sessionId)).toBeDefined()
const first = ctx.agents.get(sessionId) as ReactLoopAgent
const first = ctx.agents.get(sessionId) as Agent
const flushGate = Promise.withResolvers<undefined>()
ctx.on('session/flush', (session) => {
@@ -331,7 +331,7 @@ describe('config-driven session id', () => {
await ctx1.plugin(AgentLoop, { agents: [{ id: SessionId('cfg'), provider: 'mock', model: 'mock' }] })
await ctx1.plugin(SessionPersistenceJsonl, { root })
ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg')]))
const a1 = ctx1.agents.list()[0] as ReactLoopAgent
const a1 = ctx1.agents.list()[0] as Agent
expect(a1.id).toBe(a1.session.id)
expect(a1.session.id).toMatch(idPattern)
expect(ctx1.agents.get(SessionId('cfg'))).toBeUndefined()
@@ -350,7 +350,7 @@ describe('config-driven session id', () => {
await ctx2.plugin(AgentLoop, { agents: [{ id: SessionId('cfg'), provider: 'mock', model: 'mock' }] })
await ctx2.plugin(SessionPersistenceJsonl, { root })
ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg2')]))
const a2 = ctx2.agents.list()[0] as ReactLoopAgent
const a2 = ctx2.agents.list()[0] as Agent
expect(a2.id).toBe(a2.session.id)
expect(a2.session.id).toMatch(idPattern)
expect(a2.session.id).not.toBe(a1.session.id)
@@ -374,7 +374,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 = (await ctx1.agents.create({ sessionId: SessionId('sticky-1') })).agent as ReactLoopAgent
const a1 = (await ctx1.agents.create({ sessionId: SessionId('sticky-1') })).agent
a1.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
await ctx1.fiber.dispose()
@@ -392,10 +392,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: ReactLoopAgent | undefined
let resumed: Agent | undefined
for (let i = 0; i < 50 && !resumed; i++) {
await new Promise(r => setTimeout(r, 5))
resumed = ctx2.agents.get(SessionId('sticky-1')) as ReactLoopAgent | undefined
resumed = ctx2.agents.get(SessionId('sticky-1'))
}
expect(resumed).toBeDefined()
// The live session id IS the resumed id (NOT a fresh ${id}-session-<uuid>),

View File

@@ -4,12 +4,16 @@ import LlmService, { CallId, ContentBlock, MessageSource, StreamChunk } from '@d
import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool, type PostToolDecision } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type ContinuationDecision } from '@deepseek-ai/dsh-agent'
import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS, ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import AgentRegistry, { type Agent, type ContinuationDecision } from '@deepseek-ai/dsh-agent'
import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from '@deepseek-ai/dsh-agent-loop'
import { prepareReactLoopAgent } from '../src/agent.ts'
import * as Invariants from '@deepseek-ai/dsh-invariants'
import { maxTokensResponse, MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
function driverDone(agent: Agent): Promise<void> {
return (agent as Agent & { done: Promise<void> }).done
}
/** Regression tests for agent-loop boundary, identity, and lifecycle contracts. */
async function harness(adapter: MockAdapter) {
@@ -24,7 +28,7 @@ async function harness(adapter: MockAdapter) {
return ctx
}
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') {
@@ -35,7 +39,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
})
}
function send(agent: ReactLoopAgent, text: string) {
function send(agent: Agent, text: string) {
agent.send([{ type: 'text', text }])
}
@@ -345,7 +349,7 @@ describe('abort during tool execution ends the turn', () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'waiter', {})])
const ctx = await harness(adapter)
const started = Promise.withResolvers<undefined>()
let agent!: ReactLoopAgent
let agent!: Agent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(SessionId('a-dispose-injection'), { provider: 'mock', model: 'mock' })
}, { inject: ['agentLoop'] }))
@@ -601,7 +605,7 @@ describe('disposed status is part of the agent/status contract', () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
let agent!: ReactLoopAgent
let agent!: Agent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
}, { inject: ['agentLoop'] }))
@@ -614,7 +618,7 @@ describe('disposed status is part of the agent/status contract', () => {
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
await fiber.dispose()
await agent.done
await driverDone(agent)
expect(statuses).toEqual(['running', 'disposed'])
expect(reasons).toEqual([{ kind: 'disposed' }])
@@ -624,7 +628,7 @@ describe('disposed status is part of the agent/status contract', () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
let agent!: ReactLoopAgent
let agent!: Agent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
}, { inject: ['agentLoop'] }))
@@ -636,7 +640,7 @@ describe('disposed status is part of the agent/status contract', () => {
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
await fiber.dispose()
await agent.done // must not hang
await driverDone(agent) // must not hang
expect(agent.status).toBe('disposed')
expect(ctx.agents.get(SessionId('scoped'))).toBeUndefined() // unregistered despite the throw
@@ -967,7 +971,7 @@ describe('turn and step boundary recovery', () => {
}
/** Count turn/step boundary events for balance assertions. */
function boundaryCounts(agent: ReactLoopAgent) {
function boundaryCounts(agent: Agent) {
const e = [...agent.session.events]
return {
turnStart: e.filter(x => x.type === 'turn/start').length,
@@ -1139,7 +1143,7 @@ describe('turn and step boundary recovery', () => {
// balanced with reason disposed (no error event for a disposal).
const adapter = new MockAdapter(['hang'])
const ctx = await balancedHarness(adapter)
let agent!: ReactLoopAgent
let agent!: Agent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(SessionId('a-dispose'), { provider: 'mock', model: 'mock' })
}, { inject: ['agentLoop'] }))
@@ -1150,7 +1154,7 @@ describe('turn and step boundary recovery', () => {
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
await fiber.dispose() // dispose during the hanging step
await agent.done
await driverDone(agent)
const e = [...agent.session.events]
const turnStarts = e.filter(x => x.type === 'turn/start').length
@@ -1166,7 +1170,7 @@ describe('turn and step boundary recovery', () => {
// Disposal remains authoritative when the listener also throws.
const adapter = new MockAdapter([textResponse('never reached')])
const ctx = await balancedHarness(adapter)
let agent!: ReactLoopAgent
let agent!: Agent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(SessionId('a-prestep-dispose-throw'), { provider: 'mock', model: 'mock' })
}, { inject: ['agentLoop'] }))
@@ -1182,7 +1186,7 @@ describe('turn and step boundary recovery', () => {
ctx.on('agent/error', (_a, _t, _s, error) => void errorEmits.push(error))
send(agent, 'go')
await agent.done
await driverDone(agent)
const e = [...agent.session.events]
// Balanced: one turn/start, one turn/end carrying disposed (NOT error).
@@ -1421,7 +1425,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
return next()
})
let agent!: ReactLoopAgent
let agent!: Agent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(SessionId('a-dispose-assemble'), { provider: 'mock', model: 'mock' })
}, { inject: ['agentLoop'] }))
@@ -1438,7 +1442,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
releaseAssemble()
await disposalDone
await agent.done
await driverDone(agent)
unlisten()
// Turn boundaries are durable rows; there is no `agent/*` mirror to assert.
@@ -1471,7 +1475,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
return next()
})
let agent!: ReactLoopAgent
let agent!: Agent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(SessionId('a-cancel-assemble'), { provider: 'mock', model: 'mock' })
}, { inject: ['agentLoop'] }))
@@ -1486,7 +1490,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
releaseAssemble()
await waitForIdle(ctx, agent)
await fiber.dispose()
await agent.done
await driverDone(agent)
unlisten()
const e = [...agent.session.events]
@@ -1525,7 +1529,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
await blocker
})
let agent!: ReactLoopAgent
let agent!: Agent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(SessionId('a-dispose-prestep'), { provider: 'mock', model: 'mock' })
}, { inject: ['agentLoop'] }))
@@ -1540,7 +1544,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
const disposalDone = fiber.dispose()
releasePreStep()
await disposalDone
await agent.done
await driverDone(agent)
// After the pre-step seam finishes, the post-seam cancel/dispose check
// catches disposal. The step was never opened, no LLM call was made.
@@ -1576,7 +1580,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
await blocker
})
let agent!: ReactLoopAgent
let agent!: Agent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(SessionId('a-cancel-prestep'), { provider: 'mock', model: 'mock' })
}, { inject: ['agentLoop'] }))
@@ -1591,7 +1595,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
releasePreStep()
await waitForIdle(ctx, agent)
await fiber.dispose()
await agent.done
await driverDone(agent)
const e = [...agent.session.events]
expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1)
@@ -1626,7 +1630,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
return next()
})
let agent!: ReactLoopAgent
let agent!: Agent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(SessionId('a-dispose-no-leak'), { provider: 'mock', model: 'mock' })
}, { inject: ['agentLoop'] }))
@@ -1637,7 +1641,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
const disposalDone = fiber.dispose()
releaseAssemble()
await disposalDone
await agent.done
await driverDone(agent)
const e = [...agent.session.events]
expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1)

View File

@@ -5,11 +5,15 @@ import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session
import type { SessionEvent } 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 AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
function driverDone(agent: Agent): Promise<void> {
return (agent as Agent & { done: Promise<void> }).done
}
async function harness(adapter: MockAdapter) {
const ctx = new Context()
await ctx.plugin(LlmService)
@@ -22,7 +26,7 @@ async function harness(adapter: MockAdapter) {
return ctx
}
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') {
@@ -33,7 +37,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
})
}
function send(agent: ReactLoopAgent, text: string) {
function send(agent: Agent, text: string) {
agent.send([{ type: 'text', text }])
}
@@ -213,7 +217,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!: ReactLoopAgent
let agent!: Agent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
}, { inject: ['agentLoop'] }))
@@ -224,7 +228,7 @@ describe('disposed vs aborted branching', () => {
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
await fiber.dispose() // dispose during hang
await agent.done
await driverDone(agent)
// Disposal wins abort classification because the error path checks it first.
expect(reasons).toContainEqual({ kind: 'disposed' })

View File

@@ -4,9 +4,9 @@ import LlmService, { CallId, type Message } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type ContinuationDecision, type PromptDecision, type SessionStartSource } from '@deepseek-ai/dsh-agent'
import AgentRegistry, { type Agent, type ContinuationDecision, type PromptDecision, type SessionStartSource } from '@deepseek-ai/dsh-agent'
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
/**
@@ -30,7 +30,7 @@ async function harness(adapter: MockAdapter) {
return ctx
}
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') {
@@ -41,11 +41,11 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
})
}
function send(agent: ReactLoopAgent, text: string) {
function send(agent: Agent, text: string) {
agent.send([{ type: 'text', text }])
}
function events(agent: ReactLoopAgent): SessionEvent[] {
function events(agent: Agent): SessionEvent[] {
return [...agent.session.events]
}

View File

@@ -4,11 +4,15 @@ import LlmService, { CallId, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, 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 AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from './mock-adapter.ts'
function driverDone(agent: Agent): Promise<void> {
return (agent as Agent & { done: Promise<void> }).done
}
async function harness(adapter: MockAdapter, persona = '') {
const ctx = new Context()
await ctx.plugin(LlmService)
@@ -26,7 +30,7 @@ async function harness(adapter: MockAdapter, persona = '') {
* 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: ReactLoopAgent): Promise<void> {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') {
@@ -37,7 +41,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
})
}
function send(agent: ReactLoopAgent, text: string) {
function send(agent: Agent, text: string) {
agent.send([{ type: 'text', text }])
}
@@ -175,7 +179,7 @@ describe('agent loop', () => {
agentOptions: { provider: 'mock', model: 'mock' },
})
const agent = handle.agent as ReactLoopAgent
const agent = handle.agent
send(agent, 'hi')
await waitForIdle(ctx, agent)
@@ -998,7 +1002,7 @@ describe('agent loop', () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
let agent!: ReactLoopAgent
let agent!: Agent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
}, { inject: ['agentLoop'] }))
@@ -1009,7 +1013,7 @@ describe('agent loop', () => {
expect(agent.status).toBe('running')
await fiber.dispose()
await agent.done
await driverDone(agent)
expect(agent.status).toBe('disposed')
expect(ctx.agents.get(SessionId('scoped'))).toBeUndefined()
@@ -1029,7 +1033,7 @@ describe('agent loop', () => {
})
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agents.list()[0]! as ReactLoopAgent
const agent = ctx.agents.list()[0]!
expect(agent).toBeDefined()
expect(agent.id).toBe(agent.session.id)
expect(agent.id).toMatch(/^config-agent-session-/)
@@ -1052,7 +1056,7 @@ describe('agent loop', () => {
agents: [{ id: SessionId('config-agent'), provider: 'mock', model: 'mock', cwd: '/work/project' }],
})
const agent = ctx.agents.list()[0]! as ReactLoopAgent
const agent = ctx.agents.list()[0]!
expect(agent.session.header.cwd).toBe('/work/project')
})

View File

@@ -17,9 +17,9 @@ import { LlmAdapter } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } 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 AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import fc from 'fast-check'
/** A never-exhausting adapter: every model call returns the same short reply. */
@@ -48,7 +48,7 @@ async function harness() {
}
/** Resolve on the agent's next transition to idle (event-based, not polled). */
function nextIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
function nextIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') {
@@ -61,7 +61,7 @@ function nextIdle(ctx: Context, agent: ReactLoopAgent): 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: ReactLoopAgent): { seen: string[]; dispose: () => void } {
function recordStatus(ctx: Context, agent: Agent): { seen: string[]; dispose: () => void } {
const seen: string[] = []
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent) seen.push(status)
@@ -69,13 +69,13 @@ function recordStatus(ctx: Context, agent: ReactLoopAgent): { seen: string[]; di
return { seen, dispose }
}
function userMessageTexts(agent: ReactLoopAgent): string[] {
function userMessageTexts(agent: Agent): 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: ReactLoopAgent): number[] {
function turnNumbers(agent: Agent): number[] {
return agent.session.events
.filter(e => e.type === 'turn/start')
.map(e => (e.data as { turn: number }).turn)

View File

@@ -12,9 +12,9 @@ import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId, foldRequestHeader } 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 AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
async function harness(adapter: MockAdapter, persona = 'stable base') {
@@ -29,7 +29,7 @@ async function harness(adapter: MockAdapter, persona = 'stable base') {
return ctx
}
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') {
@@ -40,7 +40,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
})
}
function send(agent: ReactLoopAgent, text: string) {
function send(agent: Agent, text: string) {
agent.send([{ type: 'text', text }])
}
@@ -226,7 +226,7 @@ describe('request stability across the loop', () => {
seed: [...agent.session.events],
agentOptions: { provider: 'mock', model: 'mock' },
})
const agent2 = handle.agent as ReactLoopAgent
const agent2 = handle.agent
send(agent2, 'second')
await waitForIdle(ctx2, agent2)

View File

@@ -8,10 +8,10 @@ import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
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 AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse } from './mock-adapter.ts'
const dirs: string[] = []
@@ -51,7 +51,7 @@ async function persistSession(sessionId: SessionId): Promise<string> {
return root
}
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') { dispose(); resolve() }
@@ -124,7 +124,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 = (await ctx1.agents.create({ sessionId: SessionId('nocwd-sess') })).agent as ReactLoopAgent
const a1 = (await ctx1.agents.create({ sessionId: SessionId('nocwd-sess') })).agent
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
await ctx1.fiber.dispose()
@@ -140,7 +140,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({ resumeSessionId: SessionId('nocwd-sess') })).agent as ReactLoopAgent
const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('nocwd-sess') })).agent
expect(a2.session.header.cwd).toBeUndefined()
await ctx2.fiber.dispose()
})
@@ -151,7 +151,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const sources1: string[] = []
ctx1.on('agent/session-start', (_agent, source) => void sources1.push(source))
const a1 = (await ctx1.agents.create({ sessionId: SessionId('start-sess') })).agent as ReactLoopAgent
const a1 = (await ctx1.agents.create({ sessionId: SessionId('start-sess') })).agent
expect(sources1).toEqual(['startup'])
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
@@ -443,7 +443,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({ resumeSessionId: SessionId('forked-sess') })).agent as ReactLoopAgent
const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('forked-sess') })).agent
expect(a2.session.header.parentSession).toBe('parent-sess')
expect(a2.session.header.cwd).toBe('/w')
expect(a2.session.header.seedLength).toBe(seed.length)
@@ -455,7 +455,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
// clean disposal follows, so disk presence proves its own checkpoint ran.
const adapter1 = new MockAdapter([textResponse('answer')])
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent as ReactLoopAgent
const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent
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' } })
@@ -478,7 +478,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
// survive persistence and resume.
const adapter1 = new MockAdapter([textResponse('answer')])
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent as ReactLoopAgent
const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent
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' } })
@@ -496,7 +496,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({ resumeSessionId: SessionId('inject-sess') })).agent as ReactLoopAgent
const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('inject-sess') })).agent
const flat = JSON.stringify(a2.session.deriveMessages())
expect(flat).toContain('background task 42 finished')
await ctx2.fiber.dispose()
@@ -506,7 +506,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 = (await ctx1.agents.create({ sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } })).agent as ReactLoopAgent
const a1 = (await ctx1.agents.create({ sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } })).agent
a1.send([{ type: 'text', text: 'first question' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
const events1 = [...a1.session.events]
@@ -526,7 +526,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({ resumeSessionId: SessionId('sess-resume') })).agent as ReactLoopAgent
const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('sess-resume') })).agent
// The resumed session carries the prior history…
expect(a2.session.id).toBe('sess-resume')
expect(a2.session.events.length).toBe(events1.length)

View File

@@ -8,7 +8,7 @@ import AgentRegistry, { agentEvents, assembleContextFor } from '@deepseek-ai/dsh
import type { Agent } from '@deepseek-ai/dsh-agent'
import { scopeOf } from '@deepseek-ai/dsh-scope'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { MockAdapter, textResponse } from './mock-adapter.ts'
@@ -28,7 +28,7 @@ async function harness(adapter: MockAdapter = new MockAdapter([textResponse('ok'
return (await harnessWithLoop(adapter)).ctx
}
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') {
@@ -736,7 +736,7 @@ describe('agent scope lifecycle', () => {
const ctx = await harness()
let ownerCtx!: Context
let creating!: ReturnType<typeof ctx.agents.create>
let announced!: ReactLoopAgent
let announced!: Agent
const statuses: string[] = []
let scopeDisposed = false
let observerSawLive = false
@@ -745,7 +745,7 @@ describe('agent scope lifecycle', () => {
})
ctx.on('agent/session-start', (agent) => {
if (agent.id !== SessionId('session-start-dispose-s')) return
announced = agent as ReactLoopAgent
announced = agent
disposeCurrentLifecycle(ownerCtx)
})
ctx.on('agent/session-start', (agent) => {

View File

@@ -10,8 +10,8 @@ import SessionStore, { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import LlmService from '@deepseek-ai/dsh-llm'
import ToolRegistry, { defineTool, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse } from './mock-adapter.ts'
async function harness(adapter: MockAdapter, maxParallelToolCalls?: number) {
@@ -29,7 +29,7 @@ async function harness(adapter: MockAdapter, maxParallelToolCalls?: number) {
return ctx
}
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') { dispose(); resolve() }
@@ -37,7 +37,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
})
}
function events(agent: ReactLoopAgent): SessionEvent[] {
function events(agent: Agent): SessionEvent[] {
return [...agent.session.events]
}

View File

@@ -13,9 +13,9 @@ import SessionStore, { SessionId, foldRequestHeader } from '@deepseek-ai/dsh-ses
import SystemPrompt, { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
import type { Config as SystemPromptConfig } from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse } from './mock-adapter.ts'
async function harness(adapter: MockAdapter, toolOrder?: SystemPromptConfig['toolOrder']) {
@@ -30,7 +30,7 @@ async function harness(adapter: MockAdapter, toolOrder?: SystemPromptConfig['too
return ctx
}
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') {

View File

@@ -4,9 +4,9 @@ import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type ContinuationStop } from '@deepseek-ai/dsh-agent'
import AgentRegistry, { type Agent, type ContinuationStop } from '@deepseek-ai/dsh-agent'
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import * as Invariants from '@deepseek-ai/dsh-invariants'
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
@@ -23,7 +23,7 @@ async function harness(adapter: MockAdapter): Promise<Context> {
return ctx
}
function send(agent: ReactLoopAgent, text = 'go'): Promise<void> {
function send(agent: Agent, text = 'go'): Promise<void> {
agent.send([{ type: 'text', text }])
return agent.whenIdle()
}

View File

@@ -80,7 +80,7 @@ export type ContinuationStop = Extract<ContinuationDecision, { action: 'stop' }>
/** Why a session lifecycle began; seeded creates are `startup`, while persisted loads are `resume`. */
export type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact'
/** Public agent handle; the concrete driver belongs to `@deepseek-ai/dsh-agent-loop`. */
/** Public agent handle; its concrete implementation is internal to `@deepseek-ai/dsh-agent-loop`. */
export interface Agent {
/** The single identity shared with {@link session}. */
readonly id: SessionId

View File

@@ -3,7 +3,8 @@ import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import { defineTool } from '@deepseek-ai/dsh-tools'
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import * as RepeatToolGuard from '@deepseek-ai/dsh-repeat-tool-guard'
import type { Config } from '@deepseek-ai/dsh-repeat-tool-guard'
@@ -28,12 +29,12 @@ async function harness(config: Config = {}): Promise<Context> {
return ctx
}
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { const d = ctx.on('agent/status', (s, st) => { if (s === agent && st === 'idle') { d(); resolve() } }) })
}
/** Every `context/message` in the agent's log, flattened to joined text + source for terse assertions. */
function reminders(agent: ReactLoopAgent): { text: string; source: unknown }[] {
function reminders(agent: Agent): { text: string; source: unknown }[] {
return [...agent.session.events]
.filter((e): e is SessionEvent<'context/message'> => e.type === 'context/message')
.map(e => ({
@@ -249,14 +250,14 @@ describe('chain semantics', () => {
]))
// Loop agents are torn down by disposing the scope that created them
// (the loop.spec pattern): a child plugin fiber owns `first`.
let first!: ReactLoopAgent
let first!: Agent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
first = inner.agentLoop.create(SessionId('reused'), { provider: 'mock', model: 'mock' })
}, { inject: ['agentLoop'] }))
first.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, first)
await fiber.dispose()
await first.done
await first.whenIdle()
const second = ctx.agentLoop.create(SessionId('reused'), { provider: 'mock', model: 'mock' })
second.send([{ type: 'text', text: 'go' }])

View File

@@ -6,7 +6,8 @@ import { Context, type Fiber } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import { defineTool } from '@deepseek-ai/dsh-tools'
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import { SubagentRunId } from '@deepseek-ai/dsh-subagent'
@@ -52,7 +53,7 @@ async function harnessWithFiber(configDir: string, adapter: MockAdapter): Promis
return { ctx, hooks }
}
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') { dispose(); resolve() }
@@ -60,7 +61,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
})
}
function events(agent: ReactLoopAgent): SessionEvent[] {
function events(agent: Agent): SessionEvent[] {
return [...agent.session.events]
}

View File

@@ -6,7 +6,8 @@ import { Context } from 'cordis'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import { defineTool } from '@deepseek-ai/dsh-tools'
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import { SubagentRunId } from '@deepseek-ai/dsh-subagent'
@@ -38,10 +39,10 @@ async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOp
ctx.llm.registerAdapter(['mock'], adapter)
return ctx
}
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { const d = ctx.on('agent/status', (s, st) => { if (s === agent && st === 'idle') { d(); resolve() } }) })
}
function events(agent: ReactLoopAgent): SessionEvent[] { return [...agent.session.events] }
function events(agent: Agent): SessionEvent[] { return [...agent.session.events] }
/** Poll until `predicate` holds or the deadline passes — robust to detached
* emit-listener hooks firing on a `.then` (a fixed sleep flakes under load). */
async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise<void> {
@@ -466,8 +467,8 @@ export function defineCoverageCases(group: CoverageGroup): void {
const { SessionId } = await import('@deepseek-ai/dsh-session')
const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: workspace }, agentOptions: { provider: 'mock', model: 'mock' } })
handle.agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, handle.agent as ReactLoopAgent)
expect(events(handle.agent as ReactLoopAgent).some(e => e.type === 'context/message'
await waitForIdle(ctx, handle.agent)
expect(events(handle.agent).some(e => e.type === 'context/message'
&& e.data.content.some(b => b.type === 'text' && b.text.includes(`dir=${workspace}`)))).toBe(true)
await handle.dispose()
})
@@ -664,7 +665,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
const { SessionId } = await import('@deepseek-ai/dsh-session')
const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { provider: 'mock', model: 'mock' } })
handle.agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, handle.agent as ReactLoopAgent)
await waitForIdle(ctx, handle.agent)
expect(existsSync(marker)).toBe(true) // the marker landed in the SESSION dir
const { readFileSync } = await import('node:fs')

View File

@@ -6,7 +6,8 @@ import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import { defineTool } from '@deepseek-ai/dsh-tools'
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex'
@@ -46,14 +47,14 @@ async function harness(dir: string, adapter: MockAdapter): Promise<Context> {
return ctx
}
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') { dispose(); resolve() }
})
})
}
function events(agent: ReactLoopAgent): SessionEvent[] { return [...agent.session.events] }
function events(agent: Agent): SessionEvent[] { return [...agent.session.events] }
/** Poll `predicate` until true or the deadline passes (detached hook effects can't be awaited directly). */
async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise<void> {

View File

@@ -6,7 +6,8 @@ import { Context } from 'cordis'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import { defineTool } from '@deepseek-ai/dsh-tools'
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex'
@@ -33,10 +34,10 @@ async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOp
ctx.llm.registerAdapter(['mock'], adapter)
return ctx
}
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { const d = ctx.on('agent/status', (s, st) => { if (s === agent && st === 'idle') { d(); resolve() } }) })
}
function events(agent: ReactLoopAgent): SessionEvent[] { return [...agent.session.events] }
function events(agent: Agent): SessionEvent[] { return [...agent.session.events] }
/** Poll until `predicate` holds or the deadline passes — robust to detached
* emit-listener hooks firing on a `.then` (a fixed sleep flakes under load). */
async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise<void> {
@@ -627,7 +628,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
const { SessionId } = await import('@deepseek-ai/dsh-session')
const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { provider: 'mock', model: 'mock' } })
handle.agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, handle.agent as ReactLoopAgent)
await waitForIdle(ctx, handle.agent)
expect(existsSync(marker)).toBe(true)
expect(readFileSync(marker, 'utf8').trim().endsWith(sessionDir.split('/').pop()!)).toBe(true)
await handle.dispose()

View File

@@ -1,7 +1,8 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
@@ -21,7 +22,7 @@ async function harness(adapter: MockAdapter): Promise<Context> {
return ctx
}
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') {

View File

@@ -4,14 +4,14 @@
`AgentLoop` — provided by `@deepseek-ai/dsh-agent-loop`.
Concrete ReactLoopAgent factory and driver service.
Concrete agent factory and driver service.
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L408)
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L407)
### ctx.agentLoop.create(id, options?, meta?)
```ts website-api
create(id: SessionId, options: AgentOptions = {}, meta: Pick<SessionHeader, 'cwd'> = {}): ReactLoopAgent
create(id: SessionId, options: AgentOptions = {}, meta: Pick<SessionHeader, 'cwd'> = {}): Agent
```
Create an agent and session under one caller-supplied identity, owned by the accessing fiber. Constructor-driven config calls mint a fresh combined id before entering this boundary.
@@ -22,7 +22,7 @@ Create an agent and session under one caller-supplied identity, owned by the acc
**Returns** the published running agent.
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L543)
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L542)
### ctx.agentLoop.createAgent(ownerCtx, options)
@@ -37,7 +37,7 @@ Create an owned agent on a caller-supplied session id.
**Returns** the published handle.
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L565)
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L564)
### ctx.agentLoop.resume(ownerCtx, options)
@@ -52,4 +52,4 @@ Resume an owned agent from the configured persistence service.
**Returns** the published handle.
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L597)
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L596)

View File

@@ -228,7 +228,7 @@ A declarative agent entry failed before it could publish a live agent. Consumers
- `sessionId` — exact shared agent/session identity that failed startup.
- `error` — persistence, setup, or publication failure.
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L363)
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L362)
## approval/*