@gaunt-sloth/agent 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 (96) hide show
  1. package/README.md +73 -23
  2. package/dist/builtInToolsConfig.d.ts +17 -3
  3. package/dist/builtInToolsConfig.js +40 -41
  4. package/dist/builtInToolsConfig.js.map +1 -1
  5. package/dist/core/GthDeepAgent.d.ts +52 -32
  6. package/dist/core/GthDeepAgent.js +438 -198
  7. package/dist/core/GthDeepAgent.js.map +1 -1
  8. package/dist/core/debugCapture.d.ts +8 -49
  9. package/dist/core/debugCapture.js +1 -1
  10. package/dist/core/debugCapture.js.map +1 -1
  11. package/dist/core/deepAgentPermissions.d.ts +67 -2
  12. package/dist/core/deepAgentPermissions.js +151 -3
  13. package/dist/core/deepAgentPermissions.js.map +1 -1
  14. package/dist/core/gthAcpServer.js +11 -0
  15. package/dist/core/gthAcpServer.js.map +1 -1
  16. package/dist/core/gthDeepAgentFactory.d.ts +3 -0
  17. package/dist/core/gthDeepAgentFactory.js +9 -1
  18. package/dist/core/gthDeepAgentFactory.js.map +1 -1
  19. package/dist/core/resolveAgentFactory.d.ts +14 -0
  20. package/dist/core/resolveAgentFactory.js +18 -0
  21. package/dist/core/resolveAgentFactory.js.map +1 -0
  22. package/dist/core/subagentProfiles.d.ts +50 -0
  23. package/dist/core/subagentProfiles.js +76 -0
  24. package/dist/core/subagentProfiles.js.map +1 -0
  25. package/dist/index.d.ts +1 -0
  26. package/dist/index.js +1 -0
  27. package/dist/index.js.map +1 -1
  28. package/dist/mcp/OAuthClientProviderImpl.js.map +1 -1
  29. package/dist/mcp/tlsTrust.d.ts +61 -0
  30. package/dist/mcp/tlsTrust.js +109 -0
  31. package/dist/mcp/tlsTrust.js.map +1 -0
  32. package/dist/middleware/binaryContentInjectionMiddleware.d.ts +8 -1
  33. package/dist/middleware/binaryContentInjectionMiddleware.js +11 -2
  34. package/dist/middleware/binaryContentInjectionMiddleware.js.map +1 -1
  35. package/dist/middleware/frontendImageInjectionMiddleware.d.ts +106 -0
  36. package/dist/middleware/frontendImageInjectionMiddleware.js +166 -0
  37. package/dist/middleware/frontendImageInjectionMiddleware.js.map +1 -0
  38. package/dist/middleware/registry.js +36 -1
  39. package/dist/middleware/registry.js.map +1 -1
  40. package/dist/middleware/types.d.ts +16 -2
  41. package/dist/modules/a2a/A2AClientWrapper.d.ts +56 -1
  42. package/dist/modules/a2a/A2AClientWrapper.js +131 -31
  43. package/dist/modules/a2a/A2AClientWrapper.js.map +1 -1
  44. package/dist/modules/acpModule.js +17 -1
  45. package/dist/modules/acpModule.js.map +1 -1
  46. package/dist/modules/apiAgUiModule.d.ts +87 -0
  47. package/dist/modules/apiAgUiModule.js +229 -28
  48. package/dist/modules/apiAgUiModule.js.map +1 -1
  49. package/dist/modules/interactiveSessionModule.js +501 -41
  50. package/dist/modules/interactiveSessionModule.js.map +1 -1
  51. package/dist/modules/slashCommands.d.ts +576 -0
  52. package/dist/modules/slashCommands.js +995 -0
  53. package/dist/modules/slashCommands.js.map +1 -0
  54. package/dist/resolvers.js +119 -9
  55. package/dist/resolvers.js.map +1 -1
  56. package/dist/tools/GthCustomToolkit.js +95 -11
  57. package/dist/tools/GthCustomToolkit.js.map +1 -1
  58. package/dist/tools/GthDevToolkit.d.ts +11 -3
  59. package/dist/tools/GthDevToolkit.js +120 -34
  60. package/dist/tools/GthDevToolkit.js.map +1 -1
  61. package/dist/tools/GthFileSystemToolkit.d.ts +63 -0
  62. package/dist/tools/GthFileSystemToolkit.js +506 -105
  63. package/dist/tools/GthFileSystemToolkit.js.map +1 -1
  64. package/dist/tools/McpResourceTool.d.ts +31 -0
  65. package/dist/tools/McpResourceTool.js +106 -0
  66. package/dist/tools/McpResourceTool.js.map +1 -0
  67. package/dist/tools/gthChecklistTool.d.ts +30 -0
  68. package/dist/tools/gthChecklistTool.js +81 -0
  69. package/dist/tools/gthChecklistTool.js.map +1 -0
  70. package/dist/tools/gthGrepTool.d.ts +54 -0
  71. package/dist/tools/gthGrepTool.js +482 -0
  72. package/dist/tools/gthGrepTool.js.map +1 -0
  73. package/dist/tools/shell/env.js +1 -1
  74. package/dist/tools/shell/env.js.map +1 -1
  75. package/dist/tools/shell/outputBuffer.js.map +1 -1
  76. package/dist/tools/shell/workDir.d.ts +11 -0
  77. package/dist/tools/shell/workDir.js +45 -0
  78. package/dist/tools/shell/workDir.js.map +1 -0
  79. package/dist/utils/mcpAuthError.d.ts +36 -0
  80. package/dist/utils/mcpAuthError.js +89 -0
  81. package/dist/utils/mcpAuthError.js.map +1 -0
  82. package/dist/utils/mcpUtils.js +16 -0
  83. package/dist/utils/mcpUtils.js.map +1 -1
  84. package/package.json +12 -11
  85. package/dist/tools/shell/allowlist.d.ts +0 -11
  86. package/dist/tools/shell/allowlist.js +0 -12
  87. package/dist/tools/shell/allowlist.js.map +0 -1
  88. package/dist/tools/shell/arity.d.ts +0 -11
  89. package/dist/tools/shell/arity.js +0 -12
  90. package/dist/tools/shell/arity.js.map +0 -1
  91. package/dist/tools/shell/hardline.d.ts +0 -15
  92. package/dist/tools/shell/hardline.js +0 -88
  93. package/dist/tools/shell/hardline.js.map +0 -1
  94. package/dist/tools/shell/normalize.d.ts +0 -10
  95. package/dist/tools/shell/normalize.js +0 -11
  96. package/dist/tools/shell/normalize.js.map +0 -1
@@ -1,13 +1,28 @@
1
- import { isShellToolEnabled } from '@gaunt-sloth/core/config.js';
1
+ import { SHELL_TOOL_NAME, commandAnswersApprovals, resolveApprovals, resolveGatedToolNames, resolveInterruptToolNames, resolveShellApprovalGate, } from '@gaunt-sloth/core/config.js';
2
2
  import { GthAbstractAgent } from '@gaunt-sloth/core/core/GthAbstractAgent.js';
3
3
  import { StatusLevel } from '@gaunt-sloth/core/core/types.js';
4
4
  import { debugLog, debugLogObject } from '@gaunt-sloth/core/utils/debugUtils.js';
