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.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.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.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 ### 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) 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` ## `@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 '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/*` ## `approval/*`

View File

@@ -11,15 +11,17 @@ The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary
## `ctx.agentLoop` — `AgentLoop` ## `ctx.agentLoop` — `AgentLoop`
Concrete ReactLoopAgent factory and driver service. Concrete agent factory and driver service.
```ts cordis-catalog ```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 createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle>
async resume(ownerCtx: Context, options: ResumeAgentOptions): 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` ## `ctx.agents` — `AgentRegistry`

View File

@@ -288,7 +288,7 @@ The fourteen event variants (`turn/start`, `turn/end`, `step/start`, `step/end`,
## The agent handle ## 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) 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 | | 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/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/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`) | - | | `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: 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); - **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. - 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 type { SessionEvent } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { RUN_CODE_NAME } from '@deepseek-ai/dsh-tools' 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 { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
@@ -73,7 +73,7 @@ async function workspaceCodeModeHarness(): Promise<Context> {
return harness return harness
} }
function waitForIdle(harness: Context, agent: ReactLoopAgent): Promise<void> { function waitForIdle(harness: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
const dispose = harness.on('agent/status', (subject, status) => { const dispose = harness.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') { 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', 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?', 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 events: SessionEvent[] = [...handle.agent.session.events]
const dispatch = events.find(event => event.type === 'tool/code-dispatch' && event.data.name === 'read') 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 { Context } from 'cordis'
import type { SessionEvent } from '@deepseek-ai/dsh-session' 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 { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
@@ -71,7 +72,7 @@ export async function codingHarness(workdir: string, options: CodingHarnessOptio
return ctx return ctx
} }
export function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> { export function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => { const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') { if (subject === agent && status === 'idle') {

View File

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

View File

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

View File

@@ -5,7 +5,8 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path' import { join } from 'node:path'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' 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 { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import TaskService from '@deepseek-ai/dsh-tasks' import TaskService from '@deepseek-ai/dsh-tasks'
import * as ToolTasks from '@deepseek-ai/dsh-tool-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 }) 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) => { return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => { const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') { 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] return [...agent.session.events]
} }
@@ -102,7 +103,7 @@ describe('bash tool through the agent loop', () => {
sessionId: SessionId('session-env-id'), sessionId: SessionId('session-env-id'),
agentOptions: { provider: 'mock', model: 'mock' }, agentOptions: { provider: 'mock', model: 'mock' },
}) })
const agent = handle.agent as ReactLoopAgent const agent = handle.agent
const location = ctx.sessionPersistence.locate(agent.session.header) const location = ctx.sessionPersistence.locate(agent.session.header)
expect(location?.kind).toBe('jsonl') 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 type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
import { defineTool } from '@deepseek-ai/dsh-tools' 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 { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import * as Invariants from '@deepseek-ai/dsh-invariants' import * as Invariants from '@deepseek-ai/dsh-invariants'
import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic' 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 } return { ctx, compact }
} }
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> { function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => { const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') { if (subject === agent && status === 'idle') {

View File

@@ -54,9 +54,9 @@ export interface TypeApiEntry {
export const SERVICE_API: readonly ServiceApiEntry[] = [ export const SERVICE_API: readonly ServiceApiEntry[] = [
{ {
key: 'agentLoop', key: 'agentLoop',
summary: 'Concrete ReactLoopAgent factory and driver service.', summary: 'Concrete agent factory and driver service.',
methods: [ 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 createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle>',
'async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle>', 'async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle>',
], ],

View File

@@ -1,7 +1,8 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { Context } from 'cordis' import { Context } from 'cordis'
import { SessionId } from '@deepseek-ai/dsh-session' 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 { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import * as ToolCordis from '../src/index.ts' import * as ToolCordis from '../src/index.ts'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.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 return ctx
} }
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> { function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => { const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') { 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` | | `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` | | `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/` | 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. `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 # 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. 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)`. 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): `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`. 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. 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.
`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.
### Loop lifecycle (`loop.ts`) ### 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 type { Scope } from '@deepseek-ai/dsh-scope'
import { agentEvents } from '@deepseek-ai/dsh-agent' import { agentEvents } from '@deepseek-ai/dsh-agent'
import type { import type {
Agent,
AgentFactory, AgentFactory,
AgentHandle, AgentHandle,
AgentOptions, AgentOptions,
@@ -33,8 +34,6 @@ import {
import type { PreparedReactLoopAgent } from './agent.ts' import type { PreparedReactLoopAgent } from './agent.ts'
import { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from './constants.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. */ /** Fiber states that cannot own or serve a new lifecycle. */
const INACTIVE_STATES: ReadonlySet<FiberState> = new Set([ const INACTIVE_STATES: ReadonlySet<FiberState> = new Set([
FiberState.UNLOADING, 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 { export class AgentLoop extends Service implements AgentFactory {
static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt'] 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. * @param meta - optional fresh-session workspace metadata.
* @returns the published running agent. * @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 loopCtx = this.runtime.ctx
const transaction = new AgentCreationTransaction(loopCtx, this.ctx, this.ownership, id) const transaction = new AgentCreationTransaction(loopCtx, this.ctx, this.ownership, id)
try { try {

View File

@@ -4,11 +4,15 @@ import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools' 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, { DEFAULT_MAX_PARALLEL_TOOL_CALLS, ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from '@deepseek-ai/dsh-agent-loop'
import { bindReactLoopAgentContext, prepareReactLoopAgent } from '../src/agent.ts' import { bindReactLoopAgentContext, prepareReactLoopAgent, type ReactLoopAgent } from '../src/agent.ts'
import { MockAdapter, textResponse } from './mock-adapter.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) { async function harness(adapter: MockAdapter) {
const ctx = new Context() const ctx = new Context()
await ctx.plugin(LlmService) await ctx.plugin(LlmService)
@@ -21,7 +25,7 @@ async function harness(adapter: MockAdapter) {
return ctx return ctx
} }
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> { function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => { const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') { 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) => { return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => { const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === expected) { 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 }]) agent.send([{ type: 'text', text }])
} }
describe('ReactLoopAgent', () => { describe('Agent', () => {
it('rejects access before context binding and a second driver for one session', async () => { it('rejects access before context binding and a second driver for one session', async () => {
const ctx = new Context() const ctx = new Context()
await ctx.plugin(SessionStore) await ctx.plugin(SessionStore)
@@ -74,7 +78,7 @@ describe('ReactLoopAgent', () => {
expect(agent.options).toBe(options) expect(agent.options).toBe(options)
expect(agent.id).toBe('owned-bindings') expect(agent.id).toBe('owned-bindings')
expect(agent.session.id).toBe(agent.id) 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() await ctx.fiber.dispose()
}) })
@@ -82,14 +86,14 @@ describe('ReactLoopAgent', () => {
it('send() throws after disposal', async () => { it('send() throws after disposal', async () => {
const adapter = new MockAdapter(['hang']) const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter) const ctx = await harness(adapter)
let agent!: ReactLoopAgent let agent!: Agent
const fiber = await ctx.plugin(Object.assign((inner: Context) => { const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' }) agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
}, { inject: ['agentLoop'] })) }, { inject: ['agentLoop'] }))
send(agent, 'go') send(agent, 'go')
await new Promise(r => setTimeout(r, 30)) await new Promise(r => setTimeout(r, 30))
await fiber.dispose() await fiber.dispose()
await agent.done await driverDone(agent)
expect(() => { agent.send([{ type: 'text', text: 'too late' }]) }).toThrow('disposed') expect(() => { agent.send([{ type: 'text', text: 'too late' }]) }).toThrow('disposed')
}) })
@@ -97,14 +101,14 @@ describe('ReactLoopAgent', () => {
it('steer() throws after disposal', async () => { it('steer() throws after disposal', async () => {
const adapter = new MockAdapter(['hang']) const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter) const ctx = await harness(adapter)
let agent!: ReactLoopAgent let agent!: Agent
const fiber = await ctx.plugin(Object.assign((inner: Context) => { const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' }) agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
}, { inject: ['agentLoop'] })) }, { inject: ['agentLoop'] }))
send(agent, 'go') send(agent, 'go')
await new Promise(r => setTimeout(r, 30)) await new Promise(r => setTimeout(r, 30))
await fiber.dispose() await fiber.dispose()
await agent.done await driverDone(agent)
expect(() => { agent.steer([{ type: 'text', text: 'too late' }]) }).toThrow('disposed') expect(() => { agent.steer([{ type: 'text', text: 'too late' }]) }).toThrow('disposed')
}) })
@@ -112,14 +116,14 @@ describe('ReactLoopAgent', () => {
it('inject() throws after disposal', async () => { it('inject() throws after disposal', async () => {
const adapter = new MockAdapter(['hang']) const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter) const ctx = await harness(adapter)
let agent!: ReactLoopAgent let agent!: Agent
const fiber = await ctx.plugin(Object.assign((inner: Context) => { const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' }) agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
}, { inject: ['agentLoop'] })) }, { inject: ['agentLoop'] }))
send(agent, 'go') send(agent, 'go')
await new Promise(r => setTimeout(r, 30)) await new Promise(r => setTimeout(r, 30))
await fiber.dispose() await fiber.dispose()
await agent.done await driverDone(agent)
expect(() => { agent.inject([{ type: 'text', text: 'too late' }]) }).toThrow('disposed') expect(() => { agent.inject([{ type: 'text', text: 'too late' }]) }).toThrow('disposed')
}) })
@@ -170,8 +174,10 @@ describe('ReactLoopAgent', () => {
let flushes = 0 let flushes = 0
ctx.on('session/flush', () => { flushes += 1 }) ctx.on('session/flush', () => { flushes += 1 })
// Invalid injected content throws after turn/start. `finally` must still append turn/end and // Non-serializable injected content makes Session.append throw AFTER
// flush the balanced in-memory turn so a crash cannot lose it before the next checkpoint. // 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(() => { expect(() => {
agent.inject([{ type: 'text', text: 'x', bad: 1n } as never], { source: { kind: 'plugin', plugin: 'p' } }) agent.inject([{ type: 'text', text: 'x', bad: 1n } as never], { source: { kind: 'plugin', plugin: 'p' } })
}).toThrow(/non-JSON-serializable/) }).toThrow(/non-JSON-serializable/)
@@ -253,7 +259,9 @@ describe('ReactLoopAgent', () => {
}) })
it('disposer is idempotent (double-stop)', async () => { 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() const ctx = new Context()
await ctx.plugin(SessionStore) await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('test')) const session = ctx.sessions.create(SessionId('test'))
@@ -262,13 +270,17 @@ describe('ReactLoopAgent', () => {
) )
const { agent } = prepared 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() prepared.markPublished()
const dispose = prepared.startDriver() const dispose = prepared.startDriver()
// First dispose
const firstDisposal = dispose() const firstDisposal = dispose()
expect(agent.status).toBe('disposed') expect(agent.status).toBe('disposed')
await firstDisposal await firstDisposal
// Second dispose — idempotent, no throw
await expect(dispose()).resolves.toBeUndefined() await expect(dispose()).resolves.toBeUndefined()
expect(agent.status).toBe('disposed') 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 () => { 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 // Covers the waiter's disposed arm: whenIdle() queues an internal waiter
// must chain the loop's `done` promise rather than resolve before exit. // 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() const ctx = new Context()
await ctx.plugin(LlmService) await ctx.plugin(LlmService)
await ctx.plugin(SessionStore) await ctx.plugin(SessionStore)
@@ -394,11 +408,13 @@ describe('ReactLoopAgent', () => {
}) })
it('whenIdle() subscribed while running survives a FIBER dispose (no hung promise)', async () => { 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 // The waiter is internal agent state, NOT an effect-scoped ctx.on listener:
// remove before the disposed transition. Fiber teardown must still settle it. // 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 adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter) const ctx = await harness(adapter)
let agent!: ReactLoopAgent let agent!: Agent
const fiber = await ctx.plugin(Object.assign((inner: Context) => { const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' }) agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
}, { inject: ['agentLoop'] })) }, { 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 () => { 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 // The disposer emits agent/status('disposed') BEFORE the driver loop
// resolves only after true loop exit. // 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 adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter) const ctx = await harness(adapter)
let agent!: ReactLoopAgent let agent!: Agent
const fiber = await ctx.plugin(Object.assign((inner: Context) => { const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' }) agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
}, { inject: ['agentLoop'] })) }, { inject: ['agentLoop'] }))
@@ -425,7 +443,7 @@ describe('ReactLoopAgent', () => {
await new Promise(r => setTimeout(r, 30)) await new Promise(r => setTimeout(r, 30))
let doneResolved = false 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 await fiber.dispose() // sets status disposed, aborts, drains the loop
expect(agent.status).toBe('disposed') 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 SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools' 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' 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) { async function harness(adapter: MockAdapter) {
const ctx = new Context() const ctx = new Context()
await ctx.plugin(LlmService) await ctx.plugin(LlmService)
@@ -30,12 +34,12 @@ async function harness(adapter: MockAdapter) {
return ctx return ctx
} }
function send(agent: ReactLoopAgent, text: string) { function send(agent: Agent, text: string) {
agent.send([{ type: 'text', text }]) agent.send([{ type: 'text', text }])
} }
/** Resolve on the agent's next idle transition (event-based, not status poll). */ /** 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) => { return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => { const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') { dispose(); resolve() } 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). */ /** 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 return agent.session.events
.filter(e => e.type === 'user/message') .filter(e => e.type === 'user/message')
.flatMap(e => e.type === 'user/message' ? e.data.content : []) .flatMap(e => e.type === 'user/message' ? e.data.content : [])
@@ -202,7 +206,7 @@ describe('Agent.cancel()', () => {
sessionId: SessionId('dispose-prefix-session'), sessionId: SessionId('dispose-prefix-session'),
agentOptions: { provider: 'mock', model: 'mock' }, agentOptions: { provider: 'mock', model: 'mock' },
}) })
const agent = handle.agent as ReactLoopAgent const agent = handle.agent
let disposalDone: Promise<void> | undefined let disposalDone: Promise<void> | undefined
let streamed = false let streamed = false
@@ -215,7 +219,7 @@ describe('Agent.cancel()', () => {
send(agent, 'go') send(agent, 'go')
await new Promise(resolve => setTimeout(resolve, 0)) await new Promise(resolve => setTimeout(resolve, 0))
await disposalDone await disposalDone
await agent.done await driverDone(agent)
// No step opened, no model call ran, and the turn closed disposed. // No step opened, no model call ran, and the turn closed disposed.
expect(streamed).toBe(false) expect(streamed).toBe(false)
@@ -326,7 +330,7 @@ describe('Agent.cancel()', () => {
sessionId: SessionId('dispose-step-start-session'), sessionId: SessionId('dispose-step-start-session'),
agentOptions: { provider: 'mock', model: 'mock' }, agentOptions: { provider: 'mock', model: 'mock' },
}) })
const agent = handle.agent as ReactLoopAgent const agent = handle.agent
let disposalDone: Promise<void> | undefined let disposalDone: Promise<void> | undefined
let streamed = false let streamed = false
@@ -337,7 +341,7 @@ describe('Agent.cancel()', () => {
send(agent, 'go') send(agent, 'go')
await disposalDone await disposalDone
await agent.done await driverDone(agent)
expect(streamed).toBe(false) expect(streamed).toBe(false)
expect(adapter.requests).toHaveLength(0) 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 SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools' 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 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' import { MockAdapter, textResponse } from './mock-adapter.ts'
const dirs: string[] = [] const dirs: string[] = []
afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) }) 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) => { return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => { const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') { dispose(); resolve() } 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 config = { agents: [{ id: 'main', sessionId: SessionId('stdio-exact-reload'), model: 'mock' }] }
const firstLoop = await ctx.plugin(AgentLoop, config) const firstLoop = await ctx.plugin(AgentLoop, config)
let first: ReactLoopAgent | undefined let first: Agent | undefined
for (let i = 0; i < 50 && first === undefined; i++) { for (let i = 0; i < 50 && first === undefined; i++) {
await new Promise(resolve => setTimeout(resolve, 5)) 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() expect(first).toBeDefined()
first!.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } }) first!.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } })
@@ -103,10 +103,10 @@ describe('config-driven session id', () => {
await firstLoop.dispose() await firstLoop.dispose()
const secondLoop = await ctx.plugin(AgentLoop, config) const secondLoop = await ctx.plugin(AgentLoop, config)
let second: ReactLoopAgent | undefined let second: Agent | undefined
for (let i = 0; i < 50 && second === undefined; i++) { for (let i = 0; i < 50 && second === undefined; i++) {
await new Promise(resolve => setTimeout(resolve, 5)) 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(second).toBeDefined()
expect(JSON.stringify(second!.session.deriveMessages())).toContain('remember me') 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 config = { agents: [{ id: 'main', sessionId, model: 'mock' }] }
const firstLoop = await ctx.plugin(AgentLoop, config) const firstLoop = await ctx.plugin(AgentLoop, config)
await expect.poll(() => ctx.agents.get(sessionId)).toBeDefined() 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>() const flushGate = Promise.withResolvers<undefined>()
let flushStarted = false let flushStarted = false
@@ -155,7 +155,7 @@ describe('config-driven session id', () => {
flushGate.resolve(undefined) flushGate.resolve(undefined)
await firstDisposal await firstDisposal
await expect.poll(() => ctx.agents.get(sessionId)).toBeDefined() 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(second).not.toBe(first)
expect(JSON.stringify(second.session.deriveMessages())).toContain('persist before replacement') expect(JSON.stringify(second.session.deriveMessages())).toContain('persist before replacement')
expect(failures).toEqual([]) expect(failures).toEqual([])
@@ -173,7 +173,7 @@ describe('config-driven session id', () => {
const config = { agents: [{ id: 'main', sessionId, model: 'mock' }] } const config = { agents: [{ id: 'main', sessionId, model: 'mock' }] }
const firstLoop = await ctx.plugin(AgentLoop, config) const firstLoop = await ctx.plugin(AgentLoop, config)
await expect.poll(() => ctx.agents.get(sessionId)).toBeDefined() 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>() const flushGate = Promise.withResolvers<undefined>()
ctx.on('session/flush', (session) => { 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(AgentLoop, { agents: [{ id: SessionId('cfg'), provider: 'mock', model: 'mock' }] })
await ctx1.plugin(SessionPersistenceJsonl, { root }) await ctx1.plugin(SessionPersistenceJsonl, { root })
ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg')])) 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.id).toBe(a1.session.id)
expect(a1.session.id).toMatch(idPattern) expect(a1.session.id).toMatch(idPattern)
expect(ctx1.agents.get(SessionId('cfg'))).toBeUndefined() 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(AgentLoop, { agents: [{ id: SessionId('cfg'), provider: 'mock', model: 'mock' }] })
await ctx2.plugin(SessionPersistenceJsonl, { root }) await ctx2.plugin(SessionPersistenceJsonl, { root })
ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg2')])) 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.id).toBe(a2.session.id)
expect(a2.session.id).toMatch(idPattern) expect(a2.session.id).toMatch(idPattern)
expect(a2.session.id).not.toBe(a1.session.id) 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(AgentLoop, { agents: [] })
await ctx1.plugin(SessionPersistenceJsonl, { root }) await ctx1.plugin(SessionPersistenceJsonl, { root })
ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('first')])) 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' } }) a1.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1) await waitForIdle(ctx1, a1)
await ctx1.fiber.dispose() await ctx1.fiber.dispose()
@@ -392,10 +392,10 @@ describe('config-driven session id', () => {
ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('second')])) ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('second')]))
// The deferred resume runs on a microtask after the backend is available. // 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++) { for (let i = 0; i < 50 && !resumed; i++) {
await new Promise(r => setTimeout(r, 5)) 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() expect(resumed).toBeDefined()
// The live session id IS the resumed id (NOT a fresh ${id}-session-<uuid>), // 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 SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool, type PostToolDecision } from '@deepseek-ai/dsh-tools' import ToolRegistry, { defineTool, type PostToolDecision } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type ContinuationDecision } from '@deepseek-ai/dsh-agent' import AgentRegistry, { type Agent, type ContinuationDecision } from '@deepseek-ai/dsh-agent'
import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS, ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from '@deepseek-ai/dsh-agent-loop'
import { prepareReactLoopAgent } from '../src/agent.ts' import { prepareReactLoopAgent } from '../src/agent.ts'
import * as Invariants from '@deepseek-ai/dsh-invariants' import * as Invariants from '@deepseek-ai/dsh-invariants'
import { maxTokensResponse, MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' 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. */ /** Regression tests for agent-loop boundary, identity, and lifecycle contracts. */
async function harness(adapter: MockAdapter) { async function harness(adapter: MockAdapter) {
@@ -24,7 +28,7 @@ async function harness(adapter: MockAdapter) {
return ctx return ctx
} }
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> { function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => { const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') { 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 }]) 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 adapter = new MockAdapter([toolCallResponse('c1', 'waiter', {})])
const ctx = await harness(adapter) const ctx = await harness(adapter)
const started = Promise.withResolvers<undefined>() const started = Promise.withResolvers<undefined>()
let agent!: ReactLoopAgent let agent!: Agent
const fiber = await ctx.plugin(Object.assign((inner: Context) => { const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(SessionId('a-dispose-injection'), { provider: 'mock', model: 'mock' }) agent = inner.agentLoop.create(SessionId('a-dispose-injection'), { provider: 'mock', model: 'mock' })
}, { inject: ['agentLoop'] })) }, { inject: ['agentLoop'] }))
@@ -601,7 +605,7 @@ describe('disposed status is part of the agent/status contract', () => {
const adapter = new MockAdapter(['hang']) const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter) const ctx = await harness(adapter)
let agent!: ReactLoopAgent let agent!: Agent
const fiber = await ctx.plugin(Object.assign((inner: Context) => { const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' }) agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
}, { inject: ['agentLoop'] })) }, { inject: ['agentLoop'] }))
@@ -614,7 +618,7 @@ describe('disposed status is part of the agent/status contract', () => {
send(agent, 'go') send(agent, 'go')
await new Promise(r => setTimeout(r, 30)) await new Promise(r => setTimeout(r, 30))
await fiber.dispose() await fiber.dispose()
await agent.done await driverDone(agent)
expect(statuses).toEqual(['running', 'disposed']) expect(statuses).toEqual(['running', 'disposed'])
expect(reasons).toEqual([{ kind: '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 adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter) const ctx = await harness(adapter)
let agent!: ReactLoopAgent let agent!: Agent
const fiber = await ctx.plugin(Object.assign((inner: Context) => { const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' }) agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
}, { inject: ['agentLoop'] })) }, { inject: ['agentLoop'] }))
@@ -636,7 +640,7 @@ describe('disposed status is part of the agent/status contract', () => {
send(agent, 'go') send(agent, 'go')
await new Promise(r => setTimeout(r, 30)) await new Promise(r => setTimeout(r, 30))
await fiber.dispose() await fiber.dispose()
await agent.done // must not hang await driverDone(agent) // must not hang
expect(agent.status).toBe('disposed') expect(agent.status).toBe('disposed')
expect(ctx.agents.get(SessionId('scoped'))).toBeUndefined() // unregistered despite the throw 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. */ /** Count turn/step boundary events for balance assertions. */
function boundaryCounts(agent: ReactLoopAgent) { function boundaryCounts(agent: Agent) {
const e = [...agent.session.events] const e = [...agent.session.events]
return { return {
turnStart: e.filter(x => x.type === 'turn/start').length, 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). // balanced with reason disposed (no error event for a disposal).
const adapter = new MockAdapter(['hang']) const adapter = new MockAdapter(['hang'])
const ctx = await balancedHarness(adapter) const ctx = await balancedHarness(adapter)
let agent!: ReactLoopAgent let agent!: Agent
const fiber = await ctx.plugin(Object.assign((inner: Context) => { const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(SessionId('a-dispose'), { provider: 'mock', model: 'mock' }) agent = inner.agentLoop.create(SessionId('a-dispose'), { provider: 'mock', model: 'mock' })
}, { inject: ['agentLoop'] })) }, { inject: ['agentLoop'] }))
@@ -1150,7 +1154,7 @@ describe('turn and step boundary recovery', () => {
send(agent, 'go') send(agent, 'go')
await new Promise(r => setTimeout(r, 30)) await new Promise(r => setTimeout(r, 30))
await fiber.dispose() // dispose during the hanging step await fiber.dispose() // dispose during the hanging step
await agent.done await driverDone(agent)
const e = [...agent.session.events] const e = [...agent.session.events]
const turnStarts = e.filter(x => x.type === 'turn/start').length 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. // Disposal remains authoritative when the listener also throws.
const adapter = new MockAdapter([textResponse('never reached')]) const adapter = new MockAdapter([textResponse('never reached')])
const ctx = await balancedHarness(adapter) const ctx = await balancedHarness(adapter)
let agent!: ReactLoopAgent let agent!: Agent
const fiber = await ctx.plugin(Object.assign((inner: Context) => { const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(SessionId('a-prestep-dispose-throw'), { provider: 'mock', model: 'mock' }) agent = inner.agentLoop.create(SessionId('a-prestep-dispose-throw'), { provider: 'mock', model: 'mock' })
}, { inject: ['agentLoop'] })) }, { inject: ['agentLoop'] }))
@@ -1182,7 +1186,7 @@ describe('turn and step boundary recovery', () => {
ctx.on('agent/error', (_a, _t, _s, error) => void errorEmits.push(error)) ctx.on('agent/error', (_a, _t, _s, error) => void errorEmits.push(error))
send(agent, 'go') send(agent, 'go')
await agent.done await driverDone(agent)
const e = [...agent.session.events] const e = [...agent.session.events]
// Balanced: one turn/start, one turn/end carrying disposed (NOT error). // 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() return next()
}) })
let agent!: ReactLoopAgent let agent!: Agent
const fiber = await ctx.plugin(Object.assign((inner: Context) => { const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(SessionId('a-dispose-assemble'), { provider: 'mock', model: 'mock' }) agent = inner.agentLoop.create(SessionId('a-dispose-assemble'), { provider: 'mock', model: 'mock' })
}, { inject: ['agentLoop'] })) }, { inject: ['agentLoop'] }))
@@ -1438,7 +1442,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
releaseAssemble() releaseAssemble()
await disposalDone await disposalDone
await agent.done await driverDone(agent)
unlisten() unlisten()
// Turn boundaries are durable rows; there is no `agent/*` mirror to assert. // 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() return next()
}) })
let agent!: ReactLoopAgent let agent!: Agent
const fiber = await ctx.plugin(Object.assign((inner: Context) => { const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(SessionId('a-cancel-assemble'), { provider: 'mock', model: 'mock' }) agent = inner.agentLoop.create(SessionId('a-cancel-assemble'), { provider: 'mock', model: 'mock' })
}, { inject: ['agentLoop'] })) }, { inject: ['agentLoop'] }))
@@ -1486,7 +1490,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
releaseAssemble() releaseAssemble()
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
await fiber.dispose() await fiber.dispose()
await agent.done await driverDone(agent)
unlisten() unlisten()
const e = [...agent.session.events] const e = [...agent.session.events]
@@ -1525,7 +1529,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
await blocker await blocker
}) })
let agent!: ReactLoopAgent let agent!: Agent
const fiber = await ctx.plugin(Object.assign((inner: Context) => { const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(SessionId('a-dispose-prestep'), { provider: 'mock', model: 'mock' }) agent = inner.agentLoop.create(SessionId('a-dispose-prestep'), { provider: 'mock', model: 'mock' })
}, { inject: ['agentLoop'] })) }, { inject: ['agentLoop'] }))
@@ -1540,7 +1544,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
const disposalDone = fiber.dispose() const disposalDone = fiber.dispose()
releasePreStep() releasePreStep()
await disposalDone await disposalDone
await agent.done await driverDone(agent)
// After the pre-step seam finishes, the post-seam cancel/dispose check // After the pre-step seam finishes, the post-seam cancel/dispose check
// catches disposal. The step was never opened, no LLM call was made. // 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 await blocker
}) })
let agent!: ReactLoopAgent let agent!: Agent
const fiber = await ctx.plugin(Object.assign((inner: Context) => { const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(SessionId('a-cancel-prestep'), { provider: 'mock', model: 'mock' }) agent = inner.agentLoop.create(SessionId('a-cancel-prestep'), { provider: 'mock', model: 'mock' })
}, { inject: ['agentLoop'] })) }, { inject: ['agentLoop'] }))
@@ -1591,7 +1595,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
releasePreStep() releasePreStep()
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
await fiber.dispose() await fiber.dispose()
await agent.done await driverDone(agent)
const e = [...agent.session.events] const e = [...agent.session.events]
expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1) expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1)
@@ -1626,7 +1630,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
return next() return next()
}) })
let agent!: ReactLoopAgent let agent!: Agent
const fiber = await ctx.plugin(Object.assign((inner: Context) => { const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(SessionId('a-dispose-no-leak'), { provider: 'mock', model: 'mock' }) agent = inner.agentLoop.create(SessionId('a-dispose-no-leak'), { provider: 'mock', model: 'mock' })
}, { inject: ['agentLoop'] })) }, { inject: ['agentLoop'] }))
@@ -1637,7 +1641,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
const disposalDone = fiber.dispose() const disposalDone = fiber.dispose()
releaseAssemble() releaseAssemble()
await disposalDone await disposalDone
await agent.done await driverDone(agent)
const e = [...agent.session.events] const e = [...agent.session.events]
expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1) 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 type { SessionEvent } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' 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' 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) { async function harness(adapter: MockAdapter) {
const ctx = new Context() const ctx = new Context()
await ctx.plugin(LlmService) await ctx.plugin(LlmService)
@@ -22,7 +26,7 @@ async function harness(adapter: MockAdapter) {
return ctx return ctx
} }
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> { function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => { const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') { 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 }]) agent.send([{ type: 'text', text }])
} }
@@ -213,7 +217,7 @@ describe('disposed vs aborted branching', () => {
it('handles dispose during model streaming producing reason "disposed"', async () => { it('handles dispose during model streaming producing reason "disposed"', async () => {
const adapter = new MockAdapter(['hang']) const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter) const ctx = await harness(adapter)
let agent!: ReactLoopAgent let agent!: Agent
const fiber = await ctx.plugin(Object.assign((inner: Context) => { const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' }) agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
}, { inject: ['agentLoop'] })) }, { inject: ['agentLoop'] }))
@@ -224,7 +228,7 @@ describe('disposed vs aborted branching', () => {
send(agent, 'go') send(agent, 'go')
await new Promise(r => setTimeout(r, 30)) await new Promise(r => setTimeout(r, 30))
await fiber.dispose() // dispose during hang await fiber.dispose() // dispose during hang
await agent.done await driverDone(agent)
// Disposal wins abort classification because the error path checks it first. // Disposal wins abort classification because the error path checks it first.
expect(reasons).toContainEqual({ kind: 'disposed' }) 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 SessionStore, { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools' 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' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
/** /**
@@ -30,7 +30,7 @@ async function harness(adapter: MockAdapter) {
return ctx return ctx
} }
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> { function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => { const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') { 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 }]) agent.send([{ type: 'text', text }])
} }
function events(agent: ReactLoopAgent): SessionEvent[] { function events(agent: Agent): SessionEvent[] {
return [...agent.session.events] 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 SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' 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' 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 = '') { async function harness(adapter: MockAdapter, persona = '') {
const ctx = new Context() const ctx = new Context()
await ctx.plugin(LlmService) 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 * invoke this right after send(), when the loop hasn't woken yet (status is
* still 'idle' synchronously), so polling the current status would lie. * 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) => { return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => { const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') { 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 }]) agent.send([{ type: 'text', text }])
} }
@@ -175,7 +179,7 @@ describe('agent loop', () => {
agentOptions: { provider: 'mock', model: 'mock' }, agentOptions: { provider: 'mock', model: 'mock' },
}) })
const agent = handle.agent as ReactLoopAgent const agent = handle.agent
send(agent, 'hi') send(agent, 'hi')
await waitForIdle(ctx, agent) await waitForIdle(ctx, agent)
@@ -998,7 +1002,7 @@ describe('agent loop', () => {
const adapter = new MockAdapter(['hang']) const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter) const ctx = await harness(adapter)
let agent!: ReactLoopAgent let agent!: Agent
const fiber = await ctx.plugin(Object.assign((inner: Context) => { const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' }) agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
}, { inject: ['agentLoop'] })) }, { inject: ['agentLoop'] }))
@@ -1009,7 +1013,7 @@ describe('agent loop', () => {
expect(agent.status).toBe('running') expect(agent.status).toBe('running')
await fiber.dispose() await fiber.dispose()
await agent.done await driverDone(agent)
expect(agent.status).toBe('disposed') expect(agent.status).toBe('disposed')
expect(ctx.agents.get(SessionId('scoped'))).toBeUndefined() expect(ctx.agents.get(SessionId('scoped'))).toBeUndefined()
@@ -1029,7 +1033,7 @@ describe('agent loop', () => {
}) })
ctx.llm.registerAdapter(['mock'], adapter) ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agents.list()[0]! as ReactLoopAgent const agent = ctx.agents.list()[0]!
expect(agent).toBeDefined() expect(agent).toBeDefined()
expect(agent.id).toBe(agent.session.id) expect(agent.id).toBe(agent.session.id)
expect(agent.id).toMatch(/^config-agent-session-/) 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' }], 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') 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 SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools' 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' import fc from 'fast-check'
/** A never-exhausting adapter: every model call returns the same short reply. */ /** 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). */ /** 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) => { return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => { const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') { 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 /** Record every status transition for the legal-machine assertion. Returns
* the seen list plus a disposer for the listener (per the registry convention). */ * 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 seen: string[] = []
const dispose = ctx.on('agent/status', (subject, status) => { const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent) seen.push(status) if (subject === agent) seen.push(status)
@@ -69,13 +69,13 @@ function recordStatus(ctx: Context, agent: ReactLoopAgent): { seen: string[]; di
return { seen, dispose } return { seen, dispose }
} }
function userMessageTexts(agent: ReactLoopAgent): string[] { function userMessageTexts(agent: Agent): string[] {
return agent.session.events return agent.session.events
.filter(e => e.type === 'user/message') .filter(e => e.type === 'user/message')
.map(e => (e.data as { content: { type: string; text?: string }[] }).content.map(b => b.text ?? '').join('')) .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 return agent.session.events
.filter(e => e.type === 'turn/start') .filter(e => e.type === 'turn/start')
.map(e => (e.data as { turn: number }).turn) .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 SessionStore, { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' 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' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
async function harness(adapter: MockAdapter, persona = 'stable base') { async function harness(adapter: MockAdapter, persona = 'stable base') {
@@ -29,7 +29,7 @@ async function harness(adapter: MockAdapter, persona = 'stable base') {
return ctx return ctx
} }
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> { function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => { const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') { 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 }]) agent.send([{ type: 'text', text }])
} }
@@ -226,7 +226,7 @@ describe('request stability across the loop', () => {
seed: [...agent.session.events], seed: [...agent.session.events],
agentOptions: { provider: 'mock', model: 'mock' }, agentOptions: { provider: 'mock', model: 'mock' },
}) })
const agent2 = handle.agent as ReactLoopAgent const agent2 = handle.agent
send(agent2, 'second') send(agent2, 'second')
await waitForIdle(ctx2, agent2) 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 type { SessionEvent } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools' 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 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' import { MockAdapter, textResponse } from './mock-adapter.ts'
const dirs: string[] = [] const dirs: string[] = []
@@ -51,7 +51,7 @@ async function persistSession(sessionId: SessionId): Promise<string> {
return root return root
} }
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> { function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => { const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') { dispose(); resolve() } 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. // Lifecycle 1: create a no-cwd session and run a turn.
const adapter1 = new MockAdapter([textResponse('a')]) const adapter1 = new MockAdapter([textResponse('a')])
const { ctx: ctx1, root } = await persistentHarness(adapter1) 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' } }) a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1) await waitForIdle(ctx1, a1)
await ctx1.fiber.dispose() 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(AgentLoop, { agents: [] })
await ctx2.plugin(SessionPersistenceJsonl, { root }) await ctx2.plugin(SessionPersistenceJsonl, { root })
ctx2.llm.registerAdapter(['mock'], adapter2) 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() expect(a2.session.header.cwd).toBeUndefined()
await ctx2.fiber.dispose() 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 { ctx: ctx1, root } = await persistentHarness(adapter1)
const sources1: string[] = [] const sources1: string[] = []
ctx1.on('agent/session-start', (_agent, source) => void sources1.push(source)) 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']) expect(sources1).toEqual(['startup'])
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1) 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(AgentLoop, { agents: [] })
await ctx2.plugin(SessionPersistenceJsonl, { root }) await ctx2.plugin(SessionPersistenceJsonl, { root })
ctx2.llm.registerAdapter(['mock'], adapter2) 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.parentSession).toBe('parent-sess')
expect(a2.session.header.cwd).toBe('/w') expect(a2.session.header.cwd).toBe('/w')
expect(a2.session.header.seedLength).toBe(seed.length) 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. // clean disposal follows, so disk presence proves its own checkpoint ran.
const adapter1 = new MockAdapter([textResponse('answer')]) const adapter1 = new MockAdapter([textResponse('answer')])
const { ctx: ctx1, root } = await persistentHarness(adapter1) 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' } }) a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1) await waitForIdle(ctx1, a1)
a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } }) 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. // survive persistence and resume.
const adapter1 = new MockAdapter([textResponse('answer')]) const adapter1 = new MockAdapter([textResponse('answer')])
const { ctx: ctx1, root } = await persistentHarness(adapter1) 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' } }) a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1) await waitForIdle(ctx1, a1)
a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } }) 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(AgentLoop, { agents: [] })
await ctx2.plugin(SessionPersistenceJsonl, { root }) await ctx2.plugin(SessionPersistenceJsonl, { root })
ctx2.llm.registerAdapter(['mock'], adapter2) 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()) const flat = JSON.stringify(a2.session.deriveMessages())
expect(flat).toContain('background task 42 finished') expect(flat).toContain('background task 42 finished')
await ctx2.fiber.dispose() 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. // Lifecycle 1: run one full turn, persisting it.
const adapter1 = new MockAdapter([textResponse('first answer')]) const adapter1 = new MockAdapter([textResponse('first answer')])
const { ctx: ctx1, root } = await persistentHarness(adapter1) 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' } }) a1.send([{ type: 'text', text: 'first question' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1) await waitForIdle(ctx1, a1)
const events1 = [...a1.session.events] const events1 = [...a1.session.events]
@@ -526,7 +526,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
await ctx2.plugin(SessionPersistenceJsonl, { root }) await ctx2.plugin(SessionPersistenceJsonl, { root })
ctx2.llm.registerAdapter(['mock'], adapter2) 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… // The resumed session carries the prior history…
expect(a2.session.id).toBe('sess-resume') expect(a2.session.id).toBe('sess-resume')
expect(a2.session.events.length).toBe(events1.length) 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 type { Agent } from '@deepseek-ai/dsh-agent'
import { scopeOf } from '@deepseek-ai/dsh-scope' 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 type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { MockAdapter, textResponse } from './mock-adapter.ts' 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 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) => { return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => { const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') { if (subject === agent && status === 'idle') {
@@ -736,7 +736,7 @@ describe('agent scope lifecycle', () => {
const ctx = await harness() const ctx = await harness()
let ownerCtx!: Context let ownerCtx!: Context
let creating!: ReturnType<typeof ctx.agents.create> let creating!: ReturnType<typeof ctx.agents.create>
let announced!: ReactLoopAgent let announced!: Agent
const statuses: string[] = [] const statuses: string[] = []
let scopeDisposed = false let scopeDisposed = false
let observerSawLive = false let observerSawLive = false
@@ -745,7 +745,7 @@ describe('agent scope lifecycle', () => {
}) })
ctx.on('agent/session-start', (agent) => { ctx.on('agent/session-start', (agent) => {
if (agent.id !== SessionId('session-start-dispose-s')) return if (agent.id !== SessionId('session-start-dispose-s')) return
announced = agent as ReactLoopAgent announced = agent
disposeCurrentLifecycle(ownerCtx) disposeCurrentLifecycle(ownerCtx)
}) })
ctx.on('agent/session-start', (agent) => { 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 SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import LlmService from '@deepseek-ai/dsh-llm' import LlmService from '@deepseek-ai/dsh-llm'
import ToolRegistry, { defineTool, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools' import ToolRegistry, { defineTool, type PostToolDecision, type PreToolDecision } 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' import { MockAdapter, textResponse } from './mock-adapter.ts'
async function harness(adapter: MockAdapter, maxParallelToolCalls?: number) { async function harness(adapter: MockAdapter, maxParallelToolCalls?: number) {
@@ -29,7 +29,7 @@ async function harness(adapter: MockAdapter, maxParallelToolCalls?: number) {
return ctx return ctx
} }
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> { function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => { const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') { dispose(); resolve() } 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] 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 SystemPrompt, { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
import type { Config as SystemPromptConfig } 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 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' import { MockAdapter, textResponse } from './mock-adapter.ts'
async function harness(adapter: MockAdapter, toolOrder?: SystemPromptConfig['toolOrder']) { async function harness(adapter: MockAdapter, toolOrder?: SystemPromptConfig['toolOrder']) {
@@ -30,7 +30,7 @@ async function harness(adapter: MockAdapter, toolOrder?: SystemPromptConfig['too
return ctx return ctx
} }
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> { function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => { const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') { 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 SessionStore, { SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' 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 * as Invariants from '@deepseek-ai/dsh-invariants'
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
@@ -23,7 +23,7 @@ async function harness(adapter: MockAdapter): Promise<Context> {
return ctx return ctx
} }
function send(agent: ReactLoopAgent, text = 'go'): Promise<void> { function send(agent: Agent, text = 'go'): Promise<void> {
agent.send([{ type: 'text', text }]) agent.send([{ type: 'text', text }])
return agent.whenIdle() 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`. */ /** Why a session lifecycle began; seeded creates are `startup`, while persisted loads are `resume`. */
export type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' 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 { export interface Agent {
/** The single identity shared with {@link session}. */ /** The single identity shared with {@link session}. */
readonly id: SessionId readonly id: SessionId

View File

@@ -3,7 +3,8 @@ import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm' import { CallId } from '@deepseek-ai/dsh-llm'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import { defineTool } from '@deepseek-ai/dsh-tools' 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 { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import * as RepeatToolGuard from '@deepseek-ai/dsh-repeat-tool-guard' import * as RepeatToolGuard from '@deepseek-ai/dsh-repeat-tool-guard'
import type { Config } 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 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() } }) }) 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. */ /** 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] return [...agent.session.events]
.filter((e): e is SessionEvent<'context/message'> => e.type === 'context/message') .filter((e): e is SessionEvent<'context/message'> => e.type === 'context/message')
.map(e => ({ .map(e => ({
@@ -249,14 +250,14 @@ describe('chain semantics', () => {
])) ]))
// Loop agents are torn down by disposing the scope that created them // Loop agents are torn down by disposing the scope that created them
// (the loop.spec pattern): a child plugin fiber owns `first`. // (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) => { const fiber = await ctx.plugin(Object.assign((inner: Context) => {
first = inner.agentLoop.create(SessionId('reused'), { provider: 'mock', model: 'mock' }) first = inner.agentLoop.create(SessionId('reused'), { provider: 'mock', model: 'mock' })
}, { inject: ['agentLoop'] })) }, { inject: ['agentLoop'] }))
first.send([{ type: 'text', text: 'go' }]) first.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, first) await waitForIdle(ctx, first)
await fiber.dispose() await fiber.dispose()
await first.done await first.whenIdle()
const second = ctx.agentLoop.create(SessionId('reused'), { provider: 'mock', model: 'mock' }) const second = ctx.agentLoop.create(SessionId('reused'), { provider: 'mock', model: 'mock' })
second.send([{ type: 'text', text: 'go' }]) 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 Loader from '@cordisjs/plugin-loader'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import { defineTool } from '@deepseek-ai/dsh-tools' 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 { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import { SubagentRunId } from '@deepseek-ai/dsh-subagent' import { SubagentRunId } from '@deepseek-ai/dsh-subagent'
@@ -52,7 +53,7 @@ async function harnessWithFiber(configDir: string, adapter: MockAdapter): Promis
return { ctx, hooks } return { ctx, hooks }
} }
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> { function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => { const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') { dispose(); resolve() } 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] return [...agent.session.events]
} }

View File

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

View File

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

View File

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

View File

@@ -4,14 +4,14 @@
`AgentLoop` — provided by `@deepseek-ai/dsh-agent-loop`. `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?) ### ctx.agentLoop.create(id, options?, meta?)
```ts website-api ```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. 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. **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) ### ctx.agentLoop.createAgent(ownerCtx, options)
@@ -37,7 +37,7 @@ Create an owned agent on a caller-supplied session id.
**Returns** the published handle. **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) ### ctx.agentLoop.resume(ownerCtx, options)
@@ -52,4 +52,4 @@ Resume an owned agent from the configured persistence service.
**Returns** the published handle. **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. - `sessionId` — exact shared agent/session identity that failed startup.
- `error` — persistence, setup, or publication failure. - `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/* ## approval/*