@gaunt-sloth/core 2.0.0-beta.2 → 2.0.0-beta.4

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 (65) hide show
  1. package/dist/config/shell-policy.d.ts +2 -2
  2. package/dist/config/shell-policy.js +3 -3
  3. package/dist/config/tool-descriptions.d.ts +5 -5
  4. package/dist/config/tool-descriptions.js +3 -3
  5. package/dist/core/GthAbstractAgent.d.ts +67 -1
  6. package/dist/core/GthAbstractAgent.js +248 -4
  7. package/dist/core/GthAbstractAgent.js.map +1 -1
  8. package/dist/core/GthAgentRunner.d.ts +91 -10
  9. package/dist/core/GthAgentRunner.js +327 -21
  10. package/dist/core/GthAgentRunner.js.map +1 -1
  11. package/dist/core/GthLangChainAgent.d.ts +3 -2
  12. package/dist/core/GthLangChainAgent.js +25 -5
  13. package/dist/core/GthLangChainAgent.js.map +1 -1
  14. package/dist/core/reasoningBlocks.d.ts +4 -6
  15. package/dist/core/reasoningBlocks.js +4 -6
  16. package/dist/core/reasoningBlocks.js.map +1 -1
  17. package/dist/core/refusal.d.ts +54 -1
  18. package/dist/core/refusal.js +109 -1
  19. package/dist/core/refusal.js.map +1 -1
  20. package/dist/core/shell/ShellCommandFailedError.d.ts +4 -4
  21. package/dist/core/shell/ShellCommandFailedError.js +4 -4
  22. package/dist/core/shell/openWorld.d.ts +4 -3
  23. package/dist/core/shell/openWorld.js +4 -3
  24. package/dist/core/shell/openWorld.js.map +1 -1
  25. package/dist/core/terminationNotice.d.ts +111 -0
  26. package/dist/core/terminationNotice.js +209 -0
  27. package/dist/core/terminationNotice.js.map +1 -0
  28. package/dist/core/terminationReason.d.ts +271 -0
  29. package/dist/core/terminationReason.js +405 -0
  30. package/dist/core/terminationReason.js.map +1 -0
  31. package/dist/core/toolDisplay.d.ts +49 -2
  32. package/dist/core/toolDisplay.js +137 -19
  33. package/dist/core/toolDisplay.js.map +1 -1
  34. package/dist/core/types.d.ts +33 -6
  35. package/dist/core/types.js.map +1 -1
  36. package/dist/providers/geminiSchemaSanitizer.d.ts +2 -2
  37. package/dist/providers/geminiSchemaSanitizer.js +2 -2
  38. package/dist/providers/geminiThinking.d.ts +10 -5
  39. package/dist/providers/geminiThinking.js +10 -5
  40. package/dist/providers/geminiThinking.js.map +1 -1
  41. package/dist/runtime/askStructured.d.ts +22 -0
  42. package/dist/runtime/askStructured.js +53 -0
  43. package/dist/runtime/askStructured.js.map +1 -1
  44. package/dist/runtime/conversation.d.ts +11 -0
  45. package/dist/runtime/conversation.js +13 -1
  46. package/dist/runtime/conversation.js.map +1 -1
  47. package/dist/runtime/singleShot.d.ts +11 -0
  48. package/dist/runtime/singleShot.js +21 -1
  49. package/dist/runtime/singleShot.js.map +1 -1
  50. package/dist/utils/aiignoreUtils.d.ts +6 -0
  51. package/dist/utils/aiignoreUtils.js +69 -1
  52. package/dist/utils/aiignoreUtils.js.map +1 -1
  53. package/dist/utils/debugDump.d.ts +54 -0
  54. package/dist/utils/debugDump.js +32 -0
  55. package/dist/utils/debugDump.js.map +1 -1
  56. package/dist/utils/displayWidth.d.ts +10 -5
  57. package/dist/utils/displayWidth.js +148 -54
  58. package/dist/utils/displayWidth.js.map +1 -1
  59. package/dist/utils/llmUtils.d.ts +1 -1
  60. package/dist/utils/llmUtils.js +1 -1
  61. package/dist/utils/systemPromptNotes.d.ts +3 -3
  62. package/dist/utils/systemPromptNotes.js +3 -3
  63. package/dist/utils/vertexaiUtils.js +100 -1
  64. package/dist/utils/vertexaiUtils.js.map +1 -1
  65. package/package.json +4 -3
@@ -7,6 +7,8 @@ import { classifyCommand } from '#src/core/shell/arity.js';
7
7
  import { describeAbstention } from '#src/core/shell/abstention.js';
8
8
  import { normalizeCommand } from '#src/core/shell/normalize.js';
