omk-agent-core 0.98.0 → 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 (77) hide show
  1. package/dist/harness/agent-harness.d.ts +29 -14
  2. package/dist/harness/agent-harness.d.ts.map +1 -1
  3. package/dist/harness/agent-harness.js +409 -453
  4. package/dist/harness/agent-harness.js.map +1 -1
  5. package/dist/harness/compaction/branch-summarization.d.ts +5 -1
  6. package/dist/harness/compaction/branch-summarization.d.ts.map +1 -1
  7. package/dist/harness/compaction/branch-summarization.js +3 -3
  8. package/dist/harness/compaction/branch-summarization.js.map +1 -1
  9. package/dist/harness/compaction/compaction.d.ts +8 -4
  10. package/dist/harness/compaction/compaction.d.ts.map +1 -1
  11. package/dist/harness/compaction/compaction.js +12 -97
  12. package/dist/harness/compaction/compaction.js.map +1 -1
  13. package/dist/harness/compaction/operation.d.ts +13 -0
  14. package/dist/harness/compaction/operation.d.ts.map +1 -0
  15. package/dist/harness/compaction/operation.js +2 -0
  16. package/dist/harness/compaction/operation.js.map +1 -0
  17. package/dist/harness/compaction/summarization-prompts.d.ts +5 -0
  18. package/dist/harness/compaction/summarization-prompts.d.ts.map +1 -0
  19. package/dist/harness/compaction/summarization-prompts.js +88 -0
  20. package/dist/harness/compaction/summarization-prompts.js.map +1 -0
  21. package/dist/harness/errors.d.ts +15 -0
  22. package/dist/harness/errors.d.ts.map +1 -0
  23. package/dist/harness/errors.js +32 -0
  24. package/dist/harness/errors.js.map +1 -0
  25. package/dist/harness/harness-session.d.ts +52 -0
  26. package/dist/harness/harness-session.d.ts.map +1 -0
  27. package/dist/harness/harness-session.js +113 -0
  28. package/dist/harness/harness-session.js.map +1 -0
  29. package/dist/harness/messages.d.ts +3 -1
  30. package/dist/harness/messages.d.ts.map +1 -1
  31. package/dist/harness/messages.js +26 -0
  32. package/dist/harness/messages.js.map +1 -1
  33. package/dist/harness/name-validation.d.ts +2 -0
  34. package/dist/harness/name-validation.d.ts.map +1 -0
  35. package/dist/harness/name-validation.js +11 -0
  36. package/dist/harness/name-validation.js.map +1 -0
  37. package/dist/harness/operation-lifecycle-controller.d.ts +68 -0
  38. package/dist/harness/operation-lifecycle-controller.d.ts.map +1 -0
  39. package/dist/harness/operation-lifecycle-controller.js +199 -0
  40. package/dist/harness/operation-lifecycle-controller.js.map +1 -0
  41. package/dist/harness/operation-lifecycle-reducer.d.ts +19 -0
  42. package/dist/harness/operation-lifecycle-reducer.d.ts.map +1 -0
  43. package/dist/harness/operation-lifecycle-reducer.js +201 -0
  44. package/dist/harness/operation-lifecycle-reducer.js.map +1 -0
  45. package/dist/harness/operation-lifecycle-types.d.ts +130 -0
  46. package/dist/harness/operation-lifecycle-types.d.ts.map +1 -0
  47. package/dist/harness/operation-lifecycle-types.js +34 -0
  48. package/dist/harness/operation-lifecycle-types.js.map +1 -0
  49. package/dist/harness/operation-outcome.d.ts +71 -0
  50. package/dist/harness/operation-outcome.d.ts.map +1 -0
  51. package/dist/harness/operation-outcome.js +131 -0
  52. package/dist/harness/operation-outcome.js.map +1 -0
  53. package/dist/harness/session-write-coordinator.d.ts +76 -0
  54. package/dist/harness/session-write-coordinator.d.ts.map +1 -0
  55. package/dist/harness/session-write-coordinator.js +126 -0
  56. package/dist/harness/session-write-coordinator.js.map +1 -0
  57. package/dist/harness/stream-options.d.ts +31 -0
  58. package/dist/harness/stream-options.d.ts.map +1 -0
  59. package/dist/harness/stream-options.js +66 -0
  60. package/dist/harness/stream-options.js.map +1 -0
  61. package/dist/harness/subscriber-fanout.d.ts +37 -0
  62. package/dist/harness/subscriber-fanout.d.ts.map +1 -0
  63. package/dist/harness/subscriber-fanout.js +73 -0
  64. package/dist/harness/subscriber-fanout.js.map +1 -0
  65. package/dist/harness/summarization-retry.d.ts +29 -0
  66. package/dist/harness/summarization-retry.d.ts.map +1 -0
  67. package/dist/harness/summarization-retry.js +20 -0
  68. package/dist/harness/summarization-retry.js.map +1 -0
  69. package/dist/harness/tree-navigation.d.ts +45 -0
  70. package/dist/harness/tree-navigation.d.ts.map +1 -0
  71. package/dist/harness/tree-navigation.js +59 -0
  72. package/dist/harness/tree-navigation.js.map +1 -0
  73. package/dist/harness/types.d.ts +69 -43
  74. package/dist/harness/types.d.ts.map +1 -1
  75. package/dist/harness/types.js +1 -32
  76. package/dist/harness/types.js.map +1 -1
  77. package/package.json +2 -2
