From 225796c90dc5a6ea9b4afa2a9f4081f9d48606ae Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 02:32:35 +0800 Subject: [PATCH 01/18] refactor: hide the concrete agent loop --- docs/architecture.md | 2 +- docs/config-catalog.md | 2 +- docs/cordis-catalog/events.md | 26 ++++----- docs/cordis-catalog/services.md | 8 ++- docs/core-data-structures/core.md | 2 +- docs/event-producer-consumer.md | 26 ++++----- .../2026-06-21-subagent-capability-seam.md | 2 +- examples/coding-agent/tests/code-mode.e2e.ts | 6 +- examples/coding-agent/tests/harness.ts | 6 +- examples/coding-agent/tests/resume.e2e.ts | 5 +- examples/cordis-agent/tests/harness.ts | 6 +- .../bash/tool-bash/tests/integration.spec.ts | 8 +-- .../tests/compact-loop-repro.spec.ts | 6 +- .../cordis/tool-cordis/src/api-catalog.ts | 4 +- .../tool-cordis/tests/integration.spec.ts | 6 +- packages/core/README.md | 2 +- packages/core/agent-loop/README.md | 10 ++-- packages/core/agent-loop/src/index.ts | 7 +-- packages/core/agent-loop/tests/agent.spec.ts | 42 +++++++------- packages/core/agent-loop/tests/cancel.spec.ts | 22 +++++--- .../tests/config-session-id.spec.ts | 16 +++--- .../agent-loop/tests/coverage-edges.spec.ts | 16 ++++-- .../agent-loop/tests/interception.spec.ts | 10 ++-- packages/core/agent-loop/tests/loop.spec.ts | 22 +++++--- .../core/agent-loop/tests/properties.spec.ts | 12 ++-- .../tests/request-reconstruction.spec.ts | 10 ++-- packages/core/agent-loop/tests/resume.spec.ts | 24 ++++---- .../agent-loop/tests/review-fixes.spec.ts | 56 ++++++++++--------- .../agent-loop/tests/scope-lifecycle.spec.ts | 8 +-- .../core/agent-loop/tests/tool-order.spec.ts | 6 +- .../core/agent-loop/tests/turn-stop.spec.ts | 6 +- packages/core/agent/src/types.ts | 5 +- .../tests/repeat-tool-guard.spec.ts | 12 ++-- .../hooks/hooks-claude/tests/bridge.spec.ts | 8 +-- .../hooks/hooks-claude/tests/coverage.spec.ts | 14 ++--- .../hooks/hooks-codex/tests/bridge.spec.ts | 8 +-- .../hooks/hooks-codex/tests/coverage.spec.ts | 10 ++-- .../todo/tool-todo/tests/integration.spec.ts | 6 +- packages/ui/acp/README.md | 2 +- packages/ui/acp/src/index.ts | 2 +- 40 files changed, 234 insertions(+), 217 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index a1f0cae9a8..272191c9c6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -17,7 +17,7 @@ A harness is one [Cordis](cordis-primer.md) context. Packages contribute service | `ctx.systemPrompt` | `dsh-system-prompt` | ordered prompt sections, tool schemas, and prompt variables | | `ctx.tools` | `dsh-tools` | tool registry and [execution pipeline](tool-execution-pipeline.md) | | `ctx.agents` | `dsh-agent` | live agent registry, public `Agent` handle, `agent/*` events | -| `ctx.agentLoop` | `dsh-agent-loop` | shipped `ReactLoopAgent` driver | +| `ctx.agentLoop` | `dsh-agent-loop` | shipped concrete `Agent` driver | ### Capability Services diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 4ae020f0be..20d90d1e8b 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -129,7 +129,7 @@ export interface Config { Depends on: [`AgentOptions`](../packages/core/agent/src/index.ts) · [`SessionId`](../packages/core/session/src/index.ts) -Source: [`packages/core/agent-loop/src/index.ts:324`](../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:323`](../packages/core/agent-loop/src/index.ts) ## `@deepseek-ai/dsh-bash-local` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index a5e018079a..32d697f1eb 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -23,7 +23,7 @@ An agent's fully composed scoped world was published in the AgentRegistry. Its s Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:304`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:303`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -35,7 +35,7 @@ An agent was removed from the registry. The concrete AgentLoop lifecycle emits t Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:319`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:318`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -47,7 +47,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:593`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:592`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -61,7 +61,7 @@ Serial (awaited in registration order), not a waterfall: a listener mutates the Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:426`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:425`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -73,7 +73,7 @@ Waterfall: decide what happens to ONE drained queued message before it becomes a Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:444`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:443`](../../packages/core/agent/src/types.ts) ### `agent/queued` — emit @@ -85,7 +85,7 @@ A message entered the agent's inbox (queued or steering). Content and the resolv Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:348`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:347`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -97,7 +97,7 @@ Waterfall: shape the step's call configuration — model switching, sampling ove Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:473`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:472`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall @@ -113,7 +113,7 @@ The seed is a frozen empty list; a contributing listener returns a NEW array — Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:525`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:524`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -125,7 +125,7 @@ The agent's session lifecycle began, fired once before its first turn. `source` Types: [Agent](../core-data-structures/core.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:369`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:368`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -137,7 +137,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:333`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:332`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall @@ -149,7 +149,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:540`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:539`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -161,7 +161,7 @@ Waterfall: override the turn-continuation decision via a typed ContinuationDecis Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:558`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:557`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial @@ -173,7 +173,7 @@ Serial terminal-stop checkpoint after the ordinary `agent/turn-continuation` wat Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:576`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:575`](../../packages/core/agent/src/types.ts) ## `approval/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 4d162a55f5..89a8dc4edb 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -11,15 +11,17 @@ The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary ## `ctx.agentLoop` — `AgentLoop` -Concrete ReactLoopAgent factory and driver service. +Concrete agent factory and driver service. ```ts cordis-catalog -create(id: SessionId, options: AgentOptions = {}, meta: Pick = {}): ReactLoopAgent +create(id: SessionId, options: AgentOptions = {}, meta: Pick = {}): Agent async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise ``` -Source: [`packages/core/agent-loop/src/index.ts:337`](../../packages/core/agent-loop/src/index.ts) +Types: [Agent](../core-data-structures/core.md) + +Source: [`packages/core/agent-loop/src/index.ts:336`](../../packages/core/agent-loop/src/index.ts) ## `ctx.agents` — `AgentRegistry` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index c6b74ed3c0..897b04240b 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -248,7 +248,7 @@ The fourteen event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, ## The agent handle -`Agent` is the surface every plugin (UI, hooks, orchestrators) programs against. The concrete implementation is `ReactLoopAgent` in dsh-agent-loop; nothing outside the loop depends on the implementation. +`Agent` is the surface every plugin (UI, hooks, orchestrators) programs against. The concrete implementation is package-internal to dsh-agent-loop; nothing outside the loop depends on it. Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 80389608cc..ba0d9722b2 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,19 +7,19 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:304`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`stdio-agent`](../packages/ui/stdio-agent) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:319`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:593`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:426`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:444`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | -| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:348`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:473`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:525`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:369`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`invariants`](../packages/support/invariants) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:333`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`stdio-agent`](../packages/ui/stdio-agent) | -| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:540`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:558`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:576`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:303`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:318`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:592`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:425`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:443`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | +| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:347`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:472`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:524`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:368`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`invariants`](../packages/support/invariants) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:332`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:539`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:557`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:575`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:70`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:123`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:138`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md b/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md index 652eac5522..3c42e83691 100644 --- a/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md +++ b/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md @@ -10,7 +10,7 @@ The harness has a long-deferred seam for **subagents** — an agent delegating w The distinctive requirement — the one that shapes the whole design — is that **multiple subagent implementations must coexist at runtime**. A parent may want a cheap in-process child for a scoped subtask AND an isolated out-of-process child (over ACP) in the same session. The transports we foresee: -- **in-process** — a child `ReactLoopAgent` on the same `Context` (the cheapest, and nearly free given the existing agent factory); +- **in-process** — a child concrete `Agent` on the same `Context` (the cheapest, and nearly free given the existing agent factory); - **ACP** — act as an ACP *client* driving another agent process (which can be another instance of ourselves); - later: **A2A**, the **Codex app-server**, and the **Claude Code Agent SDK** — each the same out-of-process "start a child, prompt it, stream updates, cancel" shape as the ACP backend. diff --git a/examples/coding-agent/tests/code-mode.e2e.ts b/examples/coding-agent/tests/code-mode.e2e.ts index 22446f0dc3..176b2898d6 100644 --- a/examples/coding-agent/tests/code-mode.e2e.ts +++ b/examples/coding-agent/tests/code-mode.e2e.ts @@ -8,9 +8,9 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { RUN_CODE_NAME } from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' -import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' @@ -58,7 +58,7 @@ async function codeModeHarness(cwd: string): Promise { return harness } -function waitForIdle(harness: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(harness: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = harness.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { diff --git a/examples/coding-agent/tests/harness.ts b/examples/coding-agent/tests/harness.ts index dd0bc42a1b..6535e30ac3 100644 --- a/examples/coding-agent/tests/harness.ts +++ b/examples/coding-agent/tests/harness.ts @@ -4,8 +4,8 @@ import SessionStore from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' @@ -70,7 +70,7 @@ export async function codingHarness(workdir: string, options: CodingHarnessOptio return ctx } -export function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +export function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { diff --git a/examples/coding-agent/tests/resume.e2e.ts b/examples/coding-agent/tests/resume.e2e.ts index 48e135ad92..486e32608c 100644 --- a/examples/coding-agent/tests/resume.e2e.ts +++ b/examples/coding-agent/tests/resume.e2e.ts @@ -3,7 +3,6 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import type { Context } from 'cordis' -import type { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { SessionId } from '@deepseek-ai/dsh-session' import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.ts' @@ -41,7 +40,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses const first = (await ctx.agents.create({ sessionId: SESSION_ID, agentOptions: { model: 'deepseek-v4-flash' }, - })).agent as ReactLoopAgent + })).agent first.send([{ type: 'text', text: `Remember this code for later: ${SECRET}. Just acknowledge it.` }]) await waitForIdle(ctx, first) await ctx.fiber.dispose() @@ -54,7 +53,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses const resumed = (await ctx.agents.resume({ resumeSessionId: SESSION_ID, agentOptions: { model: 'deepseek-v4-flash' }, - })).agent as ReactLoopAgent + })).agent expect(resumed.session.id).toBe(SESSION_ID) // The prior user turn is in the rehydrated log before the model is asked. expect(JSON.stringify(resumed.session.deriveMessages())).toContain(SECRET) diff --git a/examples/cordis-agent/tests/harness.ts b/examples/cordis-agent/tests/harness.ts index 78e5b0bb93..c9ccd59767 100644 --- a/examples/cordis-agent/tests/harness.ts +++ b/examples/cordis-agent/tests/harness.ts @@ -3,8 +3,8 @@ import LlmService from '@deepseek-ai/dsh-llm' import SessionStore from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' @@ -34,7 +34,7 @@ export async function cordisHarness(): Promise { return ctx } -export function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +export function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { diff --git a/packages/bash/tool-bash/tests/integration.spec.ts b/packages/bash/tool-bash/tests/integration.spec.ts index 3ab3ae6628..b8275fedba 100644 --- a/packages/bash/tool-bash/tests/integration.spec.ts +++ b/packages/bash/tool-bash/tests/integration.spec.ts @@ -5,9 +5,9 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry 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 { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import { BashTaskId } from '@deepseek-ai/dsh-bash' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' @@ -32,7 +32,7 @@ async function harness(adapter: MockAdapter) { return ctx } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { @@ -43,7 +43,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { }) } -function events(agent: ReactLoopAgent): SessionEvent[] { +function events(agent: Agent): SessionEvent[] { return [...agent.session.events] } diff --git a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts index 52a2ecd134..c9d9e34b26 100644 --- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -7,9 +7,9 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import { isToolPairingBalanced } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as Invariants from '@deepseek-ai/dsh-invariants' import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic' import type { SurfaceEvent } from '@deepseek-ai/dsh-session' @@ -105,7 +105,7 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr return { ctx, compact } } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 5721bd97d9..23a8c8e1d0 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -54,9 +54,9 @@ export interface TypeApiEntry { export const SERVICE_API: readonly ServiceApiEntry[] = [ { key: 'agentLoop', - summary: 'Concrete ReactLoopAgent factory and driver service.', + summary: 'Concrete agent factory and driver service.', methods: [ - 'create(id: SessionId, options: AgentOptions = {}, meta: Pick = {}): ReactLoopAgent', + 'create(id: SessionId, options: AgentOptions = {}, meta: Pick = {}): Agent', 'async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise', 'async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise', ], diff --git a/packages/cordis/tool-cordis/tests/integration.spec.ts b/packages/cordis/tool-cordis/tests/integration.spec.ts index 0d4c6c5c18..25adece9bc 100644 --- a/packages/cordis/tool-cordis/tests/integration.spec.ts +++ b/packages/cordis/tool-cordis/tests/integration.spec.ts @@ -4,9 +4,9 @@ import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as ToolCordis from '../src/index.ts' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import { REVERSE_TOOL_CODE } from './helpers.ts' @@ -32,7 +32,7 @@ async function harness(adapter: MockAdapter): Promise { return ctx } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { diff --git a/packages/core/README.md b/packages/core/README.md index b132b04d49..d822627136 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -9,7 +9,7 @@ The session log, system-prompt assembly, tool registry, agent vocabulary, and co | `system-prompt/` | Prompt-section + tool-schema assembly registry | `ctx.systemPrompt` | | `tools/` | Scoped tool registry + pre-policy, guards, around-dispatch, post-policy, and final-result observation | `ctx.tools` | | `agent/` | Agent interface, registry, `agent/*` event vocabulary | `ctx.agents` | -| `agent-loop/` | The concrete loop plugin: `ReactLoopAgent` + the loop driver | `ctx.agentLoop` | +| `agent-loop/` | The concrete `Agent` plugin and loop driver | `ctx.agentLoop` | | `agent-core/` | Bundle plugin: the default executor-less/UI-less spine as code | (loads the spine) | `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. diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index f40577bb95..f5b25c0e25 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -1,6 +1,6 @@ # dsh-agent-loop -THE concrete agent plugin: `ReactLoopAgent` and the loop driver. Implements the `Agent` interface and drives the session/turn/step lifecycle. +THE concrete agent plugin and loop driver. Its package-internal implementation satisfies the `Agent` interface and drives the session/turn/step lifecycle. This is the only package in the harness that contains concrete loop logic. Everything else is an abstract service or a plugin against extension seams — new behavior goes into plugins, not here. @@ -14,7 +14,7 @@ The caller fiber and the AgentLoop provider are co-owners. `AgentFactory.createA Each agent and its session share one caller-chosen `SessionId`, assumed globally unique; accidental UUID collisions are outside the supported model. Two concurrent operations with the same id may both prepare, but the final `enter()` calls arbitrate publication and every loser rolls its private resources back. Each detach is bound to the exact entered object, so a stale disposer cannot remove a later same-id replacement. A detach requested during a synchronous creation notification waits for that dispatch to unwind, preserving created/disposed pairing. Teardown runs stop and drain (including outstanding idle-injection flushes) → detach agent → detach session → unwind scope; the id becomes reusable at detach even if private scope cleanup is still finishing. Ordinary non-vetoing `agent/*` notifications go through `agentEvents(ctx, agent)`, per-step assembly goes through `assembleContextFor(agent)`, and turn-end durability checkpoints go through `ctx.sessions.flush(session)`. -- `ctx.agentLoop.create(id: SessionId, options?: AgentOptions, meta?: { cwd?: string }): ReactLoopAgent` — synchronous no-setup create under the exact shared agent/session id, disposed with the calling fiber. Declarative config treats `agents[].id` as a stable label and mints `${label}-session-` before calling this boundary; `resumeSessionId` instead loads and registers the exact persisted id. This keeps 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 mints `${label}-session-` before calling this boundary; `resumeSessionId` instead loads and registers the exact persisted id. This keeps 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): @@ -42,11 +42,9 @@ interface Config { Agents listed in config are auto-created at startup. `cwd` applies only to fresh config-created sessions; `resumeSessionId` keeps the persisted session header. Config agents have no per-agent persona field: they use `dsh-system-prompt`'s deployment default, while programmatic factory callers can register an agent-scoped `deployment:persona` shadow in `setup`. The plugin registers the built-in `model`/`cwd` prompt variables on `ctx.systemPrompt`, resolved per step from `assembleContextFor(agent)` — the helper couples the typed agent with its matching scope selector. These are runtime facts of the agents THIS loop drives, unlike the `harness:identity` and default `deployment:persona` sections, which live on `dsh-system-prompt` so they survive a swapped loop plugin. -### Exported concrete class +### Internal concrete driver -- `ReactLoopAgent` — the concrete `Agent` implementation. Its inbox is a JavaScript native-private field, and one prepared session can be claimed by only one concrete driver. Everything observable happens through session events and the `agent/*` event taxonomy. - -`Inbox`, `runLoop`, and the instance-bound publication/start controls are package-internal. The package root does not export them, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than constructing or starting the driver internals. `ReactLoopAgent.send()` and running `steer()` materialize content plus resolved source once as detached, deeply frozen lossless JSON, then share that accepted record between `agent/queued` and the inbox; malformed data throws before either boundary. +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()` and running `steer()` materialize content plus resolved source once as detached, deeply frozen lossless JSON, then share that accepted record between `agent/queued` and the inbox; malformed data throws before either boundary. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy. ### Loop lifecycle (`loop.ts`) diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 33276c01f8..c9c97fb206 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -12,6 +12,7 @@ import { createScope } from '@deepseek-ai/dsh-scope' import type { Scope } from '@deepseek-ai/dsh-scope' import { agentEvents } from '@deepseek-ai/dsh-agent' import type { + Agent, AgentFactory, AgentHandle, AgentOptions, @@ -32,8 +33,6 @@ import { } from './agent.ts' import type { PreparedReactLoopAgent } from './agent.ts' -export { ReactLoopAgent } from './agent.ts' - /** Fiber states that cannot own or serve a new lifecycle. */ const INACTIVE_STATES: ReadonlySet = new Set([ FiberState.UNLOADING, @@ -333,7 +332,7 @@ export interface Config { })[] } -/** Concrete ReactLoopAgent factory and driver service. */ +/** Concrete agent factory and driver service. */ export class AgentLoop extends Service implements AgentFactory { static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt'] @@ -389,7 +388,7 @@ export class AgentLoop extends Service implements AgentFactory { * @param meta - optional fresh-session workspace metadata. * @returns the published running agent. */ - create(id: SessionId, options: AgentOptions = {}, meta: Pick = {}): ReactLoopAgent { + create(id: SessionId, options: AgentOptions = {}, meta: Pick = {}): Agent { const loopCtx = this.runtime.ctx const transaction = new AgentCreationTransaction(loopCtx, this.ctx, this.ownership, id) try { diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index 56d6489fbe..7969d18200 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -4,11 +4,15 @@ import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' -import { bindReactLoopAgentContext, prepareReactLoopAgent } from '../src/agent.ts' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { bindReactLoopAgentContext, prepareReactLoopAgent, type ReactLoopAgent } from '../src/agent.ts' import { MockAdapter, textResponse } from './mock-adapter.ts' +function driverDone(agent: Agent): Promise { + return (agent as Agent & { done: Promise }).done +} + async function harness(adapter: MockAdapter) { const ctx = new Context() await ctx.plugin(LlmService) @@ -21,7 +25,7 @@ async function harness(adapter: MockAdapter) { return ctx } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { @@ -32,7 +36,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { }) } -function waitForStatus(ctx: Context, agent: ReactLoopAgent, expected: ReactLoopAgent['status']): Promise { +function waitForStatus(ctx: Context, agent: Agent, expected: Agent['status']): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === expected) { @@ -43,11 +47,11 @@ function waitForStatus(ctx: Context, agent: ReactLoopAgent, expected: ReactLoopA }) } -function send(agent: ReactLoopAgent, text: string) { +function send(agent: Agent, text: string) { agent.send([{ type: 'text', text }]) } -describe('ReactLoopAgent', () => { +describe('Agent', () => { it('rejects access before context binding and a second driver for one session', async () => { const ctx = new Context() await ctx.plugin(SessionStore) @@ -70,7 +74,7 @@ describe('ReactLoopAgent', () => { expect(agent.options).toBe(options) expect(agent.id).toBe('owned-bindings') expect(agent.session.id).toBe(agent.id) - expect(() => { bindReactLoopAgentContext(agent, new Context()) }).toThrow(/context is already bound/) + expect(() => { bindReactLoopAgentContext(agent as ReactLoopAgent, new Context()) }).toThrow(/context is already bound/) await ctx.fiber.dispose() }) @@ -78,14 +82,14 @@ describe('ReactLoopAgent', () => { it('send() throws after disposal', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' }) }, { inject: ['agentLoop'] })) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) await fiber.dispose() - await agent.done + await driverDone(agent) expect(() => { agent.send([{ type: 'text', text: 'too late' }]) }).toThrow('disposed') }) @@ -93,14 +97,14 @@ describe('ReactLoopAgent', () => { it('steer() throws after disposal', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' }) }, { inject: ['agentLoop'] })) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) await fiber.dispose() - await agent.done + await driverDone(agent) expect(() => { agent.steer([{ type: 'text', text: 'too late' }]) }).toThrow('disposed') }) @@ -108,14 +112,14 @@ describe('ReactLoopAgent', () => { it('inject() throws after disposal', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' }) }, { inject: ['agentLoop'] })) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) await fiber.dispose() - await agent.done + await driverDone(agent) expect(() => { agent.inject([{ type: 'text', text: 'too late' }]) }).toThrow('disposed') }) @@ -251,7 +255,7 @@ describe('ReactLoopAgent', () => { }) it('disposer is idempotent (double-stop)', async () => { - // Create a bare ReactLoopAgent and start it through the package-internal + // 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() @@ -367,7 +371,7 @@ describe('ReactLoopAgent', () => { it('whenIdle() subscribed while running resolves via done when the agent is then disposed', async () => { // Covers the waiter's disposed arm: whenIdle() queues an internal waiter // while running (not the fast path), then the disposer settles it and chains - // `done` (loop exit), not an eager resolve. A bare ReactLoopAgent + direct + // `done` (loop exit), not an eager resolve. A bare Agent + direct // internal driver disposer keeps the emit synchronous. const ctx = new Context() await ctx.plugin(LlmService) @@ -401,7 +405,7 @@ describe('ReactLoopAgent', () => { // it. Regression for the round-3 whenIdle finding. const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' }) }, { inject: ['agentLoop'] })) @@ -422,7 +426,7 @@ describe('ReactLoopAgent', () => { // only after `done` — i.e. the loop has actually exited. const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' }) }, { inject: ['agentLoop'] })) @@ -430,7 +434,7 @@ describe('ReactLoopAgent', () => { await new Promise(r => setTimeout(r, 30)) let doneResolved = false - void agent.done.then(() => { doneResolved = true }) + void driverDone(agent).then(() => { doneResolved = true }) await fiber.dispose() // sets status disposed, aborts, drains the loop expect(agent.status).toBe('disposed') diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 82b1f6c58c..d88d58a340 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -16,11 +16,15 @@ import LlmService, { type Message } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse } from './mock-adapter.ts' +function driverDone(agent: Agent): Promise { + return (agent as Agent & { done: Promise }).done +} + async function harness(adapter: MockAdapter) { const ctx = new Context() await ctx.plugin(LlmService) @@ -33,12 +37,12 @@ async function harness(adapter: MockAdapter) { return ctx } -function send(agent: ReactLoopAgent, text: string) { +function send(agent: Agent, text: string) { agent.send([{ type: 'text', text }]) } /** Resolve on the agent's next idle transition (event-based, not status poll). */ -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { dispose(); resolve() } @@ -47,7 +51,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { } /** All user-message texts recorded in the log (to assert what actually ran). */ -function userTexts(agent: ReactLoopAgent): string[] { +function userTexts(agent: Agent): string[] { return agent.session.events .filter(e => e.type === 'user/message') .flatMap(e => e.type === 'user/message' ? e.data.content : []) @@ -207,7 +211,7 @@ describe('Agent.cancel()', () => { sessionId: SessionId('dispose-prefix-session'), agentOptions: { model: 'mock' }, }) - const agent = handle.agent as ReactLoopAgent + const agent = handle.agent let disposalDone: Promise | undefined let streamed = false @@ -220,7 +224,7 @@ describe('Agent.cancel()', () => { send(agent, 'go') await new Promise(resolve => setTimeout(resolve, 0)) await disposalDone - await agent.done + await driverDone(agent) // No step opened, no model call ran, and the turn closed disposed. expect(streamed).toBe(false) @@ -337,7 +341,7 @@ describe('Agent.cancel()', () => { sessionId: SessionId('dispose-step-start-session'), agentOptions: { model: 'mock' }, }) - const agent = handle.agent as ReactLoopAgent + const agent = handle.agent let disposalDone: Promise | undefined let streamed = false @@ -348,7 +352,7 @@ describe('Agent.cancel()', () => { send(agent, 'go') await disposalDone - await agent.done + await driverDone(agent) expect(streamed).toBe(false) expect(adapter.requests).toHaveLength(0) diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index 134ea68c32..428581bb58 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -7,16 +7,16 @@ import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse } from './mock-adapter.ts' const dirs: string[] = [] afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) }) -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { dispose(); resolve() } @@ -57,7 +57,7 @@ describe('config-driven session id', () => { await ctx1.plugin(AgentLoop, { agents: [{ id: 'cfg', model: 'mock' }] }) await ctx1.plugin(SessionPersistenceJsonl, { root }) ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg')])) - const a1 = ctx1.agents.list()[0] as ReactLoopAgent + const a1 = ctx1.agents.list()[0] as Agent expect(a1.id).toBe(a1.session.id) expect(a1.session.id).toMatch(idPattern) expect(ctx1.agents.get(SessionId('cfg'))).toBeUndefined() @@ -76,7 +76,7 @@ describe('config-driven session id', () => { await ctx2.plugin(AgentLoop, { agents: [{ id: 'cfg', model: 'mock' }] }) await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg2')])) - const a2 = ctx2.agents.list()[0] as ReactLoopAgent + const a2 = ctx2.agents.list()[0] as Agent expect(a2.id).toBe(a2.session.id) expect(a2.session.id).toMatch(idPattern) expect(a2.session.id).not.toBe(a1.session.id) @@ -100,7 +100,7 @@ describe('config-driven session id', () => { await ctx1.plugin(AgentLoop, { agents: [] }) await ctx1.plugin(SessionPersistenceJsonl, { root }) ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('first')])) - const a1 = (await ctx1.agents.create({ sessionId: SessionId('sticky-1') })).agent as ReactLoopAgent + const a1 = (await ctx1.agents.create({ sessionId: SessionId('sticky-1') })).agent a1.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) await ctx1.fiber.dispose() @@ -119,10 +119,10 @@ describe('config-driven session id', () => { ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('second')])) // The deferred resume runs on a microtask after the backend is available. - let resumed: ReactLoopAgent | undefined + let resumed: Agent | undefined for (let i = 0; i < 50 && !resumed; i++) { await new Promise(r => setTimeout(r, 5)) - resumed = ctx2.agents.get(SessionId('sticky-1')) as ReactLoopAgent | undefined + resumed = ctx2.agents.get(SessionId('sticky-1')) } expect(resumed).toBeDefined() // The live session id IS the resumed id (NOT a fresh ${id}-session-), diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index 4e82e398f1..708cff504b 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -5,11 +5,15 @@ import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' +function driverDone(agent: Agent): Promise { + return (agent as Agent & { done: Promise }).done +} + async function harness(adapter: MockAdapter) { const ctx = new Context() await ctx.plugin(LlmService) @@ -22,7 +26,7 @@ async function harness(adapter: MockAdapter) { return ctx } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { @@ -33,7 +37,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { }) } -function send(agent: ReactLoopAgent, text: string) { +function send(agent: Agent, text: string) { agent.send([{ type: 'text', text }]) } @@ -213,7 +217,7 @@ describe('disposed vs aborted branching', () => { it('handles dispose during model streaming producing reason "disposed"', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' }) }, { inject: ['agentLoop'] })) @@ -224,7 +228,7 @@ describe('disposed vs aborted branching', () => { send(agent, 'go') await new Promise(r => setTimeout(r, 30)) await fiber.dispose() // dispose during hang - await agent.done + await driverDone(agent) // The review-fixes test for 'HIGH: disposed status' already covers // this assertion path. The reason is 'disposed' because isDisposed() is diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index 8c95be0d82..bbae57d7f9 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -4,9 +4,9 @@ import LlmService, { CallId, type Message } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { type ContinuationDecision, type PromptDecision, type SessionStartSource } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent, type ContinuationDecision, type PromptDecision, type SessionStartSource } from '@deepseek-ai/dsh-agent' -import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' /** @@ -30,7 +30,7 @@ async function harness(adapter: MockAdapter) { return ctx } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { @@ -41,11 +41,11 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { }) } -function send(agent: ReactLoopAgent, text: string) { +function send(agent: Agent, text: string) { agent.send([{ type: 'text', text }]) } -function events(agent: ReactLoopAgent): SessionEvent[] { +function events(agent: Agent): SessionEvent[] { return [...agent.session.events] } diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index c4e49e0fa5..49a7c6a328 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -4,11 +4,15 @@ import LlmService, { CallId, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from './mock-adapter.ts' +function driverDone(agent: Agent): Promise { + return (agent as Agent & { done: Promise }).done +} + async function harness(adapter: MockAdapter, persona = '') { const ctx = new Context() await ctx.plugin(LlmService) @@ -26,7 +30,7 @@ async function harness(adapter: MockAdapter, persona = '') { * invoke this right after send(), when the loop hasn't woken yet (status is * still 'idle' synchronously), so polling the current status would lie. */ -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { @@ -37,7 +41,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { }) } -function send(agent: ReactLoopAgent, text: string) { +function send(agent: Agent, text: string) { agent.send([{ type: 'text', text }]) } @@ -175,7 +179,7 @@ describe('agent loop', () => { agentOptions: { model: 'mock' }, }) - const agent = handle.agent as ReactLoopAgent + const agent = handle.agent send(agent, 'hi') await waitForIdle(ctx, agent) @@ -911,7 +915,7 @@ describe('agent loop', () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' }) }, { inject: ['agentLoop'] })) @@ -922,7 +926,7 @@ describe('agent loop', () => { expect(agent.status).toBe('running') await fiber.dispose() - await agent.done + await driverDone(agent) expect(agent.status).toBe('disposed') expect(ctx.agents.get(SessionId('scoped'))).toBeUndefined() @@ -942,7 +946,7 @@ describe('agent loop', () => { }) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agents.list()[0]! as ReactLoopAgent + const agent = ctx.agents.list()[0]! expect(agent).toBeDefined() expect(agent.id).toBe(agent.session.id) expect(agent.id).toMatch(/^config-agent-session-/) @@ -965,7 +969,7 @@ describe('agent loop', () => { agents: [{ id: 'config-agent', 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') }) diff --git a/packages/core/agent-loop/tests/properties.spec.ts b/packages/core/agent-loop/tests/properties.spec.ts index b3653d6540..a4539587d5 100644 --- a/packages/core/agent-loop/tests/properties.spec.ts +++ b/packages/core/agent-loop/tests/properties.spec.ts @@ -17,9 +17,9 @@ import { LlmAdapter } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' -import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import fc from 'fast-check' /** A never-exhausting adapter: every model call returns the same short reply. */ @@ -48,7 +48,7 @@ async function harness() { } /** Resolve on the agent's next transition to idle (event-based, not polled). */ -function nextIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function nextIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { @@ -61,7 +61,7 @@ function nextIdle(ctx: Context, agent: ReactLoopAgent): Promise { /** Record every status transition for the legal-machine assertion. Returns * the seen list plus a disposer for the listener (per the registry convention). */ -function recordStatus(ctx: Context, agent: ReactLoopAgent): { seen: string[]; dispose: () => void } { +function recordStatus(ctx: Context, agent: Agent): { seen: string[]; dispose: () => void } { const seen: string[] = [] const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent) seen.push(status) @@ -69,13 +69,13 @@ function recordStatus(ctx: Context, agent: ReactLoopAgent): { seen: string[]; di return { seen, dispose } } -function userMessageTexts(agent: ReactLoopAgent): string[] { +function userMessageTexts(agent: Agent): string[] { return agent.session.events .filter(e => e.type === 'user/message') .map(e => (e.data as { content: { type: string; text?: string }[] }).content.map(b => b.text ?? '').join('')) } -function turnNumbers(agent: ReactLoopAgent): number[] { +function turnNumbers(agent: Agent): number[] { return agent.session.events .filter(e => e.type === 'turn/start') .map(e => (e.data as { turn: number }).turn) diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index 202a8f6795..03b9a058ea 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -15,9 +15,9 @@ import type { GenerateOptions } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' async function harness(adapter: MockAdapter, persona = 'stable base') { @@ -32,7 +32,7 @@ async function harness(adapter: MockAdapter, persona = 'stable base') { return ctx } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { @@ -43,7 +43,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { }) } -function send(agent: ReactLoopAgent, text: string) { +function send(agent: Agent, text: string) { agent.send([{ type: 'text', text }]) } @@ -229,7 +229,7 @@ describe('request stability across the loop', () => { seed: [...agent.session.events], agentOptions: { model: 'mock' }, }) - const agent2 = handle.agent as ReactLoopAgent + const agent2 = handle.agent send(agent2, 'second') await waitForIdle(ctx2, agent2) diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index f5809575ab..630331c39c 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -8,10 +8,10 @@ import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse } from './mock-adapter.ts' const dirs: string[] = [] @@ -51,7 +51,7 @@ async function persistSession(sessionId: SessionId): Promise { return root } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { dispose(); resolve() } @@ -124,7 +124,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { // Lifecycle 1: create a no-cwd session and run a turn. const adapter1 = new MockAdapter([textResponse('a')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) - const a1 = (await ctx1.agents.create({ sessionId: SessionId('nocwd-sess') })).agent as ReactLoopAgent + const a1 = (await ctx1.agents.create({ sessionId: SessionId('nocwd-sess') })).agent a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) await ctx1.fiber.dispose() @@ -140,7 +140,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx2.plugin(AgentLoop, { agents: [] }) await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], adapter2) - const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('nocwd-sess') })).agent as ReactLoopAgent + const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('nocwd-sess') })).agent expect(a2.session.header.cwd).toBeUndefined() await ctx2.fiber.dispose() }) @@ -151,7 +151,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { const { ctx: ctx1, root } = await persistentHarness(adapter1) const sources1: string[] = [] ctx1.on('agent/session-start', (_agent, source) => void sources1.push(source)) - const a1 = (await ctx1.agents.create({ sessionId: SessionId('start-sess') })).agent as ReactLoopAgent + const a1 = (await ctx1.agents.create({ sessionId: SessionId('start-sess') })).agent expect(sources1).toEqual(['startup']) a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) @@ -443,7 +443,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx2.plugin(AgentLoop, { agents: [] }) await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], adapter2) - const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('forked-sess') })).agent as ReactLoopAgent + const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('forked-sess') })).agent expect(a2.session.header.parentSession).toBe('parent-sess') expect(a2.session.header.cwd).toBe('/w') expect(a2.session.header.seedLength).toBe(seed.length) @@ -457,7 +457,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { // disk, since a crash before the next turn would otherwise lose it. const adapter1 = new MockAdapter([textResponse('answer')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) - const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent as ReactLoopAgent + const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } }) @@ -482,7 +482,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { // drop it on reload (the bug this guards). const adapter1 = new MockAdapter([textResponse('answer')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) - const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent as ReactLoopAgent + const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } }) @@ -500,7 +500,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx2.plugin(AgentLoop, { agents: [] }) await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], adapter2) - const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('inject-sess') })).agent as ReactLoopAgent + const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('inject-sess') })).agent const flat = JSON.stringify(a2.session.deriveMessages()) expect(flat).toContain('background task 42 finished') await ctx2.fiber.dispose() @@ -510,7 +510,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { // Lifecycle 1: run one full turn, persisting it. const adapter1 = new MockAdapter([textResponse('first answer')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) - const a1 = (await ctx1.agents.create({ sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } })).agent as ReactLoopAgent + const a1 = (await ctx1.agents.create({ sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } })).agent a1.send([{ type: 'text', text: 'first question' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) const events1 = [...a1.session.events] @@ -530,7 +530,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], adapter2) - const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('sess-resume') })).agent as ReactLoopAgent + const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('sess-resume') })).agent // The resumed session carries the prior history… expect(a2.session.id).toBe('sess-resume') expect(a2.session.events.length).toBe(events1.length) diff --git a/packages/core/agent-loop/tests/review-fixes.spec.ts b/packages/core/agent-loop/tests/review-fixes.spec.ts index 942199e9eb..2664e38c04 100644 --- a/packages/core/agent-loop/tests/review-fixes.spec.ts +++ b/packages/core/agent-loop/tests/review-fixes.spec.ts @@ -4,13 +4,17 @@ import LlmService, { CallId, ContentBlock, MessageSource, StreamChunk } from '@d import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } 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, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { prepareReactLoopAgent } from '../src/agent.ts' import * as Invariants from '@deepseek-ai/dsh-invariants' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' +function driverDone(agent: Agent): Promise { + return (agent as Agent & { done: Promise }).done +} + /** Regression tests for agent-loop boundary, identity, and lifecycle contracts. */ async function harness(adapter: MockAdapter) { @@ -25,7 +29,7 @@ async function harness(adapter: MockAdapter) { return ctx } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { @@ -36,7 +40,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { }) } -function send(agent: ReactLoopAgent, text: string) { +function send(agent: Agent, text: string) { agent.send([{ type: 'text', text }]) } @@ -324,7 +328,7 @@ describe('MEDIUM: disposed status is part of the agent/status contract', () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' }) }, { inject: ['agentLoop'] })) @@ -337,7 +341,7 @@ describe('MEDIUM: disposed status is part of the agent/status contract', () => { send(agent, 'go') await new Promise(r => setTimeout(r, 30)) await fiber.dispose() - await agent.done + await driverDone(agent) expect(statuses).toEqual(['running', 'disposed']) expect(reasons).toEqual([{ kind: 'disposed' }]) @@ -347,7 +351,7 @@ describe('MEDIUM: disposed status is part of the agent/status contract', () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' }) }, { inject: ['agentLoop'] })) @@ -359,7 +363,7 @@ describe('MEDIUM: disposed status is part of the agent/status contract', () => { send(agent, 'go') await new Promise(r => setTimeout(r, 30)) await fiber.dispose() - await agent.done // must not hang + await driverDone(agent) // must not hang expect(agent.status).toBe('disposed') expect(ctx.agents.get(SessionId('scoped'))).toBeUndefined() // unregistered despite the throw @@ -697,7 +701,7 @@ describe('turn and step boundary recovery', () => { } /** Count turn/step boundary events for balance assertions. */ - function boundaryCounts(agent: ReactLoopAgent) { + function boundaryCounts(agent: Agent) { const e = [...agent.session.events] return { turnStart: e.filter(x => x.type === 'turn/start').length, @@ -871,7 +875,7 @@ describe('turn and step boundary recovery', () => { // balanced with reason disposed (no error event for a disposal). const adapter = new MockAdapter(['hang']) const ctx = await balancedHarness(adapter) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { agent = inner.agentLoop.create(SessionId('a-dispose'), { model: 'mock' }) }, { inject: ['agentLoop'] })) @@ -882,7 +886,7 @@ describe('turn and step boundary recovery', () => { send(agent, 'go') await new Promise(r => setTimeout(r, 30)) await fiber.dispose() // dispose during the hanging step - await agent.done + await driverDone(agent) const e = [...agent.session.events] const turnStarts = e.filter(x => x.type === 'turn/start').length @@ -900,7 +904,7 @@ describe('turn and step boundary recovery', () => { // and must preserve reason=disposed rather than rewrite it as a plugin error. const adapter = new MockAdapter([textResponse('never reached')]) const ctx = await balancedHarness(adapter) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { agent = inner.agentLoop.create(SessionId('a-prestep-dispose-throw'), { model: 'mock' }) }, { inject: ['agentLoop'] })) @@ -919,7 +923,7 @@ describe('turn and step boundary recovery', () => { ctx.on('agent/error', (_a, _t, _s, error) => void errorEmits.push(error)) send(agent, 'go') - await agent.done + await driverDone(agent) const e = [...agent.session.events] // Balanced: one turn/start, one turn/end carrying disposed (NOT error). @@ -1148,7 +1152,7 @@ describe('disposal and cancellation during pre-step assembly', () => { // calls stop() synchronously, setting status=disposed), then release the // block. The loop must check isDisposed() after assembly and end the turn // `disposed` — no LLM call. Don't await fiber.dispose() before releasing - // the blocker: the dispose chain awaits agent.done, which hangs until the + // the blocker: the dispose chain awaits driverDone(agent), which hangs until the // loop unblocks. const adapter = new MockAdapter(['hang']) let releaseAssemble!: () => void @@ -1170,7 +1174,7 @@ describe('disposal and cancellation during pre-step assembly', () => { return next() }) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { agent = inner.agentLoop.create(SessionId('a-dispose-assemble'), { model: 'mock' }) }, { inject: ['agentLoop'] })) @@ -1183,15 +1187,15 @@ describe('disposal and cancellation during pre-step assembly', () => { await new Promise(r => setTimeout(r, 50)) // Start disposal — stop() sets status=disposed synchronously, then the - // disposer's await agent.done hangs because the loop is blocked in the + // disposer's await driverDone(agent) hangs because the loop is blocked in the // waterfall. Do NOT await yet; release the blocker first. const disposalDone = fiber.dispose() // Now release the blocked waterfall — the loop unblocks, checks - // isDisposed(), and exits, which resolves agent.done and disposalDone. + // isDisposed(), and exits, which resolves driverDone(agent) and disposalDone. releaseAssemble() await disposalDone - await agent.done + await driverDone(agent) unlisten() const e = [...agent.session.events] @@ -1226,7 +1230,7 @@ describe('disposal and cancellation during pre-step assembly', () => { return next() }) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { agent = inner.agentLoop.create(SessionId('a-cancel-assemble'), { model: 'mock' }) }, { inject: ['agentLoop'] })) @@ -1241,7 +1245,7 @@ describe('disposal and cancellation during pre-step assembly', () => { releaseAssemble() await waitForIdle(ctx, agent) await fiber.dispose() - await agent.done + await driverDone(agent) unlisten() const e = [...agent.session.events] @@ -1281,7 +1285,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await blocker }) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { agent = inner.agentLoop.create(SessionId('a-dispose-prestep'), { model: 'mock' }) }, { inject: ['agentLoop'] })) @@ -1296,7 +1300,7 @@ describe('disposal and cancellation during pre-step assembly', () => { const disposalDone = fiber.dispose() releasePreStep() await disposalDone - await agent.done + await driverDone(agent) // After the pre-step seam finishes, the post-seam cancel/dispose check // catches disposal. The step was never opened, no LLM call was made. @@ -1333,7 +1337,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await blocker }) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { agent = inner.agentLoop.create(SessionId('a-cancel-prestep'), { model: 'mock' }) }, { inject: ['agentLoop'] })) @@ -1348,7 +1352,7 @@ describe('disposal and cancellation during pre-step assembly', () => { releasePreStep() await waitForIdle(ctx, agent) await fiber.dispose() - await agent.done + await driverDone(agent) const e = [...agent.session.events] expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1) @@ -1383,7 +1387,7 @@ describe('disposal and cancellation during pre-step assembly', () => { return next() }) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { agent = inner.agentLoop.create(SessionId('a-dispose-no-leak'), { model: 'mock' }) }, { inject: ['agentLoop'] })) @@ -1394,7 +1398,7 @@ describe('disposal and cancellation during pre-step assembly', () => { const disposalDone = fiber.dispose() releaseAssemble() await disposalDone - await agent.done + await driverDone(agent) const e = [...agent.session.events] expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1) diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index c68d96e525..57dae254c8 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -8,7 +8,7 @@ import AgentRegistry, { agentEvents, assembleContextFor } from '@deepseek-ai/dsh import type { Agent } from '@deepseek-ai/dsh-agent' import { scopeOf } from '@deepseek-ai/dsh-scope' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { MockAdapter, textResponse } from './mock-adapter.ts' @@ -28,7 +28,7 @@ async function harness(adapter: MockAdapter = new MockAdapter([textResponse('ok' return (await harnessWithLoop(adapter)).ctx } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { @@ -718,7 +718,7 @@ describe('agent scope lifecycle', () => { const ctx = await harness() let ownerCtx!: Context let creating!: ReturnType - let announced!: ReactLoopAgent + let announced!: Agent const statuses: string[] = [] let scopeDisposed = false let observerSawLive = false @@ -727,7 +727,7 @@ describe('agent scope lifecycle', () => { }) ctx.on('agent/session-start', (agent) => { if (agent.id !== SessionId('session-start-dispose-s')) return - announced = agent as ReactLoopAgent + announced = agent disposeCurrentLifecycle(ownerCtx) }) ctx.on('agent/session-start', (agent) => { diff --git a/packages/core/agent-loop/tests/tool-order.spec.ts b/packages/core/agent-loop/tests/tool-order.spec.ts index 1085f01a4c..3b7df63965 100644 --- a/packages/core/agent-loop/tests/tool-order.spec.ts +++ b/packages/core/agent-loop/tests/tool-order.spec.ts @@ -14,9 +14,9 @@ import SessionStore, { SessionId, foldRequestHeader } from '@deepseek-ai/dsh-ses import SystemPrompt, { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' import type { Config as SystemPromptConfig } from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse } from './mock-adapter.ts' async function harness(adapter: MockAdapter, toolOrder?: SystemPromptConfig['toolOrder']) { @@ -31,7 +31,7 @@ async function harness(adapter: MockAdapter, toolOrder?: SystemPromptConfig['too return ctx } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { diff --git a/packages/core/agent-loop/tests/turn-stop.spec.ts b/packages/core/agent-loop/tests/turn-stop.spec.ts index c7823a2aa5..90bc0f9558 100644 --- a/packages/core/agent-loop/tests/turn-stop.spec.ts +++ b/packages/core/agent-loop/tests/turn-stop.spec.ts @@ -4,9 +4,9 @@ import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { type ContinuationStop } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent, type ContinuationStop } from '@deepseek-ai/dsh-agent' -import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as Invariants from '@deepseek-ai/dsh-invariants' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' @@ -23,7 +23,7 @@ async function harness(adapter: MockAdapter): Promise { return ctx } -function send(agent: ReactLoopAgent, text = 'go'): Promise { +function send(agent: Agent, text = 'go'): Promise { agent.send([{ type: 'text', text }]) return agent.whenIdle() } diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index b0fb78ad66..edd38e31b6 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -168,9 +168,8 @@ export type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' /** * The agent handle — the surface every plugin (UI, hooks, orchestrators) - * programs against. The concrete implementation lives in - * `@deepseek-ai/dsh-agent-loop` (class `ReactLoopAgent`); nothing outside the loop - * package should depend on the implementation. + * programs against. The concrete implementation is package-internal to + * `@deepseek-ai/dsh-agent-loop`; nothing outside that package depends on it. */ export interface Agent { /** The single identity shared with {@link session}. */ diff --git a/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts b/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts index 9538166fc4..4f7015d8c4 100644 --- a/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts +++ b/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts @@ -4,9 +4,9 @@ import LlmService, { CallId } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' -import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as RepeatToolGuard from '@deepseek-ai/dsh-repeat-tool-guard' import type { Config } from '@deepseek-ai/dsh-repeat-tool-guard' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -34,12 +34,12 @@ async function harness(config: Config = {}): Promise { return ctx } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const d = ctx.on('agent/status', (s, st) => { if (s === agent && st === 'idle') { d(); resolve() } }) }) } /** Every `context/message` in the agent's log, flattened to joined text + source for terse assertions. */ -function reminders(agent: ReactLoopAgent): { text: string; source: unknown }[] { +function reminders(agent: Agent): { text: string; source: unknown }[] { return [...agent.session.events] .filter((e): e is SessionEvent<'context/message'> => e.type === 'context/message') .map(e => ({ @@ -255,14 +255,14 @@ describe('chain semantics', () => { ])) // Loop agents are torn down by disposing the scope that created them // (the loop.spec pattern): a child plugin fiber owns `first`. - let first!: ReactLoopAgent + let first!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { first = inner.agentLoop.create(SessionId('reused'), { model: 'mock' }) }, { inject: ['agentLoop'] })) first.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, first) await fiber.dispose() - await first.done + await first.whenIdle() const second = ctx.agentLoop.create(SessionId('reused'), { model: 'mock' }) second.send([{ type: 'text', text: 'go' }]) diff --git a/packages/hooks/hooks-claude/tests/bridge.spec.ts b/packages/hooks/hooks-claude/tests/bridge.spec.ts index 2f0a2666c9..2c26d64f5a 100644 --- a/packages/hooks/hooks-claude/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude/tests/bridge.spec.ts @@ -8,9 +8,9 @@ import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' -import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -58,7 +58,7 @@ async function harnessWithFiber(configDir: string, adapter: MockAdapter): Promis return { ctx, hooks } } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { dispose(); resolve() } @@ -66,7 +66,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { }) } -function events(agent: ReactLoopAgent): SessionEvent[] { +function events(agent: Agent): SessionEvent[] { return [...agent.session.events] } diff --git a/packages/hooks/hooks-claude/tests/coverage.spec.ts b/packages/hooks/hooks-claude/tests/coverage.spec.ts index 16c48aa149..7997e0fd87 100644 --- a/packages/hooks/hooks-claude/tests/coverage.spec.ts +++ b/packages/hooks/hooks-claude/tests/coverage.spec.ts @@ -7,9 +7,9 @@ import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' -import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -42,10 +42,10 @@ async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOp ctx.llm.registerAdapter(['mock'], adapter) return ctx } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const d = ctx.on('agent/status', (s, st) => { if (s === agent && st === 'idle') { d(); resolve() } }) }) } -function events(agent: ReactLoopAgent): SessionEvent[] { return [...agent.session.events] } +function events(agent: Agent): SessionEvent[] { return [...agent.session.events] } /** Poll until `predicate` holds or the deadline passes — robust to detached * emit-listener hooks firing on a `.then` (a fixed sleep flakes under load). */ async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise { @@ -451,8 +451,8 @@ describe('hooks-claude coverage — continue:false, context arm, no-cwd', () => const { SessionId } = await import('@deepseek-ai/dsh-session') const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: workspace }, agentOptions: { model: 'mock' } }) handle.agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, handle.agent as ReactLoopAgent) - expect(events(handle.agent as ReactLoopAgent).some(e => e.type === 'context/message' + await waitForIdle(ctx, handle.agent) + expect(events(handle.agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes(`dir=${workspace}`)))).toBe(true) await handle.dispose() }) @@ -616,7 +616,7 @@ describe('hooks-claude coverage — hook runs in the session cwd, not the server const { SessionId } = await import('@deepseek-ai/dsh-session') const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { model: 'mock' } }) handle.agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, handle.agent as ReactLoopAgent) + await waitForIdle(ctx, handle.agent) expect(existsSync(marker)).toBe(true) // the marker landed in the SESSION dir const { readFileSync } = await import('node:fs') diff --git a/packages/hooks/hooks-codex/tests/bridge.spec.ts b/packages/hooks/hooks-codex/tests/bridge.spec.ts index 4aa678c36e..84e877af97 100644 --- a/packages/hooks/hooks-codex/tests/bridge.spec.ts +++ b/packages/hooks/hooks-codex/tests/bridge.spec.ts @@ -8,9 +8,9 @@ import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' -import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -54,14 +54,14 @@ async function harness(dir: string, adapter: MockAdapter): Promise { return ctx } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { dispose(); resolve() } }) }) } -function events(agent: ReactLoopAgent): SessionEvent[] { return [...agent.session.events] } +function events(agent: Agent): SessionEvent[] { return [...agent.session.events] } /** Poll `predicate` until true or the deadline passes (detached hook effects can't be awaited directly). */ async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise { diff --git a/packages/hooks/hooks-codex/tests/coverage.spec.ts b/packages/hooks/hooks-codex/tests/coverage.spec.ts index 75b04d4c57..018f109a83 100644 --- a/packages/hooks/hooks-codex/tests/coverage.spec.ts +++ b/packages/hooks/hooks-codex/tests/coverage.spec.ts @@ -7,9 +7,9 @@ import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' -import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -33,10 +33,10 @@ async function harness(configPath: string, adapter: MockAdapter, opts: { stderrS ctx.llm.registerAdapter(['mock'], adapter) return ctx } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const d = ctx.on('agent/status', (s, st) => { if (s === agent && st === 'idle') { d(); resolve() } }) }) } -function events(agent: ReactLoopAgent): SessionEvent[] { return [...agent.session.events] } +function events(agent: Agent): SessionEvent[] { return [...agent.session.events] } /** Poll until `predicate` holds or the deadline passes — robust to detached * emit-listener hooks firing on a `.then` (a fixed sleep flakes under load). */ async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise { @@ -556,7 +556,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { const { SessionId } = await import('@deepseek-ai/dsh-session') const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { model: 'mock' } }) handle.agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, handle.agent as ReactLoopAgent) + await waitForIdle(ctx, handle.agent) expect(existsSync(marker)).toBe(true) expect(readFileSync(marker, 'utf8').trim().endsWith(sessionDir.split('/').pop()!)).toBe(true) await handle.dispose() diff --git a/packages/todo/tool-todo/tests/integration.spec.ts b/packages/todo/tool-todo/tests/integration.spec.ts index 8376a70425..ba2df4f6ea 100644 --- a/packages/todo/tool-todo/tests/integration.spec.ts +++ b/packages/todo/tool-todo/tests/integration.spec.ts @@ -5,9 +5,9 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry 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 * as ToolTodo from '@deepseek-ai/dsh-tool-todo' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -30,7 +30,7 @@ async function harness(adapter: MockAdapter): Promise { return ctx } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { diff --git a/packages/ui/acp/README.md b/packages/ui/acp/README.md index 31a865c068..9f4e8b7c0b 100644 --- a/packages/ui/acp/README.md +++ b/packages/ui/acp/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-acp -The **Agent Client Protocol (ACP)** bridge: exposes DeepSeek Harness SDK agents as an ACP server over JSON-RPC stdio, so editors (Zed and other ACP clients) can drive them — streaming render, tool-call display, and resumable sessions. Zed is the current target client: baseline ACP behavior should remain reasonable for other clients, but bridge capabilities and compatibility decisions are evaluated against Zed first. **N concurrent sessions per connection** (see [ACP multi-session](../../../docs/rfc/implemented/feature/2026-06-14-acp-multi-session.md)): each maps to its own `ReactLoopAgent`, and every event is demuxed strictly by session id so two sessions streaming at once never interleave. +The **Agent Client Protocol (ACP)** bridge: exposes DeepSeek Harness SDK agents as an ACP server over JSON-RPC stdio, so editors (Zed and other ACP clients) can drive them — streaming render, tool-call display, and resumable sessions. Zed is the current target client: baseline ACP behavior should remain reasonable for other clients, but bridge capabilities and compatibility decisions are evaluated against Zed first. **N concurrent sessions per connection** (see [ACP multi-session](../../../docs/rfc/implemented/feature/2026-06-14-acp-multi-session.md)): each maps to its own concrete `Agent`, and every event is demuxed strictly by session id so two sessions streaming at once never interleave. It is a **client-driver / UI plugin**, the structured analogue of the readline `stdio-chat` plugin — NOT a loop change and NOT a [capability seam](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md). It consumes the existing `agent/*` event taxonomy, the `dsh-agent` create/resume factory, and `dsh-session-persistence`. diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index 369c8ae166..7c97ad715c 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -18,7 +18,7 @@ * turn about to start) + settle the in-flight prompt * * Multi-session (RFC 011): N concurrent sessions per connection, each mapped to - * its own `ReactLoopAgent`. Sessions are keyed by their shared agent/session id; + * its own concrete `Agent`. Sessions are keyed by their shared agent/session id; * every `session/event` and `agent/*` event is routed strictly to its owning * session record, so two sessions streaming at once never interleave their * `session/update` notifications. Permission prompts use the same identity: the From f85b831bd2cd0d37ca814473abc17ccfb2a6c287 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 02:41:23 +0800 Subject: [PATCH 02/18] refactor: hide subagent implementation helpers --- ...claude-code-and-codex-subagent-backends.md | 2 +- .../subagent-acp/tests/subagent-acp.spec.ts | 3 +- .../subagent/subagent-inprocess/README.md | 2 +- .../subagent/subagent-inprocess/src/index.ts | 4 +- .../tests/subagent-inprocess.spec.ts | 24 ++---- .../tests/subagent-spawn.spec.ts | 8 +- .../subagent/subagent-subprocess/README.md | 8 +- .../subagent/subagent-subprocess/src/index.ts | 6 +- .../tests/subagent-subprocess.spec.ts | 84 +++++++------------ 9 files changed, 54 insertions(+), 87 deletions(-) diff --git a/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md b/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md index 911d28cf1f..c05ae8d67f 100644 --- a/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md +++ b/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md @@ -12,7 +12,7 @@ Two sibling provider packages, structural variants of the ACP backend, plus one - `@deepseek-ai/dsh-subagent-claude-code` — drives a Claude Code child through `@anthropic-ai/claude-agent-sdk`'s `query()` (the SDK runs in the parent process and spawns its bundled `claude` CLI as the subprocess). Provider name `claude-code`: the child is the Claude Code *product*, not an Anthropic model adapter — "claude" stays reserved for a future `dsh-llm` adapter. - `@deepseek-ai/dsh-subagent-codex` — spawns `codex app-server` and drives one thread/turn over its JSON-RPC-over-stdio protocol with a hand-rolled newline-JSON client (~200–300 lines) in the package. -- `@deepseek-ai/dsh-subagent-process` — a pure library (the `subagent-inprocess` precedent) extracting what `dsh-subagent-acp` already carries and both new backends need: the credential env scrub (`SENSITIVE_ENV_PATTERN`/`buildChildEnv`), the EOF → SIGTERM → SIGKILL dispose ladder, and new isolated-config-dir helpers (`mkdtemp` create, best-effort remove). The ACP backend migrates onto it; `bash-local`'s sibling copy is left alone to bound the change. +- `@deepseek-ai/dsh-subagent-process` — a pure library (the `subagent-inprocess` precedent) extracting what `dsh-subagent-acp` already carries and both new backends need: the credential env scrub (`buildChildEnv`), the EOF → SIGTERM → SIGKILL dispose ladder, and new isolated-config-dir helpers (`mkdtemp` create, best-effort remove). The ACP backend migrates onto it; `bash-local`'s sibling copy is left alone to bound the change. Both providers copy the ACP backend's seam posture verbatim: fresh child per `start`, exactly one prompt round-trip, capabilities all `false`, `inheritsParentContext: false`, `request.parent`/`request.agentOptions` ignored, `id = SessionId(randomUUID())`, `result` never rejects — child-level failure flattens to a stop reason and the original error goes to `ctx.logger` via an `onError` spec callback. Model exposure is zero new code: `dsh-tool-subagent` is loaded once per provider with a distinct `toolName` (`subagent_claude_code`, `subagent_codex`). No new session events are needed — the only model-visible artifact is the tool result, so reconstructability holds exactly as it did for ACP. To be explicit about the boundary: the session log reconstructs the model-visible transcript, not workspace mutation history — a child granted write access mutates files as an ambient side effect outside the log, exactly as the bash tools and the ACP backend already do; replay reproduces requests, not the disk. diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index 115d5c3e97..cc4d8307f8 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -6,7 +6,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import SubagentService from '@deepseek-ai/dsh-subagent' -import { buildChildEnv, SENSITIVE_ENV_PATTERN } from '@deepseek-ai/dsh-subagent-subprocess' +import { buildChildEnv } from '@deepseek-ai/dsh-subagent-subprocess' import type { Agent } from '@deepseek-ai/dsh-agent' import * as acp from '../src/index.ts' import { acpStopReason, acpContentText, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, startAcpRun, toAcpPrompt, type AcpRunSpec } from '../src/run.ts' @@ -112,7 +112,6 @@ describe('buildChildEnv', () => { // The explicitly-supplied key survives (an opt-in for the child's creds). expect(env.DEEPSEEK_API_KEY).toBe('explicit') // A normal ambient var is forwarded. - expect(SENSITIVE_ENV_PATTERN.test('PATH')).toBe(false) expect(env.PATH).toBe(process.env.PATH) } finally { delete process.env.DSH_ACP_TEST_SECRET_TOKEN diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index 48fd0a201d..83c2ba3500 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -26,7 +26,7 @@ After fulfillment, the caller owns the run. Provider-plugin unload does not revo `InProcessRunOptions` is `{ seed?: SessionEvent[] }`. Spawn omits it. Fork supplies a balanced completed-turn prefix and records its length so the result reader never mistakes a seeded parent message for child output. -`depthOf(agent)` reads `AgentOptions.subagentDepth`, treating absence as top-level depth zero and rejecting malformed stored values. `SubagentDepthError` reports an attempted child depth above `maxDepth`; an unrepresentable depth above the safe-integer domain is a `RangeError`. +Depth enforcement is internal to `startInProcessRun`: it reads `AgentOptions.subagentDepth`, treats absence as top-level depth zero, rejects malformed stored values, and reports an attempted child depth above `maxDepth`. An unrepresentable depth above the safe-integer domain is a `RangeError`. ## Structured output diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index 965d9c8e78..9676c74b5b 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -36,7 +36,7 @@ declare module '@deepseek-ai/dsh-agent' { * @param agent - the agent whose options carry the depth. * @returns its non-negative safe-integer depth. */ -export function depthOf(agent: Agent): number { +function depthOf(agent: Agent): number { const depth = agent.options.subagentDepth if (depth === undefined) return 0 if (!Number.isSafeInteger(depth) || depth < 0 || Object.is(depth, -0)) { @@ -46,7 +46,7 @@ export function depthOf(agent: Agent): number { } /** Thrown when starting a child would exceed the requested depth cap. */ -export class SubagentDepthError extends Error { +class SubagentDepthError extends Error { constructor(public readonly attemptedDepth: number, public readonly maxDepth: number) { super(`subagent depth ${attemptedDepth} exceeds maxDepth ${maxDepth}`) this.name = 'SubagentDepthError' diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index aa0f946c89..c594e27fa8 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -10,7 +10,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as Invariants from '@deepseek-ai/dsh-invariants' import SubagentService from '@deepseek-ai/dsh-subagent' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' -import { depthOf, SubagentDepthError, startInProcessRun } from '../src/index.ts' +import { startInProcessRun } from '../src/index.ts' type Script = ConstructorParameters[0] @@ -37,19 +37,6 @@ function text(blocks: readonly { type: string; text?: string }[]): string { return blocks.filter(block => block.type === 'text').map(block => block.text).join('') } -describe('depthOf', () => { - it('reads zero for a top-level agent and an explicit child depth', async () => { - const { parent } = await setup([]) - expect(depthOf(parent)).toBe(0) - expect(depthOf({ options: { subagentDepth: 3 } } as unknown as Agent)).toBe(3) - }) - - it.each([Number.NaN, 1.5, -1, -0, Number.MAX_SAFE_INTEGER + 1])('rejects malformed depth %s', (value) => { - expect(() => depthOf({ options: { subagentDepth: value } } as unknown as Agent)) - .toThrow('non-negative safe integer') - }) -}) - describe('startInProcessRun', () => { it('returns only after publication, drives a fresh child, and disposes it', async () => { const { ctx, parent } = await setup([textResponse('driver answer')]) @@ -58,7 +45,7 @@ describe('startInProcessRun', () => { const result = await run.result expect(result.stopReason).toBe('completed') expect(text(result.output)).toBe('driver answer') - expect(depthOf(ctx.agents.get(run.id)!)).toBe(1) + expect(ctx.agents.get(run.id)!.options.subagentDepth).toBe(1) await run.dispose() await run.dispose() expect(ctx.agents.get(run.id)).toBeUndefined() @@ -83,7 +70,12 @@ describe('startInProcessRun', () => { await expect(startInProcessRun({ ...request(parent), maxDepth: -1 }, {})) .rejects.toThrow('non-negative safe integer') await expect(startInProcessRun({ ...request(parent), maxDepth: 0 }, {})) - .rejects.toBeInstanceOf(SubagentDepthError) + .rejects.toMatchObject({ name: 'SubagentDepthError' }) + for (const value of [Number.NaN, 1.5, -1, -0, Number.MAX_SAFE_INTEGER + 1]) { + const malformed = { options: { subagentDepth: value } } as unknown as Agent + await expect(startInProcessRun(request(malformed), {})) + .rejects.toThrow('agent subagentDepth must be a non-negative safe integer') + } const maxParent = { options: { subagentDepth: Number.MAX_SAFE_INTEGER } } as unknown as Agent await expect(startInProcessRun(request(maxParent), {})).rejects.toBeInstanceOf(RangeError) }) diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index 06aa40ca9f..9b9e2a8fd2 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -13,7 +13,7 @@ import * as Invariants from '@deepseek-ai/dsh-invariants' import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent' import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import * as spawn from '../src/index.ts' -import { depthOf, STRUCTURED_OUTPUT_TOOL, SubagentDepthError } from '@deepseek-ai/dsh-subagent-inprocess' +import { STRUCTURED_OUTPUT_TOOL } from '@deepseek-ai/dsh-subagent-inprocess' type Script = ConstructorParameters[0] @@ -118,11 +118,11 @@ describe('dsh-subagent-spawn', () => { it('stamps child depth = parent depth + 1 (via the merged AgentOptions field)', async () => { const { ctx, parent } = await setup([textResponse('x')]) - expect(depthOf(parent)).toBe(0) + expect(parent.options.subagentDepth).toBeUndefined() const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) await run.result const child = ctx.agents.get(run.id)! - expect(depthOf(child)).toBe(1) + expect(child.options.subagentDepth).toBe(1) await run.dispose() }) @@ -130,7 +130,7 @@ describe('dsh-subagent-spawn', () => { const { ctx, parent } = await setup([]) // parent is depth 0, child would be depth 1 — cap at 0 forbids any child. await expect(start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent, maxDepth: 0 })) - .rejects.toThrow(SubagentDepthError) + .rejects.toThrow('subagent depth 1 exceeds maxDepth 0') }) it('maps a child that hit its token ceiling to stopReason "max-tokens"', async () => { diff --git a/packages/subagent/subagent-subprocess/README.md b/packages/subagent/subagent-subprocess/README.md index ccc68bf31b..c8b8a06437 100644 --- a/packages/subagent/subagent-subprocess/README.md +++ b/packages/subagent/subagent-subprocess/README.md @@ -6,7 +6,7 @@ Every tunable is a **parameter**: the dispose ladder takes its grace periods per ## What it exports -### `SENSITIVE_ENV_PATTERN` / `buildChildEnv(extra)` +### `buildChildEnv(extra)` The credential env scrub (same pattern as the [bash executor](../../bash/bash-local/README.md)): the child env is the ambient env minus credential-shaped vars (`/KEY|SECRET|TOKEN/i`), with `extra` layered on top AFTER the scrub. `PATH`, `HOME`, `TMPDIR`, locale, and proxy vars survive, so the child CLI runs normally; the parent's own secrets never leak implicitly, while an explicitly supplied credential (the child's OWN key in a backend's `env` config) still reaches the child. @@ -14,10 +14,6 @@ The credential env scrub (same pattern as the [bash executor](../../bash/bash-lo Spawn-failure capture: a promise that resolves (never rejects) with the child's first `error` event. A spawn failure such as `ENOENT` is an event, not a thrown exception — without a listener Node crashes the parent process — so call this in the same tick as `spawn()` and race it in the run's result path; a bad command then settles as an ordinary child-level failure. For a child that spawns cleanly the promise never settles. -### `waitForExit(child)` / `exitsWithin(child, ms)` - -Exit waits over a `ChildProcess`: resolve once the child exits by any code or signal (immediately if it is already gone), or race that against a timer (`true` = exited in time). The race cleans up after itself on both outcomes — the pending timer is `unref()`ed and cleared on exit, the exit listener removed on timeout — so repeated calls (the dispose ladder's tiers, a poll loop) never accumulate listeners on the child. - ### `disposeChildProcess(child, graces)` The three-tier dispose ladder. Resolves only once the child has ACTUALLY exited — quiescence reached, not merely requested (see [defensive patterns](../../../docs/defensive-patterns.md)): @@ -28,6 +24,8 @@ The three-tier dispose ladder. Resolves only once the child has ACTUALLY exited The two graces (`DisposeLadderGraces`) come from the consuming plugin's `disposeEofGraceMs`/`disposeGraceMs` Config fields; the EOF window is deliberately a separate — usually wider — grace than the signal tier, since a cooperative child's EOF teardown may itself await a signal-trapping grandchild plus a final flush. +The exit waits are internal to this ladder. They clean up their timer and listener on either outcome, so escalation never accumulates listeners on the child. + ### `createIsolatedConfigDir(prefix, pinnedPath?)` A per-run isolated config directory for an external CLI child (the target of `CLAUDE_CONFIG_DIR` / `CODEX_HOME`-style redirection), so child behavior is a function of deployment config alone — never of whatever `~/.claude` / `~/.codex`-style state exists on the host. Returns an `IsolatedConfigDir` handle: `path` goes into the child env, `remove()` runs on dispose. diff --git a/packages/subagent/subagent-subprocess/src/index.ts b/packages/subagent/subagent-subprocess/src/index.ts index 35d7383456..2ee2745985 100644 --- a/packages/subagent/subagent-subprocess/src/index.ts +++ b/packages/subagent/subagent-subprocess/src/index.ts @@ -32,7 +32,7 @@ import { join } from 'node:path' * the scrub, so an intended `DEEPSEEK_API_KEY` survives while an incidental * `AWS_SECRET_ACCESS_KEY` does not. */ -export const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i +const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i /** * The ambient env minus credential-shaped vars, plus the caller's explicit @@ -72,7 +72,7 @@ export function spawnFailure(child: ChildProcess): Promise { * already gone. * @param child - the child process to await. */ -export function waitForExit(child: ChildProcess): Promise { +function waitForExit(child: ChildProcess): Promise { if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve() return new Promise(resolve => child.once('exit', () => { resolve() })) } @@ -87,7 +87,7 @@ export function waitForExit(child: ChildProcess): Promise { * @returns `true` if the child exits within `ms` (immediately if it is * already gone), `false` on timeout. */ -export function exitsWithin(child: ChildProcess, ms: number): Promise { +function exitsWithin(child: ChildProcess, ms: number): Promise { if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true) return new Promise((resolve) => { const onExit = (): void => { diff --git a/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts b/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts index 2766ed0a41..8e19a8a4a1 100644 --- a/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts +++ b/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts @@ -9,10 +9,7 @@ import { buildChildEnv, createIsolatedConfigDir, disposeChildProcess, - exitsWithin, - SENSITIVE_ENV_PATTERN, spawnFailure, - waitForExit, } from '../src/index.ts' // `rm` is wrapped (real-passthrough by default) so ONE test can inject a @@ -47,6 +44,8 @@ interface FakeChildScript { diesOn?: LethalTrigger /** Delay (ms) between the lethal trigger and the exit event. */ delayMs?: number + /** Complete the scripted exit inside the triggering call. */ + synchronousExit?: boolean /** `false` models a child spawned without a stdin pipe. */ stdin?: boolean } @@ -80,11 +79,13 @@ class FakeChild extends EventEmitter { // SIGKILL is uncatchable — it always fells the child; any other trigger // only when the scenario scripts it as the lethal one. if (trigger !== 'SIGKILL' && this.script.diesOn !== trigger) return - setTimeout(() => { + const exit = (): void => { if (trigger === 'eof') this.exitCode = 0 else this.signalCode = trigger this.emit('exit', this.exitCode, this.signalCode) - }, this.script.delayMs ?? 0) + } + if (this.script.synchronousExit === true) exit() + else setTimeout(exit, this.script.delayMs ?? 0) } } @@ -93,7 +94,7 @@ function asChild(fake: FakeChild): ChildProcess { return fake as unknown as ChildProcess } -describe('buildChildEnv / SENSITIVE_ENV_PATTERN', () => { +describe('buildChildEnv', () => { it('drops credential-shaped ambient vars (KEY/SECRET/TOKEN, case-insensitive)', () => { process.env.DSH_PROC_TEST_API_KEY = 'leak' process.env.dsh_proc_test_secret = 'leak' @@ -111,7 +112,6 @@ describe('buildChildEnv / SENSITIVE_ENV_PATTERN', () => { }) it('forwards normal ambient vars', () => { - expect(SENSITIVE_ENV_PATTERN.test('PATH')).toBe(false) expect(buildChildEnv({}).PATH).toBe(process.env.PATH) }) @@ -149,7 +149,7 @@ describe('spawnFailure', () => { const fake = new FakeChild({ diesOn: 'SIGTERM' }) const failure = spawnFailure(asChild(fake)) fake.kill('SIGTERM') - await waitForExit(asChild(fake)) + await new Promise(resolve => fake.once('exit', () => { resolve() })) // A clean lifecycle emits `exit`, never `error` — the capture stays // pending forever, so a race against it is decided by the other arms. const settled = await Promise.race([ @@ -160,51 +160,6 @@ describe('spawnFailure', () => { }) }) -describe('waitForExit / exitsWithin', () => { - it('resolves immediately for a child that already exited by code', async () => { - const fake = new FakeChild() - fake.exitCode = 0 - await expect(waitForExit(asChild(fake))).resolves.toBeUndefined() - }) - - it('resolves immediately for a child that already died by signal', async () => { - const fake = new FakeChild() - fake.signalCode = 'SIGTERM' - await expect(waitForExit(asChild(fake))).resolves.toBeUndefined() - }) - - it('resolves on the exit event of a live child', async () => { - const fake = new FakeChild({ diesOn: 'SIGTERM', delayMs: 5 }) - const exited = waitForExit(asChild(fake)) - fake.kill('SIGTERM') - await expect(exited).resolves.toBeUndefined() - expect(fake.signalCode).toBe('SIGTERM') - }) - - it('exitsWithin resolves true immediately for an already-exited child (no listener attached)', async () => { - const fake = new FakeChild() - fake.exitCode = 0 - await expect(exitsWithin(asChild(fake), 1000)).resolves.toBe(true) - expect(fake.listenerCount('exit')).toBe(0) - }) - - it('exitsWithin resolves true when the child exits inside the window', async () => { - const fake = new FakeChild({ diesOn: 'SIGTERM', delayMs: 5 }) - fake.kill('SIGTERM') - await expect(exitsWithin(asChild(fake), 1000)).resolves.toBe(true) - // The once-listener fired and the grace timer was cleared — nothing lingers. - expect(fake.listenerCount('exit')).toBe(0) - }) - - it('exitsWithin resolves false on timeout for a child that never exits', async () => { - const fake = new FakeChild() // nothing short of SIGKILL fells it; no signal sent - await expect(exitsWithin(asChild(fake), 20)).resolves.toBe(false) - // The timeout arm removed its exit listener: repeated waits (a poll loop, - // the ladder's tiers) never accumulate listeners on the same child. - expect(fake.listenerCount('exit')).toBe(0) - }) -}) - describe('disposeChildProcess', () => { it('returns immediately for an already-exited child (no EOF, no signals)', async () => { const fake = new FakeChild() @@ -230,12 +185,28 @@ describe('disposeChildProcess', () => { expect(fake.exitCode).toBe(0) }) + it('recognizes a child that exits synchronously on stdin EOF', async () => { + const fake = new FakeChild({ diesOn: 'eof', synchronousExit: true }) + await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 }) + expect(fake.exitCode).toBe(0) + expect(fake.listenerCount('exit')).toBe(0) + }) + it('tier 2: a child that ignores EOF but honors SIGTERM dies on the middle rung', async () => { const fake = new FakeChild({ diesOn: 'SIGTERM', delayMs: 5 }) await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }) expect(fake.stdinEnded).toBe(true) expect(fake.kills).toEqual(['SIGTERM']) expect(fake.signalCode).toBe('SIGTERM') + expect(fake.listenerCount('exit')).toBe(0) + }) + + it('recognizes a child that exits synchronously on SIGTERM', async () => { + const fake = new FakeChild({ diesOn: 'SIGTERM', synchronousExit: true }) + await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }) + expect(fake.kills).toEqual(['SIGTERM']) + expect(fake.signalCode).toBe('SIGTERM') + expect(fake.listenerCount('exit')).toBe(0) }) it('tier 3: a SIGTERM-trapping child is SIGKILLed, and dispose resolves only after the exit', async () => { @@ -247,6 +218,13 @@ describe('disposeChildProcess', () => { expect(fake.signalCode).toBe('SIGKILL') }) + it('recognizes a child already gone when the final exit wait begins', async () => { + const fake = new FakeChild({ synchronousExit: true }) + await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 20 }) + expect(fake.kills).toEqual(['SIGTERM', 'SIGKILL']) + expect(fake.signalCode).toBe('SIGKILL') + }) + it('walks the ladder for a child spawned without a stdin pipe', async () => { const fake = new FakeChild({ stdin: false, diesOn: 'SIGTERM', delayMs: 5 }) await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }) From 65521b589f3ffa1a912bb3d8db05a6e2ce204a85 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 02:45:34 +0800 Subject: [PATCH 03/18] refactor: hide remaining subagent helpers --- ...t-variables-and-tool-guidance-ownership.md | 2 +- ...7-05-subagent-provider-lifecycle-events.md | 2 +- .../2026-06-22-fork-snapshot-scenarios.md | 2 +- packages/subagent/subagent-fork/README.md | 2 +- packages/subagent/subagent-fork/src/index.ts | 2 +- .../subagent-fork/tests/subagent-fork.spec.ts | 42 ++++++++----------- packages/subagent/tool-subagent/src/index.ts | 4 +- 7 files changed, 25 insertions(+), 31 deletions(-) diff --git a/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md b/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md index 0961830c1a..aae4564513 100644 --- a/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md +++ b/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md @@ -38,7 +38,7 @@ Per-tool semantics and when-to-use live in tool DESCRIPTIONS, which already ship ### The subagent conversation-history descriptor -`SubagentProvider` gains `readonly inheritsParentContext: boolean` — a DESCRIPTIVE conversation-history fact beside `capabilities`, not in it (capabilities are start-time validation; nothing validates against this flag). Spawn and ACP declare `false`, fork declares `true`. The name refers only to conversation seeding, not Cordis scope, services, tools, or authority. `dsh-tool-subagent` derives both the tool description and the `prompt` parameter description from the flag (`providerWording`): the fork instance now tells the model the child is seeded with the conversation's completed turns (not the in-flight turn) and that its prompt should state only what is new. Deriving the description from a provider that arrives on its own fiber is what forced the provider-lifecycle events and the tool's reactive registration — that mechanism, its Loader-concurrency rationale, and its rejected alternatives are recorded in [the provider-lifecycle-events RFC](2026-07-05-subagent-provider-lifecycle-events.md). +`SubagentProvider` gains `readonly inheritsParentContext: boolean` — a DESCRIPTIVE conversation-history fact beside `capabilities`, not in it (capabilities are start-time validation; nothing validates against this flag). Spawn and ACP declare `false`, fork declares `true`. The name refers only to conversation seeding, not Cordis scope, services, tools, or authority. `dsh-tool-subagent` derives both the tool description and the `prompt` parameter description from the flag: the fork instance now tells the model the child is seeded with the conversation's completed turns (not the in-flight turn) and that its prompt should state only what is new. Deriving the description from a provider that arrives on its own fiber is what forced the provider-lifecycle events and the tool's reactive registration — that mechanism, its Loader-concurrency rationale, and its rejected alternatives are recorded in [the provider-lifecycle-events RFC](2026-07-05-subagent-provider-lifecycle-events.md). ## Alternatives considered diff --git a/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.md b/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.md index 46e7ea4e71..4a96ef7419 100644 --- a/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.md +++ b/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.md @@ -4,7 +4,7 @@ Status: implemented ## Problem -[The prompt-variables RFC](2026-07-05-prompt-variables-and-tool-guidance-ownership.md) makes `dsh-tool-subagent` DERIVE its model-facing wording from its provider: `SubagentProvider.inheritsParentContext` (spawn/ACP `false`, fork `true`) drives both the tool description and the `prompt` parameter description (`providerWording`), so the fork tool stops lying about context inheritance. That fix created a cross-fiber data dependency: a tool's description is fixed at TOOL REGISTRATION (deliberately — the description is where tool-choice guidance lives), but the provider arrives on its own plugin fiber, on no particular schedule. +[The prompt-variables RFC](2026-07-05-prompt-variables-and-tool-guidance-ownership.md) makes `dsh-tool-subagent` DERIVE its model-facing wording from its provider: `SubagentProvider.inheritsParentContext` (spawn/ACP `false`, fork `true`) drives both the tool description and the `prompt` parameter description, so the fork tool stops lying about context inheritance. That fix created a cross-fiber data dependency: a tool's description is fixed at TOOL REGISTRATION (deliberately — the description is where tool-choice guidance lives), but the provider arrives on its own plugin fiber, on no particular schedule. The first implementation resolved the provider at the tool plugin's `apply` time and threw when it was absent — an implicit load-order requirement ("list the backend before the tool in cordis.yml"). Review reproduced the failure that requirement hides: the cordis Loader starts sibling entries CONCURRENTLY (`Promise.all` over the group) and `Entry.init()` does not await activation, so a backend whose activation is delayed leaves the tool's fiber permanently failed even when "listed first". The ordering the requirement leaned on is not a contract the Loader offers — "async state is not synchronous state" ([defensive patterns](../../../defensive-patterns.md)). diff --git a/docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.md b/docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.md index b2c39047b9..34bebd1194 100644 --- a/docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.md +++ b/docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.md @@ -17,7 +17,7 @@ Record two scenarios against the real API, both replayed keyless in the default ### Why a completed turn-1 is required -The fork backend seeds the child with the parent's **balanced completed-turn prefix** ([`completedTurnPrefix`](../../../../packages/subagent/subagent-fork)). A parent that forks on its very first turn has no completed turn to inherit, so the seed is empty (≡ a fresh spawn, `seedLength` 0) — which would NOT exercise the slice. Both scenarios therefore use a two-prompt input: the first prompt completes a turn (establishing a codeword the child is later asked to recall), the second delegates the fork. The recalled codeword in the child's transcript is incidental to the model's behavior; the load-bearing artifact is the child fixture's recorded `seedLength`, which the replay slice consumes. +The fork backend seeds the child with the parent's **balanced completed-turn prefix**. A parent that forks on its very first turn has no completed turn to inherit, so the seed is empty (≡ a fresh spawn, `seedLength` 0) — which would NOT exercise the slice. Both scenarios therefore use a two-prompt input: the first prompt completes a turn (establishing a codeword the child is later asked to recall), the second delegates the fork. The recalled codeword in the child's transcript is incidental to the model's behavior; the load-bearing artifact is the child fixture's recorded `seedLength`, which the replay slice consumes. ## Consequences diff --git a/packages/subagent/subagent-fork/README.md b/packages/subagent/subagent-fork/README.md index bf90ecdf52..0909dc0bf1 100644 --- a/packages/subagent/subagent-fork/README.md +++ b/packages/subagent/subagent-fork/README.md @@ -6,7 +6,7 @@ The fork provider creates an in-process child seeded with the parent's completed The parent's current tool-calling turn is still open when a subagent starts: its log contains the assistant tool call but not the matching tool result or `turn/end`. Copying that raw log would give the child an invalid, unbalanced session. -Fork therefore uses `completedTurnPrefix(parent.session.events)`: the contiguous prefix ending at the last `turn/end`. The child sees all completed parent turns and none of the in-flight turn. If the parent has not completed a turn yet, the seed is empty and the child behaves like a fresh spawn. +Fork therefore computes the contiguous prefix ending at the last `turn/end`. The child sees all completed parent turns and none of the in-flight turn. If the parent has not completed a turn yet, the seed is empty and the child behaves like a fresh spawn. The seed transfers conversation history only. The child still receives a fresh flat registration scope; it does not inherit the parent's tool restrictions or authority. diff --git a/packages/subagent/subagent-fork/src/index.ts b/packages/subagent/subagent-fork/src/index.ts index ebf64e7b70..22be47cc56 100644 --- a/packages/subagent/subagent-fork/src/index.ts +++ b/packages/subagent/subagent-fork/src/index.ts @@ -54,7 +54,7 @@ export const Config: z = z.object({ * @param parent - the agent whose session log to slice. * @returns the seed events, contiguous from seq 0; empty when no turn has completed. */ -export function completedTurnPrefix(parent: Agent): SessionEvent[] { +function completedTurnPrefix(parent: Agent): SessionEvent[] { const events = parent.session.events const lastEnd = events.findLast(e => e.type === 'turn/end') if (lastEnd === undefined) return [] diff --git a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts index 360098e369..bfb0bedac7 100644 --- a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts +++ b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts @@ -14,7 +14,6 @@ import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent import type { StreamChunk } from '@deepseek-ai/dsh-llm' import * as fork from '../src/index.ts' import { STRUCTURED_OUTPUT_TOOL } from '@deepseek-ai/dsh-subagent-inprocess' -import { completedTurnPrefix } from '../src/index.ts' type Script = ConstructorParameters[0] @@ -52,28 +51,6 @@ function text(blocks: { type: string; text?: string }[]): string { return blocks.filter(b => b.type === 'text').map(b => b.text).join('') } -describe('completedTurnPrefix', () => { - it('returns an empty prefix for a parent that has never completed a turn', async () => { - const { parent } = await setup([]) - expect(completedTurnPrefix(parent)).toEqual([]) - }) - - it('returns the balanced prefix up to and including the last turn/end', async () => { - const { parent } = await setup([textResponse('first'), textResponse('second')]) - parent.send([{ type: 'text', text: 'q1' }]) - await parent.whenIdle() - parent.send([{ type: 'text', text: 'q2' }]) - await parent.whenIdle() - - const prefix = completedTurnPrefix(parent) - // Ends exactly at the last turn/end; seq is contiguous from 0. - expect(prefix.at(-1)?.type).toBe('turn/end') - expect(prefix.map(e => e.seq)).toEqual(prefix.map((_, i) => i)) - // Both completed turns are present. - expect(prefix.filter(e => e.type === 'turn/end')).toHaveLength(2) - }) -}) - describe('dsh-subagent-fork', () => { it('emits subagent/start only after the seeded child is published', async () => { const { ctx, parent } = await setup([textResponse('child answer')]) @@ -96,7 +73,6 @@ describe('dsh-subagent-fork', () => { // The parent has never completed a turn → empty prefix → the provider omits // the seed → the child runs fresh. Exercises the `seed.length > 0` false arm. const { ctx, parent } = await setup([textResponse('fresh child')]) - expect(completedTurnPrefix(parent)).toEqual([]) const run = await start(ctx, 'fork', { prompt: [{ type: 'text', text: 'child q' }], parent }) const result = await run.result expect(result.stopReason).toBe('completed') @@ -104,6 +80,24 @@ describe('dsh-subagent-fork', () => { const child = ctx.agents.get(run.id)! // Only the child's own turn — no seeded parent turns. expect(child.session.events.filter(e => e.type === 'turn/end')).toHaveLength(1) + expect(child.session.header.seedLength).toBeUndefined() + await run.dispose() + }) + + it('seeds every completed parent turn through the last turn/end', async () => { + const { ctx, parent } = await setup([textResponse('first'), textResponse('second'), textResponse('child')]) + parent.send([{ type: 'text', text: 'q1' }]) + await parent.whenIdle() + parent.send([{ type: 'text', text: 'q2' }]) + await parent.whenIdle() + const parentPrefixLen = parent.session.events.length + + const run = await start(ctx, 'fork', { prompt: [{ type: 'text', text: 'child q' }], parent }) + await run.result + const child = ctx.agents.get(run.id)! + expect(child.session.header.seedLength).toBe(parentPrefixLen) + expect(child.session.events.slice(0, parentPrefixLen).at(-1)?.type).toBe('turn/end') + expect(child.session.events.slice(0, parentPrefixLen).filter(e => e.type === 'turn/end')).toHaveLength(2) await run.dispose() }) diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index d07e6726dd..426753dbb9 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -161,13 +161,13 @@ function stopReasonError(result: SubagentResult): string | undefined { * A fresh child needs a standalone prompt; a forked child already sees the * conversation's completed turns — telling the model to restate everything * (or, worse, that the child "does not see this conversation") would be false - * for a fork. Exported for tests. + * for a fork. * @param inheritsConversation - whether the child's conversation is seeded * with the parent's completed turns; this says nothing about tool, service, * scope, or authority inheritance. * @returns the tool `description` and the `prompt` parameter description. */ -export function providerWording(inheritsConversation: boolean): { description: string; promptDescription: string } { +function providerWording(inheritsConversation: boolean): { description: string; promptDescription: string } { if (inheritsConversation) { return { description: From 8ada835396a021a7dce8f72d6b025dcd3a264c15 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 08:03:33 +0800 Subject: [PATCH 04/18] docs: remove links to private subagent helpers --- packages/subagent/subagent-subprocess/src/index.ts | 12 +++++------- packages/subagent/tool-subagent/src/index.ts | 10 +++++----- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/packages/subagent/subagent-subprocess/src/index.ts b/packages/subagent/subagent-subprocess/src/index.ts index 2ee2745985..bd792e7f23 100644 --- a/packages/subagent/subagent-subprocess/src/index.ts +++ b/packages/subagent/subagent-subprocess/src/index.ts @@ -2,11 +2,10 @@ * Shared machinery for OUT-OF-PROCESS subagent backends — providers that spawn * an external agent as a child process and must keep the parent deployment's * credentials out of it, tear it down to quiescence, and isolate it from the - * host user's on-disk CLI state. The pieces: the credential env scrub - * ({@link SENSITIVE_ENV_PATTERN} / {@link buildChildEnv}), the spawn-failure - * capture ({@link spawnFailure}), the child-exit waits ({@link waitForExit} / - * {@link exitsWithin}), the stdin-EOF → SIGTERM → SIGKILL dispose ladder - * ({@link disposeChildProcess}), and the per-run isolated config dir + * host user's on-disk CLI state. The pieces: credential-shaped env scrubbing + * ({@link buildChildEnv}), spawn-failure capture ({@link spawnFailure}), + * bounded child-exit waits inside the stdin-EOF → SIGTERM → SIGKILL dispose + * ladder ({@link disposeChildProcess}), and the per-run isolated config dir * ({@link createIsolatedConfigDir}). * * This package owns no provider and registers nothing; it is a pure library @@ -37,8 +36,7 @@ const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i /** * The ambient env minus credential-shaped vars, plus the caller's explicit * env. `PATH`, `HOME`, `TMPDIR`, locale, and proxy vars survive the scrub, so - * a child CLI runs normally; only {@link SENSITIVE_ENV_PATTERN}-shaped names - * are dropped. + * a child CLI runs normally; only credential-shaped names are dropped. * @param extra - explicit vars layered on top AFTER the scrub, so a * credential-shaped name supplied deliberately still reaches the child. * @returns the environment to spawn the child with. diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 426753dbb9..ff54bc109a 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -12,11 +12,11 @@ * sees only `{ description, prompt }`. * * The tool DESCRIPTION is derived from the bound provider's conversation-history - * descriptor ({@link providerWording}): a fresh-conversation provider (spawn, - * ACP) gets the standalone-prompt wording, while a seeded-conversation provider - * (fork) tells the model the child already sees the conversation's completed - * turns. This descriptor says nothing about Cordis scope, services, tools, or - * authority. The tool MIRRORS the + * descriptor ({@link SubagentProvider.inheritsParentContext}): a + * fresh-conversation provider (spawn, ACP) gets the standalone-prompt wording, + * while a seeded-conversation provider (fork) tells the model the child already + * sees the conversation's completed turns. This descriptor says nothing about + * Cordis scope, services, tools, or authority. The tool MIRRORS the * provider's lifecycle via `subagent/provider-added`/`-removed` — it registers * when the provider is (or becomes) available and unregisters when the * provider goes away — so no load-order requirement exists and an HMR reload From d33a819d15964fac1212f7b716f50106c8bb0ed1 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 08:55:38 +0800 Subject: [PATCH 05/18] fix: share scope carrier across built JSON-RPC --- AGENTS.md | 2 +- docs/testing.md | 2 +- packages/ui/jsonrpc/package.json | 2 + .../jsonrpc/tests/built-scope-carrier.e2e.ts | 121 ++++++++++++++++++ pnpm-lock.yaml | 3 + scripts/run-gates.ts | 1 + 6 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts diff --git a/AGENTS.md b/AGENTS.md index 4dfbedb97b..1494d3fcf8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,7 +76,7 @@ printf '%s\n' "$out" | grep -q '\[tool call\] echo({"text":"ci smoke"})' printf '%s\n' "$out" | grep -q '\[tool result\] ECHO: CI SMOKE' test -n "$(find .sessions -path '.sessions/cwd-*/main-session-*.jsonl' -type f -print -quit)" rm -rf .sessions -pnpm exec vitest run --config vitest.e2e.config.ts packages/ui/stdio-agent/tests/built-bin.e2e.ts packages/ui/acp-agent/tests/built-bin.e2e.ts packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts +pnpm exec vitest run --config vitest.e2e.config.ts packages/ui/stdio-agent/tests/built-bin.e2e.ts packages/ui/acp-agent/tests/built-bin.e2e.ts packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts ``` `test:coverage`, not `test`, is the gating run ([why](docs/testing.md)); a sign-off counts only for commands actually run. diff --git a/docs/testing.md b/docs/testing.md index 23cca651fe..a1a841296a 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -25,7 +25,7 @@ An e2e assertion re-runs the command or re-reads the file externally; a keyword - A plugin shipped via `cordis.yml` needs at least one test through the REAL Loader path: hand-built `ctx.plugin({...})` mounts bypass `unwrapExports` and cannot catch a broken export shape ([postmortem 0001](postmortem/0001-acp-default-export-drops-inject.md); export-shape rules in [packages/AGENTS.md](../packages/AGENTS.md)). - A guard only guards if the regression actually fails it. For a plugin without `inject` (bundle/composition plugins), a Loader smoke stays green under a broken export shape — add an explicit `expect('default' in mod).toBe(false)` plus an `unwrapExports` round-trip assertion, and prove it: introduce the regression, watch red, revert. -- "Real entry path" means the published artifact: the package `bin` points at built `lib/bin.js` under plain `node`, which tsx masks (settle races, module resolution, a swallowed load failure exiting 0). The same applies to any non-index runtime entry the built package resolves at run time (the worker-thread runtime's sibling `lib/worker.cjs`). Keep the built-artifact smokes green (`packages/ui/*/tests/built-bin.e2e.ts`, `packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts`), and assert a genuinely-missing config exits non-zero. +- "Real entry path" means the published artifact: a package `bin` runs built `lib/bin.js` under plain `node`, exposing failures tsx masks (settle races, module resolution, swallowed load failures). The same applies to non-index runtime entries (the worker-thread sibling `lib/worker.cjs`) and singleton modules shared across bundles (`packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts`). Keep the built-artifact smokes green (`packages/ui/*/tests/built-bin.e2e.ts`, `packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts`), and assert a genuinely-missing config exits non-zero. - An e2e that spawns an example from a temp cwd sets `TSX_TSCONFIG_PATH` to the repo-root tsconfig, or it silently falls back to stale built `lib/` ([examples/AGENTS.md](../examples/AGENTS.md)). ## When a snapshot test is required diff --git a/packages/ui/jsonrpc/package.json b/packages/ui/jsonrpc/package.json index be19ab7217..bf4fe39118 100644 --- a/packages/ui/jsonrpc/package.json +++ b/packages/ui/jsonrpc/package.json @@ -28,6 +28,7 @@ "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-llm-deepseek": "^0.0.1", + "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", "cordis": "^4.0.0-rc.6" @@ -38,6 +39,7 @@ "@deepseek-ai/dsh-agent-core": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", diff --git a/packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts b/packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts new file mode 100644 index 0000000000..159600e49c --- /dev/null +++ b/packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts @@ -0,0 +1,121 @@ +/** + * Built-artifact guard for the scope carrier shared by `dsh-subagent` and + * `dsh-jsonrpc`. The carrier registry is module-local, so both bundles must + * externalize `dsh-scope`; source-mode tests cannot expose an accidentally + * inlined second registry. This test runs the real `lib/index.js` bundles in a + * plain Node subprocess, disposes the child before settlement, and requires the + * SDK completion notification to retain the delegating parent. + */ + +import { execFile } from 'node:child_process' +import { existsSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import { describe, expect, it } from 'vitest' + +const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) +const jsonrpcBundle = fileURLToPath(new URL('../lib/index.js', import.meta.url)) +const execFileAsync = promisify(execFile) + +const builtRuntimeProbe = String.raw` +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +const load = (path) => import(pathToFileURL(resolve(path)).href); +const [ + { Context }, + agentCore, + { default: SubagentService }, + { default: SessionPersistenceJsonl }, + { HarnessSdkServer }, + { SessionId }, +] = await Promise.all([ + load("vendor/cordis/lib/index.js"), + load("packages/core/agent-core/lib/index.js"), + load("packages/subagent/subagent/lib/index.js"), + load("packages/session-persistence/session-persistence-jsonl/lib/index.js"), + load("packages/ui/jsonrpc/lib/index.js"), + load("packages/core/session/lib/index.js"), +]); + +const storageRoot = await mkdtemp(join(tmpdir(), "jsonrpc-built-scope-")); +const ctx = new Context(); +try { + await ctx.plugin(agentCore); + await ctx.plugin(SubagentService); + await ctx.plugin(SessionPersistenceJsonl, { root: storageRoot }); + await new Promise((ready) => setTimeout(ready, 50)); + + const notifications = []; + const server = new HarnessSdkServer(ctx, { + request() { return Promise.reject(new Error("unexpected host request")); }, + notify(method, params) { notifications.push({ method, params }); }, + }); + const parent = await ctx.agents.create({ + sessionId: SessionId("built-parent"), + meta: { cwd: storageRoot }, + agentOptions: { model: "test" }, + }); + const child = await ctx.agents.create({ + sessionId: SessionId("built-child"), + meta: { cwd: storageRoot, parentSession: SessionId("built-parent") }, + agentOptions: { model: "test" }, + }); + const result = Promise.withResolvers(); + const unregister = ctx.subagents.registerProvider({ + name: "built-local", + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + inheritsParentContext: false, + start() { + return Promise.resolve({ + id: child.agent.id, + result: result.promise, + dispose() { return Promise.resolve(); }, + }); + }, + }); + const run = await ctx.subagents.start("built-local", { + parent: parent.agent, + prompt: [], + signal: new AbortController().signal, + }); + await child.dispose(); + result.resolve({ output: [], stopReason: "completed" }); + await run.result; + await Promise.resolve(); + + console.log(JSON.stringify(notifications.filter(({ method }) => method === "subagent.finished"))); + await run.dispose(); + unregister(); + await parent.dispose(); + await server.shutdown(); +} finally { + await ctx.fiber.dispose(); + await rm(storageRoot, { recursive: true, force: true }); +} +` + +describe.skipIf(!existsSync(jsonrpcBundle))('dsh-jsonrpc BUILT scope carrier', () => { + it('preserves parent-scoped completion after child disposal', async () => { + const { stdout, stderr } = await execFileAsync(process.execPath, ['--input-type=module', '-e', builtRuntimeProbe], { + cwd: repoRoot, + timeout: 15_000, + }) + + expect(stderr).not.toContain('listener threw') + expect(JSON.parse(stdout) as unknown).toEqual([{ + method: 'subagent.finished', + params: { + provider: 'built-local', + agentId: 'built-child', + parentSessionId: 'built-parent', + childSessionId: 'built-child', + status: 'ok', + stopReason: 'completed', + lastAssistantMessage: [], + }, + }]) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6214564eae..dec8c3024f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1288,6 +1288,9 @@ importers: '@deepseek-ai/dsh-llm-deepseek': specifier: workspace:^ version: link:../../llm/llm-deepseek + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../core/scope '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 131501abb0..e1c41149ca 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -332,6 +332,7 @@ function builtBinSmokeGate(): Gate { 'vitest.e2e.config.ts', 'packages/ui/stdio-agent/tests/built-bin.e2e.ts', 'packages/ui/acp-agent/tests/built-bin.e2e.ts', + 'packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts', // The worker-entry packages' built bundles: the only automated proof // that lib/index.js resolves its sibling lib/worker.cjs under plain node // (the e2e lane runs unbuilt, so these files self-skip there). From 39c6c1120abdcc0414325bbdd1624917b0cb8624 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 08:56:21 +0800 Subject: [PATCH 06/18] Revert "fix: share scope carrier across built JSON-RPC" This reverts commit 835fd3ca4f46e0c6464098e3d0864dc56f31398d. --- AGENTS.md | 2 +- docs/testing.md | 2 +- packages/ui/jsonrpc/package.json | 2 - .../jsonrpc/tests/built-scope-carrier.e2e.ts | 121 ------------------ pnpm-lock.yaml | 3 - scripts/run-gates.ts | 1 - 6 files changed, 2 insertions(+), 129 deletions(-) delete mode 100644 packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts diff --git a/AGENTS.md b/AGENTS.md index 1494d3fcf8..4dfbedb97b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,7 +76,7 @@ printf '%s\n' "$out" | grep -q '\[tool call\] echo({"text":"ci smoke"})' printf '%s\n' "$out" | grep -q '\[tool result\] ECHO: CI SMOKE' test -n "$(find .sessions -path '.sessions/cwd-*/main-session-*.jsonl' -type f -print -quit)" rm -rf .sessions -pnpm exec vitest run --config vitest.e2e.config.ts packages/ui/stdio-agent/tests/built-bin.e2e.ts packages/ui/acp-agent/tests/built-bin.e2e.ts packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts +pnpm exec vitest run --config vitest.e2e.config.ts packages/ui/stdio-agent/tests/built-bin.e2e.ts packages/ui/acp-agent/tests/built-bin.e2e.ts packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts ``` `test:coverage`, not `test`, is the gating run ([why](docs/testing.md)); a sign-off counts only for commands actually run. diff --git a/docs/testing.md b/docs/testing.md index a1a841296a..23cca651fe 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -25,7 +25,7 @@ An e2e assertion re-runs the command or re-reads the file externally; a keyword - A plugin shipped via `cordis.yml` needs at least one test through the REAL Loader path: hand-built `ctx.plugin({...})` mounts bypass `unwrapExports` and cannot catch a broken export shape ([postmortem 0001](postmortem/0001-acp-default-export-drops-inject.md); export-shape rules in [packages/AGENTS.md](../packages/AGENTS.md)). - A guard only guards if the regression actually fails it. For a plugin without `inject` (bundle/composition plugins), a Loader smoke stays green under a broken export shape — add an explicit `expect('default' in mod).toBe(false)` plus an `unwrapExports` round-trip assertion, and prove it: introduce the regression, watch red, revert. -- "Real entry path" means the published artifact: a package `bin` runs built `lib/bin.js` under plain `node`, exposing failures tsx masks (settle races, module resolution, swallowed load failures). The same applies to non-index runtime entries (the worker-thread sibling `lib/worker.cjs`) and singleton modules shared across bundles (`packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts`). Keep the built-artifact smokes green (`packages/ui/*/tests/built-bin.e2e.ts`, `packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts`), and assert a genuinely-missing config exits non-zero. +- "Real entry path" means the published artifact: the package `bin` points at built `lib/bin.js` under plain `node`, which tsx masks (settle races, module resolution, a swallowed load failure exiting 0). The same applies to any non-index runtime entry the built package resolves at run time (the worker-thread runtime's sibling `lib/worker.cjs`). Keep the built-artifact smokes green (`packages/ui/*/tests/built-bin.e2e.ts`, `packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts`), and assert a genuinely-missing config exits non-zero. - An e2e that spawns an example from a temp cwd sets `TSX_TSCONFIG_PATH` to the repo-root tsconfig, or it silently falls back to stale built `lib/` ([examples/AGENTS.md](../examples/AGENTS.md)). ## When a snapshot test is required diff --git a/packages/ui/jsonrpc/package.json b/packages/ui/jsonrpc/package.json index bf4fe39118..be19ab7217 100644 --- a/packages/ui/jsonrpc/package.json +++ b/packages/ui/jsonrpc/package.json @@ -28,7 +28,6 @@ "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-llm-deepseek": "^0.0.1", - "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", "cordis": "^4.0.0-rc.6" @@ -39,7 +38,6 @@ "@deepseek-ai/dsh-agent-core": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", - "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", diff --git a/packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts b/packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts deleted file mode 100644 index 159600e49c..0000000000 --- a/packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts +++ /dev/null @@ -1,121 +0,0 @@ -/** - * Built-artifact guard for the scope carrier shared by `dsh-subagent` and - * `dsh-jsonrpc`. The carrier registry is module-local, so both bundles must - * externalize `dsh-scope`; source-mode tests cannot expose an accidentally - * inlined second registry. This test runs the real `lib/index.js` bundles in a - * plain Node subprocess, disposes the child before settlement, and requires the - * SDK completion notification to retain the delegating parent. - */ - -import { execFile } from 'node:child_process' -import { existsSync } from 'node:fs' -import { fileURLToPath } from 'node:url' -import { promisify } from 'node:util' -import { describe, expect, it } from 'vitest' - -const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) -const jsonrpcBundle = fileURLToPath(new URL('../lib/index.js', import.meta.url)) -const execFileAsync = promisify(execFile) - -const builtRuntimeProbe = String.raw` -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; -import { pathToFileURL } from "node:url"; - -const load = (path) => import(pathToFileURL(resolve(path)).href); -const [ - { Context }, - agentCore, - { default: SubagentService }, - { default: SessionPersistenceJsonl }, - { HarnessSdkServer }, - { SessionId }, -] = await Promise.all([ - load("vendor/cordis/lib/index.js"), - load("packages/core/agent-core/lib/index.js"), - load("packages/subagent/subagent/lib/index.js"), - load("packages/session-persistence/session-persistence-jsonl/lib/index.js"), - load("packages/ui/jsonrpc/lib/index.js"), - load("packages/core/session/lib/index.js"), -]); - -const storageRoot = await mkdtemp(join(tmpdir(), "jsonrpc-built-scope-")); -const ctx = new Context(); -try { - await ctx.plugin(agentCore); - await ctx.plugin(SubagentService); - await ctx.plugin(SessionPersistenceJsonl, { root: storageRoot }); - await new Promise((ready) => setTimeout(ready, 50)); - - const notifications = []; - const server = new HarnessSdkServer(ctx, { - request() { return Promise.reject(new Error("unexpected host request")); }, - notify(method, params) { notifications.push({ method, params }); }, - }); - const parent = await ctx.agents.create({ - sessionId: SessionId("built-parent"), - meta: { cwd: storageRoot }, - agentOptions: { model: "test" }, - }); - const child = await ctx.agents.create({ - sessionId: SessionId("built-child"), - meta: { cwd: storageRoot, parentSession: SessionId("built-parent") }, - agentOptions: { model: "test" }, - }); - const result = Promise.withResolvers(); - const unregister = ctx.subagents.registerProvider({ - name: "built-local", - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, - inheritsParentContext: false, - start() { - return Promise.resolve({ - id: child.agent.id, - result: result.promise, - dispose() { return Promise.resolve(); }, - }); - }, - }); - const run = await ctx.subagents.start("built-local", { - parent: parent.agent, - prompt: [], - signal: new AbortController().signal, - }); - await child.dispose(); - result.resolve({ output: [], stopReason: "completed" }); - await run.result; - await Promise.resolve(); - - console.log(JSON.stringify(notifications.filter(({ method }) => method === "subagent.finished"))); - await run.dispose(); - unregister(); - await parent.dispose(); - await server.shutdown(); -} finally { - await ctx.fiber.dispose(); - await rm(storageRoot, { recursive: true, force: true }); -} -` - -describe.skipIf(!existsSync(jsonrpcBundle))('dsh-jsonrpc BUILT scope carrier', () => { - it('preserves parent-scoped completion after child disposal', async () => { - const { stdout, stderr } = await execFileAsync(process.execPath, ['--input-type=module', '-e', builtRuntimeProbe], { - cwd: repoRoot, - timeout: 15_000, - }) - - expect(stderr).not.toContain('listener threw') - expect(JSON.parse(stdout) as unknown).toEqual([{ - method: 'subagent.finished', - params: { - provider: 'built-local', - agentId: 'built-child', - parentSessionId: 'built-parent', - childSessionId: 'built-child', - status: 'ok', - stopReason: 'completed', - lastAssistantMessage: [], - }, - }]) - }) -}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dec8c3024f..6214564eae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1288,9 +1288,6 @@ importers: '@deepseek-ai/dsh-llm-deepseek': specifier: workspace:^ version: link:../../llm/llm-deepseek - '@deepseek-ai/dsh-scope': - specifier: workspace:^ - version: link:../../core/scope '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index e1c41149ca..131501abb0 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -332,7 +332,6 @@ function builtBinSmokeGate(): Gate { 'vitest.e2e.config.ts', 'packages/ui/stdio-agent/tests/built-bin.e2e.ts', 'packages/ui/acp-agent/tests/built-bin.e2e.ts', - 'packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts', // The worker-entry packages' built bundles: the only automated proof // that lib/index.js resolves its sibling lib/worker.cjs under plain node // (the e2e lane runs unbuilt, so these files self-skip there). From ba8a5c89ed4390b4b1679b736f2c44d79bdbaab9 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:29:02 +0800 Subject: [PATCH 07/18] docs: name the public agent injection seam --- packages/bash/tool-bash/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/bash/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts index fe07b80989..cda683c5b4 100644 --- a/packages/bash/tool-bash/src/index.ts +++ b/packages/bash/tool-bash/src/index.ts @@ -457,7 +457,7 @@ export function apply(ctx: Context): void { ) } catch (error: unknown) { // The ONE expected failure: the agent was disposed between task - // completion and this injection (ReactLoopAgent.inject throws + // completion and this injection (Agent.inject throws // `agent "" is disposed`). That race is benign — drop the notice. // Anything else is a real bug and must surface, not be swallowed. if (error instanceof Error && error.message.includes('is disposed')) return From 628006889e07846af0816a127683b3e5aa5f2b5c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:51:21 +0800 Subject: [PATCH 08/18] docs: keep agent-loop map concise --- docs/architecture.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/architecture.md b/docs/architecture.md index 0e83cba072..89bb27604b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -17,7 +17,7 @@ A harness is one [Cordis](cordis-primer.md) context. Packages contribute service | `ctx.systemPrompt` | `dsh-system-prompt` | ordered prompt sections, tool schemas, and prompt variables | | `ctx.tools` | `dsh-tools` | tool registry and [execution pipeline](tool-execution-pipeline.md) | | `ctx.agents` | `dsh-agent` | live agent registry, public `Agent` handle, `agent/*` events | -| `ctx.agentLoop` | `dsh-agent-loop` | shipped concrete `Agent` driver | +| `ctx.agentLoop` | `dsh-agent-loop` | concrete `Agent` driver | ### Capability Services From 4a5463cfc49b8f505b5f47aa7634909c08602a08 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:53:38 +0800 Subject: [PATCH 09/18] test: use the public agent type --- packages/core/agent-loop/tests/config-session-id.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index f001fa3154..2c8a5c31bd 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -145,7 +145,7 @@ describe('config-driven session id', () => { const config = { agents: [{ id: 'main', sessionId, model: 'mock' }] } const firstLoop = await ctx.plugin(AgentLoop, config) await expect.poll(() => ctx.agents.get(sessionId)).toBeDefined() - const first = ctx.agents.get(sessionId) as ReactLoopAgent + const first = ctx.agents.get(sessionId) as Agent const flushGate = Promise.withResolvers() ctx.on('session/flush', (session) => { From 64a3933270a0f4636b36b485197597c66945599b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 19:03:07 +0800 Subject: [PATCH 10/18] docs: refresh config catalog after parent merge --- docs/config-catalog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 14cc973995..6f74792213 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -899,7 +899,7 @@ export interface Config { Depends on: [`AgentOptions`](../packages/core/agent/src/index.ts) -Source: [`packages/subagent/tool-subagent/src/index.ts:24`](../packages/subagent/tool-subagent/src/index.ts) +Source: [`packages/subagent/tool-subagent/src/index.ts:47`](../packages/subagent/tool-subagent/src/index.ts) ## `@deepseek-ai/dsh-tool-web` From ed3654da00fd14597b79e8e97bf61f09c5981ced Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:38:53 +0800 Subject: [PATCH 11/18] docs: reconcile hidden internals with prose standard --- ...t-variables-and-tool-guidance-ownership.md | 16 ++--- .../subagent/subagent-subprocess/src/index.ts | 61 +++++-------------- packages/subagent/tool-subagent/src/index.ts | 49 +++------------ 3 files changed, 31 insertions(+), 95 deletions(-) diff --git a/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md b/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md index aae4564513..854807c109 100644 --- a/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md +++ b/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md @@ -24,21 +24,21 @@ The assembled system prompt had four defects, all of one family: facts the harne ### Prompt variables -Plugins contribute named values via `ctx.systemPrompt.variable(name, provider)`; prompt text references them as `{{name}}`. Providers are functions of the `AssembleContext` and may return `undefined` — "no value for THIS assembly". `assemble()` resolves every registered variable into `PromptAssembly.variables` (waterfall listeners can see, add, or override); `renderPrompt` interpolates. Rendering is STRICT — fail loud beats shipping a malformed prompt: a reference to an unregistered name throws (listing what exists; lookup is `Object.hasOwn`, so a prototype property like `{{constructor}}` is unknown, not a function spliced into the prompt), a registered-but-valueless reference throws, a complete `{{…}}` group that is not a well-formed name (`[a-z][a-z0-9_]*`, e.g. `{{ model }}`) throws, and a `{{` that opens no complete group while a `}}` still follows (`{{{model}}}`, `{{a{b}}`) throws. A lone `{{` with no `}}` anywhere after it is ordinary prose and passes through verbatim; substituted values are never re-scanned. Registration rejects duplicate and unreferenceable names, mirroring the tool registry — and `section()` now rejects duplicate section names, making the documented dedup real. +Plugins register `{{name}}` values through `ctx.systemPrompt.variable(name, provider)`. Assembly resolves them into the waterfall-visible variable map. Rendering rejects unknown own-property references, registered providers that return `undefined`, malformed complete references, and unbalanced references that still contain a closing `}}`; a lone unmatched `{{` remains prose, and substituted values are not rescanned. Registration rejects invalid or duplicate variable names, and section names are unique. `dsh-agent-loop` registers the two built-ins, both pure projections of the context agent: `model` (= `options.model`) and `cwd` (= `session.header.cwd`). The example personas write `powered by the {{model}} model` — the model name is stated once, in the `model:` config key. `{{cwd}}` is demonstrated in the ACP example only: every ACP session carries the client's cwd, while config-pre-created stdio agents have none (a persona claiming `{{cwd}}` there fails the turn — by design). The variables stay on the loop plugin (unlike the sections below): they are runtime facts of the agents THIS loop drives, and a replacement loop supplies its own. ### Persona as the order-0 section -`dsh-system-prompt` itself registers the two harness-owned sections (they must survive a swapped loop plugin, so they do NOT live on `dsh-agent-loop`): the static `harness:identity` at order `-100` — every prompt opens by stating the agent is powered by the DeepSeek Harness SDK — and the global default `deployment:persona` at order 0, whose text is the plugin's own `persona` config. `AgentOptions.systemPrompt` and the loop's special-case join are gone: `fullSystemPrompt ≡ renderPrompt(assembly)`, one ordered pipeline for everything the model sees, and `agent/pre-step` (compaction's token-pressure input) measures exactly the real prompt. An agent-scoped section with the same `deployment:persona` name shadows the default for that agent; programmatic setup may register one directly, and the subagent persona feature installs one before publishing an in-process child when the selected provider supports it. Order bands are convention: harness identity `-100`, persona `0`, tool guidance `100–199`; other negative orders also render before the persona. +`dsh-system-prompt` owns `harness:identity` at order `-100` and the configured `deployment:persona` at order 0, so both survive a replacement loop. Prompt rendering has one path, `renderPrompt(assembly)`, and `agent/pre-step` therefore measures the exact prompt used for compaction. An agent-scoped `deployment:persona` shadows the global default and lets subagent providers install a persona before publication. The conventional order bands are identity `-100`, persona `0`, and tool guidance `100–199`. ### Tool guidance ownership -Per-tool semantics and when-to-use live in tool DESCRIPTIONS, which already ship in every request — the YAML prose was ~fully redundant with them. Sections carry only the cross-call habits a single call's description cannot: `dsh-tool-bash` contributes `tool:bash` (order 105) — check the `[exit code: N]` marker on every result; `dsh-tool-fs`'s read section gains the "not shell commands like cat" contrast. `todo_write` and the subagent tools need NO section — their descriptions already carry the whole contract. The leaf personas shrink to identity + behavior (verify your work; keep answers brief), and the welcome banner stops enumerating tools. +Per-tool semantics and selection guidance live in tool descriptions. Prompt sections carry only cross-call habits, such as checking bash exit markers or preferring filesystem tools over shell commands. `todo_write` and subagent tools need no section because their descriptions contain the full contract. Deployment personas contain only role and behavior. ### The subagent conversation-history descriptor -`SubagentProvider` gains `readonly inheritsParentContext: boolean` — a DESCRIPTIVE conversation-history fact beside `capabilities`, not in it (capabilities are start-time validation; nothing validates against this flag). Spawn and ACP declare `false`, fork declares `true`. The name refers only to conversation seeding, not Cordis scope, services, tools, or authority. `dsh-tool-subagent` derives both the tool description and the `prompt` parameter description from the flag: the fork instance now tells the model the child is seeded with the conversation's completed turns (not the in-flight turn) and that its prompt should state only what is new. Deriving the description from a provider that arrives on its own fiber is what forced the provider-lifecycle events and the tool's reactive registration — that mechanism, its Loader-concurrency rationale, and its rejected alternatives are recorded in [the provider-lifecycle-events RFC](2026-07-05-subagent-provider-lifecycle-events.md). +`SubagentProvider.inheritsParentContext` describes conversation seeding, not scope, services, tools, or authority. Spawn and ACP set it to `false`; fork sets it to `true`. `dsh-tool-subagent` derives its tool and prompt-parameter descriptions from the flag, including that fork inherits completed turns but not the in-flight turn. Provider lifecycle events keep that wording synchronized with reactive provider registration; their rationale lives in the [provider-lifecycle-events RFC](2026-07-05-subagent-provider-lifecycle-events.md). ## Alternatives considered @@ -56,10 +56,10 @@ Per-tool semantics and when-to-use live in tool DESCRIPTIONS, which already ship ## Shipped invariants -- `renderPrompt(await assemble(assembleContextFor(agent)))` for the coding-agent example renders the harness identity, then the persona (with the agent's model name interpolated), then the fs/bash/web guidance sections; the loop has no other prompt-composition path. -- The `subagent_fork` schema description says the child inherits the conversation; the `subagent` one says it does not. The tool follows its provider: absent before the backend activates, present after, gone when the backend unloads, re-worded from the fresh provider on reload. -- Unknown/valueless/malformed/unbalanced `{{…}}` references throw with the section name in the message; duplicate section, variable, and tool-name registrations all throw. -- Snapshot goldens are prompt-independent by construction: llm-replay keys replay on (turn, step) chunk streams and never re-verifies the outgoing request. +- The coding-agent prompt renders identity, persona with the interpolated model, then fs/bash/web guidance through one assembly path. +- Fork and fresh subagent descriptions reflect whether the provider inherits completed conversation turns; the tool appears, disappears, and is reworded with provider lifecycle changes. +- Unknown, valueless, malformed, or unbalanced variable references name the section and throw; duplicate section, variable, and tool registrations also throw. +- Snapshot replay is prompt-independent: it keys recorded chunk streams by turn and step without comparing the outgoing request. ## Consequences diff --git a/packages/subagent/subagent-subprocess/src/index.ts b/packages/subagent/subagent-subprocess/src/index.ts index bd792e7f23..3831d2bb6a 100644 --- a/packages/subagent/subagent-subprocess/src/index.ts +++ b/packages/subagent/subagent-subprocess/src/index.ts @@ -1,19 +1,8 @@ /** - * Shared machinery for OUT-OF-PROCESS subagent backends — providers that spawn - * an external agent as a child process and must keep the parent deployment's - * credentials out of it, tear it down to quiescence, and isolate it from the - * host user's on-disk CLI state. The pieces: credential-shaped env scrubbing - * ({@link buildChildEnv}), spawn-failure capture ({@link spawnFailure}), - * bounded child-exit waits inside the stdin-EOF → SIGTERM → SIGKILL dispose - * ladder ({@link disposeChildProcess}), and the per-run isolated config dir - * ({@link createIsolatedConfigDir}). - * - * This package owns no provider and registers nothing; it is a pure library - * the out-of-process backend packages depend on (the `subagent-inprocess` - * shape, for the process boundary). Every tunable — the ladder's grace - * periods, a pinned config dir — is a PARAMETER here: defaults belong in each - * consuming plugin's Config, per the no-hardcoded-tunables rule. - * + * Shared machinery for OUT-OF-PROCESS subagent backends — providers that spawn an external + * agent as a child process and must keep the parent deployment's credentials out of it, tear + * it down to quiescence, and isolate it from the host user's on-disk CLI state. This package + * registers no provider; consuming plugins own and validate every timing or path default. * @module @deepseek-ai/dsh-subagent-subprocess */ @@ -50,11 +39,8 @@ export function buildChildEnv(extra: Record): NodeJS.ProcessEnv } /** - * Capture the child's spawn-level failure as a promise the run's result path - * can race. A spawn failure (e.g. `ENOENT` for a bad command) is emitted as an - * `error` EVENT, not a thrown exception — and without a listener Node treats - * it as an unhandled error and crashes the parent process. Call this in the - * SAME TICK as `spawn()`, so no window exists for the event to fire unheard. + * Capture the child's spawn-level `error` event as a promise. Call in the same tick as + * `spawn()`; otherwise an early event can be unhandled and crash the parent. * @param child - the just-spawned child process. * @returns a promise that RESOLVES (never rejects) with the child's first * `error` event; for a child that spawns cleanly it never settles. @@ -123,15 +109,8 @@ export interface DisposeLadderGraces { } /** - * Tear a child process down to QUIESCENCE: resolves only once the child has - * actually exited (or was already gone), never merely after requesting it. - * Three-tier escalation — - * - * 1. stdin EOF (when stdin is piped), then wait `disposeEofGraceMs`: a - * cooperative child quiesces on its own, its teardown and flushes intact; - * 2. `SIGTERM`, then wait `disposeGraceMs`; - * 3. `SIGKILL`, then await the (now-certain) exit — a child that ignores EOF - * and traps `SIGTERM` must not wedge dispose forever. + * Tear a child process down to quiescence, resolving only after exit: close stdin and allow + * cooperative flush, then send `SIGTERM`, then `SIGKILL` and await the forced exit. * * @param child - the child process to tear down. * @param graces - the two grace periods, from the consuming plugin's Config. @@ -139,10 +118,7 @@ export interface DisposeLadderGraces { export async function disposeChildProcess(child: ChildProcess, graces: DisposeLadderGraces): Promise { // Already gone: nothing to reap. if (child.exitCode !== null || child.signalCode !== null) return - // 1. Graceful: end the request stream (stdin EOF) and let the child quiesce - // on its own. Sending SIGTERM in the same tick (or too soon) would - // default-terminate a cooperative child mid-flush, orphaning its nested - // work. A child spawned without a stdin pipe skips straight to the wait. + // 1. Close stdin and allow cooperative teardown and durable-state flush. child.stdin?.end() if (await exitsWithin(child, graces.disposeEofGraceMs)) return // 2. SIGTERM, escalating if the child still does not exit within the grace. @@ -171,16 +147,9 @@ export interface IsolatedConfigDir { } /** - * An isolated config dir for one child run, so the child's behavior is a - * function of deployment config alone — never of whatever `~/.claude` / - * `~/.codex`-style state happens to exist on the host machine. Two modes: - * - * - no `pinnedPath` (the default): creates a FRESH private (0700) `mkdtemp` - * dir under the OS temp root; {@link IsolatedConfigDir.remove} deletes it - * best-effort; - * - `pinnedPath` set (a deployment deliberately sharing state across runs): - * the pinned path is returned as-is — never created, never removed — the - * deployment owns that directory's lifecycle. + * An isolated config dir for one child run, independent of host CLI state. Without + * `pinnedPath`, creates a private temp directory and removes it best-effort; a pinned directory + * is returned unchanged and remains deployment-owned. * * @param prefix - the `mkdtemp` name prefix for a fresh dir (e.g. * `dsh-subagent-codex-`); ignored when `pinnedPath` is set. @@ -207,10 +176,8 @@ export async function createIsolatedConfigDir(prefix: string, pinnedPath?: strin try { await rm(path, { recursive: true, force: true }) } catch { - // Best-effort by contract: swallows rm failures (EACCES/EBUSY-style — - // e.g. the dead child left an unreadable entry behind). The dir lives - // under the OS temp root, which reclaims it; failing dispose over - // cleanup would be worse than a leftover temp dir. + // Best-effort by contract: swallows rm failures (EACCES/EBUSY-style — e.g. the dead + // child left an unreadable entry behind). } }, } diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index ff54bc109a..59821147af 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -1,34 +1,11 @@ /** - * The model-facing `subagent` tool: delegate a task to a child agent and return - * its final output. Pure schema + lifecycle shaping — every transport concern - * lives behind the `ctx.subagents` provider registry - * (`@deepseek-ai/dsh-subagent`), so an in-process, ACP, or future A2A backend - * swaps in without touching what the model sees. - * - * Provider selection is config, not model-facing: this plugin is bound to - * EXACTLY ONE provider name (`Config.provider`). To expose more than one - * transport, load the plugin more than once, each bound to a different provider - * — there is no provider/type parameter in the model-facing schema. The model - * sees only `{ description, prompt }`. - * - * The tool DESCRIPTION is derived from the bound provider's conversation-history - * descriptor ({@link SubagentProvider.inheritsParentContext}): a - * fresh-conversation provider (spawn, ACP) gets the standalone-prompt wording, - * while a seeded-conversation provider (fork) tells the model the child already - * sees the conversation's completed turns. This descriptor says nothing about - * Cordis scope, services, tools, or authority. The tool MIRRORS the - * provider's lifecycle via `subagent/provider-added`/`-removed` — it registers - * when the provider is (or becomes) available and unregisters when the - * provider goes away — so no load-order requirement exists and an HMR reload - * of the backend re-derives the wording from the fresh provider. - * - * Collection is SYNCHRONOUS this cut: `execute` starts a run and awaits - * `run.result` inside a `try/finally` that always disposes the run, so the - * owned child agent/session is torn down on every path (success, error, abort) - * and never leaks as a live idle child. A non-`completed` stop reason maps to an - * `isError` tool result (by throwing) rather than returning partial output as - * success. + * Model-facing delegation tool bound by configuration to one provider; transport selection is not + * exposed in its `{ description, prompt }` schema. Provider lifecycle controls registration and + * re-derives conversation-history wording after reload, so load order is irrelevant. * + * Execution synchronously awaits the child result and always disposes the run. Non-completed stop + * reasons become error results, while transport details remain behind `ctx.subagents`. Load this + * plugin more than once to expose multiple configured providers. * @module @deepseek-ai/dsh-tool-subagent */ @@ -105,16 +82,8 @@ export const Config: z = z.object({ model: z.string(), }).default(undefined as unknown as { model: string }), persona: z.string(), - // A schemastery object materializes {} (with [] for nested arrays) when the - // key is omitted — for toolFilter that would mean an EMPTY ALLOW-LIST, i.e. - // deny-everything, silently. Force the omitted key to stay absent (the same - // shape discipline as SystemPrompt's toolOrder); the cast is needed because - // .default() expects the object type. - // The NESTED arrays get the same treatment as the object itself: a partial - // filter ({deny: […]}) must not materialize allow: [] beside it — an empty - // allow-list means deny-EVERYTHING, so the materialized default would turn - // a deny-one config into deny-all. An EXPLICIT allow: [] (grant-only - // children) survives, since only the omitted key defaults to undefined. + // Schemastery otherwise materializes omitted objects and nested arrays as `{ allow: [] }`, which + // silently means deny all. Preserve omission while retaining an explicit empty allow-list. toolFilter: z.object({ allow: z.array(z.string()).default(undefined as unknown as string[]), deny: z.array(z.string()).default(undefined as unknown as string[]), @@ -290,7 +259,7 @@ export function apply(ctx: Context, config: Config): void { if (present !== undefined) { mount(present) } else { - // Not an error: the backend's fiber may simply activate after this one. + // Not an error: the backend's fiber may activate after this one. // The tool appears the moment the provider registers; a typo'd provider // name shows up as this note plus a tool that never materializes. ctx.logger.info(`subagent provider "${config.provider}" not registered yet; the "${config.toolName ?? 'subagent'}" tool will register when it appears`) From cb177090f9573b30947af31daa876d586bea844a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:53:23 +0800 Subject: [PATCH 12/18] docs: refresh subagent config catalog --- docs/config-catalog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 093e534d23..d9e42366ba 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -915,7 +915,7 @@ export interface Config { Depends on: [`AgentOptions`](../packages/core/agent/src/index.ts) -Source: [`packages/subagent/tool-subagent/src/index.ts:47`](../packages/subagent/tool-subagent/src/index.ts) +Source: [`packages/subagent/tool-subagent/src/index.ts:24`](../packages/subagent/tool-subagent/src/index.ts) ## `@deepseek-ai/dsh-tool-web` From 2a6fef66ef3d55aff250f31f3d728f15e15f5269 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 16 Jul 2026 00:52:49 +0800 Subject: [PATCH 13/18] test(agent-loop): remove review bookkeeping --- packages/core/agent-loop/tests/agent.spec.ts | 3 +-- .../tests/contract-regressions.spec.ts | 18 +++++++++--------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index 7969d18200..91fc1a891d 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -401,8 +401,7 @@ describe('Agent', () => { // The waiter is internal agent state, NOT an effect-scoped ctx.on listener: // disposing the OWNING fiber runs the agent's listener disposers, which would // have dropped a ctx.on-based waiter before the 'disposed' transition and - // hung the promise. With internal waiters, the fiber disposer still settles - // it. Regression for the round-3 whenIdle finding. + // hung the promise. With internal waiters, the fiber disposer still settles it. const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) let agent!: Agent diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index ec2412aff5..00eafa18cb 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -44,7 +44,7 @@ function send(agent: Agent, text: string) { agent.send([{ type: 'text', text }]) } -describe('HIGH: session log records what agent/step-result actually produced', () => { +describe('session log records what agent/step-result actually produced', () => { it('a step-result rewrite is what the log, derived history, and tool dispatch all see', async () => { const adapter = new MockAdapter([textResponse('original'), textResponse('done')]) const ctx = await harness(adapter) @@ -94,7 +94,7 @@ describe('HIGH: session log records what agent/step-result actually produced', ( }) }) -describe('HIGH: abort during tool execution ends the turn', () => { +describe('abort during tool execution ends the turn', () => { it('aborting the in-flight step inside a tool prevents both remaining tools and the next model step', async () => { const adapter = new MockAdapter([ // model asks for two tool calls in one step @@ -146,7 +146,7 @@ describe('HIGH: abort during tool execution ends the turn', () => { }) }) -describe('HIGH: steering from late extension points is never stranded', () => { +describe('steering from late extension points is never stranded', () => { it('steer() from an agent/turn-continuation listener overrides a stop decision', async () => { const adapter = new MockAdapter([ textResponse('no tools, would stop here'), @@ -268,7 +268,7 @@ describe('HIGH: steering from late extension points is never stranded', () => { }) }) -describe('HIGH: plugin exceptions are contained', () => { +describe('plugin exceptions are contained', () => { it('a throwing agent/turn-continuation listener ends the turn with an error, loop survives', async () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) @@ -323,7 +323,7 @@ describe('HIGH: plugin exceptions are contained', () => { }) }) -describe('MEDIUM: disposed status is part of the agent/status contract', () => { +describe('disposed status is part of the agent/status contract', () => { it('disposing the fiber emits agent/status(disposed) and ends the turn with reason disposed', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) @@ -370,7 +370,7 @@ describe('MEDIUM: disposed status is part of the agent/status contract', () => { }) }) -describe('MEDIUM: misc registry and config fixes', () => { +describe('misc registry and config fixes', () => { it('duplicate adapter registration is rejected', async () => { const ctx = new Context() await ctx.plugin(LlmService) @@ -529,7 +529,7 @@ describe('MEDIUM: misc registry and config fixes', () => { }) }) -describe('MEDIUM: turn numbering continues across seeded (forked) sessions', () => { +describe('turn numbering continues across seeded (forked) sessions', () => { it('a forked agent continues turn numbers after the seed log', async () => { const first = new MockAdapter([textResponse('turn one')]) const ctx = await harness(first) @@ -567,7 +567,7 @@ describe('MEDIUM: turn numbering continues across seeded (forked) sessions', () }) }) -describe('LOW: discriminated SessionEvent narrows without casts', () => { +describe('discriminated SessionEvent narrows without casts', () => { it('narrows event.data from event.type', () => { const session = new Session(SessionId('s')) const appended: SessionEvent = session.append('tool/call', { @@ -585,7 +585,7 @@ describe('LOW: discriminated SessionEvent narrows without casts', () => { }) }) -describe('HIGH: a finish-error stream chunk ends the turn as error, not completed', () => { +describe('a finish-error stream chunk ends the turn as error, not completed', () => { it('translates finish {kind:error} into a turn error with a logged error event', async () => { // The second sanctioned adapter error path (besides throwing): an // adapter that cannot throw mid-stream ends the stream with a From 90220b235fcbdc506c6106a4c33cddd3faebacb1 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 16 Jul 2026 01:17:46 +0800 Subject: [PATCH 14/18] docs(core): describe the public loop contract --- packages/core/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/README.md b/packages/core/README.md index 921591d85e..fdd8e3669e 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -9,7 +9,7 @@ The session log, system-prompt assembly, tool registry, agent vocabulary, and co | `system-prompt/` | Prompt-section + tool-schema assembly registry | `ctx.systemPrompt` | | `tools/` | Scoped tool registry + pre-policy, guards, around-dispatch, post-policy, and final-result observation | `ctx.tools` | | `agent/` | Agent interface, registry, `agent/*` event vocabulary | `ctx.agents` | -| `agent-loop/` | The concrete loop plugin: `ReactLoopAgent` + the loop driver | `ctx.agentLoop` | +| `agent-loop/` | Concrete plugin implementing the public `Agent` contract and owning the loop driver | `ctx.agentLoop` | `scope/` is the one non-service package here: a dependency-free library (`createScope`/`scopeOf`/`scopeTarget`) the registries and the loop build per-agent scoping on — it sits below `session/` and `system-prompt/` in the module graph precisely so they can consume it without a cycle. From 3855a77e7d8b779c74c53cbbe7736a6a29563ba2 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 16 Jul 2026 02:04:04 +0800 Subject: [PATCH 15/18] docs(approval): qualify audit guarantees --- .../implemented/feature/2026-07-06-approval-seam.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/rfc/implemented/feature/2026-07-06-approval-seam.md b/docs/rfc/implemented/feature/2026-07-06-approval-seam.md index 9f4968c3a4..0945228593 100644 --- a/docs/rfc/implemented/feature/2026-07-06-approval-seam.md +++ b/docs/rfc/implemented/feature/2026-07-06-approval-seam.md @@ -25,7 +25,7 @@ One `cordis.yml` entry mounts the seam. Not loading it is the fail-closed opt-ou The entry alone provides mechanism, not a channel: with no answerer composed, every ask resolves `unavailable` and the asking tool call denies — fail-closed needs no configuration. Composing the ACP app (`@deepseek-ai/dsh-acp-demo`, as in [the acp-agent example's default tree](../../../../examples/acp-agent/README.md)) completes the loop: its bridge registers an answerer that prompts the owning editor session via `session/request_permission`, so a hook's `ask` or an escalation request surfaces as a one-shot Allow/Reject prompt attached to the already-streamed tool call. `policy: never` is the unattended stance — every ask auto-rejects deterministically, stated in the system prompt, no human in the loop. `policy` is validated against the closed list at plugin load; anything else throws. -What a composed deployment observes: `allowed-once` lets exactly that call proceed; rejection, dismissal, and channel absence deny with three distinct reasons the model can tell apart; every ask lands a durable `approval/asked`/`approval/decided` pair on the asking agent's session log; nothing about a grant persists past the call that asked. +What a composed deployment observes: `allowed-once` lets exactly that call proceed; rejection, dismissal, and channel absence deny with three distinct reasons the model can tell apart; a successful in-turn request lands a durable `approval/asked`/`approval/decided` pair on the asking agent's session log; nothing about a grant persists past the call that asked. An idle request or audit append failure rejects instead of returning an unaudited decision. One ask under this composition, verbatim from the sandbox example's recorded `escalation-approved` scenario — the model requests a sandbox escalation, the gate asks, the bridge prompts the owning editor, the user clicks Allow once: @@ -71,7 +71,7 @@ The answerer routes through the bridge's exact-agent ownership check described b #### Audit, and what the model sees -`approval/asked` / `approval/decided` are log-only session events (the `hook/invoked`/`hook/result` precedent): durable, replayable, never in the model transcript. The model's entire view of an approval is the tool result the asker derives from the outcome — reconstructability holds because that result is an ordinary logged `tool/result`. One `decided` lands per `asked`, whatever the outcome, including an already-aborted signal (settled `cancelled` without dispatching), a contained answerer failure, or a session observer that throws after either event is already appended. +`approval/asked` / `approval/decided` are log-only session events (the `hook/invoked`/`hook/result` precedent): durable, replayable, never in the model transcript. The model's entire view of an approval is the tool result the asker derives from the outcome — reconstructability holds because that result is an ordinary logged `tool/result`. Successful request completion commits one `decided` per `asked`, whatever the outcome, including an already-aborted signal (settled `cancelled` without dispatching), a contained answerer failure, or a session observer that throws after either event is already appended. An idle request appends neither event; a pre-commit append failure rejects, and failure of the second append can leave the already-committed `asked` without a `decided`. #### Entities and dependencies @@ -105,7 +105,7 @@ The implemented contract is pinned by the suites in Testing: - With an ApprovalService and an answerer composed, a hook's `ask` reaches a human and `allowed-once` dispatches the tool; every other outcome denies with its distinct reason. - A `'never'` session auto-rejects every ask without prompting anyone, states the policy in its prompt, and narrates switches (the shared switching mechanics are pinned in [the sandbox RFC](2026-07-06-sandbox.md)). - Every unanswerable path fails closed to `unavailable`: no service, no listener, a foreign or agent-less request, a throwing answerer, a rogue return value, or a dead client connection. -- Every `request()` routes through its readonly agent identity and lands exactly one `approval/asked`/`approval/decided` pair on that agent's log, replayable and invisible to the model transcript; post-append observer failures cannot split the pair. +- Every successful `request()` routes through its readonly agent identity and lands exactly one `approval/asked`/`approval/decided` pair on that agent's log, replayable and invisible to the model transcript; idle and pre-commit failures reject, while post-append observer failures cannot split the pair. - Prompts route per-session through the bridge's ownership map; one session's prompt can never reach another session's editor. - A deployment with no ApprovalService emits no approval prompt or approval audit events and denies every `ask` request. @@ -113,7 +113,7 @@ Costs and accepted limits: - **Two decide-eager answerers race for the slot.** Sibling-plugin listener order is not deterministic, so the seam cannot referee competing terminal answerers — mitigated by convention (one terminal answerer per deployment; `prepend` only for decide-or-delegate gates) rather than a priority mechanism the event bus does not have. - **Production exercise rests on one composition.** `ask` has two producer families — the hook bridges through `tools/pre-execute`, and sandbox escalation through its own gate — with the wire recorded in the sandbox example's snapshot suite, so the seam's real-world coverage is that one composition until more deployments compose it. -- **Ownership keys on `Agent` object identity.** The answerer resolves sessions through the bridge's existing WeakMap; every current path hands the same object through the loop and the seams, but a future boundary that clones or proxies agents would make the bridge delegate and fail closed — safe, but silently UI-less — and would need session-id matching instead. +- **Ownership keys on `Agent` object identity.** The answerer resolves the forward session-map record at `agent.session.id`, then requires that record to own the exact agent object; every current path hands the same object through the loop and the seams, but a future boundary that clones or proxies agents would make the bridge delegate and fail closed — safe, but silently UI-less — and would need a different ownership contract. ## FAQ @@ -123,10 +123,10 @@ Behavioral and usage questions only — every "why not X?" design question lives - **Can a grant persist — "always allow this"?** No. `allowed-once` authorizes the single asked-about action and the service stores nothing between requests; `allow_always` is deliberately not advertised until grant storage is designed (§ Deferred). - **What does the model see of an approval?** Only the tool result the asker derives from the outcome — the audit pair never enters the transcript. The three non-grant reasons are distinct, so the model can tell a human "no" from a dismissed prompt from a missing channel. - **Who decides whether a call asks in the first place?** Policy producers: a hook returning `permissionDecision: ask`, any `tools/pre-execute` listener, or the sandbox escalation gate. The seam and the bridge only route and answer; neither injects its own judgment about what deserves a prompt. -- **What happens when the user dismisses the prompt, or the turn aborts mid-ask?** Dismissal maps to `cancelled` with its own deny text. An already-aborted signal settles `cancelled` without dispatching; an abort during the ask discards the late answer — one audit pair either way, never two. +- **What happens when the user dismisses the prompt, or the turn aborts mid-ask?** Dismissal maps to `cancelled` with its own deny text. An already-aborted signal settles `cancelled` without dispatching; an abort during the ask discards the late answer. When both audit appends commit, either path records one pair, never two. - **What if the client answers with an option the harness never offered?** Any selection other than the offered `allow_once` maps to `rejected` — an unknown optionId from a non-conforming client can never grant. - **How do subagents' approvals route?** An agent no answerer owns delegates through the whole waterfall and fails closed — in-process subagents are deliberately unanswerable. `subagent-acp`'s child-side auto-answer is separate; routing a child's asks to the parent's editor is deferred (§ Deferred). -- **What does `policy: 'never'` actually change at runtime?** The service resolves every ask for that session to `rejected` before dispatching any answerer (in-service, so no registration order can bypass it); the system prompt states the policy; switches are narrated at boundaries; the audit pair still lands for every auto-rejection. +- **What does `policy: 'never'` actually change at runtime?** The service resolves every ask for that session to `rejected` before dispatching any answerer (in-service, so no registration order can bypass it); the system prompt states the policy; switches are narrated at boundaries; each successful auto-rejection records the audit pair. - **What happens across a hot reload, or when the UI plugin unloads mid-session?** Answerers dispose with their owning fiber, so the next ask degrades to `unavailable` instead of hanging on a dead channel; remounting re-registers the answerer with no catch-up state. - **Where does the user see what they are approving?** On the tool call itself: the prompt attaches to the already-streamed call via `callId` — arguments included — and adds the asker's human-readable `reason`; the request carries no argument copy of its own. From 3befcfc566b4deec276d600df993949f6e49facc Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 16 Jul 2026 02:04:47 +0800 Subject: [PATCH 16/18] Revert "docs(approval): qualify audit guarantees" This reverts commit 14439a93c2bb915441e7b59f505d94a79b22d4af. --- .../implemented/feature/2026-07-06-approval-seam.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/rfc/implemented/feature/2026-07-06-approval-seam.md b/docs/rfc/implemented/feature/2026-07-06-approval-seam.md index 0945228593..9f4968c3a4 100644 --- a/docs/rfc/implemented/feature/2026-07-06-approval-seam.md +++ b/docs/rfc/implemented/feature/2026-07-06-approval-seam.md @@ -25,7 +25,7 @@ One `cordis.yml` entry mounts the seam. Not loading it is the fail-closed opt-ou The entry alone provides mechanism, not a channel: with no answerer composed, every ask resolves `unavailable` and the asking tool call denies — fail-closed needs no configuration. Composing the ACP app (`@deepseek-ai/dsh-acp-demo`, as in [the acp-agent example's default tree](../../../../examples/acp-agent/README.md)) completes the loop: its bridge registers an answerer that prompts the owning editor session via `session/request_permission`, so a hook's `ask` or an escalation request surfaces as a one-shot Allow/Reject prompt attached to the already-streamed tool call. `policy: never` is the unattended stance — every ask auto-rejects deterministically, stated in the system prompt, no human in the loop. `policy` is validated against the closed list at plugin load; anything else throws. -What a composed deployment observes: `allowed-once` lets exactly that call proceed; rejection, dismissal, and channel absence deny with three distinct reasons the model can tell apart; a successful in-turn request lands a durable `approval/asked`/`approval/decided` pair on the asking agent's session log; nothing about a grant persists past the call that asked. An idle request or audit append failure rejects instead of returning an unaudited decision. +What a composed deployment observes: `allowed-once` lets exactly that call proceed; rejection, dismissal, and channel absence deny with three distinct reasons the model can tell apart; every ask lands a durable `approval/asked`/`approval/decided` pair on the asking agent's session log; nothing about a grant persists past the call that asked. One ask under this composition, verbatim from the sandbox example's recorded `escalation-approved` scenario — the model requests a sandbox escalation, the gate asks, the bridge prompts the owning editor, the user clicks Allow once: @@ -71,7 +71,7 @@ The answerer routes through the bridge's exact-agent ownership check described b #### Audit, and what the model sees -`approval/asked` / `approval/decided` are log-only session events (the `hook/invoked`/`hook/result` precedent): durable, replayable, never in the model transcript. The model's entire view of an approval is the tool result the asker derives from the outcome — reconstructability holds because that result is an ordinary logged `tool/result`. Successful request completion commits one `decided` per `asked`, whatever the outcome, including an already-aborted signal (settled `cancelled` without dispatching), a contained answerer failure, or a session observer that throws after either event is already appended. An idle request appends neither event; a pre-commit append failure rejects, and failure of the second append can leave the already-committed `asked` without a `decided`. +`approval/asked` / `approval/decided` are log-only session events (the `hook/invoked`/`hook/result` precedent): durable, replayable, never in the model transcript. The model's entire view of an approval is the tool result the asker derives from the outcome — reconstructability holds because that result is an ordinary logged `tool/result`. One `decided` lands per `asked`, whatever the outcome, including an already-aborted signal (settled `cancelled` without dispatching), a contained answerer failure, or a session observer that throws after either event is already appended. #### Entities and dependencies @@ -105,7 +105,7 @@ The implemented contract is pinned by the suites in Testing: - With an ApprovalService and an answerer composed, a hook's `ask` reaches a human and `allowed-once` dispatches the tool; every other outcome denies with its distinct reason. - A `'never'` session auto-rejects every ask without prompting anyone, states the policy in its prompt, and narrates switches (the shared switching mechanics are pinned in [the sandbox RFC](2026-07-06-sandbox.md)). - Every unanswerable path fails closed to `unavailable`: no service, no listener, a foreign or agent-less request, a throwing answerer, a rogue return value, or a dead client connection. -- Every successful `request()` routes through its readonly agent identity and lands exactly one `approval/asked`/`approval/decided` pair on that agent's log, replayable and invisible to the model transcript; idle and pre-commit failures reject, while post-append observer failures cannot split the pair. +- Every `request()` routes through its readonly agent identity and lands exactly one `approval/asked`/`approval/decided` pair on that agent's log, replayable and invisible to the model transcript; post-append observer failures cannot split the pair. - Prompts route per-session through the bridge's ownership map; one session's prompt can never reach another session's editor. - A deployment with no ApprovalService emits no approval prompt or approval audit events and denies every `ask` request. @@ -113,7 +113,7 @@ Costs and accepted limits: - **Two decide-eager answerers race for the slot.** Sibling-plugin listener order is not deterministic, so the seam cannot referee competing terminal answerers — mitigated by convention (one terminal answerer per deployment; `prepend` only for decide-or-delegate gates) rather than a priority mechanism the event bus does not have. - **Production exercise rests on one composition.** `ask` has two producer families — the hook bridges through `tools/pre-execute`, and sandbox escalation through its own gate — with the wire recorded in the sandbox example's snapshot suite, so the seam's real-world coverage is that one composition until more deployments compose it. -- **Ownership keys on `Agent` object identity.** The answerer resolves the forward session-map record at `agent.session.id`, then requires that record to own the exact agent object; every current path hands the same object through the loop and the seams, but a future boundary that clones or proxies agents would make the bridge delegate and fail closed — safe, but silently UI-less — and would need a different ownership contract. +- **Ownership keys on `Agent` object identity.** The answerer resolves sessions through the bridge's existing WeakMap; every current path hands the same object through the loop and the seams, but a future boundary that clones or proxies agents would make the bridge delegate and fail closed — safe, but silently UI-less — and would need session-id matching instead. ## FAQ @@ -123,10 +123,10 @@ Behavioral and usage questions only — every "why not X?" design question lives - **Can a grant persist — "always allow this"?** No. `allowed-once` authorizes the single asked-about action and the service stores nothing between requests; `allow_always` is deliberately not advertised until grant storage is designed (§ Deferred). - **What does the model see of an approval?** Only the tool result the asker derives from the outcome — the audit pair never enters the transcript. The three non-grant reasons are distinct, so the model can tell a human "no" from a dismissed prompt from a missing channel. - **Who decides whether a call asks in the first place?** Policy producers: a hook returning `permissionDecision: ask`, any `tools/pre-execute` listener, or the sandbox escalation gate. The seam and the bridge only route and answer; neither injects its own judgment about what deserves a prompt. -- **What happens when the user dismisses the prompt, or the turn aborts mid-ask?** Dismissal maps to `cancelled` with its own deny text. An already-aborted signal settles `cancelled` without dispatching; an abort during the ask discards the late answer. When both audit appends commit, either path records one pair, never two. +- **What happens when the user dismisses the prompt, or the turn aborts mid-ask?** Dismissal maps to `cancelled` with its own deny text. An already-aborted signal settles `cancelled` without dispatching; an abort during the ask discards the late answer — one audit pair either way, never two. - **What if the client answers with an option the harness never offered?** Any selection other than the offered `allow_once` maps to `rejected` — an unknown optionId from a non-conforming client can never grant. - **How do subagents' approvals route?** An agent no answerer owns delegates through the whole waterfall and fails closed — in-process subagents are deliberately unanswerable. `subagent-acp`'s child-side auto-answer is separate; routing a child's asks to the parent's editor is deferred (§ Deferred). -- **What does `policy: 'never'` actually change at runtime?** The service resolves every ask for that session to `rejected` before dispatching any answerer (in-service, so no registration order can bypass it); the system prompt states the policy; switches are narrated at boundaries; each successful auto-rejection records the audit pair. +- **What does `policy: 'never'` actually change at runtime?** The service resolves every ask for that session to `rejected` before dispatching any answerer (in-service, so no registration order can bypass it); the system prompt states the policy; switches are narrated at boundaries; the audit pair still lands for every auto-rejection. - **What happens across a hot reload, or when the UI plugin unloads mid-session?** Answerers dispose with their owning fiber, so the next ask degrades to `unavailable` instead of hanging on a dead channel; remounting re-registers the answerer with no catch-up state. - **Where does the user see what they are approving?** On the tool call itself: the prompt attaches to the already-streamed call via `callId` — arguments included — and adds the asker's human-readable `reason`; the request carries no argument copy of its own. From c2cf2cbbd846f477e6b3674b095a87e5ed0aa2c0 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 16 Jul 2026 02:05:44 +0800 Subject: [PATCH 17/18] test(agent-loop): name regression contracts --- packages/core/agent-loop/tests/contract-regressions.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index 00eafa18cb..098a53261c 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -370,7 +370,7 @@ describe('disposed status is part of the agent/status contract', () => { }) }) -describe('misc registry and config fixes', () => { +describe('registration, request routing, and queued-input ownership contracts', () => { it('duplicate adapter registration is rejected', async () => { const ctx = new Context() await ctx.plugin(LlmService) From 2f1a40d9381aea67badc63f7c1d30e5cda5c0f02 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 16 Jul 2026 02:35:31 +0800 Subject: [PATCH 18/18] docs(catalog): refresh AgentLoop source link --- docs/cordis-catalog/services.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 7cc00a407a..44490c39f0 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -19,7 +19,9 @@ async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise ``` -Source: [`packages/core/agent-loop/src/index.ts:391`](../../packages/core/agent-loop/src/index.ts) +Types: [Agent](../core-data-structures/core.md) + +Source: [`packages/core/agent-loop/src/index.ts:390`](../../packages/core/agent-loop/src/index.ts) ## `ctx.agents` — `AgentRegistry`