@gaunt-sloth/core 2.0.0-beta.1 → 2.0.0-beta.3

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 (117) hide show
  1. package/LICENSE +6 -6
  2. package/dist/config/loader.d.ts +13 -0
  3. package/dist/config/loader.js +55 -11
  4. package/dist/config/loader.js.map +1 -1
  5. package/dist/config/schema.d.ts +10 -0
  6. package/dist/config/schema.js +46 -24
  7. package/dist/config/schema.js.map +1 -1
  8. package/dist/config/shell-policy.d.ts +144 -3
  9. package/dist/config/shell-policy.js +117 -10
  10. package/dist/config/shell-policy.js.map +1 -1
  11. package/dist/config/tool-descriptions.d.ts +5 -5
  12. package/dist/config/tool-descriptions.js +3 -3
  13. package/dist/config/types.d.ts +19 -0
  14. package/dist/config/types.js.map +1 -1
  15. package/dist/constants.d.ts +10 -0
  16. package/dist/constants.js +10 -0
  17. package/dist/constants.js.map +1 -1
  18. package/dist/core/GthAbstractAgent.d.ts +32 -1
  19. package/dist/core/GthAbstractAgent.js +222 -7
  20. package/dist/core/GthAbstractAgent.js.map +1 -1
  21. package/dist/core/GthAgentRunner.d.ts +323 -27
  22. package/dist/core/GthAgentRunner.js +1092 -112
  23. package/dist/core/GthAgentRunner.js.map +1 -1
  24. package/dist/core/GthLangChainAgent.js +1 -1
  25. package/dist/core/GthLangChainAgent.js.map +1 -1
  26. package/dist/core/approvals/approvalRequest.d.ts +247 -0
  27. package/dist/core/approvals/approvalRequest.js +315 -0
  28. package/dist/core/approvals/approvalRequest.js.map +1 -0
  29. package/dist/core/approvals/grants.d.ts +165 -9
  30. package/dist/core/approvals/grants.js +702 -57
  31. package/dist/core/approvals/grants.js.map +1 -1
  32. package/dist/core/plainToolIndication.d.ts +11 -1
  33. package/dist/core/plainToolIndication.js +25 -10
  34. package/dist/core/plainToolIndication.js.map +1 -1
  35. package/dist/core/reasoningBlocks.d.ts +4 -6
  36. package/dist/core/reasoningBlocks.js +4 -6
  37. package/dist/core/reasoningBlocks.js.map +1 -1
  38. package/dist/core/shell/ShellCommandFailedError.d.ts +4 -4
  39. package/dist/core/shell/ShellCommandFailedError.js +4 -4
  40. package/dist/core/shell/abstention.d.ts +1 -1
  41. package/dist/core/shell/abstention.js +65 -11
  42. package/dist/core/shell/abstention.js.map +1 -1
  43. package/dist/core/shell/alignment.d.ts +491 -0
  44. package/dist/core/shell/alignment.js +687 -0
  45. package/dist/core/shell/alignment.js.map +1 -0
  46. package/dist/core/shell/approvalCapture.d.ts +53 -41
  47. package/dist/core/shell/approvalCapture.js +0 -42
  48. package/dist/core/shell/approvalCapture.js.map +1 -1
  49. package/dist/core/shell/approvalStop.d.ts +121 -3
  50. package/dist/core/shell/approvalStop.js +153 -90
  51. package/dist/core/shell/approvalStop.js.map +1 -1
  52. package/dist/core/shell/hardline.d.ts +26 -4
  53. package/dist/core/shell/hardline.js +321 -16
  54. package/dist/core/shell/hardline.js.map +1 -1
  55. package/dist/core/shell/negotiation.d.ts +352 -63
  56. package/dist/core/shell/negotiation.js +295 -123
  57. package/dist/core/shell/negotiation.js.map +1 -1
  58. package/dist/core/shell/openWorld.d.ts +136 -0
  59. package/dist/core/shell/openWorld.js +573 -35
  60. package/dist/core/shell/openWorld.js.map +1 -1
  61. package/dist/core/shell/provenance.d.ts +91 -0
  62. package/dist/core/shell/provenance.js +136 -0
  63. package/dist/core/shell/provenance.js.map +1 -0
  64. package/dist/core/shell/rater.d.ts +394 -163
  65. package/dist/core/shell/rater.js +536 -238
  66. package/dist/core/shell/rater.js.map +1 -1
  67. package/dist/core/shell/raterHealth.d.ts +101 -0
  68. package/dist/core/shell/raterHealth.js +121 -0
  69. package/dist/core/shell/raterHealth.js.map +1 -0
  70. package/dist/core/shell/raterModel.d.ts +17 -8
  71. package/dist/core/shell/raterModel.js +11 -8
  72. package/dist/core/shell/raterModel.js.map +1 -1
  73. package/dist/core/shell/rejection.d.ts +20 -5
  74. package/dist/core/shell/rejection.js +18 -4
  75. package/dist/core/shell/rejection.js.map +1 -1
  76. package/dist/core/toolDisplay.d.ts +91 -2
  77. package/dist/core/toolDisplay.js +168 -19
  78. package/dist/core/toolDisplay.js.map +1 -1
  79. package/dist/core/types.d.ts +140 -22
  80. package/dist/core/types.js.map +1 -1
  81. package/dist/providers/configurationPassthrough.d.ts +36 -6
  82. package/dist/providers/configurationPassthrough.js +40 -28
  83. package/dist/providers/configurationPassthrough.js.map +1 -1
  84. package/dist/providers/geminiSchemaSanitizer.d.ts +2 -2
  85. package/dist/providers/geminiSchemaSanitizer.js +2 -2
  86. package/dist/providers/geminiThinking.d.ts +10 -5
  87. package/dist/providers/geminiThinking.js +10 -5
  88. package/dist/providers/geminiThinking.js.map +1 -1
  89. package/dist/providers/openrouter.js +36 -6
  90. package/dist/providers/openrouter.js.map +1 -1
  91. package/dist/providers/vertexai.js +21 -0
  92. package/dist/providers/vertexai.js.map +1 -1
  93. package/dist/runtime/askStructured.d.ts +22 -0
  94. package/dist/runtime/askStructured.js +53 -0
  95. package/dist/runtime/askStructured.js.map +1 -1
  96. package/dist/utils/aiignoreUtils.d.ts +6 -0
  97. package/dist/utils/aiignoreUtils.js +69 -1
  98. package/dist/utils/aiignoreUtils.js.map +1 -1
  99. package/dist/utils/binaryOutputUtils.js +103 -21
  100. package/dist/utils/binaryOutputUtils.js.map +1 -1
  101. package/dist/utils/displayWidth.d.ts +10 -5
  102. package/dist/utils/displayWidth.js +148 -54
  103. package/dist/utils/displayWidth.js.map +1 -1
  104. package/dist/utils/fileUtils.d.ts +7 -1
  105. package/dist/utils/fileUtils.js +17 -3
  106. package/dist/utils/fileUtils.js.map +1 -1
  107. package/dist/utils/globalConfigUtils.d.ts +5 -2
  108. package/dist/utils/globalConfigUtils.js +14 -3
  109. package/dist/utils/globalConfigUtils.js.map +1 -1
  110. package/dist/utils/llmUtils.d.ts +1 -1
  111. package/dist/utils/llmUtils.js +1 -1
  112. package/dist/utils/systemPromptNotes.d.ts +3 -3
  113. package/dist/utils/systemPromptNotes.js +3 -3
  114. package/dist/utils/vertexaiUtils.js +160 -3
  115. package/dist/utils/vertexaiUtils.js.map +1 -1
  116. package/package.json +6 -5
  117. package/schema/gsloth-config.schema.json +34 -0