@@ -1,138 +1,33 @@
1
- import { streamSimple } from "omk-ai";
2
- import { runAgentLoop } from "../agent-loop.js";
3
- import { collectEntriesForBranchSummary, generateBranchSummary } from "./compaction/branch-summarization.js";
4
- import { compact, DEFAULT_COMPACTION_SETTINGS, prepareCompaction } from "./compaction/compaction.js";
5
- import { convertToLlm } from "./messages.js";
1
+ import { isContextOverflow, streamSimple, } from "omk-ai";
2
+ import { runAgentLoop, runAgentLoopContinue } from "../agent-loop.js";
3
+ import { collectEntriesForBranchSummary } from "./compaction/branch-summarization.js";
4
+ import { compact, DEFAULT_COMPACTION_SETTINGS, estimateContextTokens, prepareCompaction, shouldCompact, } from "./compaction/compaction.js";
5
+ import { HarnessSessionFacade } from "./harness-session.js";
6
+ import { convertToLlm, createFailureMessage, createUserMessage } from "./messages.js";
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";
6
11
  import { formatPromptTemplateInvocation } from "./prompt-templates.js";
12
+ import { uuidv7 } from "./session/uuid.js";
13
+ import { SessionWriteCoordinator } from "./session-write-coordinator.js";
7
14
  import { formatSkillInvocation } from "./skills.js";
8
- import { AgentHarnessError, BranchSummaryError, CompactionError, SessionError, toError } from "./types.js";
9
- function createUserMessage(text, images) {
10
- const content = [{ type: "text", text }];
11
- if (images)
12
- content.push(...images);
13
- return { role: "user", content, timestamp: Date.now() };
14
- }
15
- function createFailureMessage(model, error, aborted) {
16
- return {
17
- role: "assistant",
18
- content: [{ type: "text", text: "" }],
19
- api: model.api,
20
- provider: model.provider,
21
- model: model.id,
22
- stopReason: aborted ? "aborted" : "error",
23
- errorMessage: error instanceof Error ? error.message : String(error),
24
- timestamp: Date.now(),
25
- usage: {
26
- input: 0,
27
- output: 0,
28
- cacheRead: 0,
29
- cacheWrite: 0,
30
- totalTokens: 0,
31
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
32
- },
33
- };
34
- }
35
- function cloneStreamOptions(streamOptions) {
36
- return {
37
- ...streamOptions,
38
- headers: streamOptions?.headers ? { ...streamOptions.headers } : undefined,
39
- metadata: streamOptions?.metadata ? { ...streamOptions.metadata } : undefined,
40
- };
41
- }
42
- function mergeHeaders(...headers) {
43
- const merged = {};
44
- let hasHeaders = false;
45
- for (const entry of headers) {
46
- if (!entry)
47
- continue;
48
- Object.assign(merged, entry);
49
- hasHeaders = true;
50
- }
51
- return hasHeaders ? merged : undefined;
52
- }
53
- function findDuplicateNames(names) {
54
- const seen = new Set();
55
- const duplicates = new Set();
56
- for (const name of names) {
57
- if (seen.has(name))
58
- duplicates.add(name);
59
- seen.add(name);
60
- }
61
- return [...duplicates];
62
- }
63
- function applyStreamOptionsPatch(base, patch) {
64
- const result = cloneStreamOptions(base);
65
- if (!patch)
66
- return result;
67
- if (Object.hasOwn(patch, "transport"))
68
- result.transport = patch.transport;
69
- if (Object.hasOwn(patch, "timeoutMs"))
70
- result.timeoutMs = patch.timeoutMs;
71
- if (Object.hasOwn(patch, "maxRetries"))
72
- result.maxRetries = patch.maxRetries;
73
- if (Object.hasOwn(patch, "maxRetryDelayMs"))
74
- result.maxRetryDelayMs = patch.maxRetryDelayMs;
75
- if (Object.hasOwn(patch, "cacheRetention"))
76
- result.cacheRetention = patch.cacheRetention;
77
- if (Object.hasOwn(patch, "headers")) {
78
- if (patch.headers === undefined) {
79
- result.headers = undefined;
80
- }
81
- else {
82
- const headers = { ...(result.headers ?? {}) };
83
- for (const [key, value] of Object.entries(patch.headers)) {
84
- if (value === undefined)
85
- delete headers[key];
86
- else
87
- headers[key] = value;
88
- }
89
- result.headers = Object.keys(headers).length > 0 ? headers : undefined;
90
- }
91
- }
92
- if (Object.hasOwn(patch, "metadata")) {
93
- if (patch.metadata === undefined) {
94
- result.metadata = undefined;
95
- }
96
- else {
97
- const metadata = { ...(result.metadata ?? {}) };
98
- for (const [key, value] of Object.entries(patch.metadata)) {
99
- if (value === undefined)
100
- delete metadata[key];
101
- else
102
- metadata[key] = value;
103
- }
104
- result.metadata = Object.keys(metadata).length > 0 ? metadata : undefined;
105
- }
106
- }
107
- return result;
108
- }
109
- const SUBSCRIBER_EVENT_TYPE = "*";
110
- function normalizeHarnessError(error, fallbackCode) {
111
- if (error instanceof AgentHarnessError)
112
- return error;
113
- const cause = toError(error);
114
- if (cause instanceof SessionError)
115
- return new AgentHarnessError("session", cause.message, cause);
116
- if (cause instanceof CompactionError)
117
- return new AgentHarnessError("compaction", cause.message, cause);
118
- if (cause instanceof BranchSummaryError)
119
- return new AgentHarnessError("branch_summary", cause.message, cause);
120
- return new AgentHarnessError(fallbackCode, cause.message, cause);
121
- }
122
- function normalizeHookError(error) {
123
- return normalizeHarnessError(error, "hook");
124
- }
15
+ import { applyStreamOptionsPatch, cloneStreamOptions, mergeHeaders } from "./stream-options.js";
16
+ import { SubscriberFanout } from "./subscriber-fanout.js";
17
+ import { createSummarizationRetry } from "./summarization-retry.js";
18
+ import { resolveNavigationTarget, runBranchSummary } from "./tree-navigation.js";
19
+ import { AgentHarnessError, toError } from "./types.js";
125
20
  export class AgentHarness {
126
21
  env;
127
22
  session;
128
- phase = "idle";
129
- runAbortController;
130
- runPromise;
131
- pendingSessionWrites = [];
23
+ sessionFacade;
24
+ lifecycle;
25
+ sessionWrites;
132
26
  model;
133
27
  thinkingLevel;
134
28
  systemPrompt;
135
29
  streamOptions;
30
+ compactionSettings;
136
31
  getApiKeyAndHeaders;
137
32
  resources;
138
33
  tools = new Map();
@@ -143,11 +38,19 @@ export class AgentHarness {
143
38
  followUpQueueMode;
144
39
  nextTurnQueue = [];
145
40
  handlers = new Map();
41
+ subscribers = new SubscriberFanout();
146
42
  constructor(options) {
147
43
  this.env = options.env;
148
44
  this.session = options.session;
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);
149
51
  this.resources = options.resources ?? {};
150
52
  this.streamOptions = cloneStreamOptions(options.streamOptions);
53
+ this.compactionSettings = { ...DEFAULT_COMPACTION_SETTINGS, ...options.compaction };
151
54
  this.systemPrompt = options.systemPrompt;
152
55
  this.getApiKeyAndHeaders = options.getApiKeyAndHeaders;
153
56
  this.validateUniqueNames((options.tools ?? []).map((tool) => tool.name), "Duplicate tool name(s)");
@@ -164,31 +67,19 @@ export class AgentHarness {
164
67
  this.steeringQueueMode = options.steeringMode ?? "one-at-a-time";
165
68
  this.followUpQueueMode = options.followUpMode ?? "one-at-a-time";
166
69
  }
167
- getHandlers(type) {
168
- return this.handlers.get(type);
169
- }
170
70
  async emitOwn(event, signal) {
171
- for (const listener of this.getHandlers(SUBSCRIBER_EVENT_TYPE) ?? []) {
172
- try {
173
- await listener(event, signal);
174
- }
175
- catch (error) {
176
- throw normalizeHookError(error);
177
- }
178
- }
71
+ await this.emitAny(event, signal);
179
72
  }
73
+ /** Subscriber fan-out; the self-wait barrier lives in `SubscriberFanout`. */
180
74
  async emitAny(event, signal) {
181
- for (const listener of this.getHandlers(SUBSCRIBER_EVENT_TYPE) ?? []) {
182
- try {
183
- await listener(event, signal);
184
- }
185
- catch (error) {
186
- throw normalizeHookError(error);
187
- }
188
- }
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);
189
80
  }
190
81
  async emitHook(event) {
191
- const handlers = this.getHandlers(event.type);
82
+ const handlers = this.handlers.get(event.type);
192
83
  if (!handlers || handlers.size === 0)
193
84
  return undefined;
194
85
  let lastResult;
@@ -200,13 +91,13 @@ export class AgentHarness {
200
91
  }
201
92
  }
202
93
  catch (error) {
203
- throw normalizeHookError(error);
94
+ throw normalizeHarnessError(error, "hook");
204
95
  }
205
96
  }
