Merge remote-tracking branch 'origin/master' into xjt/generated-docs-zh-translation-apply

# Conflicts:
#	.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.i18n.yaml
#	.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.zh.md
#	docs/architecture.i18n.yaml
#	docs/architecture.zh.md
#	docs/user/guide/providers.i18n.yaml
#	docs/user/guide/providers.zh.md
#	docs/user/guide/quickstart.i18n.yaml
#	docs/user/guide/quickstart.zh.md
#	packages/boot/app-boot/README.i18n.yaml
#	packages/boot/app-boot/README.zh.md
#	packages/bundle/headless/README.i18n.yaml
#	packages/bundle/headless/README.zh.md
This commit is contained in:
xjt
2026-08-09 15:17:35 +08:00
169 changed files with 1618 additions and 1086 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/bundle/headless/README.md
README.md: 661b377817482d22f58f22b573075722646729a2
README.zh.md: 8d3ed1c0f99b9245d27c68887aa7b2301368a17b
README.md: fbdf80be3d587b0ef526c1a36952a761035f7312
README.zh.md: fa1b5ac280eb9020d71035cc6b09ab2508bff45d

View File

@@ -2,11 +2,13 @@
English | [中文](README.zh.md)
The dsh one-shot bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md) + [`dsh-web-app`](../web-app/README.md): it moves the webserver to an OS-assigned port (parallel runs never collide), silences the URL line, and inserts this package's `headless-runner` plugin (config `{task}`). The runner drives one task turn through the in-process API carrier (`InProcessApiClient` over `toFetchHandler(ctx.apiProxy)`, so the full wire chain — serialization, zod, SSE framing — really runs), waits at idle until that mux has consumed the session's final event sequence, aggregates the turn's final assistant text, writes it to stdout, and requests exit (completed → 0, else 1) through the launcher-provided `ctx.headlessIo` seam. The Web composition stays mounted, so the running session is observable in a browser at the stderr-announced URL. The launcher patches the task text in (`dsh run "task"`), and fails loud when the selected profile lacks this row.
The dsh one-shot bundle. [`cordis.patch.yml`](cordis.patch.yml) rides directly over [`dsh-base`](../base/README.md): it supplies the coding persona and tool mode, disables HMR, mounts Code Mode's worker as a core execution capability, and inserts this package's `headless-runner` plugin (config `{task}`). It mounts no Host, HTTP server, Web runtime, or browser plugin.
After the Loader settles, the runner reads the shared [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md), creates one fresh persisted Agent through `ctx.agents`, submits the task as an ordinary user message, and waits for quiescence. It flushes the Session before folding the owned durable event interval, writes the last non-empty assistant text to stdout, and requests exit through the launcher-provided `ctx.headlessIo` seam (final `turn/end` completed → 0, otherwise 1). A terminal `error` reason also writes its code and message to stderr; successful runs keep stderr empty. The process opens no listening port. The launcher patches the task text in (`dsh run "task"`) and fails loud when the selected profile lacks this row.
## Model Experience
None, as the runner submits the task as an ordinary user message over the shared composition; prompts and tools belong to the base/web bundles.
None, as the runner submits the task as an ordinary user message; prompts and tools belong to the base and headless bundle rows.
#### KV Cache effect
@@ -14,5 +16,5 @@ None; the runner adds nothing to the request prefix.
## Known Limitations and Deferred Work
- **One turn only** — the runner anchors on the first message-triggered turn and exits at its end; queued follow-ups and multi-turn tasks are out of scope.
- **One submitted task only** — the runner has no interactive follow-up surface; it waits through any work the Agent completes before returning to idle and prints the last non-empty assistant message in that interval.
- **`ctx.headlessIo` is launcher-owned** — booting the headless profile outside the `dsh` launcher fails loud at activation until the host provides the seam.

View File

