@gaunt-sloth/core 2.0.0-alpha.3 → 2.0.0-alpha.31

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 (271) hide show
  1. package/README.md +52 -20
  2. package/dist/config/colour.d.ts +38 -0
  3. package/dist/config/colour.js +36 -0
  4. package/dist/config/colour.js.map +1 -0
  5. package/dist/config/defaults.d.ts +84 -0
  6. package/dist/config/defaults.js +99 -0
  7. package/dist/config/defaults.js.map +1 -0
  8. package/dist/config/filesystem-tools.d.ts +41 -0
  9. package/dist/config/filesystem-tools.js +56 -0
  10. package/dist/config/filesystem-tools.js.map +1 -0
  11. package/dist/config/jsonc.d.ts +12 -0
  12. package/dist/config/jsonc.js +41 -0
  13. package/dist/config/jsonc.js.map +1 -0
  14. package/dist/config/loader.d.ts +246 -0
  15. package/dist/config/loader.js +1321 -0
  16. package/dist/config/loader.js.map +1 -0
  17. package/dist/config/mouse.d.ts +50 -0
  18. package/dist/config/mouse.js +44 -0
  19. package/dist/config/mouse.js.map +1 -0
  20. package/dist/config/profiles.d.ts +68 -0
  21. package/dist/config/profiles.js +93 -0
  22. package/dist/config/profiles.js.map +1 -0
  23. package/dist/config/providerKeys.d.ts +69 -0
  24. package/dist/config/providerKeys.js +69 -0
  25. package/dist/config/providerKeys.js.map +1 -0
  26. package/dist/config/schema.d.ts +2999 -0
  27. package/dist/config/schema.js +1519 -0
  28. package/dist/config/schema.js.map +1 -0
  29. package/dist/config/shell-policy.d.ts +896 -0
  30. package/dist/config/shell-policy.js +750 -0
  31. package/dist/config/shell-policy.js.map +1 -0
  32. package/dist/config/tool-descriptions.d.ts +211 -0
  33. package/dist/config/tool-descriptions.js +272 -0
  34. package/dist/config/tool-descriptions.js.map +1 -0
  35. package/dist/config/types.d.ts +760 -0
  36. package/dist/config/types.js +13 -0
  37. package/dist/config/types.js.map +1 -0
  38. package/dist/config.d.ts +34 -827
  39. package/dist/config.js +25 -657
  40. package/dist/config.js.map +1 -1
  41. package/dist/constants.d.ts +46 -0
  42. package/dist/constants.js +46 -0
  43. package/dist/constants.js.map +1 -1
  44. package/dist/core/GthAbstractAgent.d.ts +135 -2
  45. package/dist/core/GthAbstractAgent.js +496 -30
  46. package/dist/core/GthAbstractAgent.js.map +1 -1
  47. package/dist/core/GthAgentRunner.d.ts +568 -54
  48. package/dist/core/GthAgentRunner.js +1537 -136
  49. package/dist/core/GthAgentRunner.js.map +1 -1
  50. package/dist/core/GthLangChainAgent.d.ts +115 -0
  51. package/dist/core/GthLangChainAgent.js +644 -17
  52. package/dist/core/GthLangChainAgent.js.map +1 -1
  53. package/dist/core/approvals/annotations.d.ts +122 -0
  54. package/dist/core/approvals/annotations.js +137 -0
  55. package/dist/core/approvals/annotations.js.map +1 -0
  56. package/dist/core/approvals/grants.d.ts +216 -0
  57. package/dist/core/approvals/grants.js +469 -0
  58. package/dist/core/approvals/grants.js.map +1 -0
  59. package/dist/core/approvals/matcher.d.ts +202 -0
  60. package/dist/core/approvals/matcher.js +267 -0
  61. package/dist/core/approvals/matcher.js.map +1 -0
  62. package/dist/core/approvals/mcpSubjects.d.ts +40 -0
  63. package/dist/core/approvals/mcpSubjects.js +99 -0
  64. package/dist/core/approvals/mcpSubjects.js.map +1 -0
  65. package/dist/core/approvals/toolAnnotationSources.d.ts +105 -0
  66. package/dist/core/approvals/toolAnnotationSources.js +277 -0
  67. package/dist/core/approvals/toolAnnotationSources.js.map +1 -0
  68. package/dist/core/approvals/toolHost.d.ts +46 -0
  69. package/dist/core/approvals/toolHost.js +108 -0
  70. package/dist/core/approvals/toolHost.js.map +1 -0
  71. package/dist/core/debugCapture.d.ts +75 -0
  72. package/dist/core/debugCapture.js +101 -0
  73. package/dist/core/debugCapture.js.map +1 -0
  74. package/dist/core/gthLeanAgentFactory.d.ts +9 -0
  75. package/dist/core/gthLeanAgentFactory.js +10 -0
  76. package/dist/core/gthLeanAgentFactory.js.map +1 -0
  77. package/dist/core/launchBanner.d.ts +120 -0
  78. package/dist/core/launchBanner.js +418 -0
  79. package/dist/core/launchBanner.js.map +1 -0
  80. package/dist/core/plainToolIndication.d.ts +15 -0
  81. package/dist/core/plainToolIndication.js +174 -0
  82. package/dist/core/plainToolIndication.js.map +1 -0
  83. package/dist/core/reasoningBlocks.d.ts +60 -0
  84. package/dist/core/reasoningBlocks.js +98 -0
  85. package/dist/core/reasoningBlocks.js.map +1 -0
  86. package/dist/core/refusal.d.ts +53 -0
  87. package/dist/core/refusal.js +133 -0
  88. package/dist/core/refusal.js.map +1 -0
  89. package/dist/core/runStats.d.ts +52 -0
  90. package/dist/core/runStats.js +118 -0
  91. package/dist/core/runStats.js.map +1 -0
  92. package/dist/core/shell/ShellCommandFailedError.d.ts +54 -0
  93. package/dist/core/shell/ShellCommandFailedError.js +68 -0
  94. package/dist/core/shell/ShellCommandFailedError.js.map +1 -0
  95. package/dist/core/shell/abstention.d.ts +88 -0
  96. package/dist/core/shell/abstention.js +184 -0
  97. package/dist/core/shell/abstention.js.map +1 -0
  98. package/dist/core/shell/approvalCapture.d.ts +271 -0
  99. package/dist/core/shell/approvalCapture.js +108 -0
  100. package/dist/core/shell/approvalCapture.js.map +1 -0
  101. package/dist/core/shell/approvalStop.d.ts +84 -0
  102. package/dist/core/shell/approvalStop.js +119 -0
  103. package/dist/core/shell/approvalStop.js.map +1 -0
  104. package/dist/core/shell/arity.d.ts +6 -0
  105. package/dist/core/shell/arity.js +20 -6
  106. package/dist/core/shell/arity.js.map +1 -1
  107. package/dist/core/shell/denylist.d.ts +11 -0
  108. package/dist/core/shell/denylist.js +37 -0
  109. package/dist/core/shell/denylist.js.map +1 -0
  110. package/dist/core/shell/escalationSeverity.d.ts +141 -0
  111. package/dist/core/shell/escalationSeverity.js +89 -0
  112. package/dist/core/shell/escalationSeverity.js.map +1 -0
  113. package/dist/core/shell/framing.d.ts +190 -0
  114. package/dist/core/shell/framing.js +633 -0
  115. package/dist/core/shell/framing.js.map +1 -0
  116. package/dist/core/shell/hardline.d.ts +81 -0
  117. package/dist/core/shell/hardline.js +607 -0
  118. package/dist/core/shell/hardline.js.map +1 -0
  119. package/dist/core/shell/negotiation.d.ts +249 -0
  120. package/dist/core/shell/negotiation.js +355 -0
  121. package/dist/core/shell/negotiation.js.map +1 -0
  122. package/dist/core/shell/normalize.d.ts +44 -4
  123. package/dist/core/shell/normalize.js +61 -7
  124. package/dist/core/shell/normalize.js.map +1 -1
  125. package/dist/core/shell/openWorld.d.ts +263 -0
  126. package/dist/core/shell/openWorld.js +1188 -0
  127. package/dist/core/shell/openWorld.js.map +1 -0
  128. package/dist/core/shell/rater.d.ts +935 -0
  129. package/dist/core/shell/rater.js +1473 -0
  130. package/dist/core/shell/rater.js.map +1 -0
  131. package/dist/core/shell/raterModel.d.ts +41 -0
  132. package/dist/core/shell/raterModel.js +51 -0
  133. package/dist/core/shell/raterModel.js.map +1 -0
  134. package/dist/core/shell/rejection.d.ts +69 -0
  135. package/dist/core/shell/rejection.js +38 -0
  136. package/dist/core/shell/rejection.js.map +1 -0
  137. package/dist/core/toolCallRepair/grammar.d.ts +41 -0
  138. package/dist/core/toolCallRepair/grammar.js +116 -0
  139. package/dist/core/toolCallRepair/grammar.js.map +1 -0
  140. package/dist/core/toolCallRepair/index.d.ts +2 -0
  141. package/dist/core/toolCallRepair/index.js +7 -0
  142. package/dist/core/toolCallRepair/index.js.map +1 -0
  143. package/dist/core/toolCallRepair/payload.d.ts +36 -0
  144. package/dist/core/toolCallRepair/payload.js +341 -0
  145. package/dist/core/toolCallRepair/payload.js.map +1 -0
  146. package/dist/core/toolCallRepair/promote.d.ts +45 -0
  147. package/dist/core/toolCallRepair/promote.js +90 -0
  148. package/dist/core/toolCallRepair/promote.js.map +1 -0
  149. package/dist/core/toolDisplay.d.ts +123 -0
  150. package/dist/core/toolDisplay.js +451 -0
  151. package/dist/core/toolDisplay.js.map +1 -0
  152. package/dist/core/toolOutputChannel.d.ts +95 -0
  153. package/dist/core/toolOutputChannel.js +165 -0
  154. package/dist/core/toolOutputChannel.js.map +1 -0
  155. package/dist/core/types.d.ts +294 -12
  156. package/dist/core/types.js.map +1 -1
  157. package/dist/history/historyFormat.d.ts +28 -0
  158. package/dist/history/historyFormat.js +127 -0
  159. package/dist/history/historyFormat.js.map +1 -0
  160. package/dist/history/historyStore.d.ts +198 -0
  161. package/dist/history/historyStore.js +482 -0
  162. package/dist/history/historyStore.js.map +1 -0
  163. package/dist/history/recordSession.d.ts +37 -0
  164. package/dist/history/recordSession.js +56 -0
  165. package/dist/history/recordSession.js.map +1 -0
  166. package/dist/index.d.ts +4 -0
  167. package/dist/index.js +4 -0
  168. package/dist/index.js.map +1 -1
  169. package/dist/providers/anthropic.d.ts +1 -1
  170. package/dist/providers/anthropic.js +5 -10
  171. package/dist/providers/anthropic.js.map +1 -1
  172. package/dist/providers/deepseek.d.ts +1 -1
  173. package/dist/providers/deepseek.js +5 -10
  174. package/dist/providers/deepseek.js.map +1 -1
  175. package/dist/providers/geminiSchemaSanitizer.d.ts +55 -0
  176. package/dist/providers/geminiSchemaSanitizer.js +347 -0
  177. package/dist/providers/geminiSchemaSanitizer.js.map +1 -0
  178. package/dist/providers/geminiThinking.d.ts +52 -0
  179. package/dist/providers/geminiThinking.js +72 -0
  180. package/dist/providers/geminiThinking.js.map +1 -0
  181. package/dist/providers/google-genai.d.ts +1 -1
  182. package/dist/providers/google-genai.js +12 -11
  183. package/dist/providers/google-genai.js.map +1 -1
  184. package/dist/providers/groq.d.ts +1 -1
  185. package/dist/providers/groq.js +5 -10
  186. package/dist/providers/groq.js.map +1 -1
  187. package/dist/providers/huggingface.d.ts +25 -0
  188. package/dist/providers/huggingface.js +69 -0
  189. package/dist/providers/huggingface.js.map +1 -0
  190. package/dist/providers/modelCatalog.d.ts +109 -0
  191. package/dist/providers/modelCatalog.js +245 -0
  192. package/dist/providers/modelCatalog.js.map +1 -0
  193. package/dist/providers/modelDiscovery.d.ts +153 -6
  194. package/dist/providers/modelDiscovery.js +295 -41
  195. package/dist/providers/modelDiscovery.js.map +1 -1
  196. package/dist/providers/ollama.d.ts +19 -5
  197. package/dist/providers/ollama.js +60 -52
  198. package/dist/providers/ollama.js.map +1 -1
  199. package/dist/providers/openai.d.ts +1 -1
  200. package/dist/providers/openai.js +39 -10
  201. package/dist/providers/openai.js.map +1 -1
  202. package/dist/providers/openrouter.d.ts +4 -5
  203. package/dist/providers/openrouter.js +20 -35
  204. package/dist/providers/openrouter.js.map +1 -1
  205. package/dist/providers/vertexai.d.ts +1 -1
  206. package/dist/providers/vertexai.js +12 -11
  207. package/dist/providers/vertexai.js.map +1 -1
  208. package/dist/providers/xai.d.ts +1 -1
  209. package/dist/providers/xai.js +5 -10
  210. package/dist/providers/xai.js.map +1 -1
  211. package/dist/runtime/askStructured.d.ts +105 -0
  212. package/dist/runtime/askStructured.js +120 -0
  213. package/dist/runtime/askStructured.js.map +1 -0
  214. package/dist/runtime/conversation.d.ts +60 -0
  215. package/dist/runtime/conversation.js +151 -0
  216. package/dist/runtime/conversation.js.map +1 -0
  217. package/dist/runtime/singleShot.d.ts +25 -5
  218. package/dist/runtime/singleShot.js +95 -36
  219. package/dist/runtime/singleShot.js.map +1 -1
  220. package/dist/runtime/structuredOutput.d.ts +104 -0
  221. package/dist/runtime/structuredOutput.js +393 -0
  222. package/dist/runtime/structuredOutput.js.map +1 -0
  223. package/dist/utils/ProgressIndicator.d.ts +21 -0
  224. package/dist/utils/ProgressIndicator.js +30 -3
  225. package/dist/utils/ProgressIndicator.js.map +1 -1
  226. package/dist/utils/aiignoreUtils.js.map +1 -1
  227. package/dist/utils/binaryOutputUtils.js.map +1 -1
  228. package/dist/utils/consoleUtils.d.ts +52 -0
  229. package/dist/utils/consoleUtils.js +72 -2
  230. package/dist/utils/consoleUtils.js.map +1 -1
  231. package/dist/utils/crashHandler.d.ts +87 -0
  232. package/dist/utils/crashHandler.js +128 -0
  233. package/dist/utils/crashHandler.js.map +1 -0
  234. package/dist/utils/debugDump.d.ts +134 -0
  235. package/dist/utils/debugDump.js +381 -0
  236. package/dist/utils/debugDump.js.map +1 -0
  237. package/dist/utils/debugUtils.d.ts +13 -4
  238. package/dist/utils/debugUtils.js +36 -13
  239. package/dist/utils/debugUtils.js.map +1 -1
  240. package/dist/utils/displayWidth.d.ts +53 -0
  241. package/dist/utils/displayWidth.js +195 -0
  242. package/dist/utils/displayWidth.js.map +1 -0
  243. package/dist/utils/fileUtils.d.ts +24 -2
  244. package/dist/utils/fileUtils.js +54 -12
  245. package/dist/utils/fileUtils.js.map +1 -1
  246. package/dist/utils/llmUtils.d.ts +39 -8
  247. package/dist/utils/llmUtils.js +76 -8
  248. package/dist/utils/llmUtils.js.map +1 -1
  249. package/dist/utils/redactSecrets.d.ts +63 -0
  250. package/dist/utils/redactSecrets.js +286 -0
  251. package/dist/utils/redactSecrets.js.map +1 -0
  252. package/dist/utils/systemPromptNotes.d.ts +225 -0
  253. package/dist/utils/systemPromptNotes.js +340 -0
  254. package/dist/utils/systemPromptNotes.js.map +1 -0
  255. package/dist/utils/systemUtils.d.ts +49 -1
  256. package/dist/utils/systemUtils.js +67 -3
  257. package/dist/utils/systemUtils.js.map +1 -1
  258. package/dist/utils/toolMatching.d.ts +30 -0
  259. package/dist/utils/toolMatching.js +44 -0
  260. package/dist/utils/toolMatching.js.map +1 -0
  261. package/dist/utils/untrustedText.d.ts +66 -0
  262. package/dist/utils/untrustedText.js +80 -0
  263. package/dist/utils/untrustedText.js.map +1 -0
  264. package/package.json +22 -6
  265. package/schema/gsloth-config.schema.json +3134 -0
  266. package/dist/core/shell/allowlist.d.ts +0 -75
  267. package/dist/core/shell/allowlist.js +0 -187
  268. package/dist/core/shell/allowlist.js.map +0 -1
  269. package/dist/core/shell/judge.d.ts +0 -161
  270. package/dist/core/shell/judge.js +0 -261
  271. package/dist/core/shell/judge.js.map +0 -1