206
97
  return lastResult;
207
98
  }
208
99
  async emitBeforeProviderRequest(model, sessionId, streamOptions) {
209
- const handlers = this.getHandlers("before_provider_request");
100
+ const handlers = this.handlers.get("before_provider_request");
210
101
  let current = cloneStreamOptions(streamOptions);
211
102
  if (!handlers || handlers.size === 0)
212
103
  return current;
@@ -223,13 +114,13 @@ export class AgentHarness {
223
114
  }
224
115
  }
225
116
  catch (error) {
226
- throw normalizeHookError(error);
117
+ throw normalizeHarnessError(error, "hook");
227
118
  }
228
119
  }
229
120
  return current;
230
121
  }
231
122
  async emitBeforeProviderPayload(model, payload) {
232
- const handlers = this.getHandlers("before_provider_payload");
123
+ const handlers = this.handlers.get("before_provider_payload");
233
124
  let current = payload;
234
125
  if (!handlers || handlers.size === 0)
235
126
  return current;
@@ -241,7 +132,7 @@ export class AgentHarness {
241
132
  }
242
133
  }
243
134
  catch (error) {
244
- throw normalizeHookError(error);
135
+ throw normalizeHarnessError(error, "hook");
245
136
  }
246
137
  }
247
138
  return current;
@@ -254,23 +145,88 @@ export class AgentHarness {
254
145
  nextTurn: [...this.nextTurnQueue],
255
146
  });
256
147
  }
257
- startRunPromise() {
258
- let finish = () => { };
259
- const runPromise = new Promise((resolve) => {
260
- 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,
261
210
  });
262
- this.runPromise = runPromise;
263
- return {
264
- runPromise,
265
- finishRunPromise: () => {
266
- // A settled/agent_end listener may have started a new run while this
267
- // one was still unwinding; only clear state still owned by this run.
268
- if (this.runPromise === runPromise) {
269
- this.runPromise = undefined;
270
- }
271
- finish();
272
- },
273
- };
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;
274
230
  }