@@ -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
  *
@@ -662,6 +662,25 @@ export interface RatingConfig {
662
662
  * @default true
663
663
  */
664
664
  errorOnReviewFail?: boolean;
665
+ /**
666
+ * Wall-clock budget (ms) for the single rating model call.
667
+ *
668
+ * GS2-105 — rating is a SECOND model call fired from `afterAgent` once the review prose is
669
+ * already written, and it renders nothing while it runs. Without a bound, a model that fails to
670
+ * emit the rating tool call generates prose until its context window stops it: measured at 15,385
671
+ * tokens and 485 seconds on `gemma4:12b`, against a healthy rating call of ~50 seconds. The budget
672
+ * turns that silent hang into a bounded, legible failure, after which the existing
673
+ * missing-artifact path reports it and exits non-zero.
674
+ *
675
+ * It is a WALL-CLOCK budget for one call, not a per-token cap, because the per-token knob is not
676
+ * portable: `numPredict` is a constructor field on `ChatOllama` rather than a call option, and
677
+ * that provider declares `tool_choice?: never`, so neither of the obvious source-level remedies is
678
+ * available where this actually bites. `timeout` is a standard `RunnableConfig` field, so it works
679
+ * on every provider and genuinely aborts the request instead of merely abandoning it.
680
+ *
681
+ * @default 120000
682
+ */
683
+ timeoutMs?: number;
665
684
  }
666
685
  /**
667
686
  * Validation checks that can be skipped for custom command parameters.
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/config/types.ts"],"names":[],"mappings":"AAmuBA,MAAM,CAAC,MAAM,uBAAuB,GAAG;IACrC,UAAU;IACV,WAAW;IACX,MAAM;IACN,UAAU;IACV,QAAQ;IACR,cAAc;IACd,KAAK;IACL,YAAY;IACZ,aAAa;IACb,QAAQ;CACA,CAAC"}
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../../src/config/types.ts"],"names":[],"mappings":"AAsvBA,MAAM,CAAC,MAAM,uBAAuB,GAAG;IACrC,UAAU;IACV,WAAW;IACX,MAAM;IACN,UAAU;IACV,QAAQ;IACR,cAAc;IACd,KAAK;IACL,YAAY;IACZ,aAAa;IACb,QAAQ;CACA,CAAC"}
@@ -42,6 +42,16 @@ export declare const CONFIG_SCHEMA_POINTER = "https://gauntsloth.app/schema/v2/g
42
42
  * Lives under `.gsloth/.gsloth-settings/` like other project settings.
43
43
  */
