@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,10 +1,284 @@
1
+ import { SHELL_TOOL_NAME, commandAnswersApprovals, resolveApprovals, resolveGatedToolNames, resolveInterruptToolNames, resolveShellApprovalGate, } from '#src/config.js';
1
2
  import { StatusLevel } from '#src/core/types.js';
2
3
  import { GthAbstractAgent } from '#src/core/GthAbstractAgent.js';
3
4
  import { debugLog, debugLogObject } from '#src/utils/debugUtils.js';
4
- import { formatToolCalls } from '#src/utils/llmUtils.js';
5
+ import { buildSystemMessages, formatToolCalls, readModePrompt } from '#src/utils/llmUtils.js';
5
6
  import { getCurrentWorkDir } from '#src/utils/systemUtils.js';
6
- import { AIMessage } from '@langchain/core/messages';
7
- import { createAgent, createMiddleware } from 'langchain';
7
+ import { isToolAllowed } from '#src/utils/toolMatching.js';
8
+ import { appendOsShellNote, appendCwdNote, appendCommitCoAuthorNote, appendModelContextNote, appendMcpServerInstructionsNote, resolveModelIdentity, } from '#src/utils/systemPromptNotes.js';
9
+ import { isShellCommandFailedError } from '#src/core/shell/ShellCommandFailedError.js';
10
+ import { extractDebugRequestExtras } from '#src/core/debugCapture.js';
11
+ import { promoteTextEmittedToolCallMessage } from '#src/core/toolCallRepair/index.js';
12
+ import { AIMessage, ToolMessage } from '@langchain/core/messages';
13
+ import { createAgent, createMiddleware, humanInTheLoopMiddleware, } from 'langchain';
14
+ /**
15
+ * GS2-36 — default cap for the tool-error retry budget: how many status:'error' tool results may
16
+ * accrue back-to-back (no successful tool result in between) before the run is ended gracefully.
17
+ * Small on purpose: it still lets the model try a couple of genuine recovery variants (the whole
18
+ * point of feeding errors back — GS2-32 showed the model routes around a surfaced error in 1–2
19
+ * tries) while stopping a runaway self-inflicted loop long before createAgent's coarse
20
+ * recursionLimit backstop would.
21
+ */
22
+ export const MAX_CONSECUTIVE_TOOL_ERRORS = 5;
23
+ /**
24
+ * GS2-36 — the tool-error retry budget as a standalone, testable middleware factory (exported so the
25
+ * real thing can be unit-tested and exercised in a real `createAgent` graph, mirroring
26
+ * `createPathNamespaceCorrectionMiddleware`).
27
+ *
28
+ * Runs in `beforeModel` (like langchain's own `modelCallLimitMiddleware`): after the tools node has
29
+ * appended its result(s) and before the next model call is spent, it walks the trailing messages and
30
+ * counts CONSECUTIVE errored tool results — a `ToolMessage` with `status: 'error'` (the shape the
31
+ * shell/MCP softeners produce; GthAbstractAgent maps `status==='error' → isError`). The walk skips
32
+ * the assistant tool-call requests between rounds, and RESETS on the first successful tool result
33
+ * (progress / diagnosis) or a Human/System message (a fresh user turn). Once the count reaches the
34
+ * cap it returns `{ jumpTo: 'end', messages: [<action-oriented notice>] }`, ending the run without
35
+ * spending another model call.
36
+ *
37
+ * Scope: counts `status: 'error'` results only. The recoverable fs error STRINGS
38
+ * (`write_file`/`edit_file`/…) are `status: 'success'` by the write_file precedent, so a pure fs
39
+ * error loop is deliberately NOT capped here — it stays bounded by the coarse `recursionLimit` and is
40
+ * the remit of the loop-DETECTION node (EXT-36). Counting `status: 'error'` overall (not per-tool)
41
+ * catches both same-tool and alternating-tool error loops with one robust rule.
42
+ */
43
+ export function createToolErrorBudgetMiddleware(maxConsecutiveErrors = MAX_CONSECUTIVE_TOOL_ERRORS) {
44
+ return createMiddleware({
45
+ name: 'GthLeanToolErrorBudget',
46
+ beforeModel: {
47
+ canJumpTo: ['end'],
48
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
49
+ hook: (state) => {
50
+ const messages = Array.isArray(state?.messages) ? state.messages : [];
51
+ let consecutive = 0;
52
+ let lastErrorContent = '';
53
+ for (let i = messages.length - 1; i >= 0; i--) {
54
+ const msg = messages[i];
55
+ if (ToolMessage.isInstance(msg)) {
56
+ if (msg.status === 'error') {
57
+ consecutive++;
58
+ if (!lastErrorContent) {
59
+ lastErrorContent = typeof msg.content === 'string' ? msg.content : '';
60
+ }
61
+ }
62
+ else {
63
+ // A successful tool result — the model is making progress / diagnosing, so the
64
+ // unrecovered-error streak is broken. Stop the walk (reset).
65
+ break;
66
+ }
67
+ }
68
+ else if (AIMessage.isInstance(msg)) {
69
+ // The assistant tool-call request that produced the error above; skip and keep counting.
70
+ // GS2-72: this `continue` INTENTIONALLY also skips the budget's OWN injected terminal
71
+ // notice (itself an AIMessage). If the run re-enters beforeModel on the same thread after
72
+ // a jumpTo:'end' (a re-invoke on the same thread — e.g. the no-checkpointer degrade of
73
+ // the string path's empty-stream fallback, or a later turn that keeps erroring), skipping
74
+ // the notice lets the walk still reach the errored results and re-trip deterministically.
75
+ // Do NOT special-case the notice to reset/break here — treating it as a fresh-turn
76
+ // boundary would let the capped loop resume.
77
+ continue;
78
+ }
79
+ else {
80
+ // A Human/System message: a fresh user-turn boundary — earlier errors don't count.
81
+ break;
82
+ }
83
+ }
84
+ if (consecutive >= maxConsecutiveErrors) {
85
+ const firstLine = (lastErrorContent.split('\n')[0] ?? '').slice(0, 300);
86
+ const notice = `Stopped after ${consecutive} consecutive failed tool calls to avoid a retry loop that ` +
87
+ 'keeps spending tokens without making progress' +
88
+ (firstLine ? ` (last error: ${firstLine})` : '') +
89
+ '. Do not repeat the same call: inspect the error, then change your approach — different ' +
90
+ 'arguments, a narrower path, or a different tool — or report the blocker to the user.';
91
+ return { jumpTo: 'end', messages: [new AIMessage(notice)] };
92
+ }
93
+ return undefined;
94
+ },
95
+ },
96
+ });
97
+ }
98
+ /**
99
+ * EXT-36 — default number of consecutive identical `(tool, args)` calls before the tool-loop guard
100
+ * fires. Small on purpose: it must catch a genuine no-progress loop (a model re-issuing the SAME
101
+ * call verbatim) while never tripping a legitimate one-off retry (2x). Kept below GS2-36's coarser
102
+ * error cap (5) because a same-signature repeat is a stronger, more specific loop signal than "an
103
+ * error happened again".
104
+ */
105
+ export const DEFAULT_TOOL_LOOP_THRESHOLD = 3;
106
+ /**
107
+ * EXT-36 — normalise the `toolLoopGuard` config union (`false | true | { warn?, halt?, threshold? }`)
108
+ * into concrete {@link ToolLoopGuardOptions}, applying the WARN-ON-by-default policy at the read site
109
+ * (mirrors how `output.header` / `debugDump.redact` default with `!== false`, NOT in DEFAULT_CONFIG,
110
+ * so the effective-config snapshot never churns).
111
+ * - `false` → both modes off (a no-op guard);
112
+ * - `true` / absent → warn on, halt off, default threshold;
113
+ * - object → per-field, with warn defaulting ON and halt defaulting OFF.
114
+ */
115
+ export function resolveToolLoopGuardOptions(setting) {
116
+ if (setting === false)
117
+ return { warn: false, halt: false };
118
+ if (setting === true || setting === undefined || setting === null)
119
+ return {};
120
+ return { warn: setting.warn, halt: setting.halt, threshold: setting.threshold };
121
+ }
122
+ /**
123
+ * EXT-36 — a deterministic, key-sorted stringify so `(tool, args)` signatures are stable regardless
124
+ * of object key order. No-args (`{}`) collapses to `"{}"`, so identical no-arg repeats collide BY
125
+ * DESIGN — that is exactly the loop signal. Known limitation: args carrying a volatile value (a
126
+ * timestamp / uuid) make every call look distinct, so the guard cannot see that loop; volatile-key
127
+ * stripping is deliberately NOT attempted (over-engineering for a rare, model-authored case).
128
+ */
129
+ function stableStringify(value) {
130
+ if (value === null || typeof value !== 'object')
131
+ return JSON.stringify(value) ?? 'null';
132
+ if (Array.isArray(value))
133
+ return `[${value.map(stableStringify).join(',')}]`;
134
+ const record = value;
135
+ const keys = Object.keys(record).sort();
136
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(record[k])}`).join(',')}}`;
137
+ }
138
+ /**
139
+ * The delimiter between a tool name and its serialised arguments in a call signature. U+0000 is
140
+ * chosen because a tool name cannot contain a control character, so no `(name, args)` pair can be
141
+ * spelled two ways and no pair of distinct calls can collide on one signature.
142
+ *
143
+ * It MUST stay written as this escape. A raw NUL byte in the source makes the whole file binary to
144
+ * ripgrep and ugrep, which then skip it in silence — every symbol in this file becomes invisible to
145
+ * a repo-wide search, and an empty result reads as proof of absence. `noRawControlBytes.spec.ts` guards
146
+ * the repo against the raw form returning.
147
+ */
148
+ export const TOOL_CALL_SIGNATURE_DELIMITER = '\u0000';
149
+ /**
150
+ * EXT-36 — the `(tool, args)` identity a repeat-detection streak is counted over. Exported so the
151
+ * delimiter invariant above is asserted directly rather than inferred from loop behaviour.
152
+ */
153
+ export function toolCallSignature(name, args) {
154
+ return `${name}${TOOL_CALL_SIGNATURE_DELIMITER}${stableStringify(args ?? {})}`;
155
+ }
156
+ /**
157
+ * EXT-36 — the tool-loop guardrail as a standalone, testable middleware factory. The ORTHOGONAL
158
+ * sibling of {@link createToolErrorBudgetMiddleware}: GS2-36 caps a consecutive-tool-ERROR streak;
159
+ * this catches a **repeated identical `(tool, args)` / no-progress loop** — the same call re-issued
160
+ * verbatim, whether it keeps erroring OR keeps "succeeding" with the same result (the fs-error-string
161
+ * loop GS2-36's comment explicitly leaves to EXT-36).
162
+ *
163
+ * STATELESS (the critical trap): the factory runs ONCE per session, so a closure-held counter would
164
+ * bleed across every turn. Like GS2-36 it holds NO state — each `beforeModel` recomputes the streak
165
+ * from the message tail.
166
+ *
167
+ * Detection. A signature is `(tool_name, args_hash)`. Name + args live on `AIMessage.tool_calls[]`,
168
+ * NOT on the `ToolMessage` (softener ToolMessages carry only content+tool_call_id+status), so each
169
+ * `ToolMessage` is paired to its call by `tool_call_id === AIMessage.tool_calls[].id` to recover the
170
+ * signature. The backward walk counts CONSECUTIVE ToolMessages with the SAME signature; a DIFFERENT
171
+ * signature breaks the streak (the model tried something else = progress), and a Human/System message
172
+ * is a fresh-turn boundary. Assistant messages (the tool-call requests) are skipped. Known no-op
173
+ * (safe, never a false trip): a single AIMessage issuing PARALLEL tool calls yields back-to-back
174
+ * differing signatures, which the walk reads as progress and resets.
175
+ *
176
+ * Two modes (composable):
177
+ * - WARN (default ON, provably harmless): the default path must NOT change what the model sees.
178
+ * Appending ANY message then re-invoking the model mutates its input — a *steer*, not a *warn* —
179
+ * and is provider-unsafe by default (Gemini expects a trailing user turn → crash risk; Anthropic
180
+ * treats a trailing assistant as PREFILL → the note silently becomes the opening of the model's own
181
+ * next reply, corrupt with no error; a HumanMessage after a ToolMessage is two consecutive user
182
+ * turns for Anthropic/Gemini). So WARN instead SURFACES a user-visible notice via {@link onWarn}
183
+ * and returns `undefined` — zero `state.messages` mutation, zero control-flow. Fired statelessly
184
+ * ONCE per streak at the exact crossing (`streak === threshold`): a still-looping streak on later
185
+ * turns (`streak > threshold`) does not re-surface, while an interrupted-then-resumed loop
186
+ * re-reaches `threshold` and surfaces again — no marker/`additional_kwargs` machinery needed.
187
+ * - HALT (opt-in only, active loop-breaking): at/over threshold, return
188
+ * `{ jumpTo: 'end', messages: [new AIMessage(reason)] }` — a TERMINAL notice (the model is never
189
+ * re-invoked after it, so the prefill/role hazard cannot arise; the GS2-72-proven clean-stream
190
+ * path). NEVER throws. A validated behaviour change lives behind this opt-in.
191
+ *
192
+ * @param onWarn TUI-safe user-notice sink for WARN, wired at the read site to
193
+ * `statusUpdate(StatusLevel.WARNING, …)` (routed through the agent's status callback the renderer
194
+ * consumes, never raw stdout, so it can't leak over the Ink frame). Omitted → WARN still runs but
195
+ * surfaces nothing (still zero model-input mutation).
196
+ */
197
+ export function createToolLoopGuardMiddleware(options = {}, onWarn) {
198
+ const warn = options.warn ?? true;
199
+ const halt = options.halt ?? false;
200
+ const threshold = options.threshold ?? DEFAULT_TOOL_LOOP_THRESHOLD;
201
+ return createMiddleware({
202
+ name: 'GthLeanToolLoopGuard',
203
+ beforeModel: {
204
+ canJumpTo: ['end'],
205
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
206
+ hook: (state) => {
207
+ // `false` config resolves to warn:false + halt:false — a genuine no-op (fast bail).
208
+ if (!warn && !halt)
209
+ return undefined;
210
+ const messages = Array.isArray(state?.messages) ? state.messages : [];
211
+ // Recover each tool call's signature by id — name + args are on the AIMessage, not the
212
+ // ToolMessage. One pass over all AIMessages builds the id → {sig, name} lookup.
213
+ const callById = new Map();
214
+ for (const msg of messages) {
215
+ if (AIMessage.isInstance(msg) && Array.isArray(msg.tool_calls)) {
216
+ for (const tc of msg.tool_calls) {
217
+ const id = tc?.id;
218
+ if (typeof id === 'string') {
219
+ const name = typeof tc.name === 'string' ? tc.name : '';
220
+ // Signature identity + its delimiter: see toolCallSignature.
221
+ callById.set(id, { sig: toolCallSignature(name, tc.args), name });
222
+ }
223
+ }
224
+ }
225
+ }
226
+ // Walk the tail backward, counting consecutive identical signatures since the last boundary.
227
+ let streak = 0;
228
+ let currentSig;
229
+ let currentName = '';
230
+ for (let i = messages.length - 1; i >= 0; i--) {
231
+ const msg = messages[i];
232
+ if (ToolMessage.isInstance(msg)) {
233
+ const call = callById.get(msg.tool_call_id);
234
+ // No paired call → cannot prove a repeat; treat as a boundary (never a false trip).
235
+ if (!call)
236
+ break;
237
+ if (currentSig === undefined) {
238
+ currentSig = call.sig;
239
+ currentName = call.name;
240
+ streak = 1;
241
+ }
242
+ else if (call.sig === currentSig) {
243
+ streak++;
244
+ }
245
+ else {
246
+ break; // different signature = the model tried something else = progress
247
+ }
248
+ }
249
+ else if (AIMessage.isInstance(msg)) {
250
+ continue; // the tool-call request — skip and keep counting the streak
251
+ }
252
+ else {
253
+ break; // Human/System message: a fresh user turn resets everything.
254
+ }
255
+ }
256
+ if (currentSig === undefined || streak < threshold)
257
+ return undefined;
258
+ // HALT (opt-in): end the run cleanly — never a throw. Terminal, so the model is never
259
+ // re-invoked after this AIMessage (no prefill/role hazard). Takes precedence over WARN.
260
+ if (halt) {
261
+ const notice = `Stopped after ${streak} identical calls to the \`${currentName}\` tool with the same ` +
262
+ 'arguments to avoid a loop that keeps spending tokens without making progress. ' +
263
+ 'The same call cannot yield a different result: change your approach — different ' +
264
+ 'arguments, a narrower step, or a different tool — or report the blocker to the user.';
265
+ return { jumpTo: 'end', messages: [new AIMessage(notice)] };
266
+ }
267
+ // WARN (default): SURFACE a user-visible notice and DO NOT touch state.messages (return
268
+ // undefined → the model's input is byte-for-byte unchanged, so no prefill/role hazard on any
269
+ // provider). Fire once per streak at the exact crossing: `streak === threshold` is a
270
+ // stateless "fire once" — a still-looping streak (streak > threshold) stays quiet, while an
271
+ // interrupted-then-resumed loop re-reaches threshold and surfaces again.
272
+ if (warn && streak === threshold) {
273
+ onWarn?.(`Tool-loop guard: the agent has called \`${currentName}\` with the same arguments ` +
274
+ `${streak} times without new progress. It may be stuck — consider interrupting and ` +
275
+ 'refining the request.');
276
+ }
277
+ return undefined;
278
+ },
279
+ },
280
+ });
281
+ }
8
282
  /**
9
283
  * Lean agent: builds a standard `createAgent` (ReAct) graph. All run/stream/event
10
284
  * plumbing lives in {@link GthAbstractAgent}; this class only knows how to construct
@@ -22,16 +296,16 @@ export class GthLangChainAgent extends GthAbstractAgent {
22
296
  streamOutput: this.config.streamOutput,
23
297
  debugLog: this.config.debugLog,
24
298
  });
25
- this.statusUpdate(StatusLevel.INFO, `Workdir: ${getCurrentWorkDir()}`);
299
+ this.headerStatus(`Workdir: ${getCurrentWorkDir()}`);
26
300
  if (this.config.modelDisplayName) {
27
- this.statusUpdate(StatusLevel.INFO, `Model: ${this.config.modelDisplayName}`);
301
+ this.headerStatus(`Model: ${this.config.modelDisplayName}`);
28
302
  }
29
303
  // An empty allowedTools allow-list disables every tool. Skip resolution entirely so we
30
304
  // don't contact MCP servers (and trigger OAuth) just to discard the result.
31
305
  const allowedTools = this.config.allowedTools;
32
306
  const toolsDisabled = Array.isArray(allowedTools) && allowedTools.length === 0;
33
307
  if (toolsDisabled) {
34
- this.statusUpdate(StatusLevel.INFO, 'Tool loading disabled by allowedTools: []; MCP/A2A servers will not be contacted. Omit allowedTools for no filtering.');
308
+ this.headerStatus('Tool loading disabled by allowedTools: []; MCP/A2A servers will not be contacted. Omit allowedTools for no filtering.');
35
309
  }
36
310
  // Resolve tools via resolver or fall back to config tools only
37
311
  debugLog('Resolving tools...');
@@ -47,19 +321,20 @@ export class GthLangChainAgent extends GthAbstractAgent {
47
321
  // Combine all tools, then apply the allowedTools name allow-list when configured.
48
322
  let tools = [...resolvedTools, ...flattenedConfigTools];
49
323
  if (Array.isArray(allowedTools)) {
50
- const allowed = new Set(allowedTools);
51
- // Filter named tools by the allow-list. ServerTools (provider-native "magic objects" such
52
- // as Anthropic web search) may have no `name`, so they can never be referenced in the
53
- // allow-list - drop-by-default would silently remove them with no recourse. Retain such
54
- // nameless tools instead; the allow-list is a name-based filter and cannot target them.
55
- tools = tools.filter((tool) => !tool.name || allowed.has(tool.name));
324
+ // Filter named tools by the allow-list. Entries match by exact name, or glob-style when
325
+ // they contain `*` (e.g. `mcp__unimarket__*`) see isToolAllowed. ServerTools
326
+ // (provider-native "magic objects" such as Anthropic web search) may have no `name`, so
327
+ // they can never be referenced in the allow-list - drop-by-default would silently remove
328
+ // them with no recourse. Retain such nameless tools instead; the allow-list is a name-based
329
+ // filter and cannot target them.
330
+ tools = tools.filter((tool) => !tool.name || isToolAllowed(tool.name, allowedTools));
56
331
  }
57
332
  if (tools.length > 0) {
58
333
  const toolNames = tools
59
334
  .map((tool) => tool.name)
60
335
  .filter((name) => name)
61
336
  .join(', ');
62
- this.statusUpdate(StatusLevel.INFO, `Loaded tools: ${toolNames}`);
337
+ this.headerStatus(`Loaded tools: ${toolNames}`);
63
338
  debugLog(`Total tools available: ${tools.length}`);
64
339
  debugLogObject('All Tools', toolNames.split(', '));
65
340
  }
@@ -87,15 +362,367 @@ export class GthLangChainAgent extends GthAbstractAgent {
87
362
  return state;
88
363
  },
89
364
  });
90
- // Combine all middleware
91
- const middleware = [...configuredMiddleware, toolCallStatusMiddleware];
92
- this.statusUpdate(StatusLevel.INFO, `Loaded middleware: ${middleware.map((m) => m.name).join(', ')}`);
93
- // Create agent with configured middleware
365
+ // EXT-35: promote a text-emitted tool call to a native tool_call so the loop doesn't stall.
366
+ // Small/local models (Gemma, lmstudio, gpt-oss) often serialise a tool call as assistant TEXT
367
+ // instead of a native `tool_call`; the ReAct router then sees no tool_calls on the last message
368
+ // and ENDS the turn ("no tool calls = done"). This afterModel hook runs ONLY when the last
369
+ // AIMessage carries no native tool_calls (the native happy path is byte-for-byte untouched):
370
+ // it parses a STANDALONE text-emitted call (bracket / <function=…> / Harmony), gated HARD by the
371
+ // bound-tool allow-list + a payload-size cap + standalone-only, and — when it promotes — returns
372
+ // the rewritten message. Preserving the original message id is load-bearing: LangGraph's
373
+ // message-state reducer merges by id, so a same-id message REPLACES the model's text message in
374
+ // graph state; the router then sees the native tool_calls and routes to the tools node, so the
375
+ // loop continues instead of concluding done. Ported from the openclaw tool-call-repair reference.
376
+ // Bound-tool names are the allow-list; an empty toolset promotes nothing (prose-safe default).
377
+ const repairToolNames = new Set(tools.map((t) => t.name).filter((name) => Boolean(name)));
378
+ const toolCallRepairMiddleware = createMiddleware({
379
+ name: 'GthMiddlewareToolCallRepair',
380
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
381
+ afterModel: (state) => {
382
+ const lastMessage = state.messages[state.messages.length - 1];
383
+ if (!AIMessage.isInstance(lastMessage))
384
+ return state;
385
+ if (lastMessage.tool_calls && lastMessage.tool_calls.length > 0)
386
+ return state;
387
+ const promoted = promoteTextEmittedToolCallMessage(lastMessage, {
388
+ allowedToolNames: repairToolNames,
389
+ });
390
+ if (!promoted)
391
+ return state;
392
+ debugLog(`Repaired a text-emitted tool call into a native tool_call: ${formatToolCalls(promoted.tool_calls ?? [])}`);
393
+ // Replace-by-id (same id) so the reducer swaps the text message rather than appending.
394
+ return { messages: [promoted] };
395
+ },
396
+ });
397
+ // EXT-21: lean-path sibling of the deep agent's GthDeepShellExitSoftening (GthDeepAgent.ts).
398
+ // `exec` / `ask --write` route through this lean `createAgent` graph, whose run_* shell/dev
399
+ // tools (GthDevToolkit.executeCommand) THROW a ShellCommandFailedError on a non-zero exit or a
400
+ // timeout-kill. langchain's default ToolNode would catch that throw into a ToolMessage but leave
401
+ // it status:'success' (✓) — misreporting a failed command. Catch it here at the tool-wrap layer
402
+ // and return an error ToolMessage that PRESERVES the full stdout/stderr body: the model's
403
+ // observation is unchanged except the status flips to 'error', which drives the ✗ (isError)
404
+ // glyph (GthAbstractAgent maps status==='error' → isError). Returning a ToolMessage (rather than
405
+ // rethrowing) keeps it a normal, observed tool result — no run-abort, no retry loop. Recognised
406
+ // via isShellCommandFailedError (instanceof + structural fallback) since core cannot import the
407
+ // throw site in the agent package. Every OTHER throw is rethrown untouched so genuine failures
408
+ // and control-flow (GraphInterrupt / AbortError) still surface.
409
+ const shellExitSoftening = createMiddleware({
410
+ name: 'GthLeanShellExitSoftening',
411
+ wrapToolCall: async (request, handler) => {
412
+ try {
413
+ return await handler(request);
414
+ }
415
+ catch (e) {
416
+ if (isShellCommandFailedError(e)) {
417
+ debugLog(`Softened shell/dev command failure (exit ${e.exitCode ?? 'timeout'}) into an ` +
418
+ `error ToolMessage for '${e.command}'`);
419
+ return new ToolMessage({
420
+ content: e.output,
421
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
422
+ tool_call_id: request.toolCall?.id ?? '',
423
+ status: 'error',
424
+ });
425
+ }
426
+ throw e;
427
+ }
428
+ },
429
+ });
430
+ // MCP tool-execution errors are spec-compliant RESULTS, not fatal faults. Per the MCP spec
431
+ // (2025-11-25 & draft, "Server › Tools › Error Handling"), a tool that hits an API failure, an
432
+ // input-validation problem, or a business-logic error (e.g. a disabled capability) returns a
433
+ // normal tools/call result with `isError: true`, and the CLIENT *SHOULD* hand that error to the
434
+ // model so it can self-correct. `@langchain/mcp-adapters` instead surfaces such a result by
435
+ // THROWING a ToolException at call time (its `_convertCallToolResult`). Because we install a
436
+ // wrapToolCall middleware, langchain's ToolNode treats any error a middleware rethrows as a fatal
437
+ // "middleware error" (`errorFromMiddleware && handleToolErrors !== true` → throw) and aborts the
438
+ // whole turn instead of relaying the error to the model — the opposite of the spec's client
439
+ // SHOULD. This middleware closes that gap: it catches a thrown ToolException and RETURNS it as a
440
+ // status:'error' ToolMessage (→ isError → ✗), so the model observes the error and can retry or
441
+ // explain (matching the non-stream invoke path's ToolException handling). Scope & safety: matched
442
+ // by name === 'ToolException' (the adapter's marker), so GraphInterrupt and every non-MCP throw
443
+ // fall through the final rethrow untouched. The adapter ALSO wraps a call-time AbortError into a
444
+ // ToolException (its `_callTool` catch-all), so we RETHROW when the run's abort signal is set —
445
+ // otherwise softening here would swallow user cancellation that ToolNode's own `signal?.aborted`
446
+ // guard normally enforces (bypassed once we handle the error in middleware). MCP connect/auth
447
+ // (401/403) and load failures are handled at CONNECT time (resolvers.ts throwOnLoadError +
448
+ // onConnectionError), not here, so they stay fatal as intended.
449
+ const mcpToolErrorSoftening = createMiddleware({
450
+ name: 'GthMcpToolErrorSoftening',
451
+ wrapToolCall: async (request, handler) => {
452
+ try {
453
+ return await handler(request);
454
+ }
455
+ catch (e) {
456
+ if (e instanceof Error &&
457
+ e.name === 'ToolException' &&
458
+ !request.runtime?.signal?.aborted) {
459
+ debugLog(`Softened MCP tool error into an error ToolMessage: ${e.message}`);
460
+ return new ToolMessage({
461
+ content: e.message,
462
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
463
+ tool_call_id: request.toolCall?.id ?? '',
464
+ status: 'error',
465
+ });
466
+ }
467
+ throw e;
468
+ }
469
+ },
470
+ });
471
+ // Debug-capture middleware (TUI `/debug` panel) — the lean-path sibling of GthDeepAgent's.
472
+ // Always installed but lazy: it reads `this.debugCapture` per call, so until the TUI attaches a
473
+ // sink it is a transparent pass-through (one extra await around the handler — the normal path
474
+ // pays nothing). `request.messages` is the real history at call time; `handler(request)`
475
+ // resolves to the AIMessage response. Without this, the TUI's System-prompt/Tools/Chat-history
476
+ // tabs stay empty on the (now default) lean backend.
477
+ const getDebugCapture = () => this.debugCapture;
478
+ const debugCaptureMiddleware = createMiddleware({
479
+ name: 'GthMiddlewareDebugCapture',
480
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
481
+ wrapModelCall: async (request, handler) => {
482
+ // GS2-56: stash the always-on last-model-request snapshot (extras + as-sent messages)
483
+ // UNCONDITIONALLY — before the `capture` short-circuit — so `/debug-dump` has the full
484
+ // model input even when no TUI `/debug` sink is attached (a non-TUI surface, or `/debug`
485
+ // never opened). Guarded: snapshotting must never break the run. The computed extras are
486
+ // reused for the sink below so extraction runs once.
487
+ let extras;
488
+ try {
489
+ extras = extractDebugRequestExtras(request);
490
+ this.setLastModelRequest(request.messages, extras);
491
+ }
492
+ catch {
493
+ /* the always-on snapshot must never break the run */
494
+ }
495
+ const capture = getDebugCapture();
496
+ if (!capture)
497
+ return handler(request);
498
+ try {
499
+ capture.onRequest?.(request.messages, extras);
500
+ }
501
+ catch {
502
+ /* a debug sink must never break the run */
503
+ }
504
+ const response = await handler(request);
505
+ try {
506
+ capture.onResponse?.(response);
507
+ }
508
+ catch {
509
+ /* a debug sink must never break the run */
510
+ }
511
+ return response;
512
+ },
513
+ });
514
+ // shellExitSoftening FIRST so it is the outermost wrapToolCall — it must see the raw
515
+ // ShellCommandFailedError throw before any user-configured middleware could transform it.
516
+ // mcpToolErrorSoftening sits right after it, still outboard of any user-configured middleware so
517
+ // it sees the raw ToolException before a user wrapToolCall could transform it. Order between the
518
+ // two softeners is not load-bearing: they catch DISJOINT conditions (a ShellCommandFailedError
519
+ // vs a name==='ToolException') and each rethrows what it doesn't recognize, so neither can
520
+ // swallow the other.
521
+ // EXT-35: toolCallRepairMiddleware sits AFTER toolCallStatusMiddleware in the array. afterModel
522
+ // nodes execute in reverse array order (the later one runs first), so repair runs BEFORE the
523
+ // status middleware — a promoted call is therefore reported by the "Requested tools:" line too.
524
+ // Correctness (routing) is order-independent: the router reads final graph state after all
525
+ // afterModel nodes, and repair replaces-by-id, so the promoted tool_calls are present regardless.
526
+ // GS2-36: cap a self-inflicted tool-error loop. The shell/MCP softeners above turn a failed
527
+ // run_*/MCP call into a status:'error' ToolMessage the model observes; a model that keeps
528
+ // re-issuing the same failing call would drain tokens turn after turn. This beforeModel guard
529
+ // ends the run gracefully once MAX_CONSECUTIVE_TOOL_ERRORS such results accrue with no successful
530
+ // tool result in between — a tighter, error-specific complement to createAgent's coarse
531
+ // recursionLimit (loop DETECTION proper is the separate EXT-36). Placed after the softeners and
532
+ // before user middleware so it can't be bypassed. Lean backend only (per GS2-36 scope); the deep
533
+ // backend keeps its own recursionLimit backstop.
534
+ const toolErrorBudget = createToolErrorBudgetMiddleware();
535
+ // EXT-36: the ORTHOGONAL loop guard — repeated identical (tool, args) / no-progress detection,
536
+ // the sibling of GS2-36's error budget above. It catches the case GS2-36 explicitly leaves open:
537
+ // a model re-issuing the SAME call verbatim, whether it keeps erroring or keeps "succeeding" with
538
+ // the same result. Placed at index 3, immediately AFTER toolErrorBudget (index 2) and BEFORE user
539
+ // middleware: beforeModel hooks run in forward order with jumpTo short-circuiting, so on a
540
+ // simultaneous trip GS2-36's coarse error cap wins first and EXT-36 fires on its own
541
+ // signature-repeat threshold otherwise; keeping it outboard of user middleware means it can't be
542
+ // bypassed. WARN is on by default and SURFACES a user notice WITHOUT touching state.messages (the
543
+ // default path must never mutate the model's input — appending a message + re-invoking is a
544
+ // provider-unsafe steer, not a warn); HALT (opt-in) actively breaks the loop via a terminal
545
+ // jumpTo:'end'. Default WARN-ON is applied here at the read site (resolveToolLoopGuardOptions),
546
+ // NOT in DEFAULT_CONFIG, so the
547
+ // effective-config snapshot never churns. Lean backend only (like GS2-36); the deep array is
548
+ // untouched. `toolLoopGuard: false` resolves to a no-op guard (still installed at index 3 so the
549
+ // placement is stable).
550
+ const toolLoopGuard = createToolLoopGuardMiddleware(resolveToolLoopGuardOptions(this.config.toolLoopGuard),
551
+ // WARN surfaces through the same TUI-safe status channel every other agent notice uses
552
+ // (renderer-consumed, never raw stdout) so it can't leak over the Ink frame (TUI-C31).
553
+ (message) => statusUpdate(StatusLevel.WARNING, message));
554
+ // EXT-52: gate the opt-in run_shell_command tool behind the SAME per-command approval
555
+ // interrupt the deep backend has always wired (deepagents' `interruptOn` installs this very
556
+ // langchain `humanInTheLoopMiddleware` — see GthDeepAgent.buildDeepAgentParams). Without it,
557
+ // no interrupt ever fired on the lean (default) backend, so the runner's whole approval stack
558
+ // (`GthAgentRunner.decideToolApproval`: sessionYolo → allow-list → judge → human callback,
559
+ // fail-closed reject) was DEAD CODE on lean and shell commands ran unprompted. A matching tool
560
+ // call now suspends the graph with a HITLRequest interrupt; the runner drains it via
561
+ // getPendingToolInterrupts/streamResume (both backend-agnostic in GthAbstractAgent), so ONE
562
+ // gating code path drives both backends and the existing TUI + readline approval prompts fire
563
+ // identically on lean.
564
+ //
565
+ // The gate condition and its user-facing notices are the SHARED policy
566
+ // (`resolveShellApprovalGate`, EXT-12 semantics documented there); the two backends differ only
567
+ // in how they install the interrupt — directly as middleware here, via deepagents' `interruptOn`
568
+ // in GthDeepAgent.
569
+ const { gateShell, notice: shellGateNotice } = resolveShellApprovalGate(this.config ?? undefined, this.command);
570
+ //
571
+ // EXT-80: the shell is not the whole story. At `manual` and `write` every bound tool the
572
+ // rung's access class does not auto-grant — the write built-ins, MCP tools, custom tools — must
573
+ // reach the human, because those two rungs promise the user that anything beyond reading
574
+ // (respectively, beyond reading and writing files here) comes to them.
575
+ //
576
+ // **The interrupt is wired rung-INDEPENDENTLY, over every tool any rung could gate.** It is
577
+ // installed once, here, while `/approvals <rung>` moves the rung for the rest of the session
578
+ // without rebuilding this graph; a set that carried the rung would be frozen at the rung the
579
+ // session started on, and since the default is `assisted`, typing `/approvals manual` would
580
+ // leave exactly the write tools ungated. `GthAgentRunner.decideToolApproval` decides on the rung
581
+ // in force instead, which is where the rung has always been read — so wiring wider does not gate
582
+ // wider: at a rated rung a non-shell call is approved there with no rating call and no prompt.
583
+ //
584
+ // Both sets come from core's shared policy, which the deep backend and the runner also call, so
585
+ // no two of the three can disagree; and both read the FINAL tool array below rather than any
586
+ // static list, because a hand-written list cannot contain an MCP or custom tool, which is
587
+ // exactly what has to escalate.
588
+ const rung = resolveApprovals(this.config ?? undefined, this.command).rung;
589
+ const boundToolNames = tools
590
+ .map((tool) => tool?.name)
591
+ .filter((name) => typeof name === 'string' && name.length > 0);
592
+ // **What a surface that answers no approval gets — for BOTH sets below.** An interrupt nobody
593
+ // can answer suspends the graph forever: the tool never runs and the client is never asked. So
594
+ // such a surface is wired with exactly what the shell gate itself requires and nothing more,
595
+ // and is not TOLD it will be asked either. Neither the live set nor the interrupt set is a safe
596
+ // fallback here — both are non-empty at `manual` and `write`, which is precisely where an
597
+ // AG-UI server's writes and MCP calls would vanish, or be announced as approvable when nothing
598
+ // will ever approve them.
599
+ const answersApprovals = commandAnswersApprovals(this.command);
600
+ const noDrainTools = gateShell ? [SHELL_TOOL_NAME] : [];
601
+ // The LIVE gated set — what THIS rung gates — for the §4.5 tool descriptions below. Narrower
602
+ // than the interrupt set at the rated rungs, and it must stay so: a description promising an
603
+ // approval the runner will not ask for is the drift §4.5 calls worse than no description.
604
+ const gatedTools = answersApprovals
605
+ ? resolveGatedToolNames({ rung, gateShell, boundToolNames })
606
+ : noDrainTools;
607
+ // Rung-independent ONLY where something answers the interrupt.
608
+ const interruptTools = answersApprovals
609
+ ? resolveInterruptToolNames({ gateShell, boundToolNames })
610
+ : noDrainTools;
611
+ // Installed on the interrupt SET, not on `gateShell`: at a deterministic rung there is a gate to
612
+ // install even when the shell tool is disabled or the command emits no dev tools (a plain
613
+ // `chat` session with MCP servers). Keying the install off `gateShell` there would leave every
614
+ // one of those tools ungated while the rung's description promised otherwise.
615
+ const approvalMiddleware = interruptTools.length > 0
616
+ ? [
617
+ humanInTheLoopMiddleware({
618
+ interruptOn: Object.fromEntries(interruptTools.map((name) => [
619
+ name,
620
+ { allowedDecisions: ['approve', 'reject'] },
621
+ ])),
622
+ }),
623
+ ]
624
+ : [];
625
+ if (shellGateNotice) {
626
+ this.statusUpdate(shellGateNotice.level, shellGateNotice.message);
627
+ }
628
+ // EXT-58 (spec §4.5) — state the approvals posture where the model reads it: on the tool
629
+ // descriptions themselves. Every tool NOT auto-approved at the resolved rung gets the rung's
630
+ // sentence appended; every granted tool keeps its description exactly as written, because the
631
+ // ABSENCE of the sentence is what marks it free. `gatedTools` is the LIVE set for the rung in
632
+ // force — narrower than the interrupt set above, which covers every rung so the rung can still
633
+ // move — so a description can never promise an approval this rung will not ask for. Applied
634
+ // after the allowedTools filter and before createAgent, so the model only ever sees the final,
635
+ // suffixed set.
636
+ this.registerApprovalsAwareTools(tools, { rung, gatedTools });
637
+ // EXT-52 placement note: the HITL gate sits EARLY in the array — before user-configured
638
+ // middleware and, crucially, before toolCallRepairMiddleware — because afterModel hooks run in
639
+ // REVERSE array order (the EXT-35 rule above). The gate's afterModel therefore executes LAST,
640
+ // after EXT-35's repair has promoted a text-emitted `run_shell_command` into a native
641
+ // tool_call, so a small local model that serialises the call as text is gated too (were the
642
+ // gate appended last, like deepagents does on the deep path, it would run FIRST and a promoted
643
+ // shell call would bypass approval entirely).
644
+ const middleware = [
645
+ shellExitSoftening,
646
+ mcpToolErrorSoftening,
647
+ toolErrorBudget,
648
+ toolLoopGuard,
649
+ ...approvalMiddleware,
650
+ ...configuredMiddleware,
651
+ toolCallStatusMiddleware,
652
+ toolCallRepairMiddleware,
653
+ debugCaptureMiddleware,
654
+ ];
655
+ this.headerStatus(`Loaded middleware: ${middleware.map((m) => m.name).join(', ')}`);
656
+ // GS2-21: compose gsloth's system prompt (backstory + guidelines + per-command mode prompt +
657
+ // system prompt) EXACTLY as GthDeepAgent does, so identity profiles and `.gsloth.*.md` are
658
+ // honored on the lean backend too. Previously the lean agent gave the model NO system prompt
659
+ // (only the deep agent composed one), so `system-prompt.md` / the guidelines never reached
660
+ // the model — the robot (agent.backend: lean) behaved as if it never got its guidelines.
661
+ // This is passed to createAgent as `systemPrompt`, which langchain applies as the agent's
662
+ // static system message on every turn — NOT injected as a separate mid-conversation
663
+ // SystemMessage (a non-first system message that Anthropic rejects). GS2-79: which mode prompt
664
+ // a command gets is decided ONCE, in core's `readModePrompt` — 'code' the code-mode prompt,
665
+ // 'exec' the exec-mode prompt, 'review'/'pr' the REVIEW INSTRUCTIONS, chat/api/others the chat
666
+ // prompt — so this backend and the deep one cannot disagree, and a command left out of the
667
+ // selection can no longer be served the chat prompt by silent default.
668
+ const modePrompt = readModePrompt(this.command, this.config);
669
+ const systemMessages = buildSystemMessages(this.config, modePrompt);
670
+ const baseSystemPrompt = typeof systemMessages[0]?.content === 'string' ? systemMessages[0].content : undefined;
671
+ // GS2-27: in `code` mode append the SHARED code-mode notes the deep backend has always carried
672
+ // — the real-cwd / path-model note (EXT-13) and the OS + shell-dialect note (EXT-26). Both are
673
+ // backend-agnostic (the lean backend also exposes `run_shell_command` and runs on the real-fs
674
+ // cwd), so composing them here closes the deep-only drift that left a lean code session with no
675
+ // cwd value and no shell-dialect guidance (e.g. on Windows). The deepagents virtual-fs-namespace
676
+ // notes stay deep-only (lean never runs virtualMode). Same order the deep backend's real-path
677
+ // branch uses: cwd note first, OS/shell note last. `getCurrentWorkDir()` is already read above
678
+ // for the status line, so the value is free.
679
+ // GS2-34/EXT-83: resolve the active model identity ONCE, honouring the `injectModelContext`
680
+ // opt-out (default ON) at this single read site. Both consumers below take this same value, so
681
+ // the commit trailer and the model-context note can never disagree about which model is serving
682
+ // the session — and the opt-out means "my model identity stays out of the prompt", which covers
683
+ // the trailer as much as the identity line.
684
+ const modelIdentity = this.config.injectModelContext !== false ? resolveModelIdentity(this.config) : undefined;
685
+ // GS2-35: also append the commit co-authoring rule so the agent credits Gaunt Sloth (config
686
+ // `commit.coAuthor`, defaulting to the Gaunt Sloth account) in the `Co-Authored-By` trailer, and
687
+ // the EXT-83 commit-message rules (plain English, and passed by file — never inline, where the
688
+ // shell would expand the message before git runs). Same code-mode gate as the shell/cwd notes —
689
+ // the git-commit capability rides on `run_shell_command`, which is a code-mode tool.
690
+ // EXT-84: the effective `filesystem` is threaded in so the note names the writing tool only
691
+ // where that tool is registered. `this.config` is the command-merged value (getEffectiveConfig,
692
+ // above) — the SAME value handed to the tool resolver, so the note and the registered toolset
693
+ // cannot disagree. Mirrored on the deep backend.
694
+ const codeNotesPrompt = this.command === 'code'
695
+ ? appendCommitCoAuthorNote(appendOsShellNote(appendCwdNote(baseSystemPrompt, getCurrentWorkDir())), this.config.commit?.coAuthor, modelIdentity, this.config.filesystem)
696
+ : baseSystemPrompt;
697
+ // GS2-34: inject the resolved provider:model identity so the agent knows which model is serving
698
+ // it (to answer "what model are you?" and reason about its own capabilities/limits). Composed
699
+ // OUTSIDE the code-mode gate above — unlike the cwd/os-shell/commit notes, that question can
700
+ // arise in ANY mode (chat/ask/code/exec), so the identity must be visible everywhere. The
701
+ // `injectModelContext` opt-out is applied at the single read site above; when it is off — or
702
+ // when no model resolves — `modelIdentity` is undefined, nothing is appended, and the prompt is
703
+ // exactly as before. Backend-agnostic: the deep backend composes the same note (GS2-27). GS2-6's
704
+ // capability note is a deferred follow-up (bare provider:model identity only for now).
705
+ const modelContextPrompt = appendModelContextNote(codeNotesPrompt, modelIdentity);
706
+ // EXT-32: inject each connected MCP server's discovery `instructions` (captured during tool
707
+ // resolution) into the prompt — fenced + per-server-labelled as untrusted server-provided
708
+ // context. Mode-independent: MCP tools load in every mode, so their usage guidance applies in
709
+ // every mode (not just `code`). Empty/absent capture (or a resolver without the accessor) adds
710
+ // nothing. Composed through this shared path so it reaches the lean AND deep backends alike.
711
+ // When tools are disabled, resolveTools is skipped entirely (no MCP contact), so a REUSED
712
+ // resolver could still hold a prior run's capture — gate on toolsDisabled so no stale
713
+ // instructions leak into a tools-disabled session.
714
+ const mcpInstructions = toolsDisabled
715
+ ? []
716
+ : (this.resolvers?.getMcpServerInstructions?.() ?? []);
717
+ const systemPrompt = appendMcpServerInstructionsNote(modelContextPrompt, mcpInstructions);
718
+ // Create agent with configured middleware. Only pass systemPrompt when non-empty so we never
719
+ // hand createAgent an empty system message.
94
720
  this.agent = createAgent({
95
721
  model: this.config.llm,
96
722
  tools,
97
723
  middleware,
98
724
  checkpointer,
725
+ ...(systemPrompt ? { systemPrompt } : {}),
99
726
  });
100
727
  debugLog('React agent created successfully');
101
728
  }