@genesislcap/ai-assistant 15.24.0 → 15.24.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.
@@ -6221,7 +6221,7 @@
6221
6221
  {
6222
6222
  "kind": "PropertySignature",
6223
6223
  "canonicalReference": "@genesislcap/ai-assistant!ChatDriverConfig#budgetExhaustedMessage:member",
6224
- "docComment": "/**\n * Transcript copy appended when the AI-spend budget wall is hit (GENC-1464), used VERBATIM — set it only to own the copy entirely (white-labelling). When unset, the driver composes the bubble per wall: the neutral `DEFAULT_BUDGET_EXHAUSTED_MESSAGE`, upgraded with \"switch to another AI provider\" advice only when the 402's `otherVendorAvailable` positively says another metered vendor still has headroom — the same rule the blocked banner applies, so the two surfaces can no longer contradict each other.\n *\n * The assistant element already lets a host override the blocked **banner** via `setBlocked(true, reason)`; without this the transcript **bubble** stayed on the default, so a white-labelled host got its own copy in the banner and the shipped default directly below it. Passing the same effective copy here keeps the two surfaces saying one thing.\n *\n * Deliberately a driver-config field rather than something read off a chat config: `ChatDriver` has no `chatConfig` and is used standalone (see `chat-driver-node`), so threading one in would be a much larger and less reversible change.\n */\n",
6224
+ "docComment": "/**\n * Transcript copy appended when the AI-spend budget wall is hit (GENC-1464), used VERBATIM — set it only to own the copy entirely (white-labelling). When unset, the driver composes the bubble per wall: the neutral `TURN_FAILURE_EXITS.budgetExhausted.copy`, upgraded with \"switch to another AI provider\" advice only when the 402's `otherVendorAvailable` positively says another metered vendor still has headroom — the same rule the blocked banner applies, so the two surfaces can no longer contradict each other.\n *\n * The assistant element already lets a host override the blocked **banner** via `setBlocked(true, reason)`; without this the transcript **bubble** stayed on the default, so a white-labelled host got its own copy in the banner and the shipped default directly below it. Passing the same effective copy here keeps the two surfaces saying one thing.\n *\n * Deliberately a driver-config field rather than something read off a chat config: `ChatDriver` has no `chatConfig` and is used standalone (see `chat-driver-node`), so threading one in would be a much larger and less reversible change.\n */\n",
6225
6225
  "excerptTokens": [
6226
6226
  {
6227
6227
  "kind": "Content",
@@ -6388,7 +6388,7 @@
6388
6388
  {
6389
6389
  "kind": "PropertySignature",
6390
6390
  "canonicalReference": "@genesislcap/ai-assistant!ChatDriverConfig#providerRefusedMessage:member",
6391
- "docComment": "/**\n * Overrides the transcript sentence shown when the upstream PROVIDER refuses the account (GENC-1506) — see `DEFAULT_PROVIDER_REFUSED_MESSAGE`.\n *\n * Separate from `budgetExhaustedMessage` because the two conditions are separate: ours is a spend cap a host administrator can raise, this is the vendor declining to serve us at all. A host that white-labels one will usually want to white-label both, but conflating them into one field would force identical copy on two situations with different remedies.\n *\n * Rarely needed: unlike the budget default, the shipped sentence names no vendor and no Genesis, so it is already safe for a white-labelled deployment.\n */\n",
6391
+ "docComment": "/**\n * Overrides the transcript sentence shown when the upstream PROVIDER refuses the account (GENC-1506) — see `TURN_FAILURE_EXITS.providerRefused.copy`.\n *\n * Separate from `budgetExhaustedMessage` because the two conditions are separate: ours is a spend cap a host administrator can raise, this is the vendor declining to serve us at all. A host that white-labels one will usually want to white-label both, but conflating them into one field would force identical copy on two situations with different remedies.\n *\n * Rarely needed: unlike the budget default, the shipped sentence names no vendor and no Genesis, so it is already safe for a white-labelled deployment.\n */\n",
6392
6392
  "excerptTokens": [
6393
6393
  {
6394
6394
  "kind": "Content",
@@ -1719,6 +1719,26 @@ export declare class ChatDriver extends EventTarget implements AiDriver {
1719
1719
  * the historical `{ reason: 'done' }`.
1720
1720
  */
1721
1721
  private turnDone;
1722
+ /**
1723
+ * End the turn on a terminal failure: fail upward when this driver is a sub-agent, speak
1724
+ * to the user when it is not, and hand the reason to the caller either way.
1725
+ *
1726
+ * Only that fork lives here. Each exit keeps its own `recordTurnError` call, because the
1727
+ * telemetry payload is what legitimately differs between them — attempt counts, token
1728
+ * figures, tool names — and folding it in would mean one optional field per condition.
1729
+ *
1730
+ * `overrides.copy` serves the two walls whose prose is composed per occurrence:
1731
+ * `budgetExhaustedBubble` names the vendor and can offer another, `providerRefusedBubble`
1732
+ * prefers a host-supplied message. Every other exit reads its copy from the table.
1733
+ *
1734
+ * One exit stays out: the `BudgetExhaustedError` catch. Not because its shape differs —
1735
+ * its two telemetry payloads are field-for-field identical — but because its main-turn
1736
+ * branch calls `reportBudgetExhausted`, which is public and also serves
1737
+ * `OrchestratingDriver`'s classification phase, where no tool loop is running. That
1738
+ * method has to survive either way, so routing the catch through here would relocate the
1739
+ * duplication rather than remove it.
1740
+ */
1741
+ private endTurnWithFailure;
1722
1742
  /**
1723
1743
  * Terminal budget outcome for a wall hit **outside** the tool loop — today,
1724
1744
  * `OrchestratingDriver`'s classification phase, which calls the provider
@@ -1756,15 +1776,6 @@ export declare class ChatDriver extends EventTarget implements AiDriver {
1756
1776
  * has gone unpaid — so there is nothing here to compose. A host override wins verbatim.
1757
1777
  */
1758
1778
  private providerRefusedBubble;
1759
- /**
1760
- * Terminal provider-refusal outcome. Mirrors `reportBudgetExhausted` so the two walls behave
1761
- * identically from the caller's side, while keeping their diagnostics distinct.
1762
- *
1763
- * `kind` reaches the debug log and the result but never the transcript: it is what lets an operator
1764
- * tell "top up the account" from "rotate the key", and with cause-free user copy this is the only
1765
- * place that distinction survives.
1766
- */
1767
- private reportProviderRefused;
1768
1779
  /** The typed failure reason on a loop result, or `undefined` for a clean turn / handoff. */
1769
1780
  private static failureReasonOf;
1770
1781
  /**
@@ -2209,7 +2220,7 @@ export declare interface ChatDriverConfig {
2209
2220
  * Transcript copy appended when the AI-spend budget wall is hit (GENC-1464),
2210
2221
  * used VERBATIM — set it only to own the copy entirely (white-labelling).
2211
2222
  * When unset, the driver composes the bubble per wall: the neutral
2212
- * `DEFAULT_BUDGET_EXHAUSTED_MESSAGE`, upgraded with "switch to another AI
2223
+ * `TURN_FAILURE_EXITS.budgetExhausted.copy`, upgraded with "switch to another AI
2213
2224
  * provider" advice only when the 402's `otherVendorAvailable` positively says
2214
2225
  * another metered vendor still has headroom — the same rule the blocked
2215
2226
  * banner applies, so the two surfaces can no longer contradict each other.
@@ -2228,7 +2239,7 @@ export declare interface ChatDriverConfig {
2228
2239
  budgetExhaustedMessage?: string;
2229
2240
  /**
2230
2241
  * Overrides the transcript sentence shown when the upstream PROVIDER refuses the account
2231
- * (GENC-1506) — see `DEFAULT_PROVIDER_REFUSED_MESSAGE`.
2242
+ * (GENC-1506) — see `TURN_FAILURE_EXITS.providerRefused.copy`.
2232
2243
  *
2233
2244
  * Separate from `budgetExhaustedMessage` because the two conditions are separate: ours is a spend
2234
2245
  * cap a host administrator can raise, this is the vendor declining to serve us at all. A host that
@@ -4400,6 +4400,78 @@ function accumulate(messages, into) {
4400
4400
  // src/utils/tool-fold.ts
4401
4401
  var TOOL_FOLD_SYMBOL = Symbol("toolFold");
4402
4402
 
4403
+ // src/components/chat-driver/turn-failures.ts
4404
+ var TURN_FAILURE_EXITS = {
4405
+ contextGuard: {
4406
+ subReason: "context_exhausted",
4407
+ resultReason: "context-exhausted",
4408
+ copy: "I had to stop here \u2014 this conversation has filled the available context. Compact it to free up room, then ask me to continue."
4409
+ },
4410
+ contextOverflow: {
4411
+ subReason: "context_exhausted",
4412
+ resultReason: "context-exhausted",
4413
+ copy: DEFAULT_CONTEXT_OVERFLOW_MESSAGE
4414
+ },
4415
+ malformedToolCall: {
4416
+ subReason: "malformed_tool_call",
4417
+ // Not `malformed-tool-call`: the main-turn reason is named after the provider finish
4418
+ // reason (`MALFORMED_FUNCTION_CALL`) that produces it, and is part of the published
4419
+ // TurnFailureReason union, so the two names cannot be brought into line here.
4420
+ resultReason: "malformed-function-call",
4421
+ copy: "While working on your request, I repeatedly called my tools incorrectly. This often works on a second try \u2014 would you like me to try again? If it happens again, try breaking your request into smaller steps."
4422
+ },
4423
+ responseTruncated: {
4424
+ subReason: "response_truncated",
4425
+ resultReason: "response-truncated",
4426
+ copy: "My response was cut off because a single step reached the model output limit. This usually means one step tried to produce too much at once \u2014 try breaking your request into smaller steps."
4427
+ },
4428
+ timeout: {
4429
+ subReason: "timeout",
4430
+ // There is no `timeout` member of TurnFailureReason, so the main turn reports the
4431
+ // generic `exception`. A sub-agent gets the specific reason because its parent
4432
+ // branches on it to decide whether a retry is worth attempting.
4433
+ resultReason: "exception",
4434
+ copy: "The request timed out. You can ask me to try again, or break this into a smaller step."
4435
+ },
4436
+ unknownToolLimit: {
4437
+ subReason: "unknown_tool_limit",
4438
+ resultReason: "unknown-tool-limit",
4439
+ copy: "I'm sorry, I repeatedly tried to use tools that aren't available to me, so I couldn't complete that. If a 'Download agent log' option appears in the Settings (cog) menu, you can download the log and share it with whoever set up this assistant to help fix the issue."
4440
+ },
4441
+ maxIterations: {
4442
+ subReason: "max_iterations",
4443
+ resultReason: "max-iterations",
4444
+ copy: "I've reached my limit for this response. You can ask me to continue and I'll pick up where I left off."
4445
+ },
4446
+ refusal: {
4447
+ subReason: "refusal",
4448
+ resultReason: "refusal",
4449
+ copy: "This request was declined. Try rephrasing it, or breaking it into smaller steps."
4450
+ },
4451
+ emptyResponse: {
4452
+ subReason: "empty_response",
4453
+ resultReason: "empty-response",
4454
+ copy: "While working on your request, I repeatedly generated a blank response. This often works on a second try \u2014 would you like me to try again? If it happens again, try breaking your request into smaller steps."
4455
+ },
4456
+ providerRefused: {
4457
+ subReason: "provider_refused",
4458
+ resultReason: "provider-refused",
4459
+ // Overridden per wall by `ChatDriver.providerRefusedBubble`, which prefers a
4460
+ // host-supplied message; this is the fallback that method returns.
4461
+ copy: DEFAULT_PROVIDER_REFUSED_MESSAGE
4462
+ },
4463
+ budgetExhausted: {
4464
+ subReason: "budget_exhausted",
4465
+ resultReason: "budget-exhausted",
4466
+ // Overridden per wall by `ChatDriver.budgetExhaustedBubble`, which upgrades this with
4467
+ // "switch to another AI" when a second vendor is reachable.
4468
+ copy: DEFAULT_BUDGET_EXHAUSTED_MESSAGE
4469
+ }
4470
+ // `satisfies`, not an annotation: an annotation would widen `resultReason` to
4471
+ // `TurnFailureReason` and cost the literal types that let `turn-failures.test.ts` pin the
4472
+ // two deliberate divergences at compile time instead of at runtime.
4473
+ };
4474
+
4403
4475
  // src/components/chat-driver/chat-driver.ts
4404
4476
  var toolResultImages = (result) => {
4405
4477
  if (!result || typeof result !== "object") return void 0;
@@ -4774,6 +4846,33 @@ var ChatDriver = class _ChatDriver extends EventTarget {
4774
4846
  ...providerRefused ? { providerRefused } : {}
4775
4847
  };
4776
4848
  }
4849
+ /**
4850
+ * End the turn on a terminal failure: fail upward when this driver is a sub-agent, speak
4851
+ * to the user when it is not, and hand the reason to the caller either way.
4852
+ *
4853
+ * Only that fork lives here. Each exit keeps its own `recordTurnError` call, because the
4854
+ * telemetry payload is what legitimately differs between them — attempt counts, token
4855
+ * figures, tool names — and folding it in would mean one optional field per condition.
4856
+ *
4857
+ * `overrides.copy` serves the two walls whose prose is composed per occurrence:
4858
+ * `budgetExhaustedBubble` names the vendor and can offer another, `providerRefusedBubble`
4859
+ * prefers a host-supplied message. Every other exit reads its copy from the table.
4860
+ *
4861
+ * One exit stays out: the `BudgetExhaustedError` catch. Not because its shape differs —
4862
+ * its two telemetry payloads are field-for-field identical — but because its main-turn
4863
+ * branch calls `reportBudgetExhausted`, which is public and also serves
4864
+ * `OrchestratingDriver`'s classification phase, where no tool loop is running. That
4865
+ * method has to survive either way, so routing the catch through here would relocate the
4866
+ * duplication rather than remove it.
4867
+ */
4868
+ endTurnWithFailure(exit, overrides) {
4869
+ if (this.isSubAgent) {
4870
+ this.failSubAgent(exit.subReason, overrides?.budget, overrides?.providerRefused);
4871
+ } else {
4872
+ this.appendDriverNotice(overrides?.copy ?? exit.copy);
4873
+ }
4874
+ return this.turnDone(exit.resultReason, overrides?.budget, overrides?.providerRefused);
4875
+ }
4777
4876
  /**
4778
4877
  * Terminal budget outcome for a wall hit **outside** the tool loop — today,
4779
4878
  * `OrchestratingDriver`'s classification phase, which calls the provider
@@ -4802,7 +4901,7 @@ var ChatDriver = class _ChatDriver extends EventTarget {
4802
4901
  */
4803
4902
  budgetExhaustedBubble(wall) {
4804
4903
  if (this.budgetExhaustedMessageOverride != null) return this.budgetExhaustedMessageOverride;
4805
- if (wall?.otherVendorAvailable !== true) return DEFAULT_BUDGET_EXHAUSTED_MESSAGE;
4904
+ if (wall?.otherVendorAvailable !== true) return TURN_FAILURE_EXITS.budgetExhausted.copy;
4806
4905
  const subject = wall?.vendorLabel ? `${wall.vendorLabel}'s AI usage limit` : "your AI usage limit";
4807
4906
  return `You've reached ${subject}. Switch to another AI provider in Settings to keep going, or contact your administrator to raise it.`;
4808
4907
  }
@@ -4836,32 +4935,7 @@ var ChatDriver = class _ChatDriver extends EventTarget {
4836
4935
  * has gone unpaid — so there is nothing here to compose. A host override wins verbatim.
4837
4936
  */
4838
4937
  providerRefusedBubble() {
4839
- return this.providerRefusedMessageOverride ?? DEFAULT_PROVIDER_REFUSED_MESSAGE;
4840
- }
4841
- /**
4842
- * Terminal provider-refusal outcome. Mirrors `reportBudgetExhausted` so the two walls behave
4843
- * identically from the caller's side, while keeping their diagnostics distinct.
4844
- *
4845
- * `kind` reaches the debug log and the result but never the transcript: it is what lets an operator
4846
- * tell "top up the account" from "rotate the key", and with cause-free user copy this is the only
4847
- * place that distinction survives.
4848
- */
4849
- reportProviderRefused(e) {
4850
- const detail = providerRefusedDetailOf(e);
4851
- this.providerRefusedThisTurn = true;
4852
- this.providerRefusedDetail = detail;
4853
- logger2.error("ChatDriver: provider refused the request", e);
4854
- recordTurnError(this.sessionKey, "provider-refused", {
4855
- agent: this.activeAgentName,
4856
- provider: this.lastResolvedProviderName,
4857
- vendor: vendorTypeOfLabel(e.vendorLabel) ?? this.lastResolvedProvider,
4858
- kind: e.kind,
4859
- upstreamStatus: e.upstreamStatus,
4860
- upstreamType: e.upstreamType,
4861
- isSubAgent: this.isSubAgent
4862
- });
4863
- this.appendDriverNotice(this.providerRefusedBubble());
4864
- return this.turnDone("provider-refused", void 0, detail);
4938
+ return this.providerRefusedMessageOverride ?? TURN_FAILURE_EXITS.providerRefused.copy;
4865
4939
  }
4866
4940
  /** The typed failure reason on a loop result, or `undefined` for a clean turn / handoff. */
4867
4941
  static failureReasonOf(result) {
@@ -6152,12 +6226,10 @@ Output format (strict):
6152
6226
  via: "sub-agent",
6153
6227
  isSubAgent: this.isSubAgent
6154
6228
  });
6155
- if (this.isSubAgent) {
6156
- this.failSubAgent("provider_refused", void 0, this.providerRefusedDetail);
6157
- } else {
6158
- this.appendDriverNotice(this.providerRefusedBubble());
6159
- }
6160
- return this.turnDone("provider-refused", void 0, this.providerRefusedDetail);
6229
+ return this.endTurnWithFailure(TURN_FAILURE_EXITS.providerRefused, {
6230
+ copy: this.providerRefusedBubble(),
6231
+ providerRefused: this.providerRefusedDetail
6232
+ });
6161
6233
  }
6162
6234
  if (this.budgetExhaustedThisTurn) {
6163
6235
  logger2.error("ChatDriver: ending the turn \u2014 a sub-agent hit the AI budget wall");
@@ -6177,12 +6249,10 @@ Output format (strict):
6177
6249
  via: "sub-agent",
6178
6250
  isSubAgent: this.isSubAgent
6179
6251
  });
6180
- if (this.isSubAgent) {
6181
- this.failSubAgent("budget_exhausted", this.budgetWallDetail);
6182
- } else {
6183
- this.appendDriverNotice(this.budgetExhaustedBubble(this.budgetWallDetail));
6184
- }
6185
- return this.turnDone("budget-exhausted", this.budgetWallDetail);
6252
+ return this.endTurnWithFailure(TURN_FAILURE_EXITS.budgetExhausted, {
6253
+ copy: this.budgetExhaustedBubble(this.budgetWallDetail),
6254
+ budget: this.budgetWallDetail
6255
+ });
6186
6256
  }
6187
6257
  const promptCtx = {
6188
6258
  agentName: this.activeAgentName ?? "",
@@ -6347,14 +6417,7 @@ ${tailBody}
6347
6417
  iterations,
6348
6418
  isSubAgent: this.isSubAgent
6349
6419
  });
6350
- if (this.isSubAgent) {
6351
- this.failSubAgent("context_exhausted");
6352
- } else {
6353
- this.appendDriverNotice(
6354
- "I had to stop here \u2014 this conversation has filled the available context. Compact it to free up room, then ask me to continue."
6355
- );
6356
- }
6357
- return this.turnDone("context-exhausted");
6420
+ return this.endTurnWithFailure(TURN_FAILURE_EXITS.contextGuard);
6358
6421
  }
6359
6422
  let response;
6360
6423
  try {
@@ -6384,14 +6447,7 @@ ${tailBody}
6384
6447
  finishMessage: e.finishMessage,
6385
6448
  isSubAgent: this.isSubAgent
6386
6449
  });
6387
- if (this.isSubAgent) {
6388
- this.failSubAgent("malformed_tool_call");
6389
- } else {
6390
- this.appendDriverNotice(
6391
- "While working on your request, I repeatedly called my tools incorrectly. This often works on a second try \u2014 would you like me to try again? If it happens again, try breaking your request into smaller steps."
6392
- );
6393
- }
6394
- return this.turnDone("malformed-function-call");
6450
+ return this.endTurnWithFailure(TURN_FAILURE_EXITS.malformedToolCall);
6395
6451
  }