9
9
  import { ApprovalStopError, AttackHaltError, NonInteractiveEscalationError, } from '#src/core/shell/approvalStop.js';
10
+ import { attachTerminationReason, classifyThrownTermination, terminationReason, terminationReasonOf, } from '#src/core/terminationReason.js';
11
+ import { terminationLogLine } from '#src/core/terminationNotice.js';
10
12
  import { applyDestructiveFloor, effectivePreflightFloorFinding, isBelowDestructiveFloor, isNegotiableCall, isRaterTimeout, mapAllowMatchedVerdictToAction, mapVerdictToAction, openWorldToolFloorReason, preflightFloorFinding, RATER_DEFAULT_TIMEOUT_MS, rateShellCommand, } from '#src/core/shell/rater.js';
11
13
  import { RaterHealth } from '#src/core/shell/raterHealth.js';
12
14
  import { alignmentApprovalNotice, isAlignmentFailClosed, runAlignmentCheck, } from '#src/core/shell/alignment.js';
@@ -207,10 +209,29 @@ export class GthAgentRunner {
207
209
  * {@link getRunStats} before cleanup. Defaults to an empty tally.
208
210
  */
209
211
  lastRunStats = { tools: [] };
212
+ /**
213
+ * [[EXT-159]] — why the current turn ended, as classified by the sites the RUNNER owns (the two
214
+ * exception wrappers, the approvals re-throws, the empty-response throws, the ordinary end of a
215
+ * turn). The agent owns the sites inside it and its answer outranks this one; see
216
+ * {@link getTerminationReason}.
217
+ */
218
+ terminationReason = null;
219
+ /**
220
+ * [[EXT-159]] — snapshot of the agent's own termination reason, for the same reason
221
+ * {@link lastRunStats} exists: {@link cleanup} nulls the agent, and the single-shot path reads
222
+ * the reason afterwards.
223
+ */
224
+ agentTerminationReason = null;
225
+ /**
226
+ * [[EXT-159]] — snapshot of the agent's per-message `finish_reason` observations, kept for the
227
+ * same reason {@link agentTerminationReason} is: `/debug-dump` and the non-interactive verbs ask
228
+ * after {@link cleanup} has already dropped the agent.
229
+ */
230
+ agentFinishReasons = [];
210
231
  /**
211
232
  * CFG-27 — the runtime, session-scoped approvals posture, seeded at {@link init} from
212
233
  * {@link resolveApprovals} and thereafter switchable for the session by `/approvals <rung>`.
213
- * **This field, not the interrupt wiring, is where the rung lives.** The backends wire the
234
+ * **This field, not the interrupt wiring, is where the rung lives.** The agent wires the
214
235
  * interrupt rung-independently, so every tool any rung could gate arrives at the top of
215
236
  * {@link decideToolApproval} and is judged against the rung recorded here — which is what makes
216
237
  * `/approvals manual` take effect mid-session, and what keeps a config that pre-selects
@@ -813,6 +834,8 @@ export class GthAgentRunner {
813
834
  }
814
835
  // GS2-16: start this turn's analytics tally from zero (the runner is reused across turns).
815
836
  this.resetRunStats();
837
+ // [[EXT-159]] — the previous turn's termination reason goes with the previous turn's tally.
838
+ this.resetTerminationReason();
816
839
  // GS2-48 — record this turn's transcript tail for the crash handler.
817
840
  updateCrashContext({ transcriptTail: messages.slice(-CRASH_TRANSCRIPT_TAIL_MESSAGES) });
818
841
  // [[EXT-29]] §5 — a new user turn is the human being reached, so it ends any negotiation still
@@ -845,11 +868,17 @@ export class GthAgentRunner {
845
868
  // CFG-27 — an approvals STOP is not a stream failure: it is the gate deliberately
846
869
  // ending the run, and its message IS the explanation the spec requires it to carry.
847
870
  // Re-thrown unchanged (the outer catch does the same) so nothing buries it.
848
- if (streamError instanceof ApprovalStopError)
871
+ if (streamError instanceof ApprovalStopError) {
872
+ this.noteApprovalStop('runner.stream-approval-stop', streamError);
849
873
  throw streamError;
874
+ }
850
875
  // Handle streaming-specific errors
851
876
  debugLogError('Stream processing', streamError);
852
- throw new Error(`Stream processing failed: ${streamError instanceof Error ? streamError.message : String(streamError)}`);
877
+ // [[EXT-159]] classify the ORIGINAL error, not the wrapper built from it: the wrapper's
878
+ // message is where the diagnosis was being thrown away. The one reason is attached to the
879
+ // wrapper as well, so a catcher that only ever sees the re-thrown error still reads it.
880
+ const reason = this.classifyThrownAt('runner.stream-error', streamError);
881
+ throw attachTerminationReason(new Error(`Stream processing failed: ${streamError instanceof Error ? streamError.message : String(streamError)}`), reason);
853
882
  }
854
883
  debugLog(`Stream completed. Total response length: ${result.length}`);
855
884
  // EXT-37: a content-policy refusal (OpenAI content_filter / Anthropic stop_reason=refusal /
@@ -877,10 +906,16 @@ export class GthAgentRunner {
877
906
  const fallback = await this.agent.invoke(messages, this.runConfig);
878
907
  debugLog(`Fallback non-stream response length: ${fallback.length}`);
879
908
  if (fallback.trim().length === 0) {
880
- throw new Error('Model returned an empty response after tool execution. Try again or switch to a more stable model.');
909
+ // [[EXT-159]] the retry has already been spent here, so this is the terminal empty
910
+ // turn rather than the first one.
911
+ const reason = terminationReason('runner.empty-after-fallback', 'control', 'empty_response');
912
+ this.noteTermination(reason);
913
+ throw attachTerminationReason(new Error('Model returned an empty response after tool execution. Try again or switch to a more stable model.'), reason);
881
914
  }
915
+ this.noteCompleted('runner.completed');
882
916
  return fallback;
883
917
  }
918
+ this.noteCompleted('runner.completed');
884
919
  return result;
885
920
  }
886
921
  else {
@@ -897,8 +932,13 @@ export class GthAgentRunner {
897
932
  result += await this.resolveToolInterrupts();
898
933
  debugLog(`Non-stream response length: ${result.length}`);
899
934
  if (result.trim().length === 0) {
900
- throw new Error('Model returned an empty response. Try again or switch to a more stable model.');
935
+ // [[EXT-159]] the non-streaming path has no retry to spend, so an empty turn is
936
+ // terminal here at once.
937
+ const reason = terminationReason('runner.empty-invoke', 'control', 'empty_response');
938
+ this.noteTermination(reason);
939
+ throw attachTerminationReason(new Error('Model returned an empty response. Try again or switch to a more stable model.'), reason);
901
940
  }
941
+ this.noteCompleted('runner.completed');
902
942
  return result;
903
943
  }
904
944
  }
@@ -907,13 +947,20 @@ export class GthAgentRunner {
907
947
  // its own words: the command, the rating and its reason are the whole point of it. Wrapping
908
948
  // it as "Agent processing failed: …" would bury the explanation the spec requires it to
909
949
  // carry, so it is re-thrown unchanged.
910
- if (error instanceof ApprovalStopError)
950
+ if (error instanceof ApprovalStopError) {
951
+ this.noteApprovalStop('runner.turn-approval-stop', error);
911
952
  throw error;
953
+ }
912
954
  // Handle agent invocation errors
913
955
  debugLogError('Agent processing', error);
956
+ // [[EXT-159]] — the OUTER of two nested wrappers. On the streaming path the inner one has
957
+ // already classified this same failure and re-thrown, so `noteTermination`'s first-write-wins
958
+ // keeps the inner, truer site; on the non-streaming path this is the only classification
959
+ // there is. Both are reachable, so both classify.
960
+ const reason = this.classifyThrownAt('runner.turn-error', error);
914
961
  const originalMessage = error instanceof Error ? error.message : String(error);
915
962
  const enhancedMessage = enhanceVertexUnauthorizedMessage(originalMessage, this.config?.llm);
916
- throw new Error(`Agent processing failed: ${enhancedMessage}`, error instanceof Error ? { cause: error } : undefined);
963
+ throw attachTerminationReason(new Error(`Agent processing failed: ${enhancedMessage}`, error instanceof Error ? { cause: error } : undefined), reason);
917
964
  }
918
965
  finally {
919
966
  // [[TUI-C69]] §5.4 — the turn is over, so the argument is over. The reasoning is the same as
@@ -946,10 +993,10 @@ export class GthAgentRunner {
946
993
  * streamed across all resume turns (empty when nothing was resumed).
947
994
  *
948
995
  * No-ops (returns '') when the agent does not support interrupts (`getPendingToolInterrupts`/
949
- * `streamResume` absent) — that is the only exemption. As of EXT-52 BOTH backends gate
950
- * `run_shell_command` and expose the interrupt surface, so the lean (default) agent is now
951
- * exactly the agent this loop serves; only an agent implementation without those methods
952
- * (e.g. a test double) skips it.
996
+ * `streamResume` absent) — that is the only exemption. As of EXT-52 the shipped agent gates
997
+ * `run_shell_command` and exposes the interrupt surface, so the lean agent is exactly the agent
998
+ * this loop serves; only an agent implementation without those methods (e.g. a test double)
999
+ * skips it.
953
1000
  */
954
1001
  async resolveToolInterrupts() {
955
1002
  const agent = this.agent;
@@ -959,11 +1006,17 @@ export class GthAgentRunner {
959
1006
  if (!agent.getPendingToolInterrupts || !agent.streamResume)
960
1007
  return '';
961
1008
  let resumedText = '';
1009
+ // [[EXT-159]] — which way the loop left decides what ended the turn, and only the loop knows.
1010
+ // Falling out of the bound and draining cleanly are different endings; a caller sees the same
1011
+ // returned string either way, so a caller cannot tell them apart.
1012
+ let drained = false;
962
1013
  // Bound the loop defensively so a misbehaving graph that re-suspends forever cannot spin.
963
1014
  for (let guard = 0; guard < 100; guard++) {
964
1015
  const pending = await agent.getPendingToolInterrupts(runConfig);
965
- if (pending.length === 0)
1016
+ if (pending.length === 0) {
1017
+ drained = true;
966
1018
  break;
1019
+ }
967
1020
  const decisions = [];
968
1021
  for (const tool of pending) {
969
1022
  decisions.push(await this.decideToolApproval(tool));
@@ -971,6 +1024,14 @@ export class GthAgentRunner {
971
1024
  const stream = await agent.streamResume({ decisions }, runConfig);
972
1025
  resumedText += await this.drainTextStream(stream);
973
1026
  }
1027
+ // [[EXT-159]] — the runtime gave up, and the turn ends here because of that. It still returns
1028
+ // into `processMessages`, which then reports an ordinary completion (or an empty turn) at its
1029
+ // own site — so routing to an enumerated site is no defence: that site would state a category
1030
+ // that is affirmatively false. Noting it HERE, before those sites run, is what makes
1031
+ // first-write-wins keep the one fact they cannot see.
1032
+ if (!drained) {
1033
+ this.noteTermination(terminationReason('runner.interrupt-guard-exhausted', 'control', 'interrupt_drain_guard'));
1034
+ }
974
1035
  return resumedText;
975
1036
  }
976
1037
  /**
@@ -1085,7 +1146,7 @@ export class GthAgentRunner {
1085
1146
  * (defense in depth in `GthDevToolkit.executeCommand`), so an allow-listed `rm -rf /` still
1086
1147
  * cannot run.
1087
1148
  *
1088
- * **Step 0 is the rung.** The backends wire the interrupt over every tool ANY rung could gate,
1149
+ * **Step 0 is the rung.** The agent wires the interrupt over every tool ANY rung could gate,
1089
1150
  * because the graph is built once and `/approvals <rung>` moves the rung under it for the rest of
1090
1151
  * the session. So a call arriving here has not yet been judged against the rung in force: this is
1091
1152
  * where that happens, on `sessionApprovals.rung`, which a mid-session switch has already updated.
@@ -1321,7 +1382,7 @@ export class GthAgentRunner {
1321
1382
  const command = typeof tool.args?.command === 'string' ? tool.args.command : null;
1322
1383
  const isShellCommand = tool.name === SHELL_TOOL_NAME && command !== null;
1323
1384
  const approvals = this.sessionApprovals;
1324
- // (0) Does the rung IN FORCE gate this tool at all? Same shared predicate the backends built the
1385
+ // (0) Does the rung IN FORCE gate this tool at all? Same shared predicate the agent built the
1325
1386
  // interrupt from, asked about this one call, so the wiring and the decision cannot disagree.
1326
1387
  // Scope `once`, so nothing is written to any allow-list: this is not a grant, it is the absence
1327
1388
  // of a gate.
@@ -2224,7 +2285,7 @@ export class GthAgentRunner {
2224
2285
  if (registered.length === 0)
2225
2286
  return [];
2226
2287
  // The LIVE gated set, from the SAME shared policy `decideToolApproval` decides on and the
2227
- // backends derive their interrupt from, so "granted" here means exactly what it means at
2288
+ // agent derives its interrupt from, so "granted" here means exactly what it means at
2228
2289
  // tool-registration time (§4.5) and at the gate.
2229
2290
  //
2230
2291
  // EXT-80 makes this non-drift property load-bearing rather than incidental. At `manual` the
@@ -2629,6 +2690,8 @@ export class GthAgentRunner {
2629
2690
  }
2630
2691
  // GS2-16: start this turn's analytics tally from zero (the runner is reused across turns).
2631
2692
  this.resetRunStats();
2693
+ // [[EXT-159]] — the previous turn's termination reason goes with the previous turn's tally.
2694
+ this.resetTerminationReason();
2632
2695
  // GS2-48 — record this turn's transcript tail for the crash handler.
2633
2696
  updateCrashContext({ transcriptTail: messages.slice(-CRASH_TRANSCRIPT_TAIL_MESSAGES) });
2634
2697
  // [[EXT-29]] §5 — a new user turn is the human being reached, so it ends any negotiation still
@@ -2644,8 +2707,102 @@ export class GthAgentRunner {
2644
2707
  debugLog('Processing messages (event stream)...');
2645
2708
  debugLogObject('Input Messages', messages);
2646
2709
  try {
2647
- yield* this.agent.streamWithEvents(messages, this.runConfig, signal);
2648
- yield* this.resolveToolInterruptsWithEvents(signal);
2710
+ // [[TUI-C100]] — **a tool call that never produced a result is closed here, and nowhere
2711
+ // earlier.** `processEventStream` ends a call when its own result arrives and otherwise
2712
+ // leaves it open, because from inside a stream a call suspended at the approval gate looks
2713
+ // exactly like a terminal one: the graph interrupts, the stream returns, and the calls it
2714
+ // announced are still outstanding either way. The two only part company at THIS level — the
2715
+ // prompt is opened by `resolveToolInterruptsWithEvents` below, after the first stream has
2716
+ // finished — so closing anything before that point would tell the surface a call had
2717
+ // finished while the human was still being asked whether it may run at all.
2718
+ //
2719
+ // **Membership in this set at the drain IS "produced no result".** A call is forgotten on its
2720
+ // own `tool_end` or its own `tool_result`, and a call that actually ran always yields the
2721
+ // latter from its `ToolMessage` — so nothing that survives to the loop below has a result,
2722
+ // whatever the reason. The reasons are several and the surface cannot tell most of them
2723
+ // apart: the turn was abandoned; a refusal made the middleware jump back to the model and the
2724
+ // whole round's tool node was skipped; the call was terminal. They do not differ in the one
2725
+ // thing a row reports, so they are closed alike — as a call with no result, which is what an
2726
+ // error result says, and never with the tick and the word `done`, which no arm of this loop
2727
+ // could make true.
2728
+ const unendedToolCalls = new Set();
2729
+ // [[EXT-159]] — did this turn produce an ANSWER? Grammar member (2), "the model produced
2730
+ // nothing", had two sites on the string path and none here, so an empty typed-event turn —
2731
+ // the node's own motivating symptom, on the surface users actually watch — reported
2732
+ // `completed`, i.e. a legitimate hand-back.
2733
+ //
2734
+ // `text` is the exact analogue of the string path's `result.trim().length === 0`: that path
2735
+ // enqueues only `answerTextOf(chunk.content)`, which drops reasoning segments, and this path
2736
+ // splits the same segments into `text` (answer) and `reasoning_delta` (not the answer). So a
2737
+ // reasoning-only turn is empty on BOTH surfaces, and they cannot disagree about one turn.
2738
+ // Testing each delta rather than the concatenation is equivalent: if every delta is blank
2739
+ // their join is blank, and one non-blank delta makes the join non-blank.
2740
+ //
2741
+ // This is CLASSIFICATION only. The string path's empty-stream retry / `invoke` fallback is
2742
+ // still deliberately not duplicated here (see this method's docblock) — naming what happened
2743
+ // is not fixing it.
2744
+ let sawAnswerText = false;
2745
+ const tracking = async function* (source) {
2746
+ for await (const event of source) {
2747
+ if (event.type === 'tool_start')
2748
+ unendedToolCalls.add(event.id);
2749
+ else if (event.type === 'tool_end' || event.type === 'tool_result')
2750
+ unendedToolCalls.delete(event.id);
2751
+ else if (event.type === 'text' && event.delta.trim().length > 0)
2752
+ sawAnswerText = true;
2753
+ yield event;
2754
+ }
2755
+ };
2756
+ yield* tracking(this.agent.streamWithEvents(messages, this.runConfig, signal));
2757
+ yield* tracking(this.resolveToolInterruptsWithEvents(signal));
2758
+ // The abort is the one case that IS distinguishable here, and it buys the wording rather than
2759
+ // a different outcome: a turn stopped part-way may have had a call in flight, so this says
2760
+ // what is certain — no result arrived — without claiming the command never started.
2761
+ const noResult = signal?.aborted
2762
+ ? 'Cancelled before this call produced a result.'
2763
+ : 'This call did not run.';
2764
+ // Deliberately at the end of the `try` rather than in the `finally`. Esc does NOT break out
2765
+ // of the consumer's loop — it aborts the signal, `streamWithEvents` catches the `AbortError`
2766
+ // and returns cleanly, and `resolveToolInterruptsWithEvents` returns at its own aborted
2767
+ // guard, so an abandoned turn reaches this line like any other and its rows are closed here
2768
+ // too. The case the placement is actually for is a consumer that stops consuming: breaking
2769
+ // out of a `for await`, or calling `return()` on this generator, closes it, and yielding
2770
+ // during that forced return suspends the generator again instead of finishing it — which
2771
+ // would leave `clearNegotiationDisplay()` below unreached. Rows abandoned that way keep
2772
+ // whatever they last said, which is the price of the `finally` still running.
2773
+ //
2774
+ // A close is not cosmetic either way: an unclosed row on the TUI sits at `running` for the
2775
+ // rest of the session, and a client is owed a terminal state for every call it was shown.
2776
+ for (const id of unendedToolCalls) {
2777
+ yield { type: 'tool_result', id, content: noResult, isError: true };
2778
+ }
2779
+ // [[EXT-159]] — the typed-event turn reached its own end. `signal?.aborted` is read again
2780
+ // rather than reused from `noResult` above because a turn can be cancelled with no tool call
2781
+ // outstanding, and that turn owes a reason just as much. First-write-wins keeps whatever the
2782
+ // agent's own sites already said (a refusal, a suspend, an earlier abort).
2783
+ if (signal?.aborted) {
2784
+ this.noteTermination(terminationReason('runner.events-cancelled', 'control', {
2785
+ category: 'cancelled',
2786
+ detail: 'signal',
2787
+ }));
2788
+ }
2789
+ else if (!sawAnswerText) {
2790
+ // A turn that ended having said nothing did NOT complete, and saying it did is worse than
2791
+ // saying nothing: this design defines an ABSENT reason as "a site we missed", and a
2792
+ // present-but-false one silences that detector at the one case it was built for.
2793
+ this.noteTermination(terminationReason('runner.events-empty', 'control', 'empty_response'));
2794
+ }
2795
+ else {
2796
+ this.noteCompleted('runner.events-completed');
2797
+ }
2798
+ }
2799
+ catch (error) {
2800
+ // [[EXT-159]] — the typed-event path had NO catch at all, so a provider fault on the surface
2801
+ // most users are looking at was the one termination nothing classified. Re-thrown UNCHANGED:
2802
+ // this site adds a reason and takes nothing away, and the consumer's own error rendering is
2803
+ // not this node's business.
2804
+ this.classifyThrownAt('runner.events-error', error);
2805
+ throw error;
2649
2806
  }
2650
2807
  finally {
2651
2808
  // [[TUI-C69]] §5.4 — **the turn is over, so the argument is over.** Until this existed the
@@ -2660,6 +2817,12 @@ export class GthAgentRunner {
2660
2817
  // In `finally` because an abort and a thrown stream end the turn just as much as a return
2661
2818
  // does, and those are the paths where rows left standing are least likely to be noticed.
2662
2819
  this.clearNegotiationDisplay();
2820
+ // [[EXT-159]] — the one ending that reaches NEITHER the end of the try NOR the catch: a
2821
+ // consumer that stops consuming (breaking out of its `for await`, or calling `return()` on
2822
+ // this generator). Nothing was wrong with the run and nothing failed, so no other site can
2823
+ // speak for it, and without this the turn would end with no reason at all — the state that
2824
+ // must mean "a site we missed".
2825
+ this.noteTermination(terminationReason('runner.events-abandoned', 'control', 'abandoned'));
2663
2826
  }
2664
2827
  }
2665
2828
  /**
@@ -2675,9 +2838,9 @@ export class GthAgentRunner {
2675
2838
  *
2676
2839
  * No-ops (yields nothing) when the agent does not support interrupts
2677
2840
  * (`getPendingToolInterrupts`/`streamWithEventsResume` absent) — that is the only exemption.
2678
- * As of EXT-52 BOTH backends gate `run_shell_command` and expose the interrupt surface, so the
2679
- * lean (default) agent is now exactly the agent this loop serves; only an agent implementation
2680
- * without those methods (e.g. a test double) skips it. Aborts (`signal`) propagate through the
2841
+ * As of EXT-52 the shipped agent gates `run_shell_command` and exposes the interrupt surface, so
2842
+ * the lean agent is exactly the agent this loop serves; only an agent implementation without
2843
+ * those methods (e.g. a test double) skips it. Aborts (`signal`) propagate through the
2681
2844
  * resumed stream.
2682
2845
  */
2683
2846
  async *resolveToolInterruptsWithEvents(signal) {
@@ -2687,19 +2850,29 @@ export class GthAgentRunner {
2687
2850
  return;
2688
2851
  if (!agent.getPendingToolInterrupts || !agent.streamWithEventsResume)
2689
2852
  return;
2853
+ // [[EXT-159]] — see {@link resolveToolInterrupts}: which way the loop left is the fact that
2854
+ // distinguishes this ending, and it is discarded unless the loop itself records it. An abort
2855
+ // `return`s below and never reaches the note, which is right — a cancelled turn was stopped by
2856
+ // the user, not by this bound.
2857
+ let drained = false;
2690
2858
  // Bound the loop defensively so a misbehaving graph that re-suspends forever cannot spin.
2691
2859
  for (let guard = 0; guard < 100; guard++) {
2692
2860
  if (signal?.aborted)
2693
2861
  return;
2694
2862
  const pending = await agent.getPendingToolInterrupts(runConfig);
2695
- if (pending.length === 0)
2863
+ if (pending.length === 0) {
2864
+ drained = true;
2696
2865
  break;
2866
+ }
2697
2867
  const decisions = [];
2698
2868
  for (const tool of pending) {
2699
2869
  decisions.push(await this.decideToolApproval(tool));
2700
2870
  }
2701
2871
  yield* agent.streamWithEventsResume({ decisions }, runConfig, [], signal);
2702
2872
  }
2873
+ if (!drained) {
2874
+ this.noteTermination(terminationReason('runner.events-interrupt-guard-exhausted', 'control', 'interrupt_drain_guard'));
2875
+ }
2703
2876
  }
2704
2877
  // noinspection JSUnusedGlobalSymbols
2705
2878
  getAgent() {
@@ -2719,6 +2892,133 @@ export class GthAgentRunner {
2719
2892
  /* fail-soft: analytics must never affect a run */
2720
2893
  }
2721
2894
  }
2895
+ /**
2896
+ * [[EXT-159]] — forget the previous turn's termination reason, on both this runner and the live
2897
+ * agent, so a new turn starts with none. Called at the top of each `processMessages` /
2898
+ * `processMessagesWithEvents`, alongside {@link resetRunStats}. Fail-soft.
2899
+ */
2900
+ resetTerminationReason() {
2901
+ this.terminationReason = null;
2902
+ this.agentTerminationReason = null;
2903
+ this.agentFinishReasons = [];
2904
+ try {
2905
+ this.agent?.resetTerminationReason?.();
2906
+ }
2907
+ catch {
2908
+ /* fail-soft: classification must never affect a run */
2909
+ }
2910
+ }
2911
+ /**
2912
+ * [[EXT-159]] — record why the turn ended, **first-write-wins**.
2913
+ *
2914
+ * The runner's two exception wrappers are NESTED, not alternatives: a stream fault is classified
2915
+ * at the inner one, re-thrown, and caught again by the outer one. Under last-write-wins the outer
2916
+ * site would overwrite the inner classification on every streamed failure — the funnel this
2917
+ * taxonomy replaces, rebuilt one level up.
2918
+ */
2919
+ noteTermination(reason) {
2920
+ try {
2921
+ if (this.terminationReason)
2922
+ return;
2923
+ this.terminationReason = reason;
2924
+ // [[EXT-159]] — the debug log carried the wrapped error string and never the classification.
2925
+ // Written at the decision so it survives a session whose surface never got to ask, and so a
2926
+ // dump taken after a kill still holds it (the ring buffer behind `debugLog` is always on).
2927
+ debugLog(terminationLogLine(reason));
2928
+ }
2929
+ catch {
2930
+ /* fail-soft */
2931
+ }
2932
+ }
2933
+ /**
2934
+ * [[EXT-159]] — classify a thrown value at a runner site: record it here AND attach it to the
2935
+ * error, then hand the error back so the throw reads as one expression.
2936
+ *
2937
+ * Both carriers matter. The runner's own field serves a caller holding the runner; the attached
2938
+ * value serves every layer above that only ever sees the error — and neither is the message, so
2939
+ * no user-facing string is the only carrier of the classification.
2940
+ */
2941
+ classifyThrownAt(site, error) {
2942
+ // A reason already on the error was attached by an INNER site that saw the failure first, and
2943
+ // that one is the truer classification — so it is inherited rather than replaced, and the two
2944
+ // carriers cannot end up disagreeing about the same failure.
2945
+ const existing = terminationReasonOf(error);
2946
+ const reason = existing ?? terminationReason(site, 'exception', classifyThrownTermination(error));
2947
+ this.noteTermination(reason);
2948
+ if (!existing)
2949
+ attachTerminationReason(error, reason);
2950
+ return reason;
2951
+ }
2952
+ /**
2953
+ * [[EXT-159]] — classify an approvals stop, which the generic classifier cannot see.
2954
+ *
2955
+ * The gate's errors are typed by their own subclass names, and their prose is the explanation
2956
+ * rather than a diagnosis, so nothing in the exception classifier's grammar recognises one. It
2957
+ * does not need to: this site reaches an `instanceof ApprovalStopError` branch, so it *knows*
2958
+ * what ended the run, and stating the category is more honest than pattern-matching for it.
2959
+ */
2960
+ noteApprovalStop(site, error) {
2961
+ const reason = terminationReason(site, 'control', {
2962
+ category: 'approval_stop',
2963
+ detail: error instanceof Error ? error.name : undefined,
2964
+ });
2965
+ this.noteTermination(reason);
2966
+ attachTerminationReason(error, reason);
2967
+ }
2968
+ /**
2969
+ * [[EXT-159]] — record that the turn ended because the model finished.
2970
+ *
2971
+ * An ordinary completion is a termination too, and recording it is what makes "no reason" mean
2972
+ * *a site nobody classified* rather than *nothing went wrong*. First-write-wins keeps a deeper
2973
+ * site's answer — a refusal or a truncation is what really ended a turn that also returned text.
2974
+ */
2975
+ noteCompleted(site) {
2976
+ this.noteTermination(terminationReason(site, 'control', 'completed'));
2977
+ }
2978
+ /**
2979
+ * [[EXT-159]] — why the just-finished turn ended, or `null` when nothing classified it.
2980
+ *
2981
+ * The agent's answer wins when it has one: its sites (the metadata reader, the cancellation and
2982
+ * suspend paths, the run-ending middlewares) sit INSIDE the runner's catches, so the innermost
2983
+ * classification is the true one. Never throws.
2984
+ */
2985
+ getTerminationReason() {
2986
+ return this.captureAgentTerminationReason() ?? this.terminationReason;
2987
+ }
2988
+ /** [[EXT-159]] — read the live agent's reason into the snapshot (fail-soft). */
2989
+ captureAgentTerminationReason() {
2990
+ try {
2991
+ const reason = this.agent?.getTerminationReason?.();
2992
+ if (reason)
2993
+ this.agentTerminationReason = reason;
2994
+ }
2995
+ catch {
2996
+ /* fail-soft */
2997
+ }
2998
+ return this.agentTerminationReason;
2999
+ }
3000
+ /**
3001
+ * [[EXT-159]] — what the provider said about why each model message stopped, this turn.
3002
+ *
3003
+ * Reads live from the agent while one is present and falls back to the {@link cleanup} snapshot
3004
+ * afterwards, the same way {@link getRunStats} does — `/debug-dump` on the readline surface and
3005
+ * the non-interactive verbs both ask once the agent has been dropped.
3006
+ */
3007
+ getFinishReasonObservations() {
3008
+ return this.captureFinishReasonObservations();
3009
+ }
3010
+ /** [[EXT-159]] — read the live agent's finish-reason observations into the snapshot (fail-soft). */
3011
+ captureFinishReasonObservations() {
3012
+ try {
3013
+ const observed = this.agent?.getFinishReasonObservations?.();
3014
+ if (observed)
3015
+ this.agentFinishReasons = observed;
3016
+ }
3017
+ catch {
3018
+ /* fail-soft */
3019
+ }
3020
+ return this.agentFinishReasons;
3021
+ }
2722
3022
  /** GS2-16 — read the live agent's run stats (fail-soft; empty tally if unavailable). */
2723
3023
  captureRunStats() {
2724
3024
  try {
@@ -2770,6 +3070,12 @@ export class GthAgentRunner {
2770
3070
  // GS2-16: snapshot the agent's run stats BEFORE nulling it, so a post-cleanup reader
2771
3071
  // (runSingleShot records history after calling cleanup) still gets this turn's analytics.
2772
3072
  this.lastRunStats = this.captureRunStats();
3073
+ // [[EXT-159]] — and the agent's termination reason with it, for the same reason: the
3074
+ // single-shot path asks why the run ended after the agent has already been nulled.
3075
+ this.captureAgentTerminationReason();
3076
+ // [[EXT-159]] — likewise the provider's per-message finish reasons, which `/debug-dump` and the
3077
+ // non-interactive verbs read post-cleanup.
3078
+ this.captureFinishReasonObservations();
2773
3079
  if (this.agent && 'cleanup' in this.agent && typeof this.agent.cleanup === 'function') {
2774
3080
  await this.agent.cleanup();
2775
3081
  }