44
44
  export declare const SHELL_ALLOWLIST_FILE = "shell-allowlist.json";
45
+ /**
46
+ * [[EXT-107]]: project-scoped persisted shell deny-list — the escalation menu's *always reject*
47
+ * choice. Its mirror on the refusal side, in the same directory and the same EXT-71 entry grammar,
48
+ * so the two files read identically to anyone who opens them.
49
+ *
50
+ * A separate file rather than a second array in {@link SHELL_ALLOWLIST_FILE}: the two lists have
51
+ * opposite failure modes, and a parse error that took the refusals down with the approvals would
52
+ * fail in the one direction this design never lets anything fail in.
53
+ */
54
+ export declare const SHELL_DENYLIST_FILE = "shell-denylist.json";
45
55
  /**
46
56
  * The `additionalToolNamePrefix` handed to `MultiServerMCPClient` so every MCP tool name is
47
57
  * namespaced. Combined with `prefixToolNameWithServerName`, the adapter emits tool names shaped
package/dist/constants.js CHANGED
@@ -42,6 +42,16 @@ export const CONFIG_SCHEMA_POINTER = 'https://gauntsloth.app/schema/v2/gsloth-co
42
42
  * Lives under `.gsloth/.gsloth-settings/` like other project settings.
43
43
  */
44
44
  export const SHELL_ALLOWLIST_FILE = 'shell-allowlist.json';
45
+ /**
46
+ * [[EXT-107]]: project-scoped persisted shell deny-list — the escalation menu's *always reject*
47
+ * choice. Its mirror on the refusal side, in the same directory and the same EXT-71 entry grammar,
48
+ * so the two files read identically to anyone who opens them.
49
+ *
50
+ * A separate file rather than a second array in {@link SHELL_ALLOWLIST_FILE}: the two lists have
51
+ * opposite failure modes, and a parse error that took the refusals down with the approvals would
52
+ * fail in the one direction this design never lets anything fail in.
53
+ */
54
+ export const SHELL_DENYLIST_FILE = 'shell-denylist.json';
45
55
  /**
46
56
  * The `additionalToolNamePrefix` handed to `MultiServerMCPClient` so every MCP tool name is
47
57
  * namespaced. Combined with `prefixToolNameWithServerName`, the adapter emits tool names shaped
@@ -1 +1 @@
1
- {"version":3,"file":"constants.js","sourceRoot":"","sources":["../src/constants.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,UAAU,GAAG,SAAS,CAAC;AACpC,MAAM,CAAC,MAAM,mBAAmB,GAAG,kBAAkB,CAAC;AACtD,MAAM,CAAC,MAAM,WAAW,GAAG,cAAc,CAAC;AAC1C,MAAM,CAAC,MAAM,sBAAsB,GAAG,mBAAmB,CAAC;AAC1D,MAAM,CAAC,MAAM,wBAAwB,GAAG,qBAAqB,CAAC;AAC9D;;;;GAIG;AACH,MAAM,CAAC,MAAM,yBAAyB,GAAG,sBAAsB,CAAC;AAChE,MAAM,CAAC,MAAM,uBAAuB,GAAG,oBAAoB,CAAC;AAC5D,MAAM,CAAC,MAAM,sBAAsB,GAAG,mBAAmB,CAAC;AAC1D,MAAM,CAAC,MAAM,gBAAgB,GAAG,sBAAsB,CAAC;AACvD,MAAM,CAAC,MAAM,kBAAkB,GAAG,uBAAuB,CAAC;AAC1D,MAAM,CAAC,MAAM,2BAA2B,GAAG,mBAAmB,CAAC;AAC/D,MAAM,CAAC,MAAM,oBAAoB,GAAG,mBAAmB,CAAC;AACxD,MAAM,CAAC,MAAM,kBAAkB,GAAG,iBAAiB,CAAC;AACpD,MAAM,CAAC,MAAM,kBAAkB,GAAG,iBAAiB,CAAC;AACpD,MAAM,CAAC,MAAM,kBAAkB,GAAG,iBAAiB,CAAC;AACpD,MAAM,CAAC,MAAM,aAAa,GAAG,WAAW,CAAC;AACzC;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG,4DAA4D,CAAC;AAClG;;;GAGG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAG,sBAAsB,CAAC;AAE3D;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAG,KAAK,CAAC;AAE1C;;;;;;;;;;GAUG;AACH,MAAM,CAAC,MAAM,6BAA6B,GAAG,aAAa,CAAC;AAC3D,MAAM,CAAC,MAAM,8BAA8B,GAAG,qBAAqB,CAAC"}
1
+ {"version":3,"file":"constants.js","sourceRoot":"","sources":["../src/constants.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,UAAU,GAAG,SAAS,CAAC;AACpC,MAAM,CAAC,MAAM,mBAAmB,GAAG,kBAAkB,CAAC;AACtD,MAAM,CAAC,MAAM,WAAW,GAAG,cAAc,CAAC;AAC1C,MAAM,CAAC,MAAM,sBAAsB,GAAG,mBAAmB,CAAC;AAC1D,MAAM,CAAC,MAAM,wBAAwB,GAAG,qBAAqB,CAAC;AAC9D;;;;GAIG;AACH,MAAM,CAAC,MAAM,yBAAyB,GAAG,sBAAsB,CAAC;AAChE,MAAM,CAAC,MAAM,uBAAuB,GAAG,oBAAoB,CAAC;AAC5D,MAAM,CAAC,MAAM,sBAAsB,GAAG,mBAAmB,CAAC;AAC1D,MAAM,CAAC,MAAM,gBAAgB,GAAG,sBAAsB,CAAC;AACvD,MAAM,CAAC,MAAM,kBAAkB,GAAG,uBAAuB,CAAC;AAC1D,MAAM,CAAC,MAAM,2BAA2B,GAAG,mBAAmB,CAAC;AAC/D,MAAM,CAAC,MAAM,oBAAoB,GAAG,mBAAmB,CAAC;AACxD,MAAM,CAAC,MAAM,kBAAkB,GAAG,iBAAiB,CAAC;AACpD,MAAM,CAAC,MAAM,kBAAkB,GAAG,iBAAiB,CAAC;AACpD,MAAM,CAAC,MAAM,kBAAkB,GAAG,iBAAiB,CAAC;AACpD,MAAM,CAAC,MAAM,aAAa,GAAG,WAAW,CAAC;AACzC;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG,4DAA4D,CAAC;AAClG;;;GAGG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAG,sBAAsB,CAAC;AAE3D;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAG,qBAAqB,CAAC;AAEzD;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAG,KAAK,CAAC;AAE1C;;;;;;;;;;GAUG;AACH,MAAM,CAAC,MAAM,6BAA6B,GAAG,aAAa,CAAC;AAC3D,MAAM,CAAC,MAAM,8BAA8B,GAAG,qBAAqB,CAAC"}
@@ -132,7 +132,7 @@ export declare abstract class GthAbstractAgent implements GthAgentInterface {
132
132
  */
