fix(subagent): make strict steering atomic
This commit is contained in:
@@ -132,7 +132,6 @@ export class ReactLoopAgent implements Agent {
|
||||
private abort: AbortController | undefined
|
||||
/** Resolves when the current admission and turn exit. */
|
||||
done: Promise<void> = Promise.resolve()
|
||||
|
||||
/** The agent-scoped registration boundary; the lifecycle owner unwinds it after {@link done}. */
|
||||
readonly scope: Scope
|
||||
/** The agent's scoped composition context ({@link Agent.ctx}). */
|
||||
@@ -143,6 +142,8 @@ export class ReactLoopAgent implements Agent {
|
||||
/** Whether the session log is owed a matching turn end event. */
|
||||
private turnOpen = false
|
||||
private stepOpen = false
|
||||
/** Whether {@link trySteer} can still join the current step's final drain. */
|
||||
private strictSteeringOpen = false
|
||||
/** Whether this loop instance has appended its initial/resume request anchor. */
|
||||
private requestHeaderLogged = false
|
||||
|
||||
@@ -242,6 +243,16 @@ export class ReactLoopAgent implements Agent {
|
||||
})
|
||||
}
|
||||
|
||||
/** Atomically steer only while the current step still owns its final drain. */
|
||||
trySteer(input: UserMessage): boolean {
|
||||
if (!this.strictSteeringOpen) return false
|
||||
this.send(input, {
|
||||
target: 'next-step',
|
||||
wakeup: true,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
/** Append model-facing context without waking the driver. */
|
||||
inject(input: UserMessage): void {
|
||||
this.send(input, {
|
||||
@@ -500,6 +511,7 @@ export class ReactLoopAgent implements Agent {
|
||||
case 'request-failed': {
|
||||
// step() reports request failures only after step/start commits
|
||||
// and before its own step/end, so the step is always open here.
|
||||
this.strictSteeringOpen = false
|
||||
this.stepOpen = false
|
||||
this.session.append('step/end', { turn, step })
|
||||
if (!signal.aborted) {
|
||||
@@ -535,6 +547,7 @@ export class ReactLoopAgent implements Agent {
|
||||
} catch (caught: unknown) {
|
||||
try {
|
||||
if (this.stepOpen) {
|
||||
this.strictSteeringOpen = false
|
||||
this.stepOpen = false
|
||||
this.session.append('step/end', { turn, step })
|
||||
}
|
||||
@@ -552,6 +565,7 @@ export class ReactLoopAgent implements Agent {
|
||||
// failure paths (step(), the request-failed branch, the catch), so the
|
||||
// finally owes only the turn boundary.
|
||||
this.acceptsNextStep = false
|
||||
this.strictSteeringOpen = false
|
||||
try {
|
||||
if (this.turnOpen) {
|
||||
// Re-entrant turn/end listeners must route new input to a later turn.
|
||||
@@ -624,6 +638,7 @@ export class ReactLoopAgent implements Agent {
|
||||
|
||||
session.append('step/start', { turn, step })
|
||||
this.stepOpen = true
|
||||
this.strictSteeringOpen = true
|
||||
signal.throwIfAborted()
|
||||
|
||||
const { request, preparedCall } = await this.buildRequest(
|
||||
@@ -692,6 +707,7 @@ export class ReactLoopAgent implements Agent {
|
||||
|
||||
// Tool results stay adjacent to their calls; input accepted during the
|
||||
// request enters the log only after the complete result batch.
|
||||
this.strictSteeringOpen = false
|
||||
const steered = this.drainOutbox(turn)
|
||||
session.append('step/end', { turn, step })
|
||||
this.stepOpen = false
|
||||
|
||||
@@ -65,6 +65,7 @@ The handle every plugin programs against:
|
||||
- `agent.updateInbox(itemId, action)` — synchronously edits or removes one still-pending queued occurrence. Edit keeps its `MessageId`, `InboxItemId`, source, and FIFO position while replacing frozen content; remove emits the occurrence's terminal discard. Steering and claimed occurrences return `not-found`.
|
||||
- `agent.followup(input)` — the `next-turn`/wakeup preset of `send()`: queue an ordinary follow-up turn and wake the driver.
|
||||
- `agent.steer(input)` — the `next-step`/wakeup preset: during prompt admission or an open turn, stage steering for the next safe boundary without dispatching `agent/prompt-submit`; outside that acceptance window, delegate to a woken follow-up. Admission failure leaves staged steering for retry or a later admitted prompt, while cancellation or disposal may discard it.
|
||||
- `agent.trySteer?(input)` — an optional strict-steering capability implemented by the default loop. It atomically submits an identified message only while the current step still owns its final drain, returning `false` without accepting input during admission, between steps, or after that drain begins; cancellation and disposal can still discard accepted steering.
|
||||
- `agent.inject(input)` — the `next-step`/no-wakeup preset: append model-facing context without running the model; the next request sees a verbatim user-role message whose provenance is carried by the required `input.source`. During prompt admission or an open turn, injection waits in the outbox for the next safe boundary. Outside that acceptance window, it appends immediately without opening a turn; a context-only admission batch takes this fallback if admission closes without a turn, while context staged beside steering remains pending with it. Persistence reacts to `session/event` independently. Injection emits no `agent/inbox/*` event.
|
||||
- `agent.acceptsNextStep` — whether a `next-step` send would currently join prompt admission or the open turn. Use this narrower routing predicate when a caller must choose between steering and a fresh admitted prompt; `status === 'running'` also covers admission exit and turn settlement.
|
||||
- `agent.cancel(cause, options?)` — cancel the active turn and, unless `options.keepInbox`, ALL pending work. Callers must choose the `user | parent` cause explicitly; an active holder copies its discriminant into a detached frozen signal reason before aborting. An effective call emits `agent/cancel-requested` with the cause before clearing queued and steering work; dropped items are reported on `agent/inbox/discard`, and observers may synchronize state but cannot veto cancellation. `keepInbox: true` aborts the turn but preserves queued and steering items (no discard, and un-started work is not dropped). The same-process typed seam adds no runtime validation or compatibility fallback for untyped callers. Repeated active-turn cancellation is first-wins for the signal, and idle cancellation is a safe no-op with no notification. ACP maps to `user`, while in-process parent propagation maps to `parent`. The cause is runtime-only; durable `turn/end` stays coarse `aborted`.
|
||||
|
||||
@@ -236,6 +236,16 @@ export interface Agent {
|
||||
*/
|
||||
steer(message: UserMessage): void
|
||||
|
||||
/**
|
||||
* Atomically submit steering only while the current step still owns its final
|
||||
* drain. Returns `false` without accepting the message during admission,
|
||||
* between steps, or after the final per-step drain has begun. Cancellation or
|
||||
* disposal may still discard previously accepted steering.
|
||||
* @param message - identified steering content and its producer provenance.
|
||||
* @returns whether the message entered the current step.
|
||||
*/
|
||||
trySteer?(message: UserMessage): boolean
|
||||
|
||||
/**
|
||||
* Append model-facing context without running the model — the
|
||||
* `next-step`/no-wakeup preset of {@link send}. Admission or an open turn
|
||||
|
||||
Reference in New Issue
Block a user