omk-agent-core 0.98.1 → 0.98.2

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 (43) hide show
  1. package/dist/harness/agent-harness.d.ts +20 -6
  2. package/dist/harness/agent-harness.d.ts.map +1 -1
  3. package/dist/harness/agent-harness.js +300 -307
  4. package/dist/harness/agent-harness.js.map +1 -1
  5. package/dist/harness/compaction/operation.d.ts +7 -0
  6. package/dist/harness/compaction/operation.d.ts.map +1 -1
  7. package/dist/harness/compaction/operation.js.map +1 -1
  8. package/dist/harness/harness-session.d.ts +5 -58
  9. package/dist/harness/harness-session.d.ts.map +1 -1
  10. package/dist/harness/harness-session.js +15 -18
  11. package/dist/harness/harness-session.js.map +1 -1
  12. package/dist/harness/operation-lifecycle-controller.d.ts +68 -0
  13. package/dist/harness/operation-lifecycle-controller.d.ts.map +1 -0
  14. package/dist/harness/operation-lifecycle-controller.js +199 -0
  15. package/dist/harness/operation-lifecycle-controller.js.map +1 -0
  16. package/dist/harness/operation-lifecycle-reducer.d.ts +19 -0
  17. package/dist/harness/operation-lifecycle-reducer.d.ts.map +1 -0
  18. package/dist/harness/operation-lifecycle-reducer.js +201 -0
  19. package/dist/harness/operation-lifecycle-reducer.js.map +1 -0
  20. package/dist/harness/operation-lifecycle-types.d.ts +130 -0
  21. package/dist/harness/operation-lifecycle-types.d.ts.map +1 -0
  22. package/dist/harness/operation-lifecycle-types.js +34 -0
  23. package/dist/harness/operation-lifecycle-types.js.map +1 -0
  24. package/dist/harness/operation-outcome.d.ts +71 -0
  25. package/dist/harness/operation-outcome.d.ts.map +1 -0
  26. package/dist/harness/operation-outcome.js +131 -0
  27. package/dist/harness/operation-outcome.js.map +1 -0
  28. package/dist/harness/session-write-coordinator.d.ts +76 -0
  29. package/dist/harness/session-write-coordinator.d.ts.map +1 -0
  30. package/dist/harness/session-write-coordinator.js +126 -0
  31. package/dist/harness/session-write-coordinator.js.map +1 -0
  32. package/dist/harness/subscriber-fanout.d.ts +37 -0
  33. package/dist/harness/subscriber-fanout.d.ts.map +1 -0
  34. package/dist/harness/subscriber-fanout.js +73 -0
  35. package/dist/harness/subscriber-fanout.js.map +1 -0
  36. package/dist/harness/tree-navigation.d.ts +45 -0
  37. package/dist/harness/tree-navigation.d.ts.map +1 -0
  38. package/dist/harness/tree-navigation.js +59 -0
  39. package/dist/harness/tree-navigation.js.map +1 -0
  40. package/dist/harness/types.d.ts +34 -3
  41. package/dist/harness/types.d.ts.map +1 -1
  42. package/dist/harness/types.js.map +1 -1
  43. package/package.json +2 -2
@@ -1,36 +1,28 @@
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 { collectEntriesForBranchSummary } from "./compaction/branch-summarization.js";
4
4
  import { compact, DEFAULT_COMPACTION_SETTINGS, estimateContextTokens, prepareCompaction, shouldCompact, } from "./compaction/compaction.js";
5
5
  import { HarnessSessionFacade } from "./harness-session.js";
6
6
  import { convertToLlm, createFailureMessage, createUserMessage } from "./messages.js";
7
7
  import { findDuplicateNames } from "./name-validation.js";
8
+ import { OperationLifecycleController, } from "./operation-lifecycle-controller.js";
9
+ import { PROMPT_FAMILY_KINDS, } from "./operation-lifecycle-types.js";
10
+ import { classifyAssistantOutcome, classifyAttemptFailure, classifyAttemptOutcome, classifyNavigateTreeOutcome, combineBoundaryErrors, normalizeHarnessError, resolveOperationFailure, resolveOperationOutcome, } from "./operation-outcome.js";
8
11
  import { formatPromptTemplateInvocation } from "./prompt-templates.js";