@@ -2,11 +2,13 @@
[English](README.md) | 中文
dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) + [`dsh-web-app`](../web-app/README.md) 之上:把 webserver 移到 OS 分配的端口(并行运行绝不冲突),关闭 URL 行输出,并插入本包的 `headless-runner` 插件(配置为 `{task}`。runner 通过进程内 API 载体(架在 `toFetchHandler(ctx.apiProxy)` 之上的 `InProcessApiClient`因此序列化、zod、SSEServer-Sent Events帧封装这整条 wire 链路都会真实运行)驱动一个任务轮次,在 idle 时等待该 mux 消费完会话的最终事件序号,再聚合该轮次最终的 assistant 文本,写到 stdout并经启动器提供的 `ctx.headlessIo` seam 请求退出(完成 → 0否则 1。Web 组合保持挂载,因此运行中的会话可在浏览器中通过 stderr 公告的 URL 观察。启动器把任务文本 patch 进来(`dsh run "task"`);若所选 profile 缺少该行,则显式报错
dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 直接叠加在 [`dsh-base`](../base/README.md) 之上:提供编码 persona 和工具模式、禁用 HMR热模块替换、将 Code Mode 的 worker 作为核心执行能力挂载,并插入本包的 `headless-runner` 插件(配置为 `{task}`)。它不挂载任何 Host、HTTP server、Web runtime 或浏览器插件
Loader 结算后runner 读取共享的 [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md),通过 `ctx.agents` 创建一个全新的持久化 Agent智能体将任务作为普通用户消息提交并等待完全停稳。它对 Session 执行 flush 后再汇总自身持有的持久化事件区间,将最后一条非空 assistant 文本写入 stdout再经启动器提供的 `ctx.headlessIo` seam 请求退出(最终 `turn/end` 完成 → 0否则为 1。最终 reason 为 `error` 时,还会将持久化的 code 与 message 写入 stderr成功运行时 stderr 保持为空。进程不会打开监听端口。启动器把任务文本 patch 进来(`dsh run "task"`);若所选 profile 缺少该行,则显式报错。
## 模型体验
runner 把任务作为普通用户消息经共享组合提交;提示词与工具归 baseweb 组合包所有。
影响,因为 runner 把任务作为普通用户消息提交;提示词与工具归 base 和 headless 组合包中的相应行所有。
#### KV Cache 影响
@@ -14,5 +16,5 @@ dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在
## 已知限制与延期工作
- **只运行一个轮次**runner 锚定第一个由消息触发的轮次,并在其结束时退出;排队的后续消息与多轮任务不在范围内
- **只提交一个任务**runner 没有用于交互式后续输入的 surface它会等待 Agent 在返回 idle 前完成的所有工作,并打印该区间内最后一条非空 assistant 消息
- **`ctx.headlessIo` 由启动器持有**:在 `dsh` 启动器之外启动 headless profile 会在激活时明确报错,直到宿主提供该 seam。

View File

@@ -1,21 +1,26 @@
# The dsh-headless bundle patch: one-shot task mode over dsh-base +
# dsh-web-app. The web composition stays mounted (the session is observable
# in a browser while it runs); this layer silences the URL line and the
# GUI-orientation surface context (this user is not in the GUI), moves the
# webserver to an OS-assigned port so parallel headless runs never collide,
# and mounts the one-shot runner. The launcher patches the runner's `task`.
# The dsh-headless bundle patch: one-shot task mode directly over dsh-base.
# It mounts no Host, HTTP server, Web runtime, or browser plugin. The launcher
# patches the runner's `task`; the direct driver creates an Agent through the
# core registry and prints the final durable assistant message.
- id: webserver
- id: system-prompt
config:
host: 127.0.0.1
port: 0
persona: >-
You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}.
- id: web-runtime
# One-shot runs never watch or reload their composition.
- id: hmr
disabled: true
- id: tools
config:
mode: production
printUrl: false
surfaceContext: false
# Keep the same temporary process-wide Code Mode opt-in as the Web surface.
mode: !!js process.env.DSH_TOOLS_MODE
- insert:
# Code Mode is a core execution capability, not a Web component.
- id: code-runtime
name: '@deepseek-ai/dsh-code-runtime-worker'
- id: headless-runner
name: '@deepseek-ai/dsh-headless'

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-headless",
"description": "The dsh one-shot bundle: a patch layer over dsh-base + dsh-web-app plus the runner plugin driving one task turn through the in-process API carrier",
"description": "The dsh one-shot bundle: a direct core Agent/Session runner over dsh-base with no Host, HTTP, or browser layer",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -32,22 +32,23 @@
}
},
"dependencies": {
"@deepseek-ai/dsh-code-runtime-worker": "workspace:^",
"schemastery": "^3.18.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-host-apiproxy": "^0.0.1",
"@deepseek-ai/dsh-host-webserver": "^0.0.1",
"@deepseek-ai/dsh-agent-default-model": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-agent-default-model": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"cordis": "^4.0.0-rc.7"
}

View File