133
133
  protected compactHeaderStatus(): void;
134
134
  /**
135
- * EXT-58 (spec §4.5) — the ONE tool-registration hook both backends call with their final tool
135
+ * EXT-58 (spec §4.5) — the ONE tool-registration hook every backend calls with its final tool
136
136
  * array, just before handing it to the graph builder. It does two things:
137
137
  *
138
138
  * 1. Appends the rung's approval sentence to every tool that is **not** auto-approved at that
@@ -263,6 +263,37 @@ export declare abstract class GthAbstractAgent implements GthAgentInterface {
263
263
  * setup degrades to "no approval needed" instead of breaking the run.
264
264
  */
265
265
  getPendingToolInterrupts(runConfig: RunnableConfig): Promise<PendingToolInterrupt[]>;
266
+ /**
267
+ * [[TUI-C69]] §5.4 — remember that this call was bounced back to the agent as a negotiation
268
+ * round, so both display paths can tone its result row as a clarification request.
269
+ *
270
+ * A plain set rather than a queue: the ids are LangChain tool-call ids, unique per call, and the
271
+ * two display paths read the same one without either consuming it — a session drives one of them,
272
+ * never both. {@link clearRaterClarifications} empties it with the rest of the turn's state, so a
273
+ * long run cannot accumulate.
274
+ */
275
+ noteRaterClarification(toolCallId: string): void;
276
+ /**
277
+ * [[TUI-C69]] §5.4 — **forget the noted ids**, called at the top of every turn and on `/clear`.
278
+ *
279
+ * The set is keyed on tool-call ids, and an id is only meaningful while the result carrying it is
280
+ * being rendered. Holding them past that costs a string per rater rejection for the life of the
281
+ * process — small — and risks the thing that is not small: a LATER call that reuses a noted id
282
+ * renders as a clarification request whatever it actually was, so a successful command inherits
283
+ * the tone of an argument it had no part in.
284
+ *
285
+ * That reuse is not reachable with the providers installed today — `@langchain/ollama` mints
286
+ * `v4()` uuids, `@langchain/google` `lc-tool-call-${v4()}`, and OpenAI and Anthropic supply their
287
+ * own unique ids — but **the protection was the providers', not this code's**, which is a
288
+ * property no test here can hold and no bump has to preserve.
289
+ *
290
+ * On `/clear` it is the same argument `GthAgentRunner.resetThread` makes about the negotiation:
291
+ * state from before the user asked for the conversation to be forgotten must not decide how the
292
+ * conversation after it is drawn.
293
+ */
294
+ clearRaterClarifications(): void;
295
+ /** [[TUI-C69]] — the ids {@link noteRaterClarification} has been told about this turn. */
296
+ protected raterClarifications: Set<string>;
266
297
  protected processEventStream(stream: IterableReadableStream<[BaseMessage, Record<string, unknown>]>): AsyncGenerator<AgentStreamEvent>;
