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/goal/goal-session/README.md
README.md: 6a1c3b9455c93762c2458109c753588ce9a08d9a
README.zh.md: 4162411bf2dbeebbec8da6433c71e176057c4277
README.md: 7c56f295bb7a587913b201db8860d11605886a64
README.zh.md: e41afbc4142eee6a8a50e43b4fa6ca34ecc28641

View File

@@ -23,30 +23,21 @@ The plugin has no tunable configuration. `maxGoalRounds` belongs to the goal def
When an exact live agent is idle with an active, armed goal and remaining capacity, the driver first checkpoints pending goal mutations, then reserves `roundsStarted + 1` for the current `{ goalId, revision }`. It queues one `<goal_round>` prompt with `GoalMessageSource`. Admission through `agent/prompt-submit` verifies the complete queued record and current goal both before and after downstream prompt hooks; only the accepted `user/message` increments `roundsStarted`. A reservation rejected as stale does not consume the round number.
One goal round owns one ordinary session turn, and that turn may contain several model/tool steps. The driver pairs a reservation only with a `message` turn carrying its exact `GoalMessageSource`; merge-extensible plugin turn triggers do not admit or replace that reservation. Human messages remain ordinary turns and do not consume the goal cap. If human work enters the inbox before a reservation or joins its pending batch, automatic work yields until that work settles; a pending automatic prompt in a mixed batch is rejected and re-reserved only after the agent becomes idle.
`MessageId` identifies the reserved message through durable inbox insertion and admission; it does not identify a turn result. Human messages do not consume the goal cap. If human work enters the inbox before a reservation or joins its pending batch, automatic work yields until the agent becomes idle; a pending automatic prompt in a mixed batch is rejected and re-reserved only after that checkpoint.
The retained prompt names the JSON-quoted objective and `round/maxGoalRounds`, treats the current workspace, tool results, and durable session state as authoritative, requires evidence before completion, and tells the model to leave the goal active when work remains. Quoting preserves multiline or tag-like objective text as data. Goal lifecycle mutations still require the independent authority checks in `dsh-tool-goal`.
## Settlement policy
## Idle checkpoint
| Durable turn outcome | Goal action | Automatic retry |
|---|---|---|
| `completed` with goal still active and armed | admit the next round, or block with code `round-limit` at the cap | yes |
| cancellation of a reserved/admitted goal round, or its `aborted` outcome | `paused` | no |
| cancellation with no goal-round attempt | keep durable phase; disarm activation | no |
| `error` with `RATE_LIMIT` or `QUOTA` | `blocked` with code `usage-limited` | no |
| other `error`, `max-tokens`, or a non-stale prompt rejection | `blocked` with a diagnostic code and message | no |
| durability failure, disposal, interruption, or unknown future outcome | disarm or block for inspection | no |
A goal mutation made during its round supersedes settlement of the older revision. Completion, pause, blocking, and edits therefore remain authoritative even if the physical turn closes afterward. No abnormal result is retried automatically.
At whole-agent idle, durable goal phase and revision are authoritative. An active, armed goal with capacity reserves its next round; completion, pause, blocking, and edits suppress continuation. The driver does not classify the preceding activity by correlating the goal message with `turn/end`, so provider errors and token limits are not prompt-level goal outcomes.
## Lifecycle and durability
`goal/changed` creates a durability obligation. Before queuing work, the driver awaits `ctx.sessions.flush()` and rechecks both the goal revision and competing input after the await. A closing flush failure arrives through `agent/error`; the driver associates it with the exact closed turn even if a later one-shot injection has appended another turn, then disarms before another round can start.
`goal/changed` creates a durability obligation. Before queuing work, the driver awaits `ctx.sessions.flush()` and rechecks both the goal revision and competing input after the await. A flush failure arriving through `agent/error` disarms continuation before another round can start.
Activation is never inherited when this plugin loads over an existing agent. `GoalService.disarm()` removes process-local authority without changing durable phase, revision, or history; explicit human-authorized resume records the later reactivation. The same rule applies after session resume and fork through the goal domain's `agent/session-start` handling.
Cancellation is observe-before-act: the concrete loop emits `agent/cancel-requested` with its typed cause before clearing queues or aborting the turn. The plugin durably pauses an active goal only when the cancellation owns a reserved or admitted goal attempt; cancellation of unrelated human work merely disarms process-local continuation. If the pause mutation fails, the driver falls back to disarming. Plugin teardown closes admission, disarms every live goal, cancels an admitted round with the `parent` cause, and awaits the driver plus agent quiescence while its event fence remains installed.
Cancellation removes pending inbox work or leaves an agent-wide aborted state. At the next idle checkpoint the driver pauses a goal with a reserved or admitted attempt so cancellation cannot auto-restart it; cancellation unrelated to a goal attempt only disarms process-local continuation. If the pause mutation fails, the driver falls back to disarming. Plugin teardown closes admission, disarms every live goal, cancels active work with the `parent` cause, and awaits the driver plus agent quiescence while its event fence remains installed.
## Model Experience
@@ -69,5 +60,5 @@ Append-only within an epoch: each admitted round extends the existing conversati
- **No independent evaluator** — the model-facing goal policy decides when evidence is sufficient for completion and whether a blocker is semantically unchanged; evaluator-backed certification remains deferred.
- **Same-session execution only** — this package deliberately does not spawn a fresh agent, fork a session prefix, or implement Ralph-style independent attempts; that workflow belongs to its own plugin layer.
- **Accepted-queue unload race** — Cordis plugin unload is asynchronous. A goal prompt already accepted by the agent inbox can begin and consume its round before unload starts; teardown then cancels the request, disarms the goal, and awaits quiescence. No later round starts.
- **Round cap, not resource budget** — token, currency, time, and provider quota policies remain independent; observed `RATE_LIMIT` and `QUOTA` stops only map into the blocked reason code `usage-limited`.
- **Round cap, not resource budget** — token, currency, time, and provider quota policies remain independent. Their session events are not attributed to the goal message or mapped into goal blocker codes.
- **No abnormal auto-retry** — transient provider and persistence failures require a later human-authorized resume rather than an implicit retry policy.

