@gaunt-sloth/core 2.0.0-alpha.9 → 2.0.0-beta.0

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 (275) hide show
  1. package/.gsloth.review.md +2 -0
  2. package/README.md +71 -20
  3. package/dist/config/colour.d.ts +38 -0
  4. package/dist/config/colour.js +36 -0
  5. package/dist/config/colour.js.map +1 -0
  6. package/dist/config/configDiscovery.d.ts +79 -0
  7. package/dist/config/configDiscovery.js +80 -0
  8. package/dist/config/configDiscovery.js.map +1 -0
  9. package/dist/config/defaults.d.ts +20 -20
  10. package/dist/config/defaults.js +10 -8
  11. package/dist/config/defaults.js.map +1 -1
  12. package/dist/config/filesystem-tools.d.ts +41 -0
  13. package/dist/config/filesystem-tools.js +56 -0
  14. package/dist/config/filesystem-tools.js.map +1 -0
  15. package/dist/config/loader.d.ts +171 -19
  16. package/dist/config/loader.js +1054 -144
  17. package/dist/config/loader.js.map +1 -1
  18. package/dist/config/mouse.d.ts +50 -0
  19. package/dist/config/mouse.js +44 -0
  20. package/dist/config/mouse.js.map +1 -0
  21. package/dist/config/profiles.d.ts +68 -0
  22. package/dist/config/profiles.js +93 -0
  23. package/dist/config/profiles.js.map +1 -0
  24. package/dist/config/providerKeys.d.ts +69 -0
  25. package/dist/config/providerKeys.js +69 -0
  26. package/dist/config/providerKeys.js.map +1 -0
  27. package/dist/config/schema.d.ts +2671 -138
  28. package/dist/config/schema.js +1361 -85
  29. package/dist/config/schema.js.map +1 -1
  30. package/dist/config/shell-policy.d.ts +899 -111
  31. package/dist/config/shell-policy.js +800 -70
  32. package/dist/config/shell-policy.js.map +1 -1
  33. package/dist/config/tool-descriptions.d.ts +211 -0
  34. package/dist/config/tool-descriptions.js +272 -0
  35. package/dist/config/tool-descriptions.js.map +1 -0
  36. package/dist/config/types.d.ts +352 -41
  37. package/dist/config/types.js +1 -0
  38. package/dist/config/types.js.map +1 -1
  39. package/dist/config.d.ts +35 -1
  40. package/dist/config.js +16 -1
  41. package/dist/config.js.map +1 -1
  42. package/dist/constants.d.ts +28 -1
  43. package/dist/constants.js +28 -1
  44. package/dist/constants.js.map +1 -1
  45. package/dist/core/GthAbstractAgent.d.ts +166 -11
  46. package/dist/core/GthAbstractAgent.js +484 -45
  47. package/dist/core/GthAbstractAgent.js.map +1 -1
  48. package/dist/core/GthAgentRunner.d.ts +543 -57
  49. package/dist/core/GthAgentRunner.js +1494 -140
  50. package/dist/core/GthAgentRunner.js.map +1 -1
  51. package/dist/core/GthLangChainAgent.d.ts +117 -2
  52. package/dist/core/GthLangChainAgent.js +602 -29
  53. package/dist/core/GthLangChainAgent.js.map +1 -1
  54. package/dist/core/approvals/annotations.d.ts +122 -0
  55. package/dist/core/approvals/annotations.js +137 -0
  56. package/dist/core/approvals/annotations.js.map +1 -0
  57. package/dist/core/approvals/grants.d.ts +216 -0
  58. package/dist/core/approvals/grants.js +469 -0
  59. package/dist/core/approvals/grants.js.map +1 -0
  60. package/dist/core/approvals/matcher.d.ts +202 -0
  61. package/dist/core/approvals/matcher.js +267 -0
  62. package/dist/core/approvals/matcher.js.map +1 -0
  63. package/dist/core/approvals/mcpSubjects.d.ts +40 -0
  64. package/dist/core/approvals/mcpSubjects.js +99 -0
  65. package/dist/core/approvals/mcpSubjects.js.map +1 -0
  66. package/dist/core/approvals/promptHeader.d.ts +28 -0
  67. package/dist/core/approvals/promptHeader.js +62 -0
  68. package/dist/core/approvals/promptHeader.js.map +1 -0
  69. package/dist/core/approvals/toolAnnotationSources.d.ts +105 -0
  70. package/dist/core/approvals/toolAnnotationSources.js +277 -0
  71. package/dist/core/approvals/toolAnnotationSources.js.map +1 -0
  72. package/dist/core/approvals/toolHost.d.ts +46 -0
  73. package/dist/core/approvals/toolHost.js +108 -0
  74. package/dist/core/approvals/toolHost.js.map +1 -0
  75. package/dist/core/debugCapture.d.ts +74 -0
  76. package/dist/core/debugCapture.js +100 -0
  77. package/dist/core/debugCapture.js.map +1 -0
  78. package/dist/core/gthLeanAgentFactory.d.ts +4 -4
  79. package/dist/core/gthLeanAgentFactory.js +4 -4
  80. package/dist/core/launchBanner.d.ts +127 -0
  81. package/dist/core/launchBanner.js +414 -0
  82. package/dist/core/launchBanner.js.map +1 -0
  83. package/dist/core/modelLabel.d.ts +19 -0
  84. package/dist/core/modelLabel.js +26 -0
  85. package/dist/core/modelLabel.js.map +1 -0
  86. package/dist/core/plainToolIndication.d.ts +15 -0
  87. package/dist/core/plainToolIndication.js +174 -0
  88. package/dist/core/plainToolIndication.js.map +1 -0
  89. package/dist/core/reasoningBlocks.d.ts +65 -0
  90. package/dist/core/reasoningBlocks.js +103 -0
  91. package/dist/core/reasoningBlocks.js.map +1 -0
  92. package/dist/core/refusal.d.ts +53 -0
  93. package/dist/core/refusal.js +133 -0
  94. package/dist/core/refusal.js.map +1 -0
  95. package/dist/core/runHeader.d.ts +38 -0
  96. package/dist/core/runHeader.js +42 -0
  97. package/dist/core/runHeader.js.map +1 -0
  98. package/dist/core/runStats.d.ts +14 -3
  99. package/dist/core/runStats.js +48 -3
  100. package/dist/core/runStats.js.map +1 -1
  101. package/dist/core/shell/ShellCommandFailedError.d.ts +3 -4
  102. package/dist/core/shell/ShellCommandFailedError.js +3 -4
  103. package/dist/core/shell/ShellCommandFailedError.js.map +1 -1
  104. package/dist/core/shell/abstention.d.ts +88 -0
  105. package/dist/core/shell/abstention.js +184 -0
  106. package/dist/core/shell/abstention.js.map +1 -0
  107. package/dist/core/shell/approvalCapture.d.ts +271 -0
  108. package/dist/core/shell/approvalCapture.js +108 -0
  109. package/dist/core/shell/approvalCapture.js.map +1 -0
  110. package/dist/core/shell/approvalStop.d.ts +123 -0
  111. package/dist/core/shell/approvalStop.js +269 -0
  112. package/dist/core/shell/approvalStop.js.map +1 -0
  113. package/dist/core/shell/arity.d.ts +6 -0
  114. package/dist/core/shell/arity.js +20 -6
  115. package/dist/core/shell/arity.js.map +1 -1
  116. package/dist/core/shell/denylist.d.ts +11 -0
  117. package/dist/core/shell/denylist.js +37 -0
  118. package/dist/core/shell/denylist.js.map +1 -0
  119. package/dist/core/shell/escalationSeverity.d.ts +141 -0
  120. package/dist/core/shell/escalationSeverity.js +89 -0
  121. package/dist/core/shell/escalationSeverity.js.map +1 -0
  122. package/dist/core/shell/framing.d.ts +190 -0
  123. package/dist/core/shell/framing.js +633 -0
  124. package/dist/core/shell/framing.js.map +1 -0
  125. package/dist/core/shell/hardline.d.ts +103 -0
  126. package/dist/core/shell/hardline.js +780 -0
  127. package/dist/core/shell/hardline.js.map +1 -0
  128. package/dist/core/shell/negotiation.d.ts +328 -0
  129. package/dist/core/shell/negotiation.js +488 -0
  130. package/dist/core/shell/negotiation.js.map +1 -0
  131. package/dist/core/shell/normalize.d.ts +44 -4
  132. package/dist/core/shell/normalize.js +61 -7
  133. package/dist/core/shell/normalize.js.map +1 -1
  134. package/dist/core/shell/openWorld.d.ts +263 -0
  135. package/dist/core/shell/openWorld.js +1188 -0
  136. package/dist/core/shell/openWorld.js.map +1 -0
  137. package/dist/core/shell/rater.d.ts +873 -0
  138. package/dist/core/shell/rater.js +1454 -0
  139. package/dist/core/shell/rater.js.map +1 -0
  140. package/dist/core/shell/raterModel.d.ts +41 -0
  141. package/dist/core/shell/raterModel.js +51 -0
  142. package/dist/core/shell/raterModel.js.map +1 -0
  143. package/dist/core/shell/raterVocabulary.d.ts +121 -0
  144. package/dist/core/shell/raterVocabulary.js +116 -0
  145. package/dist/core/shell/raterVocabulary.js.map +1 -0
  146. package/dist/core/shell/rejection.d.ts +69 -0
  147. package/dist/core/shell/rejection.js +38 -0
  148. package/dist/core/shell/rejection.js.map +1 -0
  149. package/dist/core/toolCallRepair/grammar.d.ts +41 -0
  150. package/dist/core/toolCallRepair/grammar.js +116 -0
  151. package/dist/core/toolCallRepair/grammar.js.map +1 -0
  152. package/dist/core/toolCallRepair/index.d.ts +2 -0
  153. package/dist/core/toolCallRepair/index.js +7 -0
  154. package/dist/core/toolCallRepair/index.js.map +1 -0
  155. package/dist/core/toolCallRepair/payload.d.ts +36 -0
  156. package/dist/core/toolCallRepair/payload.js +341 -0
  157. package/dist/core/toolCallRepair/payload.js.map +1 -0
  158. package/dist/core/toolCallRepair/promote.d.ts +45 -0
  159. package/dist/core/toolCallRepair/promote.js +90 -0
  160. package/dist/core/toolCallRepair/promote.js.map +1 -0
  161. package/dist/core/toolDisplay.d.ts +123 -0
  162. package/dist/core/toolDisplay.js +451 -0
  163. package/dist/core/toolDisplay.js.map +1 -0
  164. package/dist/core/toolOutputChannel.d.ts +95 -0
  165. package/dist/core/toolOutputChannel.js +165 -0
  166. package/dist/core/toolOutputChannel.js.map +1 -0
  167. package/dist/core/types.d.ts +349 -16
  168. package/dist/core/types.js.map +1 -1
  169. package/dist/history/historyFormat.d.ts +12 -3
  170. package/dist/history/historyFormat.js +50 -8
  171. package/dist/history/historyFormat.js.map +1 -1
  172. package/dist/history/historyStore.d.ts +77 -0
  173. package/dist/history/historyStore.js +173 -6
  174. package/dist/history/historyStore.js.map +1 -1
  175. package/dist/history/recordSession.d.ts +10 -1
  176. package/dist/history/recordSession.js +27 -0
  177. package/dist/history/recordSession.js.map +1 -1
  178. package/dist/providers/anthropic.js +12 -0
  179. package/dist/providers/anthropic.js.map +1 -1
  180. package/dist/providers/configurationPassthrough.d.ts +107 -0
  181. package/dist/providers/configurationPassthrough.js +148 -0
  182. package/dist/providers/configurationPassthrough.js.map +1 -0
  183. package/dist/providers/geminiSchemaSanitizer.d.ts +55 -0
  184. package/dist/providers/geminiSchemaSanitizer.js +347 -0
  185. package/dist/providers/geminiSchemaSanitizer.js.map +1 -0
  186. package/dist/providers/geminiThinking.d.ts +60 -0
  187. package/dist/providers/geminiThinking.js +92 -0
  188. package/dist/providers/geminiThinking.js.map +1 -0
  189. package/dist/providers/google-genai.js +18 -1
  190. package/dist/providers/google-genai.js.map +1 -1
  191. package/dist/providers/groq.js +12 -0
  192. package/dist/providers/groq.js.map +1 -1
  193. package/dist/providers/huggingface.d.ts +25 -0
  194. package/dist/providers/huggingface.js +69 -0
  195. package/dist/providers/huggingface.js.map +1 -0
  196. package/dist/providers/modelCatalog.d.ts +109 -0
  197. package/dist/providers/modelCatalog.js +245 -0
  198. package/dist/providers/modelCatalog.js.map +1 -0
  199. package/dist/providers/modelDiscovery.d.ts +99 -5
  200. package/dist/providers/modelDiscovery.js +191 -35
  201. package/dist/providers/modelDiscovery.js.map +1 -1
  202. package/dist/providers/ollama.d.ts +18 -4
  203. package/dist/providers/ollama.js +67 -37
  204. package/dist/providers/ollama.js.map +1 -1
  205. package/dist/providers/openai.js +34 -0
  206. package/dist/providers/openai.js.map +1 -1
  207. package/dist/providers/openrouter.d.ts +26 -4
  208. package/dist/providers/openrouter.js +83 -26
  209. package/dist/providers/openrouter.js.map +1 -1
  210. package/dist/providers/vertexai.js +19 -1
  211. package/dist/providers/vertexai.js.map +1 -1
  212. package/dist/providers/xai.js +20 -0
  213. package/dist/providers/xai.js.map +1 -1
  214. package/dist/runtime/askStructured.d.ts +105 -0
  215. package/dist/runtime/askStructured.js +120 -0
  216. package/dist/runtime/askStructured.js.map +1 -0
  217. package/dist/runtime/conversation.d.ts +64 -0
  218. package/dist/runtime/conversation.js +171 -0
  219. package/dist/runtime/conversation.js.map +1 -0
  220. package/dist/runtime/singleShot.d.ts +37 -6
  221. package/dist/runtime/singleShot.js +113 -67
  222. package/dist/runtime/singleShot.js.map +1 -1
  223. package/dist/runtime/structuredOutput.d.ts +104 -0
  224. package/dist/runtime/structuredOutput.js +393 -0
  225. package/dist/runtime/structuredOutput.js.map +1 -0
  226. package/dist/utils/ProgressIndicator.d.ts +21 -0
  227. package/dist/utils/ProgressIndicator.js +30 -3
  228. package/dist/utils/ProgressIndicator.js.map +1 -1
  229. package/dist/utils/aiignoreUtils.js.map +1 -1
  230. package/dist/utils/binaryOutputUtils.js.map +1 -1
  231. package/dist/utils/consoleUtils.d.ts +95 -0
  232. package/dist/utils/consoleUtils.js +112 -2
  233. package/dist/utils/consoleUtils.js.map +1 -1
  234. package/dist/utils/crashHandler.d.ts +87 -0
  235. package/dist/utils/crashHandler.js +128 -0
  236. package/dist/utils/crashHandler.js.map +1 -0
  237. package/dist/utils/debugDump.d.ts +134 -0
  238. package/dist/utils/debugDump.js +381 -0
  239. package/dist/utils/debugDump.js.map +1 -0
  240. package/dist/utils/debugUtils.d.ts +13 -4
  241. package/dist/utils/debugUtils.js +36 -13
  242. package/dist/utils/debugUtils.js.map +1 -1
  243. package/dist/utils/displayWidth.d.ts +53 -0
  244. package/dist/utils/displayWidth.js +195 -0
  245. package/dist/utils/displayWidth.js.map +1 -0
  246. package/dist/utils/fileUtils.js.map +1 -1
  247. package/dist/utils/globalConfigUtils.d.ts +14 -2
  248. package/dist/utils/globalConfigUtils.js +22 -4
  249. package/dist/utils/globalConfigUtils.js.map +1 -1
  250. package/dist/utils/llmUtils.d.ts +39 -8
  251. package/dist/utils/llmUtils.js +76 -8
  252. package/dist/utils/llmUtils.js.map +1 -1
  253. package/dist/utils/redactSecrets.d.ts +63 -0
  254. package/dist/utils/redactSecrets.js +286 -0
  255. package/dist/utils/redactSecrets.js.map +1 -0
  256. package/dist/utils/systemPromptNotes.d.ts +222 -0
  257. package/dist/utils/systemPromptNotes.js +338 -0
  258. package/dist/utils/systemPromptNotes.js.map +1 -0
  259. package/dist/utils/systemUtils.d.ts +18 -1
  260. package/dist/utils/systemUtils.js +38 -3
  261. package/dist/utils/systemUtils.js.map +1 -1
  262. package/dist/utils/toolMatching.d.ts +30 -0
  263. package/dist/utils/toolMatching.js +44 -0
  264. package/dist/utils/toolMatching.js.map +1 -0
  265. package/dist/utils/untrustedText.d.ts +86 -0
  266. package/dist/utils/untrustedText.js +101 -0
  267. package/dist/utils/untrustedText.js.map +1 -0
  268. package/package.json +21 -7
  269. package/schema/gsloth-config.schema.json +1921 -326
  270. package/dist/core/shell/allowlist.d.ts +0 -75
  271. package/dist/core/shell/allowlist.js +0 -187
  272. package/dist/core/shell/allowlist.js.map +0 -1
  273. package/dist/core/shell/judge.d.ts +0 -161
  274. package/dist/core/shell/judge.js +0 -261
  275. package/dist/core/shell/judge.js.map +0 -1
