@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
@@ -591,7 +591,7 @@ export declare function isToolGatedAtRung(options: {
591
591
  * summary (§4.4) are both built from this, so neither can tell the model a tool is free while the
592
592
  * gate escalates it.
593
593
  *
594
- * **It is NOT what the backends wire into the interrupt.** That is
594
+ * **It is NOT what the agent wires into the interrupt.** That is
595
595
  * {@link resolveInterruptToolNames}, and the two are different sets on purpose: the interrupt is
596
596
  * installed once, at agent init, while `/approvals <rung>` moves the rung underneath it for the rest
597
597
  * of the session. A set that carried the rung would be frozen at the rung the session started on —
@@ -626,7 +626,7 @@ export declare function resolveGatedToolNames(options: {
626
626
  */
627
627
  export declare function commandAnswersApprovals(command: GthCommand | undefined): boolean;
628
628
  /**
629
- * **The interrupt set: which tool names the backends wire into the approval interrupt.** Every bound
629
+ * **The interrupt set: which tool names the agent wires into the approval interrupt.** Every bound
630
630
  * tool that ANY rung could gate — the union of {@link resolveGatedToolNames} over
631
631
  * {@link APPROVAL_RUNGS}, which in practice is the shell plus every bound tool that is not a
632
632
  * built-in READ tool.
@@ -660,7 +660,7 @@ export function isToolGatedAtRung(options) {
660
660
  * summary (§4.4) are both built from this, so neither can tell the model a tool is free while the
661
661
  * gate escalates it.
662
662
  *
663
- * **It is NOT what the backends wire into the interrupt.** That is
663
+ * **It is NOT what the agent wires into the interrupt.** That is
664
664
  * {@link resolveInterruptToolNames}, and the two are different sets on purpose: the interrupt is
665
665
  * installed once, at agent init, while `/approvals <rung>` moves the rung underneath it for the rest
666
666
  * of the session. A set that carried the rung would be frozen at the rung the session started on —
@@ -695,7 +695,7 @@ export function resolveGatedToolNames(options) {
695
695
  * unanswered. Measured, not inferred.
696
696
  *
697
697
  * So a command that answers nothing must be handed **no approval interrupt beyond what the shell
698
- * gate itself requires** — see the `interruptTools` wiring in both backends. In particular it must
698
+ * gate itself requires** — see the `interruptTools` wiring in the agent. In particular it must
699
699
  * NOT be handed the LIVE set for its configured rung: that set is non-empty at `manual` and
700
700
  * `write`, so it carries exactly the same trap, and `commands.api.approvals` (plus a root-level
701
701
  * `approvals`, which applies to every command) puts an ordinary config on those rungs.
@@ -735,7 +735,7 @@ export function commandAnswersApprovals(command) {
735
735
  return command === undefined ? true : (COMMAND_ANSWERS_APPROVALS[command] ?? true);
736
736
  }
737
737
  /**
738
- * **The interrupt set: which tool names the backends wire into the approval interrupt.** Every bound
738
+ * **The interrupt set: which tool names the agent wires into the approval interrupt.** Every bound
739
739
  * tool that ANY rung could gate — the union of {@link resolveGatedToolNames} over
740
740
  * {@link APPROVAL_RUNGS}, which in practice is the shell plus every bound tool that is not a
741
741
  * built-in READ tool.
@@ -17,7 +17,7 @@
17
17
  * 2. **The suffix is derived from the rung in force AND from the set of tools that rung actually
18
18
  * gates.** "A description that disagrees with what the gate will actually do is worse than no
19
19
  * description at all" (§4.5), so {@link isGrantedAtRung} takes the LIVE gated set as a parameter
20
- * and both backends pass the SAME `resolveGatedToolNames` result. The two can therefore not
20
+ * and the agent passes the SAME `resolveGatedToolNames` result. The two can therefore not
21
21
  * drift: widening what a rung gates ([[EXT-30]]) widens the descriptions in the same breath.
22
22
  * That set is narrower than the rung-independent one wired into the interrupt
23
23
  * (`resolveInterruptToolNames`), because a call the live rung does not gate is approved on
@@ -25,7 +25,7 @@
25
25
  *
26
26
  * **Scope, per §4.3.** The *rater* covers the shell only: every other gated tool goes to the human
27
27
  * without a rating call until [[EXT-30]] widens the rater. The *gate* is wider than the rater. At
28
- * the two deterministic rungs (`manual`, `write`) both backends gate every bound tool the rung's
28
+ * the two deterministic rungs (`manual`, `write`) the agent gates every bound tool the rung's
29
29
  * access class does not auto-grant — the write built-ins, the shell, MCP tools (whatever their
30
30
  * `readOnlyHint` says) and custom tools — so at those rungs a non-granted call always reaches the
31
31
  * human. At `assisted`, `auto` and `bypass` the gated set is the shell alone.
@@ -132,7 +132,7 @@ export declare function getRungToolDescriptionSuffix(rung: ApprovalRung): string
132
132
  *
133
133
  * **This is the one implementation of that rule.** {@link isGrantedAtRung} decides a grant with it,
134
134
  * and `config/shell-policy.ts`'s `isToolGatedAtRung` decides gated-set membership with it — which
135
- * is in turn what the backends' interrupt set and `GthAgentRunner`'s own live-rung check are built
135
+ * is in turn what the agent's interrupt set and `GthAgentRunner`'s own live-rung check are built
136
136
  * from. So the gate cannot escalate a call the descriptions and the rater's granted list call free
137
137
  * — the drift §4.5 names as worse than having no description at all. Two derivations of one rule is
138
138
  * exactly what this function exists to prevent; do not inline it back into either caller.
@@ -150,7 +150,7 @@ export declare function isAccessClassGrantedAtRung(toolName: string, rung: Appro
150
150
  * `resolveGatedToolNames` for the rung in force. **This is the parameter that keeps the
151
151
  * descriptions honest**: a tool the rung does not gate cannot require approval, whatever a rung's
152
152
  * table row says about tool classes, so it is granted. It is deliberately NOT the wider,
153
- * rung-independent set the backends wire into `interruptOn` (`resolveInterruptToolNames`): a call
153
+ * rung-independent set the agent wires into `interruptOn` (`resolveInterruptToolNames`): a call
154
154
  * the live rung does not gate is auto-approved the moment it arrives at the runner, so describing
155
155
  * it as needing approval would be a promise nothing keeps.
156
156
  *
@@ -182,7 +182,7 @@ export interface DescribableTool {
182
182
  * that is NOT auto-approved at that rung, and leave every granted tool's description untouched.
183
183
  *
184
184
  * Mutates in place (and returns the same array): the tools are about to be handed to
185
- * `createAgent`/`createDeepAgent`, and the array is freshly resolved per init. Idempotent — any
185
+ * `createAgent`, and the array is freshly resolved per init. Idempotent — any
186
186
  * previously-appended suffix is stripped first, so re-registration at a different rung replaces
187
187
  * the sentence rather than stacking one.
188
188
  *
@@ -142,7 +142,7 @@ export function getRungToolDescriptionSuffix(rung) {
142
142
  *
143
143
  * **This is the one implementation of that rule.** {@link isGrantedAtRung} decides a grant with it,
144
144
  * and `config/shell-policy.ts`'s `isToolGatedAtRung` decides gated-set membership with it — which
145
- * is in turn what the backends' interrupt set and `GthAgentRunner`'s own live-rung check are built
145
+ * is in turn what the agent's interrupt set and `GthAgentRunner`'s own live-rung check are built
146
146
  * from. So the gate cannot escalate a call the descriptions and the rater's granted list call free
147
147
  * — the drift §4.5 names as worse than having no description at all. Two derivations of one rule is
148
148
  * exactly what this function exists to prevent; do not inline it back into either caller.
@@ -167,7 +167,7 @@ export function isAccessClassGrantedAtRung(toolName, rung) {
167
167
  * `resolveGatedToolNames` for the rung in force. **This is the parameter that keeps the
168
168
  * descriptions honest**: a tool the rung does not gate cannot require approval, whatever a rung's
169
169
  * table row says about tool classes, so it is granted. It is deliberately NOT the wider,
170
- * rung-independent set the backends wire into `interruptOn` (`resolveInterruptToolNames`): a call
170
+ * rung-independent set the agent wires into `interruptOn` (`resolveInterruptToolNames`): a call
171
171
  * the live rung does not gate is auto-approved the moment it arrives at the runner, so describing
172
172
  * it as needing approval would be a promise nothing keeps.
173
173
  *
@@ -214,7 +214,7 @@ export function stripRungToolDescriptionSuffix(description) {
214
214
  * that is NOT auto-approved at that rung, and leave every granted tool's description untouched.
215
215
  *
216
216
  * Mutates in place (and returns the same array): the tools are about to be handed to
217
- * `createAgent`/`createDeepAgent`, and the array is freshly resolved per init. Idempotent — any
217
+ * `createAgent`, and the array is freshly resolved per init. Idempotent — any
218
218
  * previously-appended suffix is stripped first, so re-registration at a different rung replaces
219
219
  * the sentence rather than stacking one.
220
220
  *
@@ -9,6 +9,7 @@ import { BaseToolkit, StructuredToolInterface } from '@langchain/core/tools';
9
9
  import { IterableReadableStream } from '@langchain/core/utils/stream';
10
10
  import { BaseCheckpointSaver } from '@langchain/langgraph';
11
11
  import { type RefusalInfo } from '#src/core/refusal.js';
12
+ import { type GthFinishReasonObservation, type GthTerminationReason, type GthTerminationSite } from '#src/core/terminationReason.js';
12
13
  /**
13
14
  * Shared, graph-agnostic agent plumbing.
14
15
  *
@@ -70,6 +71,19 @@ export declare abstract class GthAbstractAgent implements GthAgentInterface {
70
71
  * fully fail-soft (accumulation is guarded and never throws into a run).
71
72
  */
72
73
  private runStatsAcc;
74
+ /**
75
+ * [[EXT-159]] — why the current turn ended, set by whichever site inside this agent ended it.
76
+ * Reset at each turn boundary by {@link resetTerminationReason}, read by `GthAgentRunner`.
77
+ */
78
+ private terminationReason;
79
+ /**
80
+ * [[EXT-159]] — what the provider said about why each finished model message stopped, this turn.
81
+ *
82
+ * Reset with {@link terminationReason} at each turn boundary and read by `GthAgentRunner` for the
83
+ * debug dump. Bounded, because a long agentic turn produces one entry per model round and this
84
+ * has to be safe to keep for the whole session.
85
+ */
86
+ private finishReasonObservations;
73
87
  /**
74
88
  * EXT-58 — the names of the tools registered with the graph at the last {@link init}, recorded by
75
89
  * {@link registerApprovalsAwareTools}. Read by `GthAgentRunner` to build the rater's
@@ -132,7 +146,7 @@ export declare abstract class GthAbstractAgent implements GthAgentInterface {
132
146
  */
133
147
  protected compactHeaderStatus(): void;
134
148
  /**
135
- * EXT-58 (spec §4.5) — the ONE tool-registration hook both backends call with their final tool
149
+ * EXT-58 (spec §4.5) — the ONE tool-registration hook every backend calls with its final tool
136
150
  * array, just before handing it to the graph builder. It does two things:
137
151
  *
138
152
  * 1. Appends the rung's approval sentence to every tool that is **not** auto-approved at that
@@ -189,6 +203,51 @@ export declare abstract class GthAbstractAgent implements GthAgentInterface {
189
203
  getRunStats(): GthRunStats;
190
204
  /** GS2-16 — fold one message (or chunk) into the run tally. Fully guarded (fail-soft). */
191
205
  protected recordRunStats(message: unknown): void;
206
+ /**
207
+ * [[EXT-159]] — forget the previous turn's termination reason. Called by `GthAgentRunner` at each
208
+ * turn boundary, for the same reason {@link resetRunStats} is.
209
+ */
210
+ resetTerminationReason(): void;
211
+ /** [[EXT-159]] — why this turn ended, or `null` when no site inside this agent classified it. */
212
+ getTerminationReason(): GthTerminationReason | null;
213
+ /**
214
+ * [[EXT-159]] — what the provider said about why each model message stopped, this turn.
215
+ *
216
+ * A copy, so a reader (the debug dump) cannot mutate the live record. An EMPTY list is itself
217
+ * meaningful and is not the same as a list of absences: it says no finished model message was
218
+ * observed at all, where an entry with a `null` token says one was observed and the provider said
219
+ * nothing.
220
+ */
221
+ getFinishReasonObservations(): readonly GthFinishReasonObservation[];
222
+ /**
223
+ * [[EXT-159]] — record the provider's stop/finish reason for one finished model message, and
224
+ * write it to the debug log.
225
+ *
226
+ * Called on EVERY finished message rather than only on the interesting ones, because the fact
227
+ * this exists to capture is as much "the provider said nothing" as "the provider said `length`".
228
+ * The debug log is where a maintainer looks first and it carried none of this; the ring buffer
229
+ * behind `debugLog` is populated whether or not on-disk debug logging was ever switched on, so
230
+ * the observation reaches `/debug-dump` from a default install too.
231
+ *
232
+ * Fail-soft, and bounded: a long agentic turn logs one entry per model round.
233
+ */
234
+ protected noteFinishReason(path: GthFinishReasonObservation['path'], message: unknown): void;
235
+ /**
236
+ * [[EXT-159]] — record why the turn ended, **first-write-wins**.
237
+ *
238
+ * The sites that classify are nested: the metadata reader and the cancellation paths sit inside
239
+ * the stream, which sits inside the runner's catches. Last-write-wins would let each outer layer
240
+ * overwrite the truer inner classification with its own coarser one — which is exactly the funnel
241
+ * this taxonomy replaces, rebuilt one level up. Fail-soft: classification never throws into a run.
242
+ */
243
+ protected noteTermination(reason: GthTerminationReason): void;
244
+ /**
245
+ * [[EXT-159]] — run the metadata feeder over a finished message and record what it found.
246
+ *
247
+ * The metadata reader is the half of the taxonomy that only works here, at the layer where a
248
+ * message's `response_metadata` is visible. Returns whether anything was classified.
249
+ */
250
+ protected noteStopMetadata(site: GthTerminationSite, message: unknown): boolean;
192
251
  /**
193
252
  * GS2-16 — best-effort count of messages already in the checkpointed thread state, used by
194
253
  * {@link invoke} as the baseline so it harvests only THIS turn's new messages rather than the
@@ -253,6 +312,13 @@ export declare abstract class GthAbstractAgent implements GthAgentInterface {
253
312
  * the client sends in `forwardedProps.command.resume`.
254
313
  */
255
314
  streamWithEventsResume(resumeValue: unknown, runConfig: RunnableConfig, queuedMessages?: BaseMessage[], signal?: AbortSignal): AsyncGenerator<AgentStreamEvent>;
315
+ /**
316
+ * [[EXT-159]] — which of the two clean endings a typed-event stream just took.
317
+ *
318
+ * Shared by {@link streamWithEvents} and {@link streamWithEventsResume} so the pair cannot drift:
319
+ * they catch the same union and owe the same distinction.
320
+ */
321
+ private classifyEventStreamEnd;
256
322
  /**
257
323
  * Inspect the checkpointed state for the thread and return the tool calls currently pending
258
324
  * human approval (empty array when the run finished normally). A LangGraph
@@ -12,10 +12,22 @@ import { AIMessage, AIMessageChunk, ToolMessage } from '@langchain/core/messages
12
12
  import { IterableReadableStream } from '@langchain/core/utils/stream';
13
13
  import { interrupt, Command, GraphInterrupt } from '@langchain/langgraph';
14
14
  import { extractInlineBinaryBlocks, materializeBinaryOutputs, renderAssistantContent, } from '#src/utils/binaryOutputUtils.js';
15
- import { detectRefusal, buildRefusalMessage } from '#src/core/refusal.js';
15
+ import { classifyRefusal, detectRefusal, detectStopMetadata, buildRefusalMessage, readStopReasonToken, } from '#src/core/refusal.js';
16
+ import { terminationReason, } from '#src/core/terminationReason.js';
17
+ import { terminationLogLine } from '#src/core/terminationNotice.js';
16
18
  import { answerTextOf, segmentAssistantContent, stripReasoningBlocks, } from '#src/core/reasoningBlocks.js';
17
19
  const THINK_OPEN = '<think>';
18
20
  const THINK_CLOSE = '</think>';
21
+ /**
22
+ * [[EXT-159]] — how many per-message `finish_reason` observations one turn keeps.
23
+ *
24
+ * An agentic turn produces one per model round and the list is held for the life of the turn, so it
25
+ * is bounded rather than trusted to stay small. The cap drops the TAIL rather than the head: the
26
+ * observations that explain how a turn ENDED are the last ones, but a turn that has already made a
27
+ * thousand model rounds has a different problem, and keeping the head keeps the record of how it
28
+ * got there. Every observation reaches the debug log either way, so nothing is lost outright.
29
+ */
30
+ const FINISH_REASON_OBSERVATION_MAX = 1000;
19
31
  /**
20
32
  * TUI-C22 — length of the longest suffix of `s` that is a *proper* (shorter-than-full) prefix of
21
33
  * `tag`. Used by {@link createThinkTagSplitter} to hold back a trailing partial that might complete
@@ -259,6 +271,19 @@ export class GthAbstractAgent {
259
271
  * fully fail-soft (accumulation is guarded and never throws into a run).
260
272
  */
261
273
  runStatsAcc = createRunStatsAccumulator();
274
+ /**
275
+ * [[EXT-159]] — why the current turn ended, set by whichever site inside this agent ended it.
276
+ * Reset at each turn boundary by {@link resetTerminationReason}, read by `GthAgentRunner`.
277
+ */
278
+ terminationReason = null;
279
+ /**
280
+ * [[EXT-159]] — what the provider said about why each finished model message stopped, this turn.
281
+ *
282
+ * Reset with {@link terminationReason} at each turn boundary and read by `GthAgentRunner` for the
283
+ * debug dump. Bounded, because a long agentic turn produces one entry per model round and this
284
+ * has to be safe to keep for the whole session.
285
+ */
286
+ finishReasonObservations = [];
262
287
  /**
263
288
  * EXT-58 — the names of the tools registered with the graph at the last {@link init}, recorded by
264
289
  * {@link registerApprovalsAwareTools}. Read by `GthAgentRunner` to build the rater's
@@ -340,7 +365,7 @@ export class GthAbstractAgent {
340
365
  this.statusUpdate(StatusLevel.INFO, runHeaderLine(command, label));
341
366
  }
342
367
  /**
343
- * EXT-58 (spec §4.5) — the ONE tool-registration hook both backends call with their final tool
368
+ * EXT-58 (spec §4.5) — the ONE tool-registration hook every backend calls with its final tool
344
369
  * array, just before handing it to the graph builder. It does two things:
345
370
  *
346
371
  * 1. Appends the rung's approval sentence to every tool that is **not** auto-approved at that
@@ -416,6 +441,95 @@ export class GthAbstractAgent {
416
441
  recordRunStats(message) {
417
442
  accumulateMessage(this.runStatsAcc, message);
418
443
  }
444
+ /**
445
+ * [[EXT-159]] — forget the previous turn's termination reason. Called by `GthAgentRunner` at each
446
+ * turn boundary, for the same reason {@link resetRunStats} is.
447
+ */
448
+ resetTerminationReason() {
449
+ this.terminationReason = null;
450
+ this.finishReasonObservations = [];
451
+ }
452
+ /** [[EXT-159]] — why this turn ended, or `null` when no site inside this agent classified it. */
453
+ getTerminationReason() {
454
+ return this.terminationReason;
455
+ }
456
+ /**
457
+ * [[EXT-159]] — what the provider said about why each model message stopped, this turn.
458
+ *
459
+ * A copy, so a reader (the debug dump) cannot mutate the live record. An EMPTY list is itself
460
+ * meaningful and is not the same as a list of absences: it says no finished model message was
461
+ * observed at all, where an entry with a `null` token says one was observed and the provider said
462
+ * nothing.
463
+ */
464
+ getFinishReasonObservations() {
465
+ return [...this.finishReasonObservations];
466
+ }
467
+ /**
468
+ * [[EXT-159]] — record the provider's stop/finish reason for one finished model message, and
469
+ * write it to the debug log.
470
+ *
471
+ * Called on EVERY finished message rather than only on the interesting ones, because the fact
472
+ * this exists to capture is as much "the provider said nothing" as "the provider said `length`".
473
+ * The debug log is where a maintainer looks first and it carried none of this; the ring buffer
474
+ * behind `debugLog` is populated whether or not on-disk debug logging was ever switched on, so
475
+ * the observation reaches `/debug-dump` from a default install too.
476
+ *
477
+ * Fail-soft, and bounded: a long agentic turn logs one entry per model round.
478
+ */
479
+ noteFinishReason(path, message) {
480
+ try {
481
+ // No message means no observation — NOT an observation of absence. A turn whose stream
482
+ // produced nothing to aggregate has no provider statement to be missing, and recording a
483
+ // `null` token for it would make "the provider said nothing about this message" and "there
484
+ // was no message" read the same, which is the conflation this whole node is about.
485
+ if (!message || typeof message !== 'object')
486
+ return;
487
+ const token = readStopReasonToken(message);
488
+ if (this.finishReasonObservations.length < FINISH_REASON_OBSERVATION_MAX) {
489
+ this.finishReasonObservations.push({ at: new Date().toISOString(), path, token });
490
+ }
491
+ debugLog(`EXT-159 finish_reason path=${path} token=${token ?? '<absent>'}`);
492
+ }
493
+ catch {
494
+ /* fail-soft: observing why a message stopped must never be what stops a run */
495
+ }
496
+ }
497
+ /**
498
+ * [[EXT-159]] — record why the turn ended, **first-write-wins**.
499
+ *
500
+ * The sites that classify are nested: the metadata reader and the cancellation paths sit inside
501
+ * the stream, which sits inside the runner's catches. Last-write-wins would let each outer layer
502
+ * overwrite the truer inner classification with its own coarser one — which is exactly the funnel
503
+ * this taxonomy replaces, rebuilt one level up. Fail-soft: classification never throws into a run.
504
+ */
505
+ noteTermination(reason) {
506
+ try {
507
+ if (this.terminationReason)
508
+ return;
509
+ this.terminationReason = reason;
510
+ // [[EXT-159]] — written where the reason is DECIDED, not where a surface later reads it. The
511
+ // terminating error reached only `transcript.json` and never the debug log, so the artifact a
512
+ // maintainer opens first could not say why a run ended; logging at the decision means the
513
+ // line exists even for a run whose surface never got to ask.
514
+ debugLog(terminationLogLine(reason));
515
+ }
516
+ catch {
517
+ /* fail-soft: recording why a run ended must never be what ends it */
518
+ }
519
+ }
520
+ /**
521
+ * [[EXT-159]] — run the metadata feeder over a finished message and record what it found.
522
+ *
523
+ * The metadata reader is the half of the taxonomy that only works here, at the layer where a
524
+ * message's `response_metadata` is visible. Returns whether anything was classified.
525
+ */
526
+ noteStopMetadata(site, message) {
527
+ const stop = detectStopMetadata(message);
528
+ if (!stop)
529
+ return false;
530
+ this.noteTermination(terminationReason(site, 'metadata', stop));
531
+ return true;
532
+ }
419
533
  /**
420
534
  * GS2-16 — best-effort count of messages already in the checkpointed thread state, used by
421
535
  * {@link invoke} as the baseline so it harvests only THIS turn's new messages rather than the
@@ -498,6 +612,15 @@ export class GthAbstractAgent {
498
612
  // (if declined) response as "Failed to get answer". A fallback-model attempt would hang
499
613
  // here (see the extension point in GthAgentRunner.processMessages), but no runtime
500
614
  // fallback-model config exists today, so we surface terminally.
615
+ // [[EXT-159]] — the metadata feeder, at the one layer that can see `response_metadata`.
616
+ // Records why the turn ended (a refusal, or an answer cut off against the output cap)
617
+ // before the refusal branch below decides what to SAY about it; a truncation says nothing
618
+ // and is classification only.
619
+ // [[EXT-159]] — and record what the provider said about why the message stopped, or that it
620
+ // said nothing. Separate from the classification above: the metadata feeder speaks only for
621
+ // a refusal or a truncation, while this is the raw fact, kept for every ending.
622
+ this.noteFinishReason('invoke', finalMessage);
623
+ this.noteStopMetadata('agent.invoke-stop-metadata', finalMessage);
501
624
  const refusal = detectRefusal(finalMessage);
502
625
  if (refusal) {
503
626
  return this.surfaceRefusal(refusal);
@@ -541,6 +664,13 @@ export class GthAbstractAgent {
541
664
  debugLogError('invoke outer', error);
542
665
  if (error instanceof Error) {
543
666
  if (error?.name === 'ToolException') {
667
+ // [[EXT-159]] — a tool failure that becomes the turn's ANSWER ends the turn as much as a
668
+ // throw does, and it is the one ending whose diagnostic string is returned rather than
669
+ // raised, so nothing further up ever sees an error to classify.
670
+ this.noteTermination(terminationReason('agent.invoke-tool-exception', 'exception', {
671
+ category: 'tool_error',
672
+ detail: error.name,
673
+ }));
544
674
  this.statusUpdate(StatusLevel.ERROR, `Tool execution failed: ${error?.message}`);
545
675
  return `Tool execution failed: ${error?.message}`;
546
676
  }
@@ -590,6 +720,11 @@ export class GthAbstractAgent {
590
720
  // EXT-37: bound so the stream `start()` closure can surface a detected refusal (WARNING +
591
721
  // returns the message to enqueue) without a `this` reference.
592
722
  const surfaceRefusal = (info) => this.surfaceRefusal(info);
723
+ // [[EXT-159]] — bound for the same reason as the two above: the stream `start()` closure's
724
+ // `this` is the stream source, not the agent, and the sites that end this turn live in it.
725
+ const noteTermination = (reason) => this.noteTermination(reason);
726
+ const noteStopMetadata = (site, message) => this.noteStopMetadata(site, message);
727
+ const noteFinishReason = (path, message) => this.noteFinishReason(path, message);
593
728
  // TUI-C30 — compact per-tool-call indication for the plain surface (`name(args…)` + the
594
729
  // canonical 10-line greyed preview when each ToolMessage lands). Per-stream state; emits at
595
730
  // INFO level so the existing consoleLevel gate governs it like the historical tool notices.
@@ -725,14 +860,33 @@ export class GthAbstractAgent {
725
860
  // (so the drained result is non-empty and bypasses the empty-response retry) and print it
726
861
  // once at WARNING level (surfaceRefusal). Any partial content already streamed is kept;
727
862
  // the refusal notice follows it, and its explanation carries any model-provided text.
863
+ // [[EXT-159]] — the provider's own word on why this message stopped, recorded before
864
+ // either branch decides what to make of it, so the raw fact is kept on the refusal path
865
+ // as well as the ordinary one.
866
+ noteFinishReason('stream', aggregatedChunk);
728
867
  if (refusalInfo) {
868
+ // [[EXT-159]] — classified through `classifyRefusal` rather than mapped here, so this
869
+ // site and the metadata feeder cannot come to disagree about what a refusal is.
870
+ noteTermination(terminationReason('agent.stream-stop-metadata', 'metadata', classifyRefusal(refusalInfo)));
729
871
  controller.enqueue(surfaceRefusal(refusalInfo));
730
872
  }
873
+ else if (aggregatedChunk) {
874
+ // No refusal: the same reader still has something to say about an answer that was cut
875
+ // off against the output cap. Classification only — nothing is surfaced.
876
+ noteStopMetadata('agent.stream-stop-metadata', aggregatedChunk);
877
+ }
731
878
  debugLog(`Stream completed. Total chunks: ${totalChunks}`);
732
879
  controller.close();
733
880
  }
734
881
  catch (error) {
735
882
  if (interruptState.escape || (error instanceof Error && error.name === 'AbortError')) {
883
+ // [[EXT-159]] — Esc, or a caller's abort. This turn ends with no error reaching the
884
+ // runner at all (the stream is CLOSED, not errored), so if this site does not classify
885
+ // it nothing downstream can: it is indistinguishable from a turn that simply finished.
886
+ noteTermination(terminationReason('agent.stream-cancelled', 'control', {
887
+ category: 'cancelled',
888
+ detail: interruptState.escape ? 'escape' : 'AbortError',
889
+ }));
736
890
  showInterruptMessage();
737
891
  controller.close();
738
892
  }
@@ -787,6 +941,11 @@ export class GthAbstractAgent {
787
941
  if (e instanceof GraphInterrupt ||
788
942
  e.name === 'GraphInterrupt' ||
789
943
  e.name === 'AbortError') {
944
+ // [[EXT-159]] — two different endings share this branch and must not share a reason. A
945
+ // suspend is not a failure at all (the run is parked on an `interrupt()` and continues on
946
+ // a resume); an abort is the user stopping it. Both return CLEANLY, so this is the last
947
+ // point at which either is knowable.
948
+ this.noteTermination(this.classifyEventStreamEnd('agent.events-ended', e));
790
949
  debugLog('Graph suspended (GraphInterrupt) or aborted by caller');
791
950
  return;
792
951
  }
@@ -827,12 +986,27 @@ export class GthAbstractAgent {
827
986
  if (e instanceof GraphInterrupt ||
828
987
  e.name === 'GraphInterrupt' ||
829
988
  e.name === 'AbortError') {
989
+ // [[EXT-159]] — the resume path's twin of the same two endings; see {@link streamWithEvents}.
990
+ this.noteTermination(this.classifyEventStreamEnd('agent.events-resume-ended', e));
830
991
  debugLog('Graph suspended (GraphInterrupt) or aborted by caller');
831
992
  return;
832
993
  }
833
994
  throw e;
834
995
  }
835
996
  }
997
+ /**
998
+ * [[EXT-159]] — which of the two clean endings a typed-event stream just took.
999
+ *
1000
+ * Shared by {@link streamWithEvents} and {@link streamWithEventsResume} so the pair cannot drift:
1001
+ * they catch the same union and owe the same distinction.
1002
+ */
1003
+ classifyEventStreamEnd(site, error) {
1004
+ const suspended = error instanceof GraphInterrupt || error?.name === 'GraphInterrupt';
1005
+ return terminationReason(site, suspended ? 'control' : 'exception', {
1006
+ category: suspended ? 'suspended' : 'cancelled',
1007
+ detail: suspended ? 'GraphInterrupt' : 'AbortError',
1008
+ });
1009
+ }
836
1010
  /**
837
1011
  * Inspect the checkpointed state for the thread and return the tool calls currently pending
838
1012
  * human approval (empty array when the run finished normally). A LangGraph
@@ -930,6 +1104,23 @@ export class GthAbstractAgent {
930
1104
  let aggregatedAIChunk = null;
931
1105
  let reasoningOpen = false;
932
1106
  const flushed = new Set();
1107
+ /**
1108
+ * [[TUI-C100]] — the calls THIS stream has announced (`tool_start` + `tool_args`) and whose
1109
+ * own result it has not yet seen.
1110
+ *
1111
+ * Kept here rather than derived from {@link aggregatedAIChunk} because the aggregate is nulled
1112
+ * after every round (see the reset below) while the calls it announced can still be waiting —
1113
+ * at the approval gate, or simply behind a sibling in the same message. A set that died with
1114
+ * the aggregate would lose exactly the calls this exists to track.
1115
+ *
1116
+ * **Scoped to one invocation, deliberately.** A call held at the approval gate is announced in
1117
+ * the stream that suspends and resolved in the stream that resumes, so its result lands where
1118
+ * this set is empty — and it therefore never receives a `tool_end` at all. That is the correct
1119
+ * outcome, not a leak: a stream may only speak for what it announced. What closes a call that
1120
+ * produces no result anywhere is the turn-level drain in `GthAgentRunner`'s
1121
+ * `processMessagesWithEvents`, which spans both streams.
1122
+ */
1123
+ const pendingEnds = new Set();
933
1124
  // EXT-41: a content-policy refusal on this typed-event path was de-scoped by EXT-37 (there is
934
1125
  // no empty-response retry here, so no wrong-retry bug), but it still rendered as a SILENT empty
935
1126
  // turn. Capture it (first per-chunk signal wins; aggregate fallback at stream end) and surface
@@ -978,6 +1169,18 @@ export class GthAbstractAgent {
978
1169
  }
979
1170
  }
980
1171
  }
1172
+ /**
1173
+ * Announce every call the assistant message carries, once its arguments are complete.
1174
+ *
1175
+ * [[TUI-C100]] — **announcing a call is not ending it.** This runs when the round's FIRST
1176
+ * `ToolMessage` lands, because that is what makes the streamed aggregate complete, so it speaks
1177
+ * for every call in the message at once: the one that just returned, the ones still queued, and
1178
+ * the one suspended at the approval gate with a human being asked about it. `tool_start` and
1179
+ * `tool_args` are true for all of them — the model has finished emitting the arguments — and
1180
+ * that is what puts a call on screen, with the arguments the human is about to rule on.
1181
+ * `tool_end` is not: it waits for the call's own result, and is emitted beside it when that
1182
+ * result reaches THIS stream — which for a gated call it never does. See `pendingEnds` above.
1183
+ */
981
1184
  function* flushAggregated() {
982
1185
  if (!aggregatedAIChunk)
983
1186
  return;
@@ -990,10 +1193,15 @@ export class GthAbstractAgent {
990
1193
  flushed.add(id);
991
1194
  yield { type: 'tool_start', id, name: tc.name };
992
1195
  yield { type: 'tool_args', id, delta: JSON.stringify(tc.args ?? {}) };
993
- yield { type: 'tool_end', id };
1196
+ pendingEnds.add(id);
994
1197
  }
995
1198
  // Surface invalid tool calls too so the client at least sees the raw args
996
1199
  // string the model produced, instead of silently dropping them.
1200
+ //
1201
+ // These END INLINE, unlike the calls above, and the asymmetry is the point: an invalid call
1202
+ // is never dispatched, so there is no result to wait for and nothing that could later change
1203
+ // what it says. Deferring it would leave a row that cannot progress sitting at `running`
1204
+ // until the turn ended, in exchange for nothing.
997
1205
  for (const tc of invalidToolCalls) {
998
1206
  const id = tc.id;
999
1207
  if (!id || flushed.has(id))
@@ -1090,6 +1298,20 @@ export class GthAbstractAgent {
1090
1298
  // `isError` still says the call did not run, which is what the model, `gth eval`'s
1091
1299
  // tool-result assertions and the ACP bridge all read.
1092
1300
  const toolCallId = chunk.tool_call_id;
1301
+ // [[TUI-C100]] — the call's own result is here, so NOW it has ended.
1302
+ //
1303
+ // **The guard is the stream's own honesty, not a favour to a downstream reader.** This
1304
+ // generator may only close a call it opened, and the id it is holding a result for is
1305
+ // routinely one it never announced: a gated call is announced in the stream that suspends
1306
+ // and returns in the stream that resumes, where this set is empty. So the common gated flow
1307
+ // is `tool_start`, `tool_args`, then a `tool_result` in a later stream with **no `tool_end`
1308
+ // between them, and none at all** — do not read the line below as a promise that an end
1309
+ // always precedes a result. Every consumer treats `tool_result` as terminal on its own, and
1310
+ // the AG-UI bridge additionally closes its own framing from what it actually emitted rather
1311
+ // than trusting this event to arrive.
1312
+ if (pendingEnds.delete(toolCallId)) {
1313
+ yield { type: 'tool_end', id: toolCallId };
1314
+ }
1093
1315
  yield {
1094
1316
  type: 'tool_result',
1095
1317
  id: toolCallId,
@@ -1106,7 +1328,19 @@ export class GthAbstractAgent {
1106
1328
  if (reasoningOpen) {
1107
1329
  yield { type: 'reasoning_end' };
1108
1330
  }
1109
- // Flush any tool calls not followed by a ToolMessage (e.g. terminal tool calls).
1331
+ // Announce any tool calls not followed by a ToolMessage (e.g. terminal tool calls), so a call
1332
+ // the model asked for is never silently absent from the stream.
1333
+ //
1334
+ // [[TUI-C100]] — and deliberately NO `tool_end` sweep of {@link pendingEnds} here, which is
1335
+ // the tempting shape and the wrong one. **A stream that ends with calls outstanding is what
1336
+ // suspending at the approval gate LOOKS like**: the graph interrupts, this generator returns,
1337
+ // and `GthAgentRunner.processMessagesWithEvents` only then opens the prompt
1338
+ // (`resolveToolInterruptsWithEvents`). Ending them here would put the tick and the word `done`
1339
+ // back on the row a moment before the human is asked about it — the defect, moved rather than
1340
+ // fixed. A call held at the gate and a genuinely terminal one are indistinguishable from
1341
+ // inside the stream, and the turn's end is the first point at which the human has been asked
1342
+ // and the answer acted on — so a call still without a result THERE is one that will never have
1343
+ // one, and the runner closes it as exactly that.
1110
1344
  yield* flushAggregated();
1111
1345
  if (this.config?.writeBinaryOutputsToFile && binaryBlocks.length > 0) {
1112
1346
  const processedContent = materializeBinaryOutputs(binaryBlocks.map((block) => ({
@@ -1128,10 +1362,20 @@ export class GthAbstractAgent {
1128
1362
  if (!refusalInfo && aggregatedAIChunk) {
1129
1363
  refusalInfo = detectRefusal(aggregatedAIChunk);
1130
1364
  }
1365
+ // [[EXT-159]] — the typed-event path's raw provider statement, on both branches. This is the
1366
+ // surface most users watch, and it recorded no `finish_reason` anywhere at all.
1367
+ this.noteFinishReason('events', aggregatedAIChunk);
1131
1368
  if (refusalInfo) {
1369
+ // [[EXT-159]] — the typed-event path's metadata site; classified through the same
1370
+ // `classifyRefusal` the other two use.
1371
+ this.noteTermination(terminationReason('agent.events-stop-metadata', 'metadata', classifyRefusal(refusalInfo)));
1132
1372
  debugLog(`Content-policy refusal detected on typed-event path (provider=${refusalInfo.provider} reason=${refusalInfo.reason})`);
1133
1373
  yield { type: 'text', delta: buildRefusalMessage(refusalInfo) };
1134
1374
  }
1375
+ else if (aggregatedAIChunk) {
1376
+ // No refusal, but the same reader still sees an answer cut off against the output cap.
1377
+ this.noteStopMetadata('agent.events-stop-metadata', aggregatedAIChunk);
1378
+ }
1135
1379
  }
1136
1380
  async cleanup() {
1137
1381
  debugLog('Cleaning up agent...');