12
+ import { uuidv7 } from "./session/uuid.js";
13
+ import { SessionWriteCoordinator } from "./session-write-coordinator.js";
9
14
  import { formatSkillInvocation } from "./skills.js";
10
15
  import { applyStreamOptionsPatch, cloneStreamOptions, mergeHeaders } from "./stream-options.js";
16
+ import { SubscriberFanout } from "./subscriber-fanout.js";
11
17
  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
- }
18
+ import { resolveNavigationTarget, runBranchSummary } from "./tree-navigation.js";
19
+ import { AgentHarnessError, toError } from "./types.js";
26
20
  export class AgentHarness {
27
21
  env;
28
22
  session;
29
23
  sessionFacade;
30
- phase = "idle";
31
- runAbortController;
32
- runPromise;
33
- pendingSessionWrites = [];
24
+ lifecycle;
25
+ sessionWrites;
34
26
  model;
35
27
  thinkingLevel;
36
28
  systemPrompt;
@@ -46,10 +38,16 @@ export class AgentHarness {
46
38
  followUpQueueMode;
47
39
  nextTurnQueue = [];
48
40
  handlers = new Map();
41
+ subscribers = new SubscriberFanout();
49
42
  constructor(options) {
50
43
  this.env = options.env;
51
44
  this.session = options.session;
52
- this.sessionFacade = new HarnessSessionFacade(this.session, () => this.phase, this.pendingSessionWrites);
45
+ this.sessionWrites = new SessionWriteCoordinator(this.session);
46
+ this.lifecycle = new OperationLifecycleController({
47
+ createOperationId: () => uuidv7(),
48
+ now: () => Date.now(),
49
+ });
50
+ this.sessionFacade = new HarnessSessionFacade(this.session, () => this.currentPhase(), this.sessionWrites);
53
51
  this.resources = options.resources ?? {};
54
52
  this.streamOptions = cloneStreamOptions(options.streamOptions);
55
53
  this.compactionSettings = { ...DEFAULT_COMPACTION_SETTINGS, ...options.compaction };
@@ -70,24 +68,15 @@ export class AgentHarness {
70
68
  this.followUpQueueMode = options.followUpMode ?? "one-at-a-time";
71
69
  }
72
70
  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
- }
71
+ await this.emitAny(event, signal);
81
72
  }
73
+ /** Subscriber fan-out; the self-wait barrier lives in `SubscriberFanout`. */
82
74
  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
- }
75
+ await this.subscribers.emit(event, this.lifecycle.getCurrentOperation()?.operationId, signal);
76
+ }
77
+ /** Fail closed when an awaited listener tries to wait on its own operation. */
78
+ rejectCurrentOperationSelfWait(api) {
79
+ this.subscribers.assertNotSelfWait(api, this.lifecycle.getCurrentOperation()?.operationId);
91
80
  }
92
81
  async emitHook(event) {
93
82
  const handlers = this.handlers.get(event.type);
@@ -156,23 +145,88 @@ export class AgentHarness {
156
145
  nextTurn: [...this.nextTurnQueue],
157
146
  });
158
147
  }
