Merge branch 'stack/agent-profiles-1-seam' into stack/agent-profiles-3-wire

This commit is contained in:
Yichen Jiang
2026-08-08 22:47:15 +08:00
910 changed files with 17011 additions and 6270 deletions

View File

@@ -218,6 +218,10 @@
- id: skill-local
name: '@deepseek-ai/dsh-skill-local'
- id: skill-badge
name: '@deepseek-ai/dsh-skill-badge'
disabled: true
- id: tool-skill
name: '@deepseek-ai/dsh-tool-skill'

View File

@@ -70,6 +70,7 @@
"@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^",
"@deepseek-ai/dsh-settings-local": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
"@deepseek-ai/dsh-skill-badge": "workspace:^",
"@deepseek-ai/dsh-skill-local": "workspace:^",
"@deepseek-ai/dsh-spill-local": "workspace:^",
"@deepseek-ai/dsh-spill-policy": "workspace:^",

View File

@@ -0,0 +1 @@
[]

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: d08fb08e2aca3c4e5ccd733b37fc415d492974ca
README.zh.md: 99a64ef04c4fd8fb0c6a979d3f09f1bd98b434a0
README.md: 661b377817482d22f58f22b573075722646729a2
README.zh.md: a6b91a8e60fdcc06ba23e07dcb2f4208ea1020f7

View File

@@ -2,7 +2,7 @@
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), 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 --profile headless "task"`), and fails loud when a task is given to a profile without this row.
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.
## Model Experience

View File

@@ -2,7 +2,7 @@
[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 链路都会真实运行)驱动一个任务轮次,聚合该轮次最终的 assistant 文本,写到 stdout经启动器提供的 `ctx.headlessIo` seam 请求退出(完成 → 0否则 1。Web 组合保持挂载,因此运行中的会话可在浏览器中通过 stderr 公告的 URL 观察。启动器把任务文本 patch 进来(`dsh --profile headless "task"`);如果向没有这一行的 profile 传入任务,则大声失败
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 缺少该行,则显式报错
## 模型体验

View File

@@ -6,8 +6,7 @@
* (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 --profile headless "task"`).
* task text arrives as launcher-patched config (`dsh run "task"`).
* @module @deepseek-ai/dsh-headless
*/
@@ -86,26 +85,31 @@ async function unwrap<T>(response: RpcResponse<T>, io: HeadlessIo): Promise<T> {
* `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 when the agent reaches quiescence.
* @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<void>,
idle: Promise<number>,
io: HeadlessIo,
): Promise<TurnOutcome> {
let started = false
let text = ''
let reason: string = 'error'
void (async () => {
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
@@ -121,7 +125,12 @@ async function consumeUntilIdle(
io.stderr.write(`dsh: event stream failed: ${String(error)}\n`)
}
})()
await idle
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
}
return { text, reason }
}
@@ -154,9 +163,9 @@ export function apply(ctx: Context, config: Config): void {
// 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<void>((resolve) => {
const idle = new Promise<number>((resolve) => {
ctx.on('agent/status', ({ agent, status }) => {
if (agent.id === created.sessionId && status === 'idle') resolve()
if (agent.id === created.sessionId && status === 'idle') resolve(agent.session.seq - 1)
})
})
const done = consumeUntilIdle(frames, created.sessionId, idle, io)

View File

@@ -21,26 +21,45 @@ function stamped(event: ScriptedEvent): ScriptedEvent {
interface RpcShapedRequest { rpcId: string }
interface ScriptedApiOptions {
promptFails?: boolean
framesAfterPrompt?: boolean
onPrompt?: () => void
}
/** Build a fake apiProxy (echoing rpcIds like the real gateway) whose mux stream replays `events` for the created session. */
function scriptedApi(events: ScriptedEvent[], options: { promptFails?: boolean } = {}): unknown {
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
})
return {
sessions: {
create: (request: RpcShapedRequest) =>
Promise.resolve({ rpcId: request.rpcId, result: { ok: true, value: { sessionId: 'S1' } } }),
prompt: (request: RpcShapedRequest) => 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 } } }),
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* () {
for (const event of events) {
if (event.type === 'stream/error') {
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
}
const { sessionId = 'S1', ...rest } = event
yield { rpcId: 'e', payload: { type: 'session/event', sessionId, event: stamped(rest) } }
yield { rpcId: 'e', payload: { type: 'session/event', sessionId: item.sessionId, event: item.event } }
}
},
},
@@ -51,7 +70,10 @@ function scriptedApi(events: ScriptedEvent[], options: { promptFails?: boolean }
* 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 } = {}): Promise<{ code: number; out: string; err: string }> {
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 = ''
@@ -63,16 +85,25 @@ async function run(events: ScriptedEvent[], options: { promptFails?: boolean } =
}
ctx.provide('headlessIo', io)
})
ctx.provide('apiProxy', scriptedApi(events, options) as never)
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.
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' })
ctx.emit('agent/status', { agent: { id: 'S1' } as Agent, status: 'idle' })
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 }
@@ -106,6 +137,15 @@ describe('headless runner', () => {
expect(err).toContain('observing at http://127.0.0.1:12345')
})
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('exits 1 when the final turn ends for any other reason', async () => {
const { code } = await run([messageTurn, end(1, 'aborted')])
expect(code).toBe(1)
@@ -168,7 +208,7 @@ describe('headless runner', () => {
ctx.provide('httpServer', { port: 1 } as never)
apply(ctx, { task: 't' })
await new Promise(resolve => setTimeout(resolve, 10))
ctx.emit('agent/status', { agent: { id: 'S1' } as Agent, status: 'idle' })
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')
await ctx.fiber.dispose()

View File

@@ -149,6 +149,10 @@
- id: ui-conversation
name: '@deepseek-ai/dsh-client-ui-conversation'
# Tool call tree, generic fallback, and keyed business Tool views.
- id: ui-tool
name: '@deepseek-ai/dsh-client-ui-tool'
# Turn tail: the produced-files row under each closing assistant message.
# Remove this entry to turn the surface off; the tail hole renders empty.
- id: ui-deliverables

View File

@@ -56,6 +56,7 @@
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
"@deepseek-ai/dsh-client-ui-subagent": "workspace:^",
"@deepseek-ai/dsh-client-ui-theme": "workspace:^",
"@deepseek-ai/dsh-client-ui-tool": "workspace:^",
"@deepseek-ai/dsh-client-ui-trajectory": "workspace:^",
"@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
"@deepseek-ai/dsh-code-runtime-worker": "workspace:^",