View File

@@ -23,30 +23,21 @@
当对应的活跃 agent 实例处于 idle 状态,且目标 phase 为 active、已启用续行并有剩余容量时,驱动器先为待处理 goal 变更创建检查点,再预留 `roundsStarted + 1`,对应当前 `{ goalId, revision }`。它会排入一条 `<goal_round>` 提示词,并携带 `GoalMessageSource`。通过 `agent/prompt-submit` 准入时,会在下游提示词钩子前后验证完整的排队记录与当前 goal;只有被接受的 `user/message` 才会增加 `roundsStarted`。因陈旧而被拒绝的预留不会消耗 Round 编号。
一个 Goal Round 对应一个普通会话轮次,该轮次可以包含多个模型/工具步骤。驱动器只会把预留与 `message` 轮次配对,且该轮次必须携带完全相同的 `GoalMessageSource`;可通过声明合并扩展的插件轮次触发器不会准入或替换该预留。用户消息仍是普通轮次,不消耗 goal 上限。如果用户工作在预留前进入 inbox,或加入预留的待处理批次,自动工作会让行,直到用户工作结算;混合批次中的待处理自动提示词会被拒绝,只有 agent 再次 idle 后才重新预留。
`MessageId` 通过持久 inbox 插入和准入来标识预留消息;它不标识轮次结果。用户消息不消耗 goal 上限。如果用户工作在预留前进入 inbox,或加入预留的待处理批次,自动工作会让行,直到 agent 进入 idle;混合批次中的待处理自动提示词会被拒绝,只有完成该检查点后才重新预留。
保留的提示词会点明经过 JSON 引用的目标与 `round/maxGoalRounds`,将当前工作区、工具结果和持久会话状态视为权威信息,要求在完成前提供证据,并要求在工作仍未完成时保持目标 active。引用可将多行或形似标签的目标文本保留为数据。goal 生命周期变更仍必须通过 `dsh-tool-goal` 的独立权限检查。
## 结算策略
## Idle 检查点
| 持久轮次结果 | Goal 操作 | 自动重试 |
|---|---|---|
| goal phase 仍为 active 且已启用续行时的 `completed` | 准入下一 Round;达到上限时以代码 `round-limit` 阻塞 | 是 |
| 已预留/准入 Goal Round 的取消,或其 `aborted` 结果 | `paused` | 否 |
| 未尝试 Goal Round 时取消 | 保留持久 phase;撤销激活 | 否 |
| `error` 且带 `RATE_LIMIT` 或 `QUOTA` | 设为 `blocked`,代码为 `usage-limited` | 否 |
| 其他 `error`、`max-tokens` 或非陈旧提示词拒绝 | 以诊断代码和消息设为 `blocked` | 否 |
| 持久性失败、dispose(资源释放)、中断或未知未来结果 | 撤销激活或阻塞,以便检查 | 否 |
某个 goal 在自身 Round 中发生的变更,会取代旧 revision 的结算。因此,即使物理轮次随后关闭,完成、暂停、阻塞和编辑仍具有最终决定权。任何异常结果都不会自动重试。
整个 agent 进入 idle 时,持久 goal phase 和 revision 具有权威性。phase 为 active、已启用续行且仍有容量的 goal 会预留下一 Round;完成、暂停、阻塞和编辑都会阻止续行。驱动器不会通过关联 goal 消息与 `turn/end` 来对前一段活动分类,因此提供方错误和 token 上限不属于提示词级 goal 结果。
## 生命周期与持久性
`goal/changed` 会产生持久性义务。排队工作前,驱动器会等待 `ctx.sessions.flush()`,并在等待后重新检查 goal revision 与竞争输入。关闭时的 flush 失败通过 `agent/error` 到达;即使后续一次性注入已经追加另一轮次,驱动器仍会把失败关联到完全相同的已关闭轮次,然后停用续行,避免另一 Round 启动。
`goal/changed` 会产生持久性义务。排队工作前,驱动器会等待 `ctx.sessions.flush()`,并在等待后重新检查 goal revision 与竞争输入。通过 `agent/error` 到达的 flush 失败会停用续行,避免另一 Round 启动。
此插件加载到现有 agent 上时绝不会继承续行启用状态。`GoalService.disarm()` 会移除进程本地权限,而不改变持久 phase、revision 或历史;之后由用户明确授权的 resume 会记录重新启用续行。会话 resume 和 fork 后,goal 领域通过 `agent/session-start` 处理应用相同规则。
取消采用先观察、后行动的顺序:具体循环会在清空队列或中止轮次前,发送带类型 cause 的 `agent/cancel-requested`。仅当取消操作所针对的是已预留或已准入的 Goal Round 尝试时,插件才会持久暂停 active goal;取消无关用户工作只会撤销进程本地续行权限。如果 pause 变更失败,驱动器会回退到停用续行。插件 teardown 会关闭准入,停用所有活跃 goal 的续行,以 `parent` cause 取消已经准入的 Round,并在事件隔离仍安装的情况下等待驱动器和 agent 完全停稳。
取消会移除 inbox 中待处理的工作,或留下 agent 范围的 aborted 状态。在下一次 idle 检查点,驱动器会暂停存在已预留或已准入尝试的 goal,避免取消后自动重启;与 goal 尝试无关的取消只会撤销进程本地续行权限。如果 pause 变更失败,驱动器会回退到停用续行。插件 teardown 会关闭准入,停用所有活跃 goal 的续行,以 `parent` cause 取消正在进行的工作,并在事件隔离仍安装的情况下等待驱动器和 agent 完全停稳。
## 模型体验
@@ -69,5 +60,5 @@
- **没有独立评估器**:面向模型的 goal 策略会判断证据是否足以完成,以及 blocker 在语义上是否未变;评估器支持的认证仍保持暂缓。
- **只在同一会话执行**:此包(package)有意不 spawn 新 agent、不 fork 会话前缀,也不实现 Ralph 风格的独立尝试;该工作流属于单独的插件层。
- **已接受队列的卸载竞态**:Cordis 插件卸载是异步的。已经被 agent inbox 接受的 goal 提示词可以在卸载开始前启动并消耗其 Round;teardown 随后会取消请求、撤销 goal 激活并等待完全停稳。不会再启动后续 Round。
- **只有 Round 上限,不是资源预算**:token、货币、时间与提供方配额策略保持独立;观察到 `RATE_LIMIT` 和 `QUOTA` 时,只会映射为阻塞原因代码 `usage-limited`。
- **只有 Round 上限,不是资源预算**:token、货币、时间与提供方配额策略保持独立。对应的会话事件不会归属于 goal 消息,也不会映射为 goal 阻塞代码。
- **异常情况不自动重试**:暂时性的提供方与持久化失败需要之后由用户授权 resume,而不会采用隐式重试策略。