@@ -1,5 +1,10 @@
1
+ import { applyRungAwareToolDescriptions, } from '#src/config.js';
1
2
  import { StatusLevel, } from '#src/core/types.js';
2
3
  import { accumulateMessage, createRunStatsAccumulator, finalizeRunStats, } from '#src/core/runStats.js';
4
+ import { collectDeclaredMcpToolAnnotations } from '#src/core/approvals/toolAnnotationSources.js';
5
+ import { modelProviderLabel } from '#src/core/modelLabel.js';
6
+ import { createPlainToolIndication } from '#src/core/plainToolIndication.js';
7
+ import { runHeaderLine } from '#src/core/runHeader.js';
3
8
  import { debugLog, debugLogError, debugLogObject } from '#src/utils/debugUtils.js';
4
9
  import { ProgressIndicator } from '#src/utils/ProgressIndicator.js';
5
10
  import { stopWaitingForEscape, waitForEscape } from '#src/utils/systemUtils.js';
@@ -7,18 +12,123 @@ import { AIMessage, AIMessageChunk, ToolMessage } from '@langchain/core/messages
7
12
  import { IterableReadableStream } from '@langchain/core/utils/stream';
8
13
  import { interrupt, Command, GraphInterrupt } from '@langchain/langgraph';
9
14
  import { extractInlineBinaryBlocks, materializeBinaryOutputs, renderAssistantContent, } from '#src/utils/binaryOutputUtils.js';
