refactor: remove per-followup result attribution

This commit is contained in:
_Kerman
2026-07-30 16:48:28 +08:00
parent f6db60b52c
commit a6baddaaac
72 changed files with 586 additions and 1059 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/examples/cli-demo/README.md
README.md: b8f2bde962738a1a23f0e57218ab0f90e8e0b705
README.zh.md: 0e03375ced4e087d44eed7ff33666abf1f2cec10
README.md: 6e46ae81421c23806524b0784a976e9f3c8eeab8
README.zh.md: 4dc5d482e17e87177e7a1ae3a39435879cdd76ce

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Headless one-shot app and bin for running one agent task without an interactive UI or editor client. It composes [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md), JSONL persistence, and exactly one fresh top-level agent. The bin submits the task, waits for its durable turn ending, renders the selected output, disposes to quiescence, and exits.
Headless one-shot app and bin for running one agent task without an interactive UI or editor client. It composes [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md), JSONL persistence, and exactly one fresh top-level agent. The bin owns one idle-to-idle activity interval, renders its selected output, disposes to quiescence, and exits.
The package mounts no console logger, interactive UI, user-interaction service, or `ask_user_question` tool. Stdout is reserved for the selected output format; diagnostics use stderr.
@@ -44,12 +44,12 @@ Loader configs resolve bare package specifiers through the optional native helpe
### Output formats
- `text` writes the last assistant message containing text, followed by one newline.
- `json` writes one DSH-native result record: `{ type: "result", success, sessionId, turn, result, reason, usage? }`. `usage` sums each model step in the task turn once, including billed failed retry attempts that produced usage without a committed assistant message.
- `stream-json` writes each canonical event from the top-level session's task turn as `{ type: "session_event", sessionId, event }`, then the same result record. Child-agent activity appears only through the parent tool events and results.
- `json` writes one DSH-native result record: `{ type: "result", sessionId, output, usage? }`. `output` is the last committed assistant text in the activity interval. `usage` sums each model step in that interval once, including billed failed attempts that produced usage without a committed assistant message.
- `stream-json` writes each canonical event from the top-level session's owned activity interval as `{ type: "session_event", sessionId, event }`, then the same result record. Child-agent activity appears only through the parent tool events and results.
Only `reason.kind === "completed"` exits successfully. Other durable turn endings still emit partial text or a result record, add a stderr diagnostic, and exit nonzero. Argument and boot failures leave stdout empty. SIGINT and SIGTERM cancel active work, await disposal, and exit 130 and 143 respectively.
Normal idle completion exits successfully without assigning a turn reason to the task. Argument, boot, observation, and persistence failures leave stdout empty. SIGINT and SIGTERM cancel active work, await disposal, and exit 130 and 143 respectively.
The task turn is explicitly flushed before final output. Session logs remain under `persistenceRoot` after the process exits.
The owned activity is explicitly flushed before final output. Session logs remain under `persistenceRoot` after the process exits.
## Operational safety
@@ -57,11 +57,11 @@ The headless-agent leaf supplies local bash, filesystem, skill, subagent, workfl
## Model Experience
### One-shot task turn
### One-shot activity
#### What the model sees
The positional task becomes one user message. Through `dsh-agent-spine-demo`, the top-level agent also receives configured workspace instructions and persona, the skill catalog, visible tool schemas, and retained tool results needed for later steps in the same turn.
The positional task becomes one user message. Through `dsh-agent-spine-demo`, the top-level agent also receives configured workspace instructions and persona, the skill catalog, visible tool schemas, and retained tool results needed for later steps in the owned activity.
#### Token effect
@@ -75,4 +75,4 @@ Tool-round history is append-only while the one-shot agent's prompt, schemas, mo
- **One fresh top-level session per process** — its workspace cwd is the launch directory; there is no resume, second prompt, stdin context, or concurrent top-level session in this app.
- **No interactive question or approval provider** — tools that require a human answer cannot complete unless a different leaf composes a non-interactive provider with explicit policy.
- **Streaming is top-level-session-only** — child sessions are not flattened into the stream, and aggregate usage covers only model steps recorded on the parent task turn.
- **Streaming is top-level-session-only** — child sessions are not flattened into the stream, and aggregate usage covers only model steps recorded on the parent activity interval.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
无头单次应用及 bin用于在没有交互式 UI 或编辑器客户端的情况下运行一项 agent智能体任务。它组合 [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md)、JSONL 持久化,以及恰好一个新建顶层 agent。bin 提交任务,等待其已持久化的轮次结束状态,渲染所选输出,执行 dispose资源释放直至完全停稳然后退出。
无头单次应用及 bin用于在没有交互式 UI 或编辑器客户端的情况下运行一项 agent智能体任务。它组合 [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md)、JSONL 持久化,以及恰好一个新建顶层 agent。bin 拥有一个从 idle 到 idle 的活动区间,渲染所选输出,执行 dispose资源释放直至完全停稳然后退出。
该包package不挂载 console logger、交互式 UI、用户交互服务或 `ask_user_question` 工具。Stdout 专用于所选输出格式;诊断使用 stderr。
@@ -44,12 +44,12 @@ loader 配置通过仓库安装的可选原生辅助程序解析裸包说明符
### 输出格式
- `text` 写入最后一条含文本的 assistant 消息,后跟一个换行符。
- `json` 写入一条 DSH 原生结果记录:`{ type: "result", success, sessionId, turn, result, reason, usage? }``usage`任务轮次中的每个模型步骤恰好求和一次,包括产生用量但没有提交 assistant 消息的已计费失败试。
- `stream-json` 将顶层会话任务轮次中的每个规范事件写成 `{ type: "session_event", sessionId, event }`,然后写入同一结果记录。子 agent 活动只通过父工具事件与结果出现。
- `json` 写入一条 DSH 原生结果记录:`{ type: "result", sessionId, output, usage? }``output` 是活动区间内最后提交的 assistant 文本`usage`该区间中的每个模型步骤恰好求和一次,包括产生用量但没有提交 assistant 消息的已计费失败试。
- `stream-json` 将顶层会话自有活动区间中的每个规范事件写成 `{ type: "session_event", sessionId, event }`,然后写入同一结果记录。子 agent 活动只通过父工具事件与结果出现。
只有 `reason.kind === "completed"` 会成功退出。其他已持久化的轮次结束状态仍会输出部分文本或结果记录,向 stderr 添加诊断,并以非零状态退出。参数启动失败会让 stdout 保持为空。SIGINT 与 SIGTERM 会取消正在进行的工作,等待 dispose 完成,并分别以 130 和 143 退出。
正常进入 idle 会成功退出,不会为该任务指定轮次原因。参数启动、观测和持久化失败会让 stdout 保持为空。SIGINT 与 SIGTERM 会取消正在进行的工作,等待 dispose 完成,并分别以 130 和 143 退出。
任务轮次会在最终输出前显式刷新。进程退出后,会话日志仍保留在 `persistenceRoot` 下。
自有活动会在最终输出前显式刷新。进程退出后,会话日志仍保留在 `persistenceRoot` 下。
## 操作安全
@@ -57,11 +57,11 @@ headless-agent 叶节点提供本地 bash、文件系统、skill、subagent、
## 模型体验
### 单次任务轮次
### 单次活动
#### 模型看到的内容
任务位置参数会成为一条用户消息。通过 `dsh-agent-spine-demo`,顶层 agent 还会收到已配置的工作区指令与 persona、skill 目录、可见工具 schema以及同一轮次后续步骤所需的保留工具结果。
任务位置参数会成为一条用户消息。通过 `dsh-agent-spine-demo`,顶层 agent 还会收到已配置的工作区指令与 persona、skill 目录、可见工具 schema以及自有活动后续步骤所需的保留工具结果。
#### Token 影响
@@ -75,4 +75,4 @@ headless-agent 叶节点提供本地 bash、文件系统、skill、subagent、
- **每个进程只创建一个新的顶层会话**:其工作区 cwd 是启动目录此应用不支持恢复、第二条提示词、stdin 上下文或并发顶层会话。
- **没有交互式问题或批准提供方**:需要人工回答的工具无法完成,除非其他叶节点按显式策略组合一个非交互式提供方。
- **流式输出仅限顶层会话**:子会话不会平铺到流中,聚合用量只涵盖父任务轮次记录的模型步骤。
- **流式输出仅限顶层会话**:子会话不会平铺到流中,聚合用量只涵盖父活动区间记录的模型步骤。