275
231
  async createTurnState() {
276
232
  const context = await this.session.buildContext();
@@ -316,13 +272,14 @@ export class AgentHarness {
316
272
  createStreamFn(getTurnState) {
317
273
  return async (model, context, streamOptions) => {
318
274
  const turnState = getTurnState();
275
+ const requestContext = await this.maybeAutoCompact(model, context, streamOptions?.signal);
319
276
  const auth = await this.getApiKeyAndHeaders?.(model);
320
277
  const snapshotOptions = {
321
278
  ...turnState.streamOptions,
322
279
  headers: mergeHeaders(turnState.streamOptions.headers, auth?.headers),
323
280
  };
324
281
  const requestOptions = await this.emitBeforeProviderRequest(model, turnState.sessionId, snapshotOptions);
325
- return streamSimple(model, context, {
282
+ return streamSimple(model, requestContext, {
326
283
  cacheRetention: requestOptions.cacheRetention,
327
284
  headers: requestOptions.headers,
328
285
  maxRetries: requestOptions.maxRetries,
@@ -352,10 +309,10 @@ export class AgentHarness {
352
309
  }
353
310
  catch (error) {
354
311
  queue.unshift(...messages);
355
- throw normalizeHookError(error);
312
+ throw normalizeHarnessError(error, "hook");
356
313
  }
357
314
  }
358
- createLoopConfig(getTurnState, setTurnState) {
315
+ createLoopConfig(getTurnState, setTurnState, lease) {
359
316
  const turnState = getTurnState();
360
317
  return {
361
318
  model: turnState.model,
@@ -389,7 +346,13 @@ export class AgentHarness {
389
346
  : undefined;
390
347
  },
391
348
  prepareNextTurn: async () => {
392
- 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
+ }
393
356
  const nextTurnState = await this.createTurnState();
394
357
  setTurnState(nextTurnState);
395
358
  return {
@@ -413,40 +376,7 @@ export class AgentHarness {
413
376
  if (missing.length > 0)
414
377
  throw new AgentHarnessError("invalid_argument", `Unknown tool(s): ${missing.join(", ")}`);
415
378
  }
416
- async flushPendingSessionWrites() {
417
- while (this.pendingSessionWrites.length > 0) {
418
- const write = this.pendingSessionWrites[0];
419
- if (write.type === "message") {
420
- await this.session.appendMessage(write.message);
421
- }
422
- else if (write.type === "model_change") {
423
- await this.session.appendModelChange(write.provider, write.modelId);
424
- }
425
- else if (write.type === "thinking_level_change") {
426
- await this.session.appendThinkingLevelChange(write.thinkingLevel);
427
- }
428
- else if (write.type === "active_tools_change") {
429
- await this.session.appendActiveToolsChange(write.activeToolNames);
430
- }
431
- else if (write.type === "custom") {
432
- await this.session.appendCustomEntry(write.customType, write.data);
433
- }
434
- else if (write.type === "custom_message") {
435
- await this.session.appendCustomMessageEntry(write.customType, write.content, write.display, write.details);
436
- }
437
- else if (write.type === "label") {
438
- await this.session.appendLabel(write.targetId, write.label);
439
- }
440
- else if (write.type === "session_info") {
441
- await this.session.appendSessionName(write.name ?? "");
442
- }
443
- else if (write.type === "leaf") {
444
- await this.session.getStorage().setLeafId(write.targetId);
445
- }
446
- this.pendingSessionWrites.shift();
447
- }
448
- }
449
- async handleAgentEvent(event, signal) {
379
+ async handleAgentEvent(event, signal, lease) {
450
380
  if (event.type === "message_end") {
451
381
  await this.session.appendMessage(event.message);
452
382
  await this.emitAny(event, signal);
@@ -460,39 +390,48 @@ export class AgentHarness {
460
390
  catch (error) {
461
391
  eventError = error;
462
392
  }
463
- const hadPendingMutations = this.pendingSessionWrites.length > 0;
464
- await this.flushPendingSessionWrites();
465
- if (eventError)
466
- 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;
467
413
  await this.emitOwn({ type: "save_point", hadPendingMutations });
468
414
  return;
469
415
  }
470
416
  if (event.type === "agent_end") {
471
- // Finish this run's cleanup before flipping to "idle": listeners observing
472
- // agent_end/settled may start the next prompt() immediately, and that run
473
- // must not inherit state still owned by the run that just completed.
474
- await this.flushPendingSessionWrites();
475
- if (this.runAbortController && this.runAbortController.signal === signal) {
476
- this.runAbortController = undefined;
477
- }
478
- 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();
479
420
  await this.emitAny(event, signal);
480
- await this.emitOwn({ type: "settled", nextTurnCount: this.nextTurnQueue.length }, signal);
481
421
  return;
482
422
  }
483
423
  await this.emitAny(event, signal);
484
424
  }
485
- async emitRunFailure(model, error, aborted, signal, completedMessages) {
425
+ async emitRunFailure(model, error, aborted, signal, completedMessages, lease) {
486
426
  const failureMessage = createFailureMessage(model, error, aborted);
487
427
  const messages = [...completedMessages, failureMessage];
488
- await this.handleAgentEvent({ type: "message_start", message: failureMessage }, signal);
489
- await this.handleAgentEvent({ type: "message_end", message: failureMessage }, signal);
490
- await this.handleAgentEvent({ type: "turn_end", message: failureMessage, toolResults: [] }, signal);
491
- 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);
492
432
  return messages;
493
433
  }
494
- async executeTurn(turnState, text, options) {
495
- let activeTurnState = turnState;
434
+ async executeTurn(lease, turnState, text, options) {
496
435
  let messages = [createUserMessage(text, options?.images)];
497
436
  if (this.nextTurnQueue.length > 0) {
498
437
  const queuedMessages = this.nextTurnQueue.splice(0);
@@ -501,7 +440,7 @@ export class AgentHarness {
501
440
  }
502
441
  catch (error) {
503
442
  this.nextTurnQueue.unshift(...queuedMessages);
504
- throw normalizeHookError(error);
443
+ throw normalizeHarnessError(error, "hook");
505
444
  }
506
445
  messages = [...queuedMessages, messages[0]];
507
446
  }
@@ -514,125 +453,173 @@ export class AgentHarness {
514
453
  });
515
454
  if (beforeResult?.messages)
516
455
  messages = [...messages, ...beforeResult.messages];
517
- const abortController = new AbortController();
518
- const getTurnState = () => activeTurnState;
519
- const setTurnState = (nextTurnState) => {
520
- activeTurnState = nextTurnState;
521
- };
522
- this.runAbortController = abortController;
523
- const completedMessages = [];
524
- const runResultPromise = (async () => {
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;
501
+ }
502
+ async executeAgentRun(lease, reason, turnState, context, initialMessages) {
503
+ let activeTurnState = turnState;
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;
525
517
  try {
526
- return await runAgentLoop(messages, this.createContext(turnState, beforeResult?.systemPrompt), this.createLoopConfig(getTurnState, setTurnState), async (event) => {
527
- if (event.type === "message_end")
528
- completedMessages.push(event.message);
529
- await this.handleAgentEvent(event, abortController.signal);
530
- }, abortController.signal, this.createStreamFn(getTurnState));
518
+ const loopConfig = this.createLoopConfig(getTurnState, setTurnState, lease);
519
+ const streamFn = this.createStreamFn(getTurnState);
520
+ newMessages = initialMessages
521
+ ? await runAgentLoop(initialMessages, context, loopConfig, emit, signal, streamFn)
522
+ : await runAgentLoopContinue(context, loopConfig, emit, signal, streamFn);
531
523
  }
532
524
  catch (error) {
533
525
  try {
534
- 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);
535
527
  }
536
528
  catch (failureError) {
537
529
  const cause = new AggregateError([toError(error), toError(failureError)], "Agent run failed and failure reporting failed");
538
530
  throw new AgentHarnessError("unknown", cause.message, cause);
539
531
  }
540
532
  }
541
- })();
542
- try {
543
- const newMessages = await runResultPromise;
544
533
  for (let i = newMessages.length - 1; i >= 0; i--) {
545
534
  const message = newMessages[i];
546
- if (message.role === "assistant") {
535
+ if (message.role === "assistant")
547
536
  return message;
548
- }
549
537
  }
550
538
  throw new AgentHarnessError("invalid_state", "AgentHarness prompt completed without an assistant message");
551
- }
552
- finally {
553
- try {
554
- await this.flushPendingSessionWrites();
555
- }
556
- finally {
557
- // Ownership check: a re-entrant run started from a settled/agent_end
558
- // listener may have installed its own controller already.
559
- if (this.runAbortController === abortController) {
560
- this.runAbortController = undefined;
561
- }
562
- }
563
- }
564
- }
565
- async prompt(text, options) {
566
- if (this.phase !== "idle")
567
- throw new AgentHarnessError("busy", "AgentHarness is busy");
568
- this.phase = "turn";
569
- const { runPromise, finishRunPromise } = this.startRunPromise();
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)) {
549
+ return message;
550
+ }
551
+ if (!this.getApiKeyAndHeaders)
552
+ return message;
553
+ const leafId = await this.session.getLeafId();
554
+ if (!leafId)
555
+ return message;
556
+ const leaf = await this.session.getEntry(leafId);
557
+ if (leaf?.type !== "message" ||
558
+ leaf.message.role !== "assistant" ||
559
+ leaf.message.timestamp !== message.timestamp ||
560
+ !isContextOverflow(leaf.message, this.model.contextWindow)) {
561
+ return message;
562
+ }
563
+ await this.session.moveTo(leaf.parentId);
564
+ this.lifecycle.setStage(lease, "recovering_overflow");
570
565
  try {
566
+ const compacted = await this.runCompaction({ automatic: true, signal: lease.signal });
567
+ if (!compacted) {
568
+ await this.session.moveTo(leafId);
569
+ return message;
570
+ }
571
571
  const turnState = await this.createTurnState();
572
- return await this.executeTurn(turnState, text, options);
572
+ return await this.executeAgentRun(lease, "context_overflow_recovery", turnState, this.createContext(turnState));
573
573
  }
574
574
  catch (error) {
575
- // Only reset the phase if this call still owns the run; a listener may
576
- // have started the next run while this one was unwinding.
577
- if (this.runPromise === runPromise)
578
- this.phase = "idle";
579
- throw normalizeHarnessError(error, "unknown");
580
- }
581
- finally {
582
- finishRunPromise();
575
+ await this.session.moveTo(leafId);
576
+ throw error;
583
577
  }
584
578
  }
579
+ async prompt(text, options) {
580
+ return this.runOperation("prompt", "unknown", async (lease) => {
581
+ const turnState = await this.createTurnState();
582
+ return await this.executeTurn(lease, turnState, text, options);
583
+ }, classifyAssistantOutcome);
584
+ }
585
585
  async skill(name, additionalInstructions) {
586
- if (this.phase !== "idle")
587
- throw new AgentHarnessError("busy", "AgentHarness is busy");
588
- this.phase = "turn";
589
- const { runPromise, finishRunPromise } = this.startRunPromise();
590
- try {
586
+ return this.runOperation("skill", "unknown", async (lease) => {
591
587
  const turnState = await this.createTurnState();
592
588
  const skill = (turnState.resources.skills ?? []).find((candidate) => candidate.name === name);
593
589
  if (!skill)
594
590
  throw new AgentHarnessError("invalid_argument", `Unknown skill: ${name}`);
595
- return await this.executeTurn(turnState, formatSkillInvocation(skill, additionalInstructions));
596
- }
597
- catch (error) {
598
- if (this.runPromise === runPromise)
599
- this.phase = "idle";
600
- throw normalizeHarnessError(error, "unknown");
601
- }
602
- finally {
603
- finishRunPromise();
604
- }
591
+ return await this.executeTurn(lease, turnState, formatSkillInvocation(skill, additionalInstructions));
592
+ }, classifyAssistantOutcome);
605
593
  }
606
594
  async promptFromTemplate(name, args = []) {
607
- if (this.phase !== "idle")
608
- throw new AgentHarnessError("busy", "AgentHarness is busy");
609
- this.phase = "turn";
610
- const { runPromise, finishRunPromise } = this.startRunPromise();
611
- try {
595
+ return this.runOperation("prompt_template", "unknown", async (lease) => {
612
596
  const turnState = await this.createTurnState();
613
597
  const template = (turnState.resources.promptTemplates ?? []).find((candidate) => candidate.name === name);
614
598
  if (!template)
615
599
  throw new AgentHarnessError("invalid_argument", `Unknown prompt template: ${name}`);
616
- return await this.executeTurn(turnState, formatPromptTemplateInvocation(template, args));
617
- }
618
- catch (error) {
619
- if (this.runPromise === runPromise)
620
- this.phase = "idle";
621
- throw normalizeHarnessError(error, "unknown");
622
- }
623
- finally {
624
- 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`);
625
614
  }
626
615
  }
627
616
  async steer(text, options) {
628
- if (this.phase === "idle")
629
- throw new AgentHarnessError("invalid_state", "Cannot steer while idle");
617
+ this.expectQueueConsumer("steer");
630
618
  this.steerQueue.push(createUserMessage(text, options?.images));
631
619
  await this.emitQueueUpdate();
632
620
  }
633
621
  async followUp(text, options) {
634
- if (this.phase === "idle")
635
- throw new AgentHarnessError("invalid_state", "Cannot follow up while idle");
622
+ this.expectQueueConsumer("follow up");
636
623
  this.followUpQueue.push(createUserMessage(text, options?.images));
637
624
  await this.emitQueueUpdate();
638
625
  }
@@ -640,73 +627,89 @@ export class AgentHarness {
640
627
  this.nextTurnQueue.push(createUserMessage(text, options?.images));
641
628
  await this.emitQueueUpdate();
642
629
  }
630
+ getSession() {
631
+ return this.sessionFacade;
632
+ }
643
633
  async appendMessage(message) {
644
- try {
645
- if (this.phase === "idle") {
646
- await this.session.appendMessage(message);
647
- }
648
- else {
649
- this.pendingSessionWrites.push({ type: "message", message });
650
- }
651
- }
652
- catch (error) {
653
- throw normalizeHarnessError(error, "session");
634
+ await this.sessionFacade.appendMessage(message);
635
+ }
636
+ async runCompaction(options) {
637
+ const model = this.model;
638
+ const auth = await this.getApiKeyAndHeaders?.(model);
639
+ if (!auth) {
640
+ if (options.automatic)
641
+ return undefined;
642
+ throw new AgentHarnessError("auth", "No auth available for compaction");
643
+ }
644
+ const branchEntries = await this.session.getBranch();
645
+ const preparationResult = prepareCompaction(branchEntries, this.compactionSettings);
646
+ if (!preparationResult.ok)
647
+ throw preparationResult.error;
648
+ const preparation = preparationResult.value;
649
+ if (!preparation) {
650
+ if (options.automatic)
651
+ return undefined;
652
+ throw new AgentHarnessError("compaction", "Nothing to compact");
653
+ }
654
+ const signal = options.signal ?? new AbortController().signal;
655
+ const hookResult = await this.emitHook({
656
+ type: "session_before_compact",
657
+ preparation,
658
+ branchEntries,
659
+ customInstructions: options.customInstructions,
660
+ signal,
661
+ });
662
+ if (hookResult?.cancel) {
663
+ if (options.automatic)
664
+ return undefined;
665
+ throw new AgentHarnessError("compaction", "Compaction cancelled");
666
+ }
667
+ const provided = hookResult?.compaction;
668
+ const compactResult = provided
669
+ ? { ok: true, value: provided }
670
+ : await compact(preparation, model, auth.apiKey, auth.headers, options.customInstructions, signal, this.thinkingLevel, createSummarizationRetry("compaction", this.streamOptions.summarizationRetry, (event) => this.emitOwn(event)));
671
+ if (!compactResult.ok)
672
+ throw compactResult.error;
673
+ const result = compactResult.value;
674
+ options.beforeCommit?.();
675
+ const entryId = await this.session.appendCompaction(result.summary, result.firstKeptEntryId, result.tokensBefore, result.details, provided !== undefined);
676
+ const entry = await this.session.getEntry(entryId);
677
+ if (entry?.type === "compaction") {
678
+ await this.emitOwn({ type: "session_compact", compactionEntry: entry, fromHook: provided !== undefined });
654
679
  }
680
+ return result;
681
+ }
682
+ async maybeAutoCompact(model, context, signal) {
683
+ const projected = estimateContextTokens(context.messages).tokens;
684
+ if (!shouldCompact(projected, model.contextWindow ?? 0, this.compactionSettings))
685
+ return context;
686
+ const result = await this.runCompaction({ automatic: true, signal });
687
+ if (!result)
688
+ return context;
689
+ const persisted = await this.session.buildContext();
690
+ return { ...context, messages: convertToLlm(persisted.messages) };
655
691
  }
656
692
  async compact(customInstructions) {
657
- if (this.phase !== "idle")
658
- throw new AgentHarnessError("busy", "compact() requires idle harness");
659
- this.phase = "compaction";
660
- try {
661
- const model = this.model;
662
- if (!model)
663
- throw new AgentHarnessError("invalid_state", "No model set for compaction");
664
- const auth = await this.getApiKeyAndHeaders?.(model);
665
- if (!auth)
666
- throw new AgentHarnessError("auth", "No auth available for compaction");
667
- const branchEntries = await this.session.getBranch();
668
- const preparationResult = prepareCompaction(branchEntries, DEFAULT_COMPACTION_SETTINGS);
669
- if (!preparationResult.ok)
670
- throw preparationResult.error;
671
- const preparation = preparationResult.value;
672
- if (!preparation)
673
- throw new AgentHarnessError("compaction", "Nothing to compact");
674
- const hookResult = await this.emitHook({
675
- type: "session_before_compact",
676
- preparation,
677
- branchEntries,
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,
678
697
  customInstructions,
679
- signal: new AbortController().signal,
698
+ beforeCommit: () => {
699
+ this.lifecycle.setStage(lease, "committing");
700
+ },
680
701
  });
681
- if (hookResult?.cancel)
682
- throw new AgentHarnessError("compaction", "Compaction cancelled");
683
- const provided = hookResult?.compaction;
684
- const compactResult = provided
685
- ? { ok: true, value: provided }
686
- : await compact(preparation, model, auth.apiKey, auth.headers, customInstructions, undefined, this.thinkingLevel);
687
- if (!compactResult.ok)
688
- throw compactResult.error;
689
- const result = compactResult.value;
690
- const entryId = await this.session.appendCompaction(result.summary, result.firstKeptEntryId, result.tokensBefore, result.details, provided !== undefined);
691
- const entry = await this.session.getEntry(entryId);
692
- if (entry?.type === "compaction") {
693
- await this.emitOwn({ type: "session_compact", compactionEntry: entry, fromHook: provided !== undefined });
694
- }
702
+ if (!result)
703
+ throw new AgentHarnessError("compaction", "Nothing to compact");
695
704
  return result;
696
- }
697
- catch (error) {
698
- throw normalizeHarnessError(error, "compaction");
699
- }
700
- finally {
701
- this.phase = "idle";
702
- }
705
+ });
703
706
  }
704
707
  async navigateTree(targetId, options) {
705
- if (this.phase !== "idle")
706
- throw new AgentHarnessError("busy", "navigateTree() requires idle harness");
707
- this.phase = "branch_summary";
708
- try {
708
+ return this.runOperation("tree_navigation", "branch_summary", async (lease) => {
709
+ this.lifecycle.setStage(lease, "structural_running");
709
710
  const oldLeafId = await this.session.getLeafId();
711
+ // No-op navigation mutates nothing, so it completes without ever
712
+ // entering the `committing` stage.
710
713
  if (oldLeafId === targetId)
711
714
  return { cancelled: false };
712
715
  const targetEntry = await this.session.getEntry(targetId);
@@ -737,51 +740,24 @@ export class AgentHarness {
737
740
  const auth = await this.getApiKeyAndHeaders?.(model);
738
741
  if (!auth)
739
742
  throw new AgentHarnessError("auth", "No auth available for branch summary");
740
- const branchSummary = await generateBranchSummary(entries, {
743
+ const branchSummary = await runBranchSummary({
744
+ entries,
741
745
  model,
742
746
  apiKey: auth.apiKey,
743
747
  headers: auth.headers,
744
- signal: new AbortController().signal,
745
748
  customInstructions: hookResult?.customInstructions ?? options?.customInstructions,
746
749
  replaceInstructions: hookResult?.replaceInstructions ?? options?.replaceInstructions,
750
+ summarizationRetry: this.streamOptions.summarizationRetry,
751
+ emit: (event) => this.emitOwn(event),
747
752
  });
748
- if (!branchSummary.ok) {
749
- if (branchSummary.error.code === "aborted")
750
- return { cancelled: true };
751
- throw new AgentHarnessError("branch_summary", branchSummary.error.message, branchSummary.error);
752
- }
753
- summaryText = branchSummary.value.summary;
754
- summaryDetails = {
755
- readFiles: branchSummary.value.readFiles,
756
- modifiedFiles: branchSummary.value.modifiedFiles,
757
- };
758
- }
759
- let editorText;
760
- let newLeafId;
761
- if (targetEntry.type === "message" && targetEntry.message.role === "user") {
762
- newLeafId = targetEntry.parentId;
763
- const content = targetEntry.message.content;
764
- editorText =
765
- typeof content === "string"
766
- ? content
767
- : content
768
- .filter((c) => c.type === "text")
769
- .map((c) => c.text)
770
- .join("");
771
- }
772
- else if (targetEntry.type === "custom_message") {
773
- newLeafId = targetEntry.parentId;
774
- editorText =
775
- typeof targetEntry.content === "string"
776
- ? targetEntry.content
777
- : targetEntry.content
778
- .filter((c) => c.type === "text")
779
- .map((c) => c.text)
780
- .join("");
781
- }
782
- else {
783
- newLeafId = targetId;
784
- }
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");
785
761
  const summaryId = await this.session.moveTo(newLeafId, summaryText
786
762
  ? { summary: summaryText, details: summaryDetails, fromHook: hookResult?.summary !== undefined }
787
763
  : undefined);
@@ -798,13 +774,7 @@ export class AgentHarness {
798
774
  fromHook: hookResult?.summary !== undefined,
799
775
  });
800
776
  return { cancelled: false, editorText, summaryEntry };
801
- }
802
- catch (error) {
803
- throw normalizeHarnessError(error, "branch_summary");
804
- }
805
- finally {
806
- this.phase = "idle";
807
- }
777
+ }, classifyNavigateTreeOutcome);
808
778
  }
809
779
  getModel() {
810
780
  return this.model;
@@ -812,12 +782,9 @@ export class AgentHarness {
812
782
  async setModel(model) {
813
783
  try {
814
784
  const previousModel = this.model;
815
- if (this.phase === "idle") {
816
- await this.session.appendModelChange(model.provider, model.id);
817
- }
818
- else {
819
- this.pendingSessionWrites.push({ type: "model_change", provider: model.provider, modelId: model.id });
820
- }
785
+ const nextProvider = model.provider;
786
+ const nextModelId = model.id;
787
+ await this.persistConfigChange({ type: "model_change", provider: nextProvider, modelId: nextModelId });
821
788
  this.model = model;
822
789
  await this.emitOwn({ type: "model_update", model, previousModel, source: "set" });
823
790
  }
@@ -831,12 +798,7 @@ export class AgentHarness {
831
798
  async setThinkingLevel(level) {
832
799
  try {
833
800
  const previousLevel = this.thinkingLevel;
834
- if (this.phase === "idle") {
835
- await this.session.appendThinkingLevelChange(level);
836
- }
837
- else {
838
- this.pendingSessionWrites.push({ type: "thinking_level_change", thinkingLevel: level });
839
- }
801
+ await this.persistConfigChange({ type: "thinking_level_change", thinkingLevel: level });
840
802
  this.thinkingLevel = level;
841
803
  await this.emitOwn({ type: "thinking_level_update", level, previousLevel });
842
804
  }
@@ -855,12 +817,7 @@ export class AgentHarness {
855
817
  this.validateToolNames(nextActiveToolNames, nextTools);
856
818
  const previousToolNames = [...this.tools.keys()];
857
819
  const previousActiveToolNames = [...this.activeToolNames];
858
- if (this.phase === "idle") {
859
- await this.session.appendActiveToolsChange(nextActiveToolNames);
860
- }
861
- else {
862
- this.pendingSessionWrites.push({ type: "active_tools_change", activeToolNames: [...nextActiveToolNames] });
863
- }
820
+ await this.persistConfigChange({ type: "active_tools_change", activeToolNames: [...nextActiveToolNames] });
864
821
  this.tools = nextTools;
865
822
  this.activeToolNames = [...nextActiveToolNames];
866
823
  await this.emitOwn({
@@ -881,16 +838,12 @@ export class AgentHarness {
881
838
  }
882
839
  async setActiveTools(toolNames) {
883
840
  try {
884
- this.validateToolNames(toolNames);
841
+ const nextActiveToolNames = [...toolNames];
842
+ this.validateToolNames(nextActiveToolNames);
885
843
  const previousToolNames = [...this.tools.keys()];
886
844
  const previousActiveToolNames = [...this.activeToolNames];
887
- if (this.phase === "idle") {
888
- await this.session.appendActiveToolsChange(toolNames);
889
- }
890
- else {
891
- this.pendingSessionWrites.push({ type: "active_tools_change", activeToolNames: [...toolNames] });
892
- }
893
- this.activeToolNames = [...toolNames];
845
+ await this.persistConfigChange({ type: "active_tools_change", activeToolNames: [...nextActiveToolNames] });
846
+ this.activeToolNames = [...nextActiveToolNames];
894
847
  await this.emitOwn({
895
848
  type: "tools_update",
896
849
  toolNames: [...this.tools.keys()],
@@ -937,11 +890,21 @@ export class AgentHarness {
937
890
  this.streamOptions = cloneStreamOptions(streamOptions);
938
891
  }
939
892
  async abort() {
940
- const clearedSteer = [...this.steerQueue];
941
- const clearedFollowUp = [...this.followUpQueue];
942
- this.steerQueue = [];
943
- this.followUpQueue = [];
944
- this.runAbortController?.abort();
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();
906
+ const clearedSteer = this.steerQueue.splice(0);
907
+ const clearedFollowUp = this.followUpQueue.splice(0);
945
908
  const errors = [];
946
909
  try {
947
910
  await this.emitQueueUpdate();
@@ -950,7 +913,8 @@ export class AgentHarness {
950
913
  errors.push(toError(error));
951
914
  }
952
915
  try {
953
- await this.waitForIdle();
916
+ if (capture.target)
917
+ await capture.target.settled;
954
918
  }
955
919
  catch (error) {
956
920
  errors.push(toError(error));
@@ -968,20 +932,12 @@ export class AgentHarness {
968
932
  return { clearedSteer, clearedFollowUp };
969
933
  }
970
934
  async waitForIdle() {
971
- // Runs can chain: a settled listener may start the next prompt() before the
972
- // previous run's promise resolves, so keep waiting until no run is active.
973
- while (this.runPromise) {
974
- await this.runPromise;
975
- }
935
+ this.rejectCurrentOperationSelfWait("waitForIdle()");
936
+ // Delegates to the lifecycle: resolves once no operation is active or settling.
937
+ await this.lifecycle.waitForIdle();
976
938
  }
977
939
  subscribe(listener) {
978
- let handlers = this.handlers.get(SUBSCRIBER_EVENT_TYPE);
979
- if (!handlers) {
980
- handlers = new Set();
981
- this.handlers.set(SUBSCRIBER_EVENT_TYPE, handlers);
982
- }
983
- handlers.add(listener);
984
- return () => handlers.delete(listener);
940
+ return this.subscribers.subscribe(listener);
985
941
  }
986
942
  on(type, handler) {
987
943
  let handlers = this.handlers.get(type);