@sema-agent/core 5.60.1 → 5.61.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +58 -0
- package/dist/agents/subagent.d.ts +4 -2
- package/dist/agents/subagent.js +9 -9
- package/dist/core/governance-codes.d.ts +1 -1
- package/dist/core/governance-codes.js +2 -2
- package/dist/core/memory-engine/consolidation-driver.d.ts +19 -1
- package/dist/core/memory-engine/consolidation-driver.js +75 -3
- package/dist/core/memory-engine/consolidation.d.ts +52 -5
- package/dist/core/memory-engine/consolidation.js +3 -1
- package/dist/core/memory-engine/distiller.d.ts +89 -1
- package/dist/core/memory-engine/distiller.js +94 -5
- package/dist/core/memory-engine/engine.d.ts +8 -0
- package/dist/core/memory-engine/engine.js +51 -8
- package/dist/core/memory-engine/index.d.ts +1 -1
- package/dist/core/memory-engine/index.js +1 -1
- package/dist/core/runner/prepare-task.d.ts +6 -3
- package/dist/core/runner/runtask.js +57 -26
- package/dist/core/task-notification.d.ts +50 -23
- package/dist/core/task-notification.js +20 -4
- package/dist/core/types.d.ts +76 -19
- package/dist/engine/harness/agent-harness.d.ts +58 -2
- package/dist/engine/harness/agent-harness.js +115 -5
- package/dist/engine/loop/agent-loop.js +153 -15
- package/dist/engine/loop/types.d.ts +32 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/orchestration/run-workflow-tool.d.ts +7 -2
- package/dist/orchestration/run-workflow-tool.js +1 -1
- package/dist/tools/monitor.d.ts +3 -3
- package/dist/tools/monitor.js +1 -1
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +7 -1
|
@@ -148,28 +148,28 @@ export interface ExternalNotificationInput {
|
|
|
148
148
|
export interface SystemInjection<TPayload = unknown> {
|
|
149
149
|
kind: "task_notification";
|
|
150
150
|
/**
|
|
151
|
-
* design/
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
151
|
+
* design/373 — the injection ladder is LIVE (all three values carry delivery semantics, the CC
|
|
152
|
+
* 2.1.223 form; the flat "every priority delivers at the next boundary" era ended with this
|
|
153
|
+
* design — its H-1 anchor-correction note is preserved in the design archive, and the ruled
|
|
154
|
+
* changes it named are exactly what landed here):
|
|
155
155
|
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
156
|
+
* - `"next"` — the running turn's NEXT boundary (mid-work included), arrival order, consecutive
|
|
157
|
+
* engine-note frames batch. The pre-373 behavior, byte-identical (the regression baseline).
|
|
158
|
+
* - `"later"` — never folded into the work in progress: delivered at the run's natural
|
|
159
|
+
* would-otherwise-stop seat (its own closing turn). A run that never reaches that seat
|
|
160
|
+
* (abort/maxTurns) re-pends the frame per session — the session's NEXT run delivers it at
|
|
161
|
+
* turn-open. Structural honesty note: once parked, delivery depends on a next run HAPPENING
|
|
162
|
+
* (this engine does not own an idle process the way the CC client does) — a recorded
|
|
163
|
+
* weakening, not a bug.
|
|
164
|
+
* - `"now"` — `"next"`'s delivery guarantee PLUS the boundary is manufactured early: on the
|
|
165
|
+
* notification lane the frame takes the queue's class head and the earliest natural boundary
|
|
166
|
+
* (NO turn interrupt — interrupt authority belongs exclusively to the caller-provenance
|
|
167
|
+
* steer face, `TaskStream.steer({ priority: "now" })`; the external `notify()` verb REFUSES
|
|
168
|
+
* `"now"` typed). An unknown value is refused at every entry (bad-value loudness).
|
|
165
169
|
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
* `later` = "do not fold into the running turn" arm is a behavior-face change and `now` = "abort
|
|
170
|
-
* the running turn" is a new capability; both are ruled changes, not silent ones. Until then the
|
|
171
|
-
* gap is DISCLOSED at the injection funnel rather than left as a silently inert knob (`now` is
|
|
172
|
-
* announced, an unknown value is refused) — the bad-value loudness rule.
|
|
170
|
+
* Park/uplink: the pend store carries the frame's priority ({@link PendingSessionNotifications})
|
|
171
|
+
* and the turn-open batch delivers priority-major (pend order within a class); a record with no
|
|
172
|
+
* carried priority ranks AS `later` and its wire field stays honestly absent.
|
|
173
173
|
*/
|
|
174
174
|
priority: SystemInjectionPriority;
|
|
175
175
|
dedupKey: string;
|
|
@@ -180,6 +180,13 @@ export interface SystemInjection<TPayload = unknown> {
|
|
|
180
180
|
* fire-and-forget producer (no receipt to honor). */
|
|
181
181
|
onDisposition?: (d: "queued" | "parked") => void;
|
|
182
182
|
}
|
|
183
|
+
/** design/373 (adversarial r3, recorded rule): the key deliberately EXCLUDES the injection tier.
|
|
184
|
+
* The key names an EVENT's identity; `priority` is delivery metadata about one submission of it.
|
|
185
|
+
* A repeat under an in-flight key therefore FOLDS regardless of the repeat's tier — the tier of
|
|
186
|
+
* record is the FIRST accept's, and the folded caller's "queued" receipt is true of that standing
|
|
187
|
+
* entry. A producer that wants a NEW occurrence delivered under a different tier mints a fresh
|
|
188
|
+
* `seq` (the documented repeat discipline); silently migrating a queued frame between lanes on a
|
|
189
|
+
* repeat, or refusing the repeat typed, would each turn a dedup fold into a delivery mutation. */
|
|
183
190
|
export declare function taskNotificationDedupKey(n: Pick<TaskNotificationPayload, "task_id" | "task_type" | "status" | "seq">): string;
|
|
184
191
|
/**
|
|
185
192
|
* RB-142 — the lane-scoped identity of a task, extracted from {@link taskNotificationDedupKey} so every
|
|
@@ -247,9 +254,27 @@ export declare const MAX_PENDING_SESSIONS = 100;
|
|
|
247
254
|
* preference applies, so "terminal" can never mean two things in this module.
|
|
248
255
|
*/
|
|
249
256
|
export declare function isDelegatedAgentTerminal(n: Pick<TaskNotificationPayload, "task_type" | "status">): boolean;
|
|
257
|
+
/**
|
|
258
|
+
* design/373 (#445 saturation prerequisite) — is this frame a TERMINAL notification (any lane)?
|
|
259
|
+
* Reuses {@link TERMINAL_STATUSES} — the same terminal-vs-event rule the pending store's eviction
|
|
260
|
+
* preference applies, so "terminal" can never mean two things in this module. The runner marks
|
|
261
|
+
* INTERNAL-lane terminal frames cap-preferred at the delivery queue's mouth: a watcher's event
|
|
262
|
+
* storm may delay its own batches, never crowd a completion out of the run waiting on it. The
|
|
263
|
+
* external lane never gets the preference (its `status` is caller-supplied — an untrusted injector
|
|
264
|
+
* must not be able to claim the internal completions' reservation); this predicate itself stays
|
|
265
|
+
* lane-blind, the trust cut is the marking site's.
|
|
266
|
+
*/
|
|
267
|
+
export declare function isTerminalTaskNotification(n: Pick<TaskNotificationPayload, "status">): boolean;
|
|
250
268
|
export interface DrainedPendingNotifications {
|
|
251
|
-
/**
|
|
269
|
+
/** Payloads still held when the session's next run drained. design/373 §3.5: batch order is
|
|
270
|
+
* PRIORITY-MAJOR (now < next < later; a record with no carried priority ranks AS later —
|
|
271
|
+
* the conservative seat), pend order (chronological) within a class — the idle-dequeue form. */
|
|
252
272
|
items: TaskNotificationPayload[];
|
|
273
|
+
/** design/373 §3.5 — the parked priority per item, keyed by payload identity, present exactly for
|
|
274
|
+
* records whose pend CARRIED one. A missing key is a fact (a tier-unknown park — e.g.
|
|
275
|
+
* the harness's undrained sweep hands payloads back without their lane's priority): the wire
|
|
276
|
+
* field stays honestly absent rather than fabricating `later`. */
|
|
277
|
+
priorities?: Map<TaskNotificationPayload, SystemInjectionPriority>;
|
|
253
278
|
/** task_id → notifications evicted by the bounds while pending (never delivered). `taskType` remembers
|
|
254
279
|
* the victim's lane so a survivors-none disclosure can still render an honest synthetic payload. */
|
|
255
280
|
/** RB-142: keyed by {@link taskNotificationLaneKey}, NOT the bare `task_id` — the external lane's ids are
|
|
@@ -288,8 +313,10 @@ export declare class PendingSessionNotifications {
|
|
|
288
313
|
/** RB-143: whole-session losses whose tombstone was itself evicted — the count survives, the attribution
|
|
289
314
|
* does not. Surfaced on the next session-level disclosure so it is never simply forgotten. */
|
|
290
315
|
private unattributedDrops;
|
|
291
|
-
pend(sessionId: string, n: TaskNotificationPayload): void;
|
|
292
|
-
/** Remove and return the session's pendings (one-shot — the next run consumes them exactly once).
|
|
316
|
+
pend(sessionId: string, n: TaskNotificationPayload, priority?: SystemInjectionPriority): void;
|
|
317
|
+
/** Remove and return the session's pendings (one-shot — the next run consumes them exactly once).
|
|
318
|
+
* design/373 §3.5: the batch comes out PRIORITY-MAJOR (stable sort — pend order within a class;
|
|
319
|
+
* a no-priority record ranks as later), the idle-dequeue min-value-first form. */
|
|
293
320
|
drain(sessionId: string): DrainedPendingNotifications | undefined;
|
|
294
321
|
get size(): number;
|
|
295
322
|
}
|
|
@@ -92,12 +92,15 @@ const TERMINAL_STATUSES = new Set(["completed", "failed", "killed", "cancelled"]
|
|
|
92
92
|
export function isDelegatedAgentTerminal(n) {
|
|
93
93
|
return n.task_type === "background_agent" && TERMINAL_STATUSES.has(n.status);
|
|
94
94
|
}
|
|
95
|
+
export function isTerminalTaskNotification(n) {
|
|
96
|
+
return TERMINAL_STATUSES.has(n.status);
|
|
97
|
+
}
|
|
95
98
|
export class PendingSessionNotifications {
|
|
96
99
|
sessions = new Map();
|
|
97
100
|
droppedSessions = 0;
|
|
98
101
|
evictedSessions = new Map();
|
|
99
102
|
unattributedDrops = 0;
|
|
100
|
-
pend(sessionId, n) {
|
|
103
|
+
pend(sessionId, n, priority) {
|
|
101
104
|
let s = this.sessions.get(sessionId);
|
|
102
105
|
if (s === undefined) {
|
|
103
106
|
if (this.sessions.size >= MAX_PENDING_SESSIONS) {
|
|
@@ -119,7 +122,7 @@ export class PendingSessionNotifications {
|
|
|
119
122
|
}
|
|
120
123
|
}
|
|
121
124
|
}
|
|
122
|
-
s = { items: [], dropped: new Map(), keys: new Set() };
|
|
125
|
+
s = { items: [], dropped: new Map(), keys: new Set(), priorities: new Map() };
|
|
123
126
|
this.sessions.set(sessionId, s);
|
|
124
127
|
}
|
|
125
128
|
const key = taskNotificationDedupKey(n);
|
|
@@ -127,6 +130,8 @@ export class PendingSessionNotifications {
|
|
|
127
130
|
return;
|
|
128
131
|
s.keys.add(key);
|
|
129
132
|
s.items.push(n);
|
|
133
|
+
if (priority !== undefined)
|
|
134
|
+
s.priorities?.set(n, priority);
|
|
130
135
|
const lane = taskNotificationLaneKey(n);
|
|
131
136
|
let mine = 0;
|
|
132
137
|
for (const i of s.items)
|
|
@@ -139,8 +144,14 @@ export class PendingSessionNotifications {
|
|
|
139
144
|
}
|
|
140
145
|
drain(sessionId) {
|
|
141
146
|
const s = this.sessions.get(sessionId);
|
|
142
|
-
if (s !== undefined)
|
|
147
|
+
if (s !== undefined) {
|
|
143
148
|
this.sessions.delete(sessionId);
|
|
149
|
+
const rank = (n) => {
|
|
150
|
+
const p = s.priorities?.get(n);
|
|
151
|
+
return p === "now" ? 0 : p === "next" ? 1 : 2;
|
|
152
|
+
};
|
|
153
|
+
s.items = [...s.items].sort((a, b) => rank(a) - rank(b));
|
|
154
|
+
}
|
|
144
155
|
const tomb = this.evictedSessions.get(sessionId);
|
|
145
156
|
if (tomb === undefined && this.unattributedDrops === 0)
|
|
146
157
|
return s;
|
|
@@ -163,6 +174,7 @@ function evictOldest(s, match) {
|
|
|
163
174
|
return;
|
|
164
175
|
const victim = s.items.splice(idx, 1)[0];
|
|
165
176
|
s.keys.delete(taskNotificationDedupKey(victim));
|
|
177
|
+
s.priorities?.delete(victim);
|
|
166
178
|
const laneKey = taskNotificationLaneKey(victim);
|
|
167
179
|
const prior = s.dropped.get(laneKey);
|
|
168
180
|
s.dropped.set(laneKey, { count: (prior?.count ?? 0) + 1, taskType: victim.task_type, taskId: victim.task_id });
|
|
@@ -190,10 +202,14 @@ export function discloseDroppedPending(drained) {
|
|
|
190
202
|
if (dropped === undefined || disclosed.has(lane))
|
|
191
203
|
return n;
|
|
192
204
|
disclosed.add(lane);
|
|
193
|
-
|
|
205
|
+
const annotated = {
|
|
194
206
|
...n,
|
|
195
207
|
summary: `[${n.task_id}] ${dropped.count} earlier pending notification(s) from this task were dropped (pending-queue overflow). ${n.summary}`,
|
|
196
208
|
};
|
|
209
|
+
const priority = drained.priorities?.get(n);
|
|
210
|
+
if (priority !== undefined)
|
|
211
|
+
drained.priorities?.set(annotated, priority);
|
|
212
|
+
return annotated;
|
|
197
213
|
});
|
|
198
214
|
for (const [lane, dropped] of drained.dropped) {
|
|
199
215
|
if (disclosed.has(lane))
|
package/dist/core/types.d.ts
CHANGED
|
@@ -4177,6 +4177,12 @@ export type TaskEvent = ({
|
|
|
4177
4177
|
*/
|
|
4178
4178
|
type: "task_notification";
|
|
4179
4179
|
notification: TaskNotificationPayload;
|
|
4180
|
+
/** design/373 §3.6 — the frame's injection tier as DELIVERED (additive; consumers tolerate
|
|
4181
|
+
* absence): on the live lane, the tier the routing used (`next`/`now` → steer lane, `later`
|
|
4182
|
+
* → followUp lane); on the turn-open redelivery lane, the tier the park CARRIED. ABSENT is a
|
|
4183
|
+
* fact, not a default: a pre-373 producer event, or a parked record whose tier was not
|
|
4184
|
+
* carried (the terminal sweep's re-pend) — never read absence as `"later"`. */
|
|
4185
|
+
priority?: import("./task-notification.js").SystemInjectionPriority;
|
|
4180
4186
|
} & TaskEventIdentity) | ({
|
|
4181
4187
|
/**
|
|
4182
4188
|
* design/99 MF-10 — a SUBAGENT PROGRESS TICK: the delegated sub-run's usage ACCRUING
|
|
@@ -4380,11 +4386,43 @@ export interface TaskStream extends AsyncIterable<TaskEvent> {
|
|
|
4380
4386
|
* into this run's resume prompt) is NOT in it and WOULD inject again. A deployment that needs
|
|
4381
4387
|
* cross-leg or cross-process idempotency owns that half (its own key ledger), the same division
|
|
4382
4388
|
* of labor `notify`'s park-window dedup states.
|
|
4389
|
+
*
|
|
4390
|
+
* design/373 (additive) — `priority` is the steer face's injection tier, default `"next"`
|
|
4391
|
+
* (= the pre-373 behavior, byte-identical):
|
|
4392
|
+
* - `"next"` — the running turn's next boundary (unchanged);
|
|
4393
|
+
* - `"later"` — never folded into work in progress: delivered at the run's natural
|
|
4394
|
+
* would-otherwise-stop seat (its own closing turn); a run that ends before that seat re-parks
|
|
4395
|
+
* the input via the existing durable/undrained lanes (receipt semantics unchanged — "queued"
|
|
4396
|
+
* still means accepted-at-this-moment, never "consumed"); a `"later"` steer rides the followUp
|
|
4397
|
+
* lane, so its stranded account is `task.user_followup_undrained` (the accounts are LANE-keyed
|
|
4398
|
+
* — same two-key split the settled frame uses);
|
|
4399
|
+
* - `"now"` — `"next"`'s delivery guarantee PLUS the boundary is manufactured early: the frame
|
|
4400
|
+
* takes the queue's class head and, when a turn is in flight, the engine CUTS that turn at a
|
|
4401
|
+
* reconciled boundary (finished tool calls keep their real results; never-started ones settle
|
|
4402
|
+
* as paired interrupted results; the CC-verbatim interruption marker lands) and the run
|
|
4403
|
+
* CONTINUES with this input — never the run-level `interrupt()`. Best-effort accelerator:
|
|
4404
|
+
* with nothing in flight (idle tail, between turns) delivery is exactly `"next"`'s. Every
|
|
4405
|
+
* real cut is announced (`task.turn_interrupted` notice) and counts toward `maxTurns` —
|
|
4406
|
+
* deliberately (the hard cap IS the interrupt storm's bound), which means a `now` issued at
|
|
4407
|
+
* the run's FINAL allowed turn spends the remaining budget on the cut and the run then ends
|
|
4408
|
+
* at the limit before the steer's own turn: the input takes the terminal lanes like any
|
|
4409
|
+
* accepted-but-undelivered steer (durable park when a resource-suspend seat is eligible —
|
|
4410
|
+
* redeemed on resume; otherwise the loud `task.user_steer_undrained` account). Same for the
|
|
4411
|
+
* other terminal gates (budget/walltime) firing at the cut's own `turn_end`. The receipt
|
|
4412
|
+
* never promised consumption; no budget is reserved for the interruptor.
|
|
4413
|
+
* ⚠️ POWER FACE (§5-4b): passing `priority` through to a third-party caller GRANTS it the power
|
|
4414
|
+
* to cut this run's in-flight turns (cancelling that turn's in-flight tool work). A deployment
|
|
4415
|
+
* relaying steer must gate this option at its own face — relay IS authorization.
|
|
4416
|
+
* An unknown value throws typed `steering.invalid_content` (bad-value loudness). The replay
|
|
4417
|
+
* identity (`inputId` idempotency) includes the NORMALIZED tier: the same text replayed under
|
|
4418
|
+
* the same id at a different tier refuses `steering.duplicate_input_id` — an "idempotent
|
|
4419
|
+
* success" that silently skipped the interrupt would be a disposition lie.
|
|
4383
4420
|
*/
|
|
4384
4421
|
steer(text: string, options?: {
|
|
4385
4422
|
trusted?: boolean;
|
|
4386
4423
|
actor?: ActorAssertion;
|
|
4387
4424
|
inputId?: string;
|
|
4425
|
+
priority?: import("./task-notification.js").SystemInjectionPriority;
|
|
4388
4426
|
}): Promise<void>;
|
|
4389
4427
|
/**
|
|
4390
4428
|
* design/144 §2 — inject an EXTERNAL structured event into this run's task-notification lane, as a
|
|
@@ -4392,19 +4430,37 @@ export interface TaskStream extends AsyncIterable<TaskEvent> {
|
|
|
4392
4430
|
* the payload (`task_type` is cast to `"external"` — a caller can never impersonate an internal lane)
|
|
4393
4431
|
* and every field is sanitized as UNTRUSTED data on the model face. Delivery rides the EXISTING
|
|
4394
4432
|
* notification machinery, so the semantics are identical to internal producers:
|
|
4395
|
-
* -
|
|
4396
|
-
*
|
|
4397
|
-
*
|
|
4398
|
-
*
|
|
4433
|
+
* - design/373 — the injection LADDER is live on this lane (BREAKING vs the flat era, named):
|
|
4434
|
+
* `"next"` = the running turn's next boundary (mid-work included — the previous behavior of
|
|
4435
|
+
* every frame, now this tier's); `"later"` = never folded into work in progress — delivered at
|
|
4436
|
+
* the run's natural would-otherwise-stop seat (its own closing turn), and a run that ends
|
|
4437
|
+
* before that seat parks the frame per session for the NEXT run's turn-open (delivery then
|
|
4438
|
+
* depends on a next run happening — a recorded structural honesty note, this engine owns no
|
|
4439
|
+
* idle process); `"now"` is REFUSED typed (`notify.invalid_priority`) — turn-interrupt
|
|
4440
|
+
* authority belongs to the caller-provenance steer face, never to the notification lane
|
|
4441
|
+
* (callers wanting earliest-boundary delivery say `"next"`). ⚠️ The parameterless DEFAULT is
|
|
4442
|
+
* `"later"` (the CC pending-notification default): an omitted priority moved from
|
|
4443
|
+
* next-boundary to the closing seat with this design — callers that need boundary delivery
|
|
4444
|
+
* must say `"next"` explicitly. NOTE for the flat era's callers: `"later"` was silently inert
|
|
4445
|
+
* then (delivered at the boundary with no runtime signal saying otherwise) — the version is
|
|
4446
|
+
* the ONLY discriminator of which behavior a build has. Delivery order within a tier is
|
|
4447
|
+
* ARRIVAL order; consecutive buffered notifications drain as one boundary batch. `priority`
|
|
4448
|
+
* rides the park path (pend records carry it; the turn-open batch is priority-major) and the
|
|
4449
|
+
* uplink path;
|
|
4399
4450
|
* - duplicate events dedup on `task_id:status[:seq]` — pass a fresh `seq` per repeat event. The
|
|
4400
4451
|
* external lane keys in its OWN dedup domain (design/144 X1): an external event can never collide
|
|
4401
|
-
* with (or pre-occupy) an internal lane's key, and vice versa
|
|
4452
|
+
* with (or pre-occupy) an internal lane's key, and vice versa. The key deliberately excludes
|
|
4453
|
+
* `priority` (tier = delivery metadata, not event identity): a repeat under an in-flight key
|
|
4454
|
+
* folds WHATEVER tier it names — the standing entry keeps the FIRST accept's tier — so
|
|
4455
|
+
* re-tiering requires a fresh `seq` (a new occurrence), never a same-key resend;
|
|
4402
4456
|
* - a notify landing AFTER the run ended PARKS per session and the session's NEXT run delivers it
|
|
4403
4457
|
* (unlike {@link steer}, a finished task does not reject — the park lane is the contract). The park
|
|
4404
4458
|
* leg dedups on the same key (design/144 X4) with an HONEST WINDOW LIMIT: the dedup set spans ONE
|
|
4405
4459
|
* parked batch — it does not reach back across the live-queue/park boundary, and it resets when the
|
|
4406
4460
|
* next run drains the batch (a repeat sent after that drain is deliverable again);
|
|
4407
|
-
* Throws typed `notify.invalid_payload` on a malformed input
|
|
4461
|
+
* Throws typed `notify.invalid_payload` on a malformed input, `notify.invalid_priority` on
|
|
4462
|
+
* `priority: "now"` (see above — a KNOWN value this lane refuses, distinct from an unknown one),
|
|
4463
|
+
* and `notify.not_running` when the run
|
|
4408
4464
|
* never built its notification lane (prepare failed). Everything upstream of the event reaching this
|
|
4409
4465
|
* process — transport, webhook/daemon wiring, inbound authn, durable parking for idle sessions — is
|
|
4410
4466
|
* the DEPLOYMENT half (design/43: zero daemon semantics in core).
|
|
@@ -4992,19 +5048,20 @@ export interface EngineNotice {
|
|
|
4992
5048
|
* — what is announced is the remainder that could NOT be carried, so a fully-migrated park says
|
|
4993
5049
|
* nothing at all and a queue-full / no-longer-pending row still says exactly what was lost.
|
|
4994
5050
|
*
|
|
4995
|
-
* - `"task.
|
|
4996
|
-
*
|
|
4997
|
-
*
|
|
4998
|
-
*
|
|
4999
|
-
*
|
|
5000
|
-
*
|
|
5001
|
-
*
|
|
5002
|
-
*
|
|
5003
|
-
*
|
|
5004
|
-
*
|
|
5005
|
-
*
|
|
5006
|
-
*
|
|
5007
|
-
*
|
|
5051
|
+
* - `"task.turn_interrupted"` (design/373) — a caller-provenance steer with `priority: "now"`
|
|
5052
|
+
* ACTUALLY CUT the running turn: the in-flight provider stream / tool batch was aborted at a
|
|
5053
|
+
* manufactured boundary (finished tool calls keep their real results, never-started ones
|
|
5054
|
+
* settle as paired interrupted results, the interruption marker lands) and the run CONTINUES
|
|
5055
|
+
* with the steer at the queue head. One notice per REAL cut (a `now` that found nothing in
|
|
5056
|
+
* flight, or whose frame already rode the imminent boundary, announces nothing — no false
|
|
5057
|
+
* interrupt claims); every cut turn counts toward `maxTurns`, so an interrupt storm's cost is
|
|
5058
|
+
* bounded and each of its cuts is on the record. `detail: { inputId, sessionId, actorId?,
|
|
5059
|
+
* taskId? }` — `inputId` is the steer's own correlation key (the `human_input` frame's id),
|
|
5060
|
+
* `actorId` the caller's asserted identity when one rode the steer. Audience `"user"`: the
|
|
5061
|
+
* person whose input forced the boundary is the one entitled to see that it landed.
|
|
5062
|
+
* RETIRED here (BREAKING, named): `"task.injection_priority_unimplemented"` — the ladder is
|
|
5063
|
+
* implemented, so the unhonored-knob disclosure it carried has no referent; consumers must
|
|
5064
|
+
* judge ladder support by VERSION, never by that code's absence.
|
|
5008
5065
|
*
|
|
5009
5066
|
* - `"memory.session_polluted"` (design/178 §3, #324a; message mode-aware since design/336) —
|
|
5010
5067
|
* this session's memory crossed into the one-way externally-exposed state (a tool classified
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ActorAssertion, AssistantMessage, ImageContent, Model } from "../llm/index.js";
|
|
1
|
+
import type { ActorAssertion, AssistantMessage, ImageContent, Model, UserMessage } from "../llm/index.js";
|
|
2
2
|
import type { AgentMessage, AgentTool, LoopMalformedToolUseRecovery, LoopThinkingOnlyRecovery, LoopTruncatedOutputRecovery, QueueMode, ThinkingLevel } from "../loop/types.js";
|
|
3
3
|
import { type EngineSegment } from "../../core/untrusted-text.js";
|
|
4
4
|
import type { AbortResult, AgentHarnessEvent, AgentHarnessEventResultMap, AgentHarnessOptions, AgentHarnessOwnEvent, AgentHarnessResources, AgentHarnessStreamOptions, ExecutionEnv, PromptTemplate, Skill } from "./types.js";
|
|
@@ -47,6 +47,22 @@ export interface UserMessageProvenance {
|
|
|
47
47
|
* the parked-steer queue needs (pre-framing text + trust + inputId), which only the minting seam
|
|
48
48
|
* knows — hence opaque. */
|
|
49
49
|
parkRecord?: unknown;
|
|
50
|
+
/** design/373 — an IMMEDIATE-class ("now") frame: the steer-lane enqueue inserts it ahead of the
|
|
51
|
+
* first non-immediate frame (FIFO among immediates — the dequeue-priority form), the loop's
|
|
52
|
+
* pre-request re-check sweeps it into the imminent turn, and {@link AgentHarness.interruptTurn}
|
|
53
|
+
* may force the boundary for it. Class metadata only (a sidecar, judged at the mint) — it never
|
|
54
|
+
* rides the message or the wire, and it grants NOTHING by itself: the interrupt is a separate,
|
|
55
|
+
* caller-provenance verb. */
|
|
56
|
+
immediate?: true;
|
|
57
|
+
/** design/373 (#445 saturation prerequisite) — terminal-preference at the engine-note backlog
|
|
58
|
+
* cap: when the target queue's engine-note count is AT the cap, a frame carrying this mark
|
|
59
|
+
* displaces the OLDEST non-preferred engine-note frame instead of being refused — the displaced
|
|
60
|
+
* payload is handed to the undrained sink (the runner's lossless per-session park lane), so a
|
|
61
|
+
* watcher's event storm can delay its own batches but never crowd a terminal completion out of
|
|
62
|
+
* the run that is waiting on it (the pend store's terminal-preferred eviction, mirrored at the
|
|
63
|
+
* queue mouth). Only meaningful WITH `enginePayload`; a preferred frame among only preferred
|
|
64
|
+
* frames still gets the cap refusal (nothing displaceable). */
|
|
65
|
+
capPreferred?: true;
|
|
50
66
|
}
|
|
51
67
|
/**
|
|
52
68
|
* Harness-level recovery wiring (design/118 ④b/⑤). The loop's prompt-too-long seam wants a
|
|
@@ -190,6 +206,9 @@ export declare class AgentHarness<TSkill extends Skill = Skill, TPromptTemplate
|
|
|
190
206
|
private loopRecovery?;
|
|
191
207
|
/** roadmap #5 (CC Stop hook): the runner-wired stop gate — see {@link setStopGate}. */
|
|
192
208
|
private stopGate?;
|
|
209
|
+
/** design/373 — the loop-published turn-scoped AbortController (live exactly while a main turn's
|
|
210
|
+
* work phase is in flight; `undefined` between turns). Read by {@link interruptTurn} only. */
|
|
211
|
+
private turnInterruptSeat?;
|
|
193
212
|
private handlers;
|
|
194
213
|
constructor(options: AgentHarnessOptions<TSkill, TPromptTemplate, TTool>);
|
|
195
214
|
private getHandlers;
|
|
@@ -264,12 +283,49 @@ export declare class AgentHarness<TSkill extends Skill = Skill, TPromptTemplate
|
|
|
264
283
|
* engine lanes each check the halt fact before injecting, so this arm exists for the lane that
|
|
265
284
|
* does not — including the one not written yet. */
|
|
266
285
|
private refuseHeldEngineInjection;
|
|
286
|
+
/** design/373 — returns the minted queue frame (or `undefined` on the empty-injection no-op), so
|
|
287
|
+
* a caller-provenance immediate steer can hand the SAME frame to {@link interruptTurn} (the
|
|
288
|
+
* consumption guard is identity-keyed). Additive: every existing caller awaited `void`. */
|
|
267
289
|
steer(text: string, options?: {
|
|
268
290
|
images?: ImageContent[];
|
|
269
|
-
} & UserMessageProvenance): Promise<
|
|
291
|
+
} & UserMessageProvenance): Promise<UserMessage | undefined>;
|
|
270
292
|
followUp(text: string, options?: {
|
|
271
293
|
images?: ImageContent[];
|
|
272
294
|
} & UserMessageProvenance): Promise<void>;
|
|
295
|
+
/**
|
|
296
|
+
* design/373 §3.4 — force the running turn's boundary FOR a queued immediate-class frame:
|
|
297
|
+
* abort the CURRENT main turn (provider stream + tool batch — never the run, never boundary
|
|
298
|
+
* housekeeping), whose loop-side settlement reconciles the cut and continues the run with the
|
|
299
|
+
* frame at the queue head. Best-effort accelerator by contract: `false` means no interruption
|
|
300
|
+
* happened AND none was needed — the frame is already consumed or already rides the imminent
|
|
301
|
+
* boundary — so delivery semantics are exactly the non-interrupting ones either way.
|
|
302
|
+
*
|
|
303
|
+
* Guards, in order:
|
|
304
|
+
* - an unwinding run (`abort()` latched) owns its ending — never contest it;
|
|
305
|
+
* - CONSUMPTION guard (r4/R3-1): the frame must still sit in the steer queue. Drained/consumed ⇒
|
|
306
|
+
* it is already part of a turn's input — aborting THAT turn would cut the very delivery this
|
|
307
|
+
* verb exists to speed up. Same-tick check-then-abort (single-threaded: no window);
|
|
308
|
+
* - seat liveness: no published turn (idle tail / between turns / boundary housekeeping) ⇒ no-op,
|
|
309
|
+
* the frame rides the next boundary; an already-fired seat ⇒ idempotent no-op (one boundary per
|
|
310
|
+
* turn — later immediates join the same boundary batch).
|
|
311
|
+
*/
|
|
312
|
+
interruptTurn(frame: AgentMessage): boolean;
|
|
313
|
+
/**
|
|
314
|
+
* design/373 §3.3 (the final-commit-point double check) — how many queued injection frames a
|
|
315
|
+
* boundary drain could deliver RIGHT NOW: both lanes, minus frames the current state refuses to
|
|
316
|
+
* drain (an unwinding run drains nothing — those frames belong to the terminal account; a live
|
|
317
|
+
* human-halt hold skips engine-authored frames). Synchronous and side-effect-free by contract:
|
|
318
|
+
* the loop reads it between its last drain and the terminal commit, with zero awaits.
|
|
319
|
+
*/
|
|
320
|
+
pendingInjectionCount(): number;
|
|
321
|
+
/** design/373 §3.4-3 — the pre-request re-check's drain half (see the loop-config seam): sweep
|
|
322
|
+
* queued immediate-class frames into the imminent turn's batch, merged at the class position
|
|
323
|
+
* (after immediates already pending, ahead of everything else — FIFO within the class holds
|
|
324
|
+
* across the drain/re-check split). Same booking discipline as {@link drainQueuedMessages}
|
|
325
|
+
* (in-flight account, queue_update broadcast, consumption sink, throw-rollback); the hold skips
|
|
326
|
+
* engine-authored immediates exactly as the boundary drain would (a theoretical lane today —
|
|
327
|
+
* no engine mint issues immediates — held by construction rather than by remembering). */
|
|
328
|
+
private absorbImmediateSteering;
|
|
273
329
|
nextTurn(text: string, options?: {
|
|
274
330
|
images?: ImageContent[];
|
|
275
331
|
} & UserMessageProvenance): Promise<void>;
|
|
@@ -38,6 +38,8 @@ const engineNotePayloads = new WeakMap();
|
|
|
38
38
|
const engineAuthoredInjections = new WeakSet();
|
|
39
39
|
const userInputParkRecords = new WeakMap();
|
|
40
40
|
const drainedFromLane = new WeakMap();
|
|
41
|
+
const immediateClassInjections = new WeakSet();
|
|
42
|
+
const capPreferredInjections = new WeakSet();
|
|
41
43
|
function isEngineAuthoredInjection(options) {
|
|
42
44
|
if (options === undefined)
|
|
43
45
|
return false;
|
|
@@ -257,6 +259,7 @@ export class AgentHarness {
|
|
|
257
259
|
nextTurnQueue = [];
|
|
258
260
|
loopRecovery;
|
|
259
261
|
stopGate;
|
|
262
|
+
turnInterruptSeat;
|
|
260
263
|
handlers = new Map();
|
|
261
264
|
constructor(options) {
|
|
262
265
|
this.env = options.env;
|
|
@@ -598,6 +601,11 @@ export class AgentHarness {
|
|
|
598
601
|
};
|
|
599
602
|
},
|
|
600
603
|
getSteeringMessages: async () => this.drainQueuedMessages(this.steerQueue, this.steeringQueueMode),
|
|
604
|
+
publishTurnInterruptSeat: (seat) => {
|
|
605
|
+
this.turnInterruptSeat = seat;
|
|
606
|
+
},
|
|
607
|
+
recheckImmediateInjections: async (pending) => this.absorbImmediateSteering(pending),
|
|
608
|
+
pendingInjectionCount: () => this.pendingInjectionCount(),
|
|
601
609
|
getFollowUpMessages: async () => {
|
|
602
610
|
const queued = await this.drainQueuedMessages(this.followUpQueue, this.followUpQueueMode);
|
|
603
611
|
if (queued.length > 0)
|
|
@@ -841,9 +849,16 @@ export class AgentHarness {
|
|
|
841
849
|
}
|
|
842
850
|
async enqueueInjection(queue, text, options) {
|
|
843
851
|
if (AgentHarness.emptyInjection(text, options))
|
|
844
|
-
return;
|
|
852
|
+
return undefined;
|
|
853
|
+
let displaced;
|
|
845
854
|
if (options?.enginePayload !== undefined && queue.filter((q) => engineNotePayloads.has(q)).length >= ENGINE_NOTE_STEER_BACKLOG_CAP) {
|
|
846
|
-
|
|
855
|
+
const victimIdx = options.capPreferred === true
|
|
856
|
+
? queue.findIndex((q) => engineNotePayloads.has(q) && !capPreferredInjections.has(q))
|
|
857
|
+
: -1;
|
|
858
|
+
if (victimIdx === -1) {
|
|
859
|
+
throw new AgentHarnessError("invalid_state", `engine-note backlog at cap (${ENGINE_NOTE_STEER_BACKLOG_CAP}) — park the payload for the session's next run`);
|
|
860
|
+
}
|
|
861
|
+
displaced = { frame: queue.splice(victimIdx, 1)[0], at: victimIdx };
|
|
847
862
|
}
|
|
848
863
|
const m = createUserMessage(text, options?.images, options);
|
|
849
864
|
if (options?.enginePayload !== undefined)
|
|
@@ -852,8 +867,43 @@ export class AgentHarness {
|
|
|
852
867
|
engineAuthoredInjections.add(m);
|
|
853
868
|
if (options?.parkRecord !== undefined)
|
|
854
869
|
userInputParkRecords.set(m, options.parkRecord);
|
|
855
|
-
|
|
856
|
-
|
|
870
|
+
if (options?.immediate === true)
|
|
871
|
+
immediateClassInjections.add(m);
|
|
872
|
+
if (options?.capPreferred === true && options?.enginePayload !== undefined)
|
|
873
|
+
capPreferredInjections.add(m);
|
|
874
|
+
if (options?.immediate === true && queue === this.steerQueue) {
|
|
875
|
+
let at = 0;
|
|
876
|
+
while (at < queue.length && immediateClassInjections.has(queue[at]))
|
|
877
|
+
at++;
|
|
878
|
+
queue.splice(at, 0, m);
|
|
879
|
+
}
|
|
880
|
+
else {
|
|
881
|
+
queue.push(m);
|
|
882
|
+
}
|
|
883
|
+
try {
|
|
884
|
+
await this.emitQueueUpdate();
|
|
885
|
+
}
|
|
886
|
+
catch (error) {
|
|
887
|
+
const at = queue.indexOf(m);
|
|
888
|
+
if (at !== -1) {
|
|
889
|
+
queue.splice(at, 1);
|
|
890
|
+
if (displaced !== undefined)
|
|
891
|
+
queue.splice(Math.min(displaced.at, queue.length), 0, displaced.frame);
|
|
892
|
+
throw error;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
if (displaced !== undefined) {
|
|
896
|
+
const victimPayload = engineNotePayloads.get(displaced.frame);
|
|
897
|
+
engineNotePayloads.delete(displaced.frame);
|
|
898
|
+
if (victimPayload !== undefined && this.onUndrainedEngineNotes) {
|
|
899
|
+
try {
|
|
900
|
+
this.onUndrainedEngineNotes([victimPayload]);
|
|
901
|
+
}
|
|
902
|
+
catch {
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
return m;
|
|
857
907
|
}
|
|
858
908
|
refuseHeldEngineInjection(options) {
|
|
859
909
|
if (isEngineAuthoredInjection(options) && this.engineInjectionsHeld?.() === true) {
|
|
@@ -865,7 +915,7 @@ export class AgentHarness {
|
|
|
865
915
|
throw new AgentHarnessError("invalid_state", "Cannot steer while idle");
|
|
866
916
|
}
|
|
867
917
|
this.refuseHeldEngineInjection(options);
|
|
868
|
-
await this.enqueueInjection(this.steerQueue, text, options);
|
|
918
|
+
return await this.enqueueInjection(this.steerQueue, text, options);
|
|
869
919
|
}
|
|
870
920
|
async followUp(text, options) {
|
|
871
921
|
if (this.phase === "idle") {
|
|
@@ -874,6 +924,66 @@ export class AgentHarness {
|
|
|
874
924
|
this.refuseHeldEngineInjection(options);
|
|
875
925
|
await this.enqueueInjection(this.followUpQueue, text, options);
|
|
876
926
|
}
|
|
927
|
+
interruptTurn(frame) {
|
|
928
|
+
if (this.aborting)
|
|
929
|
+
return false;
|
|
930
|
+
if (!this.steerQueue.includes(frame))
|
|
931
|
+
return false;
|
|
932
|
+
const seat = this.turnInterruptSeat;
|
|
933
|
+
if (seat === undefined || seat.signal.aborted)
|
|
934
|
+
return false;
|
|
935
|
+
seat.abort();
|
|
936
|
+
return true;
|
|
937
|
+
}
|
|
938
|
+
pendingInjectionCount() {
|
|
939
|
+
if (this.aborting)
|
|
940
|
+
return 0;
|
|
941
|
+
const held = this.engineInjectionsHeld?.() === true;
|
|
942
|
+
const drainable = (q) => held ? q.filter((m) => !engineAuthoredInjections.has(m)).length : q.length;
|
|
943
|
+
return drainable(this.steerQueue) + drainable(this.followUpQueue);
|
|
944
|
+
}
|
|
945
|
+
async absorbImmediateSteering(pending) {
|
|
946
|
+
if (this.aborting)
|
|
947
|
+
return pending;
|
|
948
|
+
const held = this.engineInjectionsHeld?.() === true;
|
|
949
|
+
const taken = [];
|
|
950
|
+
for (let i = 0; i < this.steerQueue.length; i++) {
|
|
951
|
+
const m = this.steerQueue[i];
|
|
952
|
+
if (!immediateClassInjections.has(m))
|
|
953
|
+
break;
|
|
954
|
+
if (held && engineAuthoredInjections.has(m))
|
|
955
|
+
continue;
|
|
956
|
+
taken.push(...this.steerQueue.splice(i, 1));
|
|
957
|
+
i--;
|
|
958
|
+
}
|
|
959
|
+
if (taken.length === 0)
|
|
960
|
+
return pending;
|
|
961
|
+
for (const m of taken)
|
|
962
|
+
drainedFromLane.set(m, "steer");
|
|
963
|
+
this.drainedPendingInjection.push(...taken);
|
|
964
|
+
try {
|
|
965
|
+
await this.emitQueueUpdate();
|
|
966
|
+
for (const m of taken) {
|
|
967
|
+
const payload = engineNotePayloads.get(m);
|
|
968
|
+
if (payload !== undefined) {
|
|
969
|
+
try {
|
|
970
|
+
this.onEngineNoteConsumed?.(payload);
|
|
971
|
+
}
|
|
972
|
+
catch {
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
catch (error) {
|
|
978
|
+
this.steerQueue.unshift(...taken);
|
|
979
|
+
this.unbookInFlight(taken);
|
|
980
|
+
throw normalizeHookError(error);
|
|
981
|
+
}
|
|
982
|
+
let at = 0;
|
|
983
|
+
while (at < pending.length && immediateClassInjections.has(pending[at]))
|
|
984
|
+
at++;
|
|
985
|
+
return [...pending.slice(0, at), ...taken, ...pending.slice(at)];
|
|
986
|
+
}
|
|
877
987
|
async nextTurn(text, options) {
|
|
878
988
|
await this.enqueueInjection(this.nextTurnQueue, text, options);
|
|
879
989
|
}
|