View File

@@ -8,7 +8,7 @@ import { parseArgs } from 'node:util'
import type { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { createUserMessage, type TokenUsage } from '@deepseek-ai/dsh-llm'
import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { boot, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
const CLI_NAME = 'dsh-cli-demo'
@@ -32,11 +32,8 @@ export type CliCommand =
/** DSH-native final record emitted by JSON modes. */
export interface CliResult {
readonly type: 'result'
readonly success: boolean
readonly sessionId: string
readonly turn: number
readonly result: string
readonly reason: TurnEndReason
readonly output: string
readonly usage?: TokenUsage
}
@@ -203,13 +200,8 @@ async function waitForStartupIdle(agent: Agent, signal?: AbortSignal): Promise<v
}
/**
* Run one message-triggered turn on the configured top-level agent, aggregate its
* final text and model usage, wait for idle plus an explicit persistence flush,
* and return its durable ending. Only the selected agent's task turn reaches
* `onEvent`; startup injections and unrelated sessions are ignored. The context
* must contain exactly one top-level agent. Signal abort cancels that agent; an
* abort before the correlated task turn rejects. An observer throw cancels the
* turn and is rethrown after the agent reaches idle and the session flushes.
* Run one owned activity interval on the configured top-level agent, from the
* task's durable enqueue receipt through whole-agent idle.
* @param ctx - settled Loader root containing one agent plus `ctx.sessions`.
* @param options - task, optional cancellation, and optional stream observer.
* @returns the DSH-native result envelope after durable quiescence.
@@ -222,33 +214,12 @@ export async function runOneShot(ctx: Context, options: OneShotOptions): Promise
}
await waitForStartupIdle(agent, options.signal)
let targetTurn: number | undefined
let reason: TurnEndReason | undefined
let result = ''
const message = createUserMessage({ content: [{ type: 'text', text: options.task }], source: { kind: 'user' } })
let received = false
let output = ''
const usageByStep = new Map<string, TokenUsage>()
let outputError: Error | undefined
let resolveTurn!: () => void
let rejectTurn!: (error: Error) => void
let firstTurnEnded = false
const turnEnded = new Promise<void>((resolve, reject) => {
resolveTurn = resolve
rejectTurn = reject
})
const settleResolved = (): void => {
if (firstTurnEnded) return
firstTurnEnded = true
resolveTurn()
}
const settleRejected = (error: Error): void => {
// The once-registered abort listener is the only rejecter, and a settled
// prompt makes targetTurn defined so onAbort skips rejection entirely;
// kept for symmetry with settleResolved.
/* v8 ignore next -- unreachable second settlement, see above */
if (firstTurnEnded) return
firstTurnEnded = true
rejectTurn(error)
}
let interrupted: CliInterruptedError | undefined
const observe = (sessionId: string, event: SessionEvent): void => {
if (outputError !== undefined || options.onEvent === undefined) return
try {
@@ -261,38 +232,29 @@ export async function runOneShot(ctx: Context, options: OneShotOptions): Promise
const disposeListener = ctx.on('session/event', (session, event) => {
if (session !== agent.session) return
if (targetTurn === undefined) {
if (event.type !== 'turn/start' || event.data.trigger.kind !== 'message') return
targetTurn = event.data.turn
} else if (event.type === 'turn/start' && event.data.trigger.kind === 'retry'
&& reason?.kind === 'error') {
targetTurn = event.data.turn
reason = undefined
if (!received) {
if (event.type !== 'agent/inbox/spliced'
|| !event.data.inserted.some(inserted => inserted.id === message.id)) return
received = true
}
observe(session.id, event)
if (event.type === 'assistant/chunk'
&& event.data.turn === targetTurn
&& event.data.chunk.type === 'usage') {
if (event.type === 'assistant/chunk' && event.data.chunk.type === 'usage') {
usageByStep.set(`${event.data.turn}/${event.data.step}`, event.data.chunk.usage)
}
if (event.type === 'assistant/message' && event.data.turn === targetTurn) {
result = assistantText(event) ?? result
if (event.type === 'assistant/message') {
output = assistantText(event) ?? output
if (event.data.usage !== undefined) {
usageByStep.set(`${event.data.turn}/${event.data.step}`, event.data.usage)
}
}
if (event.type === 'turn/end' && event.data.turn === targetTurn) {
reason = event.data.reason
settleResolved()
}
})
const signal = options.signal
let onAbort: (() => void) | undefined
if (signal !== undefined) {
onAbort = (): void => {
interrupted ??= new CliInterruptedError(interruptionReason(signal))
agent.cancel({ kind: 'user' })
if (targetTurn === undefined) settleRejected(new CliInterruptedError(interruptionReason(signal)))
}
signal.addEventListener('abort', onAbort, { once: true })
/* v8 ignore next -- closes the race between startup-idle completion and listener registration */
@@ -300,37 +262,27 @@ export async function runOneShot(ctx: Context, options: OneShotOptions): Promise
}
try {
/* v8 ignore next -- skips send only when cancellation wins the listener-registration race above */
if (!firstTurnEnded) { // oxlint-disable-line typescript/no-unnecessary-condition
agent.followup(createUserMessage({ content: [{ type: 'text', text: options.task }], source: { kind: 'user' } }))
}
await turnEnded
if (interrupted === undefined) agent.followup(message)
await agent.whenIdle()
} finally {
if (onAbort !== undefined) signal?.removeEventListener('abort', onAbort)
await agent.whenIdle()
disposeListener()
}
/* v8 ignore next 3 -- turnEnded resolves only from the matching branch that assigns both values */
if (targetTurn === undefined || reason === undefined) {
throw new Error('task ended without a correlated turn/end event')
}
await ctx.sessions.flush(agent.session)
if (outputError !== undefined) throw outputError
if (interrupted !== undefined) throw interrupted
const usage = [...usageByStep.values()].reduce<TokenUsage | undefined>(addUsage, undefined)
return {
type: 'result',
success: reason.kind === 'completed',
sessionId: agent.session.id,
turn: targetTurn,
result,
reason,
output,
...usage === undefined ? {} : { usage },
}
}
function renderResult(outputFormat: OutputFormat, result: CliResult): string {
return outputFormat === 'text' ? `${result.result}\n` : `${JSON.stringify(result)}\n`
return outputFormat === 'text' ? `${result.output}\n` : `${JSON.stringify(result)}\n`
}
/**
@@ -380,23 +332,6 @@ async function bootInterruptibly(
}
}
/**
* Render a non-completed turn reason for stderr.
* @param reason - durable turn ending to describe.
* @returns a concise diagnostic fragment.
*/
export function formatTurnFailure(reason: TurnEndReason): string {
switch (reason.kind) {
case 'completed': return 'completed'
case 'aborted': return 'was aborted'
case 'error': return `failed at step ${reason.step}: ${'failure' in reason ? reason.failure.message : reason.message}`
case 'disposed': return 'was disposed'
case 'max-tokens': return 'reached the model output-token limit'
case 'interrupted': return 'was interrupted during persistence recovery'
default: return `ended with ${JSON.stringify(reason)}`
}
}
/**
* Execute one CLI invocation. Argument and boot failures never write stdout;
* context disposal is awaited before return, and its failure does not replace
@@ -451,8 +386,7 @@ export async function executeCli(args: readonly string[], runtime: CliRuntime =
: {},
})
writeStdout(renderResult(command.outputFormat, result))
exitCode = result.success ? 0 : 1
if (!result.success) diagnostic = `${CLI_NAME}: turn ${result.turn} ${formatTurnFailure(result.reason)}\n`
exitCode = 0
} catch (error: unknown) {
diagnostic = `${CLI_NAME}: ${toError(error).message}\n`
} finally {