refactor(schedule): simplify request zone authority

This commit is contained in:
pku-xht
2026-08-06 19:33:19 +08:00
committed by Tianyi Cui
parent a667ec55d6
commit cd59acd6f6
25 changed files with 466 additions and 1007 deletions
+1 -5
View File
@@ -55,11 +55,7 @@ Configured agents start automatically. A model call requires both `provider` and
The concrete `ReactLoopAgent`, its inbox, and run controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy.
The unified `send()` primitive routes content and source by (`target` × `wakeup`); `followup`/`steer`/`inject` are its fixed-preset aliases. `followup()` appends to the `next-turn` FIFO and wakes the driver, `steer()` appends to the `next-step` inbox and wakes it, and `inject()` appends to that same `next-step` inbox without waking it. At a turn boundary the driver opens the durable turn, then atomically claims pending next-step input plus one queued prompt; between steps it claims only next-step input. Claiming removes the batch through pure deletion splices and emits `agent/inbox/claimed { message, turn }` once per message.
System-prompt assembly runs after that claim and before `agent/pre-step`. A provider may use this bounded asynchronous window to stage an authority-delimited envelope in the next-step inbox. The driver adds the envelope's ordinary messages to the pre-step proposal, so guards and transformations see late steering, but keeps preparation authorities outside that decision. Rejection leaves the claimed batch removed; an empty enter decision consumes the envelope without opening a step. A non-empty enter appends the transformed messages followed by only the envelope's final authority after `step/start`. If preparation fails before then, the driver removes the envelope and settles at most its final appendable authority inside the no-step turn, so no old authority leaks while unrelated pending input retains its normal ownership. The [durable time-context decision](../../../.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md) owns the current producer.
Input inserted after an ordinary claim remains pending unless it belongs to that bounded envelope, and idle injection waits until follow-up or steering wakes the driver.
The unified `send()` primitive routes content and source by (`target` × `wakeup`); `followup`/`steer`/`inject` are its fixed-preset aliases. `followup()` appends to the `next-turn` FIFO and wakes the driver, `steer()` appends to the `next-step` inbox and wakes it, and `inject()` appends to that same `next-step` inbox without waking it. At a turn boundary the driver opens the durable turn, then atomically claims pending next-step input plus one queued prompt; between steps it claims only next-step input. Claiming removes the batch through pure deletion splices and emits `agent/inbox/claimed { message, turn }` once per message. `agent/pre-step` then returns either rejection or the complete messages entering the proposed step. Rejection leaves the claimed batch removed and closes the turn without a step; input inserted after the claim remains pending, and idle injection waits until follow-up or steering wakes the driver.
Every inbox mutation publishes one normalized `agent/inbox/spliced` event before changing the live projection. Insertions, edits, removals, claiming, and cancellation replay through the same standard splice coordinates. Ordinary removals carry `outcome: 'canceled'` and emit `agent/inbox/discarded { message }`; claiming uses pure deletions with no outcome, after which the loop emits `agent/inbox/claimed`. Every insertion emits `agent/inbox/inserted { message }`. `MessageId` stays unique across both pending lists, and synchronous durable-event observers can reconstruct removed values from the pre-splice projection.
+4 -122
View File
@@ -51,36 +51,6 @@ type PreparedStep =
| { kind: 'reject' }
| { kind: 'enter'; messages: UserMessage[]; assembly: PromptAssembly }
/** The exact private time-context source shape that may span prompt assembly. */
function isPreparationAuthority(message: UserMessage, turn: number, step: number): boolean {
const source = message.source as unknown
if (typeof source !== 'object' || source === null || Array.isArray(source)) return false
const record = source as Record<string, unknown>
if (record['kind'] !== 'plugin' || record['plugin'] !== 'time-context') return false
const authority = record['authority']
return typeof authority === 'object'
&& authority !== null
&& !Array.isArray(authority)
&& (authority as Record<string, unknown>)['turn'] === turn
&& (authority as Record<string, unknown>)['step'] === step
}
/**
* Invoke the concrete driver's private Inbox range primitive without adding a
* cross-package public method or a source-only package import.
*/
function claimPreparationRange(
inbox: Inbox,
start: number,
count: number,
turn: number,
): UserMessage[] {
type DriverInbox = {
claimRange(target: InboxTarget, start: number, count: number, turn: number, publish?: boolean): UserMessage[]
}
return (inbox as unknown as DriverInbox).claimRange('next-step', start, count, turn)
}
/** Remove adapter-derived values before plugins propose the next request config. */
function requestProposal(header: EpochHeader): LlmCallConfig {
if (header.adapterDefaults === undefined) return header.config
@@ -261,86 +231,17 @@ export class ReactLoopAgent implements Agent {
signal.throwIfAborted()
const sections = renderContextSections(assembly)
const context = this.runtimeContext.project(joinContextSections(sections), sections)
const proposal = context === undefined ? claimed : [...claimed, context]
const preparation = this.preparationEnvelope(position.turn, position.step)
.filter(message => !isPreparationAuthority(message, position.turn, position.step))
const decision = await this.dispatch.waterfall(
'agent/pre-step', { messages: [...proposal, ...preparation], ...position, signal },
'agent/pre-step', { messages: claimed, ...position, signal },
(): Promise<PreStepDecision> => Promise.resolve<PreStepDecision>({
kind: 'enter',
messages: [...proposal, ...preparation],
messages: context === undefined ? claimed : [...claimed, context],
}),
)
signal.throwIfAborted()
return decision.kind === 'reject' ? decision : { ...decision, assembly }
}
/**
* Read the closed assembly envelope without consuming it. Non-authority
* messages enter the pre-step proposal; the final authority is resolved
* only after downstream pre-step transforms have settled.
*/
private preparationEnvelope(turn: number, step: number): UserMessage[] {
const pending = this.inbox.nextStep
const first = pending.findIndex(message => isPreparationAuthority(message, turn, step))
if (first < 0) return []
let last = first
for (let index = first + 1; index < pending.length; index += 1) {
const message = pending[index]
if (message !== undefined && isPreparationAuthority(message, turn, step)) last = index
}
return pending.slice(first, last + 1)
}
/**
* Claim the closed assembly envelope. Messages before or after its first and
* last authority retain ordinary next-step ownership.
*/
private claimPreparationEnvelope(turn: number, step: number): UserMessage[] {
const envelope = this.preparationEnvelope(turn, step)
const firstMessage = envelope[0]
if (firstMessage === undefined) return []
const first = this.inbox.nextStep.findIndex(message => message.id === firstMessage.id)
/* v8 ignore next -- preparationEnvelope returned a live next-step member. */
if (first < 0) throw new Error('preparation envelope moved before it could be claimed')
return claimPreparationRange(this.inbox, first, envelope.length, turn)
}
/**
* Close context-only assembly output inside a turn that never reached
* `step/start`. Each authority leaves the inbox before its surface append,
* so an append rejection fails closed instead of leaking it into a later
* turn. Steering and unrelated pending input are not touched.
*/
private settlePreparationAuthorities(turn: number, step: number): void {
let finalAuthority: UserMessage | undefined
for (const authority of [...this.inbox.nextStep]) {
if (!isPreparationAuthority(authority, turn, step)) continue
const index = this.inbox.nextStep.findIndex(message => message.id === authority.id)
if (index < 0) continue
let claimed: UserMessage[]
try {
claimed = claimPreparationRange(this.inbox, index, 1, turn)
} catch (error: unknown) {
this.dispatch.emit('agent/error', { turn, step, error })
this.loopCtx.logger.warn(
`agent "${this.id}": failed to remove pre-step time context: ${errorChain(error)}`,
)
continue
}
finalAuthority = claimed.at(-1) ?? finalAuthority
}
if (finalAuthority === undefined) return
try {
this.session.append('user/message', finalAuthority, { surfaceOp: 'append' })
} catch (error: unknown) {
this.dispatch.emit('agent/error', { turn, step, error })
this.loopCtx.logger.warn(
`agent "${this.id}": dropped pre-step time context after append failed: ${errorChain(error)}`,
)
}
}
/** Open one turn before claiming its first proposed step. */
private async turn(): Promise<boolean> {
if (this.phase.kind !== 'running') {
@@ -358,27 +259,19 @@ export class ReactLoopAgent implements Agent {
phase.turn = turn
let turnEnds: TurnEndReason | null = null
let target: InboxTarget = 'next-turn'
let preparingStep: number | undefined
try {
while (true) {
signal.throwIfAborted()
const step = phase.step + 1
preparingStep = step
const decision = await this.preStep(target, { turn, step })
if (decision.kind === 'reject') {
turnEnds = { kind: 'blocked' }
return false
}
if (turnEnds && decision.messages.length === 0) {
this.claimPreparationEnvelope(turn, step)
preparingStep = undefined
break
}
if (turnEnds && decision.messages.length === 0) break
// A removed waking message or an enter decision rewritten to empty
// still owns the initial turn boundary, but it spends no model call.
if (phase.step === 0 && decision.messages.length === 0) {
this.claimPreparationEnvelope(turn, step)
preparingStep = undefined
turnEnds = { kind: 'completed' }
return false
}
@@ -386,14 +279,7 @@ export class ReactLoopAgent implements Agent {
this.session.append('step/start', { turn, step })
phase.step = step
try {
const preparation = this.claimPreparationEnvelope(turn, step)
preparingStep = undefined
const finalAuthority = preparation.findLast(message =>
isPreparationAuthority(message, turn, step))
for (const message of [
...decision.messages,
...(finalAuthority === undefined ? [] : [finalAuthority]),
]) {
for (const message of decision.messages) {
this.session.append('user/message', message, { surfaceOp: 'append' })
}
// max-tokens is sticky: once any step hits the ceiling, later steps
@@ -428,10 +314,6 @@ export class ReactLoopAgent implements Agent {
}
this.throwError(error)
} finally {
if (preparingStep !== undefined) {
this.settlePreparationAuthorities(turn, preparingStep)
preparingStep = undefined
}
try {
// oxlint-disable-next-line typescript/no-non-null-assertion -- every exit assigns a turn ending
this.session.append('turn/end', { turn, reason: turnEnds! })