6396
6452
  if (e instanceof ResponseTruncatedError) {
6397
6453
  logger2.error("ChatDriver: response truncated at the output-token cap", e);
@@ -6404,14 +6460,7 @@ ${tailBody}
6404
6460
  tools: e.toolNames,
6405
6461
  isSubAgent: this.isSubAgent
6406
6462
  });
6407
- if (this.isSubAgent) {
6408
- this.failSubAgent("response_truncated");
6409
- } else {
6410
- this.appendDriverNotice(
6411
- "My response was cut off because a single step reached the model output limit. This usually means one step tried to produce too much at once \u2014 try breaking your request into smaller steps."
6412
- );
6413
- }
6414
- return this.turnDone("response-truncated");
6463
+ return this.endTurnWithFailure(TURN_FAILURE_EXITS.responseTruncated);
6415
6464
  }
6416
6465
  if (e instanceof ContextOverflowError) {
6417
6466
  logger2.error("ChatDriver: the prompt exceeded the model context window", e);
@@ -6424,30 +6473,28 @@ ${tailBody}
6424
6473
  via: "provider",
6425
6474
  isSubAgent: this.isSubAgent
6426
6475
  });
6427
- if (this.isSubAgent) {
6428
- this.failSubAgent("context_exhausted");
6429
- } else {
6430
- this.appendDriverNotice(DEFAULT_CONTEXT_OVERFLOW_MESSAGE);
6431
- }
6432
- return this.turnDone("context-exhausted");
6476
+ return this.endTurnWithFailure(TURN_FAILURE_EXITS.contextOverflow);
6433
6477
  }
