Merge remote-tracking branch 'origin/master' into xtr/react-loop-simplification
# Conflicts: # .agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml # .agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md # .agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md # .agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.i18n.yaml # .agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.md # .agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.zh.md # .agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml # .agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md # .agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md # docs/architecture.i18n.yaml # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.i18n.yaml # docs/core-data-structures/core.md # docs/core-data-structures/core.zh.md # docs/defensive-patterns.i18n.yaml # packages/client/runtime/src/client/sessions/session.ts # packages/client/runtime/tests/queue-store.spec.ts # packages/context/time-context/tests/time-context.spec.ts # packages/context/workspace-context/tests/workspace-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/core/agent-loop/README.i18n.yaml # packages/core/agent-loop/README.md # packages/core/agent-loop/README.zh.md # packages/core/agent-loop/src/agent.ts # packages/core/agent/README.i18n.yaml # packages/core/agent/README.md # packages/core/agent/README.zh.md # packages/core/agent/src/types.ts # packages/core/agent/tests/agent.spec.ts # packages/core/scope/src/scoped-events.generated.ts # packages/goal/command-goal/tests/command-goal.spec.ts # packages/goal/goal-session/src/index.ts # packages/goal/goal-session/tests/goal-session.spec.ts # packages/goal/goal/tests/goal.spec.ts # packages/goal/goal/tests/projection.spec.ts # packages/goal/tool-goal/tests/tool-goal.spec.ts # packages/host/apiproxy/src/api-proxy.ts # packages/host/apiproxy/src/api/events.schema.ts # packages/host/apiproxy/src/api/events.ts # packages/host/apiproxy/tests/api-proxy-workspace.spec.ts # packages/llm/llm/README.i18n.yaml # packages/llm/llm/README.zh.md # packages/llm/llm/src/index.ts # packages/pty/pty-local/tests/index.spec.ts # packages/pty/pty-local/tests/local.spec.ts # packages/pty/pty/tests/service.spec.ts # packages/pty/tool-pty/tests/loader-composition.spec.ts # packages/pty/tool-pty/tests/tools.spec.ts # packages/skill/tool-skill/tests/tool-skill.spec.ts # packages/tasks/tasks-local/tests/tasks.spec.ts # packages/ui/tui/src/index.ts # packages/ui/tui/tests/harness.ts # packages/ui/tui/tests/tui.spec.ts # scripts/gen-cordis-catalog.ts # scripts/type-equiv.manifest.json
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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
|
||||
# pnpm run verify-translation-pairing --write packages/goal/goal-session/README.md
|
||||
README.md: 6a1c3b9455c93762c2458109c753588ce9a08d9a
|
||||
README.zh.md: d06d2d96deb845f72c5c88bc47a04a1af621c078
|
||||
README.zh.md: 4162411bf2dbeebbec8da6433c71e176057c4277
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
[`ctx.goals`](../goal/README.md) 的同会话续行驱动器。它通过公开 `Agent` 与会话 seam,把活跃且已激活的目标转换为连续的 [goal round](../../../docs/glossary.md#goal-round);[同会话驱动器 Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.md)负责竞态和生命周期理由。
|
||||
[`ctx.goals`](../goal/README.md) 的同会话续行驱动器。它通过公开 `Agent` 与会话 seam,把 phase 为 active 且已启用续行的目标转换为连续的 [Goal Round](../../../docs/glossary.md#goal-round);[同会话驱动器 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-19-same-session-goal-round-driver.md) 记载竞态与生命周期方面的设计理由。
|
||||
|
||||
## 组合
|
||||
|
||||
@@ -21,9 +21,9 @@
|
||||
|
||||
## Round 契约
|
||||
|
||||
当完全相同的活跃 agent 处于 idle 状态,且目标 active、已经激活并有剩余容量时,驱动器先为待处理 goal 变更创建检查点,再预留 `roundsStarted + 1`,对应当前 `{ goalId, revision }`。它会排入一条 `<goal_round>` 提示词,并携带 `GoalMessageSource`。通过 `agent/prompt-submit` 准入时,会在下游提示词 hook 前后同时验证完整的排队记录与当前 goal;只有被接受的 `user/message` 才会增加 `roundsStarted`。因陈旧而被拒绝的预留不会消耗 round 编号。
|
||||
当对应的活跃 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 后才重新预留。
|
||||
一个 Goal Round 对应一个普通会话轮次,该轮次可以包含多个模型/工具步骤。驱动器只会把预留与 `message` 轮次配对,且该轮次必须携带完全相同的 `GoalMessageSource`;可通过声明合并扩展的插件轮次触发器不会准入或替换该预留。用户消息仍是普通轮次,不消耗 goal 上限。如果用户工作在预留前进入 inbox,或加入预留的待处理批次,自动工作会让行,直到用户工作结算;混合批次中的待处理自动提示词会被拒绝,只有 agent 再次 idle 后才重新预留。
|
||||
|
||||
保留的提示词会点明经过 JSON 引用的目标与 `round/maxGoalRounds`,将当前工作区、工具结果和持久会话状态视为权威信息,要求在完成前提供证据,并要求在工作仍未完成时保持目标 active。引用可将多行或形似标签的目标文本保留为数据。goal 生命周期变更仍必须通过 `dsh-tool-goal` 的独立权限检查。
|
||||
|
||||
@@ -31,43 +31,43 @@
|
||||
|
||||
| 持久轮次结果 | Goal 操作 | 自动重试 |
|
||||
|---|---|---|
|
||||
| goal 仍 active 且已激活时的 `completed` | 准入下一 round;达到上限时以代码 `round-limit` 阻塞 | 是 |
|
||||
| 已预留/准入 goal round 的取消,或其 `aborted` 结果 | `paused` | 否 |
|
||||
| 未尝试 goal round 时取消 | 保留持久 phase;撤销激活 | 否 |
|
||||
| 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 的结算。因此,即使物理轮次随后关闭,完成、暂停、阻塞和编辑仍具有最终决定权。任何异常结果都不会自动重试。
|
||||
某个 goal 在自身 Round 中发生的变更,会取代旧 revision 的结算。因此,即使物理轮次随后关闭,完成、暂停、阻塞和编辑仍具有最终决定权。任何异常结果都不会自动重试。
|
||||
|
||||
## 生命周期与持久性
|
||||
|
||||
`goal/changed` 会产生持久性义务。排队工作前,驱动器会等待 `ctx.sessions.flush()`,并在等待后重新检查 goal revision 与竞争输入。关闭时的 flush 失败通过 `agent/error` 到达;即使后续一次性注入已经追加另一轮次,驱动器仍会把失败关联到完全相同的已关闭轮次,然后撤销激活,避免另一 round 启动。
|
||||
`goal/changed` 会产生持久性义务。排队工作前,驱动器会等待 `ctx.sessions.flush()`,并在等待后重新检查 goal revision 与竞争输入。关闭时的 flush 失败通过 `agent/error` 到达;即使后续一次性注入已经追加另一轮次,驱动器仍会把失败关联到完全相同的已关闭轮次,然后停用续行,避免另一 Round 启动。
|
||||
|
||||
此插件加载到现有 agent 上时绝不会继承激活状态。`GoalService.disarm()` 会移除进程本地权限,而不改变持久 phase、revision 或历史;之后由用户明确授权的 resume 会记录重新激活。会话 resume 和 fork 后,goal 领域通过 `agent/session-start` 处理应用相同规则。
|
||||
此插件加载到现有 agent 上时绝不会继承续行启用状态。`GoalService.disarm()` 会移除进程本地权限,而不改变持久 phase、revision 或历史;之后由用户明确授权的 resume 会记录重新启用续行。会话 resume 和 fork 后,goal 领域通过 `agent/session-start` 处理应用相同规则。
|
||||
|
||||
取消采用先观察、后行动的顺序:具体循环会在清空队列或中止轮次前,发送带类型 cause 的 `agent/cancel-requested`。只有取消操作拥有已预留或已准入的 goal 尝试时,插件才会持久暂停 active goal;取消无关用户工作只会撤销进程本地续行权限。如果 pause 变更失败,驱动器会回退到撤销激活。插件 teardown 会关闭准入,撤销所有活跃 goal 的激活,以 `parent` cause 取消已经准入的 round,并在事件隔离仍安装的情况下等待驱动器和 agent 完全停稳。
|
||||
取消采用先观察、后行动的顺序:具体循环会在清空队列或中止轮次前,发送带类型 cause 的 `agent/cancel-requested`。仅当取消操作所针对的是已预留或已准入的 Goal Round 尝试时,插件才会持久暂停 active goal;取消无关用户工作只会撤销进程本地续行权限。如果 pause 变更失败,驱动器会回退到停用续行。插件 teardown 会关闭准入,停用所有活跃 goal 的续行,以 `parent` cause 取消已经准入的 Round,并在事件隔离仍安装的情况下等待驱动器和 agent 完全停稳。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### Goal-round 提示词
|
||||
### Goal Round 提示词
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
每个已准入 round 都是一段保留的用户角色 `<goal_round>` 块,其中点明完整目标与正 round 编号。更早的用户消息、goal 状态快照、assistant 输出与工具记录仍保留在同一会话历史中。
|
||||
每个已准入 Round 都是一段保留的用户角色 `<goal_round>` 块,其中点明完整目标与正数 Round 编号。更早的用户消息、goal 状态快照、assistant 输出与工具记录仍保留在同一会话历史中。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
每个已准入 round 会增加一个固定指令块和目标。后续请求会重新发送保留的 round,直到压缩将其遮蔽;不会创建新 agent,也不会复制对话前缀。
|
||||
每个已准入 Round 会增加一个固定指令块和目标。后续请求会重新发送保留的 Round,直到压缩(compaction)将其遮蔽;不会创建新 agent,也不会复制对话前缀。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
在一个 epoch 内仅追加:每个已准入 round 都会在可复用前缀后扩展现有对话。压缩可能替换派生历史后缀,并移动可复用边界。
|
||||
在一个 epoch 内仅追加:每个已准入 Round 都会在可复用前缀后扩展现有对话。压缩可能替换派生历史后缀,并移动可复用边界。
|
||||
|
||||
## 已知限制与暂缓工作
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **没有独立评估器**:面向模型的 goal 策略会判断证据是否足以完成,以及 blocker 在语义上是否未变;评估器支持的认证仍保持暂缓。
|
||||
- **只在同一会话执行**:此包有意不 spawn 新 agent、不 fork 会话前缀,也不实现 Ralph 风格的独立尝试;该工作流属于自己的插件层。
|
||||
- **已接受队列的卸载竞态**:Cordis 插件卸载是异步的。已经被 agent inbox 接受的 goal 提示词可以在卸载开始前启动并消耗其 round;teardown 随后会取消请求、撤销 goal 激活并等待完全停稳。不会再启动后续 round。
|
||||
- **只有 round 上限,不是资源预算**:token、货币、时间与提供方配额策略保持独立;观察到 `RATE_LIMIT` 和 `QUOTA` 时,只会映射为阻塞原因代码 `usage-limited`。
|
||||
- **异常情况不自动重试**:短暂的提供方与持久化失败需要之后由用户授权 resume,而不是隐式重试策略。
|
||||
- **只在同一会话执行**:此包(package)有意不 spawn 新 agent、不 fork 会话前缀,也不实现 Ralph 风格的独立尝试;该工作流属于单独的插件层。
|
||||
- **已接受队列的卸载竞态**:Cordis 插件卸载是异步的。已经被 agent inbox 接受的 goal 提示词可以在卸载开始前启动并消耗其 Round;teardown 随后会取消请求、撤销 goal 激活并等待完全停稳。不会再启动后续 Round。
|
||||
- **只有 Round 上限,不是资源预算**:token、货币、时间与提供方配额策略保持独立;观察到 `RATE_LIMIT` 和 `QUOTA` 时,只会映射为阻塞原因代码 `usage-limited`。
|
||||
- **异常情况不自动重试**:暂时性的提供方与持久化失败需要之后由用户授权 resume,而不会采用隐式重试策略。
|
||||
|
||||
@@ -324,6 +324,37 @@ export function apply(ctx: Context): void {
|
||||
requestDrive(state)
|
||||
}
|
||||
})
|
||||
ctx.on('agent/inbox/enqueue', (agent, item) => {
|
||||
const state = stateFor(agent)
|
||||
const attempt = state.attempt
|
||||
if (attempt !== undefined && sameQueued(item.message.content, item.message.source, attempt)) return
|
||||
state.competingQueued = true
|
||||
if (attempt?.phase === 'queued') attempt.stale = true
|
||||
})
|
||||
ctx.on('agent/cancel-requested', (agent, cause) => {
|
||||
const state = stateFor(agent)
|
||||
const attempt = state.attempt
|
||||
state.competingQueued = false
|
||||
const goal = currentGoal(state)
|
||||
if (goal?.phase === 'active' && goal.activation === 'armed') {
|
||||
if (attempt === undefined) {
|
||||
disarm(state)
|
||||
return
|
||||
}
|
||||
// An admitted round closes durably as aborted; retain it so the normal
|
||||
// turn outcome path appends pause after cancellation reaches idle.
|
||||
// Pausing here would stage context into the active outbox only for this
|
||||
// same cancel() call to discard it.
|
||||
if (attempt.turn !== undefined || attempt.phase === 'admitted') return
|
||||
state.attempt = undefined
|
||||
try {
|
||||
applyOutcome(state, goal, { kind: 'pause', reason: cause.kind })
|
||||
} catch (error: unknown) {
|
||||
ctx.logger.warn(`goal-session: could not pause cancelled goal for agent "${agent.id}": ${renderThrown(error)}`)
|
||||
disarm(state)
|
||||
}
|
||||
}
|
||||
})
|
||||
ctx.on('goal/changed', (agent) => {
|
||||
const state = stateFor(agent)
|
||||
state.needsCheckpoint = true
|
||||
|
||||
@@ -280,7 +280,7 @@ describe('same-session goal driving', () => {
|
||||
it('pauses and drops a reserved round when cancellation lands before admission', async () => {
|
||||
const test = await harness([])
|
||||
const cancel = test.ctx.on('agent/inbox/enqueue', (agent, info) => {
|
||||
if (agent === test.agent && info.source.kind === 'goal') {
|
||||
if (agent === test.agent && info.message.source.kind === 'goal') {
|
||||
cancel()
|
||||
agent.cancel({ kind: 'user' })
|
||||
}
|
||||
@@ -331,7 +331,7 @@ describe('same-session goal driving', () => {
|
||||
const test = await harness([textResponse('human batch'), textResponse('later goal')])
|
||||
let inserted = false
|
||||
test.ctx.on('agent/inbox/enqueue', (agent, info) => {
|
||||
if (agent !== test.agent || info.source.kind !== 'goal' || inserted) return
|
||||
if (agent !== test.agent || info.message.source.kind !== 'goal' || inserted) return
|
||||
inserted = true
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'human joined the pending batch' }], source: { kind: 'user' } }))
|
||||
})
|
||||
@@ -349,7 +349,7 @@ describe('same-session goal driving', () => {
|
||||
const test = await harness([textResponse('new revision')])
|
||||
let edited = false
|
||||
test.ctx.on('agent/inbox/enqueue', (agent, info) => {
|
||||
if (agent !== test.agent || info.source.kind !== 'goal' || edited) return
|
||||
if (agent !== test.agent || info.message.source.kind !== 'goal' || edited) return
|
||||
edited = true
|
||||
const current = test.ctx.goals.get(agent)
|
||||
if (current === undefined) throw new Error('missing goal during queued edit')
|
||||
@@ -629,7 +629,7 @@ describe('same-session goal driving', () => {
|
||||
const test = await harness([textResponse('retry after containment')])
|
||||
let armed = true
|
||||
test.ctx.on('agent/inbox/enqueue', (agent, info) => {
|
||||
if (agent !== test.agent || info.source.kind !== 'goal' || !armed) return
|
||||
if (agent !== test.agent || info.message.source.kind !== 'goal' || !armed) return
|
||||
armed = false
|
||||
vi.spyOn(test.ctx.goals, 'get').mockImplementationOnce(() => {
|
||||
throw new Error('admission projection failed')
|
||||
@@ -705,7 +705,7 @@ describe('same-session goal driving', () => {
|
||||
it('falls back to disarming when a cancelled reservation cannot be paused', async () => {
|
||||
const test = await harness([])
|
||||
const cancel = test.ctx.on('agent/inbox/enqueue', (agent, info) => {
|
||||
if (agent !== test.agent || info.source.kind !== 'goal') return
|
||||
if (agent !== test.agent || info.message.source.kind !== 'goal') return
|
||||
cancel()
|
||||
vi.spyOn(test.ctx.goals, 'pause').mockImplementationOnce(() => {
|
||||
throw new Error('pause failed')
|
||||
@@ -759,7 +759,7 @@ describe('same-session goal driving', () => {
|
||||
const test = await harness([])
|
||||
let unloading: Promise<void> | undefined
|
||||
test.ctx.on('agent/inbox/enqueue', (agent, info) => {
|
||||
if (agent === test.agent && info.source.kind === 'goal' && unloading === undefined) {
|
||||
if (agent === test.agent && info.message.source.kind === 'goal' && unloading === undefined) {
|
||||
unloading = Promise.resolve(test.driver.dispose())
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user