5
- import { buildSystemMessages, formatToolCalls, readChatPrompt, readCodePrompt, readExecPrompt, } from '@gaunt-sloth/core/utils/llmUtils.js';
6
- import { getCurrentWorkDir } from '@gaunt-sloth/core/utils/systemUtils.js';
5
+ import { buildSystemMessages, formatToolCalls, readModePrompt, } from '@gaunt-sloth/core/utils/llmUtils.js';
6
+ import { getCurrentWorkDir, getProjectDir, getUseColour, setProjectDir, setUseColour, } from '@gaunt-sloth/core/utils/systemUtils.js';
7
+ import { getConsoleLevel, setConsoleLevel } from '@gaunt-sloth/core/utils/consoleUtils.js';
8
+ import { buildProfileSubagents } from '#src/core/subagentProfiles.js';
9
+ import { isToolAllowed } from '@gaunt-sloth/core/utils/toolMatching.js';
10
+ // GS2-27: the OS/shell-dialect and real-cwd notes are backend-agnostic (both backends expose
11
+ // run_shell_command and run on the real-fs cwd), so their canonical source moved to core so the
12
+ // lean backend composes them too. Imported here for GthDeepAgent.init()'s code-mode composition and
13
+ // re-exported so existing importers of this module keep working.
14
+ import { appendOsShellNote, appendCwdNote, appendCommitCoAuthorNote, appendModelContextNote, appendMcpServerInstructionsNote, resolveModelIdentity, } from '@gaunt-sloth/core/utils/systemPromptNotes.js';
15
+ export { appendOsShellNote, appendCwdNote, appendCommitCoAuthorNote, appendModelContextNote, resolveModelIdentity, OS_SHELL_GUIDANCE, } from '@gaunt-sloth/core/utils/systemPromptNotes.js';
7
16
  import { AIMessage, ToolMessage } from '@langchain/core/messages';
17
+ import { GraphInterrupt } from '@langchain/langgraph';
8
18
  import { createMiddleware } from 'langchain';
9
19
  import { createDeepAgent, FilesystemBackend } from 'deepagents';
10
- import { buildPermissions, FILESYSTEM_TOOL_NAMES, } from '#src/core/deepAgentPermissions.js';
20
+ import { buildPermissions, DEEP_AGENT_BUILT_IN_TOOL_NAMES, FILESYSTEM_TOOL_NAMES, guardFilesystemBackend, } from '#src/core/deepAgentPermissions.js';
21
+ import { extractDebugRequestExtras } from '#src/core/debugCapture.js';
22
+ // Re-export so existing importers of this module (extractDebugRequestExtras.spec) keep working
23
+ // now that the implementation lives in @gaunt-sloth/core.
24
+ export { extractDebugRequestExtras } from '#src/core/debugCapture.js';
25
+ import { ShellCommandFailedError } from '#src/tools/GthDevToolkit.js';
11
26
  /**
12
27
  * EXT-16: decide whether the deepagents filesystem backend must run in virtualMode.
13
28
  *
@@ -33,7 +48,10 @@ function shouldUseVirtualFs() {
33
48
  * {@link FilesystemBackend}. gsloth's `.aiignore` + `filesystem` config are mapped
34
49
  * onto deepagents `permissions` (see {@link buildPermissions}). Any resolved tool
35
50
  * that reuses a deepagents filesystem-tool name is therefore superseded and dropped
36
- * (`createDeepAgent` would otherwise throw on the collision).
51
+ * (`createDeepAgent` would otherwise throw on the collision). EXT-14: the
52
+ * `FilesystemBackend` itself is wrapped with {@link guardFilesystemBackend} before it
53
+ * reaches `createDeepAgent`, adding a realpath (symlink-resolved) containment check the
54
+ * permission globs alone can't provide.
37
55
  * - todos / subagents / summarization come from deepagents' standard middleware.
38
56
  *
39
57
  * The transport-agnostic param assembly lives in {@link buildDeepAgentParams} so the ACP
@@ -41,13 +59,8 @@ function shouldUseVirtualFs() {
41
59
  * middleware hardening without re-running `createDeepAgent` locally.
42
60
  */
