Merge origin/master into worktree/explicit-turn-signal
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# dsh-agent-loop
|
||||
|
||||
Concrete `ReactLoopAgent` implementation and loop driver.
|
||||
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.
|
||||
|
||||
@@ -8,22 +8,24 @@ This is the only package in the harness that contains concrete loop logic. Every
|
||||
|
||||
### Public API
|
||||
|
||||
Creation and resume use one caller-owned transaction: compose while unpublished, enter both registries, announce lifecycle edges, then start the driver. Failure rolls back private resources; caller, handle, and provider teardown share one quiescence boundary. The interface contract and ownership order live in [`dsh-agent`](../agent/README.md) and the [agent-scope runtime RFC](../../../docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md).
|
||||
Creation and resume are one rollback-covered transaction: construct a private session, concrete agent, and scoped context; await optional setup; enter both registries; announce `session/created` then `agent/created`; emit `agent/session-start`; and only then start the driver. Setup receives the full scoped `Context` as trusted same-process composition code and must not drive the unpublished agent. Ordinary typed identity and option inputs are borrowed under their readonly contract, while seed events and session metadata are validated and snapshotted because they cross the durable session boundary. An optional `AbortSignal` cancels only load/setup/publication and is detached before the returned handle becomes visible.
|
||||
|
||||
Caller-chosen ids arbitrate only at final registry entry, so concurrent contenders may prepare but every loser rolls back. Entry-bound detach capabilities cannot remove a later same-id replacement. Teardown stops and drains—including idle-injection flushes—before detaching agent, session, and scope; ids become reusable at detach.
|
||||
The caller fiber and the AgentLoop provider are co-owners. `AgentFactory.createAgent(ownerCtx, options)` and `resume(ownerCtx, options)` receive caller ownership explicitly, while the factory keeps its own dependency context for `sessions`/`llm`/`tools`/`systemPrompt`; this lets a caller inject only `agents` without shrinking the new agent's service surface. Caller unload, handle disposal, or provider unload converge on one memoized quiescence boundary. Provider shutdown waits both resource teardown and the public create/resume wrapper that observed deactivation, so no continuation can publish after dependencies disappear.
|
||||
|
||||
- `ctx.agentLoop.create(id, options?, meta?)` synchronously creates a caller-fiber-owned agent with a fresh generated session id and optional cwd. Each call starts a new session rather than applying resume-or-create policy.
|
||||
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 }): 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):
|
||||
|
||||
- `ctx.agents.create({ agentId, sessionId, meta?, seed?, agentOptions?, setup?, signal? })` validates and snapshots durable seed and metadata, awaits optional composition while unpublished, creates on the supplied session id, and returns an owned [`AgentHandle`](../agent/README.md). Its signal applies only until publication.
|
||||
- `ctx.agents.resume({ agentId, resumeSessionId, agentOptions?, setup?, signal? })` loads through optional [session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md), continues stored history and turn numbering under the resumed session id, and follows the same unpublished setup and creation-only cancellation boundary. It rejects when no persistence backend is mounted.
|
||||
- `ctx.agents.create({ sessionId, meta?, seed?, agentOptions?, setup?, signal? }): Promise<AgentHandle>` — programmatic create under the caller-supplied shared id. It awaits the unpublished setup transaction before returning; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix after the session boundary validates and snapshots the durable values. `signal` applies only until this promise settles. The resolved [`AgentHandle`](../agent/README.md) owns exact teardown.
|
||||
- `ctx.agents.resume({ resumeSessionId, agentOptions?, setup?, signal? }): Promise<AgentHandle>` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)), register the agent under that same id, reconstruct its history, then await setup against a fresh unpublished agent scope before rollback-covered publication. Turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). `signal` is creation-only. Returns an `AgentHandle`.
|
||||
|
||||
The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loop fiber (it discards the handle). For a programmatic agent, the handle holder is the only consumer-facing teardown capability; AgentLoop provider unload is the independent structural teardown edge, not another handle exposed to application code.
|
||||
|
||||
### Injected services
|
||||
|
||||
`agents`, `agentExecution`, `sessions`, `llm`, `tools`, `systemPrompt` — all six interface services. The loop cannot activate without `agentExecution`; the default bundle loads its provider before the loop.
|
||||
`agents`, `sessions`, `llm`, `tools`, `systemPrompt` — all five interface services.
|
||||
|
||||
### Configuration (schemastery)
|
||||
|
||||
@@ -42,23 +44,19 @@ 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.
|
||||
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. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy.
|
||||
|
||||
`Inbox`, `runLoop`, and the instance-bound publication/start controls are package-internal. The package root does not export them, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than constructing or starting the driver internals. `ReactLoopAgent.send()`, running `steer()`, and open-turn `inject()` materialize content plus resolved source once as detached, deeply frozen lossless JSON; malformed data throws before enqueue or append. An injection that arrives while the current step executes assistant tool calls stays in a FIFO until the batch settles; successful batches place it after the complete result batch, and interrupted batches drain it before the turn closes.
|
||||
Each concrete `send()` materializes content plus resolved source once as a detached, deeply frozen lossless-JSON FIFO item. If claimed, it is the sole ordinary message in its turn; a successor waits for the preceding ordinary turn's checkpoint to settle, while cancellation, disposal, or a pre-start failure may drop it without a turn. Running `steer()` enters the steering FIFO: an open turn records it at the next steering checkpoint before a request or continuation decision, but policy can still stop before another step; steering left after turn close and its checkpoint becomes later queued input unless terminal turn policy, cancellation, or disposal discards it. Open-turn `inject()` uses the same accepted-value boundary but defers in a FIFO while the current step executes assistant tool calls; successful batches place it after all results, and interrupted batches drain it before turn close. Malformed data throws before enqueue or append.
|
||||
|
||||
### Loop lifecycle (`loop.ts`)
|
||||
|
||||
The driver owns one agent for its lifetime and runs inside `ctx.agentExecution.run({ agent }, ...)`, so process-local asynchronous continuations can recover the initiating Agent. The ALS frame contains only `{ agent }`: creation, persistence load, and unpublished setup stay outside the child boundary, while turn, step, signal, and other control state remain explicit at every seam. The [execution-context package](../agent-execution/README.md) owns propagation and detached-work rules.
|
||||
|
||||
The loop records turn, step, request, stream, and tool boundaries in the session log; live extension events coordinate policy around those durable facts. The [architecture turn flow](../../../docs/architecture.md#turn-flow) and generated [event catalog](../../../docs/cordis-catalog/events.md) are the authoritative sequence and signatures.
|
||||
The driver owns one agent for its lifetime and runs inside `ctx.agents.withInitiator(agent, ...)`. Package-private orchestration entry points recover the exact Agent, derive `agent.session` once, and let operation-local helpers capture it instead of forwarding the concrete driver or per-operation `Session` through shallow interfaces. A helper keeps an explicit `Session` when that is its actual interface, while creation, persistence load, unpublished setup, services, workers, processes, persistence, and wire protocols retain their explicit identities. The [agent service](../agent/README.md#initiating-agent-scope) owns propagation, teardown, and detached-work rules.
|
||||
|
||||
Every provider call that reaches a successful finish appends exactly one `assistant/message` completion anchor, including content-less calls and `max-tokens` finishes. A successful `agent/step-result` stores its transformed content; a rejected result records empty content before the original failure continues. The anchor retains exact chunk provenance (`[]` for a stream with no chunks) and usage when available, while empty content stays out of derived message history.
|
||||
|
||||
Plugin failure ends the current turn, not the loop. The loop creates one private turn cancellation holder before announcing `running`, passes its single signal through prompt handling, prompt assembly, every step, model and tool execution, continuation, terminal stop, turn end, and durability flush, then discards it. A replacement prompt accepted after cancellation receives a fresh holder, while all work in the cancelled turn observes the first typed runtime cause. The durable turn outcome is only `aborted`; disposal is a separate runtime interrupt and wins classification even if cancellation reached the signal first.
|
||||
|
||||
Cancellation is cooperative: the loop checks for interruption between awaited boundaries but does not abandon an in-process listener, adapter, or tool Promise with `Promise.race`. `whenIdle()` and handle disposal therefore observe real quiescence. See the [explicit turn cancellation RFC](../../../docs/rfc/implemented/architecture/2026-07-16-explicit-turn-cancellation.md).
|
||||
Plugin failure ends the current turn, not the loop. Only final adapter dispatch/iteration failures and terminal in-band error or aborted finishes enter `agent/request-error`; middleware, result processing, tools, and `agent/post-step` remain ordinary turn failures. Recovery observes a closed failed step, and a retry rebuilds the request from the durable log in a new numbered step. AgentLoop privately owns one cancellation holder whose explicit signal spans prompt policy, assembly, every step, model and tool work, continuation, terminal stop, turn close, and flush; the next turn gets a fresh signal. `cancel()` strictly normalizes a runtime-only `user | parent` cause, clears pending work, and cooperatively aborts the holder without leaking to the next prompt; durable `turn/end` remains coarse `aborted`, and undispatched model tool calls receive synthetic `tool/call` and aborted result pairs. Disposal wins terminal classification, and work that ignores the signal must settle before quiescence. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns the lifecycle and race contract. Terminal continuation stops remain authoritative through turn close and durability flush.
|
||||
|
||||
Within a step, exclusive calls form barriers; parallel-safe calls use a bounded rolling pool and are reclassified before start. Only dispatch/body overlaps. Policy, durable results, and result context remain model-ordered. Abort stops new calls, drains started results, then drains accepted batch context before the turn closes through the normal abort path.
|
||||
|
||||
@@ -66,7 +64,7 @@ Within a step, exclusive calls form barriers; parallel-safe calls use a bounded
|
||||
|
||||
Everything that goes beyond "call the model, run the tools, repeat" belongs to plugins listening on the event taxonomy:
|
||||
- Hooks and policy: the relevant `agent/*` checkpoints plus the guarded `tools/pre-execute` → `tools/execute` → `tools/post-execute` → `tools/result` pipeline; exact signatures and modes live in the [generated event catalog](../../../docs/cordis-catalog/events.md)
|
||||
- Compaction: `agent/pre-step`
|
||||
- Compaction: pressure on `agent/post-step`; canonical context overflow on `agent/request-error`
|
||||
- Sandbox, permission, plan mode: `tools/pre-execute` for extensible deny/ask, `tools.guard()` for monotonic owner policy, `tools/post-execute` for result decisions, and `tools/result` for final observation
|
||||
- Sub-agents: implemented outside the loop as `ctx.subagents` providers; in-process providers use `ctx.agents.create()` and owned `AgentHandle` teardown, while generic [`ctx.tasks`](../../tasks/tasks/) plus [`dsh-tool-subagent`](../../subagent/tool-subagent/) own background collection.
|
||||
- Persistence: `session/event` + `session/flush`
|
||||
@@ -76,19 +74,49 @@ Everything that goes beyond "call the model, run the tools, repeat" belongs to p
|
||||
|
||||
### Complete conversation request
|
||||
|
||||
**What the model sees**: For each step, the loop sends the rendered per-agent system prompt, visible tool schemas, the frozen session prefix, and the session's derived messages. It supplies `model` and `cwd` variable values but no additional fixed prose.
|
||||
#### What the model sees
|
||||
|
||||
**Token effect**: System text, schemas, and prefix are paid again on every step. Per-agent scoping chooses the initial contributions, while the authoritative assembly waterfall can alter the final request and makes its listener responsible for protocol coherence.
|
||||
For each step, the loop sends the rendered per-agent system prompt, visible tool schemas, the frozen session prefix, and the session's derived messages. It supplies `model` and `cwd` variable values but no additional fixed prose.
|
||||
|
||||
#### Token effect
|
||||
|
||||
System text, schemas, and prefix are paid again on every step. Per-agent scoping chooses the initial contributions, while the authoritative assembly waterfall can alter the final request and makes its listener responsible for protocol coherence.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only only while system text, schemas, session prefix, and earlier history remain byte-identical under the same provider and model route. A token-bearing assembly rewrite or composition change may invalidate reuse from the first altered request token.
|
||||
|
||||
### Retained message history
|
||||
|
||||
**What the model sees**: Accepted user messages, assistant messages, tool calls and results, injected context, and steering are logged and sent on later steps. Raw stream chunks, lifecycle boundaries, and other log-only events are excluded.
|
||||
#### What the model sees
|
||||
|
||||
**Token effect**: Input grows with every surface message until a compaction replacement shadows older nodes; a multi-step tool turn resends the accumulated prefix and history each step.
|
||||
Accepted user messages, assistant messages, tool calls and results, injected context, and steering are logged and sent on later steps. Raw stream chunks, lifecycle boundaries, and other log-only events are excluded.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Input grows with every surface message until a compaction replacement shadows older nodes; a multi-step tool turn resends the accumulated prefix and history each step.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Ordinary history growth is append-only and preserves reusable entries. A surface replacement or compaction invalidates reuse from the first shadowed history token.
|
||||
|
||||
### Undispatched calls after cancellation
|
||||
|
||||
#### What the model sees
|
||||
|
||||
If a later request replays an aborted step, each tool call that cancellation prevented from dispatching has the error result text `Error: tool call skipped because the step was aborted before execution`.
|
||||
|
||||
#### Token effect
|
||||
|
||||
One fixed error result per skipped call remains in history until compaction shadows it.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Append-only; each synthetic result follows the reusable request prefix and does not invalidate existing KV-cache entries.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Classification is unary** — calls whose safety depends on comparing siblings or resources must remain exclusive ([rationale](../../../docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md)).
|
||||
- **No resume-or-create policy on the config path** — config-driven `create()` starts a fresh `${id}-session-<uuid>` every run (`TODO(demo)`), and a config `resumeSessionId` whose resume fails logs a warning and creates no agent.
|
||||
- **Classification is unary** — calls whose safety depends on comparing siblings or resources must remain exclusive ([rationale](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md)).
|
||||
- **Config labels are fresh by default** — omitting `sessionId` creates a fresh `${id}-session-<uuid>` on every startup; exact resume-or-create behavior requires an explicit stable `sessionId`, while `resumeSessionId` requires existing persisted history.
|
||||
- **Config agents have no per-agent persona field or setup hook** — they use the deployment persona; scoped persona/tool composition is available only through the programmatic `ctx.agents.create()` / `resume()` factory options.
|
||||
- **No built-in turn budget** — the default continuation is `continue` whenever a step had tool calls or steering; bounding a runaway turn requires an `agent/turn-continuation` force-stop plugin.
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-agent-execution": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-scope": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
@@ -36,7 +35,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-execution": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { agentEvents, normalizeAgentCancelCause } from '@deepseek-ai/dsh-agent'
|
||||
import type { AgentCancelCause, AgentId, AgentOptions, AgentStatus, HookContext, InjectOptions, SendOptions } from '@deepseek-ai/dsh-agent'
|
||||
import type { AgentCancelCause, AgentOptions, AgentStatus, HookContext, InjectOptions, SendOptions } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { deepFreeze } from '@deepseek-ai/dsh-llm'
|
||||
import { deepFreeze, errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import { snapshotJsonValue, type Session } from '@deepseek-ai/dsh-session'
|
||||
import { snapshotJsonValue, type Session, type SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { DISPOSED_INTERRUPT_REASON, TurnCancellation } from './cancellation.ts'
|
||||
import { Inbox, type InboxMessage } from './inbox.ts'
|
||||
import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts'
|
||||
@@ -59,7 +59,11 @@ export interface PreparedReactLoopAgent {
|
||||
* @returns the agent and closures bound only to that exact instance.
|
||||
*/
|
||||
export function prepareReactLoopAgent(
|
||||
ctx: Context, id: AgentId, options: AgentOptions, session: Session, maxParallelToolCalls: number,
|
||||
ctx: Context,
|
||||
id: SessionId,
|
||||
options: AgentOptions,
|
||||
session: Session,
|
||||
maxParallelToolCalls: number,
|
||||
): PreparedReactLoopAgent {
|
||||
if (claimedDriverSessions.has(session)) {
|
||||
throw new Error(`session "${session.id}" already has a concrete agent driver`)
|
||||
@@ -77,7 +81,6 @@ export function prepareReactLoopAgent(
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the concrete agent's scope context exactly once. Construction and
|
||||
* scope minting are mutually referential (the scope key is the agent), so the
|
||||
@@ -93,7 +96,7 @@ export function bindReactLoopAgentContext(agent: ReactLoopAgent, ctx: Context):
|
||||
/**
|
||||
* The concrete {@link Agent} implementation owned by the agent-loop plugin.
|
||||
*
|
||||
* Owns the inbox (queued + steering FIFOs), one turn cancellation holder, and
|
||||
* Owns the inbox (queued + steering FIFOs), turn cancellation, and
|
||||
* the loop driver. Everything observable happens through session events and
|
||||
* the agent/* event taxonomy — plugins never need this class.
|
||||
*/
|
||||
@@ -118,17 +121,13 @@ export class ReactLoopAgent implements Agent {
|
||||
}
|
||||
|
||||
private _status: AgentStatus = 'idle'
|
||||
/** Active turn owner, installed before the running notification and retained through flush. */
|
||||
/** Active turn owner from pre-running publication through durability settlement. */
|
||||
private turnCancellation: TurnCancellation | undefined
|
||||
/** Whether runLoop has been installed into {@link done}. */
|
||||
private driverStarted = false
|
||||
/** Whether registry publication began and status disposal is externally visible. */
|
||||
private published = false
|
||||
/**
|
||||
* Cause-less marker for queued work cancelled before the driver installs a
|
||||
* turn owner. It never represents an active turn and cannot leak a cause into
|
||||
* replacement work.
|
||||
*/
|
||||
/** Cause-less marker for queued work cancelled before the driver installs a turn owner. */
|
||||
private preRunCancelled = false
|
||||
private disposed: Promise<void>
|
||||
private resolveDisposed!: () => void
|
||||
@@ -157,7 +156,7 @@ export class ReactLoopAgent implements Agent {
|
||||
|
||||
constructor(
|
||||
private loopCtx: Context,
|
||||
public readonly id: AgentId,
|
||||
public readonly id: SessionId,
|
||||
public readonly options: AgentOptions,
|
||||
public readonly session: Session,
|
||||
maxParallelToolCalls: number,
|
||||
@@ -246,7 +245,6 @@ export class ReactLoopAgent implements Agent {
|
||||
const context = {
|
||||
content,
|
||||
source,
|
||||
...options?.envelope !== undefined ? { envelope: options.envelope } : {},
|
||||
...options?.meta !== undefined ? { meta: options.meta } : {},
|
||||
}
|
||||
if (isTurnOpen(this.session)) {
|
||||
@@ -285,7 +283,7 @@ export class ReactLoopAgent implements Agent {
|
||||
if (turnRecorded) {
|
||||
// Through the store's flush (the carrier owner), never a raw parallel.
|
||||
const flush = this.loopCtx.sessions.flush(this.session).catch((error: unknown) => {
|
||||
const rendered = renderThrown(error)
|
||||
const rendered = errorChain(error)
|
||||
const err = error instanceof Error ? error : new Error(rendered)
|
||||
this.loopCtx.logger.warn(`agent "${this.id}": flush after idle injection failed: ${rendered}`)
|
||||
agentEvents(this.loopCtx, this).emit('agent/error', turn, 0, err)
|
||||
@@ -327,18 +325,16 @@ export class ReactLoopAgent implements Agent {
|
||||
}
|
||||
|
||||
cancel(cause?: AgentCancelCause): void {
|
||||
// Validate before the idle no-op so misuse fails consistently in every state.
|
||||
const accepted = normalizeAgentCancelCause(cause ?? { kind: 'user' })
|
||||
const active = this.turnCancellation
|
||||
if (active === undefined && !this.#inbox.hasQueued && !this.#inbox.hasSteering) return
|
||||
if (active === undefined) this.preRunCancelled = true
|
||||
// Drop all pending queued + steering work (un-started prompts never run; the
|
||||
// cancelled turn's steering is not re-enqueued). Clear before abort dispatch,
|
||||
// whose synchronous observers may enqueue replacement work that must survive.
|
||||
// This is direct even when the loop is parked in waitForQueued — there is no
|
||||
// turn to stop and nothing left for the parked loop to run, so no wake is needed.
|
||||
const normalized = normalizeAgentCancelCause(cause ?? { kind: 'user' })
|
||||
const cancellation = this.turnCancellation
|
||||
const preRun = cancellation === undefined && (this.#inbox.hasQueued || this.#inbox.hasSteering)
|
||||
if (preRun) {
|
||||
this.preRunCancelled = true
|
||||
}
|
||||
// Clear work already present before abort observers run. A replacement
|
||||
// synchronously enqueued by an observer belongs to the next turn.
|
||||
this.#inbox.clear()
|
||||
if (active !== undefined) active.request(accepted)
|
||||
cancellation?.request(normalized)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -376,7 +372,7 @@ export class ReactLoopAgent implements Agent {
|
||||
[startDriver](): void {
|
||||
if (this._status === 'disposed') return
|
||||
this.driverStarted = true
|
||||
this.done = this.loopCtx.agentExecution.run({ agent: this }, () => runLoop(this.loopCtx, this, {
|
||||
this.done = this.loopCtx.agents.withInitiator(this, () => runLoop(this.loopCtx, {
|
||||
inbox: this.#inbox,
|
||||
maxParallelToolCalls: this.maxParallelToolCalls,
|
||||
setStatus: (status) => { this.setStatus(status) },
|
||||
@@ -386,7 +382,7 @@ export class ReactLoopAgent implements Agent {
|
||||
return cancellation
|
||||
},
|
||||
clearTurnCancellation: (cancellation) => {
|
||||
/* v8 ignore else -- the internal driver clears only the exact holder returned by its latest install */
|
||||
/* v8 ignore else -- the driver clears only the exact owner returned by its latest install. */
|
||||
if (this.turnCancellation === cancellation) this.turnCancellation = undefined
|
||||
},
|
||||
disposed: this.disposed,
|
||||
@@ -394,7 +390,7 @@ export class ReactLoopAgent implements Agent {
|
||||
isPreRunCancelled: () => this.preRunCancelled,
|
||||
clearPreRunCancel: () => { this.preRunCancelled = false },
|
||||
withToolBatch: run => this.withToolBatch(run),
|
||||
// Pre-run cancellation re-parks without emitting a status transition.
|
||||
// Pre-run cancellation settles queued-work waiters before publishing idle.
|
||||
settleIdle: () => { this.settleIdleWaiters() },
|
||||
}))
|
||||
}
|
||||
@@ -441,8 +437,3 @@ export class ReactLoopAgent implements Agent {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Render an ordinary thrown value for the error event and log. */
|
||||
function renderThrown(value: unknown): string {
|
||||
return value instanceof Error ? value.message : String(value)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export interface InboxMessage {
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-agent inbox: a queued FIFO (drained at turn start) and a steering FIFO
|
||||
* Per-agent inbox: a queued FIFO (dequeued once per turn start) and a steering FIFO
|
||||
* (drained between steps of a running turn). Purely an in-memory mechanism of
|
||||
* the loop — the public surface is `Agent.send()` / `Agent.steer()`.
|
||||
*/
|
||||
@@ -54,11 +54,11 @@ export class Inbox {
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain all queued messages (turn start).
|
||||
* @returns the drained messages in arrival order; the queued FIFO is left empty.
|
||||
* Remove the oldest queued message for one turn start.
|
||||
* @returns the oldest message, or `undefined` when the queued FIFO is empty.
|
||||
*/
|
||||
drainQueued(): InboxMessage[] {
|
||||
return this.queuedMessages.splice(0)
|
||||
dequeueQueued(): InboxMessage | undefined {
|
||||
return this.queuedMessages.shift()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,7 +72,7 @@ export class Inbox {
|
||||
/**
|
||||
* Discard all pending messages (queued + steering) without delivering them —
|
||||
* used by `cancel()`, which drops un-started work rather than draining it into
|
||||
* a turn. Unlike `drainQueued`/`drainSteering`, the messages are thrown away.
|
||||
* a turn. Unlike `dequeueQueued`/`drainSteering`, the messages are thrown away.
|
||||
*/
|
||||
clear(): void {
|
||||
this.queuedMessages.length = 0
|
||||
|
||||
@@ -11,17 +11,16 @@ import z from 'schemastery'
|
||||
import { createScope } from '@deepseek-ai/dsh-scope'
|
||||
import type { Scope } from '@deepseek-ai/dsh-scope'
|
||||
import { agentEvents } from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-agent-execution'
|
||||
import type {
|
||||
Agent,
|
||||
AgentFactory,
|
||||
AgentHandle,
|
||||
AgentId,
|
||||
AgentOptions,
|
||||
CreateAgentOptions,
|
||||
ResumeAgentOptions,
|
||||
SessionStartSource,
|
||||
} from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-llm'
|
||||
import { errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
@@ -35,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,
|
||||
@@ -47,7 +44,9 @@ const INACTIVE_STATES: ReadonlySet<FiberState> = new Set([
|
||||
/** Factory-level ownership of every preparing or live transaction. */
|
||||
class FactoryOwnership {
|
||||
private accepting = true
|
||||
private readonly inactive = Promise.withResolvers<void>()
|
||||
private transactions = new Set<AgentCreationTransaction>()
|
||||
private startupTasks = new Set<Promise<void>>()
|
||||
|
||||
constructor(private readonly fiber: Context['fiber']) {}
|
||||
|
||||
@@ -60,17 +59,31 @@ class FactoryOwnership {
|
||||
return () => { this.transactions.delete(transaction) }
|
||||
}
|
||||
|
||||
/** Join config startup work that begins before an agent transaction exists. */
|
||||
trackStartup(task: Promise<void>): void {
|
||||
this.startupTasks.add(task)
|
||||
const forget = () => { this.startupTasks.delete(task) }
|
||||
void task.then(forget, forget)
|
||||
}
|
||||
|
||||
/** Resolve `task`, or stop waiting when factory teardown begins. */
|
||||
async waitWhileActive(task: Promise<void>): Promise<void> {
|
||||
await Promise.race([task, this.inactive.promise])
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.accepting = false
|
||||
this.inactive.resolve()
|
||||
const reason = new Error('agent loop is not active')
|
||||
await Promise.all(
|
||||
[...this.transactions].map(transaction => transaction.disposeForFactory(reason)),
|
||||
)
|
||||
await Promise.all([
|
||||
...[...this.transactions].map(transaction => transaction.disposeForFactory(reason)),
|
||||
...this.startupTasks,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the public cancellation error while preserving a caller-supplied cause. */
|
||||
function signalAbortError(id: AgentId, signal: AbortSignal): Error {
|
||||
function signalAbortError(id: SessionId, signal: AbortSignal): Error {
|
||||
if (signal.reason instanceof Error) return signal.reason
|
||||
return new Error(`agent "${id}" creation aborted`, { cause: signal.reason })
|
||||
}
|
||||
@@ -116,7 +129,7 @@ class AgentCreationTransaction {
|
||||
private readonly loopCtx: Context,
|
||||
private readonly ownerCtx: Context,
|
||||
private readonly ownership: FactoryOwnership,
|
||||
readonly id: AgentId,
|
||||
readonly id: SessionId,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
ownerCtx.fiber.assertActive()
|
||||
@@ -238,7 +251,7 @@ class AgentCreationTransaction {
|
||||
this.publishing = true
|
||||
try {
|
||||
this.detachSession = agent.ctx.sessions.enter(session)
|
||||
this.detachAgent = this.loopCtx.agents.enter(agent)
|
||||
this.detachAgent = this.loopCtx.agents.enter(agent, this.ownerAgent)
|
||||
|
||||
agent.ctx.sessions.announce(session)
|
||||
this.assertActive()
|
||||
@@ -327,6 +340,18 @@ declare module 'cordis' {
|
||||
interface Context {
|
||||
agentLoop: AgentLoop
|
||||
}
|
||||
interface Events {
|
||||
/**
|
||||
* A declarative agent entry failed before it could publish a live agent.
|
||||
* Consumers that buffer work for the configured identity use this
|
||||
* transient signal to reject that work instead of waiting forever. Normal
|
||||
* factory teardown suppresses failures from the cancelled startup attempt.
|
||||
* @param sessionId - exact shared agent/session identity that failed startup.
|
||||
* @param error - persistence, setup, or publication failure.
|
||||
* @mode emit
|
||||
*/
|
||||
'agent-loop/config-start-failed'(sessionId: SessionId, error: unknown): void
|
||||
}
|
||||
}
|
||||
|
||||
export { DEFAULT_MAX_PARALLEL_TOOL_CALLS }
|
||||
@@ -340,8 +365,10 @@ export interface Config {
|
||||
maxParallelToolCalls?: number
|
||||
/** Agents created or resumed at plugin startup. */
|
||||
agents: (AgentOptions & {
|
||||
/** Registry identity for the live agent. */
|
||||
id: AgentId
|
||||
/** Stable config label used in logs and as the fresh combined-id prefix. */
|
||||
id: string
|
||||
/** Optional stable identity; remounts resume its materialized history, while first use creates it fresh. */
|
||||
sessionId?: SessionId
|
||||
/** Optional workspace for a fresh session. */
|
||||
cwd?: string
|
||||
/** Persisted session to resume instead of creating a fresh session. */
|
||||
@@ -349,15 +376,34 @@ export interface Config {
|
||||
})[]
|
||||
}
|
||||
|
||||
/** Concrete ReactLoopAgent factory and driver service. */
|
||||
/** Reject self-contained identity conflicts before any configured agent starts. */
|
||||
function validateConfiguredAgents(agents: Config['agents']): void {
|
||||
const exactIdentities = new Map<SessionId, string>()
|
||||
for (const { id, sessionId, resumeSessionId } of agents) {
|
||||
const hasResumeId = resumeSessionId !== undefined && resumeSessionId !== ''
|
||||
if (sessionId !== undefined && hasResumeId) {
|
||||
throw new Error(`agent "${id}": sessionId and resumeSessionId are mutually exclusive`)
|
||||
}
|
||||
const exactIdentity = hasResumeId ? resumeSessionId : sessionId
|
||||
if (exactIdentity === undefined) continue
|
||||
const firstId = exactIdentities.get(exactIdentity)
|
||||
if (firstId !== undefined) {
|
||||
throw new Error(`agents "${firstId}" and "${id}" use duplicate exact session identity "${exactIdentity}"`)
|
||||
}
|
||||
exactIdentities.set(exactIdentity, id)
|
||||
}
|
||||
}
|
||||
|
||||
/** Concrete agent factory and driver service. */
|
||||
export class AgentLoop extends Service implements AgentFactory {
|
||||
static inject = ['agents', 'agentExecution', 'sessions', 'llm', 'tools', 'systemPrompt']
|
||||
static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt']
|
||||
|
||||
/** Runtime schema for declarative agents. */
|
||||
static Config = z.object({
|
||||
maxParallelToolCalls: z.number().step(1).min(1).default(DEFAULT_MAX_PARALLEL_TOOL_CALLS),
|
||||
agents: z.array(z.object({
|
||||
id: z.string().required(),
|
||||
sessionId: z.string().min(1),
|
||||
provider: z.string(),
|
||||
model: z.string(),
|
||||
cwd: z.string(),
|
||||
@@ -373,6 +419,7 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
|
||||
constructor(ctx: Context, public config: Config) {
|
||||
super(ctx, 'agentLoop')
|
||||
validateConfiguredAgents(config.agents)
|
||||
this.maxParallelToolCalls = resolveMaxParallelToolCalls(config.maxParallelToolCalls)
|
||||
this.ownership = new FactoryOwnership(ctx.fiber)
|
||||
this.runtime = { ctx }
|
||||
@@ -382,19 +429,28 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
ctx.systemPrompt.variable('model', context => context.agent?.options.model)
|
||||
ctx.systemPrompt.variable('cwd', context => context.agent?.session.header.cwd)
|
||||
|
||||
for (const { id, cwd, resumeSessionId, ...options } of config.agents) {
|
||||
for (const { id, sessionId, cwd, resumeSessionId, ...options } of config.agents) {
|
||||
const meta = cwd === undefined ? {} : { cwd }
|
||||
if (resumeSessionId === undefined || resumeSessionId === '') {
|
||||
this.create(id, options, cwd === undefined ? {} : { cwd })
|
||||
const configuredId = sessionId ?? SessionId(`${id}-session-${randomUUID()}`)
|
||||
const persistence = sessionId === undefined ? undefined : ctx.get('sessionPersistence')
|
||||
if (persistence === undefined) {
|
||||
this.create(configuredId, options, meta)
|
||||
} else {
|
||||
const startup = this.restoreOrCreateConfigured(ctx, persistence, configuredId, options, meta).catch((error: unknown) => {
|
||||
this.reportConfiguredStartupFailure(id, 'restore', configuredId, error)
|
||||
})
|
||||
this.ownership.trackStartup(startup)
|
||||
}
|
||||
continue
|
||||
}
|
||||
ctx.effect(() => {
|
||||
const fiber = ctx.inject(['sessionPersistence'], (childCtx: Context) => {
|
||||
void this.resumeWith(ctx, childCtx.sessionPersistence, {
|
||||
agentId: id,
|
||||
resumeSessionId,
|
||||
agentOptions: options,
|
||||
}).catch((error: unknown) => {
|
||||
ctx.logger.warn(`agent "${id}": config-driven resume of "${resumeSessionId}" failed: ${String(error)}`)
|
||||
this.reportConfiguredStartupFailure(id, 'resume', resumeSessionId, error)
|
||||
})
|
||||
})
|
||||
return fiber.dispose
|
||||
@@ -402,20 +458,83 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
}
|
||||
}
|
||||
|
||||
/** Report a contained declarative-start failure to identity-bound consumers. */
|
||||
private reportConfiguredStartupFailure(
|
||||
configId: string,
|
||||
action: 'restore' | 'resume',
|
||||
sessionId: SessionId,
|
||||
error: unknown,
|
||||
): void {
|
||||
if (!this.ownership.isActive()) return
|
||||
this.ctx.logger.warn(`agent "${configId}": config-driven ${action} of "${sessionId}" failed: ${errorChain(error)}`)
|
||||
const args: unknown[] = ['agent-loop/config-start-failed', sessionId, error]
|
||||
for (const callback of this.ctx.events.dispatch('emit', args)) {
|
||||
try {
|
||||
const returned: unknown = callback(...args)
|
||||
void Promise.resolve(returned).catch((listenerError: unknown) => {
|
||||
this.ctx.logger.warn(`agent "${configId}": config-start-failed listener rejected: ${errorChain(listenerError)}`)
|
||||
})
|
||||
} catch (listenerError: unknown) {
|
||||
this.ctx.logger.warn(`agent "${configId}": config-start-failed listener threw: ${errorChain(listenerError)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Restore a materialized exact config identity on remount, or create it on first use. */
|
||||
private async restoreOrCreateConfigured(
|
||||
ownerCtx: Context,
|
||||
persistence: SessionPersistence,
|
||||
sessionId: SessionId,
|
||||
agentOptions: AgentOptions,
|
||||
meta: Pick<SessionHeader, 'cwd'>,
|
||||
): Promise<void> {
|
||||
await this.waitForDrainingConfiguredIdentity(ownerCtx, sessionId)
|
||||
if (!this.ownership.isActive()) return
|
||||
const exists = (await persistence.list()).some(header => header.id === sessionId)
|
||||
if (!this.ownership.isActive()) return
|
||||
if (exists) {
|
||||
await this.resumeWith(ownerCtx, persistence, { resumeSessionId: sessionId, agentOptions })
|
||||
return
|
||||
}
|
||||
this.create(sessionId, agentOptions, meta)
|
||||
}
|
||||
|
||||
/** Wait for an already-disposed same-id lifecycle to finish registry teardown. */
|
||||
private async waitForDrainingConfiguredIdentity(ownerCtx: Context, sessionId: SessionId): Promise<void> {
|
||||
const current = ownerCtx.agents.get(sessionId)
|
||||
if (current?.status !== 'disposed') return
|
||||
|
||||
const released = Promise.withResolvers<void>()
|
||||
const checkReleased = (): void => {
|
||||
if (ownerCtx.agents.get(sessionId) === undefined && ownerCtx.sessions.get(sessionId) === undefined) {
|
||||
released.resolve()
|
||||
}
|
||||
}
|
||||
const disposeAgentListener = ownerCtx.on('agent/disposed', checkReleased)
|
||||
const disposeSessionListener = ownerCtx.on('session/disposed', checkReleased)
|
||||
try {
|
||||
checkReleased()
|
||||
await this.ownership.waitWhileActive(released.promise)
|
||||
} finally {
|
||||
disposeAgentListener()
|
||||
disposeSessionListener()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an agent on a fresh per-run session, owned by the accessing fiber.
|
||||
* Constructor-driven config calls use the loop fiber itself.
|
||||
* @param id - agent registry id.
|
||||
* 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.
|
||||
* @param id - shared agent/session identity.
|
||||
* @param options - concrete loop options.
|
||||
* @param meta - optional fresh-session workspace metadata.
|
||||
* @returns the published running agent.
|
||||
*/
|
||||
create(id: AgentId, 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 {
|
||||
const sessionId = SessionId(`${id}-session-${randomUUID()}`)
|
||||
const session = loopCtx.sessions.prepare(sessionId, { meta })
|
||||
const session = loopCtx.sessions.prepare(id, { meta })
|
||||
const agent = transaction.prepare(options, session, this.maxParallelToolCalls)
|
||||
transaction.publish('startup')
|
||||
return agent
|
||||
@@ -439,7 +558,7 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
this.runtime.ctx,
|
||||
ownerCtx,
|
||||
this.ownership,
|
||||
options.agentId,
|
||||
options.sessionId,
|
||||
options.signal,
|
||||
)
|
||||
try {
|
||||
@@ -484,7 +603,7 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
this.runtime.ctx,
|
||||
ownerCtx,
|
||||
this.ownership,
|
||||
options.agentId,
|
||||
options.resumeSessionId,
|
||||
options.signal,
|
||||
)
|
||||
try {
|
||||
@@ -492,12 +611,7 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
transaction.assertActive()
|
||||
const session = this.runtime.ctx.sessions.prepare(options.resumeSessionId, {
|
||||
seed: loaded.events,
|
||||
meta: {
|
||||
createdAt: loaded.meta.createdAt,
|
||||
...loaded.meta.cwd === undefined ? {} : { cwd: loaded.meta.cwd },
|
||||
...loaded.meta.parentSession === undefined ? {} : { parentSession: loaded.meta.parentSession },
|
||||
...loaded.meta.seedLength === undefined ? {} : { seedLength: loaded.meta.seedLength },
|
||||
},
|
||||
meta: loaded.meta,
|
||||
})
|
||||
const agent = transaction.prepare(agentOptions, session, this.maxParallelToolCalls)
|
||||
await transaction.waitFor(options.setup?.(agent.ctx))
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
/**
|
||||
* Drives one agent across queued durable turns. Turn failures are contained so
|
||||
* later work can run; the session log, not this driver, owns conversation state.
|
||||
* See docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md.
|
||||
* See .agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md.
|
||||
* @module dsh-agent-loop/loop
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import type { ContentBlock, FinishReason, GenerateOptions, LlmCallConfig, Message } from '@deepseek-ai/dsh-llm'
|
||||
import { assertNever, BlockAssembler, HarnessError, deepFreeze } from '@deepseek-ai/dsh-llm'
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import { BlockAssembler, HarnessError, assertNever, deepFreeze, errorChain, isLlmAdapterFailure } from '@deepseek-ai/dsh-llm'
|
||||
import { agentEvents, agentInterruptReasonOf, assembleContextFor } from '@deepseek-ai/dsh-agent'
|
||||
import type { AgentEventDispatch, ContinuationDecision, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent'
|
||||
import type { AgentEventDispatch, ContinuationDecision, HookContext, PromptDecision, RequestError, RequestErrorDecision } from '@deepseek-ai/dsh-agent'
|
||||
import { canonicalHeader } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session'
|
||||
import { createTransmissionLog, recordRequestHeader } from './request-log.ts'
|
||||
@@ -19,28 +19,32 @@ import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
import { executeToolCalls } from './tool-calls.ts'
|
||||
import type { ReactLoopAgent } from './agent.ts'
|
||||
import type { Inbox } from './inbox.ts'
|
||||
import type { TurnCancellation } from './cancellation.ts'
|
||||
|
||||
/** An Error with an optional machine-readable code (e.g., from LlmError or a throwing plugin). */
|
||||
type CodedError = Error & { code?: string }
|
||||
|
||||
/** Normalize thrown values while preserving an existing error code. */
|
||||
function toError(error: unknown): CodedError {
|
||||
function toError(error: unknown): RequestError {
|
||||
return error instanceof Error ? error : new HarnessError(String(error), 'UNKNOWN', { cause: error })
|
||||
}
|
||||
|
||||
/** Distinguishes final model-request failures from failures in later step processing. */
|
||||
class TerminalModelRequestFailure extends Error {
|
||||
constructor(readonly requestError: RequestError) {
|
||||
super(requestError.message, { cause: requestError })
|
||||
this.name = 'TerminalModelRequestFailure'
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert terminal failure finishes into step errors; unknown extensible finishes remain successful. */
|
||||
function finishError(finish: FinishReason): CodedError | undefined {
|
||||
function finishError(finish: FinishReason): RequestError | undefined {
|
||||
switch (finish.kind) {
|
||||
case 'error': {
|
||||
const error: CodedError = new Error(finish.message)
|
||||
const error: RequestError = new Error(finish.message)
|
||||
if (finish.code !== undefined) error.code = finish.code
|
||||
return error
|
||||
}
|
||||
case 'aborted': {
|
||||
const error: CodedError = new Error('model stream aborted')
|
||||
const error: RequestError = new Error('model stream aborted')
|
||||
error.code = 'ABORTED'
|
||||
return error
|
||||
}
|
||||
@@ -53,9 +57,12 @@ function finishError(finish: FinishReason): CodedError | undefined {
|
||||
/**
|
||||
* Build the `{ message, code? }` part of an error payload, omitting the
|
||||
* `code` key entirely when absent (exactOptionalPropertyTypes-correct).
|
||||
* The durable message renders the full cause chain: `turn/end` is the single
|
||||
* durable record of an in-turn failure, so a wrapper message alone (e.g.
|
||||
* `fetch failed`) would lose the diagnosis the session log exists to keep.
|
||||
*/
|
||||
function errorData(err: CodedError): { message: string; code?: string } {
|
||||
return { message: err.message, ...typeof err.code === 'string' ? { code: err.code } : {} }
|
||||
function errorData(err: RequestError): { message: string; code?: string } {
|
||||
return { message: errorChain(err), ...typeof err.code === 'string' ? { code: err.code } : {} }
|
||||
}
|
||||
|
||||
/** Map a successful max-token finish onto the turn reason; other successful finishes add nothing. */
|
||||
@@ -88,10 +95,10 @@ function interruptionTurnEndReason(handle: LoopHandle, signal: AbortSignal): Tur
|
||||
case 'user':
|
||||
case 'parent':
|
||||
return { kind: 'aborted' }
|
||||
/* v8 ignore next 2 -- the private holder requests disposed only after lifecycle state flips, which returns above */
|
||||
/* v8 ignore next 2 -- the private holder requests disposed only after lifecycle state flips, which returns above. */
|
||||
case 'disposed':
|
||||
return { kind: 'disposed' }
|
||||
/* v8 ignore next 2 -- AgentInterruptReason is closed and the public helper filters unsupported reasons */
|
||||
/* v8 ignore next 2 -- AgentInterruptReason is closed and the public helper filters unsupported reasons. */
|
||||
default:
|
||||
return assertNever(reason, 'AgentInterruptReason')
|
||||
}
|
||||
@@ -115,20 +122,25 @@ export interface LoopHandle {
|
||||
isPreRunCancelled(): boolean
|
||||
/** Clear the cause-less pre-run marker without affecting replacement work. */
|
||||
clearPreRunCancel(): void
|
||||
/** Settle idle waiters when pre-running cancellation skips a turn, without emitting `agent/status`. */
|
||||
/** Settle idle waiters before pre-running cancellation publishes idle. */
|
||||
settleIdle(): void
|
||||
/** Run an active tool-call batch, accepting post-tool context into the FIFO drained before settlement. */
|
||||
readonly withToolBatch: <T>(run: (acceptContext: (context: HookContext) => void) => Promise<T>) => Promise<T>
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive queued batches as durable turns until disposal. Plugin failures end the
|
||||
* current turn without terminating the driver.
|
||||
* @param ctx - the plugin context the loop reaches events (agent/…, session/flush) and services (systemPrompt, llm, tools) through.
|
||||
* @param agent - the agent this invocation drives for its whole lifetime (its inbox, session, and options).
|
||||
* Drive queued messages as independent durable turns until disposal. Plugin
|
||||
* failures end the current turn without terminating the driver. The caller
|
||||
* establishes the `ctx.agents.withInitiator()` boundary before entry; package-private
|
||||
* orchestration recovers that exact Agent and captures its Session locally.
|
||||
* @param ctx - the plugin context the loop reaches its initiating Agent,
|
||||
* events (agent/…, session/flush), and services (systemPrompt, llm, tools)
|
||||
* through.
|
||||
* @param handle - the bridge to status, turn cancellation ownership, disposal, and pre-run cancellation state.
|
||||
* @throws when no initiating Agent is active.
|
||||
*/
|
||||
export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle): Promise<void> {
|
||||
export async function runLoop(ctx: Context, handle: LoopHandle): Promise<void> {
|
||||
const agent = ctx.agents.requireInitiator()
|
||||
// Per-instance prefix and request-header state; conversation history remains in the session log.
|
||||
const transmission = createTransmissionLog()
|
||||
|
||||
@@ -137,29 +149,42 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH
|
||||
const events = agentEvents(ctx, agent)
|
||||
|
||||
while (!handle.isDisposed()) {
|
||||
await handle.inbox.waitForQueued(handle.disposed)
|
||||
if (handle.isDisposed()) break
|
||||
|
||||
// Cancellation between wake and `running` skips only the cancelled work;
|
||||
// a replacement prompt still runs and owns the eventual idle transition.
|
||||
// An idle listener can enqueue and cancel replacement work before the next
|
||||
// wait is installed. Consume that empty marker before parking the driver.
|
||||
if (handle.isPreRunCancelled()) {
|
||||
handle.clearPreRunCancel()
|
||||
if (!handle.inbox.hasQueued) {
|
||||
handle.settleIdle()
|
||||
handle.setStatus('idle')
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
await handle.inbox.waitForQueued(handle.disposed)
|
||||
if (handle.isDisposed()) break
|
||||
|
||||
// Cancellation between wake and `running` skips only the cancelled work;
|
||||
// a replacement prompt still runs before the eventual idle transition.
|
||||
if (handle.isPreRunCancelled()) {
|
||||
handle.clearPreRunCancel()
|
||||
if (!handle.inbox.hasQueued) {
|
||||
// Settle before publishing idle: the already-idle path has no status
|
||||
// transition, while an idle listener can register waiters for new work.
|
||||
handle.settleIdle()
|
||||
handle.setStatus('idle')
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
let cancellation = handle.installTurnCancellation()
|
||||
handle.setStatus('running')
|
||||
|
||||
if (handle.isDisposed()) {
|
||||
handle.clearTurnCancellation(cancellation)
|
||||
break
|
||||
}
|
||||
|
||||
// A synchronous running listener may cancel old work and enqueue a
|
||||
// replacement. The replacement receives a fresh, non-aborted turn owner.
|
||||
// A synchronous `running` listener can cancel before `runTurn`; balance the
|
||||
// status only when no replacement prompt was queued by that listener.
|
||||
if (cancellation.signal.aborted) {
|
||||
handle.clearTurnCancellation(cancellation)
|
||||
if (!handle.inbox.hasQueued) {
|
||||
@@ -173,11 +198,11 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH
|
||||
const turn = lastTurnNumber(session) + 1
|
||||
let terminalStopped = false
|
||||
try {
|
||||
terminalStopped = await runTurn(ctx, events, agent, handle, turn, transmission, cancellation.signal)
|
||||
terminalStopped = await runTurn(ctx, events, handle, turn, transmission, cancellation.signal)
|
||||
} catch (error: unknown) {
|
||||
// Pre-turn failure has no durable boundary to close; report it without appending outside a turn.
|
||||
const err = toError(error)
|
||||
ctx.logger.warn(`agent "${agent.id}": turn ${turn} failed before it started: ${err.message}`)
|
||||
ctx.logger.warn(`agent "${agent.id}": turn ${turn} failed before it started: ${errorChain(err)}`)
|
||||
try {
|
||||
events.emit('agent/error', turn, 0, err)
|
||||
} catch { /* contained: a throwing agent/error listener must not kill the driver */ }
|
||||
@@ -195,20 +220,28 @@ export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopH
|
||||
}
|
||||
|
||||
async function runTurn(
|
||||
ctx: Context, events: AgentEventDispatch, agent: ReactLoopAgent, handle: LoopHandle, turn: number, transmission: TransmissionLog,
|
||||
ctx: Context, events: AgentEventDispatch, handle: LoopHandle, turn: number, transmission: TransmissionLog,
|
||||
signal: AbortSignal,
|
||||
): Promise<boolean> {
|
||||
const agent = ctx.agents.requireInitiator()
|
||||
const { session } = agent
|
||||
const drainSteering = (): boolean => {
|
||||
const messages = handle.inbox.drainSteering()
|
||||
for (const message of messages) {
|
||||
session.append('steering/message', { turn, content: message.content, source: message.source }, { surfaceOp: 'append' })
|
||||
}
|
||||
return messages.length > 0
|
||||
}
|
||||
|
||||
// Drain before opening the turn, but append only after `turn/start`.
|
||||
const queued = handle.inbox.drainQueued()
|
||||
const first = queued[0]
|
||||
// Claim one queued message before opening its turn, but append it only after `turn/start`.
|
||||
const message = handle.inbox.dequeueQueued()
|
||||
/* v8 ignore next 3 -- invariant guard: runLoop only calls runTurn when hasQueued */
|
||||
if (!first) throw new Error('runTurn invariant violated: no queued message at turn start')
|
||||
const trigger: TurnTrigger = { kind: 'message', source: first.source }
|
||||
if (!message) throw new Error('runTurn invariant violated: no queued message at turn start')
|
||||
const trigger: TurnTrigger = { kind: 'message', source: message.source }
|
||||
|
||||
let reason: TurnEndReason = { kind: 'completed' }
|
||||
let step = 0
|
||||
let requestRetryAttempt = 0
|
||||
let stepOpen = false
|
||||
let errorReported = false
|
||||
let terminalStopped = false
|
||||
@@ -221,7 +254,7 @@ async function runTurn(
|
||||
}
|
||||
|
||||
// Record the durable turn failure once and contain the live error notification.
|
||||
const failTurn = (err: CodedError): void => {
|
||||
const failTurn = (err: RequestError): void => {
|
||||
if (errorReported) return
|
||||
errorReported = true
|
||||
reason = { kind: 'error', step, ...errorData(err) }
|
||||
@@ -244,71 +277,51 @@ async function runTurn(
|
||||
// veto leaves no turn/start in the log and therefore owes no turn/end.
|
||||
session.append('turn/start', { turn, trigger })
|
||||
interruptionCheckpoint(signal)
|
||||
// Each drained queued message runs the `agent/prompt-submit` waterfall before
|
||||
// it becomes a `user/message` — a hook can rewrite the prompt or block it.
|
||||
// The claimed message runs the `agent/prompt-submit` waterfall before it
|
||||
// becomes a `user/message` — a hook can rewrite the prompt or block it.
|
||||
// Recorded INSIDE the turn (after turn/start) so every event is turn-enclosed;
|
||||
// turn/end is now owed, so a throwing prompt-submit listener (the waterfall
|
||||
// throws) is caught below and the turn still closes.
|
||||
let anyAllowed = false
|
||||
// Seeded with a floor (only observable if the batch were empty, which
|
||||
// runTurn never allows — it is called with ≥1 queued message); each `block`
|
||||
// decision carries a required `reason` and overwrites it, so a fully-blocked
|
||||
// batch always reports the last vetoing reason.
|
||||
let lastBlockReason = 'prompt blocked by hook'
|
||||
for (const message of queued) {
|
||||
const decision = await events.waterfall(
|
||||
'agent/prompt-submit', message.content, message.source, signal,
|
||||
() => Promise.resolve<PromptDecision>({ kind: 'allow' }),
|
||||
)
|
||||
interruptionCheckpoint(signal)
|
||||
if (decision.kind === 'block') {
|
||||
lastBlockReason = decision.reason
|
||||
// Record the veto durably: `PromptDecision.reason` is the durable record
|
||||
// of why a prompt was blocked, but a fully-blocked batch's `rejected`
|
||||
// turn/end only preserves the LAST reason, and a MIXED batch (this prompt
|
||||
// blocked, another allowed) does not end `rejected` at all — so without
|
||||
// this append a blocked prompt would vanish from the log whenever any
|
||||
// sibling prompt is allowed. `prompt/blocked` sits in the open turn in
|
||||
// place of the `user/message` this prompt would have become.
|
||||
session.append('prompt/blocked', { content: message.content, source: message.source, reason: decision.reason })
|
||||
continue
|
||||
}
|
||||
anyAllowed = true
|
||||
const promptDecision = await events.waterfall(
|
||||
'agent/prompt-submit', message.content, message.source, signal,
|
||||
() => Promise.resolve<PromptDecision>({ kind: 'allow' }),
|
||||
)
|
||||
interruptionCheckpoint(signal)
|
||||
if (promptDecision.kind === 'block') {
|
||||
session.append('prompt/blocked', { content: message.content, source: message.source, reason: promptDecision.reason })
|
||||
reason = { kind: 'rejected', reason: promptDecision.reason }
|
||||
} else {
|
||||
// `allow.content` REPLACES the prompt bytes (a rewrite); absent keeps them.
|
||||
const content = decision.content ?? message.content
|
||||
const content = promptDecision.content ?? message.content
|
||||
session.append('user/message', { content, source: message.source }, { surfaceOp: 'append' })
|
||||
// Every `allow.additionalContexts` entry is a separate context/message the
|
||||
// next request also sees. The turn is open, so inject() appends each one
|
||||
// into THIS turn without flattening provenance, framing, or metadata.
|
||||
for (const context of decision.additionalContexts ?? []) {
|
||||
// into THIS turn without flattening provenance or metadata.
|
||||
for (const context of promptDecision.additionalContexts ?? []) {
|
||||
agent.inject(context.content, {
|
||||
source: context.source,
|
||||
...context.envelope !== undefined ? { envelope: context.envelope } : {},
|
||||
...context.meta !== undefined ? { meta: context.meta } : {},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
while (true) {
|
||||
// A fully blocked batch closes its zero-step turn as rejected.
|
||||
if (!anyAllowed) {
|
||||
reason = { kind: 'rejected', reason: lastBlockReason }
|
||||
break
|
||||
}
|
||||
// A blocked prompt closes its zero-step turn as rejected.
|
||||
if (promptDecision.kind === 'block') break
|
||||
step += 1
|
||||
|
||||
// Steering from the previous round's continuation listeners joins before
|
||||
// the request.
|
||||
drainSteering(agent, handle.inbox, turn)
|
||||
drainSteering()
|
||||
|
||||
// Assemble once before pre-step so pressure checks and the request share the same prompt.
|
||||
// Assemble once before pre-step so listener work and the request share one prompt value.
|
||||
const assembly = await ctx.systemPrompt.assemble(assembleContextFor(agent, signal))
|
||||
interruptionCheckpoint(signal)
|
||||
const fullSystemPrompt = renderPrompt(assembly)
|
||||
|
||||
// Compose the request-only prefix once per loop instance before pressure
|
||||
// checks. It precedes all derived history and is recorded only in the
|
||||
// request header, not as session history.
|
||||
// Compose the request-only prefix once per loop instance before the first
|
||||
// request boundary. It precedes all derived history and is recorded only
|
||||
// in the request header, not as session history.
|
||||
if (transmission.sessionPrefix === undefined) {
|
||||
const emptyPrefix: Message[] = deepFreeze([])
|
||||
const composed = await events.waterfall(
|
||||
@@ -320,8 +333,8 @@ async function runTurn(
|
||||
transmission.sessionPrefix = deepFreeze(structuredClone(composed))
|
||||
}
|
||||
|
||||
// Await surface mutations outside the step; pressure checks receive the pending prefix.
|
||||
await events.serial('agent/pre-step', turn, step, fullSystemPrompt, transmission.sessionPrefix, signal)
|
||||
// Await surface mutations outside the step before snapshotting history.
|
||||
await events.serial('agent/pre-step', turn, step, signal)
|
||||
interruptionCheckpoint(signal)
|
||||
|
||||
// Snapshot the exact log prefix before step/start: the reconstruction
|
||||
@@ -333,15 +346,67 @@ async function runTurn(
|
||||
// pre-commit veto throws before this assignment; post-commit observers
|
||||
// are contained inside Session.append().
|
||||
stepOpen = true
|
||||
|
||||
// A synchronous step/start observer can cancel after the step opened.
|
||||
interruptionCheckpoint(signal)
|
||||
|
||||
let stepOutcome: { hadToolCalls: boolean; finish: FinishReason } | { error: Error }
|
||||
let stepOutcome:
|
||||
| { hadToolCalls: boolean; finish: FinishReason }
|
||||
| { requestError: RequestError }
|
||||
| { error: RequestError }
|
||||
try {
|
||||
stepOutcome = await runStep(
|
||||
ctx, events, agent, handle, turn, step, assembly, fullSystemPrompt, boundaryMessages, transmission, signal)
|
||||
ctx, events, handle, turn, step, assembly, fullSystemPrompt, boundaryMessages, transmission, signal)
|
||||
} catch (error: unknown) {
|
||||
stepOutcome = { error: toError(error) }
|
||||
if (error instanceof TerminalModelRequestFailure) {
|
||||
stepOutcome = { requestError: error.requestError }
|
||||
} else {
|
||||
stepOutcome = { error: toError(error) }
|
||||
}
|
||||
}
|
||||
|
||||
if ('requestError' in stepOutcome) {
|
||||
// Recovery observes a balanced failed step and the original provider
|
||||
// error while the failed step's signal remains the active owner.
|
||||
closeStep()
|
||||
const interrupted = interruptionTurnEndReason(handle, signal)
|
||||
if (interrupted !== undefined) {
|
||||
reason = interrupted
|
||||
break
|
||||
}
|
||||
|
||||
const defaultDecision: RequestErrorDecision = { action: 'fail' }
|
||||
let recoveryDecision: RequestErrorDecision = defaultDecision
|
||||
try {
|
||||
recoveryDecision = await events.waterfall(
|
||||
'agent/request-error', turn, step, stepOutcome.requestError,
|
||||
requestRetryAttempt, signal,
|
||||
() => Promise.resolve(defaultDecision),
|
||||
)
|
||||
} catch (recoveryError: unknown) {
|
||||
ctx.logger.warn(
|
||||
`agent "${agent.id}": request recovery failed at turn ${turn}, step ${step}: ${errorChain(recoveryError)}`,
|
||||
)
|
||||
}
|
||||
// Cancellation and disposal always win over either a recovery decision
|
||||
// or a recovery-listener failure.
|
||||
const recoveryInterrupted = interruptionTurnEndReason(handle, signal)
|
||||
if (recoveryInterrupted !== undefined) {
|
||||
reason = recoveryInterrupted
|
||||
break
|
||||
}
|
||||
switch (recoveryDecision.action) {
|
||||
case 'retry':
|
||||
requestRetryAttempt += 1
|
||||
continue
|
||||
case 'fail':
|
||||
failTurn(stepOutcome.requestError)
|
||||
break
|
||||
/* v8 ignore next -- closed-union exhaustiveness guard */
|
||||
default:
|
||||
assertNever(recoveryDecision, 'agent request-error decision')
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if ('error' in stepOutcome) {
|
||||
@@ -350,21 +415,43 @@ async function runTurn(
|
||||
// starts a fresh turn instead of being silently consumed.
|
||||
closeStep()
|
||||
const { error } = stepOutcome
|
||||
const interruption = interruptionTurnEndReason(handle, signal)
|
||||
if (interruption === undefined) failTurn(error)
|
||||
else reason = interruption
|
||||
const interrupted = interruptionTurnEndReason(handle, signal)
|
||||
if (interrupted === undefined) failTurn(error)
|
||||
else reason = interrupted
|
||||
break
|
||||
}
|
||||
|
||||
requestRetryAttempt = 0
|
||||
|
||||
// Preserve max-token completion unless a later disposal, abort, or error wins.
|
||||
const stepReason = stepFinishReason(stepOutcome.finish)
|
||||
if (stepReason) reason = stepReason
|
||||
|
||||
// Steering that arrived during streaming/tool execution.
|
||||
const steered = drainSteering(agent, handle.inbox, turn)
|
||||
const steered = drainSteering()
|
||||
|
||||
try {
|
||||
await events.serial('agent/post-step', turn, step, signal)
|
||||
} catch (error: unknown) {
|
||||
stepOutcome = { error: toError(error) }
|
||||
}
|
||||
|
||||
if ('error' in stepOutcome) {
|
||||
closeStep()
|
||||
const interrupted = interruptionTurnEndReason(handle, signal)
|
||||
if (interrupted === undefined) failTurn(stepOutcome.error)
|
||||
else reason = interrupted
|
||||
break
|
||||
}
|
||||
|
||||
const postStepInterrupted = interruptionTurnEndReason(handle, signal)
|
||||
if (postStepInterrupted !== undefined) {
|
||||
reason = postStepInterrupted
|
||||
closeStep()
|
||||
break
|
||||
}
|
||||
|
||||
closeStep()
|
||||
interruptionCheckpoint(signal)
|
||||
|
||||
const defaultDecision: ContinuationDecision = { action: stepOutcome.hadToolCalls || steered ? 'continue' : 'stop' }
|
||||
let decision: ContinuationDecision
|
||||
@@ -375,9 +462,9 @@ async function runTurn(
|
||||
)
|
||||
interruptionCheckpoint(signal)
|
||||
} catch (error: unknown) {
|
||||
const interruption = interruptionTurnEndReason(handle, signal)
|
||||
if (interruption === undefined) failTurn(toError(error))
|
||||
else reason = interruption
|
||||
const interrupted = interruptionTurnEndReason(handle, signal)
|
||||
if (interrupted === undefined) failTurn(toError(error))
|
||||
else reason = interrupted
|
||||
break
|
||||
}
|
||||
|
||||
@@ -397,9 +484,11 @@ async function runTurn(
|
||||
interruptionCheckpoint(signal)
|
||||
terminalStop = stop !== undefined
|
||||
} catch (error: unknown) {
|
||||
const interruption = interruptionTurnEndReason(handle, signal)
|
||||
if (interruption === undefined) failTurn(toError(error))
|
||||
else reason = interruption
|
||||
// A broken terminal policy is an ordinary continuation failure: fail
|
||||
// this turn closed while leaving the driver alive for later turns.
|
||||
const interrupted = interruptionTurnEndReason(handle, signal)
|
||||
if (interrupted === undefined) failTurn(toError(error))
|
||||
else reason = interrupted
|
||||
break
|
||||
}
|
||||
if (terminalStop) {
|
||||
@@ -409,11 +498,7 @@ async function runTurn(
|
||||
shouldContinue = false
|
||||
}
|
||||
|
||||
if (!shouldContinue || handle.isDisposed()) {
|
||||
/* v8 ignore next -- disposal during continuation-decision window is a narrow race; error-path disposal is covered elsewhere */
|
||||
if (handle.isDisposed()) reason = { kind: 'disposed' }
|
||||
break
|
||||
}
|
||||
if (!shouldContinue) break
|
||||
}
|
||||
|
||||
// Normal / inline-error loop exit: close the turn.
|
||||
@@ -423,9 +508,9 @@ async function runTurn(
|
||||
const turnStartLogged = session.events.some(e => e.type === 'turn/start' && e.data.turn === turn)
|
||||
if (!turnStartLogged) throw error
|
||||
closeStep()
|
||||
const interruption = interruptionTurnEndReason(handle, signal)
|
||||
if (interruption === undefined) failTurn(toError(error))
|
||||
else reason = interruption
|
||||
const interrupted = interruptionTurnEndReason(handle, signal)
|
||||
if (interrupted === undefined) failTurn(toError(error))
|
||||
else reason = interrupted
|
||||
closeTurn()
|
||||
}
|
||||
|
||||
@@ -435,7 +520,7 @@ async function runTurn(
|
||||
} catch (error: unknown) {
|
||||
// The turn is closed, so report the failed flush live rather than append outside a turn.
|
||||
const err = toError(error)
|
||||
ctx.logger.warn(`agent "${agent.id}": session/flush failed at turn ${turn}: ${err.message}`)
|
||||
ctx.logger.warn(`agent "${agent.id}": session/flush failed at turn ${turn}: ${errorChain(err)}`)
|
||||
try {
|
||||
events.emit('agent/error', turn, step, err)
|
||||
} catch {
|
||||
@@ -445,15 +530,6 @@ async function runTurn(
|
||||
return terminalStopped
|
||||
}
|
||||
|
||||
/** Drain the steering queue into the session. Returns whether any arrived. */
|
||||
function drainSteering(agent: ReactLoopAgent, inbox: Inbox, turn: number): boolean {
|
||||
const messages = inbox.drainSteering()
|
||||
for (const message of messages) {
|
||||
agent.session.append('steering/message', { turn, content: message.content, source: message.source }, { surfaceOp: 'append' })
|
||||
}
|
||||
return messages.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one committed step: transform call config, log the request header, build
|
||||
* the request from the cached prefix plus the step-boundary snapshot, stream and
|
||||
@@ -463,7 +539,6 @@ function drainSteering(agent: ReactLoopAgent, inbox: Inbox, turn: number): boole
|
||||
async function runStep(
|
||||
ctx: Context,
|
||||
events: AgentEventDispatch,
|
||||
agent: ReactLoopAgent,
|
||||
handle: LoopHandle,
|
||||
turn: number,
|
||||
step: number,
|
||||
@@ -473,6 +548,7 @@ async function runStep(
|
||||
transmission: TransmissionLog,
|
||||
signal: AbortSignal,
|
||||
): Promise<{ hadToolCalls: boolean; finish: FinishReason }> {
|
||||
const agent = ctx.agents.requireInitiator()
|
||||
const { session, options } = agent
|
||||
|
||||
// Seed the first request from agent options and later requests from the logged header;
|
||||
@@ -483,7 +559,9 @@ async function runStep(
|
||||
: { provider: options.provider ?? '', model: options.model ?? '' }))
|
||||
|
||||
// Listener replacements are recorded in the request header before dispatch.
|
||||
const config = await events.waterfall('agent/request', turn, step, seedConfig, signal, () => Promise.resolve(seedConfig))
|
||||
const config = await events.waterfall(
|
||||
'agent/request', turn, step, seedConfig, signal, () => Promise.resolve(seedConfig),
|
||||
)
|
||||
interruptionCheckpoint(signal)
|
||||
if (!config.provider || !config.model) {
|
||||
throw new Error(`agent "${agent.id}" has no provider/model: set AgentOptions.provider and AgentOptions.model or supply both via the agent/request waterfall`)
|
||||
@@ -518,27 +596,67 @@ async function runStep(
|
||||
// --- Model call (streaming-first; raw chunks are the replay record) ---
|
||||
const assembler = new BlockAssembler()
|
||||
const chunkSeqs: number[] = []
|
||||
for await (const chunk of ctx.llm.stream(request)) {
|
||||
interruptionCheckpoint(signal)
|
||||
const chunkEvent = session.append('assistant/chunk', { turn, step, chunk })
|
||||
chunkSeqs.push(chunkEvent.seq)
|
||||
assembler.push(chunk)
|
||||
const stream = ctx.llm.stream(request)
|
||||
try {
|
||||
for await (const chunk of stream) {
|
||||
interruptionCheckpoint(signal)
|
||||
const chunkEvent = session.append('assistant/chunk', { turn, step, chunk })
|
||||
chunkSeqs.push(chunkEvent.seq)
|
||||
assembler.push(chunk)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (isLlmAdapterFailure(stream, error)) throw new TerminalModelRequestFailure(error)
|
||||
throw error
|
||||
}
|
||||
interruptionCheckpoint(signal)
|
||||
|
||||
// Normalize failure finish chunks into the same path as thrown stream errors.
|
||||
const stepError = finishError(assembler.finish)
|
||||
if (stepError) throw stepError
|
||||
if (stepError) throw new TerminalModelRequestFailure(stepError)
|
||||
|
||||
const recordAssistantMessage = (
|
||||
assembledContent: ContentBlock[],
|
||||
message: Message,
|
||||
preserveReplayState = true,
|
||||
): void => {
|
||||
session.append(
|
||||
'assistant/message',
|
||||
{
|
||||
turn,
|
||||
step,
|
||||
content: message.content,
|
||||
provenance: assistantProvenance(
|
||||
header.config,
|
||||
assembler.replayState,
|
||||
preserveReplayState && isDeepStrictEqual(message.content, assembledContent),
|
||||
),
|
||||
...assembler.usage === undefined ? {} : { usage: assembler.usage },
|
||||
},
|
||||
{ surfaceOp: 'append', sourceEventSeqs: chunkSeqs },
|
||||
)
|
||||
}
|
||||
|
||||
// A rejected result still records the successful provider call without retaining rejected output.
|
||||
const processStepResult = async (assembledContent: ContentBlock[], message: Message): Promise<Message> => {
|
||||
try {
|
||||
const processed = await events.waterfall(
|
||||
'agent/step-result', turn, step, message, signal, () => Promise.resolve(message),
|
||||
)
|
||||
interruptionCheckpoint(signal)
|
||||
return processed
|
||||
} catch (error: unknown) {
|
||||
recordAssistantMessage(assembledContent, { ...message, content: [] }, false)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
if (assembler.finish.kind === 'max-tokens') {
|
||||
const assembled = assembler.message()
|
||||
const assembledContent = structuredClone(assembled.content)
|
||||
let message: Message = withoutToolCalls(assembled)
|
||||
message = withoutToolCalls(await processStepResult(
|
||||
events, session, turn, step, header.config, assembledContent, message, assembler, chunkSeqs, signal,
|
||||
))
|
||||
message = withoutToolCalls(await processStepResult(assembledContent, message))
|
||||
// Preserve usage even when max-token truncation produced no content.
|
||||
recordAssistantMessage(session, turn, step, header.config, assembledContent, message, assembler, chunkSeqs)
|
||||
recordAssistantMessage(assembledContent, message)
|
||||
return { hadToolCalls: false, finish: assembler.finish }
|
||||
}
|
||||
|
||||
@@ -546,89 +664,23 @@ async function runStep(
|
||||
const assembled = assembler.message()
|
||||
const assembledContent = structuredClone(assembled.content)
|
||||
let message: Message = assembled
|
||||
message = await processStepResult(
|
||||
events, session, turn, step, header.config, assembledContent, message, assembler, chunkSeqs, signal,
|
||||
)
|
||||
message = await processStepResult(assembledContent, message)
|
||||
|
||||
// Every successful call records its completion anchor, including explicit
|
||||
// empty chunk provenance for a contentless, usage-less provider response.
|
||||
recordAssistantMessage(session, turn, step, header.config, assembledContent, message, assembler, chunkSeqs)
|
||||
recordAssistantMessage(assembledContent, message)
|
||||
|
||||
// Dispatch may overlap; policy, durable results, and result context stay model-ordered.
|
||||
const toolCalls = message.content.filter(block => block.type === 'tool-call')
|
||||
if (toolCalls.length === 0) return { hadToolCalls: false, finish: assembler.finish }
|
||||
return handle.withToolBatch(async (acceptContext) => {
|
||||
await executeToolCalls(
|
||||
ctx, agent, turn, step, toolCalls, signal, handle.maxParallelToolCalls, acceptContext,
|
||||
ctx, turn, step, toolCalls, signal, handle.maxParallelToolCalls, acceptContext,
|
||||
)
|
||||
return { hadToolCalls: true, finish: assembler.finish }
|
||||
})
|
||||
}
|
||||
|
||||
/** Preserve successful-call accounting without retaining output that result processing rejected. */
|
||||
async function processStepResult(
|
||||
events: AgentEventDispatch,
|
||||
session: Session,
|
||||
turn: number,
|
||||
step: number,
|
||||
config: LlmCallConfig,
|
||||
assembledContent: ContentBlock[],
|
||||
message: Message,
|
||||
assembler: BlockAssembler,
|
||||
chunkSeqs: number[],
|
||||
signal: AbortSignal,
|
||||
): Promise<Message> {
|
||||
try {
|
||||
const processed = await events.waterfall(
|
||||
'agent/step-result', turn, step, message, signal, () => Promise.resolve(message),
|
||||
)
|
||||
interruptionCheckpoint(signal)
|
||||
return processed
|
||||
} catch (error: unknown) {
|
||||
recordAssistantMessage(
|
||||
session,
|
||||
turn,
|
||||
step,
|
||||
config,
|
||||
assembledContent,
|
||||
{ ...message, content: [] },
|
||||
assembler,
|
||||
chunkSeqs,
|
||||
false,
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** Record one content-or-usage assistant message with replay-safe provenance. */
|
||||
function recordAssistantMessage(
|
||||
session: Session,
|
||||
turn: number,
|
||||
step: number,
|
||||
config: LlmCallConfig,
|
||||
assembledContent: ContentBlock[],
|
||||
message: Message,
|
||||
assembler: BlockAssembler,
|
||||
chunkSeqs: number[],
|
||||
preserveReplayState = true,
|
||||
): void {
|
||||
session.append(
|
||||
'assistant/message',
|
||||
{
|
||||
turn,
|
||||
step,
|
||||
content: message.content,
|
||||
provenance: assistantProvenance(
|
||||
config,
|
||||
assembler.replayState,
|
||||
preserveReplayState && isDeepStrictEqual(message.content, assembledContent),
|
||||
),
|
||||
...assembler.usage === undefined ? {} : { usage: assembler.usage },
|
||||
},
|
||||
{ surfaceOp: 'append', sourceEventSeqs: chunkSeqs },
|
||||
)
|
||||
}
|
||||
|
||||
/** Build durable assistant provenance, dropping replay state after any content rewrite. */
|
||||
function assistantProvenance(config: LlmCallConfig, replayState: unknown, contentUnchanged: boolean): NonNullable<Message['provenance']> {
|
||||
return {
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
* Dispatch may overlap, while policy, results, and result context remain
|
||||
* model-ordered. Abort stops replenishment and drains started calls.
|
||||
*
|
||||
* Each started call records `tool/call`; `tool/result` commits in model order,
|
||||
* preserving derived history when audit events interleave with earlier results.
|
||||
* Each advertised call records a balanced `tool/call`/`tool/result` pair. Calls
|
||||
* skipped after abort receive synthetic error results so replay stays valid.
|
||||
* @module dsh-agent-loop/tool-calls
|
||||
*/
|
||||
|
||||
@@ -14,7 +14,6 @@ import { assertNever, type ToolCallBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { HookContext } from '@deepseek-ai/dsh-agent'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import { TOOL_REGISTRY_SCHEDULER, type ToolExecutionInput, type ToolExecutionMode, type ToolExecutionResult, type ToolRunContext } from '@deepseek-ai/dsh-tools'
|
||||
import type { ReactLoopAgent } from './agent.ts'
|
||||
|
||||
/** One tool call after argument parsing, ready to schedule. */
|
||||
interface PlannedCall {
|
||||
@@ -29,21 +28,21 @@ interface Slot {
|
||||
needsPost: boolean
|
||||
}
|
||||
|
||||
/** Control-flow sentinel; turn classification reads only the explicit signal. */
|
||||
const TOOL_CALLS_INTERRUPTED = new Error('tool calls interrupted')
|
||||
|
||||
/** Stop scheduling at a cooperative boundary without exposing the runtime reason. */
|
||||
function interruptionCheckpoint(signal: AbortSignal): void {
|
||||
if (signal.aborted) throw TOOL_CALLS_INTERRUPTED
|
||||
/** One scheduler group outcome, including a drained cancellation. */
|
||||
interface GroupOutcome {
|
||||
consumed: number
|
||||
aborted: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule one assistant step's tool calls by their live concurrency mode.
|
||||
* Started calls receive ordered results. Abort drains them and rethrows after
|
||||
* accepting their context into the batch FIFO owned by the caller.
|
||||
* Started calls receive ordered results. Abort drains them, records synthetic
|
||||
* results for unstarted calls, and returns with the signal still aborted after
|
||||
* accepting started-call context into the batch FIFO owned by the caller.
|
||||
* The committed step's AgentLoop driver boundary supplies the initiating Agent
|
||||
* that becomes each explicit {@link ToolExecutionInput.agent}.
|
||||
*
|
||||
* @param ctx - loop context that owns the tool registry.
|
||||
* @param agent - agent and session receiving the call lifecycle.
|
||||
* @param ctx - loop context that owns the tool registry and carries the initiating Agent.
|
||||
* @param turn - current turn number.
|
||||
* @param step - current step number.
|
||||
* @param toolCalls - assistant calls in model order.
|
||||
@@ -53,7 +52,6 @@ function interruptionCheckpoint(signal: AbortSignal): void {
|
||||
*/
|
||||
export async function executeToolCalls(
|
||||
ctx: Context,
|
||||
agent: ReactLoopAgent,
|
||||
turn: number,
|
||||
step: number,
|
||||
toolCalls: ToolCallBlock[],
|
||||
@@ -61,6 +59,7 @@ export async function executeToolCalls(
|
||||
maxParallel: number,
|
||||
acceptContext: (context: HookContext) => void,
|
||||
): Promise<void> {
|
||||
const agent = ctx.agents.requireInitiator()
|
||||
const { session } = agent
|
||||
|
||||
// Inputs are distinct because tools/execute wrappers may replace `exec.signal`.
|
||||
@@ -82,7 +81,14 @@ export async function executeToolCalls(
|
||||
const first = planned[next]!
|
||||
const mode = ctx.tools.executionMode(first.exec).kind
|
||||
const group = mode === 'parallel' ? planned.slice(next) : [first]
|
||||
next += await runGroup(ctx, session, turn, step, group, mode, signal, maxParallel, acceptContext)
|
||||
const outcome = await runGroup(
|
||||
ctx, turn, step, group, mode, signal, maxParallel, acceptContext,
|
||||
)
|
||||
next += outcome.consumed
|
||||
if (outcome.aborted) {
|
||||
for (const call of planned.slice(next)) appendSkippedToolCall(session, turn, step, call.block)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,11 +106,11 @@ function parseArguments(raw: string): unknown {
|
||||
* before start; an exclusive reclassification waits for the current pool to
|
||||
* drain and remains for the caller's next barrier. Results and contexts commit
|
||||
* in model order. Abort stops starts, drains and commits started calls, accepts
|
||||
* their contexts into the owning batch, and throws.
|
||||
* their contexts into the owning batch, records results for skipped calls, and
|
||||
* returns an aborted outcome.
|
||||
*/
|
||||
async function runGroup(
|
||||
ctx: Context,
|
||||
session: Session,
|
||||
turn: number,
|
||||
step: number,
|
||||
group: PlannedCall[],
|
||||
@@ -112,8 +118,8 @@ async function runGroup(
|
||||
signal: AbortSignal,
|
||||
maxParallel: number,
|
||||
acceptContext: (context: HookContext) => void,
|
||||
): Promise<number> {
|
||||
interruptionCheckpoint(signal)
|
||||
): Promise<GroupOutcome> {
|
||||
const { session } = ctx.agents.requireInitiator()
|
||||
const slots: (Slot | undefined)[] = group.map(() => undefined)
|
||||
// Started slots retain their tool/call seq for result provenance.
|
||||
const callSeqs: number[] = group.map(() => -1)
|
||||
@@ -191,17 +197,30 @@ async function runGroup(
|
||||
inFlight.delete(settledIndex)
|
||||
await commitReady()
|
||||
// Abort may arrive while a tool or ordered commit awaits.
|
||||
|
||||
if (signal.aborted) aborted = true
|
||||
await fillPool()
|
||||
}
|
||||
|
||||
if (aborted) {
|
||||
// Started calls and accepted context settle before the turn records the abort.
|
||||
throw TOOL_CALLS_INTERRUPTED
|
||||
// Started calls and accepted context settle first; every remaining model
|
||||
// call then receives an ordered synthetic result before the turn aborts.
|
||||
for (const call of group.slice(started)) appendSkippedToolCall(session, turn, step, call.block)
|
||||
return { consumed: group.length, aborted: true }
|
||||
}
|
||||
/* v8 ignore next -- unreachable: a non-aborted group commits every started call */
|
||||
if (committed !== started) throw new Error('tool-call scheduler: uncommitted settled calls')
|
||||
return started
|
||||
return { consumed: started, aborted: false }
|
||||
}
|
||||
|
||||
/** Append the durable call/result pair for a model call skipped after cancellation. */
|
||||
function appendSkippedToolCall(session: Session, turn: number, step: number, block: ToolCallBlock): void {
|
||||
const callSeq = appendToolCall(session, turn, step, block)
|
||||
appendToolResult(session, turn, step, block, {
|
||||
content: [{ type: 'text', text: 'Error: tool call skipped because the step was aborted before execution' }],
|
||||
isError: true,
|
||||
error: { name: 'AbortError', code: 'ABORTED' },
|
||||
}, callSeq)
|
||||
}
|
||||
|
||||
/** Append a started call and return its provenance sequence. */
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context, FiberState, type Fiber } from 'cordis'
|
||||
import AgentRegistry, { AgentId, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution'
|
||||
import type { AgentExecutionService } from '@deepseek-ai/dsh-agent-execution'
|
||||
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import { Context, type Fiber } from 'cordis'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import LlmService, { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
@@ -13,7 +11,7 @@ import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
|
||||
|
||||
interface Harness {
|
||||
ctx: Context
|
||||
providerFiber: Fiber
|
||||
agentsFiber: Fiber
|
||||
loopFiber: Fiber
|
||||
}
|
||||
|
||||
@@ -23,14 +21,13 @@ async function harness(adapter: LlmAdapter): Promise<Harness> {
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const providerFiber = await ctx.plugin(AgentExecutionProvider)
|
||||
const agentsFiber = await ctx.plugin(AgentRegistry)
|
||||
const loopFiber = await ctx.plugin(AgentLoop, { agents: [] })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return { ctx, providerFiber, loopFiber }
|
||||
return { ctx, agentsFiber, loopFiber }
|
||||
}
|
||||
|
||||
function waitForIdle(ctx: Context, agent: ReactLoopAgent | Agent): 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') {
|
||||
@@ -56,12 +53,12 @@ class OverlapAdapter extends LlmAdapter {
|
||||
}
|
||||
|
||||
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
const before = this.ctx.agentExecution.require().agent
|
||||
const before = this.ctx.agents.requireInitiator()
|
||||
this.starts += 1
|
||||
if (this.starts === 2) this.bothStarted.resolve(true)
|
||||
await this.bothStarted.promise
|
||||
await Promise.resolve()
|
||||
const after = this.ctx.agentExecution.require().agent
|
||||
const after = this.ctx.agents.requireInitiator()
|
||||
this.observations.push({ sessionId: options.sessionId, before, after })
|
||||
yield* textResponse('done')
|
||||
}
|
||||
@@ -71,12 +68,12 @@ class OverlapAdapter extends LlmAdapter {
|
||||
class TestCapabilityTransport {
|
||||
readonly requests: { path: string; headers: Record<string, string> }[] = []
|
||||
|
||||
constructor(private readonly execution: AgentExecutionService) {}
|
||||
constructor(private readonly agents: AgentRegistry) {}
|
||||
|
||||
async request(path: string): Promise<Record<string, string>> {
|
||||
await Promise.resolve()
|
||||
const headers = {
|
||||
'X-Harness-Session-Id': this.execution.require().agent.session.id,
|
||||
'X-Harness-Session-Id': this.agents.requireInitiator().session.id,
|
||||
}
|
||||
this.requests.push({ path, headers })
|
||||
return headers
|
||||
@@ -89,11 +86,11 @@ class ReloadAdapter extends LlmAdapter {
|
||||
firstAgentDuringAbort: Agent | undefined
|
||||
laterAgent: Agent | undefined
|
||||
calls = 0
|
||||
execution: AgentExecutionService | undefined
|
||||
agents: AgentRegistry | undefined
|
||||
|
||||
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
const execution = this.execution
|
||||
if (execution === undefined) throw new Error('execution service missing')
|
||||
const agents = this.agents
|
||||
if (agents === undefined) throw new Error('agent service missing')
|
||||
this.calls += 1
|
||||
if (this.calls === 1) {
|
||||
this.firstStarted.resolve(true)
|
||||
@@ -105,18 +102,18 @@ class ReloadAdapter extends LlmAdapter {
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
await Promise.resolve()
|
||||
this.firstAgentDuringAbort = execution.require().agent
|
||||
this.firstAgentDuringAbort = agents.requireInitiator()
|
||||
throw error
|
||||
}
|
||||
return
|
||||
}
|
||||
await Promise.resolve()
|
||||
this.laterAgent = execution.require().agent
|
||||
this.laterAgent = agents.requireInitiator()
|
||||
yield* textResponse('reloaded')
|
||||
}
|
||||
}
|
||||
|
||||
describe('AgentLoop execution context', () => {
|
||||
describe('AgentLoop initiator scope', () => {
|
||||
it('keeps overlapping driver continuations bound to their exact Agents', async () => {
|
||||
const ctx = new Context()
|
||||
const adapter = new OverlapAdapter(ctx)
|
||||
@@ -125,12 +122,11 @@ describe('AgentLoop execution context', () => {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentExecutionProvider)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
|
||||
const a = ctx.agentLoop.create(AgentId('a'), { provider: 'mock', model: 'mock' })
|
||||
const b = ctx.agentLoop.create(AgentId('b'), { provider: 'mock', model: 'mock' })
|
||||
const a = ctx.agentLoop.create(SessionId('a'), { provider: 'mock', model: 'mock' })
|
||||
const b = ctx.agentLoop.create(SessionId('b'), { provider: 'mock', model: 'mock' })
|
||||
const idleA = waitForIdle(ctx, a)
|
||||
const idleB = waitForIdle(ctx, b)
|
||||
send(a, 'a')
|
||||
@@ -142,24 +138,22 @@ describe('AgentLoop execution context', () => {
|
||||
{ sessionId: a.session.id, before: a, after: a },
|
||||
{ sessionId: b.session.id, before: b, after: b },
|
||||
]))
|
||||
expect(ctx.agentExecution.current()).toBeUndefined()
|
||||
expect(ctx.agents.currentInitiator()).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('keeps ALS identity minimal while one explicit signal spans each turn seam', async () => {
|
||||
it('keeps initiator identity minimal while one explicit signal spans each turn seam', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('observe-call', 'observe', {}),
|
||||
textResponse('first done'),
|
||||
textResponse('second done'),
|
||||
])
|
||||
const { ctx } = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('signal-owner'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('signal-owner'), { provider: 'mock', model: 'mock' })
|
||||
let signals: AbortSignal[] = []
|
||||
const capture = (signal: AbortSignal | undefined): void => {
|
||||
if (signal === undefined) throw new Error('turn seam omitted its explicit signal')
|
||||
const execution = ctx.agentExecution.require()
|
||||
expect(Object.keys(execution)).toEqual(['agent'])
|
||||
expect(execution.agent).toBe(agent)
|
||||
expect(ctx.agents.requireInitiator()).toBe(agent)
|
||||
signals.push(signal)
|
||||
}
|
||||
|
||||
@@ -175,7 +169,7 @@ describe('AgentLoop execution context', () => {
|
||||
if (subject === agent) capture(signal)
|
||||
return next()
|
||||
})
|
||||
ctx.on('agent/pre-step', (subject, _turn, _step, _system, _prefix, signal) => {
|
||||
ctx.on('agent/pre-step', (subject, _turn, _step, signal) => {
|
||||
if (subject === agent) capture(signal)
|
||||
})
|
||||
ctx.on('agent/request', async (subject, _turn, _step, _config, signal, next) => {
|
||||
@@ -218,11 +212,11 @@ describe('AgentLoop execution context', () => {
|
||||
expect(secondSignal).toBeDefined()
|
||||
expect(new Set([...signals, adapter.requests[2]!.signal!])).toEqual(new Set([secondSignal]))
|
||||
expect(secondSignal).not.toBe(firstSignal)
|
||||
expect(ctx.agentExecution.current()).toBeUndefined()
|
||||
expect(ctx.agents.currentInitiator()).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('keeps child setup under the parent boundary, switches for the child driver, then restores the parent', async () => {
|
||||
it('keeps child setup under the parent boundary and restores the parent while the child driver remains active', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('spawn', 'spawn-child', {}),
|
||||
toolCallResponse('observe', 'observe-child', {}),
|
||||
@@ -233,7 +227,7 @@ describe('AgentLoop execution context', () => {
|
||||
let parentDuringSetup: Agent | undefined
|
||||
let explicitChild: Agent | undefined
|
||||
let childDuringDriver: Agent | undefined
|
||||
let parentAfterChild: Agent | undefined
|
||||
let parentWhileChildDriverActive: Agent | undefined
|
||||
let child: Agent | undefined
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
@@ -243,11 +237,10 @@ describe('AgentLoop execution context', () => {
|
||||
execute: async (_args, exec) => {
|
||||
if (exec.agent === undefined) throw new Error('parent agent missing')
|
||||
const handle = await exec.agent.ctx.agents.create({
|
||||
agentId: AgentId('child'),
|
||||
sessionId: SessionId('child-session'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
setup: (agentCtx) => {
|
||||
parentDuringSetup = ctx.agentExecution.require().agent
|
||||
parentDuringSetup = ctx.agents.requireInitiator()
|
||||
explicitChild = agentCtx.agent
|
||||
agentCtx.tools.register(defineTool({
|
||||
name: 'observe-child',
|
||||
@@ -255,23 +248,22 @@ describe('AgentLoop execution context', () => {
|
||||
parameters: {},
|
||||
execute: async () => {
|
||||
await Promise.resolve()
|
||||
childDuringDriver = ctx.agentExecution.require().agent
|
||||
childDuringDriver = ctx.agents.requireInitiator()
|
||||
return [{ type: 'text', text: 'observed' }]
|
||||
},
|
||||
}))
|
||||
},
|
||||
})
|
||||
child = handle.agent
|
||||
parentWhileChildDriverActive = ctx.agents.requireInitiator()
|
||||
send(handle.agent, 'run child')
|
||||
await handle.agent.whenIdle()
|
||||
parentAfterChild = ctx.agentExecution.require().agent
|
||||
await handle.dispose()
|
||||
return [{ type: 'text', text: 'child completed' }]
|
||||
},
|
||||
}))
|
||||
|
||||
const parentHandle = await ctx.agents.create({
|
||||
agentId: AgentId('parent'),
|
||||
sessionId: SessionId('parent-session'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
@@ -282,8 +274,8 @@ describe('AgentLoop execution context', () => {
|
||||
expect(parentDuringSetup).toBe(parentHandle.agent)
|
||||
expect(explicitChild).toBe(child)
|
||||
expect(childDuringDriver).toBe(child)
|
||||
expect(parentAfterChild).toBe(parentHandle.agent)
|
||||
expect(ctx.agentExecution.current()).toBeUndefined()
|
||||
expect(parentWhileChildDriverActive).toBe(parentHandle.agent)
|
||||
expect(ctx.agents.currentInitiator()).toBeUndefined()
|
||||
await parentHandle.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
@@ -294,7 +286,7 @@ describe('AgentLoop execution context', () => {
|
||||
textResponse('done'),
|
||||
])
|
||||
const { ctx } = await harness(adapter)
|
||||
const transport = new TestCapabilityTransport(ctx.agentExecution)
|
||||
const transport = new TestCapabilityTransport(ctx.agents)
|
||||
let directAmbient: Agent | undefined
|
||||
let captured: Agent | undefined
|
||||
|
||||
@@ -304,7 +296,7 @@ describe('AgentLoop execution context', () => {
|
||||
parameters: {},
|
||||
execute: async () => {
|
||||
await Promise.resolve()
|
||||
directAmbient = ctx.agentExecution.current()?.agent
|
||||
directAmbient = ctx.agents.currentInitiator()
|
||||
return [{ type: 'text', text: 'ok' }]
|
||||
},
|
||||
}))
|
||||
@@ -313,7 +305,7 @@ describe('AgentLoop execution context', () => {
|
||||
description: 'call the test capability transport',
|
||||
parameters: { path: { type: 'string' } },
|
||||
execute: async (args) => {
|
||||
captured = ctx.agentExecution.require().agent
|
||||
captured = ctx.agents.requireInitiator()
|
||||
const path = (args as { path: string }).path
|
||||
const headers = await transport.request(path)
|
||||
return [{ type: 'text', text: JSON.stringify(headers) }]
|
||||
@@ -329,7 +321,6 @@ describe('AgentLoop execution context', () => {
|
||||
expect(directAmbient).toBeUndefined()
|
||||
|
||||
const handle = await ctx.agents.create({
|
||||
agentId: AgentId('transport'),
|
||||
sessionId: SessionId('transport-session'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
@@ -350,45 +341,16 @@ describe('AgentLoop execution context', () => {
|
||||
|
||||
await handle.dispose()
|
||||
expect(captured?.status).toBe('disposed')
|
||||
expect(ctx.agentExecution.current()).toBeUndefined()
|
||||
expect(ctx.agents.currentInitiator()).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('keeps AgentLoop inactive until the mandatory provider appears', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const loopFiber = ctx.plugin(AgentLoop, { agents: [] })
|
||||
await Promise.resolve()
|
||||
expect(loopFiber.state).toBe(FiberState.PENDING)
|
||||
|
||||
await ctx.plugin(AgentExecutionProvider)
|
||||
await loopFiber
|
||||
expect(loopFiber.state).toBe(FiberState.ACTIVE)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('drains the old driver before disabling ALS during provider restart', async () => {
|
||||
const ctx = new Context()
|
||||
it('drains the old driver before disabling ALS during agent-service restart', async () => {
|
||||
const adapter = new ReloadAdapter()
|
||||
const { providerFiber, loopFiber } = await (async (): Promise<Harness> => {
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const mountedProvider = await ctx.plugin(AgentExecutionProvider)
|
||||
const mountedLoop = await ctx.plugin(AgentLoop, { agents: [] })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return { ctx, providerFiber: mountedProvider, loopFiber: mountedLoop }
|
||||
})()
|
||||
const oldService = ctx.agentExecution
|
||||
adapter.execution = oldService
|
||||
const { ctx, agentsFiber, loopFiber } = await harness(adapter)
|
||||
const oldService = ctx.agents
|
||||
adapter.agents = oldService
|
||||
const oldHandle = await ctx.agents.create({
|
||||
agentId: AgentId('before-restart'),
|
||||
sessionId: SessionId('before-restart-session'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
@@ -396,17 +358,16 @@ describe('AgentLoop execution context', () => {
|
||||
send(oldAgent, 'block')
|
||||
await adapter.firstStarted.promise
|
||||
|
||||
await providerFiber.restart()
|
||||
await agentsFiber.restart()
|
||||
await loopFiber.await()
|
||||
expect(adapter.firstAgentDuringAbort?.id).toBe(oldAgent.id)
|
||||
expect(adapter.firstAgentDuringAbort?.session).toBe(oldAgent.session)
|
||||
expect(oldAgent.status).toBe('disposed')
|
||||
expect(() => oldService.current()).toThrow('agent execution service is disposed')
|
||||
expect(ctx.agentExecution).not.toBe(oldService)
|
||||
adapter.execution = ctx.agentExecution
|
||||
expect(() => oldService.currentInitiator()).toThrow('agent initiator scope is disposed')
|
||||
expect(ctx.agents).not.toBe(oldService)
|
||||
adapter.agents = ctx.agents
|
||||
|
||||
const newHandle = await ctx.agents.create({
|
||||
agentId: AgentId('after-restart'),
|
||||
sessionId: SessionId('after-restart-session'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
@@ -427,13 +388,11 @@ describe('AgentLoop execution context', () => {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentExecutionProvider)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const service = ctx.agentExecution
|
||||
adapter.execution = service
|
||||
const service = ctx.agents
|
||||
adapter.agents = service
|
||||
const handle = await ctx.agents.create({
|
||||
agentId: AgentId('root-dispose'),
|
||||
sessionId: SessionId('root-dispose-session'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
@@ -445,6 +404,6 @@ describe('AgentLoop execution context', () => {
|
||||
expect(adapter.firstAgentDuringAbort?.id).toBe(agent.id)
|
||||
expect(adapter.firstAgentDuringAbort?.session).toBe(agent.session)
|
||||
expect(agent.status).toBe('disposed')
|
||||
expect(() => service.current()).toThrow('agent execution service is disposed')
|
||||
expect(() => service.currentInitiator()).toThrow('agent initiator scope is disposed')
|
||||
})
|
||||
})
|
||||
@@ -1,16 +1,18 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
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 AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution'
|
||||
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)
|
||||
@@ -18,13 +20,12 @@ async function harness(adapter: MockAdapter) {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentExecutionProvider)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
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 dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
@@ -35,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) {
|
||||
@@ -46,23 +47,22 @@ 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(AgentExecutionProvider)
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('exclusive-driver'))
|
||||
const prepared = prepareReactLoopAgent(
|
||||
ctx, AgentId('first-driver'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS,
|
||||
ctx, SessionId('first-driver'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS,
|
||||
)
|
||||
|
||||
expect(() => prepared.agent.ctx).toThrow('context is not bound')
|
||||
expect(() => prepareReactLoopAgent(
|
||||
ctx, AgentId('second-driver'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS,
|
||||
ctx, SessionId('second-driver'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS,
|
||||
))
|
||||
.toThrow('already has a concrete agent driver')
|
||||
|
||||
@@ -73,12 +73,12 @@ describe('ReactLoopAgent', () => {
|
||||
it('borrows caller options and binds its scoped context exactly once', async () => {
|
||||
const ctx = await harness(new MockAdapter([textResponse('unused')]))
|
||||
const options = { provider: 'mock', model: 'mock' }
|
||||
const agent = ctx.agentLoop.create(AgentId('owned-bindings'), options)
|
||||
const agent = ctx.agentLoop.create(SessionId('owned-bindings'), options)
|
||||
|
||||
expect(agent.options).toBe(options)
|
||||
expect(agent.id).toBe('owned-bindings')
|
||||
expect(agent.session.id).toMatch(/^owned-bindings-session-/)
|
||||
expect(() => { bindReactLoopAgentContext(agent, new Context()) }).toThrow(/context is already bound/)
|
||||
expect(agent.session.id).toBe(agent.id)
|
||||
expect(() => { bindReactLoopAgentContext(agent as ReactLoopAgent, new Context()) }).toThrow(/context is already bound/)
|
||||
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
@@ -86,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(AgentId('scoped'), { provider: 'mock', model: 'mock' })
|
||||
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')
|
||||
})
|
||||
@@ -101,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(AgentId('scoped'), { provider: 'mock', model: 'mock' })
|
||||
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')
|
||||
})
|
||||
@@ -116,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(AgentId('scoped'), { provider: 'mock', model: 'mock' })
|
||||
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')
|
||||
})
|
||||
@@ -131,7 +131,7 @@ describe('ReactLoopAgent', () => {
|
||||
it('inject() decides enclosure from the LOG (open turn), not agent status', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
// Simulate an OPEN turn in the log while the agent is idle (status is not a
|
||||
// reliable open-turn signal). inject must append into that open turn, NOT
|
||||
@@ -157,7 +157,7 @@ describe('ReactLoopAgent', () => {
|
||||
// A persistence-like listener whose flush rejects.
|
||||
ctx.on('session/flush', () => { throw new Error('disk gone') })
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
// inject() is synchronous and fires a fire-and-forget flush; a rejecting
|
||||
// flush must be contained (logged), never thrown into the caller.
|
||||
@@ -170,12 +170,14 @@ describe('ReactLoopAgent', () => {
|
||||
it('idle inject() closes its one-shot turn AND still checkpoints even if the append throws', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
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/)
|
||||
@@ -188,7 +190,7 @@ describe('ReactLoopAgent', () => {
|
||||
it('idle inject() still checkpoints when a listener throws on the synthetic turn/end', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
let flushes = 0
|
||||
ctx.on('session/flush', () => { flushes += 1 })
|
||||
// Session contains a throwing post-commit turn/end observer. The accepted
|
||||
@@ -211,7 +213,7 @@ describe('ReactLoopAgent', () => {
|
||||
// A non-Error rejection exercises the String() normalization branch.
|
||||
ctx.on('session/flush', () => { throw 'disk gone' })
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const errors: { turn: number; step: number; message: string }[] = []
|
||||
ctx.on('agent/error', (_a, turn, step, error) => void errors.push({ turn, step, message: error.message }))
|
||||
|
||||
@@ -230,7 +232,7 @@ describe('ReactLoopAgent', () => {
|
||||
it('idle inject() with a non-serializable source opens no turn (nothing to close)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
// A non-serializable source makes the turn/start append throw BEFORE the
|
||||
// event is pushed (Session.append validates before push), so NO turn opens.
|
||||
@@ -245,7 +247,7 @@ describe('ReactLoopAgent', () => {
|
||||
it('steer() when idle falls through to send() and starts a turn', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
// steer while idle delegates to send
|
||||
agent.steer([{ type: 'text', text: 'steer idle' }], { source: { kind: 'plugin', plugin: 'test' } })
|
||||
@@ -257,23 +259,29 @@ 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(AgentExecutionProvider)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const session = ctx.sessions.create(SessionId('test'))
|
||||
const prepared = prepareReactLoopAgent(
|
||||
ctx, AgentId('bare'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS,
|
||||
ctx, SessionId('bare'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS,
|
||||
)
|
||||
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')
|
||||
})
|
||||
@@ -283,7 +291,7 @@ describe('ReactLoopAgent', () => {
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('pre-start-dispose'))
|
||||
const prepared = prepareReactLoopAgent(
|
||||
ctx, AgentId('pre-start-dispose'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS,
|
||||
ctx, SessionId('pre-start-dispose'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS,
|
||||
)
|
||||
|
||||
await prepared.dispose()
|
||||
@@ -298,7 +306,7 @@ describe('ReactLoopAgent', () => {
|
||||
it('setting the same status does not emit agent/status again', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const statuses: string[] = []
|
||||
ctx.on('agent/status', (subject, status) => {
|
||||
@@ -317,7 +325,7 @@ describe('ReactLoopAgent', () => {
|
||||
it('whenIdle() resolves immediately when the agent is not running', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
// Fresh agent is idle — whenIdle() takes the not-running fast path and
|
||||
// resolves without subscribing. await must not hang.
|
||||
@@ -328,7 +336,7 @@ describe('ReactLoopAgent', () => {
|
||||
it('whenIdle() waits for queued work that has not flipped status yet', async () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
send(agent, 'queued')
|
||||
let settled = false
|
||||
@@ -346,8 +354,8 @@ describe('ReactLoopAgent', () => {
|
||||
it('whenIdle() awaits the running→idle transition, ignoring other subjects/running events', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok'), textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const other = ctx.agentLoop.create(AgentId('a2'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const other = ctx.agentLoop.create(SessionId('a2'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
// Drive `agent` into `running`, then await whenIdle() — it subscribes to
|
||||
// agent/status and resolves on the first transition out of running.
|
||||
@@ -370,10 +378,11 @@ 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(AgentExecutionProvider)
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
@@ -383,7 +392,7 @@ describe('ReactLoopAgent', () => {
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const session = ctx.sessions.create(SessionId('bare'))
|
||||
const prepared = prepareReactLoopAgent(
|
||||
ctx, AgentId('bare'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS,
|
||||
ctx, SessionId('bare'), { provider: 'mock', model: 'mock' }, session, DEFAULT_MAX_PARALLEL_TOOL_CALLS,
|
||||
)
|
||||
const { agent } = prepared
|
||||
prepared.markPublished()
|
||||
@@ -400,13 +409,15 @@ 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(AgentId('scoped'), { provider: 'mock', model: 'mock' })
|
||||
agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
send(agent, 'go')
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
@@ -419,19 +430,21 @@ 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(AgentId('scoped'), { provider: 'mock', model: 'mock' })
|
||||
agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
send(agent, 'go')
|
||||
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')
|
||||
|
||||
@@ -446,7 +459,7 @@ describe('ReactLoopAgent', () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
ctx.on('agent/status', (_subject, status) => {
|
||||
if (status === 'running') throw new Error('bad running listener')
|
||||
})
|
||||
@@ -464,7 +477,7 @@ describe('ReactLoopAgent', () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
ctx.on('agent/status', (_subject, status) => {
|
||||
if (status === 'idle') throw new Error('bad idle listener')
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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, { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution'
|
||||
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() }
|
||||
@@ -24,7 +24,281 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
async function makeCoreContext(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
return ctx
|
||||
}
|
||||
|
||||
describe('config-driven session id', () => {
|
||||
it('rejects an empty exact id before publishing an agent', async () => {
|
||||
const ctx = await makeCoreContext()
|
||||
await expect(ctx.plugin(AgentLoop, {
|
||||
agents: [{ id: 'main', sessionId: SessionId(''), model: 'mock' }],
|
||||
})).rejects.toThrow('expected string length >= 1')
|
||||
expect(ctx.agents.get(SessionId(''))).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('accepts one exact fresh id and rejects it alongside a resume id', async () => {
|
||||
const exact = await makeCoreContext()
|
||||
await exact.plugin(AgentLoop, {
|
||||
agents: [{ id: 'main', sessionId: SessionId('config-exact'), model: 'mock' }],
|
||||
})
|
||||
expect(exact.agents.get(SessionId('config-exact'))?.session.id).toBe('config-exact')
|
||||
await exact.fiber.dispose()
|
||||
|
||||
const conflicting = await makeCoreContext()
|
||||
await expect(conflicting.plugin(AgentLoop, {
|
||||
agents: [{
|
||||
id: 'main',
|
||||
sessionId: SessionId('fresh'),
|
||||
resumeSessionId: SessionId('persisted'),
|
||||
model: 'mock',
|
||||
}],
|
||||
})).rejects.toThrow('sessionId and resumeSessionId are mutually exclusive')
|
||||
await conflicting.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects duplicate exact ids before asynchronous configured startup', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-exact-duplicate-'))
|
||||
dirs.push(root)
|
||||
const ctx = await makeCoreContext()
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root })
|
||||
|
||||
const outcome = await ctx.plugin(AgentLoop, {
|
||||
agents: [
|
||||
{ id: 'first', sessionId: SessionId('shared'), model: 'mock' },
|
||||
{ id: 'second', sessionId: SessionId('shared'), model: 'mock' },
|
||||
],
|
||||
}).then(() => undefined, (error: unknown) => error)
|
||||
const published = ctx.agents.get(SessionId('shared'))
|
||||
await ctx.fiber.dispose()
|
||||
|
||||
expect(outcome).toEqual(new Error('agents "first" and "second" use duplicate exact session identity "shared"'))
|
||||
expect(published).toBeUndefined()
|
||||
})
|
||||
|
||||
it('restores a materialized exact id across an AgentLoop-only reload', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-exact-reload-'))
|
||||
dirs.push(root)
|
||||
const ctx = await makeCoreContext()
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('first'), textResponse('second')]))
|
||||
const config = { agents: [{ id: 'main', sessionId: SessionId('config-exact-reload'), model: 'mock' }] }
|
||||
|
||||
const firstLoop = await ctx.plugin(AgentLoop, config)
|
||||
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('config-exact-reload'))
|
||||
}
|
||||
expect(first).toBeDefined()
|
||||
first!.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, first!)
|
||||
await firstLoop.dispose()
|
||||
|
||||
const secondLoop = await ctx.plugin(AgentLoop, config)
|
||||
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('config-exact-reload'))
|
||||
}
|
||||
expect(second).toBeDefined()
|
||||
expect(JSON.stringify(second!.session.deriveMessages())).toContain('remember me')
|
||||
second!.send([{ type: 'text', text: 'continue' }], { source: { kind: 'user' } })
|
||||
await waitForIdle(ctx, second!)
|
||||
await ctx.sessions.flush(second!.session)
|
||||
const loaded = await ctx.sessionPersistence.load(SessionId('config-exact-reload'))
|
||||
expect(loaded.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
|
||||
|
||||
await secondLoop.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('waits for a draining exact-id lifecycle during an overlapping reload', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-exact-overlap-'))
|
||||
dirs.push(root)
|
||||
const ctx = await makeCoreContext()
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root })
|
||||
const sessionId = SessionId('config-exact-overlap')
|
||||
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 Agent
|
||||
|
||||
const flushGate = Promise.withResolvers<undefined>()
|
||||
let flushStarted = false
|
||||
ctx.on('session/flush', (session) => {
|
||||
if (session !== first.session) return
|
||||
flushStarted = true
|
||||
return flushGate.promise
|
||||
})
|
||||
first.inject([{ type: 'text', text: 'persist before replacement' }], {
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
})
|
||||
expect(flushStarted).toBe(true)
|
||||
|
||||
const firstDisposal = firstLoop.dispose()
|
||||
await expect.poll(() => first.status).toBe('disposed')
|
||||
const failures: unknown[] = []
|
||||
ctx.on('agent-loop/config-start-failed', (_id, error) => { failures.push(error) })
|
||||
const secondLoop = await ctx.plugin(AgentLoop, config)
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(ctx.agents.get(sessionId)).toBe(first)
|
||||
expect(failures).toEqual([])
|
||||
|
||||
flushGate.resolve(undefined)
|
||||
await firstDisposal
|
||||
await expect.poll(() => ctx.agents.get(sessionId)).toBeDefined()
|
||||
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([])
|
||||
|
||||
await secondLoop.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('cancels an exact-id reload while the prior lifecycle is still draining', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-exact-cancel-'))
|
||||
dirs.push(root)
|
||||
const ctx = await makeCoreContext()
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root })
|
||||
const sessionId = SessionId('config-exact-cancel')
|
||||
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 Agent
|
||||
|
||||
const flushGate = Promise.withResolvers<undefined>()
|
||||
ctx.on('session/flush', (session) => {
|
||||
if (session === first.session) return flushGate.promise
|
||||
})
|
||||
first.inject([{ type: 'text', text: 'persist before cancellation' }], {
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
})
|
||||
|
||||
const firstDisposal = firstLoop.dispose()
|
||||
await expect.poll(() => first.status).toBe('disposed')
|
||||
const secondLoop = await ctx.plugin(AgentLoop, config)
|
||||
await secondLoop.dispose()
|
||||
expect(ctx.agents.get(sessionId)).toBe(first)
|
||||
|
||||
flushGate.resolve(undefined)
|
||||
await firstDisposal
|
||||
expect(ctx.agents.get(sessionId)).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('contains an exact-id persistence lookup failure', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-exact-failure-'))
|
||||
dirs.push(root)
|
||||
const ctx = await makeCoreContext()
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root })
|
||||
const failure = new Error('persistence index failed')
|
||||
const listenerFailure = new Error('failure observer failed')
|
||||
const asyncListenerFailure = new Error('async failure observer failed')
|
||||
const failures: { sessionId: SessionId; error: unknown }[] = []
|
||||
ctx.on('agent-loop/config-start-failed', () => { throw listenerFailure })
|
||||
ctx.on('agent-loop/config-start-failed', () => Promise.reject(asyncListenerFailure) as never)
|
||||
ctx.on('agent-loop/config-start-failed', (sessionId, error) => {
|
||||
failures.push({ sessionId, error })
|
||||
})
|
||||
vi.spyOn(ctx.sessionPersistence, 'list').mockRejectedValue(failure)
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
|
||||
await ctx.plugin(AgentLoop, {
|
||||
agents: [{ id: 'main', sessionId: SessionId('config-exact-failure'), model: 'mock' }],
|
||||
})
|
||||
|
||||
await expect.poll(() => warn).toHaveBeenCalledWith(expect.stringContaining(
|
||||
'config-driven restore of "config-exact-failure" failed: persistence index failed',
|
||||
))
|
||||
expect(failures).toEqual([{ sessionId: SessionId('config-exact-failure'), error: failure }])
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'agent "main": config-start-failed listener threw: failure observer failed',
|
||||
)
|
||||
await expect.poll(() => warn).toHaveBeenCalledWith(
|
||||
'agent "main": config-start-failed listener rejected: async failure observer failed',
|
||||
)
|
||||
expect(ctx.agents.get(SessionId('config-exact-failure'))).toBeUndefined()
|
||||
warn.mockRestore()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('contains startup and observer failures whose string coercion throws', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-exact-unrenderable-'))
|
||||
dirs.push(root)
|
||||
const ctx = await makeCoreContext()
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root })
|
||||
const unrenderable = {
|
||||
[Symbol.toPrimitive](): never {
|
||||
throw new Error('coercion escaped')
|
||||
},
|
||||
}
|
||||
const failures: unknown[] = []
|
||||
ctx.on('agent-loop/config-start-failed', () => { throw unrenderable })
|
||||
// Deliberately violate the normal Error-only rejection rule to exercise the unknown boundary.
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
|
||||
ctx.on('agent-loop/config-start-failed', () => Promise.reject(unrenderable) as never)
|
||||
ctx.on('agent-loop/config-start-failed', (_sessionId, error) => { failures.push(error) })
|
||||
vi.spyOn(ctx.sessionPersistence, 'list').mockRejectedValue(unrenderable)
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
|
||||
await ctx.plugin(AgentLoop, {
|
||||
agents: [{ id: 'main', sessionId: SessionId('config-exact-unrenderable'), model: 'mock' }],
|
||||
})
|
||||
|
||||
await expect.poll(() => failures).toEqual([unrenderable])
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'agent "main": config-driven restore of "config-exact-unrenderable" failed: <unrenderable value>',
|
||||
)
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'agent "main": config-start-failed listener threw: <unrenderable value>',
|
||||
)
|
||||
await expect.poll(() => warn).toHaveBeenCalledWith(
|
||||
'agent "main": config-start-failed listener rejected: <unrenderable value>',
|
||||
)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it.each(['resolve', 'reject'] as const)(
|
||||
'joins an exact-id persistence lookup that will %s before AgentLoop disposal completes',
|
||||
async (outcome) => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-exact-dispose-'))
|
||||
dirs.push(root)
|
||||
const ctx = await makeCoreContext()
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root })
|
||||
const listing = Promise.withResolvers<Awaited<ReturnType<typeof ctx.sessionPersistence.list>>>()
|
||||
vi.spyOn(ctx.sessionPersistence, 'list').mockReturnValue(listing.promise)
|
||||
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
|
||||
const failures: unknown[] = []
|
||||
ctx.on('agent-loop/config-start-failed', (_sessionId, error) => { failures.push(error) })
|
||||
|
||||
const loop = await ctx.plugin(AgentLoop, {
|
||||
agents: [{ id: 'main', sessionId: SessionId('config-exact-dispose'), model: 'mock' }],
|
||||
})
|
||||
let disposed = false
|
||||
const disposal = loop.dispose().then(() => { disposed = true })
|
||||
await Promise.resolve()
|
||||
expect(disposed).toBe(false)
|
||||
|
||||
if (outcome === 'resolve') listing.resolve([])
|
||||
else listing.reject(new Error('startup cancelled by teardown'))
|
||||
await disposal
|
||||
expect(ctx.agents.get(SessionId('config-exact-dispose'))).toBeUndefined()
|
||||
expect(failures).toEqual([])
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
warn.mockRestore()
|
||||
await ctx.fiber.dispose()
|
||||
},
|
||||
)
|
||||
|
||||
it('identity-nests the deferred resume fiber under its labeled owner effect', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
@@ -32,9 +306,8 @@ describe('config-driven session id', () => {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentExecutionProvider)
|
||||
const loopFiber = await ctx.plugin(AgentLoop, {
|
||||
agents: [{ id: AgentId('main'), provider: 'mock', model: 'mock', resumeSessionId: SessionId('deferred') }],
|
||||
agents: [{ id: SessionId('main'), provider: 'mock', model: 'mock', resumeSessionId: SessionId('deferred') }],
|
||||
})
|
||||
|
||||
const resumeEffect = loopFiber.getEffects().find(effect => effect.label === 'agentLoop.resume(main)')
|
||||
@@ -55,12 +328,13 @@ describe('config-driven session id', () => {
|
||||
await ctx1.plugin(SystemPrompt)
|
||||
await ctx1.plugin(ToolRegistry)
|
||||
await ctx1.plugin(AgentRegistry)
|
||||
await ctx1.plugin(AgentExecutionProvider)
|
||||
await ctx1.plugin(AgentLoop, { agents: [{ id: AgentId('cfg'), provider: 'mock', model: 'mock' }] })
|
||||
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.get(AgentId('cfg')) 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()
|
||||
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
|
||||
await waitForIdle(ctx1, a1)
|
||||
await ctx1.fiber.dispose()
|
||||
@@ -73,11 +347,11 @@ describe('config-driven session id', () => {
|
||||
await ctx2.plugin(SystemPrompt)
|
||||
await ctx2.plugin(ToolRegistry)
|
||||
await ctx2.plugin(AgentRegistry)
|
||||
await ctx2.plugin(AgentExecutionProvider)
|
||||
await ctx2.plugin(AgentLoop, { agents: [{ id: AgentId('cfg'), provider: 'mock', model: 'mock' }] })
|
||||
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.get(AgentId('cfg')) 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)
|
||||
a2.send([{ type: 'text', text: 'q2' }], { source: { kind: 'user' } })
|
||||
@@ -97,11 +371,10 @@ describe('config-driven session id', () => {
|
||||
await ctx1.plugin(SystemPrompt)
|
||||
await ctx1.plugin(ToolRegistry)
|
||||
await ctx1.plugin(AgentRegistry)
|
||||
await ctx1.plugin(AgentExecutionProvider)
|
||||
await ctx1.plugin(AgentLoop, { agents: [] })
|
||||
await ctx1.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('first')]))
|
||||
const a1 = (await ctx1.agents.create({ agentId: AgentId('main'), 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()
|
||||
@@ -114,20 +387,20 @@ describe('config-driven session id', () => {
|
||||
await ctx2.plugin(SystemPrompt)
|
||||
await ctx2.plugin(ToolRegistry)
|
||||
await ctx2.plugin(AgentRegistry)
|
||||
await ctx2.plugin(AgentExecutionProvider)
|
||||
await ctx2.plugin(AgentLoop, { agents: [{ id: AgentId('main'), provider: 'mock', model: 'mock', resumeSessionId: SessionId('sticky-1') }] })
|
||||
await ctx2.plugin(AgentLoop, { agents: [{ id: SessionId('main'), provider: 'mock', model: 'mock', resumeSessionId: SessionId('sticky-1') }] })
|
||||
await ctx2.plugin(SessionPersistenceJsonl, { root })
|
||||
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(AgentId('main')) 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>),
|
||||
// and the prior turn's user message is in the derived history.
|
||||
expect(resumed!.id).toBe(SessionId('sticky-1'))
|
||||
expect(resumed!.session.id).toBe('sticky-1')
|
||||
const derived = resumed!.session.deriveMessages()
|
||||
expect(JSON.stringify(derived)).toContain('remember me')
|
||||
@@ -143,17 +416,16 @@ describe('config-driven session id', () => {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentExecutionProvider)
|
||||
await ctx.plugin(AgentLoop, { agents: [{ id: AgentId('main'), provider: 'mock', model: 'mock', resumeSessionId: SessionId('does-not-exist') }] })
|
||||
await ctx.plugin(AgentLoop, { agents: [{ id: SessionId('main'), provider: 'mock', model: 'mock', resumeSessionId: SessionId('does-not-exist') }] })
|
||||
const warn = vi.spyOn((ctx.agentLoop as unknown as { ctx: { logger: { warn: (...a: unknown[]) => void } } }).ctx.logger, 'warn')
|
||||
.mockImplementation(() => undefined)
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('x')]))
|
||||
|
||||
// The deferred resume fails (no such session on disk). It must be contained:
|
||||
// a warning is logged, no 'main' agent is registered, and the app stays up.
|
||||
// a warning is logged, no agent is registered, and the app stays up.
|
||||
await new Promise(r => setTimeout(r, 200))
|
||||
expect(ctx.agents.get(AgentId('main'))).toBeUndefined()
|
||||
expect(ctx.agents.list()).toEqual([])
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('config-driven resume of "does-not-exist" failed'))
|
||||
warn.mockRestore()
|
||||
await ctx.fiber.dispose()
|
||||
|
||||
@@ -4,13 +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, { AgentId, type ContinuationDecision } from '@deepseek-ai/dsh-agent'
|
||||
import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution'
|
||||
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) {
|
||||
@@ -20,13 +23,12 @@ async function harness(adapter: MockAdapter) {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentExecutionProvider)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
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 dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
@@ -37,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 }])
|
||||
}
|
||||
|
||||
@@ -57,7 +59,7 @@ describe('session log records what agent/step-result actually produced', () => {
|
||||
return [{ type: 'text', text: 'ran' }]
|
||||
},
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
// Plugin rewrites the message: replaces the text AND adds a tool call.
|
||||
let rewritten = false
|
||||
@@ -99,7 +101,7 @@ describe('session log records what agent/step-result actually produced', () => {
|
||||
response[response.length - 1] = { type: 'finish', reason: { kind: 'stop' }, replayState }
|
||||
const adapter = new MockAdapter([response])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('replay-state'), { provider: 'mock', model: 'next-model' })
|
||||
const agent = ctx.agentLoop.create(SessionId('replay-state'), { provider: 'mock', model: 'next-model' })
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -123,7 +125,7 @@ describe('session log records what agent/step-result actually produced', () => {
|
||||
if (block?.type === 'text') block.text = 'mutated'
|
||||
return message
|
||||
})
|
||||
const agent = ctx.agentLoop.create(AgentId('mutated-replay-state'), { provider: 'mock', model: 'next-model' })
|
||||
const agent = ctx.agentLoop.create(SessionId('mutated-replay-state'), { provider: 'mock', model: 'next-model' })
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -143,7 +145,7 @@ describe('successful provider completion survives agent/step-result failure', ()
|
||||
const adapter = new MockAdapter([response])
|
||||
const ctx = await harness(adapter)
|
||||
await ctx.plugin(Invariants)
|
||||
const agent = ctx.agentLoop.create(AgentId(id), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId(id), { provider: 'mock', model: 'mock' })
|
||||
const failure = new Error(`${id} result processing failed`)
|
||||
const reported: Error[] = []
|
||||
|
||||
@@ -202,7 +204,7 @@ describe('successful provider completion survives agent/step-result failure', ()
|
||||
})
|
||||
|
||||
describe('abort during tool execution ends the turn', () => {
|
||||
it('cancelling the active turn inside a tool prevents both remaining tools and the next model step', async () => {
|
||||
it('balances a cancelled tool batch through context and post-step before closing', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
// model asks for two tool calls in one step
|
||||
[
|
||||
@@ -216,17 +218,28 @@ describe('abort during tool execution ends the turn', () => {
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const executed: string[] = []
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'aborter',
|
||||
description: '',
|
||||
parameters: {},
|
||||
async execute() {
|
||||
async execute(_args, exec) {
|
||||
executed.push('aborter')
|
||||
exec.agent?.steer(
|
||||
[{ type: 'text', text: 'steering before abort' }],
|
||||
{ source: { kind: 'plugin', plugin: 'abort-test' } },
|
||||
)
|
||||
agent.cancel({ kind: 'user' })
|
||||
return [{ type: 'text', text: 'done' }]
|
||||
},
|
||||
}))
|
||||
ctx.on('tools/post-execute', async exec => ({
|
||||
kind: 'accept',
|
||||
additionalContexts: [{
|
||||
content: [{ type: 'text', text: `context for ${exec.callId}` }],
|
||||
source: { kind: 'plugin', plugin: 'abort-test' },
|
||||
}],
|
||||
}))
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'second',
|
||||
description: '',
|
||||
@@ -238,20 +251,69 @@ describe('abort during tool execution ends the turn', () => {
|
||||
}))
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
||||
const order: string[] = []
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (session !== agent.session) return
|
||||
switch (event.type) {
|
||||
case 'assistant/message': order.push('assistant/message'); break
|
||||
case 'tool/call': order.push(`tool/call:${event.data.callId}`); break
|
||||
case 'tool/result': {
|
||||
const outcome = event.data.error?.code === 'ABORTED' ? 'synthetic-aborted' : 'real'
|
||||
order.push(`tool/result:${event.data.callId}:${outcome}`)
|
||||
break
|
||||
}
|
||||
case 'context/message': order.push('context/message'); break
|
||||
case 'steering/message': order.push('steering/message'); break
|
||||
case 'step/end': order.push('step/end'); break
|
||||
case 'turn/end': {
|
||||
reasons.push(event.data.reason)
|
||||
order.push(`turn/end:${event.data.reason.kind}`)
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
let postSteps = 0
|
||||
ctx.on('agent/post-step', (subject, turn, step, signal) => {
|
||||
if (subject !== agent) return
|
||||
postSteps += 1
|
||||
expect({ turn, step, aborted: signal.aborted }).toEqual({ turn: 1, step: 1, aborted: true })
|
||||
order.push('agent/post-step')
|
||||
})
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(executed).toEqual(['aborter']) // second tool never ran
|
||||
expect(adapter.requests).toHaveLength(1) // no follow-up model call
|
||||
expect(executed).toEqual(['aborter'])
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(postSteps).toBe(1)
|
||||
expect(order).toEqual([
|
||||
'assistant/message',
|
||||
'tool/call:c1',
|
||||
'tool/result:c1:real',
|
||||
'tool/call:c2',
|
||||
'tool/result:c2:synthetic-aborted',
|
||||
'context/message',
|
||||
'agent/post-step',
|
||||
'step/end',
|
||||
'turn/end:aborted',
|
||||
])
|
||||
expect(reasons).toEqual([{ kind: 'aborted' }])
|
||||
const calls = agent.session.events.filter(event => event.type === 'tool/call')
|
||||
const results = agent.session.events.filter(event => event.type === 'tool/result')
|
||||
expect(calls.map(event => event.data.callId)).toEqual([CallId('c1'), CallId('c2')])
|
||||
expect(results).toHaveLength(2)
|
||||
expect(results[0]!.data).toMatchObject({ callId: CallId('c1'), isError: false })
|
||||
expect(results[1]!.data).toMatchObject({
|
||||
callId: CallId('c2'),
|
||||
isError: true,
|
||||
error: { name: 'AbortError', code: 'ABORTED' },
|
||||
})
|
||||
})
|
||||
|
||||
it('records context accepted before a tool-step abort in the same turn', async () => {
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'aborter', {})])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a-abort-injection'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a-abort-injection'), { provider: 'mock', model: 'mock' })
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'aborter',
|
||||
description: '',
|
||||
@@ -297,7 +359,7 @@ describe('abort during tool execution ends the turn', () => {
|
||||
{ type: 'finish', reason: { kind: 'tool-calls' } },
|
||||
] satisfies StreamChunk[]])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a-later-abort-context'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a-later-abort-context'), { provider: 'mock', model: 'mock' })
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'first',
|
||||
description: '',
|
||||
@@ -343,9 +405,9 @@ 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(AgentId('a-dispose-injection'), { provider: 'mock', model: 'mock' })
|
||||
agent = inner.agentLoop.create(SessionId('a-dispose-injection'), { provider: 'mock', model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'waiter',
|
||||
@@ -398,7 +460,7 @@ describe('abort during tool execution ends the turn', () => {
|
||||
textResponse('later turn'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a-historical-tool-pair'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a-historical-tool-pair'), { provider: 'mock', model: 'mock' })
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'aborter',
|
||||
description: '',
|
||||
@@ -440,7 +502,7 @@ describe('steering from late extension points is never stranded', () => {
|
||||
textResponse('continued because of steering'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
let steeredOnce = false
|
||||
ctx.on('agent/turn-continuation', async (_agent, _turn, _decision, _signal, next) => {
|
||||
@@ -466,7 +528,7 @@ describe('steering from late extension points is never stranded', () => {
|
||||
textResponse('after goal reminder'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
let steeredOnce = false
|
||||
ctx.on('session/event', (subject, event) => {
|
||||
@@ -494,7 +556,7 @@ describe('steering from late extension points is never stranded', () => {
|
||||
it('steer() from a turn/end session-event listener becomes a queued message for the next turn', async () => {
|
||||
const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const turns: number[] = []
|
||||
let steeredOnce = false
|
||||
@@ -517,27 +579,13 @@ describe('steering from late extension points is never stranded', () => {
|
||||
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('too late for this turn')
|
||||
})
|
||||
|
||||
it('steering queued before turn cancellation is discarded with the cancelled work', async () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
send(agent, 'go')
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
agent.steer([{ type: 'text', text: 'redirect' }])
|
||||
agent.cancel({ kind: 'user' })
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(JSON.stringify(agent.session.events)).not.toContain('redirect')
|
||||
})
|
||||
})
|
||||
|
||||
describe('plugin exceptions are contained', () => {
|
||||
it('a throwing agent/turn-continuation listener ends the turn with an error, loop survives', async () => {
|
||||
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
let threwOnce = false
|
||||
ctx.on('agent/turn-continuation', async (): Promise<ContinuationDecision> => {
|
||||
@@ -562,15 +610,20 @@ describe('plugin exceptions are contained', () => {
|
||||
expect(agent.status).toBe('idle')
|
||||
})
|
||||
|
||||
it('a rejecting session/flush listener is reported but does not kill the agent', async () => {
|
||||
it('a rejecting first-turn flush settles before the queued tail starts', async () => {
|
||||
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
let rejectedOnce = false
|
||||
ctx.on('session/flush', async () => {
|
||||
if (!rejectedOnce) {
|
||||
rejectedOnce = true
|
||||
const firstFlush = Promise.withResolvers<undefined>()
|
||||
const releaseFirstFlush = Promise.withResolvers<undefined>()
|
||||
let flushes = 0
|
||||
ctx.on('session/flush', async (session) => {
|
||||
if (session !== agent.session) return
|
||||
flushes += 1
|
||||
if (flushes === 1) {
|
||||
firstFlush.resolve(undefined)
|
||||
await releaseFirstFlush.promise
|
||||
throw new Error('disk full')
|
||||
}
|
||||
})
|
||||
@@ -578,24 +631,31 @@ describe('plugin exceptions are contained', () => {
|
||||
const errors: Error[] = []
|
||||
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
send(agent, 'first')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(errors.map(e => e.message)).toEqual(['disk full'])
|
||||
|
||||
send(agent, 'second')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
await firstFlush.promise
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
|
||||
|
||||
releaseFirstFlush.resolve(undefined)
|
||||
await idle
|
||||
|
||||
expect(errors.map(e => e.message)).toEqual(['disk full'])
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('disposed status is part of the agent/status contract', () => {
|
||||
it('disposing the fiber emits agent/status(disposed) and ends the turn with reason disposed', async () => {
|
||||
it('disposing the fiber ends the active turn and never starts its queued tail', 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(AgentId('scoped'), { provider: 'mock', model: 'mock' })
|
||||
agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
|
||||
const statuses: string[] = []
|
||||
@@ -605,20 +665,28 @@ describe('disposed status is part of the agent/status contract', () => {
|
||||
|
||||
send(agent, 'go')
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
send(agent, 'queued tail')
|
||||
await fiber.dispose()
|
||||
await agent.done
|
||||
await driverDone(agent)
|
||||
|
||||
expect(statuses).toEqual(['running', 'disposed'])
|
||||
expect(reasons).toEqual([{ kind: 'disposed' }])
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
|
||||
const messages = agent.session.events
|
||||
.filter(event => event.type === 'user/message')
|
||||
.flatMap(event => event.data.content)
|
||||
.flatMap(block => block.type === 'text' ? [block.text] : [])
|
||||
expect(messages).toEqual(['go'])
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('a throwing agent/status listener cannot break disposal or leak the registry entry', 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(AgentId('scoped'), { provider: 'mock', model: 'mock' })
|
||||
agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
|
||||
ctx.on('agent/status', (_agent, status) => {
|
||||
@@ -628,10 +696,10 @@ 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(AgentId('scoped'))).toBeUndefined() // unregistered despite the throw
|
||||
expect(ctx.agents.get(SessionId('scoped'))).toBeUndefined() // unregistered despite the throw
|
||||
})
|
||||
})
|
||||
|
||||
@@ -650,7 +718,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
|
||||
it('an agent without a model fails the step with a clear error (not NO_ADAPTER for "default")', async () => {
|
||||
const adapter = new MockAdapter([textResponse('never')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), {}) // no model
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), {}) // no model
|
||||
|
||||
const errors: Error[] = []
|
||||
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
|
||||
@@ -665,9 +733,9 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
|
||||
it('the agent/request waterfall can supply the model for a model-less agent', async () => {
|
||||
const adapter = new MockAdapter([textResponse('routed')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), {}) // no model — router plugin decides
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), {}) // no model — router plugin decides
|
||||
|
||||
ctx.on('agent/request', async (_agent, _turn, _step, config, _signal, _next) => {
|
||||
ctx.on('agent/request', async (_agent, _turn, _step, config, _next) => {
|
||||
return { ...config, provider: 'mock', model: 'mock' }
|
||||
})
|
||||
|
||||
@@ -680,7 +748,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
|
||||
it('agent/queued carries the resolved source; steering/message records its source', async () => {
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'noop', {}), textResponse('done')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'noop',
|
||||
description: '',
|
||||
@@ -708,7 +776,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
|
||||
it('send() owns content and source before notification and delivery', async () => {
|
||||
const adapter = new MockAdapter([textResponse('done')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('owned-send'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('owned-send'), { provider: 'mock', model: 'mock' })
|
||||
const content = [{ type: 'text' as const, text: 'accepted-send' }]
|
||||
const source = { kind: 'plugin' as const, plugin: 'accepted-source' }
|
||||
let notifiedContent: ContentBlock[] | undefined
|
||||
@@ -744,7 +812,7 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
|
||||
it('running steer() owns content and source before notification and delivery', async () => {
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'gate', {}), textResponse('done')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('owned-steer'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('owned-steer'), { provider: 'mock', model: 'mock' })
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
ctx.tools.register(defineTool({
|
||||
@@ -798,7 +866,7 @@ describe('turn numbering continues across seeded sessions', () => {
|
||||
it('a forked agent continues turn numbers after the seed log', async () => {
|
||||
const first = new MockAdapter([textResponse('turn one')])
|
||||
const ctx = await harness(first)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
send(agent, 'first')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
@@ -810,13 +878,12 @@ describe('turn numbering continues across seeded sessions', () => {
|
||||
await ctx2.plugin(SystemPrompt)
|
||||
await ctx2.plugin(ToolRegistry)
|
||||
await ctx2.plugin(AgentRegistry)
|
||||
await ctx2.plugin(AgentExecutionProvider)
|
||||
await ctx2.plugin(AgentLoop, { agents: [] })
|
||||
ctx2.llm.registerAdapter(['mock'], second)
|
||||
|
||||
const seeded = ctx2.sessions.create(SessionId('forked'), { seed: [...agent.session.events] })
|
||||
const prepared = prepareReactLoopAgent(
|
||||
ctx2, AgentId('forked-agent'), { provider: 'mock', model: 'mock' }, seeded, DEFAULT_MAX_PARALLEL_TOOL_CALLS,
|
||||
ctx2, SessionId('forked-agent'), { provider: 'mock', model: 'mock' }, seeded, DEFAULT_MAX_PARALLEL_TOOL_CALLS,
|
||||
)
|
||||
const forked = prepared.agent
|
||||
prepared.markPublished()
|
||||
@@ -861,7 +928,7 @@ describe('a finish-error stream chunk ends the turn as error, not completed', ()
|
||||
]
|
||||
const adapter = new MockAdapter([errorStream])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a-finish-error'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a-finish-error'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
||||
@@ -886,7 +953,7 @@ describe('a finish-error stream chunk ends the turn as error, not completed', ()
|
||||
]
|
||||
const adapter = new MockAdapter([abortedStream])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a-finish-aborted'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a-finish-aborted'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
||||
@@ -904,7 +971,7 @@ describe('a finish-error stream chunk ends the turn as error, not completed', ()
|
||||
]
|
||||
const adapter = new MockAdapter([errorStream])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a-finish-error-nocode'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a-finish-error-nocode'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
||||
@@ -920,7 +987,7 @@ describe('step boundary publication order', () => {
|
||||
it('the step/start event is in session.events when its session/event listener fires', async () => {
|
||||
const adapter = new MockAdapter([textResponse('done')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a-step-order'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a-step-order'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
// Append commits before observers run.
|
||||
const observed: { turn: number; step: number; lastEventType: string | undefined; sawStepStart: boolean }[] = []
|
||||
@@ -953,7 +1020,6 @@ describe('turn and step boundary recovery', () => {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentExecutionProvider)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(Invariants)
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
@@ -961,7 +1027,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,
|
||||
@@ -976,7 +1042,7 @@ describe('turn and step boundary recovery', () => {
|
||||
it('a throwing step/start observer cannot change a successful turn', async () => {
|
||||
const adapter = new MockAdapter([textResponse('request completed')])
|
||||
const ctx = await balancedHarness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a-stepstart'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a-stepstart'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
// Session owns post-commit containment. The loop sees a successful append,
|
||||
// runs the request, and balances the ordinary step and turn boundaries.
|
||||
@@ -1005,7 +1071,7 @@ describe('turn and step boundary recovery', () => {
|
||||
it('a pre-commit step/start validation failure does not invent a step boundary', async () => {
|
||||
const adapter = new MockAdapter([textResponse('never reached')])
|
||||
const ctx = await balancedHarness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a-stepstart-veto'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a-stepstart-veto'), { provider: 'mock', model: 'mock' })
|
||||
let rejected = false
|
||||
ctx.on('internal/dispatch', (_mode, name, args) => {
|
||||
if (name !== 'session/event') return
|
||||
@@ -1036,7 +1102,7 @@ describe('turn and step boundary recovery', () => {
|
||||
const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider failed' } }]
|
||||
const adapter = new MockAdapter([errorStream])
|
||||
const ctx = await balancedHarness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a-turnend-veto'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a-turnend-veto'), { provider: 'mock', model: 'mock' })
|
||||
let rejected = false
|
||||
ctx.on('internal/dispatch', (_mode, name, args) => {
|
||||
if (name !== 'session/event') return
|
||||
@@ -1070,7 +1136,7 @@ describe('turn and step boundary recovery', () => {
|
||||
it('a one-shot step/end validation failure keeps the step open until retry succeeds', async () => {
|
||||
const adapter = new MockAdapter([textResponse('completed before close validation')])
|
||||
const ctx = await balancedHarness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a-stepend-veto'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a-stepend-veto'), { provider: 'mock', model: 'mock' })
|
||||
let rejected = false
|
||||
ctx.on('internal/dispatch', (_mode, name, args) => {
|
||||
if (name !== 'session/event') return
|
||||
@@ -1102,7 +1168,7 @@ describe('turn and step boundary recovery', () => {
|
||||
const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider 500' } }]
|
||||
const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')])
|
||||
const ctx = await balancedHarness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a-errorlistener'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a-errorlistener'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
let threw = false
|
||||
ctx.on('agent/error', () => { if (!threw) { threw = true; throw new Error('boom error-listener') } })
|
||||
@@ -1129,13 +1195,13 @@ describe('turn and step boundary recovery', () => {
|
||||
|
||||
it('disposal during a running turn ends the turn with reason disposed (balanced)', async () => {
|
||||
// The 'hang' adapter blocks in stream() until the signal aborts; disposing
|
||||
// the agent's fiber mid-turn aborts the active turn. The turn must close
|
||||
// the agent's fiber mid-turn aborts the in-flight step. The turn must close
|
||||
// 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(AgentId('a-dispose'), { provider: 'mock', model: 'mock' })
|
||||
agent = inner.agentLoop.create(SessionId('a-dispose'), { provider: 'mock', model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
@@ -1144,7 +1210,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
|
||||
@@ -1160,9 +1226,9 @@ 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(AgentId('a-prestep-dispose-throw'), { provider: 'mock', model: 'mock' })
|
||||
agent = inner.agentLoop.create(SessionId('a-prestep-dispose-throw'), { provider: 'mock', model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
|
||||
let threw = false
|
||||
@@ -1176,7 +1242,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).
|
||||
@@ -1193,7 +1259,7 @@ describe('turn and step boundary recovery', () => {
|
||||
it('a throwing turn/start observer cannot starve the loop or later turns', async () => {
|
||||
const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a-preturn'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a-preturn'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
let threw = false
|
||||
ctx.on('session/event', (_session, event) => {
|
||||
@@ -1224,7 +1290,7 @@ describe('turn and step boundary recovery', () => {
|
||||
it('a throwing step/end observer cannot rewrite the turn outcome', async () => {
|
||||
const adapter = new MockAdapter([textResponse('all good'), textResponse('turn 2 ok')])
|
||||
const ctx = await balancedHarness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a-stepend-throw'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a-stepend-throw'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
let threw = false
|
||||
ctx.on('session/event', (_s, event) => {
|
||||
@@ -1263,7 +1329,7 @@ describe('turn and step boundary recovery', () => {
|
||||
const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider 500' } }]
|
||||
const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a-stependthrow'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a-stependthrow'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
let threw = false
|
||||
ctx.on('session/event', (_s, event) => {
|
||||
@@ -1293,7 +1359,7 @@ describe('turn and step boundary recovery', () => {
|
||||
// boundary stays authoritative and the loop continues normally.
|
||||
const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a-turnendappend'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a-turnendappend'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
let threw = false
|
||||
ctx.on('session/event', (_s, event) => {
|
||||
@@ -1339,7 +1405,7 @@ describe('tool result call identity', () => {
|
||||
return Promise.resolve({ kind: 'accept', content: [{ type: 'text', text: 'ok' }] })
|
||||
}, { prepend: true })
|
||||
|
||||
const agent = ctx.agentLoop.create(AgentId('a-callid'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a-callid'), { provider: 'mock', model: 'mock' })
|
||||
send(agent, 'use tool')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
@@ -1370,9 +1436,9 @@ describe('surface: assistant/message records exact empty provenance when no chun
|
||||
const adapter = new MockAdapter([[]])
|
||||
const ctx = await harness(adapter)
|
||||
await ctx.plugin(Invariants)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
ctx.on('agent/step-result', async (_agent, _turn, _step, _message, _signal, _next) => ({
|
||||
ctx.on('agent/step-result', async (_agent, _turn, _step, _message, _next) => ({
|
||||
role: 'assistant' as const,
|
||||
content: [{ type: 'text' as const, text: 'injected' }],
|
||||
}))
|
||||
@@ -1405,7 +1471,6 @@ describe('disposal and cancellation during pre-step assembly', () => {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentExecutionProvider)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(Invariants)
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
@@ -1416,9 +1481,9 @@ 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(AgentId('a-dispose-assemble'), { provider: 'mock', model: 'mock' })
|
||||
agent = inner.agentLoop.create(SessionId('a-dispose-assemble'), { provider: 'mock', model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
@@ -1433,7 +1498,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.
|
||||
@@ -1457,7 +1522,6 @@ describe('disposal and cancellation during pre-step assembly', () => {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentExecutionProvider)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(Invariants)
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
@@ -1467,9 +1531,9 @@ 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(AgentId('a-cancel-assemble'), { provider: 'mock', model: 'mock' })
|
||||
agent = inner.agentLoop.create(SessionId('a-cancel-assemble'), { provider: 'mock', model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
@@ -1482,7 +1546,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]
|
||||
@@ -1510,7 +1574,6 @@ describe('disposal and cancellation during pre-step assembly', () => {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentExecutionProvider)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(Invariants)
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
@@ -1519,9 +1582,9 @@ 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(AgentId('a-dispose-prestep'), { provider: 'mock', model: 'mock' })
|
||||
agent = inner.agentLoop.create(SessionId('a-dispose-prestep'), { provider: 'mock', model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
@@ -1534,7 +1597,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.
|
||||
@@ -1562,7 +1625,6 @@ describe('disposal and cancellation during pre-step assembly', () => {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentExecutionProvider)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(Invariants)
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
@@ -1571,9 +1633,9 @@ 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(AgentId('a-cancel-prestep'), { provider: 'mock', model: 'mock' })
|
||||
agent = inner.agentLoop.create(SessionId('a-cancel-prestep'), { provider: 'mock', model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
@@ -1586,7 +1648,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)
|
||||
@@ -1612,7 +1674,6 @@ describe('disposal and cancellation during pre-step assembly', () => {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentExecutionProvider)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(Invariants)
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
@@ -1622,9 +1683,9 @@ 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(AgentId('a-dispose-no-leak'), { provider: 'mock', model: 'mock' })
|
||||
agent = inner.agentLoop.create(SessionId('a-dispose-no-leak'), { provider: 'mock', model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
|
||||
send(agent, 'go')
|
||||
@@ -1633,7 +1694,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)
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { CallId, LlmError, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
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, { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution'
|
||||
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, 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)
|
||||
@@ -17,13 +21,12 @@ async function harness(adapter: MockAdapter) {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentExecutionProvider)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
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 dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
@@ -34,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 }])
|
||||
}
|
||||
|
||||
@@ -42,7 +45,7 @@ describe('inbox acceptance', () => {
|
||||
it('rejects non-serializable content or source synchronously before notification or enqueue', async () => {
|
||||
const adapter = new MockAdapter([textResponse('turn 1')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
let queued = 0
|
||||
ctx.on('agent/queued', () => { queued += 1 })
|
||||
|
||||
@@ -82,7 +85,7 @@ describe('tool JSON parse', () => {
|
||||
return [{ type: 'text', text: typeof args === 'string' ? `raw: ${args}` : JSON.stringify(args) }]
|
||||
},
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
send(agent, 'use tool')
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -115,7 +118,7 @@ describe('tool JSON parse', () => {
|
||||
return [{ type: 'text', text: 'ran with empty args' }]
|
||||
},
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
send(agent, 'use tool')
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -128,7 +131,7 @@ describe('toError normalization', () => {
|
||||
it('normalizes non-Error throws from pre-commit dispatch validation via the runLoop backstop', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
let threwOnce = false
|
||||
ctx.on('internal/dispatch', (_mode, name, args) => {
|
||||
@@ -143,18 +146,28 @@ describe('toError normalization', () => {
|
||||
const errors: Error[] = []
|
||||
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
|
||||
|
||||
send(agent, 'go')
|
||||
send(agent, 'fails before turn start')
|
||||
send(agent, 'survives as the next item')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(errors).toHaveLength(1)
|
||||
expect(errors[0]).toMatchObject({ message: 'naked string error', code: 'UNKNOWN' })
|
||||
expect(adapter.requests).toEqual([])
|
||||
expect(agent.session.events.some(event => event.type === 'turn/start' || event.type === 'turn/end')).toBe(false)
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
const starts = agent.session.events.filter(event => event.type === 'turn/start')
|
||||
const ends = agent.session.events.filter(event => event.type === 'turn/end')
|
||||
const messages = agent.session.events.filter(event => event.type === 'user/message')
|
||||
expect(starts).toHaveLength(1)
|
||||
expect(starts[0]?.type === 'turn/start' && starts[0].data.turn).toBe(1)
|
||||
expect(ends).toHaveLength(1)
|
||||
expect(messages).toHaveLength(1)
|
||||
expect(messages[0]?.type === 'user/message' && messages[0].data.content).toEqual([
|
||||
{ type: 'text', text: 'survives as the next item' },
|
||||
])
|
||||
})
|
||||
|
||||
it('normalizes non-Error throws from agent/request waterfall via inline toError in runStep catch', async () => {
|
||||
const adapter = new MockAdapter([textResponse('irrelevant')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
let threwOnce = false
|
||||
ctx.on('agent/request', async (_agent, _turn, _step, _options, _signal, _next) => {
|
||||
@@ -182,7 +195,7 @@ describe('coded error data emission', () => {
|
||||
it('errorData includes code when a coded error (LlmError) is thrown from a plugin', async () => {
|
||||
const adapter = new MockAdapter([textResponse('turn 1')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
let threwOnce = false
|
||||
ctx.on('agent/request', async (_agent, _turn, _step, _options, _signal, next) => {
|
||||
@@ -214,9 +227,9 @@ 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(AgentId('scoped'), { provider: 'mock', model: 'mock' })
|
||||
agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
@@ -225,14 +238,14 @@ 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' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('structured tool error propagation (the runtime-validation RFC, part 2)', () => {
|
||||
describe('structured tool error propagation (the runtime-validation Agent Note, part 2)', () => {
|
||||
it('forwards a tool HarnessError onto the tool/result session event', async () => {
|
||||
const { HarnessError } = await import('@deepseek-ai/dsh-llm')
|
||||
// First model turn calls the tool; second turn (after the tool result is
|
||||
@@ -242,7 +255,7 @@ describe('structured tool error propagation (the runtime-validation RFC, part 2)
|
||||
textResponse('done'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'boom',
|
||||
description: 'always fails',
|
||||
|
||||
@@ -8,17 +8,17 @@ function resolverPair() {
|
||||
}
|
||||
|
||||
describe('Inbox', () => {
|
||||
it('enqueues and drains queued messages in FIFO order', () => {
|
||||
it('dequeues one queued message at a time in FIFO order', () => {
|
||||
const inbox = new Inbox()
|
||||
inbox.enqueue({ content: [{ type: 'text', text: 'first' }], source: { kind: 'user' } })
|
||||
inbox.enqueue({ content: [{ type: 'text', text: 'second' }], source: { kind: 'user' } })
|
||||
expect(inbox.hasQueued).toBe(true)
|
||||
|
||||
const drained = inbox.drainQueued()
|
||||
expect(drained).toHaveLength(2)
|
||||
expect(drained[0]!.content[0]).toMatchObject({ text: 'first' })
|
||||
expect(drained[1]!.content[0]).toMatchObject({ text: 'second' })
|
||||
expect(inbox.dequeueQueued()?.content[0]).toMatchObject({ text: 'first' })
|
||||
expect(inbox.hasQueued).toBe(true)
|
||||
expect(inbox.dequeueQueued()?.content[0]).toMatchObject({ text: 'second' })
|
||||
expect(inbox.hasQueued).toBe(false)
|
||||
expect(inbox.dequeueQueued()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('pushes and drains steering messages separately from queued', () => {
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { CallId, type Message } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import SessionStore, { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, {
|
||||
AgentId,
|
||||
type ContinuationDecision,
|
||||
type PromptDecision,
|
||||
type SessionStartSource,
|
||||
} from '@deepseek-ai/dsh-agent'
|
||||
import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution'
|
||||
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import AgentRegistry, { type Agent, type ContinuationDecision, type PromptDecision, type SessionStartSource } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
|
||||
|
||||
/**
|
||||
@@ -30,13 +25,12 @@ async function harness(adapter: MockAdapter) {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentExecutionProvider)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
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 dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
@@ -47,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]
|
||||
}
|
||||
|
||||
@@ -59,7 +53,7 @@ describe('agent/prompt-submit', () => {
|
||||
it('allow (default via next) records the user/message unchanged', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const seen: string[] = []
|
||||
ctx.on('agent/prompt-submit', async (_agent, content, _source, _signal, next) => {
|
||||
@@ -78,7 +72,7 @@ describe('agent/prompt-submit', () => {
|
||||
it('allow with content REWRITES the prompt before it is recorded', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> =>
|
||||
({ kind: 'allow', content: [{ type: 'text', text: 'REWRITTEN' }] }))
|
||||
@@ -96,7 +90,7 @@ describe('agent/prompt-submit', () => {
|
||||
it('allow with additionalContexts injects separate context/message events into the turn', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const meta = { kind: 'prompt-context', version: 1 }
|
||||
ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> =>
|
||||
@@ -105,7 +99,6 @@ describe('agent/prompt-submit', () => {
|
||||
additionalContexts: [{
|
||||
content: [{ type: 'text', text: '<system-reminder>extra ctx</system-reminder>' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
envelope: 'raw',
|
||||
meta,
|
||||
}],
|
||||
}))
|
||||
@@ -119,19 +112,15 @@ describe('agent/prompt-submit', () => {
|
||||
expect(userMsg).toBeDefined()
|
||||
expect(ctxMsg?.type === 'context/message' && ctxMsg.data.content).toEqual([{ type: 'text', text: '<system-reminder>extra ctx</system-reminder>' }])
|
||||
expect(ctxMsg?.type === 'context/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' })
|
||||
expect(ctxMsg?.type === 'context/message' && ctxMsg.data.envelope).toBe('raw')
|
||||
expect(ctxMsg?.type === 'context/message' && ctxMsg.data.meta).toEqual(meta)
|
||||
// both the prompt and the injected context reach the model
|
||||
const sent = JSON.stringify(adapter.requests[0]!.messages)
|
||||
expect(sent).toContain('extra ctx')
|
||||
})
|
||||
|
||||
it('a prompt-submit rewrite + additionalContexts is VISIBLE to the agent/pre-step seam (merged ordering)', async () => {
|
||||
// Prompt rewrites and injected context land before `agent/pre-step`, so a
|
||||
// compaction listener measures the current surface before the single derive.
|
||||
it('runs pre-step after prompt rewrites and injected context become durable', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> =>
|
||||
({
|
||||
@@ -140,8 +129,6 @@ describe('agent/prompt-submit', () => {
|
||||
additionalContexts: [{ content: [{ type: 'text', text: 'injected ctx' }], source: { kind: 'plugin', plugin: 'test' } }],
|
||||
}))
|
||||
|
||||
// The pre-step seam (where compaction lives) derives the surface it would act
|
||||
// on. Capture what it sees on the first step.
|
||||
let preStepDerived: string | undefined
|
||||
ctx.on('agent/pre-step', (subject, _turn, step) => {
|
||||
if (subject === agent && step === 1) preStepDerived = JSON.stringify(subject.session.deriveMessages())
|
||||
@@ -150,8 +137,6 @@ describe('agent/prompt-submit', () => {
|
||||
send(agent, 'ORIGINAL prompt')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// The pre-step seam ran and saw BOTH the rewrite (not the original) and the
|
||||
// injected context — i.e. the prompt-submit effects landed before it.
|
||||
expect(preStepDerived).toBeDefined()
|
||||
expect(preStepDerived).toContain('REWRITTEN prompt')
|
||||
expect(preStepDerived).toContain('injected ctx')
|
||||
@@ -161,7 +146,7 @@ describe('agent/prompt-submit', () => {
|
||||
it('block drops the (only) prompt → zero-step turn ends rejected, model never called', async () => {
|
||||
const adapter = new MockAdapter([textResponse('should not run')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> =>
|
||||
({ kind: 'block', reason: 'blocked by policy' }))
|
||||
@@ -192,12 +177,10 @@ describe('agent/prompt-submit', () => {
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'rejected', reason: 'blocked by policy' })
|
||||
})
|
||||
|
||||
it('a mixed batch records a prompt/blocked for the vetoed prompt while the allowed one runs', async () => {
|
||||
// Blocking one prompt in a mixed batch must persist its reason even though
|
||||
// the allowed prompt keeps the turn from ending rejected.
|
||||
it('adjacent blocked and allowed prompts keep independent turn outcomes', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ran once')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
ctx.on('agent/prompt-submit', async (_agent, content, _source, _signal, next): Promise<PromptDecision> => {
|
||||
const text = content.map(b => (b.type === 'text' ? b.text : '')).join('')
|
||||
@@ -207,13 +190,13 @@ describe('agent/prompt-submit', () => {
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
||||
|
||||
// both sends land before the loop drains → one batched turn
|
||||
// Both sends land before the driver wakes, but each remains its own turn.
|
||||
send(agent, 'secret')
|
||||
send(agent, 'safe')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const log = events(agent)
|
||||
// the allowed prompt became a user/message and drove exactly one model call
|
||||
// The allowed prompt became a user/message and drove exactly one model call.
|
||||
const userMsgs = log.filter(e => e.type === 'user/message')
|
||||
expect(userMsgs).toHaveLength(1)
|
||||
expect(userMsgs[0]?.type === 'user/message' && userMsgs[0].data.content).toEqual([{ type: 'text', text: 'safe' }])
|
||||
@@ -225,15 +208,17 @@ describe('agent/prompt-submit', () => {
|
||||
content: [{ type: 'text', text: 'secret' }],
|
||||
reason: 'policy: no secrets',
|
||||
})
|
||||
// the turn did NOT reject — a sibling was allowed — so the boundary reason
|
||||
// alone would not have preserved the block
|
||||
expect(reasons.some(r => r.kind === 'rejected')).toBe(false)
|
||||
expect(log.filter(e => e.type === 'turn/start')).toHaveLength(2)
|
||||
expect(reasons).toEqual([
|
||||
{ kind: 'rejected', reason: 'policy: no secrets' },
|
||||
{ kind: 'completed' },
|
||||
])
|
||||
})
|
||||
|
||||
it('a throwing prompt-submit listener ends the turn balanced (error), loop survives', async () => {
|
||||
it('a throwing prompt-submit listener ends its turn balanced while an adjacent message survives', async () => {
|
||||
const adapter = new MockAdapter([textResponse('after')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
let threw = false
|
||||
ctx.on('agent/prompt-submit', async () => {
|
||||
@@ -241,20 +226,31 @@ describe('agent/prompt-submit', () => {
|
||||
return { kind: 'allow' as const }
|
||||
})
|
||||
const errors: Error[] = []
|
||||
const reasons: TurnEndReason[] = []
|
||||
const statuses: string[] = []
|
||||
ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error))
|
||||
ctx.on('agent/status', (subject, status) => { if (subject === agent) statuses.push(status) })
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (session === agent.session && event.type === 'turn/end') reasons.push(event.data.reason)
|
||||
})
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
send(agent, 'first')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(errors.map(e => e.message)).toEqual(['prompt hook broke'])
|
||||
// turn balanced
|
||||
const log = events(agent)
|
||||
expect(log.filter(e => e.type === 'turn/start')).toHaveLength(1)
|
||||
expect(log.filter(e => e.type === 'turn/end')).toHaveLength(1)
|
||||
|
||||
// loop survives: a second prompt runs normally
|
||||
send(agent, 'second')
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests.length).toBeGreaterThanOrEqual(1)
|
||||
await idle
|
||||
expect(errors.map(e => e.message)).toEqual(['prompt hook broke'])
|
||||
// The failed prompt forms one balanced error turn; the adjacent prompt forms
|
||||
// the following normal turn without an intermediate idle transition.
|
||||
const log = events(agent)
|
||||
expect(log.filter(e => e.type === 'turn/start')).toHaveLength(2)
|
||||
expect(log.filter(e => e.type === 'turn/end')).toHaveLength(2)
|
||||
expect(reasons).toEqual([
|
||||
{ kind: 'error', step: 0, message: 'prompt hook broke' },
|
||||
{ kind: 'completed' },
|
||||
])
|
||||
expect(statuses).toEqual(['running', 'idle'])
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('second')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -266,7 +262,7 @@ describe('agent/session-start', () => {
|
||||
const sources: SessionStartSource[] = []
|
||||
ctx.on('agent/session-start', (_agent, source) => void sources.push(source))
|
||||
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
// fires synchronously at create, before any turn
|
||||
expect(sources).toEqual(['startup'])
|
||||
expect(events(agent).some(e => e.type === 'turn/start')).toBe(false)
|
||||
@@ -285,7 +281,7 @@ describe('agent/session-start', () => {
|
||||
agent.inject([{ type: 'text', text: 'session preamble' }], { source: { kind: 'plugin', plugin: 'test' } })
|
||||
})
|
||||
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
@@ -303,8 +299,8 @@ describe('agent/session-start', () => {
|
||||
ctx.on('agent/session-start', () => { throw new Error('session-start hook broke') })
|
||||
|
||||
// create must not throw — the listener error is contained/logged
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
expect(agent.id).toBe(AgentId('a1'))
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
expect(agent.id).toBe(SessionId('a1'))
|
||||
|
||||
// and the agent still runs
|
||||
send(agent, 'go')
|
||||
@@ -317,8 +313,8 @@ describe('agent/session-prefix', () => {
|
||||
it('dispatches to global and matching agent-scope listeners only', async () => {
|
||||
const adapter = new MockAdapter([textResponse('a done'), textResponse('b done')])
|
||||
const ctx = await harness(adapter)
|
||||
const agentA = ctx.agentLoop.create(AgentId('prefix-a'), { provider: 'mock', model: 'mock' })
|
||||
const agentB = ctx.agentLoop.create(AgentId('prefix-b'), { provider: 'mock', model: 'mock' })
|
||||
const agentA = ctx.agentLoop.create(SessionId('prefix-a'), { provider: 'mock', model: 'mock' })
|
||||
const agentB = ctx.agentLoop.create(SessionId('prefix-b'), { provider: 'mock', model: 'mock' })
|
||||
const seen: string[] = []
|
||||
ctx.on('agent/session-prefix', async (agent, _prefix, _signal, next) => {
|
||||
seen.push(`global:${agent.id}`)
|
||||
@@ -355,7 +351,7 @@ describe('agent/session-prefix', () => {
|
||||
name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
|
||||
async execute(args) { return [{ type: 'text', text: String(args.text) }] },
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const reminder: Message = { role: 'user', content: [{ type: 'text', text: '<system-reminder>catalog</system-reminder>' }] }
|
||||
let composed = 0
|
||||
@@ -385,10 +381,10 @@ describe('agent/session-prefix', () => {
|
||||
expect(agent.session.deriveMessages()[0]).toEqual({ role: 'user', content: [{ type: 'text', text: 'go' }] })
|
||||
})
|
||||
|
||||
it('composes before the first pre-step and hands the prefix to the seam (pressure gates see the real value)', async () => {
|
||||
it('composes before the first pre-step and records the prefix on the request header', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const reminder: Message = { role: 'user', content: [{ type: 'text', text: 'opener' }] }
|
||||
const order: string[] = []
|
||||
@@ -396,26 +392,21 @@ describe('agent/session-prefix', () => {
|
||||
order.push('compose')
|
||||
return [reminder, ...await next()]
|
||||
})
|
||||
const seen: (readonly Message[])[] = []
|
||||
ctx.on('agent/pre-step', (_agent, _turn, _step, _system, sessionPrefix) => {
|
||||
ctx.on('agent/pre-step', () => {
|
||||
order.push('pre-step')
|
||||
seen.push(sessionPrefix)
|
||||
})
|
||||
|
||||
send(agent, 'hi')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// Composition precedes the pre-step seam, and the seam receives THIS
|
||||
// instance's composed prefix — a token-pressure gate (compaction) counts
|
||||
// what the request will actually carry, never a stale logged prefix.
|
||||
expect(order).toEqual(['compose', 'pre-step'])
|
||||
expect(seen[0]).toEqual([reminder])
|
||||
expect(agent.session.requestHeader()?.messagePrefix).toEqual([reminder])
|
||||
})
|
||||
|
||||
it('the canonical prepend pattern composes contributions in registration order', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
// Both listeners use the canonical `[mine, ...await next()]` prepend: the
|
||||
// waterfall unwinds innermost-first (the second listener's array is built
|
||||
@@ -437,7 +428,7 @@ describe('agent/session-prefix', () => {
|
||||
it('with no contributions the header omits messagePrefix and the request is the bare derivation', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
// A listener that delegates without contributing — the canonical no-op.
|
||||
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next) => next())
|
||||
@@ -453,7 +444,7 @@ describe('agent/session-prefix', () => {
|
||||
it('the frozen seed rejects in-place mutation — a contribution is a returned extension', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
let mutationError: unknown
|
||||
ctx.on('agent/session-prefix', async (_agent, prefix, _signal, next): Promise<Message[]> => {
|
||||
@@ -482,7 +473,7 @@ describe('agent/session-prefix', () => {
|
||||
name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
|
||||
async execute(args) { return [{ type: 'text', text: String(args.text) }] },
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const held: Message = { role: 'user', content: [{ type: 'text', text: 'v1' }] }
|
||||
ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise<Message[]> => [...await next(), held])
|
||||
@@ -503,7 +494,7 @@ describe('agent/turn-continuation (ContinuationDecision)', () => {
|
||||
it('a continue decision with a reason records next-step steering in the same turn', async () => {
|
||||
const adapter = new MockAdapter([textResponse('step 1 no tools'), textResponse('step 2')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
let forced = false
|
||||
ctx.on('agent/turn-continuation', async (_agent, _turn, _default, _signal, next): Promise<ContinuationDecision> => {
|
||||
@@ -535,7 +526,7 @@ describe('agent/turn-continuation (ContinuationDecision)', () => {
|
||||
name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
|
||||
async execute(args) { return [{ type: 'text', text: String(args.text) }] },
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
ctx.on('agent/turn-continuation', async (): Promise<ContinuationDecision> => ({ action: 'stop' }))
|
||||
|
||||
@@ -565,7 +556,7 @@ describe('tool additionalContexts buffering across a step', () => {
|
||||
name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
|
||||
async execute(args) { return [{ type: 'text', text: String(args.text) }] },
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
// Each call attaches one context naming itself.
|
||||
ctx.on('tools/post-execute', async (exec, _result): Promise<PostToolDecision> =>
|
||||
@@ -574,7 +565,6 @@ describe('tool additionalContexts buffering across a step', () => {
|
||||
additionalContexts: [{
|
||||
content: [{ type: 'text', text: `ctx-${exec.callId}` }],
|
||||
source: { kind: 'plugin', plugin: 'p' },
|
||||
envelope: 'raw',
|
||||
meta: { callId: exec.callId },
|
||||
}],
|
||||
}))
|
||||
@@ -598,7 +588,6 @@ describe('tool additionalContexts buffering across a step', () => {
|
||||
.map(b => (b.type === 'text' ? b.text : ''))
|
||||
expect(ctxTexts).toEqual(['ctx-c1', 'ctx-c2'])
|
||||
const contextEvents = events(agent).filter(e => e.type === 'context/message')
|
||||
expect(contextEvents.map(e => e.type === 'context/message' && e.data.envelope)).toEqual(['raw', 'raw'])
|
||||
expect(contextEvents.map(e => e.type === 'context/message' && e.data.meta)).toEqual([{ callId: 'c1' }, { callId: 'c2' }])
|
||||
})
|
||||
|
||||
@@ -609,11 +598,11 @@ describe('tool additionalContexts buffering across a step', () => {
|
||||
name: 'composite', description: 'composite', parameters: {},
|
||||
async execute(_args, exec) {
|
||||
exec.deferContext({ content: [{ type: 'text', text: 'nested-a' }], source: { kind: 'plugin', plugin: 'a' }, meta: { order: 1 } })
|
||||
exec.deferContext({ content: [{ type: 'text', text: 'nested-b' }], source: { kind: 'plugin', plugin: 'b' }, envelope: 'raw', meta: { order: 2 } })
|
||||
exec.deferContext({ content: [{ type: 'text', text: 'nested-b' }], source: { kind: 'plugin', plugin: 'b' }, meta: { order: 2 } })
|
||||
return [{ type: 'text', text: 'outer result' }]
|
||||
},
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -640,7 +629,7 @@ describe('tools/pre-execute gate (native-plugin permission pattern, end-to-end t
|
||||
name: 'danger', description: 'danger', parameters: {},
|
||||
async execute() { ran = true; return [{ type: 'text', text: 'should not run' }] },
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
|
||||
if (exec.name === 'danger') return { kind: 'deny', reason: 'blocked dangerous tool' }
|
||||
@@ -702,7 +691,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se
|
||||
name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
|
||||
async execute(args) { return [{ type: 'text', text: String(args.text) }] },
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
send(agent, 'please echo hi')
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -725,7 +714,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se
|
||||
const adapter = new MockAdapter([textResponse('should not run')])
|
||||
const ctx = await harness(adapter)
|
||||
await ctx.plugin(NativeGuard)
|
||||
const agent = ctx.agentLoop.create(AgentId('a2'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a2'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
||||
@@ -744,7 +733,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se
|
||||
await fiber.dispose()
|
||||
|
||||
// After disposal, a destructive prompt is NOT blocked (the listener is gone).
|
||||
const agent = ctx.agentLoop.create(AgentId('a3'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a3'), { provider: 'mock', model: 'mock' })
|
||||
send(agent, 'run rm -rf /')
|
||||
await waitForIdle(ctx, agent)
|
||||
// the prompt ran (not rejected) — proving the prompt-submit listener was disposed
|
||||
|
||||
@@ -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, { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution'
|
||||
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, 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)
|
||||
@@ -16,7 +20,6 @@ async function harness(adapter: MockAdapter, persona = '') {
|
||||
await ctx.plugin(SystemPrompt, { persona })
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentExecutionProvider)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return ctx
|
||||
@@ -27,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') {
|
||||
@@ -38,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 }])
|
||||
}
|
||||
|
||||
@@ -46,7 +49,7 @@ describe('agent loop', () => {
|
||||
it('runs a simple turn: queued message → model → idle, with ordered events', async () => {
|
||||
const adapter = new MockAdapter([textResponse('hello there')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
// All boundaries — turn and step — are durable session events on the
|
||||
// session/event feed (no agent/* mirror). Record them in fire order to
|
||||
@@ -94,7 +97,7 @@ describe('agent loop', () => {
|
||||
return [{ type: 'text', text: `echo: ${args.text}` }]
|
||||
},
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
send(agent, 'use the tool')
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -133,7 +136,7 @@ describe('agent loop', () => {
|
||||
return { content: [{ type: 'text', text: 'ok' }], meta: { diffs: [{ path: 'a.txt', oldText: null, newText: 'x' }] } }
|
||||
},
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
send(agent, 'use the tool')
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -157,7 +160,7 @@ describe('agent loop', () => {
|
||||
return []
|
||||
},
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
send(agent, 'hi')
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -171,13 +174,12 @@ describe('agent loop', () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter, 'Working in {{cwd}}.')
|
||||
const handle = await ctx.agents.create({
|
||||
agentId: AgentId('a-cwd'),
|
||||
sessionId: SessionId('s-cwd'),
|
||||
meta: { cwd: '/work/space' },
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
|
||||
const agent = handle.agent as ReactLoopAgent
|
||||
const agent = handle.agent
|
||||
send(agent, 'hi')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
@@ -190,7 +192,7 @@ describe('agent loop', () => {
|
||||
const ctx = await harness(adapter, 'In {{cwd}}.')
|
||||
const errors: Error[] = []
|
||||
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
send(agent, 'hi')
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -232,7 +234,7 @@ describe('agent loop', () => {
|
||||
ctx.on('agent/request', async (_agent, _turn, _step, config, _signal, _next) => {
|
||||
return { ...config, provider: 'mock', model: 'mock' }
|
||||
})
|
||||
const agent = ctx.agentLoop.create(AgentId('a-late-model'), {})
|
||||
const agent = ctx.agentLoop.create(SessionId('a-late-model'), {})
|
||||
|
||||
send(agent, 'hi')
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -258,7 +260,7 @@ describe('agent loop', () => {
|
||||
parameters: {},
|
||||
execute: () => Promise.resolve({ content: [{ type: 'text' as const, text: 'apparent success' }], meta }),
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('bad-meta-agent'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('bad-meta-agent'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
send(agent, 'use the tool')
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -287,7 +289,7 @@ describe('agent loop', () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.on('system-prompt/assemble', async () => ({ sections: [], tools: [], variables: {} }))
|
||||
const agent = ctx.agentLoop.create(AgentId('a-no-system'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a-no-system'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
send(agent, 'hi')
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -299,7 +301,7 @@ describe('agent loop', () => {
|
||||
it('records raw chunks for replay as assistant/chunk session events', async () => {
|
||||
const adapter = new MockAdapter([textResponse('abc')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
send(agent, 'hi')
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -323,7 +325,7 @@ describe('agent loop', () => {
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'slow',
|
||||
description: '',
|
||||
@@ -352,20 +354,30 @@ describe('agent loop', () => {
|
||||
expect(flat).toContain('change of plans')
|
||||
})
|
||||
|
||||
it('steering while idle behaves like send (starts a turn)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
it('same-tick idle steering inherits one-send-one-turn FIFO behavior', async () => {
|
||||
const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.steer([{ type: 'text', text: 'hello' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(agent.session.events.some(e => e.type === 'user/message')).toBe(true)
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
agent.steer([{ type: 'text', text: 'first idle steer' }])
|
||||
agent.steer([{ type: 'text', text: 'second idle steer' }])
|
||||
await idle
|
||||
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
|
||||
expect(agent.session.events
|
||||
.filter(event => event.type === 'user/message')
|
||||
.map(event => event.data.content)).toEqual([
|
||||
[{ type: 'text', text: 'first idle steer' }],
|
||||
[{ type: 'text', text: 'second idle steer' }],
|
||||
])
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('inject() while idle wraps context in a one-shot turn, visible to the next request', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.inject([{ type: 'text', text: 'file changed: a.ts' }], { source: { kind: 'plugin', plugin: 'watcher' } })
|
||||
// The idle inject records a self-contained turn (turn/start → context/message
|
||||
@@ -383,13 +395,13 @@ describe('agent loop', () => {
|
||||
await waitForIdle(ctx, agent)
|
||||
const flat = JSON.stringify(adapter.requests[0]!.messages)
|
||||
expect(flat).toContain('file changed: a.ts')
|
||||
expect(flat).toContain('<context source=\\"plugin\\">')
|
||||
expect(flat).not.toContain('<context source=')
|
||||
})
|
||||
|
||||
it('inject() can persist raw structured context without the generic context envelope', async () => {
|
||||
it('inject() persists structured context content verbatim with durable hidden meta', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('raw-context'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('raw-context'), { provider: 'mock', model: 'mock' })
|
||||
const text = '<system-reminder>Additional instructions from: pkg/AGENTS.md</system-reminder>'
|
||||
const meta = {
|
||||
kind: 'workspace-instructions',
|
||||
@@ -399,14 +411,13 @@ describe('agent loop', () => {
|
||||
|
||||
agent.inject([{ type: 'text', text }], {
|
||||
source: { kind: 'plugin', plugin: 'workspace-context' },
|
||||
envelope: 'raw',
|
||||
meta,
|
||||
})
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const contextEvent = agent.session.events.find(event => event.type === 'context/message')
|
||||
expect(contextEvent?.type === 'context/message' && contextEvent.data).toMatchObject({ envelope: 'raw', meta })
|
||||
expect(contextEvent?.type === 'context/message' && contextEvent.data).toMatchObject({ meta })
|
||||
const requestText = JSON.stringify(adapter.requests[0]!.messages)
|
||||
expect(requestText).toContain('Additional instructions from: pkg/AGENTS.md')
|
||||
expect(requestText).not.toContain('<context source=')
|
||||
@@ -418,7 +429,7 @@ describe('agent loop', () => {
|
||||
textResponse('done'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
let visibleDuringTool = false
|
||||
const meta = { kind: 'deferred-test', version: 1 }
|
||||
ctx.tools.register(defineTool({
|
||||
@@ -430,7 +441,6 @@ describe('agent loop', () => {
|
||||
const first = { type: 'text' as const, text: 'mid-turn notice' }
|
||||
agent.inject([first], {
|
||||
source: { kind: 'plugin', plugin: 'x' },
|
||||
envelope: 'raw',
|
||||
meta,
|
||||
})
|
||||
first.text = 'mutated after inject'
|
||||
@@ -456,7 +466,6 @@ describe('agent loop', () => {
|
||||
expect(contexts).toHaveLength(2)
|
||||
expect(result.seq).toBeLessThan(contexts[0]!.seq)
|
||||
expect(contexts[0]?.type === 'context/message' && contexts[0].data).toMatchObject({
|
||||
envelope: 'raw',
|
||||
meta,
|
||||
})
|
||||
expect(contexts.flatMap(event => event.type === 'context/message' ? event.data.content : []))
|
||||
@@ -484,7 +493,7 @@ describe('agent loop', () => {
|
||||
textResponse('done'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('invalid-context'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('invalid-context'), { provider: 'mock', model: 'mock' })
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'invalid-injector',
|
||||
description: 'attempts an invalid context injection',
|
||||
@@ -514,7 +523,7 @@ describe('agent loop', () => {
|
||||
textResponse('step 3'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
let steps = 0
|
||||
ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ })
|
||||
@@ -540,7 +549,7 @@ describe('agent loop', () => {
|
||||
return [{ type: 'text', text: String(args.text) }]
|
||||
},
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
ctx.on('agent/turn-continuation', async () => ({ action: 'stop' }) as const)
|
||||
|
||||
@@ -555,7 +564,7 @@ describe('agent loop', () => {
|
||||
it('agent/request waterfall switches models by returning a replacement config; the switch is logged', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
ctx.on('agent/request', async (_agent, _turn, _step, config, _signal, _next) => {
|
||||
// The seed is frozen — config is not a mutable per-call knob; a switch
|
||||
@@ -575,10 +584,6 @@ describe('agent loop', () => {
|
||||
})
|
||||
|
||||
it('agent/pre-step fires once per step before the step is opened', async () => {
|
||||
// Two steps (a tool call, then a final text turn) → two model calls → two
|
||||
// pre-step fires, each carrying the assembled full system prompt, BEFORE
|
||||
// the step is opened and its request is derived (the request the adapter
|
||||
// sees reflects any surface state at fire time).
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('c1', 'echo', {}, 'calling echo'),
|
||||
textResponse('done'),
|
||||
@@ -588,23 +593,21 @@ describe('agent loop', () => {
|
||||
name: 'echo', description: 'echo', parameters: {},
|
||||
async execute() { return [{ type: 'text', text: 'echoed' }] },
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const fires: { turn: number; step: number; fullSystemPrompt: string }[] = []
|
||||
ctx.on('agent/pre-step', (subject, turn, step, fullSystemPrompt) => {
|
||||
if (subject === agent) fires.push({ turn, step, fullSystemPrompt })
|
||||
const fires: { turn: number; step: number; signal: AbortSignal }[] = []
|
||||
ctx.on('agent/pre-step', (subject, turn, step, signal) => {
|
||||
if (subject === agent) fires.push({ turn, step, signal })
|
||||
})
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// One fire per step, in order, each with the assembled system prompt
|
||||
// (here just the loop's own harness-identity section — no persona set).
|
||||
const HARNESS = 'You are an AI agent powered by the DeepSeek Harness SDK.'
|
||||
expect(fires).toEqual([
|
||||
{ turn: 1, step: 1, fullSystemPrompt: HARNESS },
|
||||
{ turn: 1, step: 2, fullSystemPrompt: HARNESS },
|
||||
expect(fires.map(({ turn, step }) => ({ turn, step }))).toEqual([
|
||||
{ turn: 1, step: 1 },
|
||||
{ turn: 1, step: 2 },
|
||||
])
|
||||
expect(fires.every(({ signal }) => signal instanceof AbortSignal)).toBe(true)
|
||||
})
|
||||
|
||||
it('agent/pre-step fires BEFORE the step it precedes opens (events land outside the step)', async () => {
|
||||
@@ -612,7 +615,7 @@ describe('agent loop', () => {
|
||||
// same step's request must include it.
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
let injected = false
|
||||
ctx.on('agent/pre-step', (subject) => {
|
||||
@@ -646,7 +649,7 @@ describe('agent loop', () => {
|
||||
// closing, the turn records error, and the loop remains available.
|
||||
const adapter = new MockAdapter([textResponse('second turn ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
let throwOnce = true
|
||||
ctx.on('agent/pre-step', () => {
|
||||
@@ -680,7 +683,7 @@ describe('agent loop', () => {
|
||||
it('cancel() mid-stream ends the turn with reason aborted', async () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
||||
@@ -700,7 +703,7 @@ describe('agent loop', () => {
|
||||
// turn stops by default and ends max-tokens, not completed.
|
||||
const adapter = new MockAdapter([maxTokensResponse('truncat')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
||||
@@ -723,7 +726,7 @@ describe('agent loop', () => {
|
||||
textResponse('second half'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
let steps = 0
|
||||
ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ })
|
||||
@@ -754,7 +757,7 @@ describe('agent loop', () => {
|
||||
// stop. The per-turn reason must be independent — turn 2 ends completed.
|
||||
const adapter = new MockAdapter([maxTokensResponse('cut'), textResponse('clean')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
||||
@@ -787,7 +790,7 @@ describe('agent loop', () => {
|
||||
return [{ type: 'text', text: 'should not run' }]
|
||||
},
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
||||
@@ -824,7 +827,7 @@ describe('agent loop', () => {
|
||||
parameters: { text: { type: 'string' } },
|
||||
async execute() { return [{ type: 'text', text: 'should not run' }] },
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
||||
@@ -849,7 +852,7 @@ describe('agent loop', () => {
|
||||
// a durable successful-call boundary for replay consumers.
|
||||
const adapter = new MockAdapter([[{ type: 'finish', reason: { kind: 'stop' } }]])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const reasons: TurnEndReason[] = []
|
||||
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
|
||||
@@ -886,7 +889,7 @@ describe('agent loop', () => {
|
||||
expect(message.content).toEqual([{ type: 'text', text: 'partial text' }])
|
||||
return next()
|
||||
})
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -913,7 +916,7 @@ describe('agent loop', () => {
|
||||
return [{ type: 'text', text: String(args.text) }]
|
||||
},
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
let threw = false
|
||||
// Post-commit session observers cannot control the loop. The tool call still
|
||||
// drives the second model request, and the turn completes normally.
|
||||
@@ -929,10 +932,152 @@ describe('agent loop', () => {
|
||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('completed')
|
||||
})
|
||||
|
||||
it('chains queued messages into consecutive turns', async () => {
|
||||
it('keeps same-tick sends in separate turns and checkpoints before the next starts', async () => {
|
||||
const adapter = new MockAdapter([textResponse('first answer'), textResponse('second answer')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const firstFlush = Promise.withResolvers<undefined>()
|
||||
const releaseFirstFlush = Promise.withResolvers<undefined>()
|
||||
let flushes = 0
|
||||
ctx.on('session/flush', async (session) => {
|
||||
if (session !== agent.session) return
|
||||
flushes += 1
|
||||
if (flushes === 1) {
|
||||
firstFlush.resolve(undefined)
|
||||
await releaseFirstFlush.promise
|
||||
}
|
||||
})
|
||||
|
||||
const turns: number[] = []
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (session === agent.session && event.type === 'turn/start') turns.push(event.data.turn)
|
||||
})
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
send(agent, 'first message')
|
||||
send(agent, 'second message')
|
||||
|
||||
await firstFlush.promise
|
||||
expect(turns).toEqual([1])
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
|
||||
releaseFirstFlush.resolve(undefined)
|
||||
await idle
|
||||
|
||||
expect(turns).toEqual([1, 2])
|
||||
expect(flushes).toBe(2)
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('first answer')
|
||||
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('second message')
|
||||
})
|
||||
|
||||
it('holds a turn-end listener send behind the closing turn checkpoint', async () => {
|
||||
const adapter = new MockAdapter([textResponse('first answer'), textResponse('second answer')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const firstFlush = Promise.withResolvers<undefined>()
|
||||
const releaseFirstFlush = Promise.withResolvers<undefined>()
|
||||
let flushes = 0
|
||||
ctx.on('session/flush', async (session) => {
|
||||
if (session !== agent.session) return
|
||||
flushes += 1
|
||||
if (flushes === 1) {
|
||||
firstFlush.resolve(undefined)
|
||||
await releaseFirstFlush.promise
|
||||
}
|
||||
})
|
||||
|
||||
const turns: number[] = []
|
||||
const statuses: string[] = []
|
||||
ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent) statuses.push(status)
|
||||
})
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (session !== agent.session) return
|
||||
if (event.type === 'turn/start') turns.push(event.data.turn)
|
||||
if (event.type === 'turn/end' && event.data.turn === 1) send(agent, 'turn-end listener message')
|
||||
})
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
send(agent, 'first message')
|
||||
await firstFlush.promise
|
||||
|
||||
expect(turns).toEqual([1])
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
|
||||
releaseFirstFlush.resolve(undefined)
|
||||
await idle
|
||||
|
||||
expect(turns).toEqual([1, 2])
|
||||
expect(statuses).toEqual(['running', 'idle'])
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('first answer')
|
||||
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('turn-end listener message')
|
||||
})
|
||||
|
||||
it('keeps a reentrant agent/queued send as the next independent turn', async () => {
|
||||
const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
let nested = false
|
||||
ctx.on('agent/queued', (subject) => {
|
||||
if (subject !== agent || nested) return
|
||||
nested = true
|
||||
send(agent, 'queued listener message')
|
||||
})
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
send(agent, 'outer message')
|
||||
await idle
|
||||
|
||||
const turns = agent.session.events.filter(event => event.type === 'turn/start')
|
||||
const messages = agent.session.events
|
||||
.filter(event => event.type === 'user/message')
|
||||
.map(event => event.data.content)
|
||||
expect(turns).toHaveLength(2)
|
||||
expect(messages).toEqual([
|
||||
[{ type: 'text', text: 'outer message' }],
|
||||
[{ type: 'text', text: 'queued listener message' }],
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves independent turn sources across an adjacent microtask send', async () => {
|
||||
const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
agent.send([{ type: 'text', text: 'user message' }])
|
||||
await Promise.resolve()
|
||||
agent.send(
|
||||
[{ type: 'text', text: 'plugin message' }],
|
||||
{ source: { kind: 'plugin', plugin: 'test' } },
|
||||
)
|
||||
await idle
|
||||
|
||||
const triggers = agent.session.events
|
||||
.filter(event => event.type === 'turn/start')
|
||||
.map(event => event.data.trigger)
|
||||
const sources = agent.session.events
|
||||
.filter(event => event.type === 'user/message')
|
||||
.map(event => event.data.source)
|
||||
expect(triggers).toEqual([
|
||||
{ kind: 'message', source: { kind: 'user' } },
|
||||
{ kind: 'message', source: { kind: 'plugin', plugin: 'test' } },
|
||||
])
|
||||
expect(sources).toEqual([
|
||||
{ kind: 'user' },
|
||||
{ kind: 'plugin', plugin: 'test' },
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps a session-listener send after dequeue in the following turn', async () => {
|
||||
const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const turns: number[] = []
|
||||
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/start') turns.push(event.data.turn) })
|
||||
@@ -952,12 +1097,43 @@ describe('agent loop', () => {
|
||||
|
||||
expect(turns).toEqual([1, 2])
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('first')
|
||||
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('second message')
|
||||
})
|
||||
|
||||
it('keeps a model-adapter callback send in the following turn', async () => {
|
||||
const agentRef: { current?: Agent } = {}
|
||||
const adapter = new MockAdapter([
|
||||
() => {
|
||||
const agent = agentRef.current
|
||||
if (agent === undefined) throw new Error('model callback ran before agent setup')
|
||||
send(agent, 'model callback message')
|
||||
return textResponse('first')
|
||||
},
|
||||
textResponse('second'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agentRef.current = agent
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
send(agent, 'outer message')
|
||||
await idle
|
||||
|
||||
const messages = agent.session.events
|
||||
.filter(event => event.type === 'user/message')
|
||||
.map(event => event.data.content)
|
||||
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2)
|
||||
expect(messages).toEqual([
|
||||
[{ type: 'text', text: 'outer message' }],
|
||||
[{ type: 'text', text: 'model callback message' }],
|
||||
])
|
||||
})
|
||||
|
||||
it('awaits session/flush at turn end (persistence checkpoint)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
let flushed = 0
|
||||
let flushedBeforeIdle = false
|
||||
@@ -977,7 +1153,7 @@ describe('agent loop', () => {
|
||||
it('errors from the model surface as agent/error and end the turn', async () => {
|
||||
const adapter = new MockAdapter([]) // script exhausted → throws
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const errors: Error[] = []
|
||||
const reasons: TurnEndReason[] = []
|
||||
@@ -1000,21 +1176,21 @@ 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(AgentId('scoped'), { provider: 'mock', model: 'mock' })
|
||||
agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
|
||||
expect(ctx.agents.get(AgentId('scoped'))).toBe(agent)
|
||||
expect(ctx.agents.get(SessionId('scoped'))).toBe(agent)
|
||||
send(agent, 'go')
|
||||
await new Promise(r => setTimeout(r, 30))
|
||||
expect(agent.status).toBe('running')
|
||||
|
||||
await fiber.dispose()
|
||||
await agent.done
|
||||
await driverDone(agent)
|
||||
|
||||
expect(agent.status).toBe('disposed')
|
||||
expect(ctx.agents.get(AgentId('scoped'))).toBeUndefined()
|
||||
expect(ctx.agents.get(SessionId('scoped'))).toBeUndefined()
|
||||
expect(() => { send(agent, 'too late') }).toThrow('disposed')
|
||||
})
|
||||
|
||||
@@ -1026,15 +1202,15 @@ describe('agent loop', () => {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentExecutionProvider)
|
||||
await ctx.plugin(AgentLoop, {
|
||||
agents: [{ id: AgentId('config-agent'), provider: 'mock', model: 'mock' }],
|
||||
agents: [{ id: SessionId('config-agent'), provider: 'mock', model: 'mock' }],
|
||||
})
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
|
||||
const agent = ctx.agents.get(AgentId('config-agent'))! as ReactLoopAgent
|
||||
const agent = ctx.agents.list()[0]!
|
||||
expect(agent).toBeDefined()
|
||||
expect(agent.id).toBe('config-agent')
|
||||
expect(agent.id).toBe(agent.session.id)
|
||||
expect(agent.id).toMatch(/^config-agent-session-/)
|
||||
expect(agent.options.model).toBe('mock')
|
||||
|
||||
// the agent is alive: send triggers a turn
|
||||
@@ -1050,12 +1226,11 @@ describe('agent loop', () => {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentExecutionProvider)
|
||||
await ctx.plugin(AgentLoop, {
|
||||
agents: [{ id: AgentId('config-agent'), provider: 'mock', model: 'mock', cwd: '/work/project' }],
|
||||
agents: [{ id: SessionId('config-agent'), provider: 'mock', model: 'mock', cwd: '/work/project' }],
|
||||
})
|
||||
|
||||
const agent = ctx.agents.get(AgentId('config-agent'))! as ReactLoopAgent
|
||||
const agent = ctx.agents.list()[0]!
|
||||
expect(agent.session.header.cwd).toBe('/work/project')
|
||||
})
|
||||
|
||||
@@ -1073,7 +1248,7 @@ describe('agent loop', () => {
|
||||
return [{ type: 'text', text: String(args.text) }]
|
||||
},
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
send(agent, 'run')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
/**
|
||||
* Deterministic property tests for inbox scheduling: every sent message logs
|
||||
* once, turn numbers increase, and status follows idle→running→idle/disposed.
|
||||
* Schedules advance on status events rather than wall-clock sleeps.
|
||||
* Property-based tests for the agent loop's inbox/turn scheduling (the
|
||||
* property-testing Agent Note). Deterministic by construction: schedules are driven
|
||||
* through the `agent/status` settle signal (no wall-clock sleeps), so a flake
|
||||
* is a finding, not timing noise.
|
||||
*
|
||||
* Invariants: every sent message appears exactly once in the log (none lost);
|
||||
* turn numbers strictly increase; status transitions follow the legal machine
|
||||
* idle→running→idle (and →disposed at teardown).
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
@@ -9,12 +14,12 @@ import { Context } from 'cordis'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
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, { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution'
|
||||
import AgentLoop, { type 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 fc from 'fast-check'
|
||||
|
||||
/** A never-exhausting adapter: every model call returns the same short reply. */
|
||||
@@ -37,14 +42,13 @@ async function harness() {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentExecutionProvider)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
ctx.llm.registerAdapter(['mock'], new EchoAdapter())
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** 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') {
|
||||
@@ -57,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)
|
||||
@@ -65,18 +69,33 @@ 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)
|
||||
}
|
||||
|
||||
function turnEndNumbers(agent: Agent): number[] {
|
||||
return agent.session.events
|
||||
.filter(e => e.type === 'turn/end')
|
||||
.map(e => (e.data as { turn: number }).turn)
|
||||
}
|
||||
|
||||
function userMessageCountsByTurn(agent: Agent): number[] {
|
||||
const counts: number[] = []
|
||||
for (const event of agent.session.events) {
|
||||
if (event.type === 'turn/start') counts.push(0)
|
||||
if (event.type === 'user/message') counts[counts.length - 1]! += 1
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
/** Assert a status trace is a legal run: idle/running alternating, ending idle. */
|
||||
function assertLegalStatusTrace(trace: string[]): void {
|
||||
for (let i = 1; i < trace.length; i++) {
|
||||
@@ -86,13 +105,13 @@ function assertLegalStatusTrace(trace: string[]): void {
|
||||
}
|
||||
|
||||
describe('agent loop scheduling properties', () => {
|
||||
it('a synchronous burst loses no message and uses strictly increasing turns', async () => {
|
||||
it('a synchronous burst gives every message its own strictly increasing turn', async () => {
|
||||
await fc.assert(fc.asyncProperty(
|
||||
fc.array(fc.string({ minLength: 1 }), { minLength: 1, maxLength: 6 }),
|
||||
async (texts) => {
|
||||
const ctx = await harness()
|
||||
try {
|
||||
const agent = ctx.agentLoop.create(AgentId('a'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a'), { provider: 'mock', model: 'mock' })
|
||||
const { seen: trace } = recordStatus(ctx, agent)
|
||||
const idle = nextIdle(ctx, agent)
|
||||
// Send all in one synchronous tick: they queue before the loop wakes.
|
||||
@@ -101,8 +120,11 @@ describe('agent loop scheduling properties', () => {
|
||||
|
||||
// No message lost: every send appears as a user/message, in order.
|
||||
expect(userMessageTexts(agent)).toEqual(texts)
|
||||
// A synchronous burst batches into exactly one turn.
|
||||
expect(turnNumbers(agent)).toEqual([1])
|
||||
// This failure-free fixture maps every item to an independent turn.
|
||||
expect(turnNumbers(agent)).toEqual(texts.map((_, i) => i + 1))
|
||||
expect(turnEndNumbers(agent)).toEqual(texts.map((_, i) => i + 1))
|
||||
expect(userMessageCountsByTurn(agent)).toEqual(texts.map(() => 1))
|
||||
expect(trace).toEqual(['running', 'idle'])
|
||||
assertLegalStatusTrace(trace)
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
@@ -117,7 +139,7 @@ describe('agent loop scheduling properties', () => {
|
||||
async (texts) => {
|
||||
const ctx = await harness()
|
||||
try {
|
||||
const agent = ctx.agentLoop.create(AgentId('a'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a'), { provider: 'mock', model: 'mock' })
|
||||
for (const text of texts) {
|
||||
const idle = nextIdle(ctx, agent)
|
||||
agent.send([{ type: 'text', text }])
|
||||
@@ -133,16 +155,16 @@ describe('agent loop scheduling properties', () => {
|
||||
), { numRuns: 20, timeout: 2000 })
|
||||
})
|
||||
|
||||
it('mixed schedule (send, optionally settle) loses no message and orders turns', async () => {
|
||||
// Each step is a (text, settle?) pair: settle=true awaits idle before the
|
||||
// next send (own turn); settle=false sends in the same tick (batches).
|
||||
it('mixed settled and same-tick sends preserve one turn per message', async () => {
|
||||
// Each step optionally waits for idle before the next send; that scheduling
|
||||
// choice must not change the ordinary message-to-turn mapping.
|
||||
const stepArb = fc.record({ text: fc.string({ minLength: 1 }), settle: fc.boolean() })
|
||||
await fc.assert(fc.asyncProperty(
|
||||
fc.array(stepArb, { minLength: 1, maxLength: 6 }),
|
||||
async (steps) => {
|
||||
const ctx = await harness()
|
||||
try {
|
||||
const agent = ctx.agentLoop.create(AgentId('a'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a'), { provider: 'mock', model: 'mock' })
|
||||
// Capture before each send; the last waiter covers the final turn, and
|
||||
// awaiting an already-settled earlier waiter is harmless.
|
||||
let lastIdle: Promise<void> | undefined
|
||||
@@ -154,14 +176,13 @@ describe('agent loop scheduling properties', () => {
|
||||
}
|
||||
await lastIdle
|
||||
|
||||
// No message lost or reordered, regardless of batching.
|
||||
// No message is lost or reordered, regardless of driver timing.
|
||||
expect(userMessageTexts(agent)).toEqual(steps.map(s => s.text))
|
||||
// Turn numbers are a strictly increasing 1..N prefix (N = turn count).
|
||||
// Every item forms one FIFO-ordered turn containing only that message.
|
||||
const turns = turnNumbers(agent)
|
||||
expect(turns).toEqual(turns.map((_, i) => i + 1))
|
||||
// Every message landed in some turn; turns never exceed messages.
|
||||
expect(turns.length).toBeLessThanOrEqual(steps.length)
|
||||
expect(turns.length).toBeGreaterThanOrEqual(1)
|
||||
expect(turns).toEqual(steps.map((_, i) => i + 1))
|
||||
expect(turnEndNumbers(agent)).toEqual(turns)
|
||||
expect(userMessageCountsByTurn(agent)).toEqual(steps.map(() => 1))
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { AgentId, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
||||
|
||||
@@ -14,7 +14,7 @@ import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
||||
* multi-step tool turn (plus a follow-up turn) against the live DeepSeek API must report
|
||||
* `cacheReadTokens > 0` on every request after the first — the adapter maps the provider's
|
||||
* `prompt_cache_hit_tokens`, and the per-step usage recorded on `assistant/message` events is
|
||||
* the production observable for cache behavior (the reconstructability RFC's measurement
|
||||
* the production observable for cache behavior (the reconstructability Agent Note's measurement
|
||||
* layer: prefix stability is corollary #1). Mocks establish append-extension;
|
||||
* this key-gated test establishes a real provider cache hit.
|
||||
*/
|
||||
@@ -43,7 +43,6 @@ async function loopHarness(): Promise<Context> {
|
||||
await created.plugin(SystemPrompt, { persona: SYSTEM })
|
||||
await created.plugin(ToolRegistry)
|
||||
await created.plugin(AgentRegistry)
|
||||
await created.plugin(AgentExecutionProvider)
|
||||
await created.plugin(AgentLoop, { agents: [] })
|
||||
await created.plugin(LlmDeepSeek)
|
||||
created.tools.register(defineTool({
|
||||
@@ -71,7 +70,7 @@ function waitForIdle(context: Context, agent: Agent): Promise<void> {
|
||||
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('log-derived request cache hits (real API)', () => {
|
||||
it('every request after the first hits the provider prefix cache', async () => {
|
||||
ctx = await loopHarness()
|
||||
const agent = ctx.agentLoop.create(AgentId('cache-e2e'), { provider: 'deepseek', model: 'deepseek-v4-flash' })
|
||||
const agent = ctx.agentLoop.create(SessionId('cache-e2e'), { provider: 'deepseek', model: 'deepseek-v4-flash' })
|
||||
|
||||
// Turn 1: forces a tool call → at least two steps (two model requests).
|
||||
agent.send([{ type: 'text', text: 'Look up the key "deploy-color" with the lookup tool and tell me the value.' }])
|
||||
|
||||
@@ -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, { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution'
|
||||
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, toolCallResponse } from './mock-adapter.ts'
|
||||
|
||||
async function harness(adapter: MockAdapter, persona = 'stable base') {
|
||||
@@ -24,13 +24,12 @@ async function harness(adapter: MockAdapter, persona = 'stable base') {
|
||||
await ctx.plugin(SystemPrompt, { persona })
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentExecutionProvider)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
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 dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
@@ -41,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 }])
|
||||
}
|
||||
|
||||
@@ -73,7 +72,7 @@ describe('request stability across the loop', () => {
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
registerEcho(ctx)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -94,7 +93,7 @@ describe('request stability across the loop', () => {
|
||||
it('a later turn append-extends the previous turn (one conversation, one log)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
send(agent, 'first')
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -108,7 +107,7 @@ describe('request stability across the loop', () => {
|
||||
it('a compaction replace rewrites the resend, and the log explains it', async () => {
|
||||
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
send(agent, 'first')
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -141,7 +140,7 @@ describe('request stability across the loop', () => {
|
||||
it('a real system-prompt change is a full changed-header snapshot; a stable prompt logs nothing', async () => {
|
||||
const adapter = new MockAdapter([textResponse('one'), textResponse('two'), textResponse('three')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
send(agent, 'first')
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -165,7 +164,7 @@ describe('request stability across the loop', () => {
|
||||
it('an inject() during the agent/request waterfall joins the NEXT request (the step/start boundary)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
let injected = false
|
||||
ctx.on('agent/request', async (_agent, _turn, _step, _config, _signal, next) => {
|
||||
@@ -193,7 +192,7 @@ describe('request stability across the loop', () => {
|
||||
it('a mutation attempt on the frozen request content throws into the step (loud, not silent)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('one')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const errors: Error[] = []
|
||||
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
|
||||
@@ -214,7 +213,7 @@ describe('request stability across the loop', () => {
|
||||
it('a fresh loop instance over a seeded log anchors with a resume snapshot and stays cache-aligned', async () => {
|
||||
const adapter = new MockAdapter([textResponse('one')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('gen1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('gen1'), { provider: 'mock', model: 'mock' })
|
||||
send(agent, 'first')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
@@ -223,12 +222,11 @@ describe('request stability across the loop', () => {
|
||||
const adapter2 = new MockAdapter([textResponse('two')])
|
||||
const ctx2 = await harness(adapter2)
|
||||
const handle = await ctx2.agents.create({
|
||||
agentId: AgentId('gen2'),
|
||||
sessionId: SessionId('gen2-session'),
|
||||
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)
|
||||
|
||||
@@ -243,7 +241,7 @@ describe('request stability across the loop', () => {
|
||||
it('a delegating listener cannot mutate the seed through next() — the fold stays log-true', async () => {
|
||||
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
ctx.on('agent/request', async (_agent, _turn, _step, _config, _signal, next) => {
|
||||
const config = await next()
|
||||
@@ -277,7 +275,7 @@ describe('request stability across the loop', () => {
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
registerEcho(ctx)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
516
packages/core/agent-loop/tests/request-recovery.spec.ts
Normal file
516
packages/core/agent-loop/tests/request-recovery.spec.ts
Normal file
@@ -0,0 +1,516 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, {
|
||||
CallId,
|
||||
CONTEXT_WINDOW_EXCEEDED_CODE,
|
||||
LlmAdapter,
|
||||
LlmError,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { PostToolDecision } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { maxTokensResponse, textResponse, toolCallResponse } from './mock-adapter.ts'
|
||||
|
||||
class FailureScriptAdapter extends LlmAdapter {
|
||||
requests: GenerateOptions[] = []
|
||||
|
||||
constructor(private readonly entries: (Error | StreamChunk[])[]) {
|
||||
super()
|
||||
}
|
||||
|
||||
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
this.requests.push(options)
|
||||
const entry = this.entries.shift()
|
||||
if (entry === undefined) throw new Error('failure script exhausted')
|
||||
if (entry instanceof Error) throw entry
|
||||
yield* entry
|
||||
}
|
||||
}
|
||||
|
||||
class IteratorConstructionFailureAdapter extends LlmAdapter {
|
||||
stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
return {
|
||||
[Symbol.asyncIterator](): AsyncIterator<StreamChunk> {
|
||||
throw new LlmError('iterator construction failed', 'ITERATOR_CONSTRUCTION')
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SynchronousDispatchFailureAdapter extends LlmAdapter {
|
||||
constructor(private readonly error: Error) {
|
||||
super()
|
||||
}
|
||||
|
||||
stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
throw this.error
|
||||
}
|
||||
}
|
||||
|
||||
class IteratorResultGetterFailureAdapter extends LlmAdapter {
|
||||
constructor(
|
||||
private readonly field: 'done' | 'value',
|
||||
private readonly error: Error,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
const result = this.field === 'done' ? {} : { done: false }
|
||||
Object.defineProperty(result, this.field, { get: () => { throw this.error } })
|
||||
return {
|
||||
[Symbol.asyncIterator](): AsyncIterator<StreamChunk> {
|
||||
return { next: () => Promise.resolve(result as unknown as IteratorResult<StreamChunk>) }
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const streamListenerFailureCases: readonly [string, (ctx: Context) => void][] = [
|
||||
['synchronous listener throw', (ctx) => {
|
||||
ctx.on('llm/stream', () => { throw new Error('synchronous stream listener failed') })
|
||||
}],
|
||||
['invalid listener iterable', (ctx) => {
|
||||
ctx.on('llm/stream', () => ({}) as AsyncIterable<StreamChunk>)
|
||||
}],
|
||||
['listener wrapper iteration failure', (ctx) => {
|
||||
ctx.on('llm/stream', (_options, next) => (async function * () {
|
||||
for await (const chunk of next()) {
|
||||
yield chunk
|
||||
throw new Error('stream listener wrapper failed')
|
||||
}
|
||||
})())
|
||||
}],
|
||||
]
|
||||
|
||||
async function harness(adapter?: LlmAdapter): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
if (adapter) ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return ctx
|
||||
}
|
||||
|
||||
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 send(agent: Agent): void {
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
}
|
||||
|
||||
function contextError(message = 'context too large'): LlmError {
|
||||
return new LlmError(message, CONTEXT_WINDOW_EXCEEDED_CODE)
|
||||
}
|
||||
|
||||
describe('agent post-step and request-error lifecycle', () => {
|
||||
it('fires post-step after results, buffered context, and steering but before step/end', async () => {
|
||||
const twoCalls: StreamChunk[] = [
|
||||
{ type: 'block-start', index: 0, blockType: 'tool-call' },
|
||||
{ type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('call-1'), name: 'work', arguments: '{}' } },
|
||||
{ type: 'block-start', index: 1, blockType: 'tool-call' },
|
||||
{ type: 'block-end', index: 1, block: { type: 'tool-call', id: CallId('call-2'), name: 'work', arguments: '{}' } },
|
||||
{ type: 'usage', usage: { inputTokens: 10, outputTokens: 5 } },
|
||||
{ type: 'finish', reason: { kind: 'tool-calls' } },
|
||||
]
|
||||
const adapter = new FailureScriptAdapter([twoCalls, textResponse('done')])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'work',
|
||||
description: 'do work',
|
||||
parameters: {},
|
||||
async execute(_args, exec) {
|
||||
if (exec.callId === CallId('call-2')) {
|
||||
exec.agent?.steer([{ type: 'text', text: 'steered' }], { source: { kind: 'plugin', plugin: 'test' } })
|
||||
}
|
||||
return [{ type: 'text', text: 'worked' }]
|
||||
},
|
||||
}))
|
||||
ctx.on('tools/post-execute', async (exec, _result): Promise<PostToolDecision> => ({
|
||||
kind: 'accept',
|
||||
additionalContexts: [{
|
||||
content: [{ type: 'text', text: `context for ${exec.callId}` }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}],
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(SessionId('post-step-order'), { provider: 'mock', model: 'mock' })
|
||||
const order: string[] = []
|
||||
ctx.on('session/event', (_session, event) => {
|
||||
if (
|
||||
event.type === 'assistant/message' || event.type === 'tool/call'
|
||||
|| event.type === 'tool/result' || event.type === 'context/message'
|
||||
|| event.type === 'steering/message' || event.type === 'step/end'
|
||||
) {
|
||||
if (!('step' in event.data) || event.data.step === 1) order.push(event.type)
|
||||
}
|
||||
})
|
||||
ctx.on('agent/post-step', (subject, turn, step, signal) => {
|
||||
if (subject !== agent || step !== 1) return
|
||||
expect({ turn, step, aborted: signal.aborted }).toEqual({ turn: 1, step: 1, aborted: false })
|
||||
subject.inject([{ type: 'text', text: 'listener mutation' }], { source: { kind: 'plugin', plugin: 'post-step' } })
|
||||
order.push('agent/post-step')
|
||||
})
|
||||
|
||||
send(agent)
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(order).toEqual([
|
||||
'assistant/message',
|
||||
'tool/call',
|
||||
'tool/result',
|
||||
'tool/call',
|
||||
'tool/result',
|
||||
'context/message',
|
||||
'context/message',
|
||||
'steering/message',
|
||||
'context/message',
|
||||
'agent/post-step',
|
||||
'step/end',
|
||||
])
|
||||
})
|
||||
|
||||
it('fires post-step for max-tokens and lets cancellation override that success', async () => {
|
||||
const adapter = new FailureScriptAdapter([maxTokensResponse('partial')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('cancel-post-step-max-tokens'), { provider: 'mock', model: 'mock' })
|
||||
let entered!: () => void
|
||||
const postStepEntered = new Promise<void>((resolve) => { entered = resolve })
|
||||
ctx.on('agent/post-step', async (_agent, turn, step, signal) => {
|
||||
expect({ turn, step }).toEqual({ turn: 1, step: 1 })
|
||||
entered()
|
||||
await new Promise<void>((resolve) => {
|
||||
signal.addEventListener('abort', () => { resolve() }, { once: true })
|
||||
})
|
||||
})
|
||||
|
||||
send(agent)
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
await postStepEntered
|
||||
agent.cancel({ kind: 'user' })
|
||||
await idle
|
||||
|
||||
expect(agent.session.events.find(event => event.type === 'assistant/message')).toMatchObject({
|
||||
data: { usage: { inputTokens: 10, outputTokens: 7 } },
|
||||
})
|
||||
expect(agent.session.events.at(-1)).toMatchObject({
|
||||
type: 'turn/end',
|
||||
data: { reason: { kind: 'aborted' } },
|
||||
})
|
||||
})
|
||||
|
||||
it('closes the successful step as disposed when disposal lands during post-step', async () => {
|
||||
const adapter = new FailureScriptAdapter([
|
||||
toolCallResponse('dispose-call', 'work', {}),
|
||||
textResponse('must not continue'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'work',
|
||||
description: 'do work',
|
||||
parameters: {},
|
||||
async execute() { return [{ type: 'text', text: 'worked' }] },
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(SessionId('dispose-post-step'), { provider: 'mock', model: 'mock' })
|
||||
let entered!: () => void
|
||||
const postStepEntered = new Promise<void>((resolve) => { entered = resolve })
|
||||
ctx.on('agent/post-step', async (_agent, turn, step, signal) => {
|
||||
expect({ turn, step }).toEqual({ turn: 1, step: 1 })
|
||||
entered()
|
||||
await new Promise<void>((resolve) => {
|
||||
signal.addEventListener('abort', () => { resolve() }, { once: true })
|
||||
})
|
||||
})
|
||||
|
||||
send(agent)
|
||||
await postStepEntered
|
||||
await ctx.fiber.dispose()
|
||||
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
const boundaries = agent.session.events.filter(event =>
|
||||
event.type === 'step/start' || event.type === 'step/end',
|
||||
)
|
||||
expect(boundaries.map(event => event.type)).toEqual(['step/start', 'step/end'])
|
||||
expect(boundaries.map(event => event.data)).toEqual([
|
||||
{ turn: 1, step: 1 },
|
||||
{ turn: 1, step: 1 },
|
||||
])
|
||||
expect(agent.session.events.at(-1)).toMatchObject({
|
||||
type: 'turn/end',
|
||||
data: { reason: { kind: 'disposed' } },
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['thrown', contextError()],
|
||||
['in-band', [{ type: 'finish', reason: { kind: 'error', message: 'too large', code: CONTEXT_WINDOW_EXCEEDED_CODE } }] satisfies StreamChunk[]],
|
||||
] as const)('recovers a %s request failure in a new reconstructable step', async (_style, failure) => {
|
||||
const adapter = new FailureScriptAdapter([failure, textResponse('recovered')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId(`recover-${_style}`), { provider: 'mock', model: 'mock' })
|
||||
const attempts: number[] = []
|
||||
ctx.on('agent/request-error', async (subject, turn, step, error, attempt) => {
|
||||
expect(subject).toBe(agent)
|
||||
expect({ turn, step, code: error.code }).toEqual({ turn: 1, step: 1, code: CONTEXT_WINDOW_EXCEEDED_CODE })
|
||||
attempts.push(attempt)
|
||||
subject.session.append('context/message', {
|
||||
content: [{ type: 'text', text: 'RECOVERY SURFACE MUTATION' }],
|
||||
source: { kind: 'plugin', plugin: 'test-recovery' },
|
||||
}, { surfaceOp: 'append' })
|
||||
return { action: 'retry' }
|
||||
})
|
||||
|
||||
send(agent)
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(attempts).toEqual([0])
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('RECOVERY SURFACE MUTATION')
|
||||
const starts = agent.session.events.filter(event => event.type === 'step/start')
|
||||
const ends = agent.session.events.filter(event => event.type === 'step/end')
|
||||
expect(starts.map(event => event.data.step)).toEqual([1, 2])
|
||||
expect(ends.map(event => event.data.step)).toEqual([1, 2])
|
||||
const recovery = agent.session.events.find(event => event.type === 'context/message')!
|
||||
expect(ends[0]!.seq).toBeLessThan(recovery.seq)
|
||||
expect(recovery.seq).toBeLessThan(starts[1]!.seq)
|
||||
})
|
||||
|
||||
it.each(streamListenerFailureCases)('does not offer %s to request recovery', async (_name, install) => {
|
||||
const ctx = await harness(new FailureScriptAdapter([textResponse('unused')]))
|
||||
const agent = ctx.agentLoop.create(SessionId(`stream-plugin-${_name.replaceAll(' ', '-')}`), { provider: 'mock', model: 'mock' })
|
||||
let recoveries = 0
|
||||
install(ctx)
|
||||
ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _attempt, _signal, next) => {
|
||||
recoveries += 1
|
||||
return next()
|
||||
})
|
||||
|
||||
send(agent)
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(recoveries).toBe(0)
|
||||
expect(agent.session.events.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'error' } } })
|
||||
})
|
||||
|
||||
it('does not offer a nested model-call failure as the outer request failure', async () => {
|
||||
const outer = new FailureScriptAdapter([textResponse('outer adapter must not run')])
|
||||
const nested = new FailureScriptAdapter([contextError('nested overflow')])
|
||||
const ctx = await harness(outer)
|
||||
ctx.llm.registerAdapter(['nested'], nested)
|
||||
ctx.on('llm/stream', (options, next) => {
|
||||
if (options.provider !== 'mock') return next()
|
||||
return (async function* () {
|
||||
yield* ctx.llm.stream({
|
||||
provider: 'nested',
|
||||
model: 'nested',
|
||||
messages: [],
|
||||
...options.signal === undefined ? {} : { signal: options.signal },
|
||||
})
|
||||
yield* next()
|
||||
})()
|
||||
})
|
||||
const agent = ctx.agentLoop.create(SessionId('nested-stream-not-recoverable'), { provider: 'mock', model: 'mock' })
|
||||
let recoveries = 0
|
||||
ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _attempt, _signal, next) => {
|
||||
recoveries += 1
|
||||
return next()
|
||||
})
|
||||
|
||||
send(agent)
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(nested.requests).toHaveLength(1)
|
||||
expect(outer.requests).toHaveLength(0)
|
||||
expect(recoveries).toBe(0)
|
||||
expect(agent.session.events.at(-1)).toMatchObject({
|
||||
type: 'turn/end',
|
||||
data: { reason: { kind: 'error', message: 'nested overflow', code: CONTEXT_WINDOW_EXCEEDED_CODE } },
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['prompt-submit', 'prompt-assembly', 'pre-step', 'request'] as const)(
|
||||
'does not offer %s middleware failures to request recovery',
|
||||
async (boundary) => {
|
||||
const adapter = new FailureScriptAdapter([textResponse('unused')])
|
||||
const ctx = await harness(adapter)
|
||||
if (boundary === 'prompt-submit') {
|
||||
ctx.on('agent/prompt-submit', () => { throw new Error('prompt submit failed') })
|
||||
} else if (boundary === 'prompt-assembly') {
|
||||
ctx.on('system-prompt/assemble', () => { throw new Error('prompt assembly failed') })
|
||||
} else if (boundary === 'pre-step') {
|
||||
ctx.on('agent/pre-step', () => { throw new Error('pre-step failed') })
|
||||
} else {
|
||||
ctx.on('agent/request', () => { throw new Error('request middleware failed') })
|
||||
}
|
||||
const agent = ctx.agentLoop.create(SessionId(`${boundary}-not-recoverable`), { provider: 'mock', model: 'mock' })
|
||||
let recoveries = 0
|
||||
ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _attempt, _signal, next) => {
|
||||
recoveries += 1
|
||||
return next()
|
||||
})
|
||||
|
||||
send(agent)
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(recoveries).toBe(0)
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(agent.session.events.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'error' } } })
|
||||
},
|
||||
)
|
||||
|
||||
it('does not offer result, tool, or post-step plugin failures to request recovery', async () => {
|
||||
for (const failure of ['result', 'tool', 'post-step'] as const) {
|
||||
const adapter = new FailureScriptAdapter([
|
||||
failure === 'tool' ? toolCallResponse(`call-${failure}`, 'work', {}) : textResponse('done'),
|
||||
...(failure === 'tool' ? [textResponse('done')] : []),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
if (failure === 'result') ctx.on('agent/step-result', () => { throw new Error('result failed') })
|
||||
if (failure === 'post-step') ctx.on('agent/post-step', () => { throw new Error('post-step failed') })
|
||||
if (failure === 'tool') {
|
||||
vi.spyOn(ctx.tools, 'execute').mockRejectedValue(new Error('tool service failed'))
|
||||
}
|
||||
const agent = ctx.agentLoop.create(SessionId(`${failure}-not-recoverable`), { provider: 'mock', model: 'mock' })
|
||||
let recoveries = 0
|
||||
ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _attempt, _signal, next) => {
|
||||
recoveries += 1
|
||||
return next()
|
||||
})
|
||||
send(agent)
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(recoveries, failure).toBe(0)
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
['synchronous dispatch', (error: Error) => new SynchronousDispatchFailureAdapter(error)],
|
||||
['done getter', (error: Error) => new IteratorResultGetterFailureAdapter('done', error)],
|
||||
['value getter', (error: Error) => new IteratorResultGetterFailureAdapter('value', error)],
|
||||
] as const)('preserves original Error identity for adapter %s', async (_name, makeAdapter) => {
|
||||
const original = contextError(`${_name} overflow`)
|
||||
const ctx = await harness(makeAdapter(original))
|
||||
const agent = ctx.agentLoop.create(SessionId(`identity-${_name.replaceAll(' ', '-')}`), { provider: 'mock', model: 'mock' })
|
||||
let seen: Error | undefined
|
||||
ctx.on('agent/request-error', async (_agent, _turn, _step, error, _attempt, _signal, next) => {
|
||||
seen = error
|
||||
return next()
|
||||
})
|
||||
|
||||
send(agent)
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(seen).toBe(original)
|
||||
})
|
||||
|
||||
it('classifies iterator construction and explicit NO_ADAPTER as model-request failures', async () => {
|
||||
for (const scenario of ['iterator', 'no-adapter'] as const) {
|
||||
const ctx = scenario === 'iterator' ? await harness(new IteratorConstructionFailureAdapter()) : await harness()
|
||||
const agent = ctx.agentLoop.create(SessionId(`request-boundary-${scenario}`), { provider: 'mock', model: 'mock' })
|
||||
let seen = ''
|
||||
ctx.on('agent/request-error', async (_agent, _turn, _step, error, _attempt, _signal, next) => {
|
||||
seen = error.code ?? ''
|
||||
return next()
|
||||
})
|
||||
send(agent)
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(seen).toBe(scenario === 'iterator' ? 'ITERATOR_CONSTRUCTION' : 'NO_ADAPTER')
|
||||
}
|
||||
})
|
||||
|
||||
it('tracks consecutive retry attempts and resets after a successful request', async () => {
|
||||
const capped = new FailureScriptAdapter([contextError('first overflow'), contextError('second overflow')])
|
||||
const cappedCtx = await harness(capped)
|
||||
const cappedAgent = cappedCtx.agentLoop.create(SessionId('retry-cap'), { provider: 'mock', model: 'mock' })
|
||||
const cappedAttempts: number[] = []
|
||||
cappedCtx.on('agent/request-error', async (_agent, _turn, _step, _error, attempt, _signal, next) => {
|
||||
cappedAttempts.push(attempt)
|
||||
return attempt < 1 ? { action: 'retry' } : next()
|
||||
})
|
||||
send(cappedAgent)
|
||||
await waitForIdle(cappedCtx, cappedAgent)
|
||||
expect(cappedAttempts).toEqual([0, 1])
|
||||
|
||||
const reset = new FailureScriptAdapter([
|
||||
contextError('first overflow'),
|
||||
toolCallResponse('retry-reset-call', 'work', {}),
|
||||
contextError('later overflow'),
|
||||
])
|
||||
const resetCtx = await harness(reset)
|
||||
resetCtx.tools.register(defineTool({
|
||||
name: 'work',
|
||||
description: 'continue',
|
||||
parameters: {},
|
||||
async execute() { return [{ type: 'text', text: 'worked' }] },
|
||||
}))
|
||||
const resetAgent = resetCtx.agentLoop.create(SessionId('retry-reset'), { provider: 'mock', model: 'mock' })
|
||||
const resetAttempts: { step: number; attempt: number }[] = []
|
||||
resetCtx.on('agent/request-error', async (_agent, _turn, step, _error, attempt, _signal, next) => {
|
||||
resetAttempts.push({ step, attempt })
|
||||
return resetAttempts.length === 1 ? { action: 'retry' } : next()
|
||||
})
|
||||
send(resetAgent)
|
||||
await waitForIdle(resetCtx, resetAgent)
|
||||
expect(resetAttempts).toEqual([{ step: 1, attempt: 0 }, { step: 3, attempt: 0 }])
|
||||
})
|
||||
|
||||
it('preserves the original provider error when recovery throws', async () => {
|
||||
const adapter = new FailureScriptAdapter([contextError('original overflow')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('recovery-throws'), { provider: 'mock', model: 'mock' })
|
||||
ctx.on('agent/request-error', () => { throw new Error('recovery exploded') })
|
||||
|
||||
send(agent)
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(agent.session.events.at(-1)).toMatchObject({
|
||||
type: 'turn/end',
|
||||
data: { reason: { kind: 'error', message: 'original overflow', code: CONTEXT_WINDOW_EXCEEDED_CODE } },
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['cancel', 'dispose'] as const)('keeps %s live through request recovery', async (action) => {
|
||||
const adapter = new FailureScriptAdapter([contextError()])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId(`${action}-recovery`), { provider: 'mock', model: 'mock' })
|
||||
let entered!: () => void
|
||||
const recoveryEntered = new Promise<void>((resolve) => { entered = resolve })
|
||||
ctx.on('agent/request-error', async (_agent, _turn, _step, _error, _attempt, signal) => {
|
||||
entered()
|
||||
await new Promise<void>((resolve) => {
|
||||
signal.addEventListener('abort', () => { resolve() }, { once: true })
|
||||
})
|
||||
return { action: 'retry' }
|
||||
})
|
||||
|
||||
send(agent)
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
await recoveryEntered
|
||||
if (action === 'cancel') {
|
||||
agent.cancel({ kind: 'user' })
|
||||
await idle
|
||||
} else {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(agent.session.events.at(-1)).toMatchObject({
|
||||
type: 'turn/end',
|
||||
data: { reason: action === 'cancel' ? { kind: 'aborted' } : { kind: 'disposed' } },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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, { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution'
|
||||
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[] = []
|
||||
@@ -30,7 +30,6 @@ async function mountPersistentHarness(root: string, adapter: MockAdapter): Promi
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentExecutionProvider)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
@@ -52,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() }
|
||||
@@ -76,7 +75,7 @@ function throwUnknown(value: unknown): never {
|
||||
throw value
|
||||
}
|
||||
|
||||
describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
describe('the session-persistence Agent Note: AgentLoop factory create/resume', () => {
|
||||
it('normalizes a non-Error resume publication failure for rollback and rethrows it', async () => {
|
||||
const sessionId = SessionId('unknown-resume-failure-s')
|
||||
const root = await persistSession(sessionId)
|
||||
@@ -85,11 +84,10 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
ctx.on('session/created', () => throwUnknown(failure))
|
||||
|
||||
await expect(ctx.agents.resume({
|
||||
agentId: AgentId('unknown-resume-failure'),
|
||||
resumeSessionId: sessionId,
|
||||
})).rejects.toBe(failure)
|
||||
|
||||
expect(ctx.agents.get(AgentId('unknown-resume-failure'))).toBeUndefined()
|
||||
expect(ctx.agents.get(SessionId('unknown-resume-failure'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(sessionId)).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
@@ -97,27 +95,26 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
it('createAgent uses the caller-supplied sessionId (not ${id}-session)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('hi')])
|
||||
const { ctx } = await persistentHarness(adapter)
|
||||
const { agent } = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('custom-session'), meta: { cwd: '/w' } })
|
||||
const { agent } = await ctx.agents.create({ sessionId: SessionId('custom-session'), meta: { cwd: '/w' } })
|
||||
expect(agent.session.id).toBe('custom-session')
|
||||
expect(agent.session.header.cwd).toBe('/w')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('createAgent rejects a duplicate agent id BEFORE creating the session (no orphan)', async () => {
|
||||
it('createAgent rejects a duplicate identity without orphaning a session', async () => {
|
||||
const adapter = new MockAdapter([textResponse('hi')])
|
||||
const { ctx } = await persistentHarness(adapter)
|
||||
await ctx.agents.create({ agentId: AgentId('dup'), sessionId: SessionId('sess-a') })
|
||||
// A second create with the SAME agent id but a fresh session id must reject
|
||||
// up front — and must NOT leave an orphaned 'sess-b' session behind.
|
||||
await expect(ctx.agents.create({ agentId: AgentId('dup'), sessionId: SessionId('sess-b') })).rejects.toThrow(/already registered/)
|
||||
expect(ctx.sessions.get(SessionId('sess-b'))).toBeUndefined()
|
||||
const sessionId = SessionId('sess-a')
|
||||
await ctx.agents.create({ sessionId })
|
||||
await expect(ctx.agents.create({ sessionId })).rejects.toThrow(/already exists/)
|
||||
expect(ctx.sessions.list()).toHaveLength(1)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('createAgent works without meta (no cwd)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('hi')])
|
||||
const { ctx } = await persistentHarness(adapter)
|
||||
const { agent } = await ctx.agents.create({ agentId: AgentId('a-nometa'), sessionId: SessionId('nometa-session') })
|
||||
const { agent } = await ctx.agents.create({ sessionId: SessionId('nometa-session') })
|
||||
expect(agent.session.id).toBe('nometa-session')
|
||||
expect(agent.session.header.cwd).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
@@ -127,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({ agentId: AgentId('m'), 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,11 +137,10 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
await ctx2.plugin(SystemPrompt)
|
||||
await ctx2.plugin(ToolRegistry)
|
||||
await ctx2.plugin(AgentRegistry)
|
||||
await ctx2.plugin(AgentExecutionProvider)
|
||||
await ctx2.plugin(AgentLoop, { agents: [] })
|
||||
await ctx2.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx2.llm.registerAdapter(['mock'], adapter2)
|
||||
const a2 = (await ctx2.agents.resume({ agentId: AgentId('m'), 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()
|
||||
})
|
||||
@@ -155,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({ agentId: AgentId('s'), 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)
|
||||
@@ -169,13 +165,12 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
await ctx2.plugin(SystemPrompt)
|
||||
await ctx2.plugin(ToolRegistry)
|
||||
await ctx2.plugin(AgentRegistry)
|
||||
await ctx2.plugin(AgentExecutionProvider)
|
||||
await ctx2.plugin(AgentLoop, { agents: [] })
|
||||
await ctx2.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx2.llm.registerAdapter(['mock'], adapter2)
|
||||
const sources2: string[] = []
|
||||
ctx2.on('agent/session-start', (_agent, source) => void sources2.push(source))
|
||||
await ctx2.agents.resume({ agentId: AgentId('s'), resumeSessionId: SessionId('start-sess') })
|
||||
await ctx2.agents.resume({ resumeSessionId: SessionId('start-sess') })
|
||||
expect(sources2).toEqual(['resume'])
|
||||
await ctx2.fiber.dispose()
|
||||
})
|
||||
@@ -190,7 +185,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
|
||||
ctx.on('session/created', (session) => {
|
||||
expect(ctx.sessions.get(session.id)).toBe(session)
|
||||
expect(ctx.agents.get(AgentId('resumed-atomic'))?.session).toBe(session)
|
||||
expect(ctx.agents.get(sessionId)?.session).toBe(session)
|
||||
order.push('session/created')
|
||||
})
|
||||
ctx.on('agent/created', (agent) => {
|
||||
@@ -203,11 +198,10 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
})
|
||||
|
||||
const resuming = ctx.agents.resume({
|
||||
agentId: AgentId('resumed-atomic'),
|
||||
resumeSessionId: sessionId,
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
setup: async (agentCtx) => {
|
||||
expect(agentCtx.agent?.id).toBe(AgentId('resumed-atomic'))
|
||||
expect(agentCtx.agent?.id).toBe(sessionId)
|
||||
expect(agentCtx.agent?.session.events).toHaveLength(2)
|
||||
agentCtx.on('session/created', () => void order.push('setup-listener:session/created'))
|
||||
agentCtx.on('agent/created', () => void order.push('setup-listener:agent/created'))
|
||||
@@ -219,7 +213,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
})
|
||||
|
||||
await setupStarted.promise
|
||||
expect(ctx.agents.get(AgentId('resumed-atomic'))).toBeUndefined()
|
||||
expect(ctx.agents.get(sessionId)).toBeUndefined()
|
||||
expect(ctx.sessions.get(sessionId)).toBeUndefined()
|
||||
expect(order).toEqual(['setup:start'])
|
||||
|
||||
@@ -240,17 +234,15 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
|
||||
it('successful resume disposal retires its caller-owned transaction effects', async () => {
|
||||
const sessionId = SessionId('resume-retired-effects-s')
|
||||
const agentId = AgentId('resume-retired-effects')
|
||||
const root = await persistSession(sessionId)
|
||||
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
|
||||
const handle = await ctx.agents.resume({
|
||||
agentId,
|
||||
resumeSessionId: sessionId,
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
const transactionLabels = [
|
||||
`agentLoop.owner(${agentId})`,
|
||||
`agentLoop.lifecycle(${agentId})`,
|
||||
`agentLoop.owner(${sessionId})`,
|
||||
`agentLoop.lifecycle(${sessionId})`,
|
||||
]
|
||||
|
||||
expect(ctx.fiber.getEffects().map(effect => effect.label)).toEqual(expect.arrayContaining(transactionLabels))
|
||||
@@ -259,7 +251,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('resume setup rejection publishes nothing, unwinds, and releases both identities', async () => {
|
||||
it('resume setup rejection publishes nothing, unwinds, and releases the identity', async () => {
|
||||
const sessionId = SessionId('resume-setup-reject')
|
||||
const root = await persistSession(sessionId)
|
||||
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
|
||||
@@ -269,7 +261,6 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
ctx.on('agent/session-start', () => void published.push('agent/session-start'))
|
||||
|
||||
await expect(ctx.agents.resume({
|
||||
agentId: AgentId('resume-reject'),
|
||||
resumeSessionId: sessionId,
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
setup: async () => {
|
||||
@@ -279,10 +270,9 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
})).rejects.toThrow('resume setup failed')
|
||||
|
||||
expect(published).toEqual([])
|
||||
expect(ctx.agents.get(AgentId('resume-reject'))).toBeUndefined()
|
||||
expect(ctx.agents.get(sessionId)).toBeUndefined()
|
||||
expect(ctx.sessions.get(sessionId)).toBeUndefined()
|
||||
const retry = await ctx.agents.resume({
|
||||
agentId: AgentId('resume-reject'),
|
||||
resumeSessionId: sessionId,
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
@@ -303,7 +293,6 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
let resuming!: ReturnType<typeof ctx.agents.resume>
|
||||
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
resuming = inner.agents.resume({
|
||||
agentId: AgentId('resume-owner-race'),
|
||||
resumeSessionId: sessionId,
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
setup: async () => {
|
||||
@@ -317,7 +306,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
await owner.dispose()
|
||||
await expect(resuming).rejects.toThrow(/owner disposed during setup/)
|
||||
expect(published).toEqual([])
|
||||
expect(ctx.agents.get(AgentId('resume-owner-race'))).toBeUndefined()
|
||||
expect(ctx.agents.get(sessionId)).toBeUndefined()
|
||||
expect(ctx.sessions.get(sessionId)).toBeUndefined()
|
||||
|
||||
gate.resolve(undefined)
|
||||
@@ -326,9 +315,8 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('owner unload aborts a never-settling persistence load, releases identities, and blocks late publication', async () => {
|
||||
it('owner unload aborts a never-settling persistence load, releases the identity, and blocks late publication', async () => {
|
||||
const sessionId = SessionId('resume-load-owner-unload')
|
||||
const agentId = AgentId('resume-load-race')
|
||||
const root = await persistSession(sessionId)
|
||||
const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')]))
|
||||
const snapshot = await ctx.sessionPersistence.load(sessionId)
|
||||
@@ -352,19 +340,19 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
|
||||
let resuming!: ReturnType<typeof ctx.agents.resume>
|
||||
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
resuming = inner.agents.resume({ agentId, resumeSessionId: sessionId, agentOptions: { provider: 'mock', model: 'mock' } })
|
||||
resuming = inner.agents.resume({ resumeSessionId: sessionId, agentOptions: { provider: 'mock', model: 'mock' } })
|
||||
}, { inject: ['agents'] }))
|
||||
await loadStarted.promise
|
||||
|
||||
const rejection = expect(promptly(resuming)).rejects.toThrow(/owner disposed during setup/)
|
||||
await promptly(owner.dispose())
|
||||
expect(published).toEqual([])
|
||||
expect(ctx.agents.get(agentId)).toBeUndefined()
|
||||
expect(ctx.agents.get(sessionId)).toBeUndefined()
|
||||
expect(ctx.sessions.get(sessionId)).toBeUndefined()
|
||||
|
||||
// owner.dispose() awaited transaction settlement, so the same identities
|
||||
// can be reused before awaiting the public rejection.
|
||||
const retry = await promptly(ctx.agents.resume({ agentId, resumeSessionId: sessionId, agentOptions: { provider: 'mock', model: 'mock' } }))
|
||||
const retry = await promptly(ctx.agents.resume({ resumeSessionId: sessionId, agentOptions: { provider: 'mock', model: 'mock' } }))
|
||||
await rejection
|
||||
expect(loads).toBe(2)
|
||||
expect(published).toEqual(['session/created', 'agent/created', 'agent/session-start'])
|
||||
@@ -374,7 +362,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
lateLoad.resolve(structuredClone(snapshot))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(ctx.agents.get(agentId)).toBe(retry.agent)
|
||||
expect(ctx.agents.get(sessionId)).toBe(retry.agent)
|
||||
expect(ctx.sessions.get(sessionId)).toBe(retry.agent.session)
|
||||
expect(published).toEqual(['session/created', 'agent/created', 'agent/session-start'])
|
||||
|
||||
@@ -384,7 +372,6 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
|
||||
it('AgentLoop unload aborts persistence load and awaits wrapper settlement', async () => {
|
||||
const sessionId = SessionId('resume-load-factory-unload')
|
||||
const agentId = AgentId('resume-load-factory-race')
|
||||
const root = await persistSession(sessionId)
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
@@ -392,7 +379,6 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentExecutionProvider)
|
||||
const loopFiber = await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('next')]))
|
||||
@@ -409,14 +395,14 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
ctx.on('session/created', () => void published.push('session/created'))
|
||||
ctx.on('agent/created', () => void published.push('agent/created'))
|
||||
|
||||
const resuming = ctx.agents.resume({ agentId, resumeSessionId: sessionId, agentOptions: { provider: 'mock', model: 'mock' } })
|
||||
const resuming = ctx.agents.resume({ resumeSessionId: sessionId, agentOptions: { provider: 'mock', model: 'mock' } })
|
||||
await loadStarted.promise
|
||||
const rejection = expect(promptly(resuming)).rejects.toThrow(/agent loop is not active/)
|
||||
await promptly(loopFiber.dispose())
|
||||
await rejection
|
||||
|
||||
expect(published).toEqual([])
|
||||
expect(ctx.agents.get(agentId)).toBeUndefined()
|
||||
expect(ctx.agents.get(sessionId)).toBeUndefined()
|
||||
expect(ctx.sessions.get(sessionId)).toBeUndefined()
|
||||
lateLoad.resolve(structuredClone(snapshot))
|
||||
await Promise.resolve()
|
||||
@@ -425,7 +411,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('resume of a forked session preserves the parentSession lineage and seed boundary in the header', async () => {
|
||||
it('resume of a forked session preserves the lineage, seed boundary, and delegation depth in the header', async () => {
|
||||
// Lifecycle 1: persist a FORKED session (carries parentSession + seedLength
|
||||
// in its header) by creating it with a complete-turn seed — the write path
|
||||
// materializes the fork (header + seed) on disk.
|
||||
@@ -437,7 +423,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
const { ctx: ctx1, root } = await persistentHarness(adapter1)
|
||||
const forked = ctx1.sessions.create(SessionId('forked-sess'), {
|
||||
seed,
|
||||
meta: { cwd: '/w', parentSession: SessionId('parent-sess'), seedLength: seed.length },
|
||||
meta: { cwd: '/w', parentSession: SessionId('parent-sess'), seedLength: seed.length, delegationDepth: 1 },
|
||||
})
|
||||
await ctx1.parallel('session/flush', forked)
|
||||
await ctx1.fiber.dispose()
|
||||
@@ -454,14 +440,16 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
await ctx2.plugin(SystemPrompt)
|
||||
await ctx2.plugin(ToolRegistry)
|
||||
await ctx2.plugin(AgentRegistry)
|
||||
await ctx2.plugin(AgentExecutionProvider)
|
||||
await ctx2.plugin(AgentLoop, { agents: [] })
|
||||
await ctx2.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx2.llm.registerAdapter(['mock'], adapter2)
|
||||
const a2 = (await ctx2.agents.resume({ agentId: AgentId('m'), 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)
|
||||
// The recursion budget survives resume — a dropped depth would let a
|
||||
// resumed child delegate as if it were top-level.
|
||||
expect(a2.session.header.delegationDepth).toBe(1)
|
||||
await ctx2.fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -470,7 +458,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({ agentId: AgentId('m'), 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' } })
|
||||
@@ -493,7 +481,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({ agentId: AgentId('m'), 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' } })
|
||||
@@ -508,11 +496,10 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
await ctx2.plugin(SystemPrompt)
|
||||
await ctx2.plugin(ToolRegistry)
|
||||
await ctx2.plugin(AgentRegistry)
|
||||
await ctx2.plugin(AgentExecutionProvider)
|
||||
await ctx2.plugin(AgentLoop, { agents: [] })
|
||||
await ctx2.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx2.llm.registerAdapter(['mock'], adapter2)
|
||||
const a2 = (await ctx2.agents.resume({ agentId: AgentId('m'), 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()
|
||||
@@ -522,7 +509,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({ agentId: AgentId('main'), 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]
|
||||
@@ -538,12 +525,11 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
await ctx2.plugin(SystemPrompt)
|
||||
await ctx2.plugin(ToolRegistry)
|
||||
await ctx2.plugin(AgentRegistry)
|
||||
await ctx2.plugin(AgentExecutionProvider)
|
||||
await ctx2.plugin(AgentLoop, { agents: [] })
|
||||
await ctx2.plugin(SessionPersistenceJsonl, { root })
|
||||
ctx2.llm.registerAdapter(['mock'], adapter2)
|
||||
|
||||
const a2 = (await ctx2.agents.resume({ agentId: AgentId('main'), 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)
|
||||
@@ -569,10 +555,9 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentExecutionProvider)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
await expect(ctx.agents.resume({ agentId: AgentId('m'), resumeSessionId: SessionId('nope') }))
|
||||
await expect(ctx.agents.resume({ resumeSessionId: SessionId('nope') }))
|
||||
.rejects.toThrow(/session persistence is not configured/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -4,11 +4,11 @@ import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { AgentId, agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry, { agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { scopeOf } from '@deepseek-ai/dsh-scope'
|
||||
import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution'
|
||||
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'
|
||||
|
||||
@@ -19,7 +19,6 @@ async function harnessWithLoop(adapter: MockAdapter = new MockAdapter([textRespo
|
||||
await ctx.plugin(SystemPrompt, { persona: 'You are the deployment.' })
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentExecutionProvider)
|
||||
const loopFiber = await ctx.plugin(AgentLoop, { agents: [] })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
return { ctx, loopFiber }
|
||||
@@ -29,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') {
|
||||
@@ -59,33 +58,31 @@ function disposeCurrentLifecycle(ownerCtx: Context): void {
|
||||
}
|
||||
|
||||
describe('agent scope lifecycle', () => {
|
||||
it('rejects an already-aborted creation signal before publishing either identity', async () => {
|
||||
it('rejects an already-aborted creation signal before publishing either object', async () => {
|
||||
const ctx = await harness()
|
||||
const reason = new Error('cancelled before creation')
|
||||
const controller = new AbortController()
|
||||
controller.abort(reason)
|
||||
|
||||
await expect(ctx.agents.create({
|
||||
agentId: AgentId('pre-aborted'),
|
||||
sessionId: SessionId('pre-aborted-s'),
|
||||
signal: controller.signal,
|
||||
})).rejects.toBe(reason)
|
||||
|
||||
expect(ctx.agents.get(AgentId('pre-aborted'))).toBeUndefined()
|
||||
expect(ctx.agents.get(SessionId('pre-aborted-s'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('pre-aborted-s'))).toBeUndefined()
|
||||
|
||||
const valueController = new AbortController()
|
||||
valueController.abort('plain cancellation reason')
|
||||
await expect(ctx.agents.create({
|
||||
agentId: AgentId('pre-aborted-value'),
|
||||
sessionId: SessionId('pre-aborted-value-s'),
|
||||
signal: valueController.signal,
|
||||
})).rejects.toMatchObject({
|
||||
message: 'agent "pre-aborted-value" creation aborted',
|
||||
message: 'agent "pre-aborted-value-s" creation aborted',
|
||||
cause: 'plain cancellation reason',
|
||||
})
|
||||
|
||||
expect(ctx.agents.get(AgentId('pre-aborted-value'))).toBeUndefined()
|
||||
expect(ctx.agents.get(SessionId('pre-aborted-value-s'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('pre-aborted-value-s'))).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
@@ -102,12 +99,11 @@ describe('agent scope lifecycle', () => {
|
||||
})
|
||||
|
||||
await expect(ctx.agents.create({
|
||||
agentId: AgentId('prepare-abort'),
|
||||
sessionId: SessionId('prepare-abort-s'),
|
||||
signal: controller.signal,
|
||||
})).rejects.toBe(reason)
|
||||
|
||||
expect(ctx.agents.get(AgentId('prepare-abort'))).toBeUndefined()
|
||||
expect(ctx.agents.get(SessionId('prepare-abort-s'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('prepare-abort-s'))).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
@@ -126,7 +122,7 @@ describe('agent scope lifecycle', () => {
|
||||
thrown = createFailure
|
||||
let createCaught: unknown
|
||||
try {
|
||||
ctx.agentLoop.create(AgentId('unknown-create'))
|
||||
ctx.agentLoop.create(SessionId('unknown-create'))
|
||||
} catch (error: unknown) {
|
||||
createCaught = error
|
||||
}
|
||||
@@ -135,28 +131,45 @@ describe('agent scope lifecycle', () => {
|
||||
const ownedFailure = { source: 'createAgent' }
|
||||
thrown = ownedFailure
|
||||
await expect(ctx.agents.create({
|
||||
agentId: AgentId('unknown-owned-create'),
|
||||
sessionId: SessionId('unknown-owned-create-s'),
|
||||
})).rejects.toBe(ownedFailure)
|
||||
|
||||
expect(ctx.agents.get(AgentId('unknown-create'))).toBeUndefined()
|
||||
expect(ctx.agents.get(AgentId('unknown-owned-create'))).toBeUndefined()
|
||||
expect(ctx.agents.get(SessionId('unknown-create'))).toBeUndefined()
|
||||
expect(ctx.agents.get(SessionId('unknown-owned-create-s'))).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('wires agent.ctx: tagged with the agent, DX field set, ctx.agent safe elsewhere', async () => {
|
||||
const ctx = await harness()
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
expect(scopeOf(agent.ctx)).toBe(agent)
|
||||
expect(agent.ctx.agent).toBe(agent)
|
||||
// The root accessor default: a plain context answers undefined, not a throw.
|
||||
expect(ctx.agent).toBeUndefined()
|
||||
await ctx.agents.get(AgentId('a1'))?.whenIdle()
|
||||
await ctx.agents.get(SessionId('a1'))?.whenIdle()
|
||||
})
|
||||
|
||||
it('records agents created through an agent context as non-root runtime children', async () => {
|
||||
const ctx = await harness()
|
||||
const root = await ctx.agents.create({
|
||||
sessionId: SessionId('runtime-root'),
|
||||
agentOptions: { model: 'mock' },
|
||||
})
|
||||
const child = await root.agent.ctx.agents.create({
|
||||
sessionId: SessionId('runtime-child'),
|
||||
agentOptions: { model: 'mock' },
|
||||
})
|
||||
|
||||
expect(ctx.agents.list()).toEqual([root.agent, child.agent])
|
||||
expect(ctx.agents.roots()).toEqual([root.agent])
|
||||
|
||||
await child.dispose()
|
||||
await root.dispose()
|
||||
})
|
||||
|
||||
it('scoped registrations live in the agent world and die with the agent', async () => {
|
||||
const ctx = await harness()
|
||||
const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), agentOptions: { provider: 'mock', model: 'mock' } })
|
||||
const handle = await ctx.agents.create({ sessionId: SessionId('s1'), agentOptions: { provider: 'mock', model: 'mock' } })
|
||||
const { agent } = handle
|
||||
agent.ctx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: 'You run tests.' })
|
||||
agent.ctx.tools.register({
|
||||
@@ -181,8 +194,8 @@ describe('agent scope lifecycle', () => {
|
||||
|
||||
it('agent.ctx listeners hear only their own agent (scoped dispatch end to end)', async () => {
|
||||
const ctx = await harness(new MockAdapter([textResponse('one'), textResponse('two')]))
|
||||
const a = ctx.agentLoop.create(AgentId('a'), { provider: 'mock', model: 'mock' })
|
||||
const b = ctx.agentLoop.create(AgentId('b'), { provider: 'mock', model: 'mock' })
|
||||
const a = ctx.agentLoop.create(SessionId('a'), { provider: 'mock', model: 'mock' })
|
||||
const b = ctx.agentLoop.create(SessionId('b'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
const heard: string[] = []
|
||||
a.ctx.on('agent/status', (subject, status) => void heard.push(`a-sees:${subject.id}:${status}`))
|
||||
@@ -212,7 +225,6 @@ describe('agent scope lifecycle', () => {
|
||||
})
|
||||
|
||||
const handle = await ctx.agents.create({
|
||||
agentId: AgentId('child'),
|
||||
sessionId: SessionId('child-s'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
setup: async (agentCtx) => {
|
||||
@@ -226,14 +238,14 @@ describe('agent scope lifecycle', () => {
|
||||
await handle.dispose()
|
||||
})
|
||||
|
||||
it('keeps both identities unpublished until async setup completes, then announces in order', async () => {
|
||||
it('keeps both objects unpublished until async setup completes, then announces in order', async () => {
|
||||
const ctx = await harness()
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
const setupStarted = Promise.withResolvers<undefined>()
|
||||
const order: string[] = []
|
||||
ctx.on('session/created', (session) => {
|
||||
expect(ctx.sessions.get(session.id)).toBe(session)
|
||||
expect(ctx.agents.get(AgentId('atomic'))?.session).toBe(session)
|
||||
expect(ctx.agents.get(session.id)?.session).toBe(session)
|
||||
order.push('session/created')
|
||||
})
|
||||
ctx.on('agent/created', () => void order.push('agent/created'))
|
||||
@@ -241,11 +253,10 @@ describe('agent scope lifecycle', () => {
|
||||
const acceptedOptions = { provider: 'mock', model: 'mock' }
|
||||
|
||||
const creating = ctx.agents.create({
|
||||
agentId: AgentId('atomic'),
|
||||
sessionId: SessionId('atomic-s'),
|
||||
sessionId: SessionId('atomic'),
|
||||
agentOptions: acceptedOptions,
|
||||
setup: async (agentCtx) => {
|
||||
expect(agentCtx.agent?.id).toBe(AgentId('atomic'))
|
||||
expect(agentCtx.agent?.id).toBe(SessionId('atomic'))
|
||||
agentCtx.on('session/created', () => void order.push('setup-listener:session/created'))
|
||||
agentCtx.on('agent/created', () => void order.push('setup-listener:agent/created'))
|
||||
order.push('setup:start')
|
||||
@@ -255,8 +266,8 @@ describe('agent scope lifecycle', () => {
|
||||
},
|
||||
})
|
||||
await setupStarted.promise
|
||||
expect(ctx.agents.get(AgentId('atomic'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('atomic-s'))).toBeUndefined()
|
||||
expect(ctx.agents.get(SessionId('atomic'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('atomic'))).toBeUndefined()
|
||||
expect(order).toEqual(['setup:start'])
|
||||
gate.resolve(undefined)
|
||||
const handle = await creating
|
||||
@@ -283,16 +294,14 @@ describe('agent scope lifecycle', () => {
|
||||
if (started === 2) bothStarted.resolve(undefined)
|
||||
await gate.promise
|
||||
}
|
||||
const agentId = AgentId('concurrent-final-enter')
|
||||
const sessionId = SessionId('concurrent-final-enter')
|
||||
const first = ctx.agents.create({
|
||||
agentId,
|
||||
sessionId: SessionId('concurrent-final-enter-a'),
|
||||
sessionId,
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
setup,
|
||||
})
|
||||
const second = ctx.agents.create({
|
||||
agentId,
|
||||
sessionId: SessionId('concurrent-final-enter-b'),
|
||||
sessionId,
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
setup,
|
||||
})
|
||||
@@ -306,7 +315,7 @@ describe('agent scope lifecycle', () => {
|
||||
const rejected = outcomes.filter((outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected')
|
||||
expect(fulfilled).toHaveLength(1)
|
||||
expect(rejected).toHaveLength(1)
|
||||
expect(String(rejected[0]!.reason)).toMatch(/already registered/)
|
||||
expect(String(rejected[0]!.reason)).toMatch(/already exists/)
|
||||
expect(ctx.agents.list()).toEqual([fulfilled[0]!.value.agent])
|
||||
expect(ctx.sessions.list()).toEqual([fulfilled[0]!.value.agent.session])
|
||||
|
||||
@@ -320,7 +329,6 @@ describe('agent scope lifecycle', () => {
|
||||
const pendingController = new AbortController()
|
||||
const setupStarted = Promise.withResolvers<undefined>()
|
||||
const pending = ctx.agents.create({
|
||||
agentId: AgentId('signal-pending'),
|
||||
sessionId: SessionId('signal-pending-s'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
signal: pendingController.signal,
|
||||
@@ -332,12 +340,11 @@ describe('agent scope lifecycle', () => {
|
||||
await setupStarted.promise
|
||||
pendingController.abort(new Error('cancel pending creation'))
|
||||
await expect(pending).rejects.toThrow('cancel pending creation')
|
||||
expect(ctx.agents.get(AgentId('signal-pending'))).toBeUndefined()
|
||||
expect(ctx.agents.get(SessionId('signal-pending-s'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('signal-pending-s'))).toBeUndefined()
|
||||
|
||||
const liveController = new AbortController()
|
||||
const live = await ctx.agents.create({
|
||||
agentId: AgentId('signal-live'),
|
||||
sessionId: SessionId('signal-live-s'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
signal: liveController.signal,
|
||||
@@ -360,7 +367,6 @@ describe('agent scope lifecycle', () => {
|
||||
let creating!: ReturnType<typeof ctx.agents.create>
|
||||
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
creating = inner.agents.create({
|
||||
agentId: AgentId('owner-race'),
|
||||
sessionId: SessionId('owner-race-s'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
setup: async () => {
|
||||
@@ -374,7 +380,7 @@ describe('agent scope lifecycle', () => {
|
||||
await owner.dispose()
|
||||
await expect(creating).rejects.toThrow(/owner disposed during setup/)
|
||||
expect(published).toEqual([])
|
||||
expect(ctx.agents.get(AgentId('owner-race'))).toBeUndefined()
|
||||
expect(ctx.agents.get(SessionId('owner-race-s'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('owner-race-s'))).toBeUndefined()
|
||||
// Let the losing callback settle; Promise.race already observes it.
|
||||
gate.resolve(undefined)
|
||||
@@ -388,7 +394,6 @@ describe('agent scope lifecycle', () => {
|
||||
let creating2!: ReturnType<typeof ctx.agents.create>
|
||||
const owner2 = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
creating2 = inner.agents.create({
|
||||
agentId: AgentId('owner-race-2'),
|
||||
sessionId: SessionId('owner-race-s-2'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
setup: async () => {
|
||||
@@ -402,7 +407,7 @@ describe('agent scope lifecycle', () => {
|
||||
const unload2 = owner2.dispose()
|
||||
await expect(creating2).rejects.toThrow(/owner disposed during setup/)
|
||||
await unload2
|
||||
expect(ctx.agents.get(AgentId('owner-race-2'))).toBeUndefined()
|
||||
expect(ctx.agents.get(SessionId('owner-race-s-2'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('owner-race-s-2'))).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -415,7 +420,6 @@ describe('agent scope lifecycle', () => {
|
||||
ctx.on('agent/created', () => void published.push('agent/created'))
|
||||
|
||||
const creating = ctx.agents.create({
|
||||
agentId: AgentId('factory-setup-race'),
|
||||
sessionId: SessionId('factory-setup-race-s'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
setup: async () => {
|
||||
@@ -428,7 +432,7 @@ describe('agent scope lifecycle', () => {
|
||||
await loopFiber.dispose()
|
||||
await expect(creating).rejects.toThrow(/agent loop is not active/)
|
||||
expect(published).toEqual([])
|
||||
expect(ctx.agents.get(AgentId('factory-setup-race'))).toBeUndefined()
|
||||
expect(ctx.agents.get(SessionId('factory-setup-race-s'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('factory-setup-race-s'))).toBeUndefined()
|
||||
|
||||
gate.resolve(undefined)
|
||||
@@ -446,7 +450,6 @@ describe('agent scope lifecycle', () => {
|
||||
})
|
||||
|
||||
const creating = ctx.agents.create({
|
||||
agentId: AgentId('factory-scope-race'),
|
||||
sessionId: SessionId('factory-scope-race-s'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
setup: () => { setupCalls += 1 },
|
||||
@@ -454,7 +457,7 @@ describe('agent scope lifecycle', () => {
|
||||
await expect(creating).rejects.toThrow(/agent loop is not active/)
|
||||
await loopFiber.dispose()
|
||||
expect(setupCalls).toBe(0)
|
||||
expect(ctx.agents.get(AgentId('factory-scope-race'))).toBeUndefined()
|
||||
expect(ctx.agents.get(SessionId('factory-scope-race-s'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('factory-scope-race-s'))).toBeUndefined()
|
||||
|
||||
await ctx.fiber.dispose()
|
||||
@@ -481,7 +484,6 @@ describe('agent scope lifecycle', () => {
|
||||
const owner = ctx.plugin(Object.assign((inner: Context) => {
|
||||
ownerFiber = inner.fiber
|
||||
creating = inner.agents.create({
|
||||
agentId: AgentId('caller-scope-race'),
|
||||
sessionId: SessionId('caller-scope-race-s'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
@@ -497,7 +499,7 @@ describe('agent scope lifecycle', () => {
|
||||
await ownerDisposal
|
||||
await owner
|
||||
expect(scopeFiber?.uid).toBeNull()
|
||||
expect(ctx.agents.get(AgentId('caller-scope-race'))).toBeUndefined()
|
||||
expect(ctx.agents.get(SessionId('caller-scope-race-s'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('caller-scope-race-s'))).toBeUndefined()
|
||||
await owner.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
@@ -513,17 +515,17 @@ describe('agent scope lifecycle', () => {
|
||||
void loopFiber.dispose()
|
||||
})
|
||||
|
||||
expect(() => ctx.agentLoop.create(AgentId('config-scope-race'), { provider: 'mock', model: 'mock' }))
|
||||
expect(() => ctx.agentLoop.create(SessionId('config-scope-race'), { provider: 'mock', model: 'mock' }))
|
||||
.toThrow(/agent loop is not active/)
|
||||
await loopFiber.dispose()
|
||||
expect(ctx.agents.get(AgentId('config-scope-race'))).toBeUndefined()
|
||||
expect(ctx.agents.get(SessionId('config-scope-race'))).toBeUndefined()
|
||||
expect(ctx.sessions.list()).toHaveLength(sessionsBefore)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('synchronous create leaves no lifecycle state when session preparation fails', async () => {
|
||||
const ctx = await harness()
|
||||
const id = AgentId('config-prepare-failure')
|
||||
const id = SessionId('config-prepare-failure')
|
||||
|
||||
expect(() => ctx.agentLoop.create(id, { provider: 'mock', model: 'mock' }, { cwd: 'relative' }))
|
||||
.toThrow(/absolute path/)
|
||||
@@ -544,12 +546,11 @@ describe('agent scope lifecycle', () => {
|
||||
})
|
||||
|
||||
await expect(ctx.agents.create({
|
||||
agentId: AgentId('factory-scope-throw'),
|
||||
sessionId: SessionId('factory-scope-throw-s'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})).rejects.toThrow('scope preparation failed')
|
||||
await loopFiber.dispose()
|
||||
expect(ctx.agents.get(AgentId('factory-scope-throw'))).toBeUndefined()
|
||||
expect(ctx.agents.get(SessionId('factory-scope-throw-s'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('factory-scope-throw-s'))).toBeUndefined()
|
||||
|
||||
await ctx.fiber.dispose()
|
||||
@@ -558,23 +559,21 @@ describe('agent scope lifecycle', () => {
|
||||
it('AgentLoop unload is a structural co-owner of every live programmatic agent', async () => {
|
||||
const { ctx, loopFiber } = await harnessWithLoop()
|
||||
const loop = ctx.agentLoop
|
||||
const agentId = AgentId('factory-live')
|
||||
const sessionId = SessionId('factory-live')
|
||||
const handle = await ctx.agents.create({
|
||||
agentId,
|
||||
sessionId: SessionId('factory-live-s'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
|
||||
await loopFiber.dispose()
|
||||
expect(handle.agent.status).toBe('disposed')
|
||||
expect(ctx.agents.get(agentId)).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('factory-live-s'))).toBeUndefined()
|
||||
expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.owner(${agentId})`)).toEqual([])
|
||||
expect(ctx.agents.get(sessionId)).toBeUndefined()
|
||||
expect(ctx.sessions.get(sessionId)).toBeUndefined()
|
||||
expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.owner(${sessionId})`)).toEqual([])
|
||||
// The consumer handle shares the provider's completed quiescence boundary.
|
||||
await handle.dispose()
|
||||
|
||||
await expect(loop.createAgent(ctx, {
|
||||
agentId: AgentId('factory-inactive'),
|
||||
sessionId: SessionId('factory-inactive-s'),
|
||||
})).rejects.toThrow('agent loop is not active')
|
||||
await ctx.fiber.dispose()
|
||||
@@ -585,7 +584,6 @@ describe('agent scope lifecycle', () => {
|
||||
let creating!: ReturnType<typeof ctx.agents.create>
|
||||
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
creating = inner.agents.create({
|
||||
agentId: AgentId('dependency-origin'),
|
||||
sessionId: SessionId('dependency-origin-s'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
setup: (agentCtx) => {
|
||||
@@ -625,7 +623,7 @@ describe('agent scope lifecycle', () => {
|
||||
})
|
||||
ctx.on('session/created', (session) => {
|
||||
if (session.id !== SessionId('session-created-barrier-s')) return
|
||||
const agent = ctx.agents.get(AgentId('session-created-barrier'))!
|
||||
const agent = ctx.agents.get(SessionId('session-created-barrier-s'))!
|
||||
expect(ctx.sessions.get(session.id)).toBe(session)
|
||||
expect(agent.session).toBe(session)
|
||||
agent.ctx.effect(() => () => { lifecycle.push('scope-disposed') })
|
||||
@@ -640,7 +638,6 @@ describe('agent scope lifecycle', () => {
|
||||
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
ownerCtx = inner
|
||||
creating = inner.agents.create({
|
||||
agentId: AgentId('session-created-barrier'),
|
||||
sessionId: SessionId('session-created-barrier-s'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
@@ -654,7 +651,7 @@ describe('agent scope lifecycle', () => {
|
||||
'session-disposed',
|
||||
'scope-disposed',
|
||||
])
|
||||
expect(ctx.agents.get(AgentId('session-created-barrier'))).toBeUndefined()
|
||||
expect(ctx.agents.get(SessionId('session-created-barrier-s'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('session-created-barrier-s'))).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
@@ -668,19 +665,19 @@ describe('agent scope lifecycle', () => {
|
||||
if (session.id === SessionId('agent-created-barrier-s')) lifecycle.push('session-created')
|
||||
})
|
||||
ctx.on('agent/created', (agent) => {
|
||||
if (agent.id !== AgentId('agent-created-barrier')) return
|
||||
if (agent.id !== SessionId('agent-created-barrier-s')) return
|
||||
lifecycle.push('agent-created:dispose')
|
||||
disposeCurrentLifecycle(ownerCtx)
|
||||
})
|
||||
ctx.on('agent/created', (agent) => {
|
||||
if (agent.id !== AgentId('agent-created-barrier')) return
|
||||
if (agent.id !== SessionId('agent-created-barrier-s')) return
|
||||
expect(ctx.agents.get(agent.id)).toBe(agent)
|
||||
expect(ctx.sessions.get(agent.session.id)).toBe(agent.session)
|
||||
agent.ctx.effect(() => () => { lifecycle.push('scope-disposed') })
|
||||
lifecycle.push('agent-created:observer')
|
||||
})
|
||||
ctx.on('agent/disposed', (agent) => {
|
||||
if (agent.id === AgentId('agent-created-barrier')) lifecycle.push('agent-disposed')
|
||||
if (agent.id === SessionId('agent-created-barrier-s')) lifecycle.push('agent-disposed')
|
||||
})
|
||||
ctx.on('session/disposed', (session) => {
|
||||
if (session.id === SessionId('agent-created-barrier-s')) lifecycle.push('session-disposed')
|
||||
@@ -689,7 +686,6 @@ describe('agent scope lifecycle', () => {
|
||||
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
ownerCtx = inner
|
||||
creating = inner.agents.create({
|
||||
agentId: AgentId('agent-created-barrier'),
|
||||
sessionId: SessionId('agent-created-barrier-s'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
@@ -705,7 +701,7 @@ describe('agent scope lifecycle', () => {
|
||||
'session-disposed',
|
||||
'scope-disposed',
|
||||
])
|
||||
expect(ctx.agents.get(AgentId('agent-created-barrier'))).toBeUndefined()
|
||||
expect(ctx.agents.get(SessionId('agent-created-barrier-s'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('agent-created-barrier-s'))).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
@@ -717,13 +713,12 @@ describe('agent scope lifecycle', () => {
|
||||
let creating!: ReturnType<typeof ctx.agents.create>
|
||||
ctx.on('agent/session-start', agent => void starts.push(agent.id))
|
||||
ctx.on('agent/created', (agent) => {
|
||||
if (agent.id === AgentId('listener-dispose')) void ownerCtx.fiber.dispose()
|
||||
if (agent.id === SessionId('listener-dispose-s')) void ownerCtx.fiber.dispose()
|
||||
})
|
||||
|
||||
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
ownerCtx = inner
|
||||
creating = inner.agents.create({
|
||||
agentId: AgentId('listener-dispose'),
|
||||
sessionId: SessionId('listener-dispose-s'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
@@ -732,7 +727,7 @@ describe('agent scope lifecycle', () => {
|
||||
await expect(creating).rejects.toThrow(/owner disposed during setup/)
|
||||
await owner.dispose()
|
||||
expect(starts).toEqual([])
|
||||
expect(ctx.agents.get(AgentId('listener-dispose'))).toBeUndefined()
|
||||
expect(ctx.agents.get(SessionId('listener-dispose-s'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('listener-dispose-s'))).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
@@ -741,20 +736,20 @@ 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
|
||||
ctx.on('agent/status', (agent, status) => {
|
||||
if (agent.id === AgentId('session-start-dispose')) statuses.push(status)
|
||||
if (agent.id === SessionId('session-start-dispose-s')) statuses.push(status)
|
||||
})
|
||||
ctx.on('agent/session-start', (agent) => {
|
||||
if (agent.id !== AgentId('session-start-dispose')) return
|
||||
announced = agent as ReactLoopAgent
|
||||
if (agent.id !== SessionId('session-start-dispose-s')) return
|
||||
announced = agent
|
||||
disposeCurrentLifecycle(ownerCtx)
|
||||
})
|
||||
ctx.on('agent/session-start', (agent) => {
|
||||
if (agent.id !== AgentId('session-start-dispose')) return
|
||||
if (agent.id !== SessionId('session-start-dispose-s')) return
|
||||
expect(ctx.agents.get(agent.id)).toBe(agent)
|
||||
expect(ctx.sessions.get(agent.session.id)).toBe(agent.session)
|
||||
agent.ctx.effect(() => () => { scopeDisposed = true })
|
||||
@@ -764,7 +759,6 @@ describe('agent scope lifecycle', () => {
|
||||
const owner = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
ownerCtx = inner
|
||||
creating = inner.agents.create({
|
||||
agentId: AgentId('session-start-dispose'),
|
||||
sessionId: SessionId('session-start-dispose-s'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
@@ -777,7 +771,7 @@ describe('agent scope lifecycle', () => {
|
||||
expect(observerSawLive).toBe(true)
|
||||
expect(scopeDisposed).toBe(true)
|
||||
expect(announced.session.events).toEqual([])
|
||||
expect(ctx.agents.get(AgentId('session-start-dispose'))).toBeUndefined()
|
||||
expect(ctx.agents.get(SessionId('session-start-dispose-s'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('session-start-dispose-s'))).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
@@ -789,7 +783,6 @@ describe('agent scope lifecycle', () => {
|
||||
ctx.on('agent/created', () => void published.push('agent/created'))
|
||||
ctx.on('agent/session-start', () => void published.push('agent/session-start'))
|
||||
await expect(ctx.agents.create({
|
||||
agentId: AgentId('bad'),
|
||||
sessionId: SessionId('bad-s'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
setup: async () => {
|
||||
@@ -800,13 +793,13 @@ describe('agent scope lifecycle', () => {
|
||||
|
||||
// Nothing leaked: no agent, no session, and the ids are reusable.
|
||||
expect(published).toEqual([])
|
||||
expect(ctx.agents.get(AgentId('bad'))).toBeUndefined()
|
||||
expect(ctx.agents.get(SessionId('bad-s'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('bad-s'))).toBeUndefined()
|
||||
const retry = await ctx.agents.create({ agentId: AgentId('bad'), sessionId: SessionId('bad-s'), agentOptions: { provider: 'mock', model: 'mock' } })
|
||||
const retry = await ctx.agents.create({ sessionId: SessionId('bad-s'), agentOptions: { provider: 'mock', model: 'mock' } })
|
||||
await retry.dispose()
|
||||
})
|
||||
|
||||
it('rejects an exotic durable seed before publishing either identity', async () => {
|
||||
it('rejects an exotic durable seed before publishing either object', async () => {
|
||||
const ctx = await harness()
|
||||
const published: string[] = []
|
||||
ctx.on('session/created', () => { published.push('session') })
|
||||
@@ -819,17 +812,15 @@ describe('agent scope lifecycle', () => {
|
||||
}] as unknown as SessionEvent[]
|
||||
|
||||
await expect(ctx.agents.create({
|
||||
agentId: AgentId('exotic-seed'),
|
||||
sessionId: SessionId('exotic-seed-session'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
seed,
|
||||
})).rejects.toThrow(/seed event at index 0 is not losslessly JSON-serializable/)
|
||||
|
||||
expect(published).toEqual([])
|
||||
expect(ctx.agents.get(AgentId('exotic-seed'))).toBeUndefined()
|
||||
expect(ctx.agents.get(SessionId('exotic-seed-session'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('exotic-seed-session'))).toBeUndefined()
|
||||
const retry = await ctx.agents.create({
|
||||
agentId: AgentId('exotic-seed'),
|
||||
sessionId: SessionId('exotic-seed-session'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
@@ -845,13 +836,13 @@ describe('agent scope lifecycle', () => {
|
||||
if (boom) { boom = false; throw new Error('boom created') }
|
||||
})
|
||||
await expect(ctx.agents.create({
|
||||
agentId: AgentId('bad'), sessionId: SessionId('bad-s'), agentOptions: { provider: 'mock', model: 'mock' },
|
||||
sessionId: SessionId('bad-s'), agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})).rejects.toThrow('boom created')
|
||||
expect(ctx.agents.get(AgentId('bad'))).toBeUndefined()
|
||||
expect(ctx.agents.get(SessionId('bad-s'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('bad-s'))).toBeUndefined()
|
||||
expect(disposed).toEqual([]) // inserted but never announced: no impossible disposed edge
|
||||
// The rollback also disposed the scope fiber: re-creating works cleanly.
|
||||
const retry = await ctx.agents.create({ agentId: AgentId('bad'), sessionId: SessionId('bad-s'), agentOptions: { provider: 'mock', model: 'mock' } })
|
||||
const retry = await ctx.agents.create({ sessionId: SessionId('bad-s'), agentOptions: { provider: 'mock', model: 'mock' } })
|
||||
expect(scopeOf(retry.agent.ctx)).toBe(retry.agent)
|
||||
await retry.dispose()
|
||||
})
|
||||
@@ -868,18 +859,17 @@ describe('agent scope lifecycle', () => {
|
||||
ctx.on('agent/disposed', (agent) => { lifecycle.push(`agent-disposed:${agent.id}`) })
|
||||
|
||||
await expect(ctx.agents.create({
|
||||
agentId: AgentId('partial-agent'),
|
||||
sessionId: SessionId('partial-session'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})).rejects.toThrow('agent observer failed')
|
||||
|
||||
expect(lifecycle).toEqual([
|
||||
'session-created:partial-session',
|
||||
'agent-created:partial-agent',
|
||||
'agent-disposed:partial-agent',
|
||||
'agent-created:partial-session',
|
||||
'agent-disposed:partial-session',
|
||||
'session-disposed:partial-session',
|
||||
])
|
||||
expect(ctx.agents.get(AgentId('partial-agent'))).toBeUndefined()
|
||||
expect(ctx.agents.get(SessionId('partial-session'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('partial-session'))).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -894,23 +884,23 @@ describe('agent scope lifecycle', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(() => ctx.agentLoop.create(AgentId('config-bad'), { provider: 'mock', model: 'mock' }))
|
||||
expect(() => ctx.agentLoop.create(SessionId('config-bad'), { provider: 'mock', model: 'mock' }))
|
||||
.toThrow('config publish failed')
|
||||
expect(ctx.agents.get(AgentId('config-bad'))).toBeUndefined()
|
||||
expect(ctx.agents.get(SessionId('config-bad'))).toBeUndefined()
|
||||
expect(ctx.sessions.list()).toHaveLength(sessionsBefore)
|
||||
})
|
||||
|
||||
it('registrations through a disposed agent ctx throw INACTIVE_EFFECT', async () => {
|
||||
const ctx = await harness()
|
||||
const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), agentOptions: { provider: 'mock', model: 'mock' } })
|
||||
const handle = await ctx.agents.create({ sessionId: SessionId('s1'), agentOptions: { provider: 'mock', model: 'mock' } })
|
||||
await handle.dispose()
|
||||
expect(() => handle.agent.ctx.on('agent/status', () => {})).toThrow(/inactive context/)
|
||||
})
|
||||
|
||||
it('agentEvents fuses carrier and subject for custom drivers', async () => {
|
||||
const ctx = await harness()
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const other = ctx.agentLoop.create(AgentId('a2'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const other = ctx.agentLoop.create(SessionId('a2'), { provider: 'mock', model: 'mock' })
|
||||
const heard: string[] = []
|
||||
agent.ctx.on('agent/error', (subject: Agent, turn: number) => void heard.push(`${subject.id}:${turn}`))
|
||||
|
||||
@@ -923,7 +913,7 @@ describe('agent scope lifecycle', () => {
|
||||
const ctx = await harness()
|
||||
let handle!: Awaited<ReturnType<typeof ctx.agents.create>>
|
||||
const owner = await ctx.plugin(Object.assign(async (inner: Context) => {
|
||||
handle = await inner.agents.create({ agentId: AgentId('o1'), sessionId: SessionId('o1-s'), agentOptions: { provider: 'mock', model: 'mock' } })
|
||||
handle = await inner.agents.create({ sessionId: SessionId('o1-s'), agentOptions: { provider: 'mock', model: 'mock' } })
|
||||
}, { inject: ['agents'] }))
|
||||
const { agent } = handle
|
||||
|
||||
@@ -932,7 +922,7 @@ describe('agent scope lifecycle', () => {
|
||||
if (event.type === 'turn/end') order.push('turn-end')
|
||||
})
|
||||
ctx.on('agent/disposed', () => {
|
||||
order.push(`disposed(listed=${ctx.agents.get(AgentId('o1')) !== undefined})`)
|
||||
order.push(`disposed(listed=${ctx.agents.get(SessionId('o1-s')) !== undefined})`)
|
||||
order.push(`session-still-stored=${ctx.sessions.get(SessionId('o1-s')) !== undefined}`)
|
||||
})
|
||||
|
||||
@@ -955,7 +945,7 @@ describe('agent scope lifecycle', () => {
|
||||
const ctx = await harness()
|
||||
let handle!: Awaited<ReturnType<typeof ctx.agents.create>>
|
||||
const owner = await ctx.plugin(Object.assign(async (inner: Context) => {
|
||||
handle = await inner.agents.create({ agentId: AgentId('h1'), sessionId: SessionId('h1-s'), agentOptions: { provider: 'mock', model: 'mock' } })
|
||||
handle = await inner.agents.create({ sessionId: SessionId('h1-s'), agentOptions: { provider: 'mock', model: 'mock' } })
|
||||
}, { inject: ['agents'] }))
|
||||
|
||||
const teardownDone: string[] = []
|
||||
@@ -967,23 +957,22 @@ describe('agent scope lifecycle', () => {
|
||||
// actually finished (the raw wrapper returns undefined on a repeat call).
|
||||
await handle.dispose()
|
||||
expect(teardownDone).toContain('unregistered')
|
||||
expect(ctx.agents.get(AgentId('h1'))).toBeUndefined()
|
||||
expect(ctx.agents.get(SessionId('h1-s'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('h1-s'))).toBeUndefined()
|
||||
await unload
|
||||
})
|
||||
|
||||
it('successful handle disposal retires its caller ownership effect', async () => {
|
||||
const ctx = await harness()
|
||||
const agentId = AgentId('retired-owner-effect')
|
||||
const sessionId = SessionId('retired-owner-effect')
|
||||
const handle = await ctx.agents.create({
|
||||
agentId,
|
||||
sessionId: SessionId('retired-owner-effect-s'),
|
||||
sessionId,
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
|
||||
expect(ctx.fiber.getEffects().map(effect => effect.label)).toContain(`agentLoop.owner(${agentId})`)
|
||||
expect(ctx.fiber.getEffects().map(effect => effect.label)).toContain(`agentLoop.owner(${sessionId})`)
|
||||
await handle.dispose()
|
||||
expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.owner(${agentId})`)).toEqual([])
|
||||
expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.owner(${sessionId})`)).toEqual([])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -994,7 +983,6 @@ describe('agent scope lifecycle', () => {
|
||||
let handle!: Awaited<ReturnType<typeof ctx.agents.create>>
|
||||
const owner = await ctx.plugin(Object.assign(async (inner: Context) => {
|
||||
handle = await inner.agents.create({
|
||||
agentId: AgentId('manual-first'),
|
||||
sessionId: SessionId('manual-first-s'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
setup(agentCtx) {
|
||||
@@ -1014,7 +1002,7 @@ describe('agent scope lifecycle', () => {
|
||||
expect(ownerSettled).toBe(false)
|
||||
gate.resolve(undefined)
|
||||
await Promise.all([disposing, unloading])
|
||||
expect(ctx.agents.get(AgentId('manual-first'))).toBeUndefined()
|
||||
expect(ctx.agents.get(SessionId('manual-first-s'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('manual-first-s'))).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
@@ -1024,13 +1012,11 @@ describe('agent scope lifecycle', () => {
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
const cleanupStarted = Promise.withResolvers<undefined>()
|
||||
const sessionDisposed = Promise.withResolvers<undefined>()
|
||||
const agentId = AgentId('quiescent-reuse')
|
||||
const sessionId = SessionId('quiescent-reuse-s')
|
||||
const sessionId = SessionId('quiescent-reuse')
|
||||
ctx.on('session/disposed', (session) => {
|
||||
if (session.id === sessionId) sessionDisposed.resolve(undefined)
|
||||
})
|
||||
const first = await ctx.agents.create({
|
||||
agentId,
|
||||
sessionId,
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
setup(agentCtx) {
|
||||
@@ -1043,10 +1029,10 @@ describe('agent scope lifecycle', () => {
|
||||
|
||||
const disposing = first.dispose()
|
||||
await Promise.all([sessionDisposed.promise, cleanupStarted.promise])
|
||||
expect(ctx.agents.get(agentId)).toBeUndefined()
|
||||
expect(ctx.agents.get(sessionId)).toBeUndefined()
|
||||
expect(ctx.sessions.get(sessionId)).toBeUndefined()
|
||||
const replacement = await ctx.agents.create({ agentId, sessionId, agentOptions: { provider: 'mock', model: 'mock' } })
|
||||
expect(ctx.agents.get(agentId)).toBe(replacement.agent)
|
||||
const replacement = await ctx.agents.create({ sessionId, agentOptions: { provider: 'mock', model: 'mock' } })
|
||||
expect(ctx.agents.get(sessionId)).toBe(replacement.agent)
|
||||
expect(ctx.sessions.get(sessionId)).toBe(replacement.agent.session)
|
||||
|
||||
gate.resolve(undefined)
|
||||
@@ -1058,7 +1044,6 @@ describe('agent scope lifecycle', () => {
|
||||
it('handle.dispose() awaits an idle-injection flush before unregistering or detaching', async () => {
|
||||
const ctx = await harness()
|
||||
const handle = await ctx.agents.create({
|
||||
agentId: AgentId('idle-flush'),
|
||||
sessionId: SessionId('idle-flush-s'),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
@@ -1077,12 +1062,12 @@ describe('agent scope lifecycle', () => {
|
||||
const disposal = handle.dispose().then(() => { disposed = true })
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
expect(disposed).toBe(false)
|
||||
expect(ctx.agents.get(AgentId('idle-flush'))).toBe(handle.agent)
|
||||
expect(ctx.agents.get(SessionId('idle-flush-s'))).toBe(handle.agent)
|
||||
expect(ctx.sessions.get(SessionId('idle-flush-s'))).toBe(handle.agent.session)
|
||||
|
||||
gate.resolve(undefined)
|
||||
await disposal
|
||||
expect(ctx.agents.get(AgentId('idle-flush'))).toBeUndefined()
|
||||
expect(ctx.agents.get(SessionId('idle-flush-s'))).toBeUndefined()
|
||||
expect(ctx.sessions.get(SessionId('idle-flush-s'))).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
/**
|
||||
* Exercises scheduler ordering and cancellation with deterministic gated tools.
|
||||
* ACP goldens own transcript-facing coverage.
|
||||
* ACP expected outputs own transcript-facing coverage.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
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, { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution'
|
||||
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) {
|
||||
@@ -22,7 +21,6 @@ async function harness(adapter: MockAdapter, maxParallelToolCalls?: number) {
|
||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentExecutionProvider)
|
||||
await ctx.plugin(AgentLoop, {
|
||||
agents: [],
|
||||
...maxParallelToolCalls === undefined ? {} : { maxParallelToolCalls },
|
||||
@@ -31,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() }
|
||||
@@ -39,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]
|
||||
}
|
||||
|
||||
@@ -105,7 +103,7 @@ describe('tool-call scheduler: grouping and barriers', () => {
|
||||
const ctx = await harness(adapter)
|
||||
const gated = gatedParallelTool('p')
|
||||
ctx.tools.register(gated.tool)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await until(() => gated.started.length === 3)
|
||||
@@ -134,7 +132,7 @@ describe('tool-call scheduler: grouping and barriers', () => {
|
||||
name: 'w', description: 'write', parameters: { id: { type: 'string', required: true } },
|
||||
async execute(args) { order.push(`w-${args.id}`); return [{ type: 'text', text: 'w' }] },
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
@@ -169,7 +167,7 @@ describe('tool-call scheduler: grouping and barriers', () => {
|
||||
return [{ type: 'text', text: 'replaced' }]
|
||||
},
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await until(() => replacement.started.length === 1)
|
||||
@@ -200,7 +198,7 @@ describe('tool-call scheduler: grouping and barriers', () => {
|
||||
disposeInitial()
|
||||
ctx.tools.register(replacement.tool)
|
||||
})
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await until(() => initial.started.length === 2)
|
||||
@@ -226,7 +224,7 @@ describe('tool-call scheduler: model-order results despite out-of-order settleme
|
||||
const ctx = await harness(adapter)
|
||||
const gated = gatedParallelTool('p')
|
||||
ctx.tools.register(gated.tool)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await until(() => gated.started.length === 2)
|
||||
@@ -249,7 +247,7 @@ describe('tool-call scheduler: model-order results despite out-of-order settleme
|
||||
const ctx = await harness(adapter)
|
||||
const gated = gatedParallelTool('p')
|
||||
ctx.tools.register(gated.tool)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await until(() => gated.started.length === 2)
|
||||
gated.release('2'); gated.release('1')
|
||||
@@ -294,7 +292,7 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () =>
|
||||
const ctx = await harness(adapter, 2)
|
||||
const gated = gatedParallelTool('p')
|
||||
ctx.tools.register(gated.tool)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await until(() => gated.started.length === 2)
|
||||
@@ -324,7 +322,7 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () =>
|
||||
const ctx = await harness(adapter, 1)
|
||||
const gated = gatedParallelTool('p')
|
||||
ctx.tools.register(gated.tool)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await until(() => gated.started.length === 1)
|
||||
await new Promise(r => setTimeout(r, 5))
|
||||
@@ -346,12 +344,11 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () =>
|
||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentExecutionProvider)
|
||||
await ctx.plugin(AgentLoop, { agents: [], maxParallelToolCalls: 1 })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const gated = gatedParallelTool('p')
|
||||
ctx.tools.register(gated.tool)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await until(() => gated.started.length === 1)
|
||||
await new Promise(r => setTimeout(r, 5))
|
||||
@@ -377,7 +374,7 @@ describe('tool-call scheduler: ordered middleware and additional contexts', () =
|
||||
const post: string[] = []
|
||||
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => { pre.push(String(exec.callId)); return next() })
|
||||
ctx.on('tools/post-execute', async (exec, _result, next): Promise<PostToolDecision> => { post.push(String(exec.callId)); return next() })
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await until(() => gated.started.length === 3)
|
||||
@@ -398,7 +395,7 @@ describe('tool-call scheduler: ordered middleware and additional contexts', () =
|
||||
ctx.tools.register(gated.tool)
|
||||
ctx.on('tools/post-execute', async (exec, _result): Promise<PostToolDecision> =>
|
||||
({ kind: 'accept', additionalContexts: [{ content: [{ type: 'text', text: `ctx-${exec.callId}` }], source: { kind: 'plugin', plugin: 'p' } }] }))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await until(() => gated.started.length === 2)
|
||||
@@ -436,7 +433,7 @@ describe('tool-call scheduler: ordered middleware and additional contexts', () =
|
||||
post.push(String(exec.callId))
|
||||
return next()
|
||||
})
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await until(() => gated.started.length === 1)
|
||||
@@ -461,7 +458,7 @@ describe('tool-call scheduler: abort handling', () => {
|
||||
const ctx = await harness(adapter)
|
||||
const gated = gatedParallelTool('p')
|
||||
ctx.tools.register(gated.tool)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (session === agent.session && event.type === 'assistant/message') {
|
||||
agent.cancel({ kind: 'user' })
|
||||
@@ -472,8 +469,16 @@ describe('tool-call scheduler: abort handling', () => {
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(gated.started).toEqual([])
|
||||
expect(events(agent).filter(e => e.type === 'tool/call')).toEqual([])
|
||||
expect(events(agent).filter(e => e.type === 'tool/result')).toEqual([])
|
||||
expect(events(agent).filter(e => e.type === 'tool/call').map(e => e.data.callId))
|
||||
.toEqual([CallId('c1'), CallId('c2')])
|
||||
expect(events(agent).filter(e => e.type === 'tool/result').map(e => ({
|
||||
callId: e.data.callId,
|
||||
isError: e.data.isError,
|
||||
error: e.data.error,
|
||||
}))).toEqual([
|
||||
{ callId: CallId('c1'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } },
|
||||
{ callId: CallId('c2'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } },
|
||||
])
|
||||
})
|
||||
|
||||
it('stops starting siblings when abort fires during ordered pre-execute', async () => {
|
||||
@@ -484,7 +489,7 @@ describe('tool-call scheduler: abort handling', () => {
|
||||
const ctx = await harness(adapter)
|
||||
const gated = gatedParallelTool('p')
|
||||
ctx.tools.register(gated.tool)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
|
||||
if (exec.callId === CallId('c1')) {
|
||||
agent.cancel({ kind: 'user' })
|
||||
@@ -500,9 +505,11 @@ describe('tool-call scheduler: abort handling', () => {
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(events(agent).filter(e => e.type === 'tool/call').map(e => e.data.callId))
|
||||
.toEqual([CallId('c1')])
|
||||
.toEqual([CallId('c1'), CallId('c2')])
|
||||
expect(events(agent).filter(e => e.type === 'tool/result').map(e => e.data.callId))
|
||||
.toEqual([CallId('c1')])
|
||||
.toEqual([CallId('c1'), CallId('c2')])
|
||||
expect(events(agent).filter(e => e.type === 'tool/result').at(-1)?.data)
|
||||
.toMatchObject({ callId: CallId('c2'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } })
|
||||
})
|
||||
|
||||
it('stops replenishing after abort, commits started results, and drains accepted additional contexts', async () => {
|
||||
@@ -517,7 +524,7 @@ describe('tool-call scheduler: abort handling', () => {
|
||||
...await next(),
|
||||
additionalContexts: [{ content: [{ type: 'text', text: `ctx-${exec.callId}` }], source: { kind: 'plugin', plugin: 'p' } }],
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await until(() => gated.started.length === 2)
|
||||
@@ -528,12 +535,17 @@ describe('tool-call scheduler: abort handling', () => {
|
||||
|
||||
expect(gated.started).toEqual(['1', '2'])
|
||||
expect(events(agent).filter(e => e.type === 'tool/call').map(e => e.data.callId))
|
||||
.toEqual([CallId('c1'), CallId('c2')])
|
||||
.toEqual([CallId('c1'), CallId('c2'), CallId('c3'), CallId('c4')])
|
||||
expect(events(agent).filter(e => e.type === 'tool/result').map(e => e.data.callId))
|
||||
.toEqual([CallId('c1'), CallId('c2')])
|
||||
.toEqual([CallId('c1'), CallId('c2'), CallId('c3'), CallId('c4')])
|
||||
expect(events(agent).filter(e => e.type === 'tool/result').slice(-2).map(e => e.data))
|
||||
.toEqual([
|
||||
expect.objectContaining({ callId: CallId('c3'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } }),
|
||||
expect.objectContaining({ callId: CallId('c4'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } }),
|
||||
])
|
||||
const settled = events(agent).filter(e => e.type === 'tool/result' || e.type === 'context/message')
|
||||
expect(settled.map(e => e.type))
|
||||
.toEqual(['tool/result', 'tool/result', 'context/message', 'context/message'])
|
||||
.toEqual(['tool/result', 'tool/result', 'tool/result', 'tool/result', 'context/message', 'context/message'])
|
||||
expect(settled.filter(e => e.type === 'context/message')
|
||||
.map(e => (e.data.content[0] as { text: string }).text))
|
||||
.toEqual(['ctx-c1', 'ctx-c2'])
|
||||
@@ -558,7 +570,7 @@ describe('tool-call scheduler: abort handling', () => {
|
||||
parameters: { id: { type: 'string', required: true } },
|
||||
async execute(args) { exclusive.push(args.id); return [{ type: 'text', text: 'x' }] },
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await until(() => gated.started.length === 2)
|
||||
@@ -569,6 +581,8 @@ describe('tool-call scheduler: abort handling', () => {
|
||||
|
||||
expect(exclusive).toEqual([])
|
||||
expect(events(agent).filter(e => e.type === 'tool/call').map(e => e.data.callId))
|
||||
.toEqual([CallId('c1'), CallId('c2')])
|
||||
.toEqual([CallId('c1'), CallId('c2'), CallId('c3')])
|
||||
expect(events(agent).filter(e => e.type === 'tool/result').at(-1)?.data)
|
||||
.toMatchObject({ callId: CallId('c3'), isError: true, error: { name: 'AbortError', code: 'ABORTED' } })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,13 +9,13 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { foldRequestHeader } from '@deepseek-ai/dsh-session'
|
||||
import SessionStore, { SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session'
|
||||
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, { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution'
|
||||
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, toolOrder?: SystemPromptConfig['toolOrder']) {
|
||||
@@ -25,13 +25,12 @@ async function harness(adapter: MockAdapter, toolOrder?: SystemPromptConfig['too
|
||||
await ctx.plugin(SystemPrompt, { persona: 'stable base', ...toolOrder !== undefined ? { toolOrder } : {} })
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentExecutionProvider)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
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 dispose = ctx.on('agent/status', (subject, status) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
@@ -58,7 +57,7 @@ async function runTurn(registrationOrder: string[], toolOrder?: SystemPromptConf
|
||||
const adapter = new MockAdapter([textResponse('done')])
|
||||
const ctx = await harness(adapter, toolOrder)
|
||||
for (const name of registrationOrder) registerNamed(ctx, name)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
return { ctx, agent, adapter }
|
||||
@@ -100,7 +99,7 @@ describe('loop-level canonical tool order', () => {
|
||||
registerNamed(ctx, 'alpha')
|
||||
const errors: Error[] = []
|
||||
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import SessionStore, { SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, { AgentId, type ContinuationStop } from '@deepseek-ai/dsh-agent'
|
||||
import AgentExecutionProvider from '@deepseek-ai/dsh-agent-execution'
|
||||
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
import AgentRegistry, { type Agent, type ContinuationStop } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
|
||||
|
||||
@@ -18,13 +18,12 @@ async function harness(adapter: MockAdapter): Promise<Context> {
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(Invariants)
|
||||
await ctx.plugin(AgentExecutionProvider)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
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()
|
||||
}
|
||||
@@ -47,7 +46,7 @@ describe('agent/turn-stop', () => {
|
||||
textResponse('must not be requested'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('terminal-steering'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('terminal-steering'), { provider: 'mock', model: 'mock' })
|
||||
agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
|
||||
|
||||
let steered = false
|
||||
@@ -74,7 +73,7 @@ describe('agent/turn-stop', () => {
|
||||
textResponse('must not become a late-steering turn'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('terminal-flush-steering'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('terminal-flush-steering'), { provider: 'mock', model: 'mock' })
|
||||
agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
|
||||
|
||||
let injected = false
|
||||
@@ -100,7 +99,7 @@ describe('agent/turn-stop', () => {
|
||||
textResponse('queued follow-up answer'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('terminal-flush-send'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('terminal-flush-send'), { provider: 'mock', model: 'mock' })
|
||||
agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
|
||||
|
||||
let queued = false
|
||||
@@ -126,8 +125,8 @@ describe('agent/turn-stop', () => {
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
registerEcho(ctx)
|
||||
const stopped = ctx.agentLoop.create(AgentId('stopped'), { provider: 'mock', model: 'mock' })
|
||||
const ordinary = ctx.agentLoop.create(AgentId('ordinary'), { provider: 'mock', model: 'mock' })
|
||||
const stopped = ctx.agentLoop.create(SessionId('stopped'), { provider: 'mock', model: 'mock' })
|
||||
const ordinary = ctx.agentLoop.create(SessionId('ordinary'), { provider: 'mock', model: 'mock' })
|
||||
stopped.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
|
||||
|
||||
await send(stopped)
|
||||
@@ -147,7 +146,7 @@ describe('agent/turn-stop', () => {
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
registerEcho(ctx)
|
||||
const agent = ctx.agentLoop.create(AgentId('owned-listener'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('owned-listener'), { provider: 'mock', model: 'mock' })
|
||||
const disposeStop = agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' }))
|
||||
|
||||
await send(agent, 'first turn')
|
||||
@@ -164,7 +163,7 @@ describe('agent/turn-stop', () => {
|
||||
textResponse('healthy later turn'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('bad-policy'), { provider: 'mock', model: 'mock' })
|
||||
const agent = ctx.agentLoop.create(SessionId('bad-policy'), { provider: 'mock', model: 'mock' })
|
||||
const reasons: TurnEndReason[] = []
|
||||
const errors: string[] = []
|
||||
ctx.on('session/event', (session, event) => {
|
||||
|
||||
@@ -35,9 +35,6 @@
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent-execution"
|
||||
},
|
||||
{
|
||||
"path": "../../core/scope"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user