@@ -1,4 +1,8 @@
1
+ import { applyRungAwareToolDescriptions, } from '#src/config.js';
1
2
  import { StatusLevel, } from '#src/core/types.js';
3
+ import { accumulateMessage, createRunStatsAccumulator, finalizeRunStats, } from '#src/core/runStats.js';
4
+ import { collectDeclaredMcpToolAnnotations } from '#src/core/approvals/toolAnnotationSources.js';
5
+ import { createPlainToolIndication } from '#src/core/plainToolIndication.js';
2
6
  import { debugLog, debugLogError, debugLogObject } from '#src/utils/debugUtils.js';
3
7
  import { ProgressIndicator } from '#src/utils/ProgressIndicator.js';
4
8
  import { stopWaitingForEscape, waitForEscape } from '#src/utils/systemUtils.js';
@@ -6,6 +10,112 @@ import { AIMessage, AIMessageChunk, ToolMessage } from '@langchain/core/messages
6
10
  import { IterableReadableStream } from '@langchain/core/utils/stream';
7
11
  import { interrupt, Command, GraphInterrupt } from '@langchain/langgraph';
8
12
  import { extractInlineBinaryBlocks, materializeBinaryOutputs, renderAssistantContent, } from '#src/utils/binaryOutputUtils.js';