15
+ import { detectRefusal, buildRefusalMessage } from '#src/core/refusal.js';
16
+ import { answerTextOf, segmentAssistantContent, stripReasoningBlocks, } from '#src/core/reasoningBlocks.js';
17
+ const THINK_OPEN = '<think>';
18
+ const THINK_CLOSE = '</think>';
19
+ /**
20
+ * TUI-C22 — length of the longest suffix of `s` that is a *proper* (shorter-than-full) prefix of
21
+ * `tag`. Used by {@link createThinkTagSplitter} to hold back a trailing partial that might complete
22
+ * into `tag` on the next chunk (e.g. a chunk ending in `<thi` when the tag is `<think>`).
23
+ */
24
+ function trailingPartialLen(s, tag) {
25
+ const max = Math.min(s.length, tag.length - 1);
26
+ for (let k = max; k > 0; k--) {
27
+ if (s.slice(s.length - k) === tag.slice(0, k))
28
+ return k;
29
+ }
30
+ return 0;
31
+ }
32
+ /**
33
+ * TUI-C22 — stateful separator of inline `<think>...</think>` thinking from answer text, robust to
34
+ * tags split across streamed chunks. Many thinking models served over an OpenAI-compatible `/v1`
35
+ * shim (qwen3 / deepseek-r1 over Ollama) inline their reasoning as `<think>…</think>` in the
36
+ * message `content` rather than in `additional_kwargs.reasoning_content`; without this it would
37
+ * render as answer text and the `/reasoning` panel would stay empty.
38
+ *
39
+ * `push(text)` returns the segments it can classify unambiguously *now*, buffering any trailing
40
+ * partial tag (so a `<think>` arriving as `<thi` + `nk>` across two chunks is still detected) and
41
+ * the run of thinking between an open and a not-yet-seen close tag. `flush()` drains the buffer at
42
+ * a message/stream boundary: an unterminated `<think>` at EOF yields its remainder as reasoning; a
43
+ * dangling non-tag partial (e.g. a lone `<` or `<thi` that never completed) yields as answer, so no
44
+ * text is ever dropped. Purely additive — text with no `<think>` passes straight through as answer.
45
+ */
46
+ function createThinkTagSplitter() {
47
+ let buffer = '';
48
+ let inThink = false;
49
+ function push(text) {
50
+ const segments = [];
51
+ if (text.length === 0 && buffer.length === 0)
52
+ return segments;
53
+ buffer += text;
54
+ for (;;) {
55
+ if (inThink) {
56
+ const idx = buffer.indexOf(THINK_CLOSE);
57
+ if (idx >= 0) {
58
+ if (idx > 0)
59
+ segments.push({ kind: 'reasoning', text: buffer.slice(0, idx) });
60
+ buffer = buffer.slice(idx + THINK_CLOSE.length);
61
+ inThink = false;
62
+ continue;
63
+ }
64
+ // No full close tag yet — emit reasoning except a trailing partial of `</think>`.
65
+ const hold = trailingPartialLen(buffer, THINK_CLOSE);
66
+ const emit = buffer.slice(0, buffer.length - hold);
67
+ if (emit.length > 0)
68
+ segments.push({ kind: 'reasoning', text: emit });
69
+ buffer = hold > 0 ? buffer.slice(buffer.length - hold) : '';
70
+ break;
71
+ }
72
+ else {
73
+ const idx = buffer.indexOf(THINK_OPEN);
74
+ if (idx >= 0) {
75
+ if (idx > 0)
76
+ segments.push({ kind: 'answer', text: buffer.slice(0, idx) });
77
+ buffer = buffer.slice(idx + THINK_OPEN.length);
78
+ inThink = true;
79
+ continue;
80
+ }
81
+ // No full open tag yet — emit answer except a trailing partial of `<think>`.
82
+ const hold = trailingPartialLen(buffer, THINK_OPEN);
83
+ const emit = buffer.slice(0, buffer.length - hold);
84
+ if (emit.length > 0)
85
+ segments.push({ kind: 'answer', text: emit });
86
+ buffer = hold > 0 ? buffer.slice(buffer.length - hold) : '';
87
+ break;
88
+ }
89
+ }
90
+ return segments;
91
+ }
92
+ function flush() {
93
+ const segments = [];
94
+ if (buffer.length > 0) {
95
+ segments.push({ kind: inThink ? 'reasoning' : 'answer', text: buffer });
96
+ }
97
+ buffer = '';
98
+ inThink = false;
99
+ return segments;
100
+ }
101
+ return { push, flush };
102
+ }
103
+ /**
104
+ * Pick this chunk's or message's reasoning delta/content.
105
+ * Precedence:
106
+ * 1. `additional_kwargs.reasoning_content` — standard DeepSeek/Anthropic/OpenRouter convention.
107
+ * 2. `additional_kwargs.reasoning` — direct reasoning fallback if present.
108
+ */
109
+ function pickReasoningDelta(kwargs) {
110
+ if (!kwargs)
111
+ return '';
112
+ const reasoningContent = kwargs.reasoning_content;
113
+ if (typeof reasoningContent === 'string' && reasoningContent.length > 0) {
114
+ return reasoningContent;
115
+ }
116
+ const direct = kwargs.reasoning;
117
+ if (typeof direct === 'string' && direct.length > 0)
118
+ return direct;
119
+ return '';
120
+ }
10
121
  /**
11
122
  * Shared, graph-agnostic agent plumbing.
12
123
  *
13
- * Both the lean {@link GthLangChainAgent} (`createAgent`, in core) and the deep
14
- * `GthDeepAgent` (`createDeepAgent`, in `@gaunt-sloth/agent`) differ only in how they
15
- * build the compiled LangGraph in {@link init}; everything downstream — invoking,
124
+ * A backend differs from another only in how it builds the compiled LangGraph in {@link init} —
125
+ * today that is the lean {@link GthLangChainAgent} (`createAgent`, in core); everything downstream invoking,
16
126
  * streaming to the console, emitting typed {@link AgentStreamEvent}s, client-tool
17
127
  * `interrupt()` stubbing, suspend/resume, and cleanup — is identical and lives here.
18
128
  *
19
- * The base operates solely on the structural {@link GthCompiledGraph} surface, so it
20
- * does NOT import `langchain`/`deepagents` graph builders. Subclasses construct the
21
- * graph and assign it to {@link agent} in their `init()`.
129
+ * The base operates solely on the structural {@link GthCompiledGraph} surface, so it does NOT
130
+ * import a graph builder. Subclasses construct the graph and assign it to {@link agent} in their
131
+ * `init()`.
22
132
  */
