Merge remote-tracking branch 'origin/master' into worktree/pr628-merge-20260727

# Conflicts:
#	.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml
#	.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md
#	.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.zh.md
#	docs/architecture.i18n.yaml
#	docs/architecture.md
#	docs/architecture.zh.md
#	docs/cordis-catalog/events.md
#	docs/core-data-structures/llm-streaming.i18n.yaml
#	docs/core-data-structures/llm-streaming.md
#	docs/core-data-structures/llm-streaming.zh.md
#	docs/event-producer-consumer.md
#	examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl
#	packages/compact/compact-basic/src/index.ts
#	packages/compact/compact-basic/tests/compact-basic.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/loop.ts
#	packages/core/agent-loop/tests/request-recovery.spec.ts
#	packages/core/agent/src/types.ts
#	packages/core/scope/tests/invariant.spec.ts
#	packages/llm/llm-retry/README.i18n.yaml
#	packages/llm/llm-retry/README.md
#	packages/llm/llm-retry/README.zh.md
#	packages/llm/llm-retry/src/index.ts
#	packages/llm/llm-retry/src/invariant.ts
#	packages/llm/llm-retry/tests/invariant.spec.ts
#	packages/llm/llm-retry/tests/retry.spec.ts
#	packages/plan/plan-mode/src/index.ts
#	packages/plan/plan-mode/tests/integration.spec.ts
#	packages/plan/plan-mode/tests/plan-mode.spec.ts
This commit is contained in:
Tianyi Cui
2026-07-27 23:29:26 +08:00
461 changed files with 8294 additions and 10405 deletions

View File

@@ -8,9 +8,9 @@
*
* The state in force is folded from the session log (`plan/mode`, last one
* wins), so resume and fork restore it without a live mirror. User selections
* are held as pending intent until a turn boundary because every session event
* is turn-enclosed. The service flushes before the affected request assembly
* on prompt submission, ordinary continuation, and request-recovery retry.
* are held as pending intent until an in-turn request boundary because every
* session event is turn-enclosed. The service flushes at `agent/step` before
* the affected request assembly, including retry turns.
*
* The exit tool remains registered while plan mode is inactive so crossing a
* boundary changes only the prompt section, not the request tool catalog.
@@ -145,9 +145,9 @@ export class PlanModeService extends Service {
private readonly section: string
/**
* Latest selection per session awaiting a turn-boundary flush. `narrate` is
* true for user selections and false for the exit tool, whose result already
* narrates the transition.
* Latest selection per session awaiting an in-turn request-boundary flush.
* `narrate` is true for user selections and false for the exit tool, whose
* result already narrates the transition.
*/
private readonly pendingIntents = new WeakMap<Session, { active: boolean; narrate: boolean }>()
@@ -156,46 +156,20 @@ export class PlanModeService extends Service {
this.section = resolveConfig(config).section
let disposed = false
// Boundary flushes use loop interception seams, not post-commit
// `session/event` observation. Flush after next(): a selection arriving
// while a downstream async listener awaits must still shape the request
// this boundary precedes. Failures are contained so policy cannot block a
// prompt or turn; a failed append remains pending for a later boundary.
const flushAfter = async <T>(agent: Agent, next: () => Promise<T>): Promise<T> => {
const decision = await next()
if (!disposed) {
try {
this.onBoundary(agent)
} catch (error) {
ctx.logger.warn('dsh-plan-mode: boundary flush failed: %o', error)
}
}
return decision
}
ctx.on('agent/prompt-submit', (agent, _content, _source, _signal, next) =>
flushAfter(agent, next), { prepend: true })
ctx.on('agent/turn-continuation', (agent, _turn, _decision, _signal, next) =>
flushAfter(agent, next), { prepend: true })
ctx.on('agent/request-error', async (
agent,
_turn,
_step,
_error,
_failure,
_priorFailures,
_retryPolicy,
_signal,
next,
) => {
const decision = await next()
// A waterfall can retain this wrapper after Cordis unregisters it.
if (disposed || decision.action !== 'retry') return decision
// The boundary flush uses the loop's `agent/step` interception seam, not
// post-commit `session/event` observation. `agent/step` runs inside the
// open turn before every request derivation (including turn 1 step 1), so
// it is the sole flush point: prompt admission happens pre-turn, where a
// `plan/mode` append would land outside any open turn. Failures are
// contained so policy cannot block a turn; a failed append remains
// pending for a later boundary.
ctx.on('agent/step', (agent) => {
if (disposed) return
try {
this.onBoundary(agent)
} catch (error) {
ctx.logger.warn('dsh-plan-mode: boundary flush failed: %o', error)
}
return decision
}, { prepend: true })
ctx.effect(() => () => { disposed = true }, 'dsh-plan-mode: close boundary lifetime')
@@ -227,7 +201,7 @@ export class PlanModeService extends Service {
return { kind: 'success', text: 'Plan mode is already inactive.' }
}
this.set(agent, true)
if (message !== '') agent.steer([{ type: 'text', text: message }])
if (message !== '') agent.steer({ content: [{ type: 'text', text: message }], source: { kind: 'user' } })
return {
kind: 'success',
text: 'Entering plan mode (applies from the next step). Use /plan off to leave.',
@@ -324,7 +298,7 @@ export class PlanModeService extends Service {
}
/**
* Select whether plan mode should be active from the next turn boundary.
* Select whether plan mode should be active from the next request boundary.
* Repeated selection of the current or already-pending state is a no-op.
*
* @param agent The agent to switch.