13
+ import { detectRefusal, buildRefusalMessage } from '#src/core/refusal.js';
14
+ import { answerTextOf, segmentAssistantContent, stripReasoningBlocks, } from '#src/core/reasoningBlocks.js';
15
+ const THINK_OPEN = '<think>';
16
+ const THINK_CLOSE = '</think>';
17
+ /**
18
+ * TUI-C22 — length of the longest suffix of `s` that is a *proper* (shorter-than-full) prefix of
19
+ * `tag`. Used by {@link createThinkTagSplitter} to hold back a trailing partial that might complete
20
+ * into `tag` on the next chunk (e.g. a chunk ending in `<thi` when the tag is `<think>`).
21
+ */
22
+ function trailingPartialLen(s, tag) {
23
+ const max = Math.min(s.length, tag.length - 1);
24
+ for (let k = max; k > 0; k--) {
25
+ if (s.slice(s.length - k) === tag.slice(0, k))
26
+ return k;
27
+ }
28
+ return 0;
29
+ }
30
+ /**
31
+ * TUI-C22 — stateful separator of inline `<think>...</think>` thinking from answer text, robust to
32
+ * tags split across streamed chunks. Many thinking models served over an OpenAI-compatible `/v1`
33
+ * shim (qwen3 / deepseek-r1 over Ollama) inline their reasoning as `<think>…</think>` in the
34
+ * message `content` rather than in `additional_kwargs.reasoning_content`; without this it would
35
+ * render as answer text and the `/reasoning` panel would stay empty.
36
+ *
37
+ * `push(text)` returns the segments it can classify unambiguously *now*, buffering any trailing
38
+ * partial tag (so a `<think>` arriving as `<thi` + `nk>` across two chunks is still detected) and
39
+ * the run of thinking between an open and a not-yet-seen close tag. `flush()` drains the buffer at
40
+ * a message/stream boundary: an unterminated `<think>` at EOF yields its remainder as reasoning; a
41
+ * dangling non-tag partial (e.g. a lone `<` or `<thi` that never completed) yields as answer, so no
42
+ * text is ever dropped. Purely additive — text with no `<think>` passes straight through as answer.
43
+ */
44
+ function createThinkTagSplitter() {
45
+ let buffer = '';
46
+ let inThink = false;
47
+ function push(text) {
48
+ const segments = [];
49
+ if (text.length === 0 && buffer.length === 0)
50
+ return segments;
51
+ buffer += text;
52
+ for (;;) {
53
+ if (inThink) {
54
+ const idx = buffer.indexOf(THINK_CLOSE);
55
+ if (idx >= 0) {
56
+ if (idx > 0)
57
+ segments.push({ kind: 'reasoning', text: buffer.slice(0, idx) });
58
+ buffer = buffer.slice(idx + THINK_CLOSE.length);
59
+ inThink = false;
60
+ continue;
61
+ }
62
+ // No full close tag yet — emit reasoning except a trailing partial of `</think>`.
63
+ const hold = trailingPartialLen(buffer, THINK_CLOSE);
64
+ const emit = buffer.slice(0, buffer.length - hold);
65
+ if (emit.length > 0)
66
+ segments.push({ kind: 'reasoning', text: emit });
67
+ buffer = hold > 0 ? buffer.slice(buffer.length - hold) : '';
68
+ break;
69
+ }
70
+ else {
71
+ const idx = buffer.indexOf(THINK_OPEN);
72
+ if (idx >= 0) {
73
+ if (idx > 0)
74
+ segments.push({ kind: 'answer', text: buffer.slice(0, idx) });
75
+ buffer = buffer.slice(idx + THINK_OPEN.length);
76
+ inThink = true;
77
+ continue;
78
+ }
79
+ // No full open tag yet — emit answer except a trailing partial of `<think>`.
80
+ const hold = trailingPartialLen(buffer, THINK_OPEN);
81
+ const emit = buffer.slice(0, buffer.length - hold);
82
+ if (emit.length > 0)
83
+ segments.push({ kind: 'answer', text: emit });
84
+ buffer = hold > 0 ? buffer.slice(buffer.length - hold) : '';
85
+ break;
86
+ }
87
+ }
88
+ return segments;
89
+ }
90
+ function flush() {
91
+ const segments = [];
92
+ if (buffer.length > 0) {
93
+ segments.push({ kind: inThink ? 'reasoning' : 'answer', text: buffer });
94
+ }
95
+ buffer = '';
96
+ inThink = false;
97
+ return segments;
98
+ }
99
+ return { push, flush };
100
+ }
101
+ /**
102
+ * Pick this chunk's or message's reasoning delta/content.
103
+ * Precedence:
104
+ * 1. `additional_kwargs.reasoning_content` — standard DeepSeek/Anthropic/OpenRouter convention.
105
+ * 2. `additional_kwargs.reasoning` — direct reasoning fallback if present.
106
+ */
107
+ function pickReasoningDelta(kwargs) {
108
+ if (!kwargs)
109
+ return '';
110
+ const reasoningContent = kwargs.reasoning_content;
111
+ if (typeof reasoningContent === 'string' && reasoningContent.length > 0) {
112
+ return reasoningContent;
113
+ }
114
+ const direct = kwargs.reasoning;
115
+ if (typeof direct === 'string' && direct.length > 0)
116
+ return direct;
117
+ return '';
118
+ }
9
119
  /**
10
120
  * Shared, graph-agnostic agent plumbing.
11
121
  *
@@ -25,12 +135,183 @@ export class GthAbstractAgent {
25
135
  agent = null;
26
136
  config = null;
27
137
  command = undefined;
138
+ /**
139
+ * Opt-in debug sink for the TUI `/debug` panel. Set AFTER {@link init} via
140
+ * `runner.getAgent()`; read lazily inside each backend's `wrapModelCall` capture middleware
141
+ * so that when it is `undefined` (the normal path) the middleware is a transparent
142
+ * pass-through. Lives on the base so BOTH the lean and deep backends support it; the AG-UI
143
+ * server / non-TUI callers simply never set it, so those contracts are unchanged.
144
+ */
145
+ debugCapture;
146
+ /**
147
+ * GS2-56 — the ALWAYS-ON snapshot of the most recent model request (extras + the as-sent,
148
+ * post-summarization messages), populated UNCONDITIONALLY at each backend's `wrapModelCall` feed
149
+ * site — NOT gated on {@link debugCapture} being attached. This is what lets `/debug-dump` render
150
+ * the full model input even when the TUI `/debug` panel was never opened and on non-TUI surfaces
151
+ * (the sink only ever fed the live `/debug` panel). O(1): a single overwritten reference retaining
152
+ * only the LAST call — no accumulation, so the "pay nothing until you need it" intent is kept.
153
+ */
154
+ lastModelRequest;
155
+ /**
156
+ * GS2-56 — stash the last model request (the as-sent messages + {@link DebugRequestExtras}).
157
+ * Called unconditionally from each backend's capture middleware, independent of the debug sink,
158
+ * so the snapshot is available to `/debug-dump` on every surface. Overwrites (retains only the
159
+ * most recent call). Callers already guard the invocation; kept trivial so it can never throw.
160
+ */
161
+ setLastModelRequest(messages, extras) {
162
+ this.lastModelRequest = { messages, extras };
163
+ }
164
+ /**
165
+ * GS2-16 — per-run analytics tally (token usage + invoked tool names) folded from the messages
166
+ * flowing through {@link invoke} / the streaming paths. Reset at each turn boundary via
167
+ * {@link resetRunStats} (the runner is reused across turns), read via {@link getRunStats}, and
168
+ * fully fail-soft (accumulation is guarded and never throws into a run).
169
+ */
170
+ runStatsAcc = createRunStatsAccumulator();
171
+ /**
172
+ * EXT-58 — the names of the tools registered with the graph at the last {@link init}, recorded by
173
+ * {@link registerApprovalsAwareTools}. Read by `GthAgentRunner` to build the rater's
174
+ * granted-built-in list (§4.4), so a suggestion can only ever name a tool the model actually has.
175
+ */
176
+ registeredToolNames = [];
177
+ /**
178
+ * EXT-70 §4.7.1 — what the connected MCP servers DECLARED about their own tools, captured from
179
+ * the same registration hook as {@link registeredToolNames} and keyed by the registered tool
180
+ * name. Read by `GthAgentRunner` as the `mcp` half of a `DeclaredToolAnnotationLookup`.
181
+ *
182
+ * It is a record of claims, never of decisions: no trust is applied here (that is
183
+ * `createEffectiveToolAnnotationSource`'s only job), and an absent tool yields the fail-closed
184
+ * defaults rather than "declared nothing".
185
+ */
186
+ declaredMcpToolAnnotations = new Map();
28
187
  constructor(statusUpdate, resolvers) {
29
188
  this.statusUpdate = (level, message) => {
30
189
  statusUpdate(level, message);
31
190
  };
32
191
  this.resolvers = resolvers;
33
192
  }