6434
6478
  if (e instanceof ProviderRefusedError) {
6435
- if (this.isSubAgent) {
6436
- logger2.error("ChatDriver: provider refused the request", e);
6437
- recordTurnError(this.sessionKey, "provider-refused", {
6438
- agent: this.activeAgentName,
6439
- provider: this.lastResolvedProviderName,
6440
- vendor: vendorTypeOfLabel(e.vendorLabel) ?? this.lastResolvedProvider,
6441
- kind: e.kind,
6442
- upstreamStatus: e.upstreamStatus,
6443
- upstreamType: e.upstreamType,
6444
- isSubAgent: true
6445
- });
6446
- const detail = providerRefusedDetailOf(e);
6447
- this.failSubAgent("provider_refused", void 0, detail);
6448
- return this.turnDone("provider-refused", void 0, detail);
6479
+ const detail = providerRefusedDetailOf(e);
6480
+ if (!this.isSubAgent) {
6481
+ this.providerRefusedThisTurn = true;
6482
+ this.providerRefusedDetail = detail;
6449
6483
  }
6450
- return this.reportProviderRefused(e);
6484
+ logger2.error("ChatDriver: provider refused the request", e);
6485
+ recordTurnError(this.sessionKey, "provider-refused", {
6486
+ agent: this.activeAgentName,
6487
+ provider: this.lastResolvedProviderName,
6488
+ vendor: vendorTypeOfLabel(e.vendorLabel) ?? this.lastResolvedProvider,
6489
+ kind: e.kind,
6490
+ upstreamStatus: e.upstreamStatus,
6491
+ upstreamType: e.upstreamType,
6492
+ isSubAgent: this.isSubAgent
6493
+ });
6494
+ return this.endTurnWithFailure(TURN_FAILURE_EXITS.providerRefused, {
6495
+ copy: this.providerRefusedBubble(),
6496
+ providerRefused: detail
6497
+ });
6451
6498
  }
6452
6499
  if (e instanceof BudgetExhaustedError) {
6453
6500
  this.budgetExhaustedThisTurn = true;
@@ -6477,14 +6524,7 @@ ${tailBody}
6477
6524
  message: e.message,
6478
6525
  isSubAgent: this.isSubAgent
6479
6526
  });