267
298
  cleanup(): Promise<void>;
268
299
  getEffectiveConfig(config: GthConfig, command: GthCommand | undefined): GthConfig;
@@ -118,6 +118,84 @@ function pickReasoningDelta(kwargs) {
118
118
  return direct;
119
119
  return '';
120
120
  }
121
+ /**
122
+ * [[TUI-C69]] §5.4 — **the tool-call ids a suspended graph's pending requests were built from.**
123
+ *
124
+ * LangChain's HITL middleware assembles its `actionRequests` from the last AI message's
125
+ * `tool_calls`, keeping `{ name, args, description }` and dropping the id — so a decision about a
126
+ * pending call has nothing to attribute it to on screen. The ids are still right there: the same
127
+ * `getState` snapshot that carries `tasks[].interrupts` carries `values.messages`, whose last
128
+ * `AIMessage` holds the very calls the middleware filtered. Measured against a real suspended
129
+ * graph rather than reasoned from the library's source, because a middleware hook that ran inside
130
+ * the model node instead of after it would leave this empty.
131
+ *
132
+ * Returns them in message order, for {@link claimToolCallId} to consume. Defensive throughout: an
133
+ * unexpected shape yields an empty list, and every id is then simply absent.
134
+ */
135
+ function pendingToolCallIds(state) {
136
+ const messages = state?.values?.messages;
137
+ if (!Array.isArray(messages))
138
+ return [];
139
+ // The LAST message carrying tool calls is the one the interrupt suspended on. Earlier AI
140
+ // messages in the thread carry calls that already ran, and their ids must never be claimed.
141
+ for (let index = messages.length - 1; index >= 0; index--) {
142
+ const toolCalls = messages[index]?.tool_calls;
143
+ if (!Array.isArray(toolCalls) || toolCalls.length === 0)
144
+ continue;
145
+ const claimable = [];
146
+ for (const call of toolCalls) {
147
+ const name = call?.name;
148
+ const id = call?.id;
149
+ if (typeof name !== 'string' || typeof id !== 'string' || id.length === 0)
150
+ continue;
151
+ claimable.push({
152
+ name,
153
+ args: stableArgs(call?.args),
154
+ id,
155
+ claimed: false,
156
+ });
157
+ }
158
+ return claimable;
159
+ }
160
+ return [];
161
+ }
162
+ /**
163
+ * Match one pending action request back to its tool call and take that call's id.
164
+ *
165
+ * Name AND arguments, then FIRST unclaimed — which is exact rather than a correlation, because the
166
+ * request was built from that very object. The claim flag is what keeps two identical calls in one
167
+ * message (a model proposing the same command twice) from both taking the first id.
168
+ *
169
+ * **The arguments are half the match, and only one arrangement can show it.** Two calls of one name
170
+ * with the SAME arguments come back correctly under a name-only match too, because the claim flag
171
+ * alone puts them in order — so the case that discriminates is two calls of one name with DIFFERENT
172
+ * arguments whose requests arrive in the opposite order to the `tool_calls` they were built from.
173
+ * There, name-only gives every request an id and attaches each to the wrong command.
174
+ */
175
+ function claimToolCallId(candidates, name, args) {
176
+ const wanted = stableArgs(args);
177
+ const match = candidates.find((c) => !c.claimed && c.name === name && c.args === wanted);
178
+ if (!match)
179
+ return undefined;
180
+ match.claimed = true;
181
+ return match.id;
182
+ }
183
+ /**
184
+ * A tool call's arguments as one comparable string, with the keys sorted so two objects that
185
+ * differ only in insertion order still match. Fail-soft: an unserialisable argument (a BigInt, a
186
+ * cycle) yields a token that matches nothing, so the id is simply not attributed.
187
+ */
188
+ function stableArgs(args) {
189
+ if (!args || typeof args !== 'object')
190
+ return '{}';
191
+ try {
192
+ const entries = Object.entries(args).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
193
+ return JSON.stringify(entries);
194
+ }
195
+ catch {
196
+ return '<unserialisable>';
197
+ }
198
+ }
121
199
  /**
122
200
  * Shared, graph-agnostic agent plumbing.
123
201
  *
@@ -262,7 +340,7 @@ export class GthAbstractAgent {
262
340
  this.statusUpdate(StatusLevel.INFO, runHeaderLine(command, label));
263
341
  }
264
342
  /**
265
- * EXT-58 (spec §4.5) — the ONE tool-registration hook both backends call with their final tool
343
+ * EXT-58 (spec §4.5) — the ONE tool-registration hook every backend calls with its final tool
266
344
  * array, just before handing it to the graph builder. It does two things:
267
345
  *
268
346
  * 1. Appends the rung's approval sentence to every tool that is **not** auto-approved at that
@@ -405,7 +483,7 @@ export class GthAbstractAgent {
405
483
  // tool call gets its `✓ 📁 name(args…)` block here too. Only the plain surface reaches
406
484
  // invoke (the TUI uses processMessagesWithEvents); observe() is fail-soft internally.
407
485
  const allMessages = Array.isArray(response.messages) ? response.messages : [];
408
- const toolIndication = createPlainToolIndication();
486
+ const toolIndication = createPlainToolIndication(undefined, (id) => this.raterClarifications.has(id));
409
487
  for (const m of allMessages.slice(priorMessageCount)) {
410
488
  this.recordRunStats(m);
411
489
  toolIndication.observe(m);
@@ -516,7 +594,13 @@ export class GthAbstractAgent {
516
594
  // canonical 10-line greyed preview when each ToolMessage lands). Per-stream state; emits at
517
595
  // INFO level so the existing consoleLevel gate governs it like the historical tool notices.
518
596
  // The TUI never runs this string path (it renders the typed event stream itself).
519
- const toolIndication = createPlainToolIndication();
597
+ //
598
+ // [[TUI-C69]] §5.4 — the plain surface's twin of the typed event's `raterClarification`, read
599
+ // through a closure rather than handed a snapshot: the set is filled WHILE this stream is
600
+ // drained, because the runner notes the id at the moment it refuses the call, which is after
601
+ // this observer was built and before the refusal's own result arrives.
602
+ const raterClarifications = this.raterClarifications;
603
+ const toolIndication = createPlainToolIndication(undefined, (id) => raterClarifications.has(id));
520
604
  const interruptState = { escape: false, messageShown: false };
521
605
  const abortController = new AbortController();
522
606
  const showInterruptMessage = () => {
@@ -774,6 +858,8 @@ export class GthAbstractAgent {
774
858
  if (!Array.isArray(tasks)) {
775
859
  return [];
776
860
  }
861
+ // [[TUI-C69]] — the ids the action requests were built from, ready to be claimed in order.
862
+ const unclaimedIds = pendingToolCallIds(state);
777
863
  const pending = [];
778
864
  for (const task of tasks) {
779
865
  const interrupts = task?.interrupts;
@@ -789,15 +875,54 @@ export class GthAbstractAgent {
789
875
  if (typeof name !== 'string')
790
876
  continue;
791
877
  const args = action?.args;
878
+ const resolvedArgs = args && typeof args === 'object' ? args : {};
879
+ const id = claimToolCallId(unclaimedIds, name, resolvedArgs);
792
880
  pending.push({
793
881
  name,
794
- args: args && typeof args === 'object' ? args : {},
882
+ args: resolvedArgs,
883
+ ...(id === undefined ? {} : { id }),
795
884
  });
796
885
  }
797
886
  }
798
887
  }
799
888
  return pending;
800
889
  }
890
+ /**
891
+ * [[TUI-C69]] §5.4 — remember that this call was bounced back to the agent as a negotiation
892
+ * round, so both display paths can tone its result row as a clarification request.
893
+ *
894
+ * A plain set rather than a queue: the ids are LangChain tool-call ids, unique per call, and the
895
+ * two display paths read the same one without either consuming it — a session drives one of them,
896
+ * never both. {@link clearRaterClarifications} empties it with the rest of the turn's state, so a
897
+ * long run cannot accumulate.
898
+ */
899
+ noteRaterClarification(toolCallId) {
900
+ if (toolCallId)
901
+ this.raterClarifications.add(toolCallId);
902
+ }
903
+ /**
904
+ * [[TUI-C69]] §5.4 — **forget the noted ids**, called at the top of every turn and on `/clear`.
905
+ *
906
+ * The set is keyed on tool-call ids, and an id is only meaningful while the result carrying it is
907
+ * being rendered. Holding them past that costs a string per rater rejection for the life of the
908
+ * process — small — and risks the thing that is not small: a LATER call that reuses a noted id
909
+ * renders as a clarification request whatever it actually was, so a successful command inherits
910
+ * the tone of an argument it had no part in.
911
+ *
912
+ * That reuse is not reachable with the providers installed today — `@langchain/ollama` mints
913
+ * `v4()` uuids, `@langchain/google` `lc-tool-call-${v4()}`, and OpenAI and Anthropic supply their
914
+ * own unique ids — but **the protection was the providers', not this code's**, which is a
915
+ * property no test here can hold and no bump has to preserve.
916
+ *
917
+ * On `/clear` it is the same argument `GthAgentRunner.resetThread` makes about the negotiation:
918
+ * state from before the user asked for the conversation to be forgotten must not decide how the
919
+ * conversation after it is drawn.
920
+ */
921
+ clearRaterClarifications() {
922
+ this.raterClarifications.clear();
923
+ }
924
+ /** [[TUI-C69]] — the ids {@link noteRaterClarification} has been told about this turn. */
925
+ raterClarifications = new Set();
801
926
  async *processEventStream(stream) {
802
927
  // Aggregate AIMessageChunks via concat so tool_call_chunks collapse into
803
928
  // tool_calls with complete args (per-chunk tool_calls only ever sees that
@@ -805,11 +930,30 @@ export class GthAbstractAgent {
805
930
  let aggregatedAIChunk = null;
806
931
  let reasoningOpen = false;
807
932
  const flushed = new Set();
933
+ /**
934
+ * [[TUI-C100]] — the calls THIS stream has announced (`tool_start` + `tool_args`) and whose
935
+ * own result it has not yet seen.
936
+ *
937
+ * Kept here rather than derived from {@link aggregatedAIChunk} because the aggregate is nulled
938
+ * after every round (see the reset below) while the calls it announced can still be waiting —
939
+ * at the approval gate, or simply behind a sibling in the same message. A set that died with
940
+ * the aggregate would lose exactly the calls this exists to track.
941
+ *
942
+ * **Scoped to one invocation, deliberately.** A call held at the approval gate is announced in
943
+ * the stream that suspends and resolved in the stream that resumes, so its result lands where
944
+ * this set is empty — and it therefore never receives a `tool_end` at all. That is the correct
945
+ * outcome, not a leak: a stream may only speak for what it announced. What closes a call that
946
+ * produces no result anywhere is the turn-level drain in `GthAgentRunner`'s
947
+ * `processMessagesWithEvents`, which spans both streams.
948
+ */
949
+ const pendingEnds = new Set();
808
950
  // EXT-41: a content-policy refusal on this typed-event path was de-scoped by EXT-37 (there is
809
951
  // no empty-response retry here, so no wrong-retry bug), but it still rendered as a SILENT empty
810
952
  // turn. Capture it (first per-chunk signal wins; aggregate fallback at stream end) and surface
811
953
  // it as a `text` event so every consumer (Ink TUI viewModel, AG-UI SSE) shows a clear notice.
812
954
  let refusalInfo = null;
955
+ const seenBinaryKeys = new Set();
956
+ const binaryBlocks = [];
813
957
  // TUI-C22 — one splitter for the whole stream so a <think> opened in one chunk and closed
814
958
  // several chunks later is tracked across the boundary. Reset at message boundaries via flush().
815
959
  const thinkSplitter = createThinkTagSplitter();
@@ -851,6 +995,18 @@ export class GthAbstractAgent {
851
995
  }
852
996
  }
853
997
  }
