omk-agent-core 0.98.1 → 0.98.3

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.
Files changed (91) hide show
  1. package/CHANGELOG.md +644 -0
  2. package/dist/agent.d.ts.map +1 -1
  3. package/dist/agent.js +3 -5
  4. package/dist/agent.js.map +1 -1
  5. package/dist/effects/effect-journal.d.ts +43 -0
  6. package/dist/effects/effect-journal.d.ts.map +1 -0
  7. package/dist/effects/effect-journal.js +186 -0
  8. package/dist/effects/effect-journal.js.map +1 -0
  9. package/dist/effects/effect-recovery.d.ts +70 -0
  10. package/dist/effects/effect-recovery.d.ts.map +1 -0
  11. package/dist/effects/effect-recovery.js +120 -0
  12. package/dist/effects/effect-recovery.js.map +1 -0
  13. package/dist/effects/effect-transitions.d.ts +34 -0
  14. package/dist/effects/effect-transitions.d.ts.map +1 -0
  15. package/dist/effects/effect-transitions.js +148 -0
  16. package/dist/effects/effect-transitions.js.map +1 -0
  17. package/dist/effects/effect-types.d.ts +135 -0
  18. package/dist/effects/effect-types.d.ts.map +1 -0
  19. package/dist/effects/effect-types.js +32 -0
  20. package/dist/effects/effect-types.js.map +1 -0
  21. package/dist/harness/abort-delivery.d.ts +26 -0
  22. package/dist/harness/abort-delivery.d.ts.map +1 -0
  23. package/dist/harness/abort-delivery.js +36 -0
  24. package/dist/harness/abort-delivery.js.map +1 -0
  25. package/dist/harness/agent-harness.d.ts +38 -6
  26. package/dist/harness/agent-harness.d.ts.map +1 -1
  27. package/dist/harness/agent-harness.js +327 -325
  28. package/dist/harness/agent-harness.js.map +1 -1
  29. package/dist/harness/canonical-digest.d.ts +32 -0
  30. package/dist/harness/canonical-digest.d.ts.map +1 -0
  31. package/dist/harness/canonical-digest.js +164 -0
  32. package/dist/harness/canonical-digest.js.map +1 -0
  33. package/dist/harness/compaction/operation.d.ts +7 -0
  34. package/dist/harness/compaction/operation.d.ts.map +1 -1
  35. package/dist/harness/compaction/operation.js.map +1 -1
  36. package/dist/harness/deferred-commands.d.ts +53 -0
  37. package/dist/harness/deferred-commands.d.ts.map +1 -0
  38. package/dist/harness/deferred-commands.js +96 -0
  39. package/dist/harness/deferred-commands.js.map +1 -0
  40. package/dist/harness/harness-session.d.ts +5 -58
  41. package/dist/harness/harness-session.d.ts.map +1 -1
  42. package/dist/harness/harness-session.js +15 -18
  43. package/dist/harness/harness-session.js.map +1 -1
  44. package/dist/harness/operation-lifecycle-controller.d.ts +68 -0
  45. package/dist/harness/operation-lifecycle-controller.d.ts.map +1 -0
  46. package/dist/harness/operation-lifecycle-controller.js +199 -0
  47. package/dist/harness/operation-lifecycle-controller.js.map +1 -0
  48. package/dist/harness/operation-lifecycle-reducer.d.ts +19 -0
  49. package/dist/harness/operation-lifecycle-reducer.d.ts.map +1 -0
  50. package/dist/harness/operation-lifecycle-reducer.js +201 -0
  51. package/dist/harness/operation-lifecycle-reducer.js.map +1 -0
  52. package/dist/harness/operation-lifecycle-types.d.ts +130 -0
  53. package/dist/harness/operation-lifecycle-types.d.ts.map +1 -0
  54. package/dist/harness/operation-lifecycle-types.js +34 -0
  55. package/dist/harness/operation-lifecycle-types.js.map +1 -0
  56. package/dist/harness/operation-outcome.d.ts +79 -0
  57. package/dist/harness/operation-outcome.d.ts.map +1 -0
  58. package/dist/harness/operation-outcome.js +167 -0
  59. package/dist/harness/operation-outcome.js.map +1 -0
  60. package/dist/harness/operation-trace-divergence.d.ts +60 -0
  61. package/dist/harness/operation-trace-divergence.d.ts.map +1 -0
  62. package/dist/harness/operation-trace-divergence.js +199 -0
  63. package/dist/harness/operation-trace-divergence.js.map +1 -0
  64. package/dist/harness/operation-trace.d.ts +134 -0
  65. package/dist/harness/operation-trace.d.ts.map +1 -0
  66. package/dist/harness/operation-trace.js +161 -0
  67. package/dist/harness/operation-trace.js.map +1 -0
  68. package/dist/harness/session-write-coordinator.d.ts +76 -0
  69. package/dist/harness/session-write-coordinator.d.ts.map +1 -0
  70. package/dist/harness/session-write-coordinator.js +126 -0
  71. package/dist/harness/session-write-coordinator.js.map +1 -0
  72. package/dist/harness/subscriber-fanout.d.ts +50 -0
  73. package/dist/harness/subscriber-fanout.d.ts.map +1 -0
  74. package/dist/harness/subscriber-fanout.js +92 -0
  75. package/dist/harness/subscriber-fanout.js.map +1 -0
  76. package/dist/harness/tree-navigation.d.ts +45 -0
  77. package/dist/harness/tree-navigation.d.ts.map +1 -0
  78. package/dist/harness/tree-navigation.js +59 -0
  79. package/dist/harness/tree-navigation.js.map +1 -0
  80. package/dist/harness/types.d.ts +34 -3
  81. package/dist/harness/types.d.ts.map +1 -1
  82. package/dist/harness/types.js.map +1 -1
  83. package/dist/index.d.ts +2 -0
  84. package/dist/index.d.ts.map +1 -1
  85. package/dist/index.js +2 -0
  86. package/dist/index.js.map +1 -1
  87. package/dist/listener-delivery.d.ts +22 -0
  88. package/dist/listener-delivery.d.ts.map +1 -0
  89. package/dist/listener-delivery.js +36 -0
  90. package/dist/listener-delivery.js.map +1 -0
  91. package/package.json +4 -3
