@gaunt-sloth/core 2.0.0-beta.3 → 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.
@@ -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,6 +209,25 @@ 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>`.
@@ -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
@@ -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
  /**
@@ -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
@@ -2663,12 +2726,30 @@ export class GthAgentRunner {
2663
2726
  // error result says, and never with the tick and the word `done`, which no arm of this loop
2664
2727
  // could make true.
2665
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;
2666
2745
  const tracking = async function* (source) {
2667
2746
  for await (const event of source) {
2668
2747
  if (event.type === 'tool_start')
2669
2748
  unendedToolCalls.add(event.id);
2670
2749
  else if (event.type === 'tool_end' || event.type === 'tool_result')
2671
2750
  unendedToolCalls.delete(event.id);
2751
+ else if (event.type === 'text' && event.delta.trim().length > 0)
2752
+ sawAnswerText = true;
2672
2753
  yield event;
2673
2754
  }
2674
2755
  };
@@ -2695,6 +2776,33 @@ export class GthAgentRunner {
2695
2776
  for (const id of unendedToolCalls) {
2696
2777
  yield { type: 'tool_result', id, content: noResult, isError: true };
2697
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;
2698
2806
  }
2699
2807
  finally {
2700
2808
  // [[TUI-C69]] §5.4 — **the turn is over, so the argument is over.** Until this existed the
@@ -2709,6 +2817,12 @@ export class GthAgentRunner {
2709
2817
  // In `finally` because an abort and a thrown stream end the turn just as much as a return
2710
2818
  // does, and those are the paths where rows left standing are least likely to be noticed.
2711
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'));
2712
2826
  }
2713
2827
  }
2714
2828
  /**
@@ -2736,19 +2850,29 @@ export class GthAgentRunner {
2736
2850
  return;
2737
2851
  if (!agent.getPendingToolInterrupts || !agent.streamWithEventsResume)
2738
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;
2739
2858
  // Bound the loop defensively so a misbehaving graph that re-suspends forever cannot spin.
2740
2859
  for (let guard = 0; guard < 100; guard++) {
2741
2860
  if (signal?.aborted)
2742
2861
  return;
2743
2862
  const pending = await agent.getPendingToolInterrupts(runConfig);
2744
- if (pending.length === 0)
2863
+ if (pending.length === 0) {
2864
+ drained = true;
2745
2865
  break;
2866
+ }
2746
2867
  const decisions = [];
2747
2868
  for (const tool of pending) {
2748
2869
  decisions.push(await this.decideToolApproval(tool));
2749
2870
  }
2750
2871
  yield* agent.streamWithEventsResume({ decisions }, runConfig, [], signal);
2751
2872
  }
2873
+ if (!drained) {
2874
+ this.noteTermination(terminationReason('runner.events-interrupt-guard-exhausted', 'control', 'interrupt_drain_guard'));
2875
+ }
2752
2876
  }
2753
2877
  // noinspection JSUnusedGlobalSymbols
2754
2878
  getAgent() {
@@ -2768,6 +2892,133 @@ export class GthAgentRunner {
2768
2892
  /* fail-soft: analytics must never affect a run */
2769
2893
  }
2770
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
+ }
2771
3022
  /** GS2-16 — read the live agent's run stats (fail-soft; empty tally if unavailable). */
2772
3023
  captureRunStats() {
2773
3024
  try {
@@ -2819,6 +3070,12 @@ export class GthAgentRunner {
2819
3070
  // GS2-16: snapshot the agent's run stats BEFORE nulling it, so a post-cleanup reader
2820
3071
  // (runSingleShot records history after calling cleanup) still gets this turn's analytics.
2821
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();
2822
3079
  if (this.agent && 'cleanup' in this.agent && typeof this.agent.cleanup === 'function') {
2823
3080
  await this.agent.cleanup();
2824
3081
  }