193
+ /**
194
+ * GS2-63 — emit one line of the technical run-header preamble (the Workdir/Model/Tools/Middleware
195
+ * block) UNLESS it is opted out via `output.header: false`. The opt-out only ever reaches here in
196
+ * non-TUI text modes: the interactive TUI forces `output.header` on before init (see
197
+ * `createTuiSession`), and the TUI event path never goes through the interrupt-hint site, so the
198
+ * whole preamble stays visible there. Only INFO header lines route through this — real model/tool
199
+ * output, warnings and errors keep using {@link statusUpdate} directly.
200
+ */
201
+ headerStatus(message) {
202
+ if (this.config?.output?.header === false)
203
+ return;
204
+ this.statusUpdate(StatusLevel.INFO, message);
205
+ }
206
+ /**
207
+ * EXT-58 (spec §4.5) — the ONE tool-registration hook both backends call with their final tool
208
+ * array, just before handing it to the graph builder. It does two things:
209
+ *
210
+ * 1. Appends the rung's approval sentence to every tool that is **not** auto-approved at that
211
+ * rung, and leaves every granted tool's description untouched (the absence of the sentence is
212
+ * what marks a tool free). See {@link applyRungAwareToolDescriptions}.
213
+ * 2. Records the registered tool names for {@link getRegisteredToolNames}, which feeds the
214
+ * rater's granted-alternative list (§4.4).
215
+ * 3. EXT-70 §4.7.1 — records what the MCP servers declared about their own tools, for
216
+ * {@link getDeclaredMcpToolAnnotations}. This is the ONE place a `tools/list` annotation
217
+ * enters the approvals stack, and it enters as a claim: nothing here decides whether it is
218
+ * believed.
219
+ *
220
+ * `gatedTools` MUST be the **LIVE gated set for the rung in force** — `resolveGatedToolNames` for
221
+ * that rung — and NOT the set the caller wires into the approval interrupt. The two are different
222
+ * on purpose: the interrupt is installed once, at agent init, and is deliberately
223
+ * rung-independent (`resolveInterruptToolNames`, the union over every rung) so that
224
+ * `/approvals <mode>` can move the mode underneath it for the rest of the session. Passing that
225
+ * wider set here would describe tools as needing approval that the live mode does not gate — and
226
+ * a call the live mode does not gate is auto-approved the moment it reaches the runner, so the
227
+ * sentence would be a promise nothing keeps.
228
+ *
229
+ * What keeps a description from promising an approval the gate will not ask for is therefore that
230
+ * both this and `GthAgentRunner`'s own check are projections of the SAME rule,
231
+ * `isToolGatedAtRung`, evaluated against the SAME live mode — §4.5's "a description that disagrees
232
+ * with what the gate will actually do is worse than no description at all".
233
+ *
234
+ * `additionalToolNames` covers tools the graph builder registers itself and that therefore never
235
+ * appear in `tools` — deepagents' own filesystem tools on the deep backend. Their descriptions
236
+ * are deepagents', not ours, so they cannot be suffixed here; they are recorded only so the
237
+ * rater's suggestion list reflects what the deep model actually has.
238
+ */
239
+ registerApprovalsAwareTools(tools, options) {
240
+ applyRungAwareToolDescriptions(tools, {
241
+ rung: options.rung,
242
+ gatedTools: options.gatedTools,
243
+ });
244
+ const names = tools
245
+ .map((tool) => tool?.name)
246
+ .filter((name) => typeof name === 'string' && name.length > 0);
247
+ this.registeredToolNames = [...names, ...(options.additionalToolNames ?? [])];
248
+ // `additionalToolNames` are deliberately NOT consulted: they are names the graph builder
249
+ // registers itself, with no tool object and therefore no declaration to read.
250
+ this.declaredMcpToolAnnotations = collectDeclaredMcpToolAnnotations(tools);
251
+ return tools;
252
+ }
253
+ /**
254
+ * EXT-58 — the tool names registered with the graph at the last {@link init} (empty before it).
255
+ * The runner intersects these with the built-in summaries table to build the rater's
256
+ * granted-alternative list, so the rater can never name a tool this session does not have.
257
+ */
258
+ getRegisteredToolNames() {
259
+ return [...this.registeredToolNames];
260
+ }
261
+ /**
262
+ * EXT-70 §4.7.1 — what the MCP servers declared for their tools at the last {@link init}, keyed
263
+ * by registered tool name (empty before it, and on a session with no MCP servers).
264
+ */
265
+ getDeclaredMcpToolAnnotations() {
266
+ return this.declaredMcpToolAnnotations;
267
+ }
268
+ /**
269
+ * GS2-16 — clear the per-run analytics tally so the next turn starts from zero. The runner
270
+ * calls this at each turn boundary because it (and this agent) are reused across turns in an
271
+ * interactive session.
272
+ */
273
+ resetRunStats() {
274
+ this.runStatsAcc = createRunStatsAccumulator();
275
+ }
276
+ /** GS2-16 — the analytics harvested since the last {@link resetRunStats}. Never throws. */
277
+ getRunStats() {
278
+ return finalizeRunStats(this.runStatsAcc);
279
+ }
280
+ /** GS2-16 — fold one message (or chunk) into the run tally. Fully guarded (fail-soft). */
281
+ recordRunStats(message) {
282
+ accumulateMessage(this.runStatsAcc, message);
283
+ }
284
+ /**
285
+ * GS2-16 — best-effort count of messages already in the checkpointed thread state, used by
286
+ * {@link invoke} as the baseline so it harvests only THIS turn's new messages rather than the
287
+ * whole accumulated conversation a checkpointer returns. Fail-soft: a missing `getState`, an odd
288
+ * state shape, or any error yields 0 (worst case a one-turn over-count, never a throw).
289
+ */
290
+ async getStateMessageCount(runConfig) {
291
+ try {
292
+ if (!this.agent || typeof this.agent.getState !== 'function')
293
+ return 0;
294
+ const state = await this.agent.getState(runConfig);
295
+ const messages = state?.values?.messages;
296
+ return Array.isArray(messages) ? messages.length : 0;
297
+ }
298
+ catch {
299
+ return 0;
300
+ }
301
+ }
302
+ /**
303
+ * EXT-37 — surface a detected content-policy refusal: emit the clear, user-facing explanation at
304
+ * WARNING level (an empty-content refusal streams nothing, so without this the console shows
305
+ * nothing) and return the same message so it becomes the turn's terminal answer. Shared by the
306
+ * non-streaming {@link invoke} and streaming {@link streamFromInput} paths so both render a
307
+ * refusal identically. A refusal is a *successful* (if declined) response — never a retry.
308
+ */
309
+ surfaceRefusal(info) {
310
+ const message = buildRefusalMessage(info);
311
+ debugLog(`Content-policy refusal detected (provider=${info.provider} reason=${info.reason})`);
312
+ this.statusUpdate(StatusLevel.WARNING, message);
313
+ return message;
314
+ }
34
315
  /**
35
316
  * Invoke LLM with a message and runnable config.
36
317
  * For streaming use {@link #stream} method, streaming is preferred if model API supports it.
@@ -48,9 +329,50 @@ export class GthAbstractAgent {
48
329
  const progress = new ProgressIndicator('Thinking.');
49
330
  try {
50
331
  debugLog('Calling agent.invoke...');
332
+ // GS2-16: capture the prior conversation length BEFORE invoking so we harvest ONLY this
333
+ // turn's NEW messages. With a checkpointer + persistent thread (a multi-turn `--no-tui`
334
+ // interactive session with `streamOutput: false`), `response.messages` is the FULL
335
+ // accumulated conversation, not just this turn — folding all of it would re-sum prior
336
+ // turns' usage_metadata and re-collect prior tools (per-turn over-count). This baseline
337
+ // slice also prevents a double-harvest by the empty-stream fallback invoke in
338
+ // GthAgentRunner: by then the streamed turn is checkpointed, so it is BEFORE the baseline.
339
+ // Fail-soft: an unreadable baseline yields 0 (a one-turn over-count at worst, never a throw).
340
+ const priorMessageCount = await this.getStateMessageCount(runConfig);
51
341
  const response = await this.agent.invoke({ messages }, runConfig);
342
+ // Harvest token usage + invoked tool names from THIS turn's new messages only (fail-soft)
343
+ // so the opt-in history recorder can populate `gth insights`.
344
+ // TUI-C32 residual f — the streaming path renders the compact per-tool indication via
345
+ // streamFromInput's observer; the non-streaming invoke path (`streamOutput: false`) had
346
+ // none, so a plain-surface tool call surfaced nothing after the legacy fs notices were
347
+ // dropped (residual b). Feed THIS turn's new messages through the SAME observer so each
348
+ // tool call gets its `✓ 📁 name(args…)` block here too. Only the plain surface reaches
349
+ // invoke (the TUI uses processMessagesWithEvents); observe() is fail-soft internally.
350
+ const allMessages = Array.isArray(response.messages) ? response.messages : [];
351
+ const toolIndication = createPlainToolIndication();
352
+ for (const m of allMessages.slice(priorMessageCount)) {
353
+ this.recordRunStats(m);
354
+ toolIndication.observe(m);
355
+ }
52
356
  const finalMessage = response.messages[response.messages.length - 1];
53
- const finalContent = finalMessage?.content;
357
+ // EXT-37: content-policy refusal. A successful response whose stop/finish reason is a
358
+ // refusal (OpenAI content_filter / Anthropic stop_reason=refusal / Bedrock
359
+ // guardrail_intervened) is terminal-but-clear: surface the model's explanation and RETURN
360
+ // it as the answer. It must NOT flow into the empty-response retry (a refusal is
361
+ // deterministic — retrying just burns a paid call). Returning a non-empty message means the
362
+ // caller writes it to the output file and exits ok, rather than re-wrapping a *successful*
363
+ // (if declined) response as "Failed to get answer". A fallback-model attempt would hang
364
+ // here (see the extension point in GthAgentRunner.processMessages), but no runtime
365
+ // fallback-model config exists today, so we surface terminally.
366
+ const refusal = detectRefusal(finalMessage);
367
+ if (refusal) {
368
+ return this.surfaceRefusal(refusal);
369
+ }
370
+ // CFG-33: Gemini's thought summaries ride inside `content` as `thought: true` text blocks,
371
+ // which renderAssistantContent would print as part of the answer (and write to the output
372
+ // file). The plain surface has never shown reasoning — every other provider's arrives
373
+ // out-of-band in additional_kwargs — so drop them for rendering only; graph state keeps the
374
+ // message whole so the thought parts still replay as history.
375
+ const finalContent = stripReasoningBlocks(finalMessage?.content);
54
376
  const processedContent = !this.config.writeBinaryOutputsToFile
55
377
  ? {
56
378
  renderedContent: renderAssistantContent(finalContent),
@@ -127,6 +449,17 @@ export class GthAbstractAgent {
127
449
  const statusUpdate = this.statusUpdate;
128
450
  const config = this.config;
129
451
  const command = this.command;
452
+ // GS2-16: bound so the stream `start()` closure (whose `this` is the stream source, not the
453
+ // agent) can fold each chunk into the run tally. Fail-soft inside recordRunStats.
454
+ const recordRunStats = (m) => this.recordRunStats(m);
455
+ // EXT-37: bound so the stream `start()` closure can surface a detected refusal (WARNING +
456
+ // returns the message to enqueue) without a `this` reference.
457
+ const surfaceRefusal = (info) => this.surfaceRefusal(info);
458
+ // TUI-C30 — compact per-tool-call indication for the plain surface (`name(args…)` + the
459
+ // canonical 10-line greyed preview when each ToolMessage lands). Per-stream state; emits at
460
+ // INFO level so the existing consoleLevel gate governs it like the historical tool notices.
461
+ // The TUI never runs this string path (it renders the typed event stream itself).
462
+ const toolIndication = createPlainToolIndication();
130
463
  const interruptState = { escape: false, messageShown: false };
131
464
  const abortController = new AbortController();
132
465
  const showInterruptMessage = () => {
@@ -141,7 +474,11 @@ export class GthAbstractAgent {
141
474
  if (!abortController.signal.aborted) {
142
475
  abortController.abort();
143
476
  }
144
- }, this.config.canInterruptInferenceWithEsc);
477
+ }, this.config.canInterruptInferenceWithEsc,
478
+ // GS2-63: the interrupt hint is part of the run-header preamble. Suppress the hint box (while
479
+ // still arming the Esc/Q handler) when the header is opted out. This site only runs in the
480
+ // non-TUI text path (`streamFromInput`); the TUI event path never reaches it.
481
+ this.config.output?.header !== false);
145
482
  let stream;
146
483
  try {
147
484
  stream = await this.agent.stream(input, {
@@ -166,10 +503,44 @@ export class GthAbstractAgent {
166
503
  let totalChunks = 0;
167
504
  const seenBinaryBlocks = new Set();
168
505
  const binaryBlocks = [];
506
+ // EXT-37: a content-policy refusal's stop/finish reason rides on a chunk's
507
+ // response_metadata (usually with empty content). Capture it here and surface it AFTER
508
+ // the stream drains, so the returned text is non-empty and the run loop treats it as
509
+ // terminal-but-clear instead of routing an empty streamed turn into the retry.
510
+ let refusalInfo = null;
511
+ // EXT-41: belt-and-suspenders — also concat the AI chunks so a refusal can be read off
512
+ // the FINAL aggregated message's stop/finish reason, not only a per-chunk one. Some
513
+ // providers surface the reason only on the assembled message (or split it across chunks
514
+ // that concat into it); without this fallback such a refusal would be swallowed by the
515
+ // empty-response retry, making the EXT-37 surfacing cosmetic on the DEFAULT streaming
516
+ // surface. Reset at each tool round (below) so a prior round's reason can't concatenate
517
+ // with the final turn's (mirrors processEventStream's per-round reset).
518
+ let aggregatedChunk = null;
169
519
  for await (const [chunk, _metadata] of stream) {
170
520
  debugLogObject('Stream chunk', { chunk, _metadata });
521
+ // GS2-16: fold every chunk (AIMessageChunk usage/tool_calls, ToolMessage name) into
522
+ // the run tally before the text-only handling below.
523
+ recordRunStats(chunk);
524
+ // EXT-37: first refusal signal wins; keep scanning chunks for text/binary as normal.
525
+ if (!refusalInfo) {
526
+ refusalInfo = detectRefusal(chunk);
527
+ }
528
+ // EXT-41: fold AI chunks into an aggregate for the aggregate-level refusal fallback,
529
+ // resetting at tool-round boundaries so a prior round's stop/finish reason can't bleed
530
+ // into the final turn's aggregate.
531
+ if (AIMessageChunk.isInstance(chunk)) {
532
+ aggregatedChunk = aggregatedChunk ? aggregatedChunk.concat(chunk) : chunk;
533
+ }
534
+ else if (chunk instanceof ToolMessage) {
535
+ aggregatedChunk = null;
536
+ }
537
+ // TUI-C30: fold the chunk into the plain-surface tool indication (renders each
538
+ // completed call when its ToolMessage arrives; a no-op for plain text chunks).
539
+ toolIndication.observe(chunk);
171
540
  if (AIMessage.isInstance(chunk)) {
172
- const text = chunk.text ?? '';
541
+ // CFG-33: the ANSWER text only. `.text` folds Gemini's `thought: true` blocks into the
542
+ // answer, which would print the model's thinking inline here and in the output file.
543
+ const text = answerTextOf(chunk.content);
173
544
  totalChunks++;
174
545
  if (text.length > 0) {
175
546
  statusUpdate(StatusLevel.STREAM, text);
@@ -202,6 +573,19 @@ export class GthAbstractAgent {
202
573
  statusUpdate(StatusLevel.SUCCESS, successMessage);
203
574
  }
204
575
  }
576
+ // EXT-41: aggregate-level fallback — if no per-chunk metadata flagged a refusal, inspect
577
+ // the FINAL aggregated message's stop/finish reason. Catches providers that expose the
578
+ // reason only on the assembled message (or split across chunks that concat into it).
579
+ if (!refusalInfo && aggregatedChunk) {
580
+ refusalInfo = detectRefusal(aggregatedChunk);
581
+ }
582
+ // EXT-37: surface a captured refusal as the terminal answer. Enqueue the clear message
583
+ // (so the drained result is non-empty and bypasses the empty-response retry) and print it
584
+ // once at WARNING level (surfaceRefusal). Any partial content already streamed is kept;
585
+ // the refusal notice follows it, and its explanation carries any model-provided text.
586
+ if (refusalInfo) {
587
+ controller.enqueue(surfaceRefusal(refusalInfo));
588
+ }
205
589
  debugLog(`Stream completed. Total chunks: ${totalChunks}`);
206
590
  controller.close();
207
591
  }
@@ -363,6 +747,52 @@ export class GthAbstractAgent {
363
747
  let aggregatedAIChunk = null;
364
748
  let reasoningOpen = false;
365
749
  const flushed = new Set();
750
+ // EXT-41: a content-policy refusal on this typed-event path was de-scoped by EXT-37 (there is
751
+ // no empty-response retry here, so no wrong-retry bug), but it still rendered as a SILENT empty
752
+ // turn. Capture it (first per-chunk signal wins; aggregate fallback at stream end) and surface
753
+ // it as a `text` event so every consumer (Ink TUI viewModel, AG-UI SSE) shows a clear notice.
754
+ let refusalInfo = null;
755
+ // TUI-C22 — one splitter for the whole stream so a <think> opened in one chunk and closed
756
+ // several chunks later is tracked across the boundary. Reset at message boundaries via flush().
757
+ const thinkSplitter = createThinkTagSplitter();
758
+ // TUI-C22 — emit ordered answer/reasoning segments, opening/closing the reasoning block as the
759
+ // kind switches. Shares `reasoningOpen` with the reasoning_content path so the two compose
760
+ // (a reasoning_content delta then think-derived reasoning stays one open block; answer text
761
+ // closes it), preserving the exact existing event sequence when no <think> tags are present.
762
+ function* emitSegments(segments) {
763
+ for (const seg of segments) {
764
+ if (seg.text.length === 0)
765
+ continue;
766
+ if (seg.kind === 'reasoning') {
767
+ if (!reasoningOpen) {
768
+ reasoningOpen = true;
769
+ yield { type: 'reasoning_start' };
770
+ }
771
+ yield { type: 'reasoning_delta', delta: seg.text };
772
+ }
773
+ else {
774
+ if (reasoningOpen) {
775
+ reasoningOpen = false;
776
+ yield { type: 'reasoning_end' };
777
+ }
778
+ yield { type: 'text', delta: seg.text };
779
+ }
780
+ }
781
+ }
782
+ // CFG-33 — emit a message's content segments in order. A segment already classified as the
783
+ // model's thinking (a Gemini `thought: true` block) goes straight to the reasoning channel;
784
+ // answer text still passes through the TUI-C22 think splitter, so an inline `<think>` tag is
785
+ // peeled exactly as before. With no reasoning block this is the previous `.text` behaviour.
786
+ function* emitContentSegments(segments) {
787
+ for (const segment of segments) {
788
+ if (segment.kind === 'reasoning') {
789
+ yield* emitSegments([segment]);
790
+ }
791
+ else {
792
+ yield* emitSegments(thinkSplitter.push(segment.text));
793
+ }
794
+ }
795
+ }
366
796
  function* flushAggregated() {
367
797
  if (!aggregatedAIChunk)
368
798
  return;
@@ -391,30 +821,41 @@ export class GthAbstractAgent {
391
821
  }
392
822
  for await (const [chunk, _metadata] of stream) {
393
823
  debugLogObject('streamWithEvents chunk', { chunk, _metadata });
824
+ // GS2-16: fold every chunk (AIMessageChunk usage/tool_calls, ToolMessage name) into the
825
+ // run tally so the TUI turn can record real token/tool data. Fail-soft.
826
+ this.recordRunStats(chunk);
827
+ // EXT-41: reuse EXT-37's detector (do NOT fork a second one). First per-chunk signal wins;
828
+ // a ToolMessage / normal chunk yields null, so a normal turn never surfaces a false refusal.
829
+ if (!refusalInfo) {
830
+ refusalInfo = detectRefusal(chunk);
831
+ }
394
832
  if (AIMessageChunk.isInstance(chunk)) {
395
833
  aggregatedAIChunk = aggregatedAIChunk ? aggregatedAIChunk.concat(chunk) : chunk;
396
- // Reasoning deltas — Ollama (Qwen3, deepseek-r1) and Anthropic surface
397
- // thinking text in additional_kwargs.reasoning_content. Stream it as a
398
- // separate event series so clients can render it apart from the answer.
399
- const reasoningDelta = chunk.additional_kwargs?.reasoning_content;
400
- if (typeof reasoningDelta === 'string' && reasoningDelta.length > 0) {
401
- if (!reasoningOpen) {
402
- reasoningOpen = true;
403
- yield { type: 'reasoning_start' };
404
- }
405
- yield { type: 'reasoning_delta', delta: reasoningDelta };
406
- }
407
- // Yield text incrementally — use this chunk's text (delta), not the
408
- // aggregated content which is cumulative.
409
- if (chunk.text) {
410
- if (reasoningOpen) {
411
- reasoningOpen = false;
412
- yield { type: 'reasoning_end' };
413
- }
414
- yield { type: 'text', delta: chunk.text };
834
+ // Reasoning deltas — Ollama (Qwen3, deepseek-r1), Anthropic, and OpenRouter surface
835
+ // thinking in additional_kwargs.reasoning_content. Stream
836
+ // it as a separate event series so clients can render it apart from the answer.
837
+ const reasoningDelta = pickReasoningDelta(chunk.additional_kwargs);
838
+ if (reasoningDelta.length > 0) {
839
+ yield* emitSegments([{ kind: 'reasoning', text: reasoningDelta }]);
415
840
  }
841
+ // Yield text incrementally — use this chunk's text (delta), not the aggregated content
842
+ // which is cumulative. TUI-C22 routes it through the think splitter so inline
843
+ // <think>...</think> (buffered across chunks) is peeled into the reasoning channel and
844
+ // stripped from the answer; text with no think tags passes straight through unchanged.
845
+ // CFG-33 classifies the chunk's content blocks first, in order, so Gemini's `thought: true`
846
+ // blocks reach the reasoning channel instead of the answer; answer text still goes through
847
+ // the think splitter, thought text does not (it is already classified).
848
+ yield* emitContentSegments(segmentAssistantContent(chunk.content));
416
849
  }
417
850
  else if (AIMessage.isInstance(chunk)) {
851
+ // Reasoning on a non-chunk AIMessage — a non-streamed / resumed thinking message
852
+ // (e.g. a checkpoint replay) still carries its thinking in
853
+ // additional_kwargs.reasoning_content. Mirror the AIMessageChunk branch and emit the same
854
+ // reasoning event series, otherwise the thought is silently dropped (TUI-C15).
855
+ const reasoningContent = pickReasoningDelta(chunk.additional_kwargs);
856
+ if (reasoningContent.length > 0) {
857
+ yield* emitSegments([{ kind: 'reasoning', text: reasoningContent }]);
858
+ }
418
859
  // Non-chunk AIMessage (e.g. on resumed runs) carries final tool_calls
419
860
  // directly; merge them into the aggregate so flushAggregated emits them.
420
861
  if (chunk.tool_calls && chunk.tool_calls.length > 0) {
@@ -424,15 +865,17 @@ export class GthAbstractAgent {
424
865
  });
425
866
  aggregatedAIChunk = aggregatedAIChunk ? aggregatedAIChunk.concat(synthetic) : synthetic;
426
867
  }
427
- if (chunk.text) {
428
- if (reasoningOpen) {
429
- reasoningOpen = false;
430
- yield { type: 'reasoning_end' };
431
- }
432
- yield { type: 'text', delta: chunk.text };
433
- }
868
+ yield* emitContentSegments(segmentAssistantContent(chunk.content));
869
+ // A non-chunk AIMessage is a COMPLETE message, not a delta — drain any residual now
870
+ // (an unterminated <think> becomes reasoning, a dangling partial becomes answer) so its
871
+ // buffered state never leaks into a subsequent message (TUI-C22).
872
+ yield* emitSegments(thinkSplitter.flush());
434
873
  }
435
874
  if (chunk instanceof ToolMessage) {
875
+ // TUI-C22 — drain buffered think text (emitting its segments, which may open/close
876
+ // reasoning) BEFORE closing the reasoning block, so a trailing reasoning slice can't land
877
+ // after reasoning_end or be dropped. A tool round ends the assistant message, so reset.
878
+ yield* emitSegments(thinkSplitter.flush());
436
879
  if (reasoningOpen) {
437
880
  reasoningOpen = false;
438
881
  yield { type: 'reasoning_end' };
@@ -444,15 +887,38 @@ export class GthAbstractAgent {
444
887
  // and end up with empty args.
445
888
  aggregatedAIChunk = null;
446
889
  const content = typeof chunk.content === 'string' ? chunk.content : JSON.stringify(chunk.content);
447
- yield { type: 'tool_result', id: chunk.tool_call_id, content };
890
+ // Surface the real tool-result error signal (LangChain `ToolMessage.status`) so
891
+ // consumers render the ✗/error affordance from fact, not from sniffing the result
892
+ // text. Only attach the flag on error to keep the success event shape unchanged.
893
+ yield {
894
+ type: 'tool_result',
895
+ id: chunk.tool_call_id,
896
+ content,
897
+ ...(chunk.status === 'error' ? { isError: true } : {}),
898
+ };
448
899
  }
449
900
  }
901
+ // TUI-C22 — drain any buffered think text at stream end (an unterminated <think> surfaces as
902
+ // reasoning, a dangling partial as answer) before closing the reasoning block.
903
+ yield* emitSegments(thinkSplitter.flush());
450
904
  // Close any still-open reasoning block before flushing tool calls.
451
905
  if (reasoningOpen) {
452
906
  yield { type: 'reasoning_end' };
453
907
  }
454
908
  // Flush any tool calls not followed by a ToolMessage (e.g. terminal tool calls).
455
909
  yield* flushAggregated();
910
+ // EXT-41: aggregate-level fallback (I-1's robustness on this path too) — if no per-chunk
911
+ // metadata flagged a refusal, inspect the final aggregated message's stop/finish reason. Then
912
+ // surface any refusal as a `text` event so the user sees a clear notice instead of a silent
913
+ // empty turn. No statusUpdate here: consumers render the typed events, and a WARNING would
914
+ // double-render in the TUI.
915
+ if (!refusalInfo && aggregatedAIChunk) {
916
+ refusalInfo = detectRefusal(aggregatedAIChunk);
917
+ }
918
+ if (refusalInfo) {
919
+ debugLog(`Content-policy refusal detected on typed-event path (provider=${refusalInfo.provider} reason=${refusalInfo.reason})`);
920
+ yield { type: 'text', delta: buildRefusalMessage(refusalInfo) };
921
+ }
456
922
  }
457
923
  async cleanup() {
458
924
  debugLog('Cleaning up agent...');