View File

@@ -8,15 +8,11 @@ import { FiberState } from 'cordis'
import type { Context } from 'cordis'
import type { Agent, PromptDecision } from '@deepseek-ai/dsh-agent'
import type { GoalMessageSource, GoalRef, GoalView } from '@deepseek-ai/dsh-goal'
import { createUserMessage, assertNever } from '@deepseek-ai/dsh-llm'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, MessageId, MessageSource } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session'
import { classifyGoalRound } from './outcome.ts'
import type { GoalRoundOutcome } from './outcome.ts'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import { renderGoalRoundPrompt } from './prompt.ts'
export { classifyGoalRound } from './outcome.ts'
export type { GoalRoundOutcome } from './outcome.ts'
export { renderGoalRoundPrompt } from './prompt.ts'
export const name = 'goal-session'
@@ -31,13 +27,11 @@ interface RoundIdentity {
readonly round: number
}
/** One queued or admitted attempt, retained until its physical turn settles. */
/** One queued or admitted goal message retained until whole-agent quiescence. */
interface RoundAttempt extends RoundIdentity {
readonly messageId: MessageId
readonly content: ContentBlock[]
phase: 'queued' | 'admitted'
turn: number | undefined
reason: TurnEndReason | undefined
stale: boolean
}
@@ -45,13 +39,11 @@ interface RoundAttempt extends RoundIdentity {
interface DriverState {
readonly agent: Agent
attempt: RoundAttempt | undefined
openTurn: number | undefined
competingQueued: boolean
needsCheckpoint: boolean
requested: boolean
run: Promise<void> | undefined
stopping: boolean
readonly flushFailedTurns: Set<number>
}
/** Whether a source identifies an automatic, positive-numbered goal round. */
@@ -92,13 +84,11 @@ export function apply(ctx: Context): void {
const state: DriverState = {
agent,
attempt: undefined,
openTurn: undefined,
competingQueued: false,
needsCheckpoint: false,
requested: false,
run: undefined,
stopping: false,
flushFailedTurns: new Set(),
}
states.set(agent, state)
return state
@@ -134,28 +124,7 @@ export function apply(ctx: Context): void {
}
}
/** Apply one closed-round outcome only to the exact still-current revision. */
function applyOutcome(state: DriverState, goal: GoalView, outcome: GoalRoundOutcome): void {
const ref = goalRef(goal)
switch (outcome.kind) {
case 'continue':
return
case 'pause':
ctx.goals.pause(state.agent, ref)
return
case 'blocked':
ctx.goals.block(state.agent, ref, { code: outcome.code, message: outcome.message })
return
case 'disarm':
ctx.goals.disarm(state.agent)
return
/* v8 ignore next 2 -- GoalRoundOutcome is closed and every member is handled above */
default:
assertNever(outcome, 'goal round outcome')
}
}
/** Process a settled attempt, then reserve at most one next round. */
/** Process admitted work at quiescence, then reserve at most one next round. */
async function drive(state: DriverState): Promise<void> {
const { agent } = state
if (!readyToDrive(state)) return
@@ -166,8 +135,7 @@ export function apply(ctx: Context): void {
await ctx.sessions.flush(agent.session)
} catch (error: unknown) {
ctx.logger.warn(`goal-session: durability checkpoint failed for agent "${agent.id}": ${renderThrown(error)}`)
const goal = currentGoal(state)
if (goal !== undefined) applyOutcome(state, goal, { kind: 'disarm', reason: 'durability-failed' })
disarm(state)
return
}
// A mutation or ordinary prompt may have arrived while the checkpoint
@@ -177,27 +145,8 @@ export function apply(ctx: Context): void {
const attempt = state.attempt
if (attempt !== undefined) {
// Still unsettled: a contained turn-close failure reaches idle with the
// attempt's turn open in the log and no terminal reason recorded, so
// the drive pass must yield rather than misread it as settled.
if (attempt.reason === undefined) return
if (attempt.phase === 'queued') return
state.attempt = undefined
const turn = attempt.turn
/* v8 ignore next -- a closed attempt acquired its turn at turn/start */
if (turn === undefined) throw new Error('settled goal-round attempt lacks a turn')
const durable = !state.flushFailedTurns.delete(turn)
const goal = currentGoal(state)
if (goal !== undefined && goal.id === attempt.goalId && goal.revision === attempt.revision
&& goal.phase === 'active' && goal.activation === 'armed') {
const outcome = classifyGoalRound(attempt.reason, durable)
if (!attempt.stale) applyOutcome(state, goal, outcome)
}
if (!readyToDrive(state)) return
// The loop's persistence is eager write-behind with no turn-end flush,
// so this driver owns the round's durability barrier: checkpoint the
// settled round before reserving another (re-entering drive through
// the flush path above), disarming on failure instead of queueing an
// autonomous round on state that was never persisted.
state.needsCheckpoint = true
state.requested = true
return
@@ -226,8 +175,6 @@ export function apply(ctx: Context): void {
messageId: message.id,
content,
phase: 'queued',
turn: undefined,
reason: undefined,
stale: false,
}
state.attempt = reservation
@@ -286,12 +233,8 @@ export function apply(ctx: Context): void {
// One composite effect keeps the admission fence installed until this
// plugin's own scheduling tasks settle.
ctx.effect(function* () {
/** Mark a post-turn persistence failure before idle scheduling can run. */
ctx.on('agent/error', (agent, turn) => {
ctx.on('agent/error', (agent) => {
const state = stateFor(agent)
const closed = agent.session.events.some(event => event.type === 'turn/end' && event.data.turn === turn)
if (!closed) return
if (state.attempt?.turn === turn) state.flushFailedTurns.add(turn)
disarm(state)
})
@@ -300,10 +243,8 @@ export function apply(ctx: Context): void {
ctx.on('agent/session-start', (agent) => {
const state = stateFor(agent)
state.attempt = undefined
state.openTurn = undefined
state.competingQueued = false
state.needsCheckpoint = false
state.flushFailedTurns.clear()
})
ctx.on('agent/status', (agent, status) => {
const state = stateFor(agent)
@@ -311,11 +252,10 @@ export function apply(ctx: Context): void {
state.competingQueued = false
const attempt = state.attempt
const goal = currentGoal(state)
if (attempt !== undefined && attempt.turn === undefined && attempt.reason === undefined
&& goal?.phase === 'active' && goal.activation === 'armed') {
if (attempt?.phase === 'queued' && goal?.phase === 'active' && goal.activation === 'armed') {
state.attempt = undefined
try {
applyOutcome(state, goal, { kind: 'pause', reason: 'cancelled' })
ctx.goals.pause(agent, goalRef(goal))
} catch (error: unknown) {
ctx.logger.warn(`goal-session: could not pause cancelled goal for agent "${agent.id}": ${renderThrown(error)}`)
disarm(state)
@@ -345,21 +285,23 @@ export function apply(ctx: Context): void {
}
return
}
case 'turn/start': {
state.openTurn = event.data.turn
return
}
case 'user/message':
if (state.attempt !== undefined && event.data.id === state.attempt.messageId) {
state.attempt.phase = 'admitted'
/* v8 ignore next -- the loop logs admitted input inside an open turn */
if (state.openTurn !== undefined) state.attempt.turn = state.openTurn
}
return
case 'turn/end':
if (state.attempt?.turn === event.data.turn) state.attempt.reason = event.data.reason
/* v8 ignore next -- balanced live turns close the open turn just observed by this listener */
if (state.openTurn === event.data.turn) state.openTurn = undefined
if (event.data.reason.kind !== 'aborted') return
{
const goal = currentGoal(state)
if (goal?.phase !== 'active' || goal.activation !== 'armed') return
try {
ctx.goals.pause(agent, goalRef(goal))
} catch (error: unknown) {
ctx.logger.warn(`goal-session: could not pause cancelled goal for agent "${agent.id}": ${renderThrown(error)}`)
disarm(state)
}
}
return
default:
return
@@ -413,7 +355,7 @@ export function apply(ctx: Context): void {
// starve every later drive pass. Clear it and let the driver
// reschedule the round.
const attempt = state.attempt
if (attempt !== undefined && sameRound(source, attempt) && attempt.turn === undefined) {
if (attempt !== undefined && sameRound(source, attempt) && attempt.phase === 'queued') {
state.attempt = undefined
requestDrive(state)
}
@@ -468,9 +410,6 @@ export function apply(ctx: Context): void {
const attempt = state.attempt
if (attempt !== undefined) {
attempt.stale = true
if (attempt.phase === 'admitted' && state.agent.status === 'running') {
state.agent.cancel({ kind: 'parent' })
}
}
if (state.run !== undefined) waits.push(state.run)
}

View File

@@ -1,53 +0,0 @@
/** Typed settlement policy for one admitted same-session goal round. */
import type { TurnEndReason } from '@deepseek-ai/dsh-session'
/** Driver action derived from one closed goal-owned turn. */
export type GoalRoundOutcome =
| { readonly kind: 'continue' }
| { readonly kind: 'pause'; readonly reason: string }
| {
readonly kind: 'blocked'
readonly code: 'usage-limited' | 'turn-error' | 'max-tokens' | 'unknown-turn-outcome'
readonly message: string
}
| { readonly kind: 'disarm'; readonly reason: 'durability-failed' | 'disposed' | 'interrupted' }
/**
* Classify one closed goal round without mutating goal state.
* @param reason - durable reason from the round's `turn/end`.
* @param durable - whether the closing flush reached its durability checkpoint.
* @returns the single driver action; no abnormal outcome requests an automatic retry.
*/
export function classifyGoalRound(reason: TurnEndReason, durable: boolean): GoalRoundOutcome {
if (!durable) return { kind: 'disarm', reason: 'durability-failed' }
const extensibleReason: { readonly kind: string } = reason
switch (reason.kind) {
case 'completed':
return { kind: 'continue' }
case 'aborted':
return { kind: 'pause', reason: 'cancelled' }
case 'error': {
const error = reason.error
const code = typeof error === 'object' && error !== null && 'code' in error
? error.code
: undefined
const message = error instanceof Error ? error.message : String(error)
return code === 'RATE_LIMIT' || code === 'QUOTA'
? { kind: 'blocked', code: 'usage-limited', message }
: { kind: 'blocked', code: 'turn-error', message }
}
case 'max-tokens':
return { kind: 'blocked', code: 'max-tokens', message: 'model output reached max tokens' }
case 'interrupted':
return { kind: 'disarm', reason: 'interrupted' }
// TurnEndReason is merge-extensible. An unknown producer cannot opt into
// automatic retry merely by adding a tag; stop for inspection instead.
default:
return {
kind: 'blocked',
code: 'unknown-turn-outcome',
message: `unknown turn outcome: ${extensibleReason.kind}`,
}
}
}