998
+ /**
999
+ * Announce every call the assistant message carries, once its arguments are complete.
1000
+ *
1001
+ * [[TUI-C100]] — **announcing a call is not ending it.** This runs when the round's FIRST
1002
+ * `ToolMessage` lands, because that is what makes the streamed aggregate complete, so it speaks
1003
+ * for every call in the message at once: the one that just returned, the ones still queued, and
1004
+ * the one suspended at the approval gate with a human being asked about it. `tool_start` and
1005
+ * `tool_args` are true for all of them — the model has finished emitting the arguments — and
1006
+ * that is what puts a call on screen, with the arguments the human is about to rule on.
1007
+ * `tool_end` is not: it waits for the call's own result, and is emitted beside it when that
1008
+ * result reaches THIS stream — which for a gated call it never does. See `pendingEnds` above.
1009
+ */
854
1010
  function* flushAggregated() {
855
1011
  if (!aggregatedAIChunk)
856
1012
  return;
@@ -863,10 +1019,15 @@ export class GthAbstractAgent {
863
1019
  flushed.add(id);
864
1020
  yield { type: 'tool_start', id, name: tc.name };
865
1021
  yield { type: 'tool_args', id, delta: JSON.stringify(tc.args ?? {}) };
866
- yield { type: 'tool_end', id };
1022
+ pendingEnds.add(id);
867
1023
  }
868
1024
  // Surface invalid tool calls too so the client at least sees the raw args
869
1025
  // string the model produced, instead of silently dropping them.
1026
+ //
1027
+ // These END INLINE, unlike the calls above, and the asymmetry is the point: an invalid call
1028
+ // is never dispatched, so there is no result to wait for and nothing that could later change
1029
+ // what it says. Deferring it would leave a row that cannot progress sitting at `running`
1030
+ // until the turn ended, in exchange for nothing.
870
1031
  for (const tc of invalidToolCalls) {
871
1032
  const id = tc.id;
872
1033
  if (!id || flushed.has(id))
@@ -887,6 +1048,16 @@ export class GthAbstractAgent {
887
1048
  if (!refusalInfo) {
888
1049
  refusalInfo = detectRefusal(chunk);
889
1050
  }
1051
+ if (this.config?.writeBinaryOutputsToFile &&
1052
+ (AIMessageChunk.isInstance(chunk) || AIMessage.isInstance(chunk))) {
1053
+ for (const block of extractInlineBinaryBlocks(chunk.content)) {
1054
+ const binaryKey = `${block.mimeType}:${block.data.length}:${block.data}`;
1055
+ if (!seenBinaryKeys.has(binaryKey)) {
1056
+ seenBinaryKeys.add(binaryKey);
1057
+ binaryBlocks.push({ mimeType: block.mimeType, data: block.data });
1058
+ }
1059
+ }
1060
+ }
890
1061
  if (AIMessageChunk.isInstance(chunk)) {
891
1062
  aggregatedAIChunk = aggregatedAIChunk ? aggregatedAIChunk.concat(chunk) : chunk;
892
1063
  // Reasoning deltas — Ollama (Qwen3, deepseek-r1), Anthropic, and OpenRouter surface
@@ -948,11 +1119,31 @@ export class GthAbstractAgent {
948
1119
  // Surface the real tool-result error signal (LangChain `ToolMessage.status`) so
949
1120
  // consumers render the ✗/error affordance from fact, not from sniffing the result
950
1121
  // text. Only attach the flag on error to keep the success event shape unchanged.
1122
+ // [[TUI-C69]] §5.4 — the gate's own account of WHY this result is an error, when it was
1123
+ // the auto-rater asking for a clarification rather than a tool that failed. Additive:
1124
+ // `isError` still says the call did not run, which is what the model, `gth eval`'s
1125
+ // tool-result assertions and the ACP bridge all read.
1126
+ const toolCallId = chunk.tool_call_id;
1127
+ // [[TUI-C100]] — the call's own result is here, so NOW it has ended.
1128
+ //
1129
+ // **The guard is the stream's own honesty, not a favour to a downstream reader.** This
1130
+ // generator may only close a call it opened, and the id it is holding a result for is
1131
+ // routinely one it never announced: a gated call is announced in the stream that suspends
1132
+ // and returns in the stream that resumes, where this set is empty. So the common gated flow
1133
+ // is `tool_start`, `tool_args`, then a `tool_result` in a later stream with **no `tool_end`
1134
+ // between them, and none at all** — do not read the line below as a promise that an end
1135
+ // always precedes a result. Every consumer treats `tool_result` as terminal on its own, and
1136
+ // the AG-UI bridge additionally closes its own framing from what it actually emitted rather
1137
+ // than trusting this event to arrive.
1138
+ if (pendingEnds.delete(toolCallId)) {
1139
+ yield { type: 'tool_end', id: toolCallId };
1140
+ }
951
1141
  yield {
952
1142
  type: 'tool_result',
953
- id: chunk.tool_call_id,
1143
+ id: toolCallId,
954
1144
  content,
955
1145
  ...(chunk.status === 'error' ? { isError: true } : {}),
1146
+ ...(this.raterClarifications.has(toolCallId) ? { raterClarification: true } : {}),
956
1147
  };
957
1148
  }
958
1149
  }
@@ -963,8 +1154,32 @@ export class GthAbstractAgent {
963
1154
  if (reasoningOpen) {
964
1155
  yield { type: 'reasoning_end' };
965
1156
  }
966
- // Flush any tool calls not followed by a ToolMessage (e.g. terminal tool calls).
1157
+ // Announce any tool calls not followed by a ToolMessage (e.g. terminal tool calls), so a call
1158
+ // the model asked for is never silently absent from the stream.
1159
+ //
1160
+ // [[TUI-C100]] — and deliberately NO `tool_end` sweep of {@link pendingEnds} here, which is
1161
+ // the tempting shape and the wrong one. **A stream that ends with calls outstanding is what
1162
+ // suspending at the approval gate LOOKS like**: the graph interrupts, this generator returns,
1163
+ // and `GthAgentRunner.processMessagesWithEvents` only then opens the prompt
1164
+ // (`resolveToolInterruptsWithEvents`). Ending them here would put the tick and the word `done`
1165
+ // back on the row a moment before the human is asked about it — the defect, moved rather than
1166
+ // fixed. A call held at the gate and a genuinely terminal one are indistinguishable from
1167
+ // inside the stream, and the turn's end is the first point at which the human has been asked
1168
+ // and the answer acted on — so a call still without a result THERE is one that will never have
1169
+ // one, and the runner closes it as exactly that.
967
1170
  yield* flushAggregated();
1171
+ if (this.config?.writeBinaryOutputsToFile && binaryBlocks.length > 0) {
1172
+ const processedContent = materializeBinaryOutputs(binaryBlocks.map((block) => ({
1173
+ type: 'inlineData',
1174
+ inlineData: block,
1175
+ })), this.command);
1176
+ for (const successMessage of processedContent.successMessages) {
1177
+ this.statusUpdate(StatusLevel.SUCCESS, successMessage);
1178
+ }
1179
+ if (processedContent.renderedContent.trim().length > 0) {
1180
+ yield { type: 'text', delta: '\n' + processedContent.renderedContent + '\n' };
1181
+ }
1182
+ }
968
1183
  // EXT-41: aggregate-level fallback (I-1's robustness on this path too) — if no per-chunk
969
1184
  // metadata flagged a refusal, inspect the final aggregated message's stop/finish reason. Then
970
1185
  // surface any refusal as a `text` event so the user sees a clear notice instead of a silent