@@ -1,36 +1,30 @@
1
1
  import { isContextOverflow, streamSimple, } from "omk-ai";
2
2
  import { runAgentLoop, runAgentLoopContinue } from "../agent-loop.js";
3
- import { collectEntriesForBranchSummary, generateBranchSummary } from "./compaction/branch-summarization.js";
3
+ import { assertAbortAllowed, describeAbortDelivery } from "./abort-delivery.js";
4
+ import { collectEntriesForBranchSummary } from "./compaction/branch-summarization.js";
4
5
  import { compact, DEFAULT_COMPACTION_SETTINGS, estimateContextTokens, prepareCompaction, shouldCompact, } from "./compaction/compaction.js";
6
+ import { DeferredCommandQueue } from "./deferred-commands.js";
5
7
  import { HarnessSessionFacade } from "./harness-session.js";
6
8
  import { convertToLlm, createFailureMessage, createUserMessage } from "./messages.js";
7
9
  import { findDuplicateNames } from "./name-validation.js";
10
+ import { OperationLifecycleController, } from "./operation-lifecycle-controller.js";
11
+ import { PROMPT_FAMILY_KINDS, } from "./operation-lifecycle-types.js";
12
+ import { classifyAssistantOutcome, classifyAttemptFailure, classifyAttemptOutcome, classifyNavigateTreeOutcome, collectStepErrors, combineBoundaryErrors, normalizeHarnessError, resolveOperationFailure, resolveOperationOutcome, } from "./operation-outcome.js";
8
13
  import { formatPromptTemplateInvocation } from "./prompt-templates.js";
14
+ import { uuidv7 } from "./session/uuid.js";
15
+ import { SessionWriteCoordinator } from "./session-write-coordinator.js";
9
16
  import { formatSkillInvocation } from "./skills.js";
10
17
  import { applyStreamOptionsPatch, cloneStreamOptions, mergeHeaders } from "./stream-options.js";
18
+ import { SubscriberFanout } from "./subscriber-fanout.js";
11
19
  import { createSummarizationRetry } from "./summarization-retry.js";