@@ -1,36 +1,33 @@
/**
* @deepseek-ai/dsh-headless — the one-shot headless bundle: the bundle patch
* (`cordis.patch.yml`) rides over dsh-base + dsh-web-app (the headless
* session is web-observable while it runs — same composition), and this
* runner plugin drives one task through the in-process API carrier
* (InProcessApiClient over toFetchHandler(ctx.apiProxy), so the full wire
* chain — serialization, zod, SSE framing — really runs), prints the final
* assistant text at agent quiescence, and exits (completed → 0, else 1). The
* task text arrives as launcher-patched config (`dsh run "task"`).
* @deepseek-ai/dsh-headless — one-shot direct Agent driver. The bundle patch
* rides over dsh-base without Host, HTTP, or browser plugins; this runner
* creates one Agent through the core registry, drives the task to quiescence,
* flushes its Session, prints the final assistant text, and exits.
*
* @module @deepseek-ai/dsh-headless
*/
import { randomUUID } from 'node:crypto'
import type { Context } from 'cordis'
import z from 'schemastery'
import { InProcessApiClient, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
// Empty type imports carry the httpServer and agent/status Context merges used below.
import type {} from '@deepseek-ai/dsh-host-webserver'
import type {} from '@deepseek-ai/dsh-agent'
import { installModelSelection } from '@deepseek-ai/dsh-agent'
import type { ModelSelectionRef } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-agent-default-model'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
// Empty type import carries the loader Context merge for the settlement await.
import type {} from '@cordisjs/plugin-loader'
import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import type { SessionId } from '@deepseek-ai/dsh-session'
/** Stable Cordis plugin name. */
export const name = 'headless-runner'
/** Services required before the one-shot turn can start. */
export const inject = ['apiProxy', 'httpServer']
/** Core services required before the one-shot turn can start. */
export const inject = ['agentDefaultModel', 'agents', 'sessions']
/** Plugin config: the task, patched in by the launcher. */
export interface Config {
/** The prompt text for the single turn. */
/** The prompt text for the single run. */
task: string
}
@@ -38,16 +35,15 @@ export const Config: z<Config> = z.object({
task: z.string().required(),
})
/** Outcome of one headless run: aggregated final text plus the last turn-end reason kind. */
interface TurnOutcome {
/** Outcome of one owned run interval. */
interface RunOutcome {
text: string
reason: string
reason: SessionEvent<'turn/end'>['data']['reason'] | undefined
}
/**
* The process-facing effects of one run, injectable for tests: output
* streams and the exit request (the launcher wires it to its bounded
* shutdown controller).
* Process-facing effects of one run, injectable for tests. The launcher owns
* bounded tree shutdown and wires `exit()` to it.
*/
export interface HeadlessIo {
stdout: { write(chunk: string): unknown }
@@ -56,127 +52,94 @@ export interface HeadlessIo {
exit(code: number): void
}
/** Host seam: the launcher provides the exit wiring before the tree mounts. */
declare module 'cordis' {
interface Context {
/** Process-facing effects for the one-shot headless runner. */
/** Process-facing effects provided before the headless tree mounts. */
headlessIo?: HeadlessIo
}
}
/** Unwrap an RpcResponse or fail loud: business errors print and exit 1. */
async function unwrap<T>(response: RpcResponse<T>, io: HeadlessIo): Promise<T> {
if (response.result.ok) return response.result.value
const { code, message } = response.result.error
io.stderr.write(`dsh: ${code}: ${message}\n`)
io.exit(1)
// Exit is asynchronous (bounded tree disposal); park this turn forever so
// no further request rides a session that is already being torn down.
return new Promise<never>(() => {})
}
/**
* Consume mux frames until the agent reaches idle, per the one-shot CLI
* idle-to-idle contract: the stream opens immediately before the prompt, and
* its first observed turn/start begins the task. Text is the last committed
* assistant message of the whole interval (steering or injected work may run
* further turns before quiescence), and the outcome reason is the final
* turn/end's kind. Idleness is signalled out of band by the caller's
* `agent/status` subscription; the stream itself carries no status frame.
* @param frames - the mux stream opened before the prompt.
* @param sessionId - the headless session.
* @param idle - resolves to the final session-event sequence when the agent reaches quiescence.
* @param io - process-facing effects for stream diagnostics.
* @returns the aggregated outcome.
*/
async function consumeUntilIdle(
frames: AsyncIterable<RpcRequest<MuxFrame>>,
sessionId: SessionId,
idle: Promise<number>,
io: HeadlessIo,
): Promise<TurnOutcome> {
/** Aggregate the last assistant text and turn outcome in one owned interval. */
function summarize(events: readonly SessionEvent[], firstSeq: number): RunOutcome {
let started = false
let text = ''
let reason: string = 'error'
let observedSeq = -1
let resolveProgress: (() => void) | undefined
const streamDone = (async () => {
try {
for await (const frame of frames) {
const payload = frame.payload
if (payload.type === 'stream/error') return
if (payload.type !== 'session/event' || payload.sessionId !== sessionId) continue
const event = payload.event
observedSeq = event.seq
resolveProgress?.()
resolveProgress = undefined
if (event.type === 'turn/start') {
started = true
continue
}
if (!started) continue
if (event.type === 'assistant/message') {
const joined = event.data.message.content.filter(block => block.type === 'text').map(block => block.text).join('')
if (joined !== '') text = joined
}
if (event.type === 'turn/end') reason = event.data.reason.kind
}
} catch (error: unknown) {
io.stderr.write(`dsh: event stream failed: ${String(error)}\n`)
let reason: SessionEvent<'turn/end'>['data']['reason'] | undefined
for (const event of events) {
if (event.seq < firstSeq) continue
if (event.type === 'turn/start') {
started = true
continue
}
})()
const streamEnded = streamDone.then(() => 'ended' as const)
const idleSeq = await idle
while (observedSeq < idleSeq) {
const progress = new Promise<'progress'>((resolve) => { resolveProgress = () => { resolve('progress') } })
if (await Promise.race([progress, streamEnded]) === 'ended') break
if (!started) continue
if (event.type === 'assistant/message') {
const joined = event.data.message.content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
if (joined !== '') text = joined
}
if (event.type === 'turn/end') reason = event.data.reason
}
return { text, reason }
}
/** Report an unexpected direct-driver failure and request a failing exit. */
function fail(io: HeadlessIo, error: unknown): void {
io.stderr.write(`dsh: ${error instanceof Error ? error.message : String(error)}\n`)
io.exit(1)
}
/**
* Run one headless task to quiescence and request exit (completed → 0, else 1).
* @param ctx - plugin context carrying apiProxy, httpServer, and the launcher's headlessIo.
* @param config - validated {@link Config}.
* Run one task through a freshly created Agent and request process exit.
* @param ctx - plugin context carrying the Agent, default model, Session, and launcher IO services.
* @param task - one-shot task text.
* @param io - process-facing effects.
*/
async function run(ctx: Context, task: string, io: HeadlessIo): Promise<void> {
// Loader siblings mount concurrently. Await the complete application before
// creating an Agent so its scoped tools and adapters are not half-composed.
await ctx.get('loader')?.await()
const agents = ctx.get('agents')
const defaultModel = ctx.get('agentDefaultModel')
const sessions = ctx.get('sessions')
// Early process shutdown can dispose the tree while settlement is pending.
if (agents === undefined || defaultModel === undefined || sessions === undefined) return
const selection = defaultModel.currentSelection()
const { agent } = await agents.create({
sessionId: SessionId(`session-${randomUUID()}`),
meta: { cwd: process.cwd() },
agentOptions: { provider: selection.provider, model: selection.model },
setup: (agentCtx) => {
const selected: ModelSelectionRef = { current: selection, assembled: undefined }
installModelSelection(agentCtx, selected)
},
})
await agent.whenIdle()
const firstSeq = agent.session.seq
agent.followup(createUserMessage({
content: [{ type: 'text', text: task }],
source: { kind: 'user' },
}))
await agent.whenIdle()
await sessions.flush(agent.session)
const outcome = summarize(agent.session.events, firstSeq)
io.stdout.write(outcome.text + '\n')
if (outcome.reason?.kind === 'error') {
io.stderr.write(`dsh: ${outcome.reason.error.code}: ${outcome.reason.error.message}\n`)
}
io.exit(outcome.reason?.kind === 'completed' ? 0 : 1)
}
/**
* Mount the one-shot direct driver.
* @param ctx - plugin context carrying core services and the launcher-owned IO seam.
* @param config - validated task config.
*/
export function apply(ctx: Context, config: Config): void {
const io = ctx.headlessIo
if (io === undefined) {
throw new Error('headless-runner: the launcher must provide ctx.headlessIo before the tree mounts')
}
// Fire-and-forget by design: the run outlives plugin activation, and every
// failure path inside ends in io.exit, not a rejection.
void (async () => {
// The Loader mounts sibling rows concurrently and this plugin's inject
// gate covers only apiProxy/httpServer; prompting before the agent loop,
// adapters, and tools settle would fail the turn on a half-mounted tree.
// The old launcher ran strictly after settled boot — preserve that.
// A tree disposed mid-settlement (early SIGTERM) has nothing to run.
await ctx.get('loader')?.await()
if (ctx.get('httpServer') === undefined) return
// The headless session is web-observable while it runs (same composition).
io.stderr.write(`dsh: observing at http://127.0.0.1:${String(ctx.httpServer.port)}\n`)
const api = new InProcessApiClient(toFetchHandler(ctx.apiProxy))
const created = await unwrap(await api.sessions.create({}), io)
// Open the stream before prompting so no frame is lost. The quiescence
// anchor below is an in-process ctx subscription, so a remote-carrier
// port of this runner must replace it with a wire-visible idle signal.
const abort = new AbortController()
const frames = api.events.mux({}, abort.signal)
const idle = new Promise<number>((resolve) => {
ctx.on('agent/status', ({ agent, status }) => {
if (agent.id === created.sessionId && status === 'idle') resolve(agent.session.seq - 1)
})
})
const done = consumeUntilIdle(frames, created.sessionId, idle, io)
await unwrap(await api.sessions.prompt({
sessionId: created.sessionId,
mode: 'queue',
content: [{ type: 'text', text: config.task }],
}), io)
const outcome = await done
io.stdout.write(outcome.text + '\n')
abort.abort()
io.exit(outcome.reason === 'completed' ? 0 : 1)
})()
void run(ctx, config.task, io).catch((error: unknown) => { fail(io, error) })
}

View File

@@ -1,170 +1,184 @@
/**
* One-shot runner behavior over a scripted in-process API: idle-to-idle
* aggregation (last text of the whole interval), exit-code mapping by the
* final turn-end reason, stream-error and RPC-error paths, and the
* launcher-owned `ctx.headlessIo` requirement.
*/
/** Direct one-shot Agent driving, durable aggregation, flushing, and exit mapping. */
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent'
import AgentDefaultModelService from '@deepseek-ai/dsh-agent-default-model'
import { createAssistantMessage } from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import type { Session, UserMessage } from '@deepseek-ai/dsh-session'
import { apply, Config, type HeadlessIo } from '../src/index.ts'
interface ScriptedEvent { type: string; seq?: number; time?: number; sessionId?: string; data: Record<string, unknown> }
let nextSeq = 0
/** Stamp the envelope fields the wire schema requires. */
function stamped(event: ScriptedEvent): ScriptedEvent {
nextSeq += 1
return { seq: nextSeq, time: nextSeq, ...event }
interface Script {
before?(session: Session): void
afterPrompt(session: Session, message: UserMessage): Promise<void> | void
}
interface RpcShapedRequest { rpcId: string }
interface ScriptedApiOptions {
promptFails?: boolean
framesAfterPrompt?: boolean
onPrompt?: () => void
function appendTurn(
session: Session,
turn: number,
message: UserMessage,
text: string | undefined,
completed: boolean,
): void {
session.append('turn/start', { turn })
session.append('step/start', { turn, step: 1 })
session.append('user/message', message, { surfaceOp: 'append' })
if (text !== undefined) {
session.append('assistant/message', {
turn,
step: 1,
message: createAssistantMessage({
content: [{ type: 'text', text }],
source: { provider: 'test-provider', model: 'test-model' },
}),
}, { surfaceOp: 'append' })
}
session.append('step/end', { turn, step: 1 })
session.append('turn/end', {
turn,
reason: completed
? { kind: 'completed' }
: { kind: 'aborted', reason: { kind: 'user' } },
})
}
/** Build a fake apiProxy (echoing rpcIds like the real gateway) whose mux stream replays `events` for the created session. */
function scriptedApi(events: ScriptedEvent[], options: ScriptedApiOptions = {}): unknown {
let releaseFrames = (): void => {}
const framesReady = options.framesAfterPrompt === true
? new Promise<void>((resolve) => { releaseFrames = resolve })
: Promise.resolve()
const prepared = events.map((event) => {
if (event.type === 'stream/error') return { streamError: true } as const
const { sessionId = 'S1', ...rest } = event
return { streamError: false, sessionId, event: stamped(rest) } as const
/** Mount the real registries around a small scripted Agent factory. */
async function bench(script: Script): Promise<{
ctx: Context
run(): Promise<{ code: number; out: string; err: string; order: string[] }>
}> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentDefaultModelService, { provider: 'test-provider', model: 'test-model' })
ctx.agents.setFactory({
async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle> {
const session = ctx.sessions.create(options.sessionId, {
...options.meta === undefined ? {} : { meta: options.meta },
})
let idle = Promise.resolve()
const agent = {} as Agent
const agentCtx = ownerCtx.extend({ agent })
Object.assign(agent, {
id: session.id,
options: options.agentOptions ?? {},
session,
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
status: 'idle',
ctx: agentCtx,
cancel: () => {},
runMaintenance: () => Promise.reject(new Error('not used')),
send: () => {},
followup: (message: UserMessage) => {
agent.inbox.append('next-turn', message)
idle = Promise.resolve().then(() => script.afterPrompt(session, message))
},
steer: () => {},
inject: () => {},
whenIdle: () => idle,
} satisfies Partial<Agent>)
await options.setup?.(agentCtx)
script.before?.(session)
ctx.agents.register(agent)
return { agent, dispose: () => Promise.resolve() }
},
resume: () => Promise.reject(new Error('not used')),
})
return {
sessions: {
create: (request: RpcShapedRequest) =>
Promise.resolve({ rpcId: request.rpcId, result: { ok: true, value: { sessionId: 'S1' } } }),
prompt: (request: RpcShapedRequest) => {
releaseFrames()
options.onPrompt?.()
return Promise.resolve(options.promptFails === true
// A code from the closed wire union: the carrier schema rejects invented codes.
? { rpcId: request.rpcId, result: { ok: false, error: { code: 'agent-busy', message: 'agent is busy', details: { reason: 'test' } } } }
: { rpcId: request.rpcId, result: { ok: true, value: { accepted: true } } })
},
},
events: {
mux: async function* () {
await framesReady
for (const item of prepared) {
if (item.streamError) {
yield { rpcId: 'e', payload: { type: 'stream/error', error: { code: 'cancelled', message: 'stream broke', details: {} } } }
continue
}
yield { rpcId: 'e', payload: { type: 'session/event', sessionId: item.sessionId, event: item.event } }
ctx,
run: async () => {
let out = ''
let err = ''
const order: string[] = []
ctx.on('session/flush', () => { order.push('flush') })
const exited = new Promise<number>((resolve) => {
const io: HeadlessIo = {
stdout: { write: (chunk: string) => { out += chunk; return true } },
stderr: { write: (chunk: string) => { err += chunk; return true } },
exit: (code) => { order.push('exit'); resolve(code) },
}
},
ctx.provide('headlessIo', io)
})
apply(ctx, { task: 'do the thing' })
return { code: await exited, out, err, order }
},
}
}
/**
* Mount the runner against a scripted API, emit the idle transition after the
* scripted frames drain, and wait for its exit request.
*/
async function run(
events: ScriptedEvent[],
options: { promptFails?: boolean; framesAfterPrompt?: boolean; idleInPrompt?: boolean } = {},
): Promise<{ code: number; out: string; err: string }> {
const ctx = new Context()
let out = ''
let err = ''
const exited = new Promise<number>((resolve) => {
const io: HeadlessIo = {
stdout: { write: (chunk: string) => { out += chunk; return true } },
stderr: { write: (chunk: string) => { err += chunk; return true } },
exit: resolve,
}
ctx.provide('headlessIo', io)
})
const emitIdle = (): void => {
ctx.emit('agent/status', { agent: { id: 'S1', session: { seq: nextSeq + 1 } } as Agent, status: 'idle' })
}
ctx.provide('apiProxy', scriptedApi(events, {
...options.promptFails === undefined ? {} : { promptFails: options.promptFails },
...options.framesAfterPrompt === undefined ? {} : { framesAfterPrompt: options.framesAfterPrompt },
...options.idleInPrompt === true ? { onPrompt: emitIdle } : {},
}) as never)
ctx.provide('httpServer', { port: 12345 } as never)
apply(ctx, { task: 'do the thing' })
// Quiescence is out of band: give the scripted stream a beat to drain, then
// flip the agent idle exactly as the loop would. Foreign agents and
// non-idle transitions must not settle the run.
if (options.idleInPrompt !== true) {
await new Promise(resolve => setTimeout(resolve, 10))
ctx.emit('agent/status', { agent: { id: 'OTHER' } as Agent, status: 'idle' })
ctx.emit('agent/status', { agent: { id: 'S1' } as Agent, status: 'running' })
emitIdle()
}
const code = await exited
await ctx.fiber.dispose()
return { code, out, err }
}
const startupTurn: ScriptedEvent = { type: 'turn/start', data: { turn: 0, trigger: { kind: 'startup' } } }
const messageTurn: ScriptedEvent = { type: 'turn/start', data: { turn: 1, trigger: { kind: 'message' } } }
const text = (turn: number, value: string): ScriptedEvent => ({
type: 'assistant/message',
data: { turn, message: { content: [{ type: 'text', text: value }] } },
})
const end = (turn: number, reason: string): ScriptedEvent => ({ type: 'turn/end', data: { turn, reason: { kind: reason } } })
describe('headless runner', () => {
it('aggregates to quiescence: last text wins across turns, final turn-end reason maps to exit 0', async () => {
const { code, out, err } = await run([
// Frames before the first turn/start are outside the task interval.
{ type: 'assistant/message', data: { turn: 0, message: { content: [{ type: 'text', text: 'pre-task noise' }] } } },
startupTurn,
// Off-session, non-text, and text-empty frames never affect the aggregate.
{ type: 'assistant/message', sessionId: 'OTHER', data: { turn: 1, message: { content: [{ type: 'text', text: 'other session' }] } } },
{ type: 'assistant/message', data: { turn: 1, message: { content: [{ type: 'tool_call', text: 'ignored' }] } } },
text(0, 'draft'),
end(0, 'completed'),
messageTurn,
text(1, 'final answer'),
end(1, 'completed'),
])
expect(code).toBe(0)
expect(out).toBe('final answer\n')
expect(err).toContain('observing at http://127.0.0.1:12345')
it('aggregates the final text across the complete idle-to-idle interval and flushes before exit', async () => {
const test = await bench({
before(session) {
const setupMessage = {
role: 'user', content: [{ type: 'text', text: 'setup' }], source: { kind: 'user' }, id: 'setup',
} as UserMessage
appendTurn(session, 0, setupMessage, 'pre-task noise', true)
},
async afterPrompt(session, message) {
await Promise.resolve()
appendTurn(session, 1, message, '', true)
appendTurn(session, 2, message, 'final answer', true)
},
})
const result = await test.run()
expect(result).toEqual({
code: 0,
out: 'final answer\n',
err: '',
order: ['flush', 'exit'],
})
await test.ctx.fiber.dispose()
})
it('consumes through the idle sequence when queued frames arrive after the status transition', async () => {
const { code, out } = await run(
[messageTurn, text(1, 'race-free answer'), end(1, 'completed')],
{ framesAfterPrompt: true, idleInPrompt: true },
)
expect(code).toBe(0)
expect(out).toBe('race-free answer\n')
it('waits for asynchronously appended events instead of racing Agent idleness', async () => {
const test = await bench({
afterPrompt: async (session, message) => {
await new Promise(resolve => setTimeout(resolve, 5))
appendTurn(session, 1, message, 'race-free answer', true)
},
})
expect(await test.run()).toMatchObject({ code: 0, out: 'race-free answer\n', err: '' })
await test.ctx.fiber.dispose()
})
it('exits 1 when the final turn ends for any other reason', async () => {
const { code } = await run([messageTurn, end(1, 'aborted')])
expect(code).toBe(1)
it('exits 1 when the final turn does not complete', async () => {
const test = await bench({
afterPrompt(session, message) { appendTurn(session, 1, message, undefined, false) },
})
expect(await test.run()).toMatchObject({ code: 1, out: '\n', err: '' })
await test.ctx.fiber.dispose()
})
it('exits 1 when no turn ever starts (idle without work)', async () => {
const { code, out } = await run([])
expect(code).toBe(1)
expect(out).toBe('\n')
it('prints the durable model failure when the final turn ends in error', async () => {
const test = await bench({
afterPrompt(session, message) {
session.append('turn/start', { turn: 1 })
session.append('step/start', { turn: 1, step: 1 })
session.append('user/message', message, { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', {
turn: 1,
reason: { kind: 'error', error: { code: 'SERVER', message: 'provider unavailable' } },
})
},
})
expect(await test.run()).toMatchObject({
code: 1,
out: '\n',
err: 'dsh: SERVER: provider unavailable\n',
})
await test.ctx.fiber.dispose()
})
it('keeps the error outcome after a stream error ends the frame consumer early', async () => {
const { code } = await run([messageTurn, { type: 'stream/error', data: {} }, end(1, 'completed')])
// The consumer stopped at the stream error; the completed turn-end after
// it is never observed, so the reason stays 'error'.
expect(code).toBe(1)
it('exits 1 when the owned interval contains no turn', async () => {
const test = await bench({ afterPrompt: () => {} })
expect(await test.run()).toMatchObject({ code: 1, out: '\n', err: '' })
await test.ctx.fiber.dispose()
})
it('prints an RPC business error and exits 1 without waiting for idle', async () => {
it('reports a direct Agent creation failure', async () => {
const ctx = new Context()
let err = ''
const exited = new Promise<number>((resolve) => {
@@ -174,15 +188,16 @@ describe('headless runner', () => {
exit: resolve,
} satisfies HeadlessIo)
})
ctx.provide('apiProxy', scriptedApi([messageTurn, end(1, 'completed')], { promptFails: true }) as never)
ctx.provide('httpServer', { port: 1 } as never)
ctx.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'p', model: 'm' }) } as never)
ctx.provide('sessions', { flush: () => Promise.resolve(true) } as never)
ctx.provide('agents', { create: () => Promise.reject(new Error('factory exploded')) } as never)
apply(ctx, { task: 't' })
expect(await exited).toBe(1)
expect(err).toContain('agent-busy')
expect(err).toBe('dsh: factory exploded\n')
await ctx.fiber.dispose()
})
it('reports the stream-failed diagnostic when the event channel dies, still settling at idle', async () => {
it('stringifies a non-Error Agent creation failure', async () => {
const ctx = new Context()
let err = ''
const exited = new Promise<number>((resolve) => {
@@ -192,67 +207,52 @@ describe('headless runner', () => {
exit: resolve,
} satisfies HeadlessIo)
})
ctx.provide('apiProxy', {
sessions: {
create: (request: RpcShapedRequest) =>
Promise.resolve({ rpcId: request.rpcId, result: { ok: true, value: { sessionId: 'S1' } } }),
prompt: (request: RpcShapedRequest) =>
Promise.resolve({ rpcId: request.rpcId, result: { ok: true, value: { accepted: true } } }),
ctx.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'p', model: 'm' }) } as never)
ctx.provide('sessions', { flush: () => Promise.resolve(true) } as never)
const rejected = {
then(_resolve: (value: never) => void, reject: (reason: unknown) => void): void {
reject('factory exploded')
},
events: {
// Synchronous throw: the SSE response never forms, so the client-side
// iterable rejects — the runner's own catch path, not a carrier frame.
mux: () => { throw new Error('channel exploded') },
},
} as never)
ctx.provide('httpServer', { port: 1 } as never)
}
ctx.provide('agents', { create: () => rejected } as never)
apply(ctx, { task: 't' })
await new Promise(resolve => setTimeout(resolve, 10))
ctx.emit('agent/status', { agent: { id: 'S1', session: { seq: nextSeq + 1 } } as Agent, status: 'idle' })
expect(await exited).toBe(1)
expect(err).toContain('event stream failed')
expect(err).toBe('dsh: factory exploded\n')
await ctx.fiber.dispose()
})
it('waits for Loader settlement and abandons the run when the tree died during it', async () => {
it('abandons a run when the tree is disposed during Loader settlement', async () => {
const ctx = new Context()
let err = ''
let exited = false
ctx.provide('headlessIo', {
stdout: { write: () => true },
stderr: { write: (chunk: string) => { err += chunk; return true } },
stderr: { write: () => true },
exit: () => { exited = true },
} satisfies HeadlessIo)
ctx.provide('apiProxy', scriptedApi([]) as never)
// The webserver is provided by a child fiber whose disposal (early
// SIGTERM during the boot window) removes the service; settlement
// resolves only afterwards, and the runner must abandon rather than
// crash on the torn-down port read.
const webserverFiber = ctx.plugin((childCtx: Context) => {
childCtx.provide('httpServer', { port: 1 } as never)
const services = ctx.plugin((child: Context) => {
child.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'p', model: 'm' }) } as never)
child.provide('sessions', {} as never)
child.provide('agents', {} as never)
})
await webserverFiber
await services
let release: () => void
const settlement = new Promise<void>((resolve) => { release = resolve })
ctx.provide('loader', { await: () => settlement } as never)
apply(ctx, { task: 't' })
await webserverFiber.dispose()
await services.dispose()
release!()
await new Promise(resolve => setTimeout(resolve, 10))
expect(err).toBe('')
expect(exited).toBe(false)
await ctx.fiber.dispose()
})
it('fails loud without the launcher-owned headlessIo seam', () => {
const ctx = new Context()
ctx.provide('apiProxy', scriptedApi([]) as never)
ctx.provide('httpServer', { port: 1 } as never)
expect(() => { apply(ctx, { task: 't' }) }).toThrow('must provide ctx.headlessIo')
})
it('validates config: the task is required', () => {
expect(() => new Config({ } as never)).toThrow()
expect(() => new Config({} as never)).toThrow()
expect(new Config({ task: 'x' })).toEqual({ task: 'x' })
})
})

View File

@@ -17,15 +17,15 @@
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../host/apiproxy"
},
{
"path": "../../host/webserver"
},
{
"path": "../../core/agent"
},
{
"path": "../../core/agent-default-model"
},
{
"path": "../../llm/llm"
},
{
"path": "../../core/session"
},