23
133
  export class GthAbstractAgent {
24
134
  statusUpdate;
@@ -26,6 +136,44 @@ export class GthAbstractAgent {
26
136
  agent = null;
27
137
  config = null;
28
138
  command = undefined;
139
+ /**
140
+ * GS2-95 — the name of the command the USER typed, as the run header should say it. Set from
141
+ * {@link GthAgentInitOptions#displayCommand} by the backend's `init`, and read by exactly one
142
+ * thing: {@link compactHeaderStatus}.
143
+ *
144
+ * Separate from {@link command} because that field is not a label — it selects the mode prompt,
145
+ * the approvals posture and the command-specific filesystem config. Naming the header off it is
146
+ * why `gth eval` used to open with `ask`, and moving it to fix the header would silently change
147
+ * which system prompt those runs execute under. Left `undefined` by every command whose init verb
148
+ * IS its name, which is most of them.
149
+ */
150
+ displayCommand = undefined;
151
+ /**
152
+ * Opt-in debug sink for the TUI `/debug` panel. Set AFTER {@link init} via
153
+ * `runner.getAgent()`; read lazily inside each backend's `wrapModelCall` capture middleware
154
+ * so that when it is `undefined` (the normal path) the middleware is a transparent
155
+ * pass-through. Lives on the base so every backend supports it; the AG-UI server / non-TUI
156
+ * callers simply never set it, so those contracts are unchanged.
157
+ */
158
+ debugCapture;
159
+ /**
160
+ * GS2-56 — the ALWAYS-ON snapshot of the most recent model request (extras + the as-sent,
161
+ * post-summarization messages), populated UNCONDITIONALLY at each backend's `wrapModelCall` feed
162
+ * site — NOT gated on {@link debugCapture} being attached. This is what lets `/debug-dump` render
163
+ * the full model input even when the TUI `/debug` panel was never opened and on non-TUI surfaces
164
+ * (the sink only ever fed the live `/debug` panel). O(1): a single overwritten reference retaining
165
+ * only the LAST call — no accumulation, so the "pay nothing until you need it" intent is kept.
166
+ */
167
+ lastModelRequest;
168
+ /**
169
+ * GS2-56 — stash the last model request (the as-sent messages + {@link DebugRequestExtras}).
170
+ * Called unconditionally from each backend's capture middleware, independent of the debug sink,
171
+ * so the snapshot is available to `/debug-dump` on every surface. Overwrites (retains only the
172
+ * most recent call). Callers already guard the invocation; kept trivial so it can never throw.
173
+ */
174
+ setLastModelRequest(messages, extras) {
175
+ this.lastModelRequest = { messages, extras };
176
+ }
29
177
  /**
30
178
  * GS2-16 — per-run analytics tally (token usage + invoked tool names) folded from the messages
31
179
  * flowing through {@link invoke} / the streaming paths. Reset at each turn boundary via
@@ -33,12 +181,147 @@ export class GthAbstractAgent {
33
181
  * fully fail-soft (accumulation is guarded and never throws into a run).
34
182
  */
35
183
  runStatsAcc = createRunStatsAccumulator();
184
+ /**
185
+ * EXT-58 — the names of the tools registered with the graph at the last {@link init}, recorded by
186
+ * {@link registerApprovalsAwareTools}. Read by `GthAgentRunner` to build the rater's
187
+ * granted-built-in list (§4.4), so a suggestion can only ever name a tool the model actually has.
188
+ */
189
+ registeredToolNames = [];
190
+ /**
191
+ * EXT-70 §4.7.1 — what the connected MCP servers DECLARED about their own tools, captured from
192
+ * the same registration hook as {@link registeredToolNames} and keyed by the registered tool
193
+ * name. Read by `GthAgentRunner` as the `mcp` half of a `DeclaredToolAnnotationLookup`.
194
+ *
195
+ * It is a record of claims, never of decisions: no trust is applied here (that is
196
+ * `createEffectiveToolAnnotationSource`'s only job), and an absent tool yields the fail-closed
197
+ * defaults rather than "declared nothing".
198
+ */
199
+ declaredMcpToolAnnotations = new Map();
36
200
  constructor(statusUpdate, resolvers) {
37
201
  this.statusUpdate = (level, message) => {
38
202
  statusUpdate(level, message);
39
203
  };
40
204
  this.resolvers = resolvers;
41
205
  }
206
+ /**
207
+ * GS2-101 — the run-header rung in force. An unset `output.header` resolves to `compact`: a run
208
+ * that was never configured opens with one attribution line and nothing else, and the technical
209
+ * preamble is opt-in via `output.header: 'debug'`.
210
+ *
211
+ * Defaulted HERE rather than in `DEFAULT_CONFIG` (the convention `injectModelContext` and
212
+ * `debugDump.redact` also follow) so the effective-config snapshot `gth config` prints does not
213
+ * grow a key nobody set.
214
+ */
215
+ get headerRung() {
216
+ return this.config?.output?.header ?? 'compact';
217
+ }
218
+ /**
219
+ * GS2-93 — emit one line of the technical run-header preamble (the Workdir/Model/Tools/Middleware
220
+ * block). Only the `debug` rung shows it; `compact` replaces the whole block with
221
+ * {@link compactHeaderStatus}'s single line and `none` shows nothing. A non-`debug` rung only ever
222
+ * reaches here in non-TUI text modes: the interactive TUI forces `debug` before init (see
223
+ * `createTuiSession`), and the TUI event path never goes through the interrupt-hint site, so the
224
+ * whole preamble stays visible there. Only INFO header lines route through this — real model/tool
225
+ * output, warnings and errors keep using {@link statusUpdate} directly.
226
+ */
227
+ headerStatus(message) {
228
+ if (this.headerRung !== 'debug')
229
+ return;
230
+ this.statusUpdate(StatusLevel.INFO, message);
231
+ }
232
+ /**
233
+ * GS2-93 — the `compact` rung's whole output: one line naming the command and the model that
234
+ * served it, in place of the preamble. Called once from the backend's `init`, after the command
235
+ * and effective config are in place. The string is {@link runHeaderLine}'s, shared with the review
236
+ * document's opening line so the two writers cannot drift.
237
+ *
238
+ * **This renders; it does not decide.** The word after the product name is
239
+ * {@link displayCommand} when the command supplied one and the init verb otherwise — GS2-93
240
+ * forbids a label table inside the agent, because naming a user-facing line is the command's call,
241
+ * not the runtime's.
242
+ *
243
+ * Two runs deliberately emit nothing here:
244
+ *
245
+ * - **`review` and `pr`** already open with `reviewHeadingBlock`, which renders this same line.
246
+ * Emitting here as well would print the header twice on one screen.
247
+ * - **A run with no name at all** — the `pr` command's discovery sub-agent is the one such
248
+ * caller, and it runs inside a `pr` whose header is already on screen. The name is what this
249
+ * line is for, so with none there is nothing to say; inventing a word for it would be naming a
250
+ * user-facing surface from inside the agent.
251
+ *
252
+ * The model half is the shared {@link modelProviderLabel} spelling (DL-6), and it is dropped
253
+ * rather than faked when nothing resolves (DL-7), leaving the command on its own.
254
+ */
255
+ compactHeaderStatus() {
256
+ if (this.headerRung !== 'compact')
257
+ return;
258
+ const command = this.displayCommand ?? this.command;
259
+ if (!command || command === 'review' || command === 'pr')
260
+ return;
261
+ const label = modelProviderLabel(this.config?.modelDisplayName, this.config?.modelProviderType);
262
+ this.statusUpdate(StatusLevel.INFO, runHeaderLine(command, label));
263
+ }
264
+ /**
265
+ * EXT-58 (spec §4.5) — the ONE tool-registration hook both backends call with their final tool
266
+ * array, just before handing it to the graph builder. It does two things:
267
+ *
268
+ * 1. Appends the rung's approval sentence to every tool that is **not** auto-approved at that
269
+ * rung, and leaves every granted tool's description untouched (the absence of the sentence is
270
+ * what marks a tool free). See {@link applyRungAwareToolDescriptions}.
271
+ * 2. Records the registered tool names for {@link getRegisteredToolNames}, which feeds the
272
+ * rater's granted-alternative list (§4.4).
273
+ * 3. EXT-70 §4.7.1 — records what the MCP servers declared about their own tools, for
274
+ * {@link getDeclaredMcpToolAnnotations}. This is the ONE place a `tools/list` annotation
275
+ * enters the approvals stack, and it enters as a claim: nothing here decides whether it is
276
+ * believed.
277
+ *
278
+ * `gatedTools` MUST be the **LIVE gated set for the rung in force** — `resolveGatedToolNames` for
279
+ * that rung — and NOT the set the caller wires into the approval interrupt. The two are different
280
+ * on purpose: the interrupt is installed once, at agent init, and is deliberately
281
+ * rung-independent (`resolveInterruptToolNames`, the union over every rung) so that
282
+ * `/approvals <mode>` can move the mode underneath it for the rest of the session. Passing that
283
+ * wider set here would describe tools as needing approval that the live mode does not gate — and
284
+ * a call the live mode does not gate is auto-approved the moment it reaches the runner, so the
285
+ * sentence would be a promise nothing keeps.
286
+ *
287
+ * What keeps a description from promising an approval the gate will not ask for is therefore that
288
+ * both this and `GthAgentRunner`'s own check are projections of the SAME rule,
289
+ * `isToolGatedAtRung`, evaluated against the SAME live mode — §4.5's "a description that disagrees
290
+ * with what the gate will actually do is worse than no description at all".
291
+ *
292
+ * `additionalToolNames` covers tools the graph builder registers itself and that therefore never
293
+ * appear in `tools`. Their descriptions are not ours to write, so they cannot be suffixed here;
294
+ * they are recorded only so the rater's suggestion list reflects what the model actually has.
295
+ */
296
+ registerApprovalsAwareTools(tools, options) {
297
+ applyRungAwareToolDescriptions(tools, {
298
+ rung: options.rung,
299
+ gatedTools: options.gatedTools,
300
+ });
301
+ const names = tools
302
+ .map((tool) => tool?.name)
303
+ .filter((name) => typeof name === 'string' && name.length > 0);
304
+ this.registeredToolNames = [...names, ...(options.additionalToolNames ?? [])];
305
+ // `additionalToolNames` are deliberately NOT consulted: they are names the graph builder
306
+ // registers itself, with no tool object and therefore no declaration to read.
307
+ this.declaredMcpToolAnnotations = collectDeclaredMcpToolAnnotations(tools);
308
+ return tools;
309
+ }
310
+ /**
311
+ * EXT-58 — the tool names registered with the graph at the last {@link init} (empty before it).
312
+ * The runner intersects these with the built-in summaries table to build the rater's
313
+ * granted-alternative list, so the rater can never name a tool this session does not have.
314
+ */
315
+ getRegisteredToolNames() {
316
+ return [...this.registeredToolNames];
317
+ }
318
+ /**
319
+ * EXT-70 §4.7.1 — what the MCP servers declared for their tools at the last {@link init}, keyed
320
+ * by registered tool name (empty before it, and on a session with no MCP servers).
321
+ */
322
+ getDeclaredMcpToolAnnotations() {
323
+ return this.declaredMcpToolAnnotations;
324
+ }
42
325
  /**
43
326
  * GS2-16 — clear the per-run analytics tally so the next turn starts from zero. The runner
44
327
  * calls this at each turn boundary because it (and this agent) are reused across turns in an
@@ -73,6 +356,19 @@ export class GthAbstractAgent {
73
356
  return 0;
74
357
  }
75
358
  }
359
+ /**
360
+ * EXT-37 — surface a detected content-policy refusal: emit the clear, user-facing explanation at
361
+ * WARNING level (an empty-content refusal streams nothing, so without this the console shows
362
+ * nothing) and return the same message so it becomes the turn's terminal answer. Shared by the
363
+ * non-streaming {@link invoke} and streaming {@link streamFromInput} paths so both render a
364
+ * refusal identically. A refusal is a *successful* (if declined) response — never a retry.
365
+ */
366
+ surfaceRefusal(info) {
367
+ const message = buildRefusalMessage(info);
368
+ debugLog(`Content-policy refusal detected (provider=${info.provider} reason=${info.reason})`);
369
+ this.statusUpdate(StatusLevel.WARNING, message);
370
+ return message;
371
+ }
76
372
  /**
77
373
  * Invoke LLM with a message and runnable config.
78
374
  * For streaming use {@link #stream} method, streaming is preferred if model API supports it.
@@ -102,11 +398,38 @@ export class GthAbstractAgent {
102
398
  const response = await this.agent.invoke({ messages }, runConfig);
103
399
  // Harvest token usage + invoked tool names from THIS turn's new messages only (fail-soft)
104
400
  // so the opt-in history recorder can populate `gth insights`.
401
+ // TUI-C32 residual f — the streaming path renders the compact per-tool indication via
402
+ // streamFromInput's observer; the non-streaming invoke path (`streamOutput: false`) had
403
+ // none, so a plain-surface tool call surfaced nothing after the legacy fs notices were
404
+ // dropped (residual b). Feed THIS turn's new messages through the SAME observer so each
405
+ // tool call gets its `✓ 📁 name(args…)` block here too. Only the plain surface reaches
406
+ // invoke (the TUI uses processMessagesWithEvents); observe() is fail-soft internally.
105
407
  const allMessages = Array.isArray(response.messages) ? response.messages : [];
106
- for (const m of allMessages.slice(priorMessageCount))
408
+ const toolIndication = createPlainToolIndication();
409
+ for (const m of allMessages.slice(priorMessageCount)) {
107
410
  this.recordRunStats(m);
411
+ toolIndication.observe(m);
412
+ }
108
413
  const finalMessage = response.messages[response.messages.length - 1];
109
- const finalContent = finalMessage?.content;
414
+ // EXT-37: content-policy refusal. A successful response whose stop/finish reason is a
415
+ // refusal (OpenAI content_filter / Anthropic stop_reason=refusal / Bedrock
416
+ // guardrail_intervened) is terminal-but-clear: surface the model's explanation and RETURN
417
+ // it as the answer. It must NOT flow into the empty-response retry (a refusal is
418
+ // deterministic — retrying just burns a paid call). Returning a non-empty message means the
419
+ // caller writes it to the output file and exits ok, rather than re-wrapping a *successful*
420
+ // (if declined) response as "Failed to get answer". A fallback-model attempt would hang
421
+ // here (see the extension point in GthAgentRunner.processMessages), but no runtime
422
+ // fallback-model config exists today, so we surface terminally.
423
+ const refusal = detectRefusal(finalMessage);
424
+ if (refusal) {
425
+ return this.surfaceRefusal(refusal);
426
+ }
427
+ // CFG-33: Gemini's thought summaries ride inside `content` as `thought: true` text blocks,
428
+ // which renderAssistantContent would print as part of the answer (and write to the output
429
+ // file). The plain surface has never shown reasoning — every other provider's arrives
430
+ // out-of-band in additional_kwargs — so drop them for rendering only; graph state keeps the
431
+ // message whole so the thought parts still replay as history.
432
+ const finalContent = stripReasoningBlocks(finalMessage?.content);
110
433
  const processedContent = !this.config.writeBinaryOutputsToFile
111
434
  ? {
112
435
  renderedContent: renderAssistantContent(finalContent),
@@ -186,6 +509,14 @@ export class GthAbstractAgent {
186
509
  // GS2-16: bound so the stream `start()` closure (whose `this` is the stream source, not the
187
510
  // agent) can fold each chunk into the run tally. Fail-soft inside recordRunStats.
188
511
  const recordRunStats = (m) => this.recordRunStats(m);
512
+ // EXT-37: bound so the stream `start()` closure can surface a detected refusal (WARNING +
513
+ // returns the message to enqueue) without a `this` reference.
514
+ const surfaceRefusal = (info) => this.surfaceRefusal(info);
515
+ // TUI-C30 — compact per-tool-call indication for the plain surface (`name(args…)` + the
516
+ // canonical 10-line greyed preview when each ToolMessage lands). Per-stream state; emits at
517
+ // INFO level so the existing consoleLevel gate governs it like the historical tool notices.
518
+ // The TUI never runs this string path (it renders the typed event stream itself).
519
+ const toolIndication = createPlainToolIndication();
189
520
  const interruptState = { escape: false, messageShown: false };
190
521
  const abortController = new AbortController();
191
522
  const showInterruptMessage = () => {
@@ -200,7 +531,12 @@ export class GthAbstractAgent {
200
531
  if (!abortController.signal.aborted) {
201
532
  abortController.abort();
202
533
  }
203
- }, this.config.canInterruptInferenceWithEsc);
534
+ }, this.config.canInterruptInferenceWithEsc,
535
+ // GS2-93: the interrupt hint is part of the run-header preamble, so only the `debug` rung
536
+ // prints it. The Esc/Q handler stays armed at every rung — the hint box is what goes, not the
537
+ // interrupt. This site only runs in the non-TUI text path (`streamFromInput`); the TUI event
538
+ // path never reaches it.
539
+ this.headerRung === 'debug');
204
540
  let stream;
205
541
  try {
206
542
  stream = await this.agent.stream(input, {
@@ -225,13 +561,44 @@ export class GthAbstractAgent {
225
561
  let totalChunks = 0;
226
562
  const seenBinaryBlocks = new Set();
227
563
  const binaryBlocks = [];
564
+ // EXT-37: a content-policy refusal's stop/finish reason rides on a chunk's
565
+ // response_metadata (usually with empty content). Capture it here and surface it AFTER
566
+ // the stream drains, so the returned text is non-empty and the run loop treats it as
567
+ // terminal-but-clear instead of routing an empty streamed turn into the retry.
568
+ let refusalInfo = null;
569
+ // EXT-41: belt-and-suspenders — also concat the AI chunks so a refusal can be read off
570
+ // the FINAL aggregated message's stop/finish reason, not only a per-chunk one. Some
571
+ // providers surface the reason only on the assembled message (or split it across chunks
572
+ // that concat into it); without this fallback such a refusal would be swallowed by the
573
+ // empty-response retry, making the EXT-37 surfacing cosmetic on the DEFAULT streaming
574
+ // surface. Reset at each tool round (below) so a prior round's reason can't concatenate
575
+ // with the final turn's (mirrors processEventStream's per-round reset).
576
+ let aggregatedChunk = null;
228
577
  for await (const [chunk, _metadata] of stream) {
229
578
  debugLogObject('Stream chunk', { chunk, _metadata });
230
579
  // GS2-16: fold every chunk (AIMessageChunk usage/tool_calls, ToolMessage name) into
231
580
  // the run tally before the text-only handling below.
232
581
  recordRunStats(chunk);
582
+ // EXT-37: first refusal signal wins; keep scanning chunks for text/binary as normal.
583
+ if (!refusalInfo) {
584
+ refusalInfo = detectRefusal(chunk);
585
+ }
586
+ // EXT-41: fold AI chunks into an aggregate for the aggregate-level refusal fallback,
587
+ // resetting at tool-round boundaries so a prior round's stop/finish reason can't bleed
588
+ // into the final turn's aggregate.
589
+ if (AIMessageChunk.isInstance(chunk)) {
590
+ aggregatedChunk = aggregatedChunk ? aggregatedChunk.concat(chunk) : chunk;
591
+ }
592
+ else if (chunk instanceof ToolMessage) {
593
+ aggregatedChunk = null;
594
+ }
595
+ // TUI-C30: fold the chunk into the plain-surface tool indication (renders each
596
+ // completed call when its ToolMessage arrives; a no-op for plain text chunks).
597
+ toolIndication.observe(chunk);
233
598
  if (AIMessage.isInstance(chunk)) {
234
- const text = chunk.text ?? '';
599
+ // CFG-33: the ANSWER text only. `.text` folds Gemini's `thought: true` blocks into the
600
+ // answer, which would print the model's thinking inline here and in the output file.
601
+ const text = answerTextOf(chunk.content);
235
602
  totalChunks++;
236
603
  if (text.length > 0) {
237
604
  statusUpdate(StatusLevel.STREAM, text);
@@ -264,6 +631,19 @@ export class GthAbstractAgent {
264
631
  statusUpdate(StatusLevel.SUCCESS, successMessage);
265
632
  }
266
633
  }
634
+ // EXT-41: aggregate-level fallback — if no per-chunk metadata flagged a refusal, inspect
635
+ // the FINAL aggregated message's stop/finish reason. Catches providers that expose the
636
+ // reason only on the assembled message (or split across chunks that concat into it).
637
+ if (!refusalInfo && aggregatedChunk) {
638
+ refusalInfo = detectRefusal(aggregatedChunk);
639
+ }
640
+ // EXT-37: surface a captured refusal as the terminal answer. Enqueue the clear message
641
+ // (so the drained result is non-empty and bypasses the empty-response retry) and print it
642
+ // once at WARNING level (surfaceRefusal). Any partial content already streamed is kept;
643
+ // the refusal notice follows it, and its explanation carries any model-provided text.
644
+ if (refusalInfo) {
645
+ controller.enqueue(surfaceRefusal(refusalInfo));
646
+ }
267
647
  debugLog(`Stream completed. Total chunks: ${totalChunks}`);
268
648
  controller.close();
269
649
  }
@@ -425,6 +805,52 @@ export class GthAbstractAgent {
425
805
  let aggregatedAIChunk = null;
426
806
  let reasoningOpen = false;
427
807
  const flushed = new Set();
808
+ // EXT-41: a content-policy refusal on this typed-event path was de-scoped by EXT-37 (there is
809
+ // no empty-response retry here, so no wrong-retry bug), but it still rendered as a SILENT empty
810
+ // turn. Capture it (first per-chunk signal wins; aggregate fallback at stream end) and surface
811
+ // it as a `text` event so every consumer (Ink TUI viewModel, AG-UI SSE) shows a clear notice.
812
+ let refusalInfo = null;
813
+ // TUI-C22 — one splitter for the whole stream so a <think> opened in one chunk and closed
814
+ // several chunks later is tracked across the boundary. Reset at message boundaries via flush().
815
+ const thinkSplitter = createThinkTagSplitter();
816
+ // TUI-C22 — emit ordered answer/reasoning segments, opening/closing the reasoning block as the
817
+ // kind switches. Shares `reasoningOpen` with the reasoning_content path so the two compose
818
+ // (a reasoning_content delta then think-derived reasoning stays one open block; answer text
819
+ // closes it), preserving the exact existing event sequence when no <think> tags are present.
820
+ function* emitSegments(segments) {
821
+ for (const seg of segments) {
822
+ if (seg.text.length === 0)
823
+ continue;
824
+ if (seg.kind === 'reasoning') {
825
+ if (!reasoningOpen) {
826
+ reasoningOpen = true;
827
+ yield { type: 'reasoning_start' };
828
+ }
829
+ yield { type: 'reasoning_delta', delta: seg.text };
830
+ }
831
+ else {
832
+ if (reasoningOpen) {
833
+ reasoningOpen = false;
834
+ yield { type: 'reasoning_end' };
835
+ }
836
+ yield { type: 'text', delta: seg.text };
837
+ }
838
+ }
839
+ }
840
+ // CFG-33 — emit a message's content segments in order. A segment already classified as the
841
+ // model's thinking (a Gemini `thought: true` block) goes straight to the reasoning channel;
842
+ // answer text still passes through the TUI-C22 think splitter, so an inline `<think>` tag is
843
+ // peeled exactly as before. With no reasoning block this is the previous `.text` behaviour.
844
+ function* emitContentSegments(segments) {
845
+ for (const segment of segments) {
846
+ if (segment.kind === 'reasoning') {
847
+ yield* emitSegments([segment]);
848
+ }
849
+ else {
850
+ yield* emitSegments(thinkSplitter.push(segment.text));
851
+ }
852
+ }
853
+ }
428
854
  function* flushAggregated() {
429
855
  if (!aggregatedAIChunk)
430
856
  return;
@@ -456,41 +882,37 @@ export class GthAbstractAgent {
456
882
  // GS2-16: fold every chunk (AIMessageChunk usage/tool_calls, ToolMessage name) into the
457
883
  // run tally so the TUI turn can record real token/tool data. Fail-soft.
458
884
  this.recordRunStats(chunk);
885
+ // EXT-41: reuse EXT-37's detector (do NOT fork a second one). First per-chunk signal wins;
886
+ // a ToolMessage / normal chunk yields null, so a normal turn never surfaces a false refusal.
887
+ if (!refusalInfo) {
888
+ refusalInfo = detectRefusal(chunk);
889
+ }
459
890
  if (AIMessageChunk.isInstance(chunk)) {
460
891
  aggregatedAIChunk = aggregatedAIChunk ? aggregatedAIChunk.concat(chunk) : chunk;
461
- // Reasoning deltas — Ollama (Qwen3, deepseek-r1) and Anthropic surface
462
- // thinking text in additional_kwargs.reasoning_content. Stream it as a
463
- // separate event series so clients can render it apart from the answer.
464
- const reasoningDelta = chunk.additional_kwargs?.reasoning_content;
465
- if (typeof reasoningDelta === 'string' && reasoningDelta.length > 0) {
466
- if (!reasoningOpen) {
467
- reasoningOpen = true;
468
- yield { type: 'reasoning_start' };
469
- }
470
- yield { type: 'reasoning_delta', delta: reasoningDelta };
471
- }
472
- // Yield text incrementally — use this chunk's text (delta), not the
473
- // aggregated content which is cumulative.
474
- if (chunk.text) {
475
- if (reasoningOpen) {
476
- reasoningOpen = false;
477
- yield { type: 'reasoning_end' };
478
- }
479
- yield { type: 'text', delta: chunk.text };
892
+ // Reasoning deltas — Ollama (Qwen3, deepseek-r1), Anthropic, and OpenRouter surface
893
+ // thinking in additional_kwargs.reasoning_content. Stream
894
+ // it as a separate event series so clients can render it apart from the answer.
895
+ const reasoningDelta = pickReasoningDelta(chunk.additional_kwargs);
896
+ if (reasoningDelta.length > 0) {
897
+ yield* emitSegments([{ kind: 'reasoning', text: reasoningDelta }]);
480
898
  }
899
+ // Yield text incrementally — use this chunk's text (delta), not the aggregated content
900
+ // which is cumulative. TUI-C22 routes it through the think splitter so inline
901
+ // <think>...</think> (buffered across chunks) is peeled into the reasoning channel and
902
+ // stripped from the answer; text with no think tags passes straight through unchanged.
903
+ // CFG-33 classifies the chunk's content blocks first, in order, so Gemini's `thought: true`
904
+ // blocks reach the reasoning channel instead of the answer; answer text still goes through
905
+ // the think splitter, thought text does not (it is already classified).
906
+ yield* emitContentSegments(segmentAssistantContent(chunk.content));
481
907
  }
482
908
  else if (AIMessage.isInstance(chunk)) {
483
909
  // Reasoning on a non-chunk AIMessage — a non-streamed / resumed thinking message
484
910
  // (e.g. a checkpoint replay) still carries its thinking in
485
- // additional_kwargs.reasoning_content. Mirror the AIMessageChunk branch and emit the
486
- // same reasoning event series, otherwise the thought is silently dropped (TUI-C15).
487
- const reasoningContent = chunk.additional_kwargs?.reasoning_content;
488
- if (typeof reasoningContent === 'string' && reasoningContent.length > 0) {
489
- if (!reasoningOpen) {
490
- reasoningOpen = true;
491
- yield { type: 'reasoning_start' };
492
- }
493
- yield { type: 'reasoning_delta', delta: reasoningContent };
911
+ // additional_kwargs.reasoning_content. Mirror the AIMessageChunk branch and emit the same
912
+ // reasoning event series, otherwise the thought is silently dropped (TUI-C15).
913
+ const reasoningContent = pickReasoningDelta(chunk.additional_kwargs);
914
+ if (reasoningContent.length > 0) {
915
+ yield* emitSegments([{ kind: 'reasoning', text: reasoningContent }]);
494
916
  }
495
917
  // Non-chunk AIMessage (e.g. on resumed runs) carries final tool_calls
496
918
  // directly; merge them into the aggregate so flushAggregated emits them.
@@ -501,15 +923,17 @@ export class GthAbstractAgent {
501
923
  });
502
924
  aggregatedAIChunk = aggregatedAIChunk ? aggregatedAIChunk.concat(synthetic) : synthetic;
503
925
  }
504
- if (chunk.text) {
505
- if (reasoningOpen) {
506
- reasoningOpen = false;
507
- yield { type: 'reasoning_end' };
508
- }
509
- yield { type: 'text', delta: chunk.text };
510
- }
926
+ yield* emitContentSegments(segmentAssistantContent(chunk.content));
927
+ // A non-chunk AIMessage is a COMPLETE message, not a delta — drain any residual now
928
+ // (an unterminated <think> becomes reasoning, a dangling partial becomes answer) so its
929
+ // buffered state never leaks into a subsequent message (TUI-C22).
930
+ yield* emitSegments(thinkSplitter.flush());
511
931
  }
512
932
  if (chunk instanceof ToolMessage) {
933
+ // TUI-C22 — drain buffered think text (emitting its segments, which may open/close
934
+ // reasoning) BEFORE closing the reasoning block, so a trailing reasoning slice can't land
935
+ // after reasoning_end or be dropped. A tool round ends the assistant message, so reset.
936
+ yield* emitSegments(thinkSplitter.flush());
513
937
  if (reasoningOpen) {
514
938
  reasoningOpen = false;
515
939
  yield { type: 'reasoning_end' };
@@ -532,12 +956,27 @@ export class GthAbstractAgent {
532
956
  };
533
957
  }
534
958
  }
959
+ // TUI-C22 — drain any buffered think text at stream end (an unterminated <think> surfaces as
960
+ // reasoning, a dangling partial as answer) before closing the reasoning block.
961
+ yield* emitSegments(thinkSplitter.flush());
535
962
  // Close any still-open reasoning block before flushing tool calls.
536
963
  if (reasoningOpen) {
537
964
  yield { type: 'reasoning_end' };
538
965
  }
539
966
  // Flush any tool calls not followed by a ToolMessage (e.g. terminal tool calls).
540
967
  yield* flushAggregated();
968
+ // EXT-41: aggregate-level fallback (I-1's robustness on this path too) — if no per-chunk
969
+ // metadata flagged a refusal, inspect the final aggregated message's stop/finish reason. Then
970
+ // surface any refusal as a `text` event so the user sees a clear notice instead of a silent
971
+ // empty turn. No statusUpdate here: consumers render the typed events, and a WARNING would
972
+ // double-render in the TUI.
973
+ if (!refusalInfo && aggregatedAIChunk) {
974
+ refusalInfo = detectRefusal(aggregatedAIChunk);
975
+ }
976
+ if (refusalInfo) {
977
+ debugLog(`Content-policy refusal detected on typed-event path (provider=${refusalInfo.provider} reason=${refusalInfo.reason})`);
978
+ yield { type: 'text', delta: buildRefusalMessage(refusalInfo) };
979
+ }
541
980
  }
542
981
  async cleanup() {
543
982
  debugLog('Cleaning up agent...');