mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge remote-tracking branch 'origin/master' into codex/simp-name-front-door-defaults
This commit is contained in:
@@ -17,7 +17,7 @@ A harness is one [Cordis](cordis-primer.md) context. Packages contribute service
|
||||
| `ctx.systemPrompt` | `dsh-system-prompt` | ordered prompt sections, tool schemas, and prompt variables |
|
||||
| `ctx.tools` | `dsh-tools` | tool registry and [execution pipeline](tool-execution-pipeline.md) |
|
||||
| `ctx.agents` | `dsh-agent` | live agent registry, public `Agent` handle, `agent/*` events |
|
||||
| `ctx.agentLoop` | `dsh-agent-loop` | shipped `ReactLoopAgent` driver |
|
||||
| `ctx.agentLoop` | `dsh-agent-loop` | concrete `Agent` driver |
|
||||
|
||||
### Capability Services
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@ export interface Config {
|
||||
|
||||
Depends on: [`AgentOptions`](../packages/core/agent/src/index.ts) · [`SessionId`](../packages/core/session/src/index.ts)
|
||||
|
||||
Source: [`packages/core/agent-loop/src/index.ts:370`](../packages/core/agent-loop/src/index.ts)
|
||||
Source: [`packages/core/agent-loop/src/index.ts:369`](../packages/core/agent-loop/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-agent-spine-demo`
|
||||
|
||||
|
||||
@@ -179,7 +179,7 @@ A declarative agent entry failed before it could publish a live agent. Consumers
|
||||
'agent-loop/config-start-failed'(sessionId: SessionId, error: unknown): void
|
||||
```
|
||||
|
||||
Source: [`packages/core/agent-loop/src/index.ts:363`](../../packages/core/agent-loop/src/index.ts)
|
||||
Source: [`packages/core/agent-loop/src/index.ts:362`](../../packages/core/agent-loop/src/index.ts)
|
||||
|
||||
## `approval/*`
|
||||
|
||||
|
||||
@@ -11,15 +11,17 @@ The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary
|
||||
|
||||
## `ctx.agentLoop` — `AgentLoop`
|
||||
|
||||
Concrete ReactLoopAgent factory and driver service.
|
||||
Concrete agent factory and driver service.
|
||||
|
||||
```ts cordis-catalog
|
||||
create(id: SessionId, options: AgentOptions = {}, meta: Pick<SessionHeader, 'cwd'> = {}): ReactLoopAgent
|
||||
create(id: SessionId, options: AgentOptions = {}, meta: Pick<SessionHeader, 'cwd'> = {}): Agent
|
||||
async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle>
|
||||
async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle>
|
||||
```
|
||||
|
||||
Source: [`packages/core/agent-loop/src/index.ts:408`](../../packages/core/agent-loop/src/index.ts)
|
||||
Types: [Agent](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent-loop/src/index.ts:407`](../../packages/core/agent-loop/src/index.ts)
|
||||
|
||||
## `ctx.agents` — `AgentRegistry`
|
||||
|
||||
|
||||
@@ -288,7 +288,7 @@ The fourteen event variants (`turn/start`, `turn/end`, `step/start`, `step/end`,
|
||||
|
||||
## The agent handle
|
||||
|
||||
`Agent` is the surface every plugin (UI, hooks, orchestrators) programs against. The concrete implementation is `ReactLoopAgent` in dsh-agent-loop; nothing outside the loop depends on the implementation.
|
||||
`Agent` is the surface every plugin (UI, hooks, orchestrators) programs against. The concrete implementation is package-internal to dsh-agent-loop; nothing outside the loop depends on it.
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
|
||||
| Event | Mode | Declared in | Dispatchers | Listeners |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:363`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`stdio`](../packages/ui/stdio) |
|
||||
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:362`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`stdio`](../packages/ui/stdio) |
|
||||
| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:141`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio`](../packages/ui/stdio) |
|
||||
| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:150`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`stdio`](../packages/ui/stdio) |
|
||||
| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:285`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
|
||||
|
||||
@@ -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.
|
||||
|
||||
Resolving the provider at the tool plugin's `apply` time creates an implicit load-order requirement ("list the backend before the tool in cordis.yml"). That requirement fails because the Cordis Loader starts sibling entries concurrently and `Entry.init()` does not await activation: a delayed backend can leave the tool fiber failed even when listed first. The Loader offers no sibling-order guarantee — "async state is not synchronous state" ([defensive patterns](../../../defensive-patterns.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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { RUN_CODE_NAME } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
|
||||
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
||||
@@ -73,7 +73,7 @@ async function workspaceCodeModeHarness(): Promise<Context> {
|
||||
return harness
|
||||
}
|
||||
|
||||
function waitForIdle(harness: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
function waitForIdle(harness: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = harness.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
@@ -146,7 +146,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('Code Mode: real model writes a p
|
||||
type: 'text',
|
||||
text: 'Use one run_code program to call tools.read on pkg/deep/task.txt. After it finishes, answer: Code Mode workspace handshake?',
|
||||
}])
|
||||
await waitForIdle(ctx, handle.agent as ReactLoopAgent)
|
||||
await waitForIdle(ctx, handle.agent)
|
||||
|
||||
const events: SessionEvent[] = [...handle.agent.session.events]
|
||||
const dispatch = events.find(event => event.type === 'tool/code-dispatch' && event.data.name === 'read')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Context } from 'cordis'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
|
||||
@@ -71,7 +72,7 @@ export async function codingHarness(workdir: string, options: CodingHarnessOptio
|
||||
return ctx
|
||||
}
|
||||
|
||||
export function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
export function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
|
||||
@@ -3,7 +3,6 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { Context } from 'cordis'
|
||||
import type { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.ts'
|
||||
|
||||
@@ -41,7 +40,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses
|
||||
const first = (await ctx.agents.create({
|
||||
sessionId: SESSION_ID,
|
||||
agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
})).agent as ReactLoopAgent
|
||||
})).agent
|
||||
first.send([{ type: 'text', text: `Remember this code for later: ${SECRET}. Just acknowledge it.` }])
|
||||
await waitForIdle(ctx, first)
|
||||
await ctx.fiber.dispose()
|
||||
@@ -54,7 +53,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses
|
||||
const resumed = (await ctx.agents.resume({
|
||||
resumeSessionId: SESSION_ID,
|
||||
agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||
})).agent as ReactLoopAgent
|
||||
})).agent
|
||||
expect(resumed.session.id).toBe(SESSION_ID)
|
||||
// The prior user turn is in the rehydrated log before the model is asked.
|
||||
expect(JSON.stringify(resumed.session.deriveMessages())).toContain(SECRET)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Context } from 'cordis'
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
||||
import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis'
|
||||
@@ -28,7 +29,7 @@ export async function cordisHarness(): Promise<Context> {
|
||||
return ctx
|
||||
}
|
||||
|
||||
export function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
export function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
|
||||
@@ -5,7 +5,8 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import TaskService from '@deepseek-ai/dsh-tasks'
|
||||
import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
|
||||
@@ -38,7 +39,7 @@ afterEach(() => {
|
||||
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
@@ -49,7 +50,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
function events(agent: ReactLoopAgent): SessionEvent[] {
|
||||
function events(agent: Agent): SessionEvent[] {
|
||||
return [...agent.session.events]
|
||||
}
|
||||
|
||||
@@ -102,7 +103,7 @@ describe('bash tool through the agent loop', () => {
|
||||
sessionId: SessionId('session-env-id'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
const agent = handle.agent as ReactLoopAgent
|
||||
const agent = handle.agent
|
||||
const location = ctx.sessionPersistence.locate(agent.session.header)
|
||||
expect(location?.kind).toBe('jsonl')
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-a
|
||||
import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||
import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'
|
||||
@@ -82,7 +83,7 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr
|
||||
return { ctx, compact }
|
||||
}
|
||||
|
||||
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
|
||||
@@ -54,9 +54,9 @@ export interface TypeApiEntry {
|
||||
export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
{
|
||||
key: 'agentLoop',
|
||||
summary: 'Concrete ReactLoopAgent factory and driver service.',
|
||||
summary: 'Concrete agent factory and driver service.',
|
||||
methods: [
|
||||
'create(id: SessionId, options: AgentOptions = {}, meta: Pick<SessionHeader, \'cwd\'> = {}): ReactLoopAgent',
|
||||
'create(id: SessionId, options: AgentOptions = {}, meta: Pick<SessionHeader, \'cwd\'> = {}): Agent',
|
||||
'async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle>',
|
||||
'async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise<AgentHandle>',
|
||||
],
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import * as ToolCordis from '../src/index.ts'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
@@ -24,7 +25,7 @@ async function harness(adapter: MockAdapter): Promise<Context> {
|
||||
return ctx
|
||||
}
|
||||
|
||||
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# dsh-agent-loop
|
||||
|
||||
THE concrete agent plugin: `ReactLoopAgent` and the loop driver. Implements the `Agent` interface and drives the session/turn/step lifecycle.
|
||||
THE concrete agent plugin and loop driver. Its package-internal implementation satisfies the `Agent` interface and drives the session/turn/step lifecycle.
|
||||
|
||||
This is the only package in the harness that contains concrete loop logic. Everything else is an abstract service or a plugin against extension seams — new behavior goes into plugins, not here.
|
||||
|
||||
@@ -14,7 +14,7 @@ The caller fiber and the AgentLoop provider are co-owners. `AgentFactory.createA
|
||||
|
||||
Each agent and its session share one caller-chosen `SessionId`, assumed globally unique; accidental UUID collisions are outside the supported model. Two concurrent operations with the same id may both prepare, but the final `enter()` calls arbitrate publication and every loser rolls its private resources back. Each detach is bound to the exact entered object, so a stale disposer cannot remove a later same-id replacement. A detach requested during a synchronous creation notification waits for that dispatch to unwind, preserving created/disposed pairing. Teardown runs stop and drain (including outstanding idle-injection flushes) → detach agent → detach session → unwind scope; the id becomes reusable at detach even if private scope cleanup is still finishing. Ordinary non-vetoing `agent/*` notifications go through `agentEvents(ctx, agent)`, per-step assembly goes through `assembleContextFor(agent)`, and turn-end durability checkpoints go through `ctx.sessions.flush(session)`.
|
||||
|
||||
- `ctx.agentLoop.create(id: SessionId, options?: AgentOptions, meta?: { cwd?: string }): ReactLoopAgent` — synchronous no-setup create under the exact shared agent/session id, disposed with the calling fiber. Declarative config treats `agents[].id` as a stable label and normally mints `${label}-session-<uuid>` before calling this boundary. An app may instead supply a stable exact `sessionId`: first use creates it, while a remount with persistence already present resumes its materialized history. `resumeSessionId` requires and loads an existing persisted id and is mutually exclusive with `sessionId`. This keeps default fresh restarts collision-free without retaining a second live routing identity.
|
||||
- `ctx.agentLoop.create(id: SessionId, options?: AgentOptions, meta?: { cwd?: string }): Agent` — synchronous no-setup create under the exact shared agent/session id, disposed with the calling fiber. Declarative config treats `agents[].id` as a stable label and normally mints `${label}-session-<uuid>` before calling this boundary. An app may instead supply a stable exact `sessionId`: first use creates it, while a remount with persistence already present resumes its materialized history. `resumeSessionId` requires and loads an existing persisted id and is mutually exclusive with `sessionId`. This keeps default fresh restarts collision-free without retaining a second live routing identity.
|
||||
|
||||
`AgentLoop` also implements the `AgentFactory` seam and registers itself via `ctx.agents.setFactory(this)`, so plugins create/resume agents through `ctx.agents` (the interface):
|
||||
|
||||
@@ -44,11 +44,9 @@ interface Config {
|
||||
|
||||
Configured agents start automatically. A model call requires both `provider` and `model`; `agent/request` may supply a missing pair before dispatch. `maxParallelToolCalls` bounds every agent's rolling pool for parallel-safe calls and defaults to `10`. `cwd` applies only to fresh sessions, while `resumeSessionId` retains persisted metadata. Configured agents use the deployment persona, and programmatic setup can shadow it per agent. This plugin supplies the per-agent `provider`, `model`, and `cwd` prompt variables; harness identity and deployment persona belong to `dsh-system-prompt`.
|
||||
|
||||
### Exported concrete class
|
||||
### Internal concrete driver
|
||||
|
||||
- `ReactLoopAgent` — the concrete `Agent` implementation. Its inbox is a JavaScript native-private field, and one prepared session can be claimed by only one concrete driver. Everything observable happens through session events and the `agent/*` event taxonomy.
|
||||
|
||||
`Inbox`, `runLoop`, and the instance-bound publication/start controls are package-internal. The package root does not export them, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than constructing or starting the driver internals. `ReactLoopAgent.send()`, running `steer()`, and open-turn `inject()` materialize content plus resolved source once as detached, deeply frozen lossless JSON; malformed data throws before enqueue or append. An injection that arrives while the current step executes assistant tool calls stays in a FIFO until the batch settles; successful batches place it after the complete result batch, and interrupted batches drain it before the turn closes.
|
||||
The concrete `Agent` class, its `Inbox`, `runLoop`, and instance-bound publication/start controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. The concrete `send()`, running `steer()`, and open-turn `inject()` materialize content plus resolved source once as detached, deeply frozen lossless JSON; malformed data throws before enqueue or append. An injection that arrives while the current step executes assistant tool calls stays in a FIFO until the batch settles; successful batches place it after the complete result batch, and interrupted batches drain it before the turn closes. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy.
|
||||
|
||||
### Loop lifecycle (`loop.ts`)
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { createScope } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scope } from '@deepseek-ai/dsh-scope'
|
||||
import { agentEvents } from '@deepseek-ai/dsh-agent'
|
||||
import type {
|
||||
Agent,
|
||||
AgentFactory,
|
||||
AgentHandle,
|
||||
AgentOptions,
|
||||
@@ -33,8 +34,6 @@ import {
|
||||
import type { PreparedReactLoopAgent } from './agent.ts'
|
||||
import { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from './constants.ts'
|
||||
|
||||
export { ReactLoopAgent } from './agent.ts'
|
||||
|
||||
/** Fiber states that cannot own or serve a new lifecycle. */
|
||||
const INACTIVE_STATES: ReadonlySet<FiberState> = new Set([
|
||||
FiberState.UNLOADING,
|
||||
@@ -404,7 +403,7 @@ function validateConfiguredAgents(agents: Config['agents']): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** Concrete ReactLoopAgent factory and driver service. */
|
||||
/** Concrete agent factory and driver service. */
|
||||
export class AgentLoop extends Service implements AgentFactory {
|
||||
static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt']
|
||||
|
||||
@@ -540,7 +539,7 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
* @param meta - optional fresh-session workspace metadata.
|
||||
* @returns the published running agent.
|
||||
*/
|
||||
create(id: SessionId, options: AgentOptions = {}, meta: Pick<SessionHeader, 'cwd'> = {}): ReactLoopAgent {
|
||||
create(id: SessionId, options: AgentOptions = {}, meta: Pick<SessionHeader, 'cwd'> = {}): Agent {
|
||||
const loopCtx = this.runtime.ctx
|
||||
const transaction = new AgentCreationTransaction(loopCtx, this.ctx, this.ownership, id)
|
||||
try {
|
||||
|
||||
@@ -4,11 +4,15 @@ import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS, ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import { bindReactLoopAgentContext, prepareReactLoopAgent } from '../src/agent.ts'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from '@deepseek-ai/dsh-agent-loop'
|
||||
import { bindReactLoopAgentContext, prepareReactLoopAgent, type ReactLoopAgent } from '../src/agent.ts'
|
||||
import { MockAdapter, textResponse } from './mock-adapter.ts'
|
||||
|
||||
function driverDone(agent: Agent): Promise<void> {
|
||||
return (agent as Agent & { done: Promise<void> }).done
|
||||
}
|
||||
|
||||
async function harness(adapter: MockAdapter) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
@@ -21,7 +25,7 @@ async function harness(adapter: MockAdapter) {
|
||||
return ctx
|
||||
}
|
||||
|
||||
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
@@ -32,7 +36,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
function waitForStatus(ctx: Context, agent: ReactLoopAgent, expected: ReactLoopAgent['status']): Promise<void> {
|
||||
function waitForStatus(ctx: Context, agent: Agent, expected: Agent['status']): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === expected) {
|
||||
@@ -43,11 +47,11 @@ function waitForStatus(ctx: Context, agent: ReactLoopAgent, expected: ReactLoopA
|
||||
})
|
||||
}
|
||||
|
||||
function send(agent: ReactLoopAgent, text: string) {
|
||||
function send(agent: Agent, text: string) {
|
||||
agent.send([{ type: 'text', text }])
|
||||
}
|
||||
|
||||
describe('ReactLoopAgent', () => {
|
||||
describe('Agent', () => {
|
||||
it('rejects access before context binding and a second driver for one session', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
@@ -74,7 +78,7 @@ describe('ReactLoopAgent', () => {
|
||||
expect(agent.options).toBe(options)
|
||||
expect(agent.id).toBe('owned-bindings')
|
||||
expect(agent.session.id).toBe(agent.id)
|
||||
expect(() => { bindReactLoopAgentContext(agent, new Context()) }).toThrow(/context is already bound/)
|
||||
expect(() => { bindReactLoopAgentContext(agent as ReactLoopAgent, new Context()) }).toThrow(/context is already bound/)
|
||||
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
@@ -82,14 +86,14 @@ describe('ReactLoopAgent', () => {
|
||||
it('send() throws after disposal', async () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
let agent!: ReactLoopAgent
|
||||
let agent!: Agent
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
send(agent, 'go')
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
await fiber.dispose()
|
||||
await agent.done
|
||||
await driverDone(agent)
|
||||
|
||||
expect(() => { agent.send([{ type: 'text', text: 'too late' }]) }).toThrow('disposed')
|
||||
})
|
||||
@@ -97,14 +101,14 @@ describe('ReactLoopAgent', () => {
|
||||
it('steer() throws after disposal', async () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
let agent!: ReactLoopAgent
|
||||
let agent!: Agent
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
send(agent, 'go')
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
await fiber.dispose()
|
||||
await agent.done
|
||||
await driverDone(agent)
|
||||
|
||||
expect(() => { agent.steer([{ type: 'text', text: 'too late' }]) }).toThrow('disposed')
|
||||
})
|
||||
@@ -112,14 +116,14 @@ describe('ReactLoopAgent', () => {
|
||||
it('inject() throws after disposal', async () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
let agent!: ReactLoopAgent
|
||||
let agent!: Agent
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
send(agent, 'go')
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
await fiber.dispose()
|
||||
await agent.done
|
||||
await driverDone(agent)
|
||||
|
||||
expect(() => { agent.inject([{ type: 'text', text: 'too late' }]) }).toThrow('disposed')
|
||||
})
|
||||
@@ -170,8 +174,10 @@ describe('ReactLoopAgent', () => {
|
||||
let flushes = 0
|
||||
ctx.on('session/flush', () => { flushes += 1 })
|
||||
|
||||
// Invalid injected content throws after turn/start. `finally` must still append turn/end and
|
||||
// flush the balanced in-memory turn so a crash cannot lose it before the next checkpoint.
|
||||
// Non-serializable injected content makes Session.append throw AFTER
|
||||
// turn/start was recorded. The turn/end must still be appended (finally),
|
||||
// AND the durability checkpoint must still fire — the balanced turn is in
|
||||
// memory and a crash before the next turn/dispose would otherwise lose it.
|
||||
expect(() => {
|
||||
agent.inject([{ type: 'text', text: 'x', bad: 1n } as never], { source: { kind: 'plugin', plugin: 'p' } })
|
||||
}).toThrow(/non-JSON-serializable/)
|
||||
@@ -253,7 +259,9 @@ describe('ReactLoopAgent', () => {
|
||||
})
|
||||
|
||||
it('disposer is idempotent (double-stop)', async () => {
|
||||
// The internal start seam exposes one idle driver's disposer for repeated invocation.
|
||||
// Create a bare Agent and start it through the package-internal
|
||||
// test seam. Then call its disposer twice — the second call hits the
|
||||
// early-return branch.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('test'))
|
||||
@@ -262,13 +270,17 @@ describe('ReactLoopAgent', () => {
|
||||
)
|
||||
const { agent } = prepared
|
||||
|
||||
// Start the loop to get the disposer; the agent waits for messages
|
||||
// (idle, never-resolving cancel), so it will stay idle.
|
||||
prepared.markPublished()
|
||||
const dispose = prepared.startDriver()
|
||||
|
||||
// First dispose
|
||||
const firstDisposal = dispose()
|
||||
expect(agent.status).toBe('disposed')
|
||||
await firstDisposal
|
||||
|
||||
// Second dispose — idempotent, no throw
|
||||
await expect(dispose()).resolves.toBeUndefined()
|
||||
expect(agent.status).toBe('disposed')
|
||||
})
|
||||
@@ -365,8 +377,10 @@ describe('ReactLoopAgent', () => {
|
||||
})
|
||||
|
||||
it('whenIdle() subscribed while running resolves via done when the agent is then disposed', async () => {
|
||||
// Queue the internal waiter while running, then dispose the bare driver. Its disposed branch
|
||||
// must chain the loop's `done` promise rather than resolve before exit.
|
||||
// Covers the waiter's disposed arm: whenIdle() queues an internal waiter
|
||||
// while running (not the fast path), then the disposer settles it and chains
|
||||
// `done` (loop exit), not an eager resolve. A bare Agent + direct
|
||||
// internal driver disposer keeps the emit synchronous.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
@@ -394,11 +408,13 @@ describe('ReactLoopAgent', () => {
|
||||
})
|
||||
|
||||
it('whenIdle() subscribed while running survives a FIBER dispose (no hung promise)', async () => {
|
||||
// The waiter is agent-owned state, not an effect-scoped listener that owner disposal would
|
||||
// remove before the disposed transition. Fiber teardown must still settle it.
|
||||
// The waiter is internal agent state, NOT an effect-scoped ctx.on listener:
|
||||
// disposing the OWNING fiber runs the agent's listener disposers, which would
|
||||
// have dropped a ctx.on-based waiter before the 'disposed' transition and
|
||||
// hung the promise. With internal waiters, the fiber disposer still settles it.
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
let agent!: ReactLoopAgent
|
||||
let agent!: Agent
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
@@ -413,11 +429,13 @@ describe('ReactLoopAgent', () => {
|
||||
})
|
||||
|
||||
it('whenIdle() on a disposed agent awaits the loop exit (done), not just the status flip', async () => {
|
||||
// Disposed status is emitted before the driver unwinds. `whenIdle()` must chain `done` so it
|
||||
// resolves only after true loop exit.
|
||||
// The disposer emits agent/status('disposed') BEFORE the driver loop
|
||||
// unwinds, so whenIdle() must chain `done` (true quiescence) on the
|
||||
// disposed path. Dispose a running agent, then assert whenIdle() resolves
|
||||
// only after `done` — i.e. the loop has actually exited.
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
let agent!: ReactLoopAgent
|
||||
let agent!: Agent
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
@@ -425,7 +443,7 @@ describe('ReactLoopAgent', () => {
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
|
||||
let doneResolved = false
|
||||
void agent.done.then(() => { doneResolved = true })
|
||||
void driverDone(agent).then(() => { doneResolved = true })
|
||||
await fiber.dispose() // sets status disposed, aborts, drains the loop
|
||||
expect(agent.status).toBe('disposed')
|
||||
|
||||
|
||||
@@ -13,11 +13,15 @@ import LlmService, { type Message } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { MockAdapter, textResponse } from './mock-adapter.ts'
|
||||
|
||||
function driverDone(agent: Agent): Promise<void> {
|
||||
return (agent as Agent & { done: Promise<void> }).done
|
||||
}
|
||||
|
||||
async function harness(adapter: MockAdapter) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
@@ -30,12 +34,12 @@ async function harness(adapter: MockAdapter) {
|
||||
return ctx
|
||||
}
|
||||
|
||||
function send(agent: ReactLoopAgent, text: string) {
|
||||
function send(agent: Agent, text: string) {
|
||||
agent.send([{ type: 'text', text }])
|
||||
}
|
||||
|
||||
/** Resolve on the agent's next idle transition (event-based, not status poll). */
|
||||
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') { dispose(); resolve() }
|
||||
@@ -44,7 +48,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
}
|
||||
|
||||
/** All user-message texts recorded in the log (to assert what actually ran). */
|
||||
function userTexts(agent: ReactLoopAgent): string[] {
|
||||
function userTexts(agent: Agent): string[] {
|
||||
return agent.session.events
|
||||
.filter(e => e.type === 'user/message')
|
||||
.flatMap(e => e.type === 'user/message' ? e.data.content : [])
|
||||
@@ -202,7 +206,7 @@ describe('Agent.cancel()', () => {
|
||||
sessionId: SessionId('dispose-prefix-session'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
const agent = handle.agent as ReactLoopAgent
|
||||
const agent = handle.agent
|
||||
|
||||
let disposalDone: Promise<void> | undefined
|
||||
let streamed = false
|
||||
@@ -215,7 +219,7 @@ describe('Agent.cancel()', () => {
|
||||
send(agent, 'go')
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
await disposalDone
|
||||
await agent.done
|
||||
await driverDone(agent)
|
||||
|
||||
// No step opened, no model call ran, and the turn closed disposed.
|
||||
expect(streamed).toBe(false)
|
||||
@@ -326,7 +330,7 @@ describe('Agent.cancel()', () => {
|
||||
sessionId: SessionId('dispose-step-start-session'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
const agent = handle.agent as ReactLoopAgent
|
||||
const agent = handle.agent
|
||||
|
||||
let disposalDone: Promise<void> | undefined
|
||||
let streamed = false
|
||||
@@ -337,7 +341,7 @@ describe('Agent.cancel()', () => {
|
||||
|
||||
send(agent, 'go')
|
||||
await disposalDone
|
||||
await agent.done
|
||||
await driverDone(agent)
|
||||
|
||||
expect(streamed).toBe(false)
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
|
||||
@@ -7,16 +7,16 @@ import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { MockAdapter, textResponse } from './mock-adapter.ts'
|
||||
|
||||
const dirs: string[] = []
|
||||
afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) })
|
||||
|
||||
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') { dispose(); resolve() }
|
||||
@@ -92,10 +92,10 @@ describe('config-driven session id', () => {
|
||||
const config = { agents: [{ id: 'main', sessionId: SessionId('stdio-exact-reload'), model: 'mock' }] }
|
||||
|
||||
const firstLoop = await ctx.plugin(AgentLoop, config)
|
||||
let first: ReactLoopAgent | undefined
|
||||
let first: Agent | undefined
|
||||
for (let i = 0; i < 50 && first === undefined; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5))
|
||||
first = ctx.agents.get(SessionId('stdio-exact-reload')) as ReactLoopAgent | undefined
|
||||
first = ctx.agents.get(SessionId('stdio-exact-reload'))
|
||||
}
|
||||
expect(first).toBeDefined()
|
||||
first!.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } })
|
||||
@@ -103,10 +103,10 @@ describe('config-driven session id', () => {
|
||||
await firstLoop.dispose()
|
||||
|
||||
const secondLoop = await ctx.plugin(AgentLoop, config)
|
||||
let second: ReactLoopAgent | undefined
|
||||
let second: Agent | undefined
|
||||
for (let i = 0; i < 50 && second === undefined; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5))
|
||||
second = ctx.agents.get(SessionId('stdio-exact-reload')) as ReactLoopAgent | undefined
|
||||
second = ctx.agents.get(SessionId('stdio-exact-reload'))
|
||||
}
|
||||
expect(second).toBeDefined()
|
||||
expect(JSON.stringify(second!.session.deriveMessages())).toContain('remember me')
|
||||
@@ -129,7 +129,7 @@ describe('config-driven session id', () => {
|
||||
const config = { agents: [{ id: 'main', sessionId, model: 'mock' }] }
|
||||
const firstLoop = await ctx.plugin(AgentLoop, config)
|
||||
await expect.poll(() => ctx.agents.get(sessionId)).toBeDefined()
|
||||
const first = ctx.agents.get(sessionId) as ReactLoopAgent
|
||||
const first = ctx.agents.get(sessionId) as Agent
|
||||
|
||||
const flushGate = Promise.withResolvers<undefined>()
|
||||
let flushStarted = false
|
||||
@@ -155,7 +155,7 @@ describe('config-driven session id', () => {
|
||||
flushGate.resolve(undefined)
|
||||
await firstDisposal
|
||||
await expect.poll(() => ctx.agents.get(sessionId)).toBeDefined()
|
||||
const second = ctx.agents.get(sessionId) as ReactLoopAgent
|
||||
const second = ctx.agents.get(sessionId) as Agent
|
||||
expect(second).not.toBe(first)
|
||||
expect(JSON.stringify(second.session.deriveMessages())).toContain('persist before replacement')
|
||||
expect(failures).toEqual([])
|
||||
@@ -173,7 +173,7 @@ describe('config-driven session id', () => {
|
||||
const config = { agents: [{ id: 'main', sessionId, model: 'mock' }] }
|
||||
const firstLoop = await ctx.plugin(AgentLoop, config)
|
||||
await expect.poll(() => ctx.agents.get(sessionId)).toBeDefined()
|
||||
const first = ctx.agents.get(sessionId) as ReactLoopAgent
|
||||
const first = ctx.agents.get(sessionId) as Agent
|
||||
|
||||
const flushGate = Promise.withResolvers<undefined>()
|
||||
ctx.on('session/flush', (session) => {
|
||||
@@ -331,7 +331,7 @@ describe('config-driven session id', () => {
|
||||
await ctx1.plugin(AgentLoop, { agents: [{ id: SessionId('cfg'), provider: 'mock', model: 'mock' }] })
|
||||
await ctx1.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg')]))
|
||||
const a1 = ctx1.agents.list()[0] as ReactLoopAgent
|
||||
const a1 = ctx1.agents.list()[0] as Agent
|
||||
expect(a1.id).toBe(a1.session.id)
|
||||
expect(a1.session.id).toMatch(idPattern)
|
||||
expect(ctx1.agents.get(SessionId('cfg'))).toBeUndefined()
|
||||
@@ -350,7 +350,7 @@ describe('config-driven session id', () => {
|
||||
await ctx2.plugin(AgentLoop, { agents: [{ id: SessionId('cfg'), provider: 'mock', model: 'mock' }] })
|
||||
await ctx2.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg2')]))
|
||||
const a2 = ctx2.agents.list()[0] as ReactLoopAgent
|
||||
const a2 = ctx2.agents.list()[0] as Agent
|
||||
expect(a2.id).toBe(a2.session.id)
|
||||
expect(a2.session.id).toMatch(idPattern)
|
||||
expect(a2.session.id).not.toBe(a1.session.id)
|
||||
@@ -374,7 +374,7 @@ describe('config-driven session id', () => {
|
||||
await ctx1.plugin(AgentLoop, { agents: [] })
|
||||
await ctx1.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('first')]))
|
||||
const a1 = (await ctx1.agents.create({ sessionId: SessionId('sticky-1') })).agent as ReactLoopAgent
|
||||
const a1 = (await ctx1.agents.create({ sessionId: SessionId('sticky-1') })).agent
|
||||
a1.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } })
|
||||
await waitForIdle(ctx1, a1)
|
||||
await ctx1.fiber.dispose()
|
||||
@@ -392,10 +392,10 @@ describe('config-driven session id', () => {
|
||||
ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('second')]))
|
||||
|
||||
// The deferred resume runs on a microtask after the backend is available.
|
||||
let resumed: ReactLoopAgent | undefined
|
||||
let resumed: Agent | undefined
|
||||
for (let i = 0; i < 50 && !resumed; i++) {
|
||||
await new Promise(r => setTimeout(r, 5))
|
||||
resumed = ctx2.agents.get(SessionId('sticky-1')) as ReactLoopAgent | undefined
|
||||
resumed = ctx2.agents.get(SessionId('sticky-1'))
|
||||
}
|
||||
expect(resumed).toBeDefined()
|
||||
// The live session id IS the resumed id (NOT a fresh ${id}-session-<uuid>),
|
||||
|
||||
@@ -4,12 +4,16 @@ import LlmService, { CallId, ContentBlock, MessageSource, StreamChunk } from '@d
|
||||
import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool, type PostToolDecision } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { type ContinuationDecision } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS, ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import AgentRegistry, { type Agent, type ContinuationDecision } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from '@deepseek-ai/dsh-agent-loop'
|
||||
import { prepareReactLoopAgent } from '../src/agent.ts'
|
||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||
import { maxTokensResponse, MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
|
||||
|
||||
function driverDone(agent: Agent): Promise<void> {
|
||||
return (agent as Agent & { done: Promise<void> }).done
|
||||
}
|
||||
|
||||
/** Regression tests for agent-loop boundary, identity, and lifecycle contracts. */
|
||||
|
||||
async function harness(adapter: MockAdapter) {
|
||||
@@ -24,7 +28,7 @@ async function harness(adapter: MockAdapter) {
|
||||
return ctx
|
||||
}
|
||||
|
||||
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
@@ -35,7 +39,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
function send(agent: ReactLoopAgent, text: string) {
|
||||
function send(agent: Agent, text: string) {
|
||||
agent.send([{ type: 'text', text }])
|
||||
}
|
||||
|
||||
@@ -345,7 +349,7 @@ describe('abort during tool execution ends the turn', () => {
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'waiter', {})])
|
||||
const ctx = await harness(adapter)
|
||||
const started = Promise.withResolvers<undefined>()
|
||||
let agent!: ReactLoopAgent
|
||||
let agent!: Agent
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
agent = inner.agentLoop.create(SessionId('a-dispose-injection'), { provider: 'mock', model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
@@ -601,7 +605,7 @@ describe('disposed status is part of the agent/status contract', () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
|
||||
let agent!: ReactLoopAgent
|
||||
let agent!: Agent
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
@@ -614,7 +618,7 @@ describe('disposed status is part of the agent/status contract', () => {
|
||||
send(agent, 'go')
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
await fiber.dispose()
|
||||
await agent.done
|
||||
await driverDone(agent)
|
||||
|
||||
expect(statuses).toEqual(['running', 'disposed'])
|
||||
expect(reasons).toEqual([{ kind: 'disposed' }])
|
||||
@@ -624,7 +628,7 @@ describe('disposed status is part of the agent/status contract', () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
|
||||
let agent!: ReactLoopAgent
|
||||
let agent!: Agent
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
@@ -636,7 +640,7 @@ describe('disposed status is part of the agent/status contract', () => {
|
||||
send(agent, 'go')
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
await fiber.dispose()
|
||||
await agent.done // must not hang
|
||||
await driverDone(agent) // must not hang
|
||||
|
||||
expect(agent.status).toBe('disposed')
|
||||
expect(ctx.agents.get(SessionId('scoped'))).toBeUndefined() // unregistered despite the throw
|
||||
@@ -967,7 +971,7 @@ describe('turn and step boundary recovery', () => {
|
||||
}
|
||||
|
||||
/** Count turn/step boundary events for balance assertions. */
|
||||
function boundaryCounts(agent: ReactLoopAgent) {
|
||||
function boundaryCounts(agent: Agent) {
|
||||
const e = [...agent.session.events]
|
||||
return {
|
||||
turnStart: e.filter(x => x.type === 'turn/start').length,
|
||||
@@ -1139,7 +1143,7 @@ describe('turn and step boundary recovery', () => {
|
||||
// balanced with reason disposed (no error event for a disposal).
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await balancedHarness(adapter)
|
||||
let agent!: ReactLoopAgent
|
||||
let agent!: Agent
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
agent = inner.agentLoop.create(SessionId('a-dispose'), { provider: 'mock', model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
@@ -1150,7 +1154,7 @@ describe('turn and step boundary recovery', () => {
|
||||
send(agent, 'go')
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
await fiber.dispose() // dispose during the hanging step
|
||||
await agent.done
|
||||
await driverDone(agent)
|
||||
|
||||
const e = [...agent.session.events]
|
||||
const turnStarts = e.filter(x => x.type === 'turn/start').length
|
||||
@@ -1166,7 +1170,7 @@ describe('turn and step boundary recovery', () => {
|
||||
// Disposal remains authoritative when the listener also throws.
|
||||
const adapter = new MockAdapter([textResponse('never reached')])
|
||||
const ctx = await balancedHarness(adapter)
|
||||
let agent!: ReactLoopAgent
|
||||
let agent!: Agent
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
agent = inner.agentLoop.create(SessionId('a-prestep-dispose-throw'), { provider: 'mock', model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
@@ -1182,7 +1186,7 @@ describe('turn and step boundary recovery', () => {
|
||||
ctx.on('agent/error', (_a, _t, _s, error) => void errorEmits.push(error))
|
||||
|
||||
send(agent, 'go')
|
||||
await agent.done
|
||||
await driverDone(agent)
|
||||
|
||||
const e = [...agent.session.events]
|
||||
// Balanced: one turn/start, one turn/end carrying disposed (NOT error).
|
||||
@@ -1421,7 +1425,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
|
||||
return next()
|
||||
})
|
||||
|
||||
let agent!: ReactLoopAgent
|
||||
let agent!: Agent
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
agent = inner.agentLoop.create(SessionId('a-dispose-assemble'), { provider: 'mock', model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
@@ -1438,7 +1442,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
|
||||
|
||||
releaseAssemble()
|
||||
await disposalDone
|
||||
await agent.done
|
||||
await driverDone(agent)
|
||||
unlisten()
|
||||
|
||||
// Turn boundaries are durable rows; there is no `agent/*` mirror to assert.
|
||||
@@ -1471,7 +1475,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
|
||||
return next()
|
||||
})
|
||||
|
||||
let agent!: ReactLoopAgent
|
||||
let agent!: Agent
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
agent = inner.agentLoop.create(SessionId('a-cancel-assemble'), { provider: 'mock', model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
@@ -1486,7 +1490,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
|
||||
releaseAssemble()
|
||||
await waitForIdle(ctx, agent)
|
||||
await fiber.dispose()
|
||||
await agent.done
|
||||
await driverDone(agent)
|
||||
unlisten()
|
||||
|
||||
const e = [...agent.session.events]
|
||||
@@ -1525,7 +1529,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
|
||||
await blocker
|
||||
})
|
||||
|
||||
let agent!: ReactLoopAgent
|
||||
let agent!: Agent
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
agent = inner.agentLoop.create(SessionId('a-dispose-prestep'), { provider: 'mock', model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
@@ -1540,7 +1544,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
|
||||
const disposalDone = fiber.dispose()
|
||||
releasePreStep()
|
||||
await disposalDone
|
||||
await agent.done
|
||||
await driverDone(agent)
|
||||
|
||||
// After the pre-step seam finishes, the post-seam cancel/dispose check
|
||||
// catches disposal. The step was never opened, no LLM call was made.
|
||||
@@ -1576,7 +1580,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
|
||||
await blocker
|
||||
})
|
||||
|
||||
let agent!: ReactLoopAgent
|
||||
let agent!: Agent
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
agent = inner.agentLoop.create(SessionId('a-cancel-prestep'), { provider: 'mock', model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
@@ -1591,7 +1595,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
|
||||
releasePreStep()
|
||||
await waitForIdle(ctx, agent)
|
||||
await fiber.dispose()
|
||||
await agent.done
|
||||
await driverDone(agent)
|
||||
|
||||
const e = [...agent.session.events]
|
||||
expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1)
|
||||
@@ -1626,7 +1630,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
|
||||
return next()
|
||||
})
|
||||
|
||||
let agent!: ReactLoopAgent
|
||||
let agent!: Agent
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
agent = inner.agentLoop.create(SessionId('a-dispose-no-leak'), { provider: 'mock', model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
@@ -1637,7 +1641,7 @@ describe('disposal and cancellation during pre-step assembly', () => {
|
||||
const disposalDone = fiber.dispose()
|
||||
releaseAssemble()
|
||||
await disposalDone
|
||||
await agent.done
|
||||
await driverDone(agent)
|
||||
|
||||
const e = [...agent.session.events]
|
||||
expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1)
|
||||
|
||||
@@ -5,11 +5,15 @@ import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
|
||||
|
||||
function driverDone(agent: Agent): Promise<void> {
|
||||
return (agent as Agent & { done: Promise<void> }).done
|
||||
}
|
||||
|
||||
async function harness(adapter: MockAdapter) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
@@ -22,7 +26,7 @@ async function harness(adapter: MockAdapter) {
|
||||
return ctx
|
||||
}
|
||||
|
||||
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
@@ -33,7 +37,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
function send(agent: ReactLoopAgent, text: string) {
|
||||
function send(agent: Agent, text: string) {
|
||||
agent.send([{ type: 'text', text }])
|
||||
}
|
||||
|
||||
@@ -213,7 +217,7 @@ describe('disposed vs aborted branching', () => {
|
||||
it('handles dispose during model streaming producing reason "disposed"', async () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
let agent!: ReactLoopAgent
|
||||
let agent!: Agent
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
@@ -224,7 +228,7 @@ describe('disposed vs aborted branching', () => {
|
||||
send(agent, 'go')
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
await fiber.dispose() // dispose during hang
|
||||
await agent.done
|
||||
await driverDone(agent)
|
||||
|
||||
// Disposal wins abort classification because the error path checks it first.
|
||||
expect(reasons).toContainEqual({ kind: 'disposed' })
|
||||
|
||||
@@ -4,9 +4,9 @@ import LlmService, { CallId, type Message } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { type ContinuationDecision, type PromptDecision, type SessionStartSource } from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { type Agent, type ContinuationDecision, type PromptDecision, type SessionStartSource } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
|
||||
|
||||
/**
|
||||
@@ -30,7 +30,7 @@ async function harness(adapter: MockAdapter) {
|
||||
return ctx
|
||||
}
|
||||
|
||||
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
@@ -41,11 +41,11 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
function send(agent: ReactLoopAgent, text: string) {
|
||||
function send(agent: Agent, text: string) {
|
||||
agent.send([{ type: 'text', text }])
|
||||
}
|
||||
|
||||
function events(agent: ReactLoopAgent): SessionEvent[] {
|
||||
function events(agent: Agent): SessionEvent[] {
|
||||
return [...agent.session.events]
|
||||
}
|
||||
|
||||
|
||||
@@ -4,11 +4,15 @@ import LlmService, { CallId, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from './mock-adapter.ts'
|
||||
|
||||
function driverDone(agent: Agent): Promise<void> {
|
||||
return (agent as Agent & { done: Promise<void> }).done
|
||||
}
|
||||
|
||||
async function harness(adapter: MockAdapter, persona = '') {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
@@ -26,7 +30,7 @@ async function harness(adapter: MockAdapter, persona = '') {
|
||||
* invoke this right after send(), when the loop hasn't woken yet (status is
|
||||
* still 'idle' synchronously), so polling the current status would lie.
|
||||
*/
|
||||
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
@@ -37,7 +41,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
function send(agent: ReactLoopAgent, text: string) {
|
||||
function send(agent: Agent, text: string) {
|
||||
agent.send([{ type: 'text', text }])
|
||||
}
|
||||
|
||||
@@ -175,7 +179,7 @@ describe('agent loop', () => {
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
|
||||
const agent = handle.agent as ReactLoopAgent
|
||||
const agent = handle.agent
|
||||
send(agent, 'hi')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
@@ -998,7 +1002,7 @@ describe('agent loop', () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
|
||||
let agent!: ReactLoopAgent
|
||||
let agent!: Agent
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
@@ -1009,7 +1013,7 @@ describe('agent loop', () => {
|
||||
expect(agent.status).toBe('running')
|
||||
|
||||
await fiber.dispose()
|
||||
await agent.done
|
||||
await driverDone(agent)
|
||||
|
||||
expect(agent.status).toBe('disposed')
|
||||
expect(ctx.agents.get(SessionId('scoped'))).toBeUndefined()
|
||||
@@ -1029,7 +1033,7 @@ describe('agent loop', () => {
|
||||
})
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
|
||||
const agent = ctx.agents.list()[0]! as ReactLoopAgent
|
||||
const agent = ctx.agents.list()[0]!
|
||||
expect(agent).toBeDefined()
|
||||
expect(agent.id).toBe(agent.session.id)
|
||||
expect(agent.id).toMatch(/^config-agent-session-/)
|
||||
@@ -1052,7 +1056,7 @@ describe('agent loop', () => {
|
||||
agents: [{ id: SessionId('config-agent'), provider: 'mock', model: 'mock', cwd: '/work/project' }],
|
||||
})
|
||||
|
||||
const agent = ctx.agents.list()[0]! as ReactLoopAgent
|
||||
const agent = ctx.agents.list()[0]!
|
||||
expect(agent.session.header.cwd).toBe('/work/project')
|
||||
})
|
||||
|
||||
|
||||
@@ -17,9 +17,9 @@ import { LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import fc from 'fast-check'
|
||||
|
||||
/** A never-exhausting adapter: every model call returns the same short reply. */
|
||||
@@ -48,7 +48,7 @@ async function harness() {
|
||||
}
|
||||
|
||||
/** Resolve on the agent's next transition to idle (event-based, not polled). */
|
||||
function nextIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
function nextIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
@@ -61,7 +61,7 @@ function nextIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
|
||||
/** Record every status transition for the legal-machine assertion. Returns
|
||||
* the seen list plus a disposer for the listener (per the registry convention). */
|
||||
function recordStatus(ctx: Context, agent: ReactLoopAgent): { seen: string[]; dispose: () => void } {
|
||||
function recordStatus(ctx: Context, agent: Agent): { seen: string[]; dispose: () => void } {
|
||||
const seen: string[] = []
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent) seen.push(status)
|
||||
@@ -69,13 +69,13 @@ function recordStatus(ctx: Context, agent: ReactLoopAgent): { seen: string[]; di
|
||||
return { seen, dispose }
|
||||
}
|
||||
|
||||
function userMessageTexts(agent: ReactLoopAgent): string[] {
|
||||
function userMessageTexts(agent: Agent): string[] {
|
||||
return agent.session.events
|
||||
.filter(e => e.type === 'user/message')
|
||||
.map(e => (e.data as { content: { type: string; text?: string }[] }).content.map(b => b.text ?? '').join(''))
|
||||
}
|
||||
|
||||
function turnNumbers(agent: ReactLoopAgent): number[] {
|
||||
function turnNumbers(agent: Agent): number[] {
|
||||
return agent.session.events
|
||||
.filter(e => e.type === 'turn/start')
|
||||
.map(e => (e.data as { turn: number }).turn)
|
||||
|
||||
@@ -12,9 +12,9 @@ import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
|
||||
|
||||
async function harness(adapter: MockAdapter, persona = 'stable base') {
|
||||
@@ -29,7 +29,7 @@ async function harness(adapter: MockAdapter, persona = 'stable base') {
|
||||
return ctx
|
||||
}
|
||||
|
||||
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
@@ -40,7 +40,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
function send(agent: ReactLoopAgent, text: string) {
|
||||
function send(agent: Agent, text: string) {
|
||||
agent.send([{ type: 'text', text }])
|
||||
}
|
||||
|
||||
@@ -226,7 +226,7 @@ describe('request stability across the loop', () => {
|
||||
seed: [...agent.session.events],
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
const agent2 = handle.agent as ReactLoopAgent
|
||||
const agent2 = handle.agent
|
||||
send(agent2, 'second')
|
||||
await waitForIdle(ctx2, agent2)
|
||||
|
||||
|
||||
@@ -8,10 +8,10 @@ import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { MockAdapter, textResponse } from './mock-adapter.ts'
|
||||
|
||||
const dirs: string[] = []
|
||||
@@ -51,7 +51,7 @@ async function persistSession(sessionId: SessionId): Promise<string> {
|
||||
return root
|
||||
}
|
||||
|
||||
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') { dispose(); resolve() }
|
||||
@@ -124,7 +124,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
// Lifecycle 1: create a no-cwd session and run a turn.
|
||||
const adapter1 = new MockAdapter([textResponse('a')])
|
||||
const { ctx: ctx1, root } = await persistentHarness(adapter1)
|
||||
const a1 = (await ctx1.agents.create({ sessionId: SessionId('nocwd-sess') })).agent as ReactLoopAgent
|
||||
const a1 = (await ctx1.agents.create({ sessionId: SessionId('nocwd-sess') })).agent
|
||||
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
|
||||
await waitForIdle(ctx1, a1)
|
||||
await ctx1.fiber.dispose()
|
||||
@@ -140,7 +140,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
await ctx2.plugin(AgentLoop, { agents: [] })
|
||||
await ctx2.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx2.llm.registerAdapter(['mock'], adapter2)
|
||||
const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('nocwd-sess') })).agent as ReactLoopAgent
|
||||
const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('nocwd-sess') })).agent
|
||||
expect(a2.session.header.cwd).toBeUndefined()
|
||||
await ctx2.fiber.dispose()
|
||||
})
|
||||
@@ -151,7 +151,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
const { ctx: ctx1, root } = await persistentHarness(adapter1)
|
||||
const sources1: string[] = []
|
||||
ctx1.on('agent/session-start', (_agent, source) => void sources1.push(source))
|
||||
const a1 = (await ctx1.agents.create({ sessionId: SessionId('start-sess') })).agent as ReactLoopAgent
|
||||
const a1 = (await ctx1.agents.create({ sessionId: SessionId('start-sess') })).agent
|
||||
expect(sources1).toEqual(['startup'])
|
||||
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
|
||||
await waitForIdle(ctx1, a1)
|
||||
@@ -443,7 +443,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
await ctx2.plugin(AgentLoop, { agents: [] })
|
||||
await ctx2.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx2.llm.registerAdapter(['mock'], adapter2)
|
||||
const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('forked-sess') })).agent as ReactLoopAgent
|
||||
const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('forked-sess') })).agent
|
||||
expect(a2.session.header.parentSession).toBe('parent-sess')
|
||||
expect(a2.session.header.cwd).toBe('/w')
|
||||
expect(a2.session.header.seedLength).toBe(seed.length)
|
||||
@@ -455,7 +455,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
// clean disposal follows, so disk presence proves its own checkpoint ran.
|
||||
const adapter1 = new MockAdapter([textResponse('answer')])
|
||||
const { ctx: ctx1, root } = await persistentHarness(adapter1)
|
||||
const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent as ReactLoopAgent
|
||||
const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent
|
||||
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
|
||||
await waitForIdle(ctx1, a1)
|
||||
a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } })
|
||||
@@ -478,7 +478,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
// survive persistence and resume.
|
||||
const adapter1 = new MockAdapter([textResponse('answer')])
|
||||
const { ctx: ctx1, root } = await persistentHarness(adapter1)
|
||||
const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent as ReactLoopAgent
|
||||
const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent
|
||||
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
|
||||
await waitForIdle(ctx1, a1)
|
||||
a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } })
|
||||
@@ -496,7 +496,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
await ctx2.plugin(AgentLoop, { agents: [] })
|
||||
await ctx2.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx2.llm.registerAdapter(['mock'], adapter2)
|
||||
const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('inject-sess') })).agent as ReactLoopAgent
|
||||
const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('inject-sess') })).agent
|
||||
const flat = JSON.stringify(a2.session.deriveMessages())
|
||||
expect(flat).toContain('background task 42 finished')
|
||||
await ctx2.fiber.dispose()
|
||||
@@ -506,7 +506,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
// Lifecycle 1: run one full turn, persisting it.
|
||||
const adapter1 = new MockAdapter([textResponse('first answer')])
|
||||
const { ctx: ctx1, root } = await persistentHarness(adapter1)
|
||||
const a1 = (await ctx1.agents.create({ sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } })).agent as ReactLoopAgent
|
||||
const a1 = (await ctx1.agents.create({ sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } })).agent
|
||||
a1.send([{ type: 'text', text: 'first question' }], { source: { kind: 'user' } })
|
||||
await waitForIdle(ctx1, a1)
|
||||
const events1 = [...a1.session.events]
|
||||
@@ -526,7 +526,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
await ctx2.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx2.llm.registerAdapter(['mock'], adapter2)
|
||||
|
||||
const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('sess-resume') })).agent as ReactLoopAgent
|
||||
const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('sess-resume') })).agent
|
||||
// The resumed session carries the prior history…
|
||||
expect(a2.session.id).toBe('sess-resume')
|
||||
expect(a2.session.events.length).toBe(events1.length)
|
||||
|
||||
@@ -8,7 +8,7 @@ import AgentRegistry, { agentEvents, assembleContextFor } from '@deepseek-ai/dsh
|
||||
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { scopeOf } from '@deepseek-ai/dsh-scope'
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { MockAdapter, textResponse } from './mock-adapter.ts'
|
||||
|
||||
@@ -28,7 +28,7 @@ async function harness(adapter: MockAdapter = new MockAdapter([textResponse('ok'
|
||||
return (await harnessWithLoop(adapter)).ctx
|
||||
}
|
||||
|
||||
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
@@ -736,7 +736,7 @@ describe('agent scope lifecycle', () => {
|
||||
const ctx = await harness()
|
||||
let ownerCtx!: Context
|
||||
let creating!: ReturnType<typeof ctx.agents.create>
|
||||
let announced!: ReactLoopAgent
|
||||
let announced!: Agent
|
||||
const statuses: string[] = []
|
||||
let scopeDisposed = false
|
||||
let observerSawLive = false
|
||||
@@ -745,7 +745,7 @@ describe('agent scope lifecycle', () => {
|
||||
})
|
||||
ctx.on('agent/session-start', (agent) => {
|
||||
if (agent.id !== SessionId('session-start-dispose-s')) return
|
||||
announced = agent as ReactLoopAgent
|
||||
announced = agent
|
||||
disposeCurrentLifecycle(ownerCtx)
|
||||
})
|
||||
ctx.on('agent/session-start', (agent) => {
|
||||
|
||||
@@ -10,8 +10,8 @@ import SessionStore, { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import ToolRegistry, { defineTool, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { MockAdapter, textResponse } from './mock-adapter.ts'
|
||||
|
||||
async function harness(adapter: MockAdapter, maxParallelToolCalls?: number) {
|
||||
@@ -29,7 +29,7 @@ async function harness(adapter: MockAdapter, maxParallelToolCalls?: number) {
|
||||
return ctx
|
||||
}
|
||||
|
||||
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') { dispose(); resolve() }
|
||||
@@ -37,7 +37,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
function events(agent: ReactLoopAgent): SessionEvent[] {
|
||||
function events(agent: Agent): SessionEvent[] {
|
||||
return [...agent.session.events]
|
||||
}
|
||||
|
||||
|
||||
@@ -13,9 +13,9 @@ import SessionStore, { SessionId, foldRequestHeader } from '@deepseek-ai/dsh-ses
|
||||
import SystemPrompt, { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { Config as SystemPromptConfig } from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { MockAdapter, textResponse } from './mock-adapter.ts'
|
||||
|
||||
async function harness(adapter: MockAdapter, toolOrder?: SystemPromptConfig['toolOrder']) {
|
||||
@@ -30,7 +30,7 @@ async function harness(adapter: MockAdapter, toolOrder?: SystemPromptConfig['too
|
||||
return ctx
|
||||
}
|
||||
|
||||
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
|
||||
@@ -4,9 +4,9 @@ import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { type ContinuationStop } from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { type Agent, type ContinuationStop } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
|
||||
|
||||
@@ -23,7 +23,7 @@ async function harness(adapter: MockAdapter): Promise<Context> {
|
||||
return ctx
|
||||
}
|
||||
|
||||
function send(agent: ReactLoopAgent, text = 'go'): Promise<void> {
|
||||
function send(agent: Agent, text = 'go'): Promise<void> {
|
||||
agent.send([{ type: 'text', text }])
|
||||
return agent.whenIdle()
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ export type ContinuationStop = Extract<ContinuationDecision, { action: 'stop' }>
|
||||
/** Why a session lifecycle began; seeded creates are `startup`, while persisted loads are `resume`. */
|
||||
export type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact'
|
||||
|
||||
/** Public agent handle; the concrete driver belongs to `@deepseek-ai/dsh-agent-loop`. */
|
||||
/** Public agent handle; its concrete implementation is internal to `@deepseek-ai/dsh-agent-loop`. */
|
||||
export interface Agent {
|
||||
/** The single identity shared with {@link session}. */
|
||||
readonly id: SessionId
|
||||
|
||||
@@ -3,7 +3,8 @@ import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import * as RepeatToolGuard from '@deepseek-ai/dsh-repeat-tool-guard'
|
||||
import type { Config } from '@deepseek-ai/dsh-repeat-tool-guard'
|
||||
@@ -28,12 +29,12 @@ async function harness(config: Config = {}): Promise<Context> {
|
||||
return ctx
|
||||
}
|
||||
|
||||
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => { const d = ctx.on('agent/status', (s, st) => { if (s === agent && st === 'idle') { d(); resolve() } }) })
|
||||
}
|
||||
|
||||
/** Every `context/message` in the agent's log, flattened to joined text + source for terse assertions. */
|
||||
function reminders(agent: ReactLoopAgent): { text: string; source: unknown }[] {
|
||||
function reminders(agent: Agent): { text: string; source: unknown }[] {
|
||||
return [...agent.session.events]
|
||||
.filter((e): e is SessionEvent<'context/message'> => e.type === 'context/message')
|
||||
.map(e => ({
|
||||
@@ -249,14 +250,14 @@ describe('chain semantics', () => {
|
||||
]))
|
||||
// Loop agents are torn down by disposing the scope that created them
|
||||
// (the loop.spec pattern): a child plugin fiber owns `first`.
|
||||
let first!: ReactLoopAgent
|
||||
let first!: Agent
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
first = inner.agentLoop.create(SessionId('reused'), { provider: 'mock', model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
first.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, first)
|
||||
await fiber.dispose()
|
||||
await first.done
|
||||
await first.whenIdle()
|
||||
|
||||
const second = ctx.agentLoop.create(SessionId('reused'), { provider: 'mock', model: 'mock' })
|
||||
second.send([{ type: 'text', text: 'go' }])
|
||||
|
||||
@@ -6,7 +6,8 @@ import { Context, type Fiber } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import { SubagentRunId } from '@deepseek-ai/dsh-subagent'
|
||||
@@ -52,7 +53,7 @@ async function harnessWithFiber(configDir: string, adapter: MockAdapter): Promis
|
||||
return { ctx, hooks }
|
||||
}
|
||||
|
||||
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') { dispose(); resolve() }
|
||||
@@ -60,7 +61,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
function events(agent: ReactLoopAgent): SessionEvent[] {
|
||||
function events(agent: Agent): SessionEvent[] {
|
||||
return [...agent.session.events]
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ import { Context } from 'cordis'
|
||||
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import { SubagentRunId } from '@deepseek-ai/dsh-subagent'
|
||||
@@ -38,10 +39,10 @@ async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOp
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return ctx
|
||||
}
|
||||
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => { const d = ctx.on('agent/status', (s, st) => { if (s === agent && st === 'idle') { d(); resolve() } }) })
|
||||
}
|
||||
function events(agent: ReactLoopAgent): SessionEvent[] { return [...agent.session.events] }
|
||||
function events(agent: Agent): SessionEvent[] { return [...agent.session.events] }
|
||||
/** Poll until `predicate` holds or the deadline passes — robust to detached
|
||||
* emit-listener hooks firing on a `.then` (a fixed sleep flakes under load). */
|
||||
async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise<void> {
|
||||
@@ -466,8 +467,8 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
const { SessionId } = await import('@deepseek-ai/dsh-session')
|
||||
const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: workspace }, agentOptions: { provider: 'mock', model: 'mock' } })
|
||||
handle.agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, handle.agent as ReactLoopAgent)
|
||||
expect(events(handle.agent as ReactLoopAgent).some(e => e.type === 'context/message'
|
||||
await waitForIdle(ctx, handle.agent)
|
||||
expect(events(handle.agent).some(e => e.type === 'context/message'
|
||||
&& e.data.content.some(b => b.type === 'text' && b.text.includes(`dir=${workspace}`)))).toBe(true)
|
||||
await handle.dispose()
|
||||
})
|
||||
@@ -664,7 +665,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
const { SessionId } = await import('@deepseek-ai/dsh-session')
|
||||
const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { provider: 'mock', model: 'mock' } })
|
||||
handle.agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, handle.agent as ReactLoopAgent)
|
||||
await waitForIdle(ctx, handle.agent)
|
||||
|
||||
expect(existsSync(marker)).toBe(true) // the marker landed in the SESSION dir
|
||||
const { readFileSync } = await import('node:fs')
|
||||
|
||||
@@ -6,7 +6,8 @@ import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex'
|
||||
@@ -46,14 +47,14 @@ async function harness(dir: string, adapter: MockAdapter): Promise<Context> {
|
||||
return ctx
|
||||
}
|
||||
|
||||
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') { dispose(); resolve() }
|
||||
})
|
||||
})
|
||||
}
|
||||
function events(agent: ReactLoopAgent): SessionEvent[] { return [...agent.session.events] }
|
||||
function events(agent: Agent): SessionEvent[] { return [...agent.session.events] }
|
||||
|
||||
/** Poll `predicate` until true or the deadline passes (detached hook effects can't be awaited directly). */
|
||||
async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise<void> {
|
||||
|
||||
@@ -6,7 +6,8 @@ import { Context } from 'cordis'
|
||||
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex'
|
||||
@@ -33,10 +34,10 @@ async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOp
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return ctx
|
||||
}
|
||||
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => { const d = ctx.on('agent/status', (s, st) => { if (s === agent && st === 'idle') { d(); resolve() } }) })
|
||||
}
|
||||
function events(agent: ReactLoopAgent): SessionEvent[] { return [...agent.session.events] }
|
||||
function events(agent: Agent): SessionEvent[] { return [...agent.session.events] }
|
||||
/** Poll until `predicate` holds or the deadline passes — robust to detached
|
||||
* emit-listener hooks firing on a `.then` (a fixed sleep flakes under load). */
|
||||
async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise<void> {
|
||||
@@ -627,7 +628,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
|
||||
const { SessionId } = await import('@deepseek-ai/dsh-session')
|
||||
const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { provider: 'mock', model: 'mock' } })
|
||||
handle.agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, handle.agent as ReactLoopAgent)
|
||||
await waitForIdle(ctx, handle.agent)
|
||||
expect(existsSync(marker)).toBe(true)
|
||||
expect(readFileSync(marker, 'utf8').trim().endsWith(sessionDir.split('/').pop()!)).toBe(true)
|
||||
await handle.dispose()
|
||||
|
||||
@@ -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'
|
||||
@@ -108,7 +108,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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ export const Config: z<Config> = 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 []
|
||||
|
||||
@@ -11,7 +11,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<typeof MockAdapter>[0]
|
||||
|
||||
@@ -45,28 +44,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')])
|
||||
@@ -89,7 +66,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')
|
||||
@@ -97,6 +73,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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -7,7 +7,7 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test
|
||||
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<typeof MockAdapter>[0]
|
||||
|
||||
@@ -30,19 +30,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')])
|
||||
@@ -51,7 +38,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()
|
||||
@@ -76,7 +63,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)
|
||||
})
|
||||
|
||||
@@ -9,7 +9,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<typeof MockAdapter>[0]
|
||||
|
||||
@@ -110,11 +110,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()
|
||||
})
|
||||
|
||||
@@ -122,7 +122,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 () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -20,13 +20,12 @@ 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
|
||||
* 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.
|
||||
@@ -57,7 +56,7 @@ export function spawnFailure(child: ChildProcess): Promise<Error> {
|
||||
* already gone.
|
||||
* @param child - the child process to await.
|
||||
*/
|
||||
export function waitForExit(child: ChildProcess): Promise<void> {
|
||||
function waitForExit(child: ChildProcess): Promise<void> {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve()
|
||||
return new Promise<void>(resolve => child.once('exit', () => { resolve() }))
|
||||
}
|
||||
@@ -72,7 +71,7 @@ export function waitForExit(child: ChildProcess): Promise<void> {
|
||||
* @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<boolean> {
|
||||
function exitsWithin(child: ChildProcess, ms: number): Promise<boolean> {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true)
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const onExit = (): void => {
|
||||
|
||||
@@ -9,10 +9,7 @@ import {
|
||||
buildChildEnv,
|
||||
createIsolatedConfigDir,
|
||||
disposeChildProcess,
|
||||
exitsWithin,
|
||||
SENSITIVE_ENV_PATTERN,
|
||||
spawnFailure,
|
||||
waitForExit,
|
||||
} from '../src/index.ts'
|
||||
|
||||
// `rm` is real-passthrough except for one deterministic failure. Permission-based recursive-rm
|
||||
@@ -44,6 +41,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
|
||||
}
|
||||
@@ -77,11 +76,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +91,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'
|
||||
@@ -108,7 +109,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)
|
||||
})
|
||||
|
||||
@@ -146,7 +146,7 @@ describe('spawnFailure', () => {
|
||||
const fake = new FakeChild({ diesOn: 'SIGTERM' })
|
||||
const failure = spawnFailure(asChild(fake))
|
||||
fake.kill('SIGTERM')
|
||||
await waitForExit(asChild(fake))
|
||||
await new Promise<void>(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([
|
||||
@@ -157,51 +157,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()
|
||||
@@ -227,12 +182,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 () => {
|
||||
@@ -244,6 +215,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 })
|
||||
|
||||
@@ -161,13 +161,13 @@ export async function settleRun(run: SubagentRun): Promise<TaskOutcome> {
|
||||
* 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:
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
|
||||
@@ -21,7 +22,7 @@ async function harness(adapter: MockAdapter): Promise<Context> {
|
||||
return ctx
|
||||
}
|
||||
|
||||
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
|
||||
`AgentLoop` — provided by `@deepseek-ai/dsh-agent-loop`.
|
||||
|
||||
Concrete ReactLoopAgent factory and driver service.
|
||||
Concrete agent factory and driver service.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L408)
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L407)
|
||||
|
||||
### ctx.agentLoop.create(id, options?, meta?)
|
||||
|
||||
```ts website-api
|
||||
create(id: SessionId, options: AgentOptions = {}, meta: Pick<SessionHeader, 'cwd'> = {}): ReactLoopAgent
|
||||
create(id: SessionId, options: AgentOptions = {}, meta: Pick<SessionHeader, 'cwd'> = {}): Agent
|
||||
```
|
||||
|
||||
Create an agent and session under one caller-supplied identity, owned by the accessing fiber. Constructor-driven config calls mint a fresh combined id before entering this boundary.
|
||||
@@ -22,7 +22,7 @@ Create an agent and session under one caller-supplied identity, owned by the acc
|
||||
|
||||
**Returns** the published running agent.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L543)
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L542)
|
||||
|
||||
### ctx.agentLoop.createAgent(ownerCtx, options)
|
||||
|
||||
@@ -37,7 +37,7 @@ Create an owned agent on a caller-supplied session id.
|
||||
|
||||
**Returns** the published handle.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L565)
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L564)
|
||||
|
||||
### ctx.agentLoop.resume(ownerCtx, options)
|
||||
|
||||
@@ -52,4 +52,4 @@ Resume an owned agent from the configured persistence service.
|
||||
|
||||
**Returns** the published handle.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L597)
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L596)
|
||||
|
||||
@@ -228,7 +228,7 @@ A declarative agent entry failed before it could publish a live agent. Consumers
|
||||
- `sessionId` — exact shared agent/session identity that failed startup.
|
||||
- `error` — persistence, setup, or publication failure.
|
||||
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L363)
|
||||
[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent-loop/src/index.ts#L362)
|
||||
|
||||
## approval/*
|
||||
|
||||
|
||||
Reference in New Issue
Block a user