159
- startRunPromise() {
160
- let finish = () => { };
161
- const runPromise = new Promise((resolve) => {
162
- finish = resolve;
148
+ /**
149
+ * Facade write-gate vocabulary mapped from lifecycle state. `settling` maps
150
+ * to "idle": the queue is drained by the settlement finalizer first, and
151
+ * listener writes persist after it through the coordinator tail.
152
+ */
153
+ currentPhase() {
154
+ const snapshot = this.lifecycle.getSnapshot();
155
+ if (snapshot.tag !== "active")
156
+ return "idle";
157
+ switch (snapshot.operation.kind) {
158
+ case "manual_compaction":
159
+ return "compaction";
160
+ case "tree_navigation":
161
+ return "branch_summary";
162
+ default:
163
+ return "turn";
164
+ }
165
+ }
166
+ /** Config writes persist immediately outside an active operation and queue during one. */
167
+ async persistConfigChange(write) {
168
+ if (this.lifecycle.getSnapshot().tag !== "active") {
169
+ await this.sessionWrites.persistAfterPending(write);
170
+ }
171
+ else {
172
+ this.sessionWrites.enqueue(write);
173
+ }
174
+ }
175
+ /**
176
+ * Single wrapper for every public operation: begin a lease, run the body,
177
+ * then settle exactly once. The final flush and the `settled` event happen
178
+ * inside the settling barrier; a finalizer failure never reports success.
179
+ */
180
+ async runOperation(kind, fallbackCode, body, classifyResult) {
181
+ const lease = this.lifecycle.begin(kind);
182
+ let result;
183
+ let bodyError;
184
+ // Everything after a successful begin() runs inside one capture region. A
185
+ // throwing `operation_started` listener must not escape before settle(),
186
+ // or the lifecycle would stay active and wedge the harness at "busy".
187
+ try {
188
+ await this.emitOwn({ type: "operation_started", operation: lease.operation });
189
+ result = await body(lease);
190
+ }
191
+ catch (error) {
192
+ bodyError = error;
193
+ }
194
+ // The final flush precedes classification: a persistence failure after a
195
+ // provider success must never record or report a completed operation.
196
+ let flushError;
197
+ try {
198
+ await this.sessionWrites.flush();
199
+ }
200
+ catch (error) {
201
+ flushError = error;
202
+ }
203
+ const outcome = resolveOperationOutcome({
204
+ signalAborted: lease.signal.aborted,
205
+ result,
206
+ bodyError,
207
+ flushError,
208
+ classifyResult,
209
+ fallbackCode,
163
210
  });
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
- };
211
+ let settleError;
212
+ try {
213
+ await this.lifecycle.settle(lease, outcome, async () => {
214
+ await this.emitOwn({
215
+ type: "settled",
216
+ nextTurnCount: this.nextTurnQueue.length,
217
+ operationId: lease.operation.operationId,
218
+ outcome,
219
+ attemptCount: this.lifecycle.getAttemptSummaries(lease).length,
220
+ }, lease.signal);
221
+ });
222
+ }
223
+ catch (error) {
224
+ settleError = error;
225
+ }
226
+ const failure = resolveOperationFailure({ bodyError, flushError, settleError, fallbackCode });
227
+ if (failure !== undefined)
228
+ throw failure;
229
+ return result;
176
230
  }
177
231
  async createTurnState() {
178
232
  const context = await this.session.buildContext();
@@ -258,7 +312,7 @@ export class AgentHarness {
258
312
  throw normalizeHarnessError(error, "hook");
259
313
  }
260
314
  }
261
- createLoopConfig(getTurnState, setTurnState) {
315
+ createLoopConfig(getTurnState, setTurnState, lease) {
262
316
  const turnState = getTurnState();
263
317
  return {
264
318
  model: turnState.model,
@@ -292,7 +346,13 @@ export class AgentHarness {
292
346
  : undefined;
293
347
  },
294
348
  prepareNextTurn: async () => {
295
- await this.flushPendingSessionWrites();
349
+ await this.sessionWrites.flush();
350
+ if (lease) {
351
+ const snapshot = this.lifecycle.getSnapshot();
352
+ if (snapshot.tag === "active" && snapshot.stage === "save_point") {
353
+ this.lifecycle.setStage(lease, "attempt_running");
354
+ }
355
+ }
296
356
  const nextTurnState = await this.createTurnState();
297
357
  setTurnState(nextTurnState);
298
358
  return {
@@ -316,40 +376,7 @@ export class AgentHarness {
316
376
  if (missing.length > 0)
317
377
  throw new AgentHarnessError("invalid_argument", `Unknown tool(s): ${missing.join(", ")}`);
318
378
  }
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) {
379
+ async handleAgentEvent(event, signal, lease) {
353
380
  if (event.type === "message_end") {
354
381
  await this.session.appendMessage(event.message);
355
382
  await this.emitAny(event, signal);
@@ -363,39 +390,48 @@ export class AgentHarness {
363
390
  catch (error) {
364
391
  eventError = error;
365
392
  }
366
- const hadPendingMutations = this.pendingSessionWrites.length > 0;
367
- await this.flushPendingSessionWrites();
368
- if (eventError)
369
- throw eventError;
393
+ // The flush runs even after a failing listener so accepted writes are
394
+ // not stranded; a failing flush must then report next to that listener
395
+ // error, not in place of it.
396
+ const hadPendingMutations = this.sessionWrites.hasPending();
397
+ let flushError;
398
+ try {
399
+ await this.sessionWrites.flush();
400
+ }
401
+ catch (error) {
402
+ flushError = error;
403
+ }
404
+ if (lease) {
405
+ const snapshot = this.lifecycle.getSnapshot();
406
+ if (snapshot.tag === "active" && snapshot.stage === "attempt_running") {
407
+ this.lifecycle.setStage(lease, "save_point");
408
+ }
409
+ }
410
+ const failure = combineBoundaryErrors([eventError, flushError], "turn_end listener failed and the save-point flush failed", "hook");
411
+ if (failure !== undefined)
412
+ throw failure;
370
413
  await this.emitOwn({ type: "save_point", hadPendingMutations });
371
414
  return;
372
415
  }
373
416
  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";
417
+ // agent_end is an attempt event: flush its accepted writes, but lifecycle
418
+ // settlement and the settled event belong to OperationLease.settle().
419
+ await this.sessionWrites.flush();
382
420
  await this.emitAny(event, signal);
383
- await this.emitOwn({ type: "settled", nextTurnCount: this.nextTurnQueue.length }, signal);
384
421
  return;
385
422
  }
386
423
  await this.emitAny(event, signal);
387
424
  }
388
- async emitRunFailure(model, error, aborted, signal, completedMessages) {
425
+ async emitRunFailure(model, error, aborted, signal, completedMessages, lease) {
389
426
  const failureMessage = createFailureMessage(model, error, aborted);
390
427
  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);
428
+ await this.handleAgentEvent({ type: "message_start", message: failureMessage }, signal, lease);
429
+ await this.handleAgentEvent({ type: "message_end", message: failureMessage }, signal, lease);
430
+ await this.handleAgentEvent({ type: "turn_end", message: failureMessage, toolResults: [] }, signal, lease);
431
+ await this.handleAgentEvent({ type: "agent_end", messages }, signal, lease);
395
432
  return messages;
396
433
  }
397
- async executeTurn(turnState, text, options) {
398
- const runOwner = this.runPromise;
434
+ async executeTurn(lease, turnState, text, options) {
399
435
  let messages = [createUserMessage(text, options?.images)];
400
436
  if (this.nextTurnQueue.length > 0) {
401
437
  const queuedMessages = this.nextTurnQueue.splice(0);
@@ -417,65 +453,99 @@ export class AgentHarness {
417
453
  });
418
454
  if (beforeResult?.messages)
419
455
  messages = [...messages, ...beforeResult.messages];
420
- const result = await this.executeAgentRun(turnState, this.createContext(turnState, beforeResult?.systemPrompt), messages);
421
- return await this.recoverContextOverflow(result, runOwner);
456
+ const result = await this.executeAgentRun(lease, "initial", turnState, this.createContext(turnState, beforeResult?.systemPrompt), messages);
457
+ return await this.recoverContextOverflow(lease, result);
458
+ }
459
+ /**
460
+ * Sole attempt boundary: begin, announce, run, classify, close, announce, flush.
461
+ *
462
+ * Once `beginAttempt()` succeeds the attempt is closed exactly once on every
463
+ * path, so `count(attempt_started) == count(attempt_finished)` holds even when
464
+ * the `attempt_started` observer throws. `attempt_finished` is emitted only
465
+ * after the attempt is already closed, so a throwing observer can fail the
466
+ * operation but can never reopen committed attempt state. The closing flush
467
+ * is not a `finally`: a `finally` that awaits a throwing flush would replace
468
+ * the body error, hiding the provider or listener failure from the audit trail.
469
+ */
470
+ async runAttempt(lease, reason, body, classify) {
471
+ const attemptLease = this.lifecycle.beginAttempt(lease, reason);
472
+ let result;
473
+ let bodyError;
474
+ try {
475
+ await this.emitOwn({ type: "attempt_started", attempt: attemptLease.attempt }, lease.signal);
476
+ result = await body(attemptLease);
477
+ }
478
+ catch (error) {
479
+ bodyError = error;
480
+ }
481
+ const outcome = bodyError === undefined ? classify(result) : classifyAttemptFailure(bodyError);
482
+ this.lifecycle.finishAttempt(lease, attemptLease, outcome);
483
+ let observerError;
484
+ try {
485
+ await this.emitOwn({ type: "attempt_finished", summary: this.lifecycle.getAttemptSummary(lease, attemptLease) }, lease.signal);
486
+ }
487
+ catch (error) {
488
+ observerError = error;
489
+ }
490
+ let flushError;
491
+ try {
492
+ await this.sessionWrites.flush();
493
+ }
494
+ catch (error) {
495
+ flushError = error;
496
+ }
497
+ const failure = combineBoundaryErrors([bodyError, observerError, flushError], "Attempt failed and its attempt_finished reporting or closing flush failed", "unknown");
498
+ if (failure !== undefined)
499
+ throw failure;
500
+ return result;
422
501
  }
423
- async executeAgentRun(turnState, context, initialMessages) {
502
+ async executeAgentRun(lease, reason, turnState, context, initialMessages) {
424
503
  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 () => {
504
+ return await this.runAttempt(lease, reason, async (attemptLease) => {
505
+ const signal = attemptLease.signal;
506
+ const getTurnState = () => activeTurnState;
507
+ const setTurnState = (nextTurnState) => {
508
+ activeTurnState = nextTurnState;
509
+ };
510
+ const completedMessages = [];
511
+ const emit = async (event) => {
512
+ if (event.type === "message_end")
513
+ completedMessages.push(event.message);
514
+ await this.handleAgentEvent(event, signal, lease);
515
+ };
516
+ let newMessages;
438
517
  try {
439
- const loopConfig = this.createLoopConfig(getTurnState, setTurnState);
518
+ const loopConfig = this.createLoopConfig(getTurnState, setTurnState, lease);
440
519
  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);
520
+ newMessages = initialMessages
521
+ ? await runAgentLoop(initialMessages, context, loopConfig, emit, signal, streamFn)
522
+ : await runAgentLoopContinue(context, loopConfig, emit, signal, streamFn);
444
523
  }
445
524
  catch (error) {
446
525
  try {
447
- return await this.emitRunFailure(activeTurnState.model, error, abortController.signal.aborted, abortController.signal, completedMessages);
526
+ newMessages = await this.emitRunFailure(activeTurnState.model, error, signal.aborted, signal, completedMessages, lease);
448
527
  }
449
528
  catch (failureError) {
450
529
  const cause = new AggregateError([toError(error), toError(failureError)], "Agent run failed and failure reporting failed");
451
530
  throw new AgentHarnessError("unknown", cause.message, cause);
452
531
  }
453
532
  }
454
- })();
455
- try {
456
- const newMessages = await runResultPromise;
457
533
  for (let i = newMessages.length - 1; i >= 0; i--) {
458
534
  const message = newMessages[i];
459
535
  if (message.role === "assistant")
460
536
  return message;
461
537
  }
462
538
  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)) {
539
+ }, (message) => classifyAttemptOutcome(message, activeTurnState.model.contextWindow));
540
+ }
541
+ /**
542
+ * One-shot overflow recovery inside the originating operation. The lease is
543
+ * proof that this operation still owns the harness, so no run-ownership or
544
+ * phase re-check is needed; a strict lifecycle makes a newer operation
545
+ * starting mid-recovery impossible.
546
+ */
547
+ async recoverContextOverflow(lease, message) {
548
+ if (!this.compactionSettings.enabled || !isContextOverflow(message, this.model.contextWindow)) {
479
549
  return message;
480
550
  }
481
551
  if (!this.getApiKeyAndHeaders)
@@ -491,16 +561,15 @@ export class AgentHarness {
491
561
  return message;
492
562
  }
493
563
  await this.session.moveTo(leaf.parentId);
494
- this.phase = "retry";
564
+ this.lifecycle.setStage(lease, "recovering_overflow");
495
565
  try {
496
- const compacted = await this.runCompaction({ automatic: true });
566
+ const compacted = await this.runCompaction({ automatic: true, signal: lease.signal });
497
567
  if (!compacted) {
498
568
  await this.session.moveTo(leafId);
499
- this.phase = "idle";
500
569
  return message;
501
570
  }
502
571
  const turnState = await this.createTurnState();
503
- return await this.executeAgentRun(turnState, this.createContext(turnState));
572
+ return await this.executeAgentRun(lease, "context_overflow_recovery", turnState, this.createContext(turnState));
504
573
  }
505
574
  catch (error) {
506
575
  await this.session.moveTo(leafId);
@@ -508,76 +577,49 @@ export class AgentHarness {
508
577
  }
509
578
  }
510
579
  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 {
580
+ return this.runOperation("prompt", "unknown", async (lease) => {
516
581
  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
- }
582
+ return await this.executeTurn(lease, turnState, text, options);
583
+ }, classifyAssistantOutcome);
529
584
  }
530
585
  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 {
586
+ return this.runOperation("skill", "unknown", async (lease) => {
536
587
  const turnState = await this.createTurnState();
537
588
  const skill = (turnState.resources.skills ?? []).find((candidate) => candidate.name === name);
538
589
  if (!skill)
539
590
  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
- }
591
+ return await this.executeTurn(lease, turnState, formatSkillInvocation(skill, additionalInstructions));
592
+ }, classifyAssistantOutcome);
550
593
  }
551
594
  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 {
595
+ return this.runOperation("prompt_template", "unknown", async (lease) => {
557
596
  const turnState = await this.createTurnState();
558
597
  const template = (turnState.resources.promptTemplates ?? []).find((candidate) => candidate.name === name);
559
598
  if (!template)
560
599
  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();
600
+ return await this.executeTurn(lease, turnState, formatPromptTemplateInvocation(template, args));
601
+ }, classifyAssistantOutcome);
602
+ }
603
+ /**
604
+ * Steering and follow-up input is consumed only by a running agent attempt.
605
+ * A structural operation (`compact`, `navigateTree`) runs none, so accepting
606
+ * input there would silently inject it into an unrelated later prompt.
607
+ */
608
+ expectQueueConsumer(action) {
609
+ const snapshot = this.lifecycle.getSnapshot();
610
+ if (snapshot.tag !== "active")
611
+ throw new AgentHarnessError("invalid_state", `Cannot ${action} while idle`);
612
+ if (!PROMPT_FAMILY_KINDS.includes(snapshot.operation.kind)) {
613
+ throw new AgentHarnessError("invalid_state", `Cannot ${action} during ${snapshot.operation.kind}: no agent attempt can consume it`);
570
614
  }
571
615
  }
572
616
  async steer(text, options) {
573
- if (this.phase === "idle")
574
- throw new AgentHarnessError("invalid_state", "Cannot steer while idle");
617
+ this.expectQueueConsumer("steer");
575
618
  this.steerQueue.push(createUserMessage(text, options?.images));
576
619
  await this.emitQueueUpdate();
577
620
  }
578
621
  async followUp(text, options) {
579
- if (this.phase === "idle")
580
- throw new AgentHarnessError("invalid_state", "Cannot follow up while idle");
622
+ this.expectQueueConsumer("follow up");
581
623
  this.followUpQueue.push(createUserMessage(text, options?.images));
582
624
  await this.emitQueueUpdate();
583
625
  }
@@ -629,6 +671,7 @@ export class AgentHarness {
629
671
  if (!compactResult.ok)
630
672
  throw compactResult.error;
631
673
  const result = compactResult.value;
674
+ options.beforeCommit?.();
632
675
  const entryId = await this.session.appendCompaction(result.summary, result.firstKeptEntryId, result.tokensBefore, result.details, provided !== undefined);
633
676
  const entry = await this.session.getEntry(entryId);
634
677
  if (entry?.type === "compaction") {
@@ -647,28 +690,26 @@ export class AgentHarness {
647
690
  return { ...context, messages: convertToLlm(persisted.messages) };
648
691
  }
649
692
  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 });
693
+ return this.runOperation("manual_compaction", "compaction", async (lease) => {
694
+ this.lifecycle.setStage(lease, "structural_running");
695
+ const result = await this.runCompaction({
696
+ automatic: false,
697
+ customInstructions,
698
+ beforeCommit: () => {
699
+ this.lifecycle.setStage(lease, "committing");
700
+ },
701
+ });
655
702
  if (!result)
656
703
  throw new AgentHarnessError("compaction", "Nothing to compact");
657
704
  return result;
658
- }
659
- catch (error) {
660
- throw normalizeHarnessError(error, "compaction");
661
- }
662
- finally {
663
- this.phase = "idle";
664
- }
705
+ });
665
706
  }
666
707
  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 {
708
+ return this.runOperation("tree_navigation", "branch_summary", async (lease) => {
709
+ this.lifecycle.setStage(lease, "structural_running");
671
710
  const oldLeafId = await this.session.getLeafId();
711
+ // No-op navigation mutates nothing, so it completes without ever
712
+ // entering the `committing` stage.
672
713
  if (oldLeafId === targetId)
673
714
  return { cancelled: false };
674
715
  const targetEntry = await this.session.getEntry(targetId);
@@ -699,52 +740,24 @@ export class AgentHarness {
699
740
  const auth = await this.getApiKeyAndHeaders?.(model);
700
741
  if (!auth)
701
742
  throw new AgentHarnessError("auth", "No auth available for branch summary");
702
- const branchSummary = await generateBranchSummary(entries, {
743
+ const branchSummary = await runBranchSummary({
744
+ entries,
703
745
  model,
704
746
  apiKey: auth.apiKey,
705
747
  headers: auth.headers,
706
- signal: new AbortController().signal,
707
748
  customInstructions: hookResult?.customInstructions ?? options?.customInstructions,
708
749
  replaceInstructions: hookResult?.replaceInstructions ?? options?.replaceInstructions,
709
- ...createSummarizationRetry("branch_summary", this.streamOptions.summarizationRetry, (event) => this.emitOwn(event)),
750
+ summarizationRetry: this.streamOptions.summarizationRetry,
751
+ emit: (event) => this.emitOwn(event),
710
752
  });
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
- }
753
+ if (branchSummary.cancelled)
754
+ return { cancelled: true };
755
+ summaryText = branchSummary.summary;
756
+ summaryDetails = branchSummary.details;
757
+ }
758
+ const { newLeafId, editorText } = resolveNavigationTarget(targetEntry, targetId);
759
+ // Single declared commit point of a tree navigation.
760
+ this.lifecycle.setStage(lease, "committing");
748
761
  const summaryId = await this.session.moveTo(newLeafId, summaryText
749
762
  ? { summary: summaryText, details: summaryDetails, fromHook: hookResult?.summary !== undefined }
750
763
  : undefined);
@@ -761,13 +774,7 @@ export class AgentHarness {
761
774
  fromHook: hookResult?.summary !== undefined,
762
775
  });
763
776
  return { cancelled: false, editorText, summaryEntry };
764
- }
765
- catch (error) {
766
- throw normalizeHarnessError(error, "branch_summary");
767
- }
768
- finally {
769
- this.phase = "idle";
770
- }
777
+ }, classifyNavigateTreeOutcome);
771
778
  }
772
779
  getModel() {
773
780
  return this.model;
@@ -775,12 +782,9 @@ export class AgentHarness {
775
782
  async setModel(model) {
776
783
  try {
777
784
  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
- }
785
+ const nextProvider = model.provider;
786
+ const nextModelId = model.id;
787
+ await this.persistConfigChange({ type: "model_change", provider: nextProvider, modelId: nextModelId });
784
788
  this.model = model;
785
789
  await this.emitOwn({ type: "model_update", model, previousModel, source: "set" });
786
790
  }
@@ -794,12 +798,7 @@ export class AgentHarness {
794
798
  async setThinkingLevel(level) {
795
799
  try {
796
800
  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
- }
801
+ await this.persistConfigChange({ type: "thinking_level_change", thinkingLevel: level });
803
802
  this.thinkingLevel = level;
804
803
  await this.emitOwn({ type: "thinking_level_update", level, previousLevel });
805
804
  }
@@ -818,12 +817,7 @@ export class AgentHarness {
818
817
  this.validateToolNames(nextActiveToolNames, nextTools);
819
818
  const previousToolNames = [...this.tools.keys()];
820
819
  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
- }
820
+ await this.persistConfigChange({ type: "active_tools_change", activeToolNames: [...nextActiveToolNames] });
827
821
  this.tools = nextTools;
828
822
  this.activeToolNames = [...nextActiveToolNames];
829
823
  await this.emitOwn({
@@ -844,16 +838,12 @@ export class AgentHarness {
844
838
  }
845
839
  async setActiveTools(toolNames) {
846
840
  try {
847
- this.validateToolNames(toolNames);
841
+ const nextActiveToolNames = [...toolNames];
842
+ this.validateToolNames(nextActiveToolNames);
848
843
  const previousToolNames = [...this.tools.keys()];
849
844
  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];
845
+ await this.persistConfigChange({ type: "active_tools_change", activeToolNames: [...nextActiveToolNames] });
846
+ this.activeToolNames = [...nextActiveToolNames];
857
847
  await this.emitOwn({
858
848
  type: "tools_update",
859
849
  toolNames: [...this.tools.keys()],
@@ -900,11 +890,21 @@ export class AgentHarness {
900
890
  this.streamOptions = cloneStreamOptions(streamOptions);
901
891
  }
902
892
  async abort() {
903
- if (this.phase !== "idle" && this.phase !== "turn")
904
- throw new AgentHarnessError("invalid_state", `Cannot abort during ${this.phase}`);
893
+ // Aborting awaits the captured operation's settlement, so a listener of that
894
+ // same operation must never reach the wait below.
895
+ this.rejectCurrentOperationSelfWait("abort()");
896
+ const snapshot = this.lifecycle.getSnapshot();
897
+ if (snapshot.tag === "active" && snapshot.operation.kind === "manual_compaction") {
898
+ throw new AgentHarnessError("invalid_state", "Cannot abort during compaction");
899
+ }
900
+ if (snapshot.tag === "active" && snapshot.operation.kind === "tree_navigation") {
901
+ throw new AgentHarnessError("invalid_state", "Cannot abort during branch_summary");
902
+ }
903
+ // Capture the current operation before delivering any signal: an operation
904
+ // started later by a settlement listener is never this call's target.
905
+ const capture = this.lifecycle.requestAbort();
905
906
  const clearedSteer = this.steerQueue.splice(0);
906
907
  const clearedFollowUp = this.followUpQueue.splice(0);
907
- this.runAbortController?.abort();
908
908
  const errors = [];
909
909
  try {
910
910
  await this.emitQueueUpdate();
@@ -913,7 +913,8 @@ export class AgentHarness {
913
913
  errors.push(toError(error));
914
914
  }
915
915
  try {
916
- await this.waitForIdle();
916
+ if (capture.target)
917
+ await capture.target.settled;
917
918
  }
918
919
  catch (error) {
919
920
  errors.push(toError(error));
@@ -931,20 +932,12 @@ export class AgentHarness {
931
932
  return { clearedSteer, clearedFollowUp };
932
933
  }
933
934
  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
- }
935
+ this.rejectCurrentOperationSelfWait("waitForIdle()");
936
+ // Delegates to the lifecycle: resolves once no operation is active or settling.
937
+ await this.lifecycle.waitForIdle();
939
938
  }
940
939
  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);
940
+ return this.subscribers.subscribe(listener);
948
941
  }
949
942
  on(type, handler) {
950
943
  let handlers = this.handlers.get(type);