43
61
  export class GthDeepAgent extends GthAbstractAgent {
44
- /**
45
- * Opt-in debug sink for the TUI `/debug` panel. Set AFTER {@link init} via
46
- * `runner.getAgent()`; read lazily inside the `wrapModelCall` middleware so that when it
47
- * is `undefined` (the normal path) the middleware is a transparent pass-through. Never
48
- * touched by the lean agent or the AG-UI server, so those contracts are unchanged.
49
- */
50
- debugCapture;
62
+ // `debugCapture` (the opt-in TUI `/debug` sink) now lives on the shared GthAbstractAgent base
63
+ // so the lean backend supports it too; the wrapModelCall capture middleware below reads it.
51
64
  async init(command, configIn, checkpointer) {
52
65
  const params = await this.buildDeepAgentParams(command, configIn);
53
66
  // Runner-path only: surface requested tool calls to the console. This is intentionally
@@ -79,11 +92,24 @@ export class GthDeepAgent extends GthAbstractAgent {
79
92
  name: 'GthMiddlewareDebugCapture',
80
93
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
81
94
  wrapModelCall: async (request, handler) => {
95
+ // GS2-56: stash the always-on last-model-request snapshot (extras + as-sent messages)
96
+ // UNCONDITIONALLY — before the `capture` short-circuit — so `/debug-dump` has the full
97
+ // model input even when no TUI `/debug` sink is attached (a non-TUI surface, or `/debug`
98
+ // never opened). Guarded: snapshotting must never break the run. The computed extras are
99
+ // reused for the sink below so extraction runs once. Symmetric with the lean backend.
100
+ let extras;
101
+ try {
102
+ extras = extractDebugRequestExtras(request);
103
+ this.setLastModelRequest(request.messages, extras);
104
+ }
105
+ catch {
106
+ /* the always-on snapshot must never break the run */
107
+ }
82
108
  const capture = getDebugCapture();
83
109
  if (!capture)
84
110
  return handler(request);
85
111
  try {
86
- capture.onRequest?.(request.messages, extractDebugRequestExtras(request));
112
+ capture.onRequest?.(request.messages, extras);
87
113
  }
88
114
  catch {
89
115
  /* a debug sink must never break the run */
@@ -98,31 +124,54 @@ export class GthDeepAgent extends GthAbstractAgent {
98
124
  return response;
99
125
  },
100
126
  });
101
- const middleware = [...params.middleware, toolCallStatusMiddleware, debugCaptureMiddleware];
102
- this.statusUpdate(StatusLevel.INFO, `Loaded middleware: ${middleware.map((m) => m.name).join(', ')}`);
127
+ // EXT-16: whether the deepagents fs backend runs in virtualMode. deepagents' permission layer
128
+ // requires POSIX `/`-rooted paths, so a Windows real cwd (`D:\...`) can't be expressed as a
129
+ // permission glob and the EXT-13 real-path mode hangs there (`Error: path must be absolute`).
130
+ // When the real cwd isn't POSIX-rooted, fall back to virtualMode (cwd→`/`) with virtual
131
+ // permissions — the pre-EXT-13 known-good Windows behavior. Computed here because the EXT-22 S1
132
+ // middleware (below), the backend, and the systemPrompt gate (further down) all key off it.
133
+ const useVirtualFs = shouldUseVirtualFs();
134
+ // EXT-22 (S1): last-word path-namespace correction. Appends the shared guidance as a trailing
135
+ // system-message block ONLY in code + virtualMode (where the fs virtual `/` root and the
136
+ // shell's real-OS paths diverge); a transparent pass-through otherwise. Added LAST in the
137
+ // middleware array so, being the innermost wrapModelCall, its block lands AFTER deepagents'
138
+ // "All file paths must start with a /." line (see handoff/spike-systemmessage-ordering.md).
139
+ const pathNamespaceCorrectionMiddleware = createPathNamespaceCorrectionMiddleware(this.command === 'code' && useVirtualFs);
140
+ const middleware = [
141
+ ...params.middleware,
142
+ toolCallStatusMiddleware,
143
+ debugCaptureMiddleware,
144
+ pathNamespaceCorrectionMiddleware,
145
+ ];
146
+ this.headerStatus(`Loaded middleware: ${middleware.map((m) => m.name).join(', ')}`);
103
147
  // EXT-13: the backend always runs in REAL-path mode (virtualMode off) so the deepagents fs
104
148
  // tools and the EXT-9 run_shell_command tool share ONE path namespace — real absolute paths
105
- // rooted at cwd. Containment is enforced entirely by the permission allow/deny globs built in
149
+ // rooted at cwd. Containment is enforced by the permission allow/deny globs built in
106
150
  // buildDeepAgentParams (default: allow cwd/**, deny /**), which match what virtualMode used to
107
- // give for free (see deepAgentPermissions + the EXT-13 symlink/`..` parity tests).
151
+ // give for free (see deepAgentPermissions + the EXT-13 symlink/`..` parity tests), PLUS the
152
+ // EXT-14 realpath guard wrapped around the backend below (closes the intermediate-symlinked-
153
+ // directory gap those lexical globs alone can't catch).
108
154
  // `--allow-dir` (config.allowDirs) further widens those allow-rules to reach extra real dirs;
109
155
  // it removes a guardrail, so it is announced loudly by the exec command and surfaced here.
110
156
  const allowDirs = this.config?.allowDirs;
111
157
  const widenFs = Array.isArray(allowDirs) && allowDirs.length > 0;
112
- // EXT-16: deepagents' permission layer requires POSIX `/`-rooted paths, so a Windows real
113
- // cwd (`D:\...`) can't be expressed as a permission glob and the EXT-13 real-path mode hangs
114
- // there (`Error: path must be absolute`). When the real cwd isn't POSIX-rooted, fall back to
115
- // virtualMode (cwd→`/`) with virtual permissions — the pre-EXT-13 known-good Windows behavior.
116
- const useVirtualFs = shouldUseVirtualFs();
117
158
  if (widenFs) {
118
159
  this.statusUpdate(StatusLevel.WARNING, `Filesystem sandbox widened beyond cwd (--allow-dir): ${allowDirs.join(', ')}` +
119
160
  (useVirtualFs
120
161
  ? ' — note: on this platform the sandbox runs in virtual mode, so widening beyond cwd is not applied.'
121
162
  : ''));
122
163
  }
123
- const backend = new FilesystemBackend({
164
+ // EXT-14: layer the realpath containment guard around the backend deepagents' fs middleware
165
+ // (main agent AND every subagent — they all share this one `backend` reference, see
166
+ // guardFilesystemBackend's doc comment) reads/writes through. Closes the intermediate-
167
+ // symlinked-directory escape that the lexical allow/deny globs alone cannot catch.
168
+ const backend = guardFilesystemBackend(new FilesystemBackend({
124
169
  rootDir: getCurrentWorkDir(),
125
170
  virtualMode: useVirtualFs,
171
+ }), {
172
+ cwd: getCurrentWorkDir(),
173
+ virtual: useVirtualFs,
174
+ allowDirs: widenFs ? allowDirs : undefined,
126
175
  });
127
176
  // EXT-13 (part b): on the local-runner code path the model used to be told nothing about
128
177
  // where it is, so it assumed `/` was cwd and fed `/`-rooted paths to the real-fs shell. Now
@@ -131,15 +180,97 @@ export class GthDeepAgent extends GthAbstractAgent {
131
180
  // shell access; the ACP transport keeps virtualMode and re-roots per session, so this
132
181
  // real-path note must NOT leak there (which is why it lives in init(), not the
133
182
  // transport-agnostic buildDeepAgentParams).
134
- // In virtualMode (EXT-16, Windows) the model correctly assumes the virtual root `/` IS cwd
135
- // (the pre-EXT-13 behavior), so the real-cwd note must NOT be injected it would mislabel
136
- // the namespace. Only the real-path code path needs it.
137
- const systemPrompt = this.command === 'code' && !useVirtualFs
138
- ? appendCwdNote(params.systemPrompt, getCurrentWorkDir())
183
+ // In virtualMode (EXT-16, Windows) the real-cwd note must NOT be injected it would mislabel
184
+ // the namespace (the fs tools' `/` is the virtual root, not the real cwd). Instead, EXT-22 (S2)
185
+ // injects the virtualMode path-namespace note so the model is told EARLY that the fs virtual
186
+ // `/` root and run_shell_command's real-OS paths differ (the S1 middleware repeats it as the
187
+ // authoritative last word after deepagents' `/`-rooted line). Non-code paths get neither.
188
+ // EXT-26: after the cwd/virtual-cwd note, append the OS + shell-dialect note so the model is
189
+ // told its host OS and which shell run_shell_command spawns (cmd.exe on Windows, /bin/sh on
190
+ // POSIX). This is ORTHOGONAL to the path-namespace notes above (those say WHERE it is; this
191
+ // says WHAT shell it speaks) and applies in BOTH code-mode branches, independent of
192
+ // virtualMode — the shell dialect matters on every platform. Non-code paths get nothing new.
193
+ // GS2-34/EXT-83: resolve the active model identity ONCE, honouring the `injectModelContext`
194
+ // opt-out (default ON) at this single read site. Both consumers below take this same value, so
195
+ // the commit trailer and the model-context note can never disagree about which model is serving
196
+ // the session — and the opt-out means "my model identity stays out of the prompt", which covers
197
+ // the trailer as much as the identity line. Mirrors the lean backend seam.
198
+ const modelIdentity = this.config?.injectModelContext !== false ? resolveModelIdentity(this.config) : undefined;
199
+ // GS2-35: append the commit co-authoring rule (config `commit.coAuthor`, defaulting to the Gaunt
200
+ // Sloth account) so agent-authored commits credit Gaunt Sloth in the `Co-Authored-By` trailer,
201
+ // and the EXT-83 commit-message rules (plain English, and passed by file — never inline, where
202
+ // the shell would expand the message before git runs). Mirrors the lean backend seam so both
203
+ // compose the same shared note (GS2-27 parity); same code-mode gate as the shell/cwd notes and
204
+ // independent of virtualMode.
205
+ // EXT-84: the effective `filesystem` is threaded in so the note names the writing tool only
206
+ // where that tool is registered. It is the command-merged value set by buildDeepAgentParams
207
+ // (getEffectiveConfig) — NOT the `filesystem: 'none'` override used purely to keep gsloth's
208
+ // filesystem toolkit out of tool resolution, which says nothing about the fs access deepagents
209
+ // itself grants from the same value. Mirrors the lean backend seam.
210
+ const codeNotesPrompt = this.command === 'code'
211
+ ? appendCommitCoAuthorNote(appendOsShellNote(useVirtualFs
212
+ ? appendVirtualCwdNote(params.systemPrompt)
213
+ : appendCwdNote(params.systemPrompt, getCurrentWorkDir())), this.config?.commit?.coAuthor, modelIdentity, this.config?.filesystem)
139
214
  : params.systemPrompt;
215
+ // GS2-34: inject the resolved provider:model identity (mirrors the lean GthLangChainAgent seam so
216
+ // both backends compose the same shared note — GS2-27 parity). Composed OUTSIDE the code-mode
217
+ // gate above: "which model are you?" can be asked in ANY mode (chat/ask/code/exec), so the
218
+ // identity is visible everywhere, unlike the code-only cwd/os-shell/commit notes. The
219
+ // `injectModelContext` opt-out is applied at the single read site above; when it is off — or
220
+ // when no model resolves — `modelIdentity` is undefined, nothing is appended, and the prompt is
221
+ // exactly as before. Lives in init() like the cwd note (not buildDeepAgentParams), so the
222
+ // deepagents-acp transport is unaffected, consistent with every other init()-composed note.
223
+ // GS2-6 capability note deferred.
224
+ const modelContextPrompt = appendModelContextNote(codeNotesPrompt, modelIdentity);
225
+ // EXT-32: inject the connected MCP servers' discovery `instructions` (captured by the resolver
226
+ // during buildDeepAgentParams' resolveTools call, above) into the prompt — fenced + per-server-
227
+ // labelled as untrusted server-provided context. Mode-independent (MCP tools load in every
228
+ // mode). This mirrors the lean backend's GthLangChainAgent seam so both backends compose the
229
+ // same shared note (GS2-27 shared-path parity). Lives in init() like the cwd note, so the ACP
230
+ // buildDeepAgentParams entry is intentionally unaffected. Empty capture adds nothing.
231
+ // When tools are disabled, buildDeepAgentParams skips resolveTools (no MCP contact), so a REUSED
232
+ // resolver could still hold a prior run's capture — gate on toolsDisabled (recomputed from the
233
+ // effective config buildDeepAgentParams set on `this.config`) so no stale instructions leak.
234
+ const deepToolsDisabled = Array.isArray(this.config?.allowedTools) && this.config.allowedTools.length === 0;
235
+ const mcpInstructions = deepToolsDisabled
236
+ ? []
237
+ : (this.resolvers?.getMcpServerInstructions?.() ?? []);
238
+ const systemPrompt = appendMcpServerInstructionsNote(modelContextPrompt, mcpInstructions);
239
+ // GS2-33 — resolve profile-backed subagents (config `subagents`). Runner-only, like the
240
+ // cwd/model-context notes above: it lives in init() (NOT the transport-agnostic
241
+ // buildDeepAgentParams), so the deepagents-acp path is unaffected and the child-config
242
+ // resolution's process-global side effects stay on the local-runner path. Each declared
243
+ // subagent's CHILD resolves its named profile through the GS2-1 cascade, so the deepagents `task`
244
+ // tool can dispatch it under that profile's own model + tools + prompt (e.g. a cheap flash-lite
245
+ // profile for recall/search while the parent runs on a strong model). Because resolving a child
246
+ // goes through initConfig — which mutates projectDir/consoleLevel/useColour exactly as the parent
247
+ // run's own initConfig did — snapshot and restore those globals so a child profile's console
248
+ // level / colour can never leak into the parent run.
249
+ let subagents;
250
+ const subagentSpecs = this.config?.subagents;
251
+ if (Array.isArray(subagentSpecs) && subagentSpecs.length > 0) {
252
+ const savedProjectDir = getProjectDir();
253
+ const savedConsoleLevel = getConsoleLevel();
254
+ const savedUseColour = getUseColour();
255
+ try {
256
+ subagents = await buildProfileSubagents(subagentSpecs, {
257
+ command: this.command,
258
+ resolveTools: this.resolvers?.resolveTools?.bind(this.resolvers),
259
+ });
260
+ this.headerStatus(`Loaded profile subagents: ${subagents.map((s) => s.name).join(', ')}`);
261
+ }
262
+ finally {
263
+ setProjectDir(savedProjectDir);
264
+ setConsoleLevel(savedConsoleLevel);
265
+ setUseColour(savedUseColour);
266
+ }
267
+ }
140
268
  this.agent = createDeepAgent({
141
269
  model: params.model,
142
270
  tools: params.tools,
271
+ // GS2-33 — profile-backed subagents (undefined when none configured → deepagents' default
272
+ // general-purpose subagent only, unchanged behaviour).
273
+ subagents,
143
274
  // gsloth's composed prompt, combined ADDITIVELY by deepagents with its base + fs prompts
144
275
  // into a single system message (avoids the two-system-message Anthropic rejection).
145
276
  systemPrompt,
@@ -149,7 +280,7 @@ export class GthDeepAgent extends GthAbstractAgent {
149
280
  permissions: params.permissions,
150
281
  // Per-tool human-in-the-loop gating (e.g. run_shell_command confirmation). When set,
151
282
  // deepagents installs humanInTheLoopMiddleware so a matching tool call suspends the graph
152
- // for approval; `undefined` (the default, and under yolo) leaves every tool ungated.
283
+ // for approval; `undefined` (the default, and under bypass) leaves every tool ungated.
153
284
  interruptOn: params.interruptOn,
154
285
  checkpointer,
155
286
  });
@@ -173,16 +304,16 @@ export class GthDeepAgent extends GthAbstractAgent {
173
304
  streamOutput: this.config.streamOutput,
174
305
  debugLog: this.config.debugLog,
175
306
  });
176
- this.statusUpdate(StatusLevel.INFO, `Workdir: ${getCurrentWorkDir()}`);
307
+ this.headerStatus(`Workdir: ${getCurrentWorkDir()}`);
177
308
  if (this.config.modelDisplayName) {
178
- this.statusUpdate(StatusLevel.INFO, `Model: ${this.config.modelDisplayName}`);
309
+ this.headerStatus(`Model: ${this.config.modelDisplayName}`);
179
310
  }
180
311
  // An empty allowedTools allow-list disables every tool. Skip resolution entirely so we
181
312
  // don't contact MCP servers (and trigger OAuth) just to discard the result.
182
313
  const allowedTools = this.config.allowedTools;
183
314
  const toolsDisabled = Array.isArray(allowedTools) && allowedTools.length === 0;
184
315
  if (toolsDisabled) {
185
- this.statusUpdate(StatusLevel.INFO, 'Tool loading disabled by allowedTools: []; MCP/A2A servers will not be contacted. Omit allowedTools for no filtering.');
316
+ this.headerStatus('Tool loading disabled by allowedTools: []; MCP/A2A servers will not be contacted. Omit allowedTools for no filtering.');
186
317
  }
187
318
  // Resolve tools with filesystem access disabled. deepagents OWNS the filesystem
188
319
  // (its fs middleware + the `permissions` built below); gsloth's filesystem toolkit
@@ -200,11 +331,12 @@ export class GthDeepAgent extends GthAbstractAgent {
200
331
  ? []
201
332
  : this.extractAndFlattenTools(this.config.tools || []);
202
333
  debugLog(`User config tools loaded: ${flattenedConfigTools.length}`);
203
- // Combine all tools, then apply the allowedTools name allow-list when configured.
334
+ // Combine all tools, then apply the allowedTools name allow-list when configured. Entries
335
+ // match by exact name, or glob-style when they contain `*` (e.g. `mcp__unimarket__*`) — see
336
+ // isToolAllowed. Nameless ServerTools are retained (they can't be named in the allow-list).
204
337
  let tools = [...resolvedTools, ...flattenedConfigTools];
205
338
  if (Array.isArray(allowedTools)) {
206
- const allowed = new Set(allowedTools);
207
- tools = tools.filter((tool) => !tool.name || allowed.has(tool.name));
339
+ tools = tools.filter((tool) => !tool.name || isToolAllowed(tool.name, allowedTools));
208
340
  }
209
341
  // Safety net: a custom/dev/MCP tool may still reuse a deepagents filesystem-tool
210
342
  // name (createDeepAgent throws on such a collision). Drop the colliding tool — the
@@ -222,7 +354,7 @@ export class GthDeepAgent extends GthAbstractAgent {
222
354
  .map((tool) => tool.name)
223
355
  .filter((name) => name)
224
356
  .join(', ');
225
- this.statusUpdate(StatusLevel.INFO, `Loaded tools: ${toolNames}`);
357
+ this.headerStatus(`Loaded tools: ${toolNames}`);
226
358
  debugLog(`Total tools available: ${passThroughTools.length}`);
227
359
  debugLogObject('All Tools', toolNames.split(', '));
228
360
  }
@@ -244,11 +376,20 @@ export class GthDeepAgent extends GthAbstractAgent {
244
376
  }
245
377
  return true;
246
378
  });
247
- // Soften deepagents' fail-hard filesystem permission denials. By default a denied
248
- // read/write THROWS, which aborts the whole run; wrap tool calls so a denial becomes
249
- // a recoverable ToolMessage instead, letting the model continue and report it. This
250
- // preserves gsloth's recoverable-denial UX (the old GthFileSystemToolkit returned a
251
- // message rather than throwing).
379
+ // Soften deepagents' fail-hard filesystem tool throws. By default the permission layer
380
+ // THROWS on both a denied read/write AND on a malformed path the model supplied a relative
381
+ // path, a `..`/`~` segment, or an empty string (deepagents' validatePath, run BEFORE the
382
+ // permission check in enforcePermission). Any of these aborts the WHOLE run. On the AG-UI
383
+ // transport that throw propagates out of streamWithEvents into the run handler's catch, which
384
+ // emits RUN_ERROR and ends the response WITHOUT a terminal RUN_FINISHED — and since AG-UI's
385
+ // protocol makes RUN_ERROR terminal ("no further events can be sent"), a consumer waiting for
386
+ // RUN_FINISHED hangs (EXT-24). Wrap tool calls so each of these becomes a recoverable error
387
+ // ToolMessage instead, letting the model observe the mistake, retry with a good path, and
388
+ // finish the run normally (reaching RUN_FINISHED). This preserves gsloth's recoverable-denial
389
+ // UX (the old GthFileSystemToolkit returned a message rather than throwing). Only these known
390
+ // fs path/permission messages are caught; every other throw (GraphInterrupt from a client-tool
391
+ // interrupt stub, AbortError on client disconnect, unexpected errors) is rethrown untouched so
392
+ // control-flow and genuine failures still surface.
252
393
  const fsDenialSoftening = createMiddleware({
253
394
  name: 'GthDeepFsDenialSoftening',
254
395
  wrapToolCall: async (request, handler) => {
@@ -256,9 +397,27 @@ export class GthDeepAgent extends GthAbstractAgent {
256
397
  return await handler(request);
257
398
  }
258
399
  catch (e) {
400
+ // EXT-25: rethrow control-flow throws BY TYPE, BEFORE the message regex below. A
401
+ // GraphInterrupt (a client-tool interrupt() suspending the graph for HITL tool
402
+ // approval) and an AbortError (caller cancellation) must ALWAYS propagate so the graph
403
+ // suspends / cancels — never be converted into a benign ToolMessage. Mirrors the guard
404
+ // in GthAbstractAgent (error.name checks + GraphInterrupt instanceof). Today these
405
+ // survive only because their messages happen not to match the regex; guarding by type
406
+ // stops a future regex broadening from silently swallowing the HITL suspend.
407
+ if (e instanceof GraphInterrupt ||
408
+ e?.name === 'GraphInterrupt' ||
409
+ e?.name === 'AbortError') {
410
+ throw e;
411
+ }
259
412
  const message = e instanceof Error ? e.message : String(e);
260
- if (/permission denied for (read|write)/i.test(message)) {
261
- debugLog(`Softened fs permission denial into a ToolMessage: ${message}`);
413
+ // A permission denial, or a path that is relative / contains ".." or "~" / is empty —
414
+ // all recoverable model-input errors. The live source of these throws is gsloth's own
415
+ // realpath guard (guardFilesystemBackend); deepagents' enforcement used to throw the
416
+ // same messages too, but since 1.11.1 it returns the equivalent error ToolMessage
417
+ // itself and so never reaches this catch. Keep matching both: the observation the model
418
+ // sees is identical either way, and the guard still depends on this softening.
419
+ if (/permission denied for (read|write)|path must (be absolute|not contain|be a non-empty string)/i.test(message)) {
420
+ debugLog(`Softened fs tool throw into a ToolMessage: ${message}`);
262
421
  return new ToolMessage({
263
422
  content: message,
264
423
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -270,10 +429,93 @@ export class GthDeepAgent extends GthAbstractAgent {
270
429
  }
271
430
  },
272
431
  });
273
- // fsDenialSoftening first so it is the outermost wrapToolCall it must see the throw
274
- // from deepagents' permission-enforcing fs tools. The console-bound tool-call-status
275
- // middleware is NOT added here (see GthDeepAgentParams.middleware); the runner appends it.
276
- const middleware = [fsDenialSoftening, ...configuredMiddleware];
432
+ // EXT-20: sibling of fsDenialSoftening for the run_* (dev/shell) tools. GthDevToolkit's
433
+ // executeCommand now THROWS a ShellCommandFailedError on a non-zero exit or a timeout-kill
434
+ // (instead of resolving with the failure text), so the tool result no longer misreports
435
+ // status:'success' (✓). Catch it here and return an error ToolMessage that PRESERVES the full
436
+ // stdout/stderr body — the model's observation is unchanged except that status flips to
437
+ // 'error', which drives the ✗ (isError) glyph (GthAbstractAgent maps status==='error' →
438
+ // isError). Returning a ToolMessage (rather than rethrowing) also means the approved-then-failed
439
+ // command does NOT trigger a retry loop — it is a normal, observed tool result.
440
+ const shellExitSoftening = createMiddleware({
441
+ name: 'GthDeepShellExitSoftening',
442
+ wrapToolCall: async (request, handler) => {
443
+ try {
444
+ return await handler(request);
445
+ }
446
+ catch (e) {
447
+ if (e instanceof ShellCommandFailedError) {
448
+ debugLog(`Softened shell/dev command failure (exit ${e.exitCode ?? 'timeout'}) into an ` +
449
+ `error ToolMessage for '${e.command}'`);
450
+ return new ToolMessage({
451
+ content: e.output,
452
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
453
+ tool_call_id: request.toolCall?.id ?? '',
454
+ status: 'error',
455
+ });
456
+ }
457
+ throw e;
458
+ }
459
+ },
460
+ });
461
+ // MCP tool-execution errors are spec-compliant RESULTS, not fatal faults. Per the MCP spec
462
+ // (2025-11-25 & draft, "Server › Tools › Error Handling"), a tool that hits an API failure, an
463
+ // input-validation problem, or a business-logic error (e.g. a disabled capability) returns a
464
+ // normal tools/call result with `isError: true`, and the CLIENT *SHOULD* hand that error to the
465
+ // model so it can self-correct. `@langchain/mcp-adapters` instead surfaces such a result by
466
+ // THROWING a ToolException at call time (its `_convertCallToolResult`). Because we install a
467
+ // wrapToolCall middleware, langchain's ToolNode treats any error a middleware rethrows as a fatal
468
+ // "middleware error" (`errorFromMiddleware && handleToolErrors !== true` → throw) and aborts the
469
+ // whole turn instead of relaying the error to the model — the opposite of the spec's client
470
+ // SHOULD. This middleware closes that gap: it catches a thrown ToolException and RETURNS it as a
471
+ // status:'error' ToolMessage (→ isError → ✗), so the model observes the error and can retry or
472
+ // explain (matching the non-stream invoke path's ToolException handling). Scope & safety: matched
473
+ // by name === 'ToolException' (the adapter's marker), so GraphInterrupt and every non-MCP throw
474
+ // fall through the final rethrow untouched. The adapter ALSO wraps a call-time AbortError into a
475
+ // ToolException (its `_callTool` catch-all), so we RETHROW when the run's abort signal is set —
476
+ // otherwise softening here would swallow user cancellation that ToolNode's own `signal?.aborted`
477
+ // guard normally enforces (bypassed once we handle the error in middleware). MCP connect/auth
478
+ // (401/403) and load failures are handled at CONNECT time (resolvers.ts throwOnLoadError +
479
+ // onConnectionError), not here, so they stay fatal as intended.
480
+ const mcpToolErrorSoftening = createMiddleware({
481
+ name: 'GthMcpToolErrorSoftening',
482
+ wrapToolCall: async (request, handler) => {
483
+ try {
484
+ return await handler(request);
485
+ }
486
+ catch (e) {
487
+ if (e instanceof Error &&
488
+ e.name === 'ToolException' &&
489
+ !request.runtime?.signal?.aborted) {
490
+ debugLog(`Softened MCP tool error into an error ToolMessage: ${e.message}`);
491
+ return new ToolMessage({
492
+ content: e.message,
493
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
494
+ tool_call_id: request.toolCall?.id ?? '',
495
+ status: 'error',
496
+ });
497
+ }
498
+ throw e;
499
+ }
500
+ },
501
+ });
502
+ // fsDenialSoftening first so it is the outermost wrapToolCall — it must see the throw from
503
+ // deepagents' permission-enforcing fs tools. shellExitSoftening sits right after it (still
504
+ // outboard of any user-configured middleware, so it always sees the raw ShellCommandFailedError
505
+ // throw before a user wrapToolCall could transform it). Order between the two softeners is not
506
+ // load-bearing: they catch DISJOINT conditions (a permission-denied regex vs an
507
+ // `instanceof ShellCommandFailedError`) and each rethrows what it doesn't recognize, so neither
508
+ // can swallow the other. The console-bound tool-call-status middleware is NOT added here (see
509
+ // GthDeepAgentParams.middleware); the runner appends it. mcpToolErrorSoftening sits alongside the
510
+ // other two softeners (still outboard of user middleware); it catches a DISJOINT condition
511
+ // (name==='ToolException') and rethrows everything else, so ordering among the three is not
512
+ // load-bearing.
513
+ const middleware = [
514
+ fsDenialSoftening,
515
+ shellExitSoftening,
516
+ mcpToolErrorSoftening,
517
+ ...configuredMiddleware,
518
+ ];
277
519
  // Map gsloth's .aiignore + filesystem mode onto deepagents permission rules. When
278
520
  // `--allow-dir` widens the sandbox, the backend runs without virtualMode, so paths are REAL
279
521
  // absolute paths: constrain read+write to cwd + the allowed dirs (everything else denied),
@@ -294,33 +536,98 @@ export class GthDeepAgent extends GthAbstractAgent {
294
536
  // `.gsloth.*.md` are honored. This is passed to createDeepAgent as `systemPrompt` — combined
295
537
  // additively with deepagents' base + fs prompts into ONE system message — rather than injected
296
538
  // as a separate SystemMessage per turn (which produced a non-first system message that
297
- // Anthropic rejects). 'code' uses the code-mode prompt; 'exec' uses the prompt-as-script
298
- // exec-mode prompt; chat/api/others use the chat prompt.
299
- const modePrompt = this.command === 'code'
300
- ? readCodePrompt(this.config)
301
- : this.command === 'exec'
302
- ? readExecPrompt(this.config)
303
- : readChatPrompt(this.config);
539
+ // Anthropic rejects). GS2-79: which mode prompt a command gets is decided ONCE, in core's
540
+ // `readModePrompt` — 'code' the code-mode prompt, 'exec' the prompt-as-script exec-mode prompt,
541
+ // 'review'/'pr' the REVIEW INSTRUCTIONS, chat/api/others the chat prompt — so this backend and
542
+ // the lean one cannot disagree, and a command left out of the selection can no longer be served
543
+ // the chat prompt by silent default.
544
+ const modePrompt = readModePrompt(this.command, this.config);
304
545
  const systemMessages = buildSystemMessages(this.config, modePrompt);
305
546
  const systemPrompt = typeof systemMessages[0]?.content === 'string' ? systemMessages[0].content : undefined;
306
- // Gate the opt-in run_shell_command tool behind a per-command approval interrupt. The tool
307
- // is only emitted (by GthDevToolkit, via builtInToolsConfig) when its devTools.shell flag is
308
- // set; mirror the same per-command devTools resolution here so the interrupt is wired only
309
- // when the tool actually exists. yolo (shellYolo) opts OUT of the confirmation: leave
310
- // interruptOn undefined so the tool runs without suspending.
311
- const devTools = this.getEffectiveDevToolsConfig();
312
- // EXT-12 pass the active command so the absent-config default (shell ON in `code`)
313
- // is applied consistently with where the tool is actually emitted (GthDevToolkit).
314
- const shellEnabled = isShellToolEnabled(devTools, this.command);
315
- const interruptOn = shellEnabled && devTools?.shellYolo !== true
316
- ? { run_shell_command: { allowedDecisions: ['approve', 'reject'] } }
547
+ // Gate the opt-in run_shell_command tool behind a per-command approval interrupt. The gate
548
+ // condition and its user-facing notices are the SHARED core policy (`resolveShellApprovalGate`,
549
+ // EXT-12 semantics documented there), so this backend and the lean one can never drift; here it
550
+ // is installed through deepagents' `interruptOn` rather than as middleware. The resolver reads
551
+ // the same `builtInTools` registry GthDevToolkit emits the tool from (CFG-18), so the wiring
552
+ // stays in lockstep with where the tool actually exists.
553
+ const { gateShell, notice: shellGateNotice } = resolveShellApprovalGate(this.config ?? undefined, this.command);
554
+ //
555
+ // EXT-80: at `manual` and `write` every bound tool the rung's access class does not
556
+ // auto-grant must reach the human. Both sets below come from core's shared policy — the same one
557
+ // the lean backend and `GthAgentRunner` call so the three cannot disagree about what is gated.
558
+ //
559
+ // **The interrupt is wired rung-INDEPENDENTLY, over every tool any rung could gate.** It is
560
+ // built once, here, while `/approvals <rung>` moves the rung for the rest of the session without
561
+ // rebuilding the graph; a set that carried the rung would be frozen at the rung the session
562
+ // started on, and since the default is `assisted`, typing `/approvals manual` would leave
563
+ // exactly the write tools ungated. `GthAgentRunner.decideToolApproval` decides on the rung in
564
+ // force, so wiring wider does not gate wider.
565
+ //
566
+ // **`boundToolNames` must include deepagents' OWN tools.** deepagents registers its filesystem
567
+ // tools, `execute`, `task` and `write_todos` itself (this backend resolves with
568
+ // `filesystem: 'none'`), so they never appear in `passThroughTools`; deriving the set from that
569
+ // array alone would leave `write_file`, `edit_file`, `execute`, `task` and `write_todos` ungated
570
+ // at `manual` on this backend — precisely the defect this change exists to remove. Gating
571
+ // them by name works because deepagents installs the very same langchain
572
+ // `humanInTheLoopMiddleware`, which matches the model's tool CALLS by name in `afterModel` and so
573
+ // does not care which party registered the tool.
574
+ //
575
+ // Note this union keeps `execute`, unlike `additionalToolNames` below: that list answers "what
576
+ // may the rater offer as a granted alternative", a different question from "what must the gate
577
+ // stop". `execute` is deepagents' shell and has no access class, so it must be gated at both
578
+ // deterministic rungs and offered as an alternative at none.
579
+ const rung = resolveApprovals(this.config ?? undefined, this.command).rung;
580
+ const boundToolNames = [
581
+ ...passThroughTools
582
+ .map((tool) => tool?.name)
583
+ .filter((name) => typeof name === 'string' && name.length > 0),
584
+ ...DEEP_AGENT_BUILT_IN_TOOL_NAMES,
585
+ ];
586
+ // **What a surface that answers no approval gets — for BOTH sets below.** An interrupt nobody
587
+ // can answer suspends the graph forever: the tool never runs and the client is never asked. So
588
+ // such a surface is wired with exactly what the shell gate itself requires and nothing more,
589
+ // and is not TOLD it will be asked either. This backend is ONE of the two the AG-UI server can
590
+ // run on — `apiAgUiModule.createConfiguredAgent` builds a `GthDeepAgent` only when
591
+ // `agent.backend === 'deep'` (and warns that it is experimental); every other configuration,
592
+ // the default included, gets the lean `GthLangChainAgent`, which carries the same wiring for
593
+ // the same reason. It matters on both: at `manual` and `write` the live set is non-empty, so a
594
+ // write, an MCP call, `task` or deepagents' own `write_todos` bookkeeping would simply vanish —
595
+ // or be announced to the model as approvable when nothing will ever approve it.
596
+ const answersApprovals = commandAnswersApprovals(this.command);
597
+ const noDrainTools = gateShell ? [SHELL_TOOL_NAME] : [];
598
+ // The LIVE gated set — what THIS rung gates — for the §4.5 tool descriptions below.
599
+ const gatedTools = answersApprovals
600
+ ? resolveGatedToolNames({ rung, gateShell, boundToolNames })
601
+ : noDrainTools;
602
+ // Rung-independent ONLY where something answers the interrupt.
603
+ const interruptTools = answersApprovals
604
+ ? resolveInterruptToolNames({ gateShell, boundToolNames })
605
+ : noDrainTools;
606
+ // Keyed off the interrupt SET, not `gateShell`: at a deterministic rung there is a gate to
607
+ // install even when the shell tool is disabled, and deepagents installs no HITL middleware at
608
+ // all when `interruptOn` is undefined.
609
+ const interruptOn = interruptTools.length > 0
610
+ ? Object.fromEntries(interruptTools.map((name) => [name, { allowedDecisions: ['approve', 'reject'] }]))
317
611
  : undefined;
318
- if (interruptOn) {
319
- this.statusUpdate(StatusLevel.INFO, 'Shell tool (run_shell_command) enabled with per-command approval (interruptOn).');
320
- }
321
- else if (shellEnabled) {
322
- this.statusUpdate(StatusLevel.WARNING, 'Shell tool (run_shell_command) enabled in YOLO mode: commands run WITHOUT confirmation.');
612
+ if (shellGateNotice) {
613
+ this.statusUpdate(shellGateNotice.level, shellGateNotice.message);
323
614
  }
615
+ // EXT-58 (spec §4.5) — the same tool-registration hook the lean backend calls, on the same LIVE
616
+ // gated set, so neither backend's descriptions can disagree with its own rung. That set is
617
+ // narrower than the `interruptOn` set above, which covers every rung so the rung can still move
618
+ // mid-session; describing from the wider one would promise approvals this rung does not ask
619
+ // for. deepagents registers its OWN filesystem tools (this backend
620
+ // resolves with `filesystem: 'none'`), so they never appear in `passThroughTools` and their
621
+ // descriptions are deepagents' rather than ours, which is why they cannot be suffixed here even
622
+ // though the gated set above does gate them. They are declared as additional registered names so
623
+ // the rater's granted-alternative list (§4.4) reflects the tools a deep session actually has.
624
+ // `execute` is deliberately excluded from THAT list: it is deepagents' shell, not a filesystem
625
+ // tool, and must never be offered as a granted alternative to a shell command.
626
+ this.registerApprovalsAwareTools(passThroughTools, {
627
+ rung,
628
+ gatedTools,
629
+ additionalToolNames: FILESYSTEM_TOOL_NAMES.filter((name) => name !== 'execute'),
630
+ });
324
631
  return {
325
632
  model: this.config.llm,
326
633
  tools: passThroughTools,
@@ -330,143 +637,76 @@ export class GthDeepAgent extends GthAbstractAgent {
330
637
  interruptOn,
331
638
  };
332
639
  }
333
- /**
334
- * Resolve the {@link GthDevToolsConfig} that applies to the active command, mirroring the
335
- * per-command selection in `builtInToolsConfig.getDefaultTools` (which is what actually emits
336
- * the dev tools): `exec` → `commands.exec.devTools`, `ask --write` → `commands.ask.devTools`,
337
- * otherwise (`code`) → `commands.code.devTools`. Returns `undefined` for any other command,
338
- * matching the toolkit being inert there. Kept private and side-effect-free so the interrupt
339
- * wiring above and the tool emission stay in lockstep.
340
- */
341
- getEffectiveDevToolsConfig() {
342
- const config = this.config;
343
- if (!config)
344
- return undefined;
345
- const command = this.command;
346
- const askWrite = command === 'ask' && config.askWriteMode === true;
347
- if (command === 'exec')
348
- return config.commands?.exec?.devTools;
349
- if (askWrite)
350
- return config.commands?.ask?.devTools;
351
- if (command === 'code')
352
- return config.commands?.code?.devTools;
353
- return undefined;
354
- }
355
640
  }
356
641
  /**
357
- * EXT-13 (part b): append a real-cwd / path-model note to the composed code-mode system prompt.
642
+ * EXT-22: shared virtualMode path-namespace guidance ONE source of truth used by BOTH the S2
643
+ * early-framing note ({@link appendVirtualCwdNote}, injected into gsloth's composed systemPrompt =
644
+ * block 0) and the S1 last-word correction middleware
645
+ * ({@link createPathNamespaceCorrectionMiddleware}, appended after deepagents' `/`-rooted line).
646
+ *
647
+ * In a virtualMode `code` session (EXT-16, e.g. Windows) the deepagents filesystem tools use a
648
+ * VIRTUAL `/` root (= the working dir) while `run_shell_command` uses REAL native OS paths; the
649
+ * model conflates the two forms. This text draws the distinction and steers toward cwd-relative
650
+ * paths (the one form both tool families read alike).
358
651
  *
359
- * The default code-mode backend runs in REAL-path mode (no virtualMode), so the deepagents fs
360
- * tools and `run_shell_command` share one real-absolute-path namespace rooted at `cwd`. Neither
361
- * deepagents' base prompt nor `.gsloth.code.md` states the actual cwd, so without this the model
362
- * assumes `/` is cwd and hands `/`-rooted paths to the real-fs shell. The cwd is injected
363
- * dynamically (never baked into the .md). Returns the note alone when there is no base prompt.
652
+ * It deliberately does NOT equate the virtual root with a specific real path (no "`/` = D:\\work"):
653
+ * virtualMode withholds the real cwd on purpose (see the systemPrompt gate in {@link
654
+ * GthDeepAgent.init}), so the guidance is about the DISTINCTION between the two namespaces and the
655
+ * safety of relative paths, not a mapping between them.
364
656
  */
365
- export function appendCwdNote(systemPrompt, cwd) {
366
- const cwdNote = `Working directory: ${cwd}\n` +
367
- 'Paths are real absolute filesystem paths (there is no virtual root). The working directory ' +
368
- 'above is where this session runs; relative paths resolve against it, and both the filesystem ' +
369
- 'tools (ls/glob/read_file/write_file/edit_file/grep) and run_shell_command operate on these ' +
370
- 'same real paths. Check the current directory before filesystem operations and prefer absolute ' +
371
- 'paths (or paths relative to the working directory); do not assume the current directory is "/".';
372
- return systemPrompt ? `${systemPrompt}\n\n${cwdNote}` : cwdNote;
373
- }
657
+ export const PATH_NAMESPACE_GUIDANCE = 'The filesystem tools (ls, read_file, write_file, edit_file, glob, grep) use a VIRTUAL root in ' +
658
+ 'this session: a leading `/` means your working directory, and their paths are written ' +
659
+ '`/`-rooted relative to it (this is what "all file paths must start with a /" refers to). That ' +
660
+ '`/` is NOT the real operating-system filesystem root. run_shell_command is different: it runs ' +
661
+ 'in the real operating system and uses real native paths (on Windows, e.g. ' +
662
+ '`C:\\Users\\...\\project`, with backslashes), never the virtual `/` root. A `/`-rooted path ' +
663
+ 'from the filesystem tools is NOT a valid shell path and must never be passed to ' +
664
+ 'run_shell_command. The one form that means the same thing to both tool families is a path ' +
665
+ 'RELATIVE to the working directory (e.g. `src/index.ts`); prefer relative paths for both. When ' +
666
+ 'you must be absolute, use `/`-rooted form ONLY for the filesystem tools and real native form ' +
667
+ 'ONLY for run_shell_command.';
374
668
  /**
375
- * Scalar model-param fields worth surfacing in the `/debug` panel. Deliberately an
376
- * allowlist (NOT a whole-object dump) so no credential field (`apiKey`, `accessToken`, …)
377
- * can ever leak into the rendered debug view.
669
+ * EXT-22 (S2): virtualMode variant of {@link appendCwdNote}. On the `code` path when the fs
670
+ * backend runs in virtualMode (EXT-16), inject the shared path-namespace guidance EARLY in
671
+ * gsloth's composed systemPrompt (block 0) so the model is framed before deepagents' own prompt.
378
672
  *
379
- * `streaming` is intentionally NOT here: it is the model instance's static flag, which is
380
- * usually `false` even when the turn streams the GthAgentRunner decides streaming by calling
381
- * `.stream()` vs `.invoke()`, not by this property — so surfacing it just misleads.
673
+ * This is early framing only: deepagents' hardcoded `/`-rooted line lands in a LATER block and can
674
+ * partially override block 0, so the authoritative last word is delivered by the S1 middleware
675
+ * ({@link createPathNamespaceCorrectionMiddleware}); see handoff/spike-systemmessage-ordering.md.
676
+ * Returns the note alone when there is no base prompt.
382
677
  */
383
- const DEBUG_MODEL_PARAM_KEYS = [
384
- 'model',
385
- 'modelName',
386
- 'modelId',
387
- 'deploymentName',
388
- 'temperature',
389
- 'topP',
390
- 'topK',
391
- 'maxTokens',
392
- 'maxOutputTokens',
393
- 'maxReasoningTokens',
394
- 'reasoningEffort',
395
- 'thinkingBudget',
396
- 'stop',
397
- 'provider',
398
- ];
399
- /** Pull the key-free scalar model params from the (provider-specific) model instance. */
400
- function extractModelParams(model) {
401
- if (!model || typeof model !== 'object')
402
- return undefined;
403
- const src = model;
404
- const out = {};
405
- for (const key of DEBUG_MODEL_PARAM_KEYS) {
406
- const value = src[key];
407
- if (value === undefined || value === null)
408
- continue;
409
- // Only scalars / scalar arrays — never nested objects that could carry credentials.
410
- if (typeof value === 'object' && !Array.isArray(value))
411
- continue;
412
- out[key] = value;
413
- }
414
- // `model` / `modelName` / `modelId` are langchain aliases for the same value; collapse the
415
- // duplicates so the panel shows the model id once instead of two identical lines.
416
- if (typeof out.model !== 'string' && typeof out.modelName === 'string') {
417
- out.model = out.modelName;
418
- }
419
- if (out.modelName === out.model)
420
- delete out.modelName;
421
- if (out.modelId === out.model)
422
- delete out.modelId;
423
- return Object.keys(out).length > 0 ? out : undefined;
424
- }
425
- /** Best-effort tool definition (name + description + schema) for the debug view. */
426
- function extractToolDefs(tools) {
427
- if (!Array.isArray(tools) || tools.length === 0)
428
- return undefined;
429
- const defs = [];
430
- for (const tool of tools) {
431
- if (!tool || typeof tool !== 'object')
432
- continue;
433
- const t = tool;
434
- const name = typeof t.name === 'string' ? t.name : undefined;
435
- if (!name)
436
- continue;
437
- const description = typeof t.description === 'string' ? t.description : undefined;
438
- // LangChain StructuredTools expose a Zod/JSON `schema`; some carry it on `lc_kwargs`.
439
- const schema = t.schema ?? undefined;
440
- defs.push({ name, description, schema });
441
- }
442
- return defs.length > 0 ? defs : undefined;
678
+ export function appendVirtualCwdNote(systemPrompt) {
679
+ const note = `Filesystem vs shell path namespaces: ${PATH_NAMESPACE_GUIDANCE}`;
680
+ return systemPrompt ? `${systemPrompt}\n\n${note}` : note;
443
681
  }
444
682
  /**
445
- * Assemble the non-message request parts ({@link DebugRequestExtras}) for the `/debug`
446
- * panel from a `wrapModelCall` request, defensively and key-free. Never throws (the caller
447
- * already guards, but a debug sink must never break a run) and never dumps the raw model.
683
+ * EXT-22 (S1): the load-bearing path-namespace correction. A gsloth `wrapModelCall` middleware
684
+ * runs INNERMOST (inside deepagents' filesystem middleware), so appending a trailing block to
685
+ * `request.systemMessage` lands AFTER deepagents' hardcoded "All file paths must start with a /."
686
+ * line — giving gsloth the last word on path semantics (empirically verified; see
687
+ * handoff/spike-systemmessage-ordering.md). It APPENDS via `request.systemMessage.concat(...)`
688
+ * (mirroring how deepagents appends its own fs prompt), never string-splices, and returns a NEW
689
+ * request so it never mutates persisted state (no compounding across turns).
690
+ *
691
+ * `appendCorrection` gates it to `code` + virtualMode: only there do the fs virtual `/` root and
692
+ * the shell's real-OS paths diverge. On POSIX real-path mode deepagents' "start with /" is
693
+ * literally true, so the middleware is a transparent pass-through (like the debug-capture
694
+ * middleware when no sink is attached).
448
695
  */
449
- export function extractDebugRequestExtras(request) {
450
- if (!request || typeof request !== 'object')
451
- return undefined;
452
- const req = request;
453
- const systemMessage = req.systemMessage;
454
- const systemPrompt = typeof req.systemPrompt === 'string' && req.systemPrompt
455
- ? req.systemPrompt
456
- : typeof systemMessage?.content === 'string'
457
- ? systemMessage.content
458
- : undefined;
459
- const extras = {
460
- systemPrompt,
461
- tools: extractToolDefs(req.tools),
462
- modelParams: extractModelParams(req.model),
463
- toolChoice: req.toolChoice,
464
- };
465
- // Return undefined when nothing useful was captured so the renderer can show a clear empty state.
466
- const hasAny = extras.systemPrompt !== undefined ||
467
- extras.tools !== undefined ||
468
- extras.modelParams !== undefined ||
469
- extras.toolChoice !== undefined;
470
- return hasAny ? extras : undefined;
696
+ export function createPathNamespaceCorrectionMiddleware(appendCorrection) {
697
+ return createMiddleware({
698
+ name: 'GthDeepPathNamespaceCorrection',
699
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
700
+ wrapModelCall: async (request, handler) => {
701
+ if (!appendCorrection || !request?.systemMessage)
702
+ return handler(request);
703
+ const correction = 'IMPORTANT — path namespaces (authoritative; this overrides any earlier instruction that ' +
704
+ `implies every path is a simple \`/\`-rooted filesystem path): ${PATH_NAMESPACE_GUIDANCE}`;
705
+ return handler({ ...request, systemMessage: request.systemMessage.concat(correction) });
706
+ },
707
+ });
471
708
  }
709
+ // The `/debug` request-extras extraction (extractDebugRequestExtras + its model-param / tool-def
710
+ // allowlist helpers) now lives in @gaunt-sloth/core (`core/debugCapture.ts`) so the lean backend
711
+ // shares it. Imported at the top of this module and re-exported for back-compat.
472
712
  //# sourceMappingURL=GthDeepAgent.js.map