12
- import { AgentHarnessError, BranchSummaryError, CompactionError, SessionError, toError } from "./types.js";
13
- const SUBSCRIBER_EVENT_TYPE = "*";
14
- function normalizeHarnessError(error, fallbackCode) {
15
- if (error instanceof AgentHarnessError)
16
- return error;
17
- const cause = toError(error);
18
- if (cause instanceof SessionError)
19
- return new AgentHarnessError("session", cause.message, cause);
20
- if (cause instanceof CompactionError)
21
- return new AgentHarnessError("compaction", cause.message, cause);
22
- if (cause instanceof BranchSummaryError)
23
- return new AgentHarnessError("branch_summary", cause.message, cause);
24
- return new AgentHarnessError(fallbackCode, cause.message, cause);
25
- }
20
+ import { resolveNavigationTarget, runBranchSummary } from "./tree-navigation.js";
21
+ import { AgentHarnessError, toError } from "./types.js";
26
22
  export class AgentHarness {
27
23
  env;
28
24
  session;
29
25
  sessionFacade;
30
- phase = "idle";
31
- runAbortController;
32
- runPromise;
33
- pendingSessionWrites = [];
26
+ lifecycle;
27
+ sessionWrites;
34
28
  model;
35
29
  thinkingLevel;
36
30
  systemPrompt;
@@ -46,10 +40,17 @@ export class AgentHarness {
46
40
  followUpQueueMode;
47
41
  nextTurnQueue = [];
48
42
  handlers = new Map();
43
+ subscribers = new SubscriberFanout();
44
+ deferredCommands = new DeferredCommandQueue(() => this.lifecycle.getSnapshot().tag === "idle");
49
45
  constructor(options) {
50
46
  this.env = options.env;
51
47
  this.session = options.session;
52
- this.sessionFacade = new HarnessSessionFacade(this.session, () => this.phase, this.pendingSessionWrites);
48
+ this.sessionWrites = new SessionWriteCoordinator(this.session);
49
+ this.lifecycle = new OperationLifecycleController({
50
+ createOperationId: () => uuidv7(),
51
+ now: () => Date.now(),
52
+ });
53
+ this.sessionFacade = new HarnessSessionFacade(this.session, () => this.currentPhase(), this.sessionWrites);
53
54
  this.resources = options.resources ?? {};
54
55
  this.streamOptions = cloneStreamOptions(options.streamOptions);
55
56
  this.compactionSettings = { ...DEFAULT_COMPACTION_SETTINGS, ...options.compaction };
@@ -70,24 +71,15 @@ export class AgentHarness {
70
71
  this.followUpQueueMode = options.followUpMode ?? "one-at-a-time";
71
72
  }
72
73
  async emitOwn(event, signal) {
73
- for (const listener of this.handlers.get(SUBSCRIBER_EVENT_TYPE) ?? []) {
74
- try {
75
- await listener(event, signal);
76
- }
77
- catch (error) {
78
- throw normalizeHarnessError(error, "hook");
79
- }
80
- }
74
+ await this.emitAny(event, signal);
81
75
  }
76
+ /** Subscriber fan-out; the self-wait barrier lives in `SubscriberFanout`. */
82
77
  async emitAny(event, signal) {
83
- for (const listener of this.handlers.get(SUBSCRIBER_EVENT_TYPE) ?? []) {
84
- try {
85
- await listener(event, signal);
86
- }
87
- catch (error) {
88
- throw normalizeHarnessError(error, "hook");
89
- }
90
- }
78
+ await this.subscribers.emit(event, this.lifecycle.getCurrentOperation()?.operationId, signal);
79
+ }
80
+ /** Fail closed when an awaited listener tries to wait on its own operation. */
81
+ rejectCurrentOperationSelfWait(api) {
82
+ this.subscribers.assertNotSelfWait(api, this.lifecycle.getCurrentOperation()?.operationId);
91
83
  }
92
84
  async emitHook(event) {
93
85
  const handlers = this.handlers.get(event.type);
@@ -156,23 +148,92 @@ export class AgentHarness {
156
148
  nextTurn: [...this.nextTurnQueue],
157
149
  });
158
150
  }
159
- startRunPromise() {
160
- let finish = () => { };
161
- const runPromise = new Promise((resolve) => {
162
- finish = resolve;
151
+ /**
152
+ * Facade write-gate vocabulary mapped from lifecycle state. `settling` maps
153
+ * to "idle": the queue is drained by the settlement finalizer first, and
154
+ * listener writes persist after it through the coordinator tail.
155
+ */
156
+ currentPhase() {
157
+ const snapshot = this.lifecycle.getSnapshot();
158
+ if (snapshot.tag !== "active")
159
+ return "idle";
160
+ switch (snapshot.operation.kind) {
161
+ case "manual_compaction":
162
+ return "compaction";
163
+ case "tree_navigation":
164
+ return "branch_summary";
165
+ default:
166
+ return "turn";
167
+ }
168
+ }
169
+ /** Config writes persist immediately outside an active operation and queue during one. */
170
+ async persistConfigChange(write) {
171
+ if (this.lifecycle.getSnapshot().tag !== "active") {
172
+ await this.sessionWrites.persistAfterPending(write);
173
+ }
174
+ else {
175
+ this.sessionWrites.enqueue(write);
176
+ }
177
+ }
178
+ /**
179
+ * Single wrapper for every public operation: begin a lease, run the body,
180
+ * then settle exactly once. The final flush and the `settled` event happen
181
+ * inside the settling barrier; a finalizer failure never reports success.
182
+ */
183
+ async runOperation(kind, fallbackCode, body, classifyResult) {
184
+ const lease = this.lifecycle.begin(kind);
185
+ let result;
186
+ let bodyError;
187
+ // Everything after a successful begin() runs inside one capture region. A
188
+ // throwing `operation_started` listener must not escape before settle(),
189
+ // or the lifecycle would stay active and wedge the harness at "busy".
190
+ try {
191
+ await this.emitOwn({ type: "operation_started", operation: lease.operation });
192
+ result = await body(lease);
193
+ }
194
+ catch (error) {
195
+ bodyError = error;
196
+ }
197
+ // The final flush precedes classification: a persistence failure after a
198
+ // provider success must never record or report a completed operation.
199
+ let flushError;
200
+ try {
201
+ await this.sessionWrites.flush();
202
+ }
203
+ catch (error) {
204
+ flushError = error;
205
+ }
206
+ const outcome = resolveOperationOutcome({
207
+ signalAborted: lease.signal.aborted,
208
+ result,
209
+ bodyError,
210
+ flushError,
211
+ classifyResult,
212
+ fallbackCode,
163
213
  });
164
- this.runPromise = runPromise;
165
- return {
166
- runPromise,
167
- finishRunPromise: () => {
168
- // A settled/agent_end listener may have started a new run while this
169
- // one was still unwinding; only clear state still owned by this run.
170
- if (this.runPromise === runPromise) {
171
- this.runPromise = undefined;
172
- }
173
- finish();
174
- },
175
- };
214
+ let settleError;
215
+ try {
216
+ await this.lifecycle.settle(lease, outcome, async () => {
217
+ await this.emitOwn({
218
+ type: "settled",
219
+ nextTurnCount: this.nextTurnQueue.length,
220
+ operationId: lease.operation.operationId,
221
+ outcome,
222
+ attemptCount: this.lifecycle.getAttemptSummaries(lease).length,
223
+ }, lease.signal);
224
+ });
225
+ }
226
+ catch (error) {
227
+ settleError = error;
228
+ }
229
+ // Deferred commands registered from this operation's callbacks run now that
230
+ // the lifecycle is idle. Not awaited: this call's result must not depend on
231
+ // work a listener scheduled.
232
+ void this.deferredCommands.drain();
233
+ const failure = resolveOperationFailure({ bodyError, flushError, settleError, fallbackCode });
234
+ if (failure !== undefined)
235
+ throw failure;
236
+ return result;
176
237
  }
177
238
  async createTurnState() {
178
239
  const context = await this.session.buildContext();
@@ -258,7 +319,7 @@ export class AgentHarness {
258
319
  throw normalizeHarnessError(error, "hook");
259
320
  }
260
321
  }
261
- createLoopConfig(getTurnState, setTurnState) {
322
+ createLoopConfig(getTurnState, setTurnState, lease) {
262
323
  const turnState = getTurnState();
263
324
  return {
264
325
  model: turnState.model,
@@ -292,7 +353,13 @@ export class AgentHarness {
292
353
  : undefined;
293
354
  },
294
355
  prepareNextTurn: async () => {
295
- await this.flushPendingSessionWrites();
356
+ await this.sessionWrites.flush();
357
+ if (lease) {
358
+ const snapshot = this.lifecycle.getSnapshot();
359
+ if (snapshot.tag === "active" && snapshot.stage === "save_point") {
360
+ this.lifecycle.setStage(lease, "attempt_running");
361
+ }
362
+ }
296
363
  const nextTurnState = await this.createTurnState();
297
364
  setTurnState(nextTurnState);
298
365
  return {
@@ -316,40 +383,7 @@ export class AgentHarness {
316
383
  if (missing.length > 0)
317
384
  throw new AgentHarnessError("invalid_argument", `Unknown tool(s): ${missing.join(", ")}`);
318
385
  }
319
- async flushPendingSessionWrites() {
320
- while (this.pendingSessionWrites.length > 0) {
321
- const write = this.pendingSessionWrites[0];
322
- if (write.type === "message") {
323
- await this.session.appendMessage(write.message);
324
- }
325
- else if (write.type === "model_change") {
326
- await this.session.appendModelChange(write.provider, write.modelId);
327
- }
328
- else if (write.type === "thinking_level_change") {
329
- await this.session.appendThinkingLevelChange(write.thinkingLevel);
330
- }
331
- else if (write.type === "active_tools_change") {
332
- await this.session.appendActiveToolsChange(write.activeToolNames);
333
- }
334
- else if (write.type === "custom") {
335
- await this.session.appendCustomEntry(write.customType, write.data);
336
- }
337
- else if (write.type === "custom_message") {
338
- await this.session.appendCustomMessageEntry(write.customType, write.content, write.display, write.details);
339
- }
340
- else if (write.type === "label") {
341
- await this.session.appendLabel(write.targetId, write.label);
342
- }
343
- else if (write.type === "session_info") {
344
- await this.session.appendSessionName(write.name ?? "");
345
- }
346
- else if (write.type === "leaf") {
347
- await this.session.getStorage().setLeafId(write.targetId);
348
- }
349
- this.pendingSessionWrites.shift();
350
- }
351
- }
352
- async handleAgentEvent(event, signal) {
386
+ async handleAgentEvent(event, signal, lease) {
353
387
  if (event.type === "message_end") {
354
388
  await this.session.appendMessage(event.message);
355
389
  await this.emitAny(event, signal);
@@ -363,39 +397,48 @@ export class AgentHarness {
363
397
  catch (error) {
364
398
  eventError = error;
365
399
  }
366
- const hadPendingMutations = this.pendingSessionWrites.length > 0;
367
- await this.flushPendingSessionWrites();
368
- if (eventError)
369
- throw eventError;
400
+ // The flush runs even after a failing listener so accepted writes are
401
+ // not stranded; a failing flush must then report next to that listener
402
+ // error, not in place of it.
403
+ const hadPendingMutations = this.sessionWrites.hasPending();
404
+ let flushError;
405
+ try {
406
+ await this.sessionWrites.flush();
407
+ }
408
+ catch (error) {
409
+ flushError = error;
410
+ }
411
+ if (lease) {
412
+ const snapshot = this.lifecycle.getSnapshot();
413
+ if (snapshot.tag === "active" && snapshot.stage === "attempt_running") {
414
+ this.lifecycle.setStage(lease, "save_point");
415
+ }
416
+ }
417
+ const failure = combineBoundaryErrors([eventError, flushError], "turn_end listener failed and the save-point flush failed", "hook");
418
+ if (failure !== undefined)
419
+ throw failure;
370
420
  await this.emitOwn({ type: "save_point", hadPendingMutations });
371
421
  return;
372
422
  }
373
423
  if (event.type === "agent_end") {
374
- // Finish this run's cleanup before flipping to "idle": listeners observing
375
- // agent_end/settled may start the next prompt() immediately, and that run
376
- // must not inherit state still owned by the run that just completed.
377
- await this.flushPendingSessionWrites();
378
- if (this.runAbortController && this.runAbortController.signal === signal) {
379
- this.runAbortController = undefined;
380
- }
381
- this.phase = "idle";
424
+ // agent_end is an attempt event: flush its accepted writes, but lifecycle
425
+ // settlement and the settled event belong to OperationLease.settle().
426
+ await this.sessionWrites.flush();
382
427
  await this.emitAny(event, signal);
383
- await this.emitOwn({ type: "settled", nextTurnCount: this.nextTurnQueue.length }, signal);
384
428
  return;
385
429
  }
386
430
  await this.emitAny(event, signal);
387
431
  }
388
- async emitRunFailure(model, error, aborted, signal, completedMessages) {
432
+ async emitRunFailure(model, error, aborted, signal, completedMessages, lease) {
389
433
  const failureMessage = createFailureMessage(model, error, aborted);
390
434
  const messages = [...completedMessages, failureMessage];
391
- await this.handleAgentEvent({ type: "message_start", message: failureMessage }, signal);
392
- await this.handleAgentEvent({ type: "message_end", message: failureMessage }, signal);
393
- await this.handleAgentEvent({ type: "turn_end", message: failureMessage, toolResults: [] }, signal);
394
- await this.handleAgentEvent({ type: "agent_end", messages }, signal);
435
+ await this.handleAgentEvent({ type: "message_start", message: failureMessage }, signal, lease);
436
+ await this.handleAgentEvent({ type: "message_end", message: failureMessage }, signal, lease);
437
+ await this.handleAgentEvent({ type: "turn_end", message: failureMessage, toolResults: [] }, signal, lease);
438
+ await this.handleAgentEvent({ type: "agent_end", messages }, signal, lease);
395
439
  return messages;
396
440
  }
397
- async executeTurn(turnState, text, options) {
398
- const runOwner = this.runPromise;
441
+ async executeTurn(lease, turnState, text, options) {
399
442
  let messages = [createUserMessage(text, options?.images)];
400
443
  if (this.nextTurnQueue.length > 0) {
401
444
  const queuedMessages = this.nextTurnQueue.splice(0);
@@ -417,65 +460,99 @@ export class AgentHarness {
417
460
  });
418
461
  if (beforeResult?.messages)
419
462
  messages = [...messages, ...beforeResult.messages];
420
- const result = await this.executeAgentRun(turnState, this.createContext(turnState, beforeResult?.systemPrompt), messages);
421
- return await this.recoverContextOverflow(result, runOwner);
463
+ const result = await this.executeAgentRun(lease, "initial", turnState, this.createContext(turnState, beforeResult?.systemPrompt), messages);
464
+ return await this.recoverContextOverflow(lease, result);
465
+ }
466
+ /**
467
+ * Sole attempt boundary: begin, announce, run, classify, close, announce, flush.
468
+ *
469
+ * Once `beginAttempt()` succeeds the attempt is closed exactly once on every
470
+ * path, so `count(attempt_started) == count(attempt_finished)` holds even when
471
+ * the `attempt_started` observer throws. `attempt_finished` is emitted only
472
+ * after the attempt is already closed, so a throwing observer can fail the
473
+ * operation but can never reopen committed attempt state. The closing flush
474
+ * is not a `finally`: a `finally` that awaits a throwing flush would replace
475
+ * the body error, hiding the provider or listener failure from the audit trail.
476
+ */
477
+ async runAttempt(lease, reason, body, classify) {
478
+ const attemptLease = this.lifecycle.beginAttempt(lease, reason);
479
+ let result;
480
+ let bodyError;
481
+ try {
482
+ await this.emitOwn({ type: "attempt_started", attempt: attemptLease.attempt }, lease.signal);
483
+ result = await body(attemptLease);
484
+ }
485
+ catch (error) {
486
+ bodyError = error;
487
+ }
488
+ const outcome = bodyError === undefined ? classify(result) : classifyAttemptFailure(bodyError);
489
+ this.lifecycle.finishAttempt(lease, attemptLease, outcome);
490
+ let observerError;
491
+ try {
492
+ await this.emitOwn({ type: "attempt_finished", summary: this.lifecycle.getAttemptSummary(lease, attemptLease) }, lease.signal);
493
+ }
494
+ catch (error) {
495
+ observerError = error;
496
+ }
497
+ let flushError;
498
+ try {
499
+ await this.sessionWrites.flush();
500
+ }
501
+ catch (error) {
502
+ flushError = error;
503
+ }
504
+ const failure = combineBoundaryErrors([bodyError, observerError, flushError], "Attempt failed and its attempt_finished reporting or closing flush failed", "unknown");
505
+ if (failure !== undefined)
506
+ throw failure;
507
+ return result;
422
508
  }
423
- async executeAgentRun(turnState, context, initialMessages) {
509
+ async executeAgentRun(lease, reason, turnState, context, initialMessages) {
424
510
  let activeTurnState = turnState;
425
- const abortController = new AbortController();
426
- const getTurnState = () => activeTurnState;
427
- const setTurnState = (nextTurnState) => {
428
- activeTurnState = nextTurnState;
429
- };
430
- this.runAbortController = abortController;
431
- const completedMessages = [];
432
- const emit = async (event) => {
433
- if (event.type === "message_end")
434
- completedMessages.push(event.message);
435
- await this.handleAgentEvent(event, abortController.signal);
436
- };
437
- const runResultPromise = (async () => {
511
+ return await this.runAttempt(lease, reason, async (attemptLease) => {
512
+ const signal = attemptLease.signal;
513
+ const getTurnState = () => activeTurnState;
514
+ const setTurnState = (nextTurnState) => {
515
+ activeTurnState = nextTurnState;
516
+ };
517
+ const completedMessages = [];
518
+ const emit = async (event) => {
519
+ if (event.type === "message_end")
520
+ completedMessages.push(event.message);
521
+ await this.handleAgentEvent(event, signal, lease);
522
+ };
523
+ let newMessages;
438
524
  try {
439
- const loopConfig = this.createLoopConfig(getTurnState, setTurnState);
525
+ const loopConfig = this.createLoopConfig(getTurnState, setTurnState, lease);
440
526
  const streamFn = this.createStreamFn(getTurnState);
441
- return initialMessages
442
- ? await runAgentLoop(initialMessages, context, loopConfig, emit, abortController.signal, streamFn)
443
- : await runAgentLoopContinue(context, loopConfig, emit, abortController.signal, streamFn);
527
+ newMessages = initialMessages
528
+ ? await runAgentLoop(initialMessages, context, loopConfig, emit, signal, streamFn)
529
+ : await runAgentLoopContinue(context, loopConfig, emit, signal, streamFn);
444
530
  }
445
531
  catch (error) {
446
532
  try {
447
- return await this.emitRunFailure(activeTurnState.model, error, abortController.signal.aborted, abortController.signal, completedMessages);
533
+ newMessages = await this.emitRunFailure(activeTurnState.model, error, signal.aborted, signal, completedMessages, lease);
448
534
  }
449
535
  catch (failureError) {
450
536
  const cause = new AggregateError([toError(error), toError(failureError)], "Agent run failed and failure reporting failed");
451
537
  throw new AgentHarnessError("unknown", cause.message, cause);
452
538
  }
453
539
  }
454
- })();
455
- try {
456
- const newMessages = await runResultPromise;
457
540
  for (let i = newMessages.length - 1; i >= 0; i--) {
458
541
  const message = newMessages[i];
459
542
  if (message.role === "assistant")
460
543
  return message;
461
544
  }
462
545
  throw new AgentHarnessError("invalid_state", "AgentHarness prompt completed without an assistant message");
463
- }
464
- finally {
465
- try {
466
- await this.flushPendingSessionWrites();
467
- }
468
- finally {
469
- if (this.runAbortController === abortController)
470
- this.runAbortController = undefined;
471
- }
472
- }
473
- }
474
- async recoverContextOverflow(message, runOwner) {
475
- if (this.runPromise !== runOwner ||
476
- this.phase !== "idle" ||
477
- !this.compactionSettings.enabled ||
478
- !isContextOverflow(message, this.model.contextWindow)) {
546
+ }, (message) => classifyAttemptOutcome(message, activeTurnState.model.contextWindow));
547
+ }
548
+ /**
549
+ * One-shot overflow recovery inside the originating operation. The lease is
550
+ * proof that this operation still owns the harness, so no run-ownership or
551
+ * phase re-check is needed; a strict lifecycle makes a newer operation
552
+ * starting mid-recovery impossible.
553
+ */
554
+ async recoverContextOverflow(lease, message) {
555
+ if (!this.compactionSettings.enabled || !isContextOverflow(message, this.model.contextWindow)) {
479
556
  return message;
480
557
  }
481
558
  if (!this.getApiKeyAndHeaders)
@@ -491,16 +568,15 @@ export class AgentHarness {
491
568
  return message;
492
569
  }
493
570
  await this.session.moveTo(leaf.parentId);
494
- this.phase = "retry";
571
+ this.lifecycle.setStage(lease, "recovering_overflow");
495
572
  try {
496
- const compacted = await this.runCompaction({ automatic: true });
573
+ const compacted = await this.runCompaction({ automatic: true, signal: lease.signal });
497
574
  if (!compacted) {
498
575
  await this.session.moveTo(leafId);
499
- this.phase = "idle";
500
576
  return message;
501
577
  }
502
578
  const turnState = await this.createTurnState();
503
- return await this.executeAgentRun(turnState, this.createContext(turnState));
579
+ return await this.executeAgentRun(lease, "context_overflow_recovery", turnState, this.createContext(turnState));
504
580
  }
505
581
  catch (error) {
506
582
  await this.session.moveTo(leafId);
@@ -508,76 +584,49 @@ export class AgentHarness {
508
584
  }
509
585
  }
510
586
  async prompt(text, options) {
511
- if (this.phase !== "idle")
512
- throw new AgentHarnessError("busy", "AgentHarness is busy");
513
- this.phase = "turn";
514
- const { runPromise, finishRunPromise } = this.startRunPromise();
515
- try {
587
+ return this.runOperation("prompt", "unknown", async (lease) => {
516
588
  const turnState = await this.createTurnState();
517
- return await this.executeTurn(turnState, text, options);
518
- }
519
- catch (error) {
520
- // Only reset the phase if this call still owns the run; a listener may
521
- // have started the next run while this one was unwinding.
522
- if (this.runPromise === runPromise)
523
- this.phase = "idle";
524
- throw normalizeHarnessError(error, "unknown");
525
- }
526
- finally {
527
- finishRunPromise();
528
- }
589
+ return await this.executeTurn(lease, turnState, text, options);
590
+ }, classifyAssistantOutcome);
529
591
  }
530
592
  async skill(name, additionalInstructions) {
531
- if (this.phase !== "idle")
532
- throw new AgentHarnessError("busy", "AgentHarness is busy");
533
- this.phase = "turn";
534
- const { runPromise, finishRunPromise } = this.startRunPromise();
535
- try {
593
+ return this.runOperation("skill", "unknown", async (lease) => {
536
594
  const turnState = await this.createTurnState();
537
595
  const skill = (turnState.resources.skills ?? []).find((candidate) => candidate.name === name);
538
596
  if (!skill)
539
597
  throw new AgentHarnessError("invalid_argument", `Unknown skill: ${name}`);
540
- return await this.executeTurn(turnState, formatSkillInvocation(skill, additionalInstructions));
541
- }
542
- catch (error) {
543
- if (this.runPromise === runPromise)
544
- this.phase = "idle";
545
- throw normalizeHarnessError(error, "unknown");
546
- }
547
- finally {
548
- finishRunPromise();
549
- }
598
+ return await this.executeTurn(lease, turnState, formatSkillInvocation(skill, additionalInstructions));
599
+ }, classifyAssistantOutcome);
550
600
  }
551
601
  async promptFromTemplate(name, args = []) {
552
- if (this.phase !== "idle")
553
- throw new AgentHarnessError("busy", "AgentHarness is busy");
554
- this.phase = "turn";
555
- const { runPromise, finishRunPromise } = this.startRunPromise();
556
- try {
602
+ return this.runOperation("prompt_template", "unknown", async (lease) => {
557
603
  const turnState = await this.createTurnState();
558
604
  const template = (turnState.resources.promptTemplates ?? []).find((candidate) => candidate.name === name);
559
605
  if (!template)
560
606
  throw new AgentHarnessError("invalid_argument", `Unknown prompt template: ${name}`);
561
- return await this.executeTurn(turnState, formatPromptTemplateInvocation(template, args));
562
- }
563
- catch (error) {
564
- if (this.runPromise === runPromise)
565
- this.phase = "idle";
566
- throw normalizeHarnessError(error, "unknown");
567
- }
568
- finally {
569
- finishRunPromise();
607
+ return await this.executeTurn(lease, turnState, formatPromptTemplateInvocation(template, args));
608
+ }, classifyAssistantOutcome);
609
+ }
610
+ /**
611
+ * Steering and follow-up input is consumed only by a running agent attempt.
612
+ * A structural operation (`compact`, `navigateTree`) runs none, so accepting
613
+ * input there would silently inject it into an unrelated later prompt.
614
+ */
615
+ expectQueueConsumer(action) {
616
+ const snapshot = this.lifecycle.getSnapshot();
617
+ if (snapshot.tag !== "active")
618
+ throw new AgentHarnessError("invalid_state", `Cannot ${action} while idle`);
619
+ if (!PROMPT_FAMILY_KINDS.includes(snapshot.operation.kind)) {
620
+ throw new AgentHarnessError("invalid_state", `Cannot ${action} during ${snapshot.operation.kind}: no agent attempt can consume it`);
570
621
  }
571
622
  }
572
623
  async steer(text, options) {
573
- if (this.phase === "idle")
574
- throw new AgentHarnessError("invalid_state", "Cannot steer while idle");
624
+ this.expectQueueConsumer("steer");
575
625
  this.steerQueue.push(createUserMessage(text, options?.images));
576
626
  await this.emitQueueUpdate();
577
627
  }
578
628
  async followUp(text, options) {
579
- if (this.phase === "idle")
580
- throw new AgentHarnessError("invalid_state", "Cannot follow up while idle");
629
+ this.expectQueueConsumer("follow up");
581
630
  this.followUpQueue.push(createUserMessage(text, options?.images));
582
631
  await this.emitQueueUpdate();
583
632
  }
@@ -629,6 +678,7 @@ export class AgentHarness {
629
678
  if (!compactResult.ok)
630
679
  throw compactResult.error;
631
680
  const result = compactResult.value;
681
+ options.beforeCommit?.();
632
682
  const entryId = await this.session.appendCompaction(result.summary, result.firstKeptEntryId, result.tokensBefore, result.details, provided !== undefined);
633
683
  const entry = await this.session.getEntry(entryId);
634
684
  if (entry?.type === "compaction") {
@@ -647,28 +697,26 @@ export class AgentHarness {
647
697
  return { ...context, messages: convertToLlm(persisted.messages) };
648
698
  }
649
699
  async compact(customInstructions) {
650
- if (this.phase !== "idle")
651
- throw new AgentHarnessError("busy", "compact() requires idle harness");
652
- this.phase = "compaction";
653
- try {
654
- const result = await this.runCompaction({ automatic: false, customInstructions });
700
+ return this.runOperation("manual_compaction", "compaction", async (lease) => {
701
+ this.lifecycle.setStage(lease, "structural_running");
702
+ const result = await this.runCompaction({
703
+ automatic: false,
704
+ customInstructions,
705
+ beforeCommit: () => {
706
+ this.lifecycle.setStage(lease, "committing");
707
+ },
708
+ });
655
709
  if (!result)
656
710
  throw new AgentHarnessError("compaction", "Nothing to compact");
657
711
  return result;
658
- }
659
- catch (error) {
660
- throw normalizeHarnessError(error, "compaction");
661
- }
662
- finally {
663
- this.phase = "idle";
664
- }
712
+ });
665
713
  }
666
714
  async navigateTree(targetId, options) {
667
- if (this.phase !== "idle")
668
- throw new AgentHarnessError("busy", "navigateTree() requires idle harness");
669
- this.phase = "branch_summary";
670
- try {
715
+ return this.runOperation("tree_navigation", "branch_summary", async (lease) => {
716
+ this.lifecycle.setStage(lease, "structural_running");
671
717
  const oldLeafId = await this.session.getLeafId();
718
+ // No-op navigation mutates nothing, so it completes without ever
719
+ // entering the `committing` stage.
672
720
  if (oldLeafId === targetId)
673
721
  return { cancelled: false };
674
722
  const targetEntry = await this.session.getEntry(targetId);
@@ -699,52 +747,24 @@ export class AgentHarness {
699
747
  const auth = await this.getApiKeyAndHeaders?.(model);
700
748
  if (!auth)
701
749
  throw new AgentHarnessError("auth", "No auth available for branch summary");
702
- const branchSummary = await generateBranchSummary(entries, {
750
+ const branchSummary = await runBranchSummary({
751
+ entries,
703
752
  model,
704
753
  apiKey: auth.apiKey,
705
754
  headers: auth.headers,
706
- signal: new AbortController().signal,
707
755
  customInstructions: hookResult?.customInstructions ?? options?.customInstructions,
708
756
  replaceInstructions: hookResult?.replaceInstructions ?? options?.replaceInstructions,
709
- ...createSummarizationRetry("branch_summary", this.streamOptions.summarizationRetry, (event) => this.emitOwn(event)),
757
+ summarizationRetry: this.streamOptions.summarizationRetry,
758
+ emit: (event) => this.emitOwn(event),
710
759
  });
711
- if (!branchSummary.ok) {
712
- if (branchSummary.error.code === "aborted")
713
- return { cancelled: true };
714
- throw new AgentHarnessError("branch_summary", branchSummary.error.message, branchSummary.error);
715
- }
716
- summaryText = branchSummary.value.summary;
717
- summaryDetails = {
718
- readFiles: branchSummary.value.readFiles,
719
- modifiedFiles: branchSummary.value.modifiedFiles,
720
- };
721
- }
722
- let editorText;
723
- let newLeafId;
724
- if (targetEntry.type === "message" && targetEntry.message.role === "user") {
725
- newLeafId = targetEntry.parentId;
726
- const content = targetEntry.message.content;
727
- editorText =
728
- typeof content === "string"
729
- ? content
730
- : content
731
- .filter((c) => c.type === "text")
732
- .map((c) => c.text)
733
- .join("");
734
- }
735
- else if (targetEntry.type === "custom_message") {
736
- newLeafId = targetEntry.parentId;
737
- editorText =
738
- typeof targetEntry.content === "string"
739
- ? targetEntry.content
740
- : targetEntry.content
741
- .filter((c) => c.type === "text")
742
- .map((c) => c.text)
743
- .join("");
744
- }
745
- else {
746
- newLeafId = targetId;
747
- }
760
+ if (branchSummary.cancelled)
761
+ return { cancelled: true };
762
+ summaryText = branchSummary.summary;
763
+ summaryDetails = branchSummary.details;
764
+ }
765
+ const { newLeafId, editorText } = resolveNavigationTarget(targetEntry, targetId);
766
+ // Single declared commit point of a tree navigation.
767
+ this.lifecycle.setStage(lease, "committing");
748
768
  const summaryId = await this.session.moveTo(newLeafId, summaryText
749
769
  ? { summary: summaryText, details: summaryDetails, fromHook: hookResult?.summary !== undefined }
750
770
  : undefined);
@@ -761,13 +781,7 @@ export class AgentHarness {
761
781
  fromHook: hookResult?.summary !== undefined,
762
782
  });
763
783
  return { cancelled: false, editorText, summaryEntry };
764
- }
765
- catch (error) {
766
- throw normalizeHarnessError(error, "branch_summary");
767
- }
768
- finally {
769
- this.phase = "idle";
770
- }
784
+ }, classifyNavigateTreeOutcome);
771
785
  }
772
786
  getModel() {
773
787
  return this.model;
@@ -775,12 +789,9 @@ export class AgentHarness {
775
789
  async setModel(model) {
776
790
  try {
777
791
  const previousModel = this.model;
778
- if (this.phase === "idle") {
779
- await this.session.appendModelChange(model.provider, model.id);
780
- }
781
- else {
782
- this.pendingSessionWrites.push({ type: "model_change", provider: model.provider, modelId: model.id });
783
- }
792
+ const nextProvider = model.provider;
793
+ const nextModelId = model.id;
794
+ await this.persistConfigChange({ type: "model_change", provider: nextProvider, modelId: nextModelId });
784
795
  this.model = model;
785
796
  await this.emitOwn({ type: "model_update", model, previousModel, source: "set" });
786
797
  }
@@ -794,12 +805,7 @@ export class AgentHarness {
794
805
  async setThinkingLevel(level) {
795
806
  try {
796
807
  const previousLevel = this.thinkingLevel;
797
- if (this.phase === "idle") {
798
- await this.session.appendThinkingLevelChange(level);
799
- }
800
- else {
801
- this.pendingSessionWrites.push({ type: "thinking_level_change", thinkingLevel: level });
802
- }
808
+ await this.persistConfigChange({ type: "thinking_level_change", thinkingLevel: level });
803
809
  this.thinkingLevel = level;
804
810
  await this.emitOwn({ type: "thinking_level_update", level, previousLevel });
805
811
  }
@@ -818,12 +824,7 @@ export class AgentHarness {
818
824
  this.validateToolNames(nextActiveToolNames, nextTools);
819
825
  const previousToolNames = [...this.tools.keys()];
820
826
  const previousActiveToolNames = [...this.activeToolNames];
821
- if (this.phase === "idle") {
822
- await this.session.appendActiveToolsChange(nextActiveToolNames);
823
- }
824
- else {
825
- this.pendingSessionWrites.push({ type: "active_tools_change", activeToolNames: [...nextActiveToolNames] });
826
- }
827
+ await this.persistConfigChange({ type: "active_tools_change", activeToolNames: [...nextActiveToolNames] });
827
828
  this.tools = nextTools;
828
829
  this.activeToolNames = [...nextActiveToolNames];
829
830
  await this.emitOwn({
@@ -844,16 +845,12 @@ export class AgentHarness {
844
845
  }
845
846
  async setActiveTools(toolNames) {
846
847
  try {
847
- this.validateToolNames(toolNames);
848
+ const nextActiveToolNames = [...toolNames];
849
+ this.validateToolNames(nextActiveToolNames);
848
850
  const previousToolNames = [...this.tools.keys()];
849
851
  const previousActiveToolNames = [...this.activeToolNames];
850
- if (this.phase === "idle") {
851
- await this.session.appendActiveToolsChange(toolNames);
852
- }
853
- else {
854
- this.pendingSessionWrites.push({ type: "active_tools_change", activeToolNames: [...toolNames] });
855
- }
856
- this.activeToolNames = [...toolNames];
852
+ await this.persistConfigChange({ type: "active_tools_change", activeToolNames: [...nextActiveToolNames] });
853
+ this.activeToolNames = [...nextActiveToolNames];
857
854
  await this.emitOwn({
858
855
  type: "tools_update",
859
856
  toolNames: [...this.tools.keys()],
@@ -899,31 +896,44 @@ export class AgentHarness {
899
896
  async setStreamOptions(streamOptions) {
900
897
  this.streamOptions = cloneStreamOptions(streamOptions);
901
898
  }
899
+ /**
900
+ * Deliver the abort signal to the current operation without waiting for it to
901
+ * settle. Safe from an operation's own callbacks: nothing here awaits
902
+ * settlement, so it cannot form the cycle `abort()` has to refuse.
903
+ */
904
+ requestAbort() {
905
+ assertAbortAllowed(this.lifecycle.getSnapshot());
906
+ return describeAbortDelivery(this.lifecycle.requestAbort());
907
+ }
908
+ /**
909
+ * Queue work to run once the harness is idle and return its ref immediately.
910
+ *
911
+ * This is the callback-safe way to schedule follow-up work: awaiting
912
+ * `waitForIdle()` or `abort()` from a callback of the operation being settled
913
+ * deadlocks, because settlement awaits that callback. The ref reports the
914
+ * command's outcome and cancels it while it is still queued.
915
+ */
916
+ async runWhenIdle(command) {
917
+ return this.deferredCommands.enqueue(command);
918
+ }
902
919
  async abort() {
903
- if (this.phase !== "idle" && this.phase !== "turn")
904
- throw new AgentHarnessError("invalid_state", `Cannot abort during ${this.phase}`);
920
+ // Aborting awaits the captured operation's settlement, so a listener of that
921
+ // same operation must never reach the wait below.
922
+ this.rejectCurrentOperationSelfWait("abort()");
923
+ assertAbortAllowed(this.lifecycle.getSnapshot());
924
+ // Capture the current operation before delivering any signal: an operation
925
+ // started later by a settlement listener is never this call's target.
926
+ const capture = this.lifecycle.requestAbort();
905
927
  const clearedSteer = this.steerQueue.splice(0);
906
928
  const clearedFollowUp = this.followUpQueue.splice(0);
907
- this.runAbortController?.abort();
908
- const errors = [];
909
- try {
910
- await this.emitQueueUpdate();
911
- }
912
- catch (error) {
913
- errors.push(toError(error));
914
- }
915
- try {
916
- await this.waitForIdle();
917
- }
918
- catch (error) {
919
- errors.push(toError(error));
920
- }
921
- try {
922
- await this.emitOwn({ type: "abort", clearedSteer, clearedFollowUp });
923
- }
924
- catch (error) {
925
- errors.push(toError(error));
926
- }
929
+ const errors = await collectStepErrors([
930
+ () => this.emitQueueUpdate(),
931
+ async () => {
932
+ if (capture.target)
933
+ await capture.target.settled;
934
+ },
935
+ () => this.emitOwn({ type: "abort", clearedSteer, clearedFollowUp }),
936
+ ]);
927
937
  if (errors.length > 0) {
928
938
  const cause = errors.length === 1 ? errors[0] : new AggregateError(errors, "Abort completed with errors");
929
939
  throw normalizeHarnessError(cause, "hook");
@@ -931,20 +941,12 @@ export class AgentHarness {
931
941
  return { clearedSteer, clearedFollowUp };
932
942
  }
933
943
  async waitForIdle() {
934
- // Runs can chain: a settled listener may start the next prompt() before the
935
- // previous run's promise resolves, so keep waiting until no run is active.
936
- while (this.runPromise) {
937
- await this.runPromise;
938
- }
944
+ this.rejectCurrentOperationSelfWait("waitForIdle()");
945
+ // Delegates to the lifecycle: resolves once no operation is active or settling.
946
+ await this.lifecycle.waitForIdle();
939
947
  }
940
948
  subscribe(listener) {
941
- let handlers = this.handlers.get(SUBSCRIBER_EVENT_TYPE);
942
- if (!handlers) {
943
- handlers = new Set();
944
- this.handlers.set(SUBSCRIBER_EVENT_TYPE, handlers);
945
- }
946
- handlers.add(listener);
947
- return () => handlers.delete(listener);
949
+ return this.subscribers.subscribe(listener);
948
950
  }
949
951
  on(type, handler) {
950
952
  let handlers = this.handlers.get(type);