6480
- if (this.isSubAgent) {
6481
- this.failSubAgent("timeout");
6482
- } else {
6483
- this.appendDriverNotice(
6484
- "The request timed out. You can ask me to try again, or break this into a smaller step."
6485
- );
6486
- }
6487
- return this.turnDone("exception");
6527
+ return this.endTurnWithFailure(TURN_FAILURE_EXITS.timeout);
6488
6528
  }
6489
6529
  if (e instanceof DOMException && e.name === "AbortError") {
6490
6530
  return this.completeAbortedTurn();
@@ -6536,11 +6576,11 @@ ${tailBody}
6536
6576
  iterations -= 1;
6537
6577
  continue;
6538
6578
  }
6539
- const failureReason2 = isRefusal ? "refusal" : "empty-response";
6579
+ const exit = isRefusal ? TURN_FAILURE_EXITS.refusal : TURN_FAILURE_EXITS.emptyResponse;
6540
6580
  logger2.error(
6541
6581
  isRefusal ? "ChatDriver: model refused the request (safety decline) \u2014 not retrying" : "ChatDriver: empty model response after all retries"
6542
6582
  );
6543
- recordTurnError(this.sessionKey, failureReason2, {
6583
+ recordTurnError(this.sessionKey, exit.resultReason, {
6544
6584
  agent: this.activeAgentName,
6545
6585
  provider: this.lastResolvedProviderName,
6546
6586
  attempts: emptyResponseAttempts,
@@ -6550,14 +6590,7 @@ ${tailBody}
6550
6590
  isSubAgent: this.isSubAgent,
6551
6591
  ...response.responseMeta
6552
6592
  });
6553
- if (this.isSubAgent) {
6554
- this.failSubAgent(isRefusal ? "refusal" : "empty_response");
6555
- } else {
6556
- this.appendDriverNotice(
6557
- isRefusal ? "This request was declined. Try rephrasing it, or breaking it into smaller steps." : "While working on your request, I repeatedly generated a blank response. This often works on a second try \u2014 would you like me to try again? If it happens again, try breaking your request into smaller steps."
6558
- );
6559
- }
6560
- return this.turnDone(failureReason2);
6593
+ return this.endTurnWithFailure(exit);
6561
6594
  } else {
6562
6595
  const { reasoning, ...rest } = response;
6563
6596
  const displayOnly = {
@@ -6857,14 +6890,7 @@ RECOVERY: this tool failed once \u2014 you may retry it, or take a different val
6857
6890
  availableTools: Object.keys(this.toolHandlers),
6858
6891
  isSubAgent: this.isSubAgent
6859
6892
  });
6860
- if (this.isSubAgent) {
6861
- this.failSubAgent("unknown_tool_limit");
6862
- } else {
6863
- this.appendDriverNotice(
6864
- "I'm sorry, I repeatedly tried to use tools that aren't available to me, so I couldn't complete that. If a 'Download agent log' option appears in the Settings (cog) menu, you can download the log and share it with whoever set up this assistant to help fix the issue."
6865
- );
6866
- }
6867
- return this.turnDone("unknown-tool-limit");
6893
+ return this.endTurnWithFailure(TURN_FAILURE_EXITS.unknownToolLimit);
6868
6894
  }
6869
6895
  const firstContinuation = systemCalls[0];
6870
6896
  if (firstContinuation) {
@@ -6875,7 +6901,6 @@ RECOVERY: this tool failed once \u2014 you may retry it, or take a different val
6875
6901
  return this.turnDone();
6876
6902
  }
6877
6903
  }
6878
- let failureReason;
6879
6904
  if (iterations >= this.maxToolIterations) {
6880
6905
  logger2.warn("ChatDriver: reached max tool iterations, stopping");
6881
6906
  recordTurnError(this.sessionKey, "max-iterations", {
@@ -6885,16 +6910,9 @@ RECOVERY: this tool failed once \u2014 you may retry it, or take a different val
6885
6910
  limit: this.maxToolIterations,
6886
6911
  isSubAgent: this.isSubAgent
6887
6912
  });
6888
- if (this.isSubAgent) {
6889
- this.failSubAgent("max_iterations");
6890
- } else {
6891
- this.appendDriverNotice(
6892
- "I've reached my limit for this response. You can ask me to continue and I'll pick up where I left off."
6893
- );
6894
- }
6895
- failureReason = "max-iterations";
6913
+ return this.endTurnWithFailure(TURN_FAILURE_EXITS.maxIterations);
6896
6914
  }
6897
- return this.turnDone(failureReason);
6915
+ return this.turnDone();
6898
6916
  }
6899
6917
  /**
6900
6918
  * Append prose the DRIVER wrote rather than the model — a failure notice, a limit explanation,