@stigmer/runner 3.32.0 → 3.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/execute-deep-agent/cas-capture-backend.d.ts +13 -6
  3. package/dist/activities/execute-deep-agent/cas-capture-backend.js +21 -5
  4. package/dist/activities/execute-deep-agent/cas-capture-backend.js.map +1 -1
  5. package/dist/activities/execute-deep-agent/deepagents-profiles.d.ts +21 -7
  6. package/dist/activities/execute-deep-agent/deepagents-profiles.js +24 -9
  7. package/dist/activities/execute-deep-agent/deepagents-profiles.js.map +1 -1
  8. package/dist/activities/execute-deep-agent/platform-route.d.ts +75 -0
  9. package/dist/activities/execute-deep-agent/platform-route.js +107 -0
  10. package/dist/activities/execute-deep-agent/platform-route.js.map +1 -0
  11. package/dist/activities/execute-deep-agent/subagent-transformer.d.ts +2 -0
  12. package/dist/activities/execute-deep-agent/subagent-transformer.js +29 -5
  13. package/dist/activities/execute-deep-agent/subagent-transformer.js.map +1 -1
  14. package/dist/activities/execute-deep-agent/subagent-wiring.d.ts +5 -7
  15. package/dist/activities/execute-deep-agent/subagent-wiring.js +5 -7
  16. package/dist/activities/execute-deep-agent/subagent-wiring.js.map +1 -1
  17. package/dist/activities/execute-deep-agent/translator.d.ts +6 -2
  18. package/dist/activities/execute-deep-agent/translator.js +6 -2
  19. package/dist/activities/execute-deep-agent/translator.js.map +1 -1
  20. package/dist/activities/execute-deep-agent/turn-setup.js +14 -3
  21. package/dist/activities/execute-deep-agent/turn-setup.js.map +1 -1
  22. package/dist/middleware/index.d.ts +7 -3
  23. package/dist/middleware/index.js +7 -3
  24. package/dist/middleware/index.js.map +1 -1
  25. package/dist/shared/workspace/stigmer-link.d.ts +15 -3
  26. package/dist/shared/workspace/stigmer-link.js +22 -3
  27. package/dist/shared/workspace/stigmer-link.js.map +1 -1
  28. package/dist/shared/workspace/types.d.ts +4 -3
  29. package/package.json +15 -9
  30. package/src/activities/execute-deep-agent/__tests__/builtin-tool-surface.test.ts +107 -0
  31. package/src/activities/execute-deep-agent/__tests__/cas-capture-backend.test.ts +14 -0
  32. package/src/activities/execute-deep-agent/__tests__/hermetic/fixtures/paused-session.deepagents-1.10.2.json +592 -0
  33. package/src/activities/execute-deep-agent/__tests__/hermetic/goldens/sub-agent-delegation.status.json +2 -2
  34. package/src/activities/execute-deep-agent/__tests__/hermetic/goldens/tool-call.status.json +1 -1
  35. package/src/activities/execute-deep-agent/__tests__/hermetic/plain-turn.test.ts +10 -13
  36. package/src/activities/execute-deep-agent/__tests__/hermetic/resume-across-engine-upgrade.test.ts +374 -0
  37. package/src/activities/execute-deep-agent/__tests__/hermetic/sub-agent-delegation.test.ts +7 -0
  38. package/src/activities/execute-deep-agent/__tests__/hermetic/tool-call.test.ts +6 -0
  39. package/src/activities/execute-deep-agent/__tests__/plan-mode-path-normalization.test.ts +16 -8
  40. package/src/activities/execute-deep-agent/__tests__/platform-route.test.ts +148 -0
  41. package/src/activities/execute-deep-agent/__tests__/subagent-transformer.test.ts +24 -0
  42. package/src/activities/execute-deep-agent/__tests__/summarization-verification.test.ts +16 -9
  43. package/src/activities/execute-deep-agent/cas-capture-backend.ts +25 -6
  44. package/src/activities/execute-deep-agent/deepagents-profiles.ts +25 -9
  45. package/src/activities/execute-deep-agent/platform-route.ts +134 -0
  46. package/src/activities/execute-deep-agent/subagent-transformer.ts +40 -6
  47. package/src/activities/execute-deep-agent/subagent-wiring.ts +5 -7
  48. package/src/activities/execute-deep-agent/translator.ts +6 -2
  49. package/src/activities/execute-deep-agent/turn-setup.ts +17 -3
  50. package/src/middleware/index.ts +7 -3
  51. package/src/shared/workspace/__tests__/stigmer-link.test.ts +41 -18
  52. package/src/shared/workspace/stigmer-link.ts +22 -3
  53. package/src/shared/workspace/types.ts +4 -3
@@ -0,0 +1,148 @@
1
+ // Covers the native file tools' read of platform content (skills, attached
2
+ // inputs, the approved plan) at `.stigmer/…`, on the backends production
3
+ // builds (`createCasCaptureBackend` + `mountPlatformRoute`, parent and
4
+ // sub-agents alike). The stock virtual-rooted backend refuses to follow the
5
+ // workspace link out of the root; the route mounts the platform dir instead,
6
+ // read-only, on the turns the link exists. What is pinned:
7
+ // - the stock backend's refusal (why the route exists);
8
+ // - reads, listings and search through the route, for the shell-capable and
9
+ // the plan-mode backend, with `execute` kept exactly where it was;
10
+ // - every mutation through the route refused, and a root delete refused
11
+ // before it touches the workspace or the platform dir;
12
+ // - no route on a turn without the link.
13
+
14
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
15
+ import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+ import { FilesystemBackend, isSandboxBackend } from "deepagents";
19
+ import type { AnyBackendProtocol, BackendProtocolV2 } from "deepagents";
20
+
21
+ import { createCasCaptureBackend } from "../cas-capture-backend.js";
22
+ import { CasCaptureObserver } from "../cas-capture-observer.js";
23
+ import { PLATFORM_ROUTE_PREFIX, PlatformRoutedBackend, mountPlatformRoute } from "../platform-route.js";
24
+ import { LocalWorkspaceBackend } from "../../../shared/workspace/local-backend.js";
25
+ import { ensureStigmerSymlink } from "../../../shared/workspace/stigmer-link.js";
26
+
27
+ const SKILL = ".stigmer/skills/my-skill/SKILL.md";
28
+ const PLAN = ".stigmer/inputs/notes_ab12cd34.plan.md";
29
+
30
+ describe("platform route", () => {
31
+ let workspaceDir: string;
32
+ let platformDir: string;
33
+
34
+ beforeEach(async () => {
35
+ workspaceDir = await mkdtemp(join(tmpdir(), "platform-route-ws-"));
36
+ platformDir = await mkdtemp(join(tmpdir(), "platform-route-platform-"));
37
+ // The same physical writes the skill writer and the attachment resolver
38
+ // perform, through the platform-routing workspace backend.
39
+ const writer = new LocalWorkspaceBackend(workspaceDir, platformDir);
40
+ await writer.writeFile(SKILL, "# My Skill\n\nDo skillful things.\n");
41
+ await writer.writeFile(PLAN, "# The Approved Plan\n\nStep 1: do the thing.\n");
42
+ await writeFile(join(workspaceDir, "main.go"), "package main\n");
43
+ });
44
+
45
+ afterEach(async () => {
46
+ await rm(workspaceDir, { recursive: true, force: true });
47
+ await rm(platformDir, { recursive: true, force: true });
48
+ });
49
+
50
+ const observer = () => new CasCaptureObserver({ rootDir: workspaceDir, isIgnored: async () => true });
51
+
52
+ /** The parent's backend as `turn-setup.ts` builds it: shell-capable, or plan mode's filesystem-only one. */
53
+ async function productionBackend(mode: "shell" | "plan"): Promise<AnyBackendProtocol> {
54
+ const workspace = await createCasCaptureBackend({
55
+ rootDir: workspaceDir,
56
+ observer: observer(),
57
+ ...(mode === "shell" ? { shellEnv: {} } : {}),
58
+ });
59
+ return mountPlatformRoute(workspace, { workspaceDir, platformDir });
60
+ }
61
+
62
+ function v2(backend: AnyBackendProtocol): BackendProtocolV2 {
63
+ return backend as BackendProtocolV2;
64
+ }
65
+
66
+ it("the stock virtual-rooted backend refuses a path through the link (why the route exists)", async () => {
67
+ await ensureStigmerSymlink(workspaceDir, platformDir);
68
+ const stock = new FilesystemBackend({ rootDir: workspaceDir, virtualMode: true });
69
+ const result = await stock.read(`/${SKILL}`);
70
+ expect(result.content).toBeUndefined();
71
+ expect(result.error).toMatch(/outside root/i);
72
+ });
73
+
74
+ describe.each(["shell", "plan"] as const)("the %s backend with the link", (mode) => {
75
+ beforeEach(async () => {
76
+ await ensureStigmerSymlink(workspaceDir, platformDir);
77
+ });
78
+
79
+ it("reads a skill and the approved plan at the paths the prompts name", async () => {
80
+ const backend = v2(await productionBackend(mode));
81
+ expect(backend).toBeInstanceOf(PlatformRoutedBackend);
82
+ expect((await backend.read(`/${SKILL}`)).content).toContain("My Skill");
83
+ expect((await backend.read(`/${PLAN}`)).content).toContain("The Approved Plan");
84
+ expect((await backend.read("/main.go")).content, "the workspace is still the default").toContain("package main");
85
+ });
86
+
87
+ it("lists the platform once at the root, and its contents inside the route", async () => {
88
+ const backend = v2(await productionBackend(mode));
89
+ const root = (await backend.ls("/")).files ?? [];
90
+ expect(root.filter((f) => f.path === PLATFORM_ROUTE_PREFIX), "one entry, not two").toHaveLength(1);
91
+ expect(root.map((f) => f.path)).toContain("/main.go");
92
+ const inside = (await backend.ls(PLATFORM_ROUTE_PREFIX)).files ?? [];
93
+ expect(inside.map((f) => f.path).sort()).toEqual(["/.stigmer/inputs/", "/.stigmer/skills/"]);
94
+ });
95
+
96
+ it("finds a skill by glob from the root", async () => {
97
+ const backend = v2(await productionBackend(mode));
98
+ const found = (await backend.glob("**/SKILL.md", "/")).files ?? [];
99
+ expect(found.map((f) => f.path)).toContain(`/${SKILL}`);
100
+ });
101
+
102
+ it("keeps `execute` exactly where the workspace backend had it", async () => {
103
+ const backend = await productionBackend(mode);
104
+ expect(isSandboxBackend(backend)).toBe(mode === "shell");
105
+ });
106
+
107
+ it("refuses every mutation of platform content", async () => {
108
+ const backend = v2(await productionBackend(mode));
109
+ expect((await backend.write(`${PLATFORM_ROUTE_PREFIX}new.md`, "x")).error).toMatch(/read-only/);
110
+ expect((await backend.edit(`/${SKILL}`, "My Skill", "Your Skill")).error).toMatch(/read-only/);
111
+ expect((await backend.delete!(`${PLATFORM_ROUTE_PREFIX}skills`)).error).toMatch(/read-only/);
112
+ expect(await readFile(join(platformDir, "skills/my-skill/SKILL.md"), "utf8")).toContain("My Skill");
113
+ });
114
+
115
+ it("refuses a root delete before it touches the workspace or the platform dir", async () => {
116
+ const backend = v2(await productionBackend(mode));
117
+ expect((await backend.delete!("/")).error).toBeDefined();
118
+ expect(await readFile(join(workspaceDir, "main.go"), "utf8")).toBe("package main\n");
119
+ expect(await readFile(join(platformDir, "skills/my-skill/SKILL.md"), "utf8")).toContain("My Skill");
120
+ });
121
+ });
122
+
123
+ it("mounts nothing on a turn without the link", async () => {
124
+ const workspace = await createCasCaptureBackend({ rootDir: workspaceDir, observer: observer(), shellEnv: {} });
125
+ expect(await mountPlatformRoute(workspace, { workspaceDir, platformDir })).toBe(workspace);
126
+ expect(await mountPlatformRoute(workspace, { workspaceDir, platformDir: undefined })).toBe(workspace);
127
+ });
128
+
129
+ it("mounts nothing when the link points somewhere else", async () => {
130
+ const other = await mkdtemp(join(tmpdir(), "platform-route-other-"));
131
+ try {
132
+ await ensureStigmerSymlink(workspaceDir, other);
133
+ const workspace = await createCasCaptureBackend({ rootDir: workspaceDir, observer: observer() });
134
+ expect(await mountPlatformRoute(workspace, { workspaceDir, platformDir })).toBe(workspace);
135
+ } finally {
136
+ await rm(other, { recursive: true, force: true });
137
+ }
138
+ });
139
+
140
+ it("a workspace entry of its own is untouched by the route", async () => {
141
+ await mkdir(join(workspaceDir, "src"));
142
+ await writeFile(join(workspaceDir, "src/a.ts"), "export {};\n");
143
+ await ensureStigmerSymlink(workspaceDir, platformDir);
144
+ const backend = v2(await productionBackend("shell"));
145
+ expect((await backend.write("/src/b.ts", "export const b = 1;\n")).error).toBeUndefined();
146
+ expect(await readFile(join(workspaceDir, "src/b.ts"), "utf8")).toBe("export const b = 1;\n");
147
+ });
148
+ });
@@ -518,6 +518,30 @@ describe("transformAndCompileSubagents", () => {
518
518
  warnSpy.mockRestore();
519
519
  });
520
520
 
521
+ it("a sub-agent name declared twice resolves to the later declaration", async () => {
522
+ // deepagents refuses duplicate sub-agent names and would fail the turn;
523
+ // the runner keeps the behaviour agents were written against.
524
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
525
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
526
+
527
+ const result = await transformAndCompileSubagents({
528
+ ...baseOptions,
529
+ subAgents: [
530
+ mockSubAgentProto({ name: "reviewer", description: "The first reviewer." }),
531
+ mockSubAgentProto({ name: "reviewer", description: "The later reviewer." }),
532
+ ],
533
+ } as Parameters<typeof transformAndCompileSubagents>[0]);
534
+
535
+ expect(result).not.toBeNull();
536
+ const reviewers = result!.filter((r) => r.name === "reviewer");
537
+ expect(reviewers).toHaveLength(1);
538
+ expect(reviewers[0].description).toBe("The later reviewer.");
539
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("declared more than once"));
540
+
541
+ logSpy.mockRestore();
542
+ warnSpy.mockRestore();
543
+ });
544
+
521
545
  it("gracefully handles transform failures for individual subagents", async () => {
522
546
  const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
523
547
  const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
@@ -230,21 +230,26 @@ describe("middleware stack ordering in createDeepAgent", () => {
230
230
 
231
231
  it("places SummarizationMiddleware before custom middleware", () => {
232
232
  /**
233
- * Verified by reading createDeepAgent source (index.js lines 8165-8173):
233
+ * Verified by reading createDeepAgent's source (deepagents 1.14.1,
234
+ * `src/agent.ts`, the `coreMiddleware` / `tailMiddleware` pair merged by
235
+ * `mergeMiddlewareStack`):
234
236
  *
235
- * const middleware = [
236
- * todoMiddleware,
237
+ * [
237
238
  * ...skillsMiddleware,
238
239
  * fsMiddleware,
239
240
  * subagentMiddleware,
240
- * summarizationMiddleware, // <-- position 4 (0-indexed)
241
+ * summarizationMiddleware,
241
242
  * patchToolCallsMiddleware,
242
243
  * ...asyncSubAgents,
243
244
  * ...customMiddleware, // <-- Stigmer's stack goes here
245
+ * ...profile extraMiddleware,
244
246
  * ...cacheMiddleware,
245
247
  * ...memory,
246
248
  * ...interruptOn,
247
- * ];
249
+ * ]
250
+ *
251
+ * (a custom middleware named like a default replaces it in place; no
252
+ * Stigmer middleware shares a default's name).
248
253
  *
249
254
  * This means summarization's wrapModelCall runs before Stigmer's
250
255
  * cost-cap wrapModelCall, so:
@@ -279,13 +284,14 @@ describe("middleware stack ordering in createDeepAgent", () => {
279
284
 
280
285
  it("creates SummarizationMiddleware with the same backend passed to createDeepAgent", () => {
281
286
  /**
282
- * Verified from source (index.js line 8162):
287
+ * Verified from source (deepagents 1.14.1 `src/agent.ts`):
283
288
  * createSummarizationMiddleware({ backend })
284
289
  *
285
290
  * The `backend` variable is the same one received in createDeepAgent params.
286
291
  * This means summarization uses the same StateBackend instance, so
287
292
  * offloaded conversation history is stored in LangGraph state which
288
- * the HttpCheckpointSaver persists to MongoDB via the proxy.
293
+ * the checkpoint saver persists (in cloud, the HttpCheckpointSaver
294
+ * through the proxy's checkpoint store).
289
295
  */
290
296
  const backend = new StateBackend();
291
297
 
@@ -300,13 +306,14 @@ describe("middleware stack ordering in createDeepAgent", () => {
300
306
 
301
307
  it("resolves summarization model from the agent model (no explicit model option)", () => {
302
308
  /**
303
- * Verified from source (index.js lines 4067, 8114):
309
+ * Verified from source (deepagents 1.14.1 `src/agent.ts` and
310
+ * `src/middleware/summarization.ts`):
304
311
  *
305
312
  * createSummarizationMiddleware({ backend })
306
313
  * // No `model` option passed
307
314
  *
308
315
  * // Inside wrapModelCall:
309
- * const resolvedModel = request.model ?? await getChatModel();
316
+ * const resolvedModel = requestModel ?? (await getChatModel());
310
317
  *
311
318
  * Since no model option is provided, the middleware uses request.model
312
319
  * which is the same ChatAnthropic instance from turn-setup.ts with the
@@ -19,11 +19,16 @@
19
19
  * deepagents rejects filesystem `permissions` combined with an execution-capable
20
20
  * backend — plan mode is read-only by construction, so no shell tool there.
21
21
  *
22
- * deepagents mutates only via `write` (create/overwrite) and `edit` (modify);
23
- * there is no backend delete/rename, so this observes CREATE and MODIFY. A
24
- * delete-category tool consequently cannot be observed here — the approval gate
25
- * records its before-bytes at authorization time instead (`captureDeleteBefore`,
26
- * issue #303). A delete via shell (`rm`) stays on the approval gate as always.
22
+ * The observed mutations are `write` (create, or since deepagents 1.12 a
23
+ * whole-file overwrite — `recordBefore` reads the existing bytes either way)
24
+ * and `edit` (modify), so this observes CREATE and MODIFY. deepagents 1.13
25
+ * added a recursive backend `delete` and a `delete` tool over it; the native
26
+ * harness does not expose that tool (`deepagents-profiles.ts`), and these
27
+ * backends refuse a backend delete outright, so a graph whose model no profile
28
+ * resolves cannot delete a subtree behind the observer's back. Deleting stays
29
+ * on the shell (`rm`), which the approval gate holds as always, and a
30
+ * delete-category tool from elsewhere has its before-bytes recorded by the gate
31
+ * at authorization time (`captureDeleteBefore`, issue #303).
27
32
  *
28
33
  * VIRTUAL ROOT — THE ONE PATH DIALECT (issue #754)
29
34
  * ------------------------------------------------
@@ -47,7 +52,7 @@
47
52
  */
48
53
 
49
54
  import { FilesystemBackend, LocalShellBackend } from "deepagents";
50
- import type { LocalShellBackendOptions } from "deepagents";
55
+ import type { DeleteResult, LocalShellBackendOptions } from "deepagents";
51
56
  import type { CasCaptureObserver } from "./cas-capture-observer.js";
52
57
 
53
58
  export type { CasBeforeMap } from "./cas-capture-observer.js";
@@ -58,6 +63,12 @@ type FilesystemBackendOptions = NonNullable<
58
63
 
59
64
  type CasCaptureDeps = { readonly observer: CasCaptureObserver };
60
65
 
66
+ function refusedDelete(filePath: string): DeleteResult {
67
+ return {
68
+ error: `Error: deleting '${filePath}' through the file tools is not available; use the shell (rm)`,
69
+ };
70
+ }
71
+
61
72
  export class CasCaptureFilesystemBackend extends FilesystemBackend {
62
73
  private readonly observer: CasCaptureObserver;
63
74
 
@@ -80,6 +91,10 @@ export class CasCaptureFilesystemBackend extends FilesystemBackend {
80
91
  await this.observer.recordBefore(filePath);
81
92
  return super.edit(filePath, oldString, newString, replaceAll);
82
93
  }
94
+
95
+ override async delete(filePath: string): Promise<DeleteResult> {
96
+ return refusedDelete(filePath);
97
+ }
83
98
  }
84
99
 
85
100
  export class CasCaptureShellBackend extends LocalShellBackend {
@@ -104,6 +119,10 @@ export class CasCaptureShellBackend extends LocalShellBackend {
104
119
  await this.observer.recordBefore(filePath);
105
120
  return super.edit(filePath, oldString, newString, replaceAll);
106
121
  }
122
+
123
+ override async delete(filePath: string): Promise<DeleteResult> {
124
+ return refusedDelete(filePath);
125
+ }
107
126
  }
108
127
 
109
128
  export type CasCaptureBackend = CasCaptureFilesystemBackend | CasCaptureShellBackend;
@@ -1,19 +1,34 @@
1
1
  /**
2
2
  * Stigmer-specific deepagents harness profile registrations.
3
3
  *
4
- * deepagents 1.10.x auto-injects a built-in `general-purpose` sub-agent into
5
- * every `createDeepAgent()` call unless the caller supplies one with that name.
6
- * The injected sub-agent carries only deepagents' built-in middleware — not our
7
- * approval gate — so it is an ungated write/edit path today and would be an
8
- * ungated `execute` path with a shell backend.
4
+ * Two defaults of deepagents' `createDeepAgent()` are turned off for every
5
+ * provider the runner builds a model for:
9
6
  *
10
- * Registrations merge additively with deepagents' built-in prompt profiles, so
11
- * disabling auto-injection here does not alter model prompt overlays.
7
+ * - The auto-injected `general-purpose` sub-agent. It carries only
8
+ * deepagents' built-in middleware — not our approval gate — so it would be
9
+ * an ungated write/edit path, and an ungated `execute` path with a shell
10
+ * backend. The runner compiles its own gated `general-purpose` instead
11
+ * (`subagent-transformer.ts`).
12
+ * - The built-in `delete` tool (deepagents 1.13+), a recursive delete through
13
+ * the backend. The CAS capture backends observe `write` and `edit` only, so
14
+ * a directory delete would lose the before-bytes file review needs for the
15
+ * gitignored files beneath it. Deleting stays where it has always been on
16
+ * this harness: `rm` through the approval-gated shell. Excluding the tool
17
+ * here means the filesystem middleware never builds it, on the parent and
18
+ * on every sub-agent (1.14 resolves the profile per sub-agent model); the
19
+ * capture backends refuse a backend delete as well, for a graph whose model
20
+ * no profile resolves (`cas-capture-backend.ts`).
21
+ *
22
+ * Registrations merge additively with deepagents' built-in model profiles, so
23
+ * neither exclusion alters the model prompt overlays.
12
24
  */
13
25
 
14
26
  import { registerHarnessProfile } from "deepagents";
15
27
 
16
- const PROVIDERS_WITH_GP_SUPPRESSION = ["anthropic", "openai"] as const;
28
+ const PROVIDERS_WITH_STIGMER_PROFILE = ["anthropic", "openai"] as const;
29
+
30
+ /** Built-in tools the native harness never exposes; see the header. */
31
+ export const EXCLUDED_BUILTIN_TOOLS: readonly string[] = ["delete"];
17
32
 
18
33
  let registered = false;
19
34
 
@@ -22,9 +37,10 @@ export function registerStigmerDeepagentsProfiles(): void {
22
37
  if (registered) return;
23
38
  registered = true;
24
39
 
25
- for (const provider of PROVIDERS_WITH_GP_SUPPRESSION) {
40
+ for (const provider of PROVIDERS_WITH_STIGMER_PROFILE) {
26
41
  registerHarnessProfile(provider, {
27
42
  generalPurposeSubagent: { enabled: false },
43
+ excludedTools: [...EXCLUDED_BUILTIN_TOOLS],
28
44
  });
29
45
  }
30
46
  }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * The platform's read-only `.stigmer/` mount inside the native agent's
3
+ * virtual filesystem.
4
+ *
5
+ * WHY A ROUTE, NOT THE SYMLINK
6
+ * ----------------------------
7
+ * Platform content — resolved skills, attached inputs, the approved plan —
8
+ * lives in the session's platform dir, outside the workspace, and reaches the
9
+ * agent at `.stigmer/…` (`shared/workspace/stigmer-link.ts`). The Cursor SDK
10
+ * and the shell follow the workspace's `.stigmer` symlink to it. The native
11
+ * file tools cannot: deepagents (since 1.14) resolves every path of a
12
+ * `virtualMode` backend to its REAL location and refuses one that lands
13
+ * outside the root ("Path '…' resolves outside root directory",
14
+ * `FilesystemBackend.assertRealPathWithinRoot`), and every backend the native
15
+ * harness builds is virtual-rooted (`cas-capture-backend.ts`, issue #754).
16
+ * Turning that confinement off would reopen the escape it closes, so the
17
+ * platform dir is mounted instead through deepagents' own router,
18
+ * `CompositeBackend`, at exactly the prefix the prompts name.
19
+ *
20
+ * WHY READ-ONLY
21
+ * -------------
22
+ * Nothing legitimately writes platform content from a turn: inputs are
23
+ * presented as read-only and plan mode denies every write. A writable mount
24
+ * would also be dangerous and unreviewed: `CompositeBackend.delete` fans a
25
+ * delete out to every route beneath its target, so a delete of `/` or of
26
+ * `/.stigmer` would empty the platform dir, and a routed write would reach
27
+ * the platform dir without passing the CAS observer that file review reads.
28
+ * Every mutation through the mount therefore fails, which also makes the
29
+ * composite refuse any fan-out that touches it.
30
+ *
31
+ * WHEN IT IS MOUNTED
32
+ * ------------------
33
+ * On the turns where the workspace link points at the platform dir — the
34
+ * turns whose skill or attachment phase made it — so what the agent can
35
+ * reach is exactly what the link exposes to the shell on the same turn.
36
+ * The link itself stays: shell commands (a skill's own scripts among them)
37
+ * run in the workspace and resolve `.stigmer/…` through it.
38
+ */
39
+
40
+ import { CompositeBackend, FilesystemBackend } from "deepagents";
41
+ import type {
42
+ AnyBackendProtocol,
43
+ DeleteResult,
44
+ EditResult,
45
+ FileUploadResponse,
46
+ LsResult,
47
+ WriteResult,
48
+ } from "deepagents";
49
+ import { STIGMER_LOCAL_STATE_DIR, stigmerSymlinkPointsAt } from "../../shared/workspace/stigmer-link.js";
50
+
51
+ /** The route prefix, with the trailing slash `CompositeBackend` needs to match `/.stigmer/x` but never `/.stigmerx`. */
52
+ export const PLATFORM_ROUTE_PREFIX = `/${STIGMER_LOCAL_STATE_DIR}/`;
53
+
54
+ function readOnly(routedPath: string): string {
55
+ const agentPath = `${PLATFORM_ROUTE_PREFIX.slice(0, -1)}${routedPath}`;
56
+ return (
57
+ `Error: '${agentPath}' is platform content (skills, attached inputs, the approved plan) ` +
58
+ "and is read-only; write your own files in the workspace"
59
+ );
60
+ }
61
+
62
+ /** The platform dir, virtual-rooted like every native backend, refusing every mutation. */
63
+ export class PlatformContentBackend extends FilesystemBackend {
64
+ constructor(platformDir: string) {
65
+ super({ rootDir: platformDir, virtualMode: true });
66
+ }
67
+
68
+ override async write(filePath: string): Promise<WriteResult> {
69
+ return { error: readOnly(filePath) };
70
+ }
71
+
72
+ override async edit(filePath: string): Promise<EditResult> {
73
+ return { error: readOnly(filePath) };
74
+ }
75
+
76
+ override async delete(filePath: string): Promise<DeleteResult> {
77
+ return { error: readOnly(filePath) };
78
+ }
79
+
80
+ override async uploadFiles(files: Array<[string, Uint8Array]>): Promise<FileUploadResponse[]> {
81
+ return files.map(([path]) => ({ path, error: "permission_denied" }));
82
+ }
83
+ }
84
+
85
+ /**
86
+ * The workspace backend with the platform mounted at {@link PLATFORM_ROUTE_PREFIX}.
87
+ *
88
+ * One behaviour differs from the stock router: listing `/`. The composite
89
+ * adds each route to the root listing, and the workspace backend already
90
+ * lists `.stigmer/` by following the link, so the stock listing names the
91
+ * same directory twice; the root listing is de-duplicated by path.
92
+ */
93
+ export class PlatformRoutedBackend extends CompositeBackend {
94
+ override async ls(path: string): Promise<LsResult> {
95
+ const result = await super.ls(path);
96
+ if (path !== "/" || !result.files) return result;
97
+ const seen = new Set<string>();
98
+ return {
99
+ ...result,
100
+ files: result.files.filter((entry) => {
101
+ if (seen.has(entry.path)) return false;
102
+ seen.add(entry.path);
103
+ return true;
104
+ }),
105
+ };
106
+ }
107
+ }
108
+
109
+ export interface PlatformRouteOptions {
110
+ /** The workspace root the backend is rooted at (where the link lives). */
111
+ readonly workspaceDir: string;
112
+ /** The session's platform dir; absent when the session has none. */
113
+ readonly platformDir: string | undefined;
114
+ }
115
+
116
+ /**
117
+ * Mount the platform route over `backend` when this turn exposes platform
118
+ * content; otherwise hand `backend` back unchanged. `execute` and the
119
+ * sandbox identity stay the workspace backend's (`CompositeBackend` delegates
120
+ * both to its default), so a shell-capable graph keeps its `execute` tool and
121
+ * a plan-mode graph stays without one.
122
+ */
123
+ export async function mountPlatformRoute(
124
+ backend: AnyBackendProtocol,
125
+ options: PlatformRouteOptions,
126
+ ): Promise<AnyBackendProtocol> {
127
+ const { workspaceDir, platformDir } = options;
128
+ if (platformDir === undefined || !(await stigmerSymlinkPointsAt(workspaceDir, platformDir))) {
129
+ return backend;
130
+ }
131
+ return new PlatformRoutedBackend(backend, {
132
+ [PLATFORM_ROUTE_PREFIX]: new PlatformContentBackend(platformDir),
133
+ });
134
+ }
@@ -23,7 +23,7 @@
23
23
  */
24
24
 
25
25
  import { createDeepAgent, FilesystemBackend, LocalShellBackend, DEFAULT_GENERAL_PURPOSE_DESCRIPTION, DEFAULT_SUBAGENT_PROMPT } from "deepagents";
26
- import type { CompiledSubAgent, FilesystemPermission } from "deepagents";
26
+ import type { AnyBackendProtocol, CompiledSubAgent, FilesystemPermission } from "deepagents";
27
27
  import type { StructuredTool } from "@langchain/core/tools";
28
28
  import type { RunnableConfig } from "@langchain/core/runnables";
29
29
  import type { BaseChatModel } from "@langchain/core/language_models/chat_models";
@@ -33,6 +33,7 @@ import type { SubAgent, McpServerUsage } from "@stigmer/protos/ai/stigmer/agenti
33
33
  import type { WorkspaceBackend } from "../../shared/workspace/types.js";
34
34
  import type { ApprovalGateConfig } from "../../middleware/approval-gate.js";
35
35
  import { createCasCaptureBackend } from "./cas-capture-backend.js";
36
+ import { mountPlatformRoute } from "./platform-route.js";
36
37
  import type { CasCaptureObserver } from "./cas-capture-observer.js";
37
38
  import type { CostAdvisoryMiddleware, StigmerMiddleware } from "../../middleware/index.js";
38
39
  import { createThinkTool, createWebFetchTool, type GuardPosture } from "../../tools/index.js";
@@ -472,9 +473,23 @@ export function modelHasNativeThinking(modelId: string): boolean {
472
473
  *
473
474
  * Every variant is virtual-rooted (`virtualMode: true`) like the parent's —
474
475
  * see the cas-capture-backend.ts header (issue #754): workspace confinement
475
- * is structural on every graph, sub-agents included.
476
+ * is structural on every graph, sub-agents included. Every variant also gets
477
+ * the parent's read-only `.stigmer/` platform mount (`platform-route.ts`), so
478
+ * a sub-agent reads its skills where its prompt says they are.
476
479
  */
477
480
  async function buildSubagentBackend(opts: {
481
+ readonly workspaceRootDir: string;
482
+ readonly platformDir?: string;
483
+ readonly casObserver?: CasCaptureObserver;
484
+ readonly shellEnv?: Record<string, string>;
485
+ }): Promise<AnyBackendProtocol> {
486
+ return mountPlatformRoute(await buildSubagentWorkspaceBackend(opts), {
487
+ workspaceDir: opts.workspaceRootDir,
488
+ platformDir: opts.platformDir,
489
+ });
490
+ }
491
+
492
+ async function buildSubagentWorkspaceBackend(opts: {
478
493
  readonly workspaceRootDir: string;
479
494
  readonly casObserver?: CasCaptureObserver;
480
495
  readonly shellEnv?: Record<string, string>;
@@ -516,6 +531,8 @@ export async function compileSubagents(
516
531
  readonly approvalGate?: ApprovalGateConfig | null;
517
532
  readonly parentModelName: string;
518
533
  readonly workspaceRootDir: string;
534
+ /** The session's platform dir, mounted read-only at `.stigmer/` (`platform-route.ts`). */
535
+ readonly platformDir?: string;
519
536
  /** Shared CAS observer (capture mode only); see {@link SubagentTransformOptions.casObserver}. */
520
537
  readonly casObserver?: CasCaptureObserver;
521
538
  readonly modelFactory?: (modelName: string) => Promise<BaseChatModel>;
@@ -697,11 +714,27 @@ export async function transformAndCompileSubagents(
697
714
  }
698
715
  }
699
716
 
700
- // Step 3: Merge built-ins with transformed (proto overrides take precedence)
701
- const protoNames = new Set(transformed.map((t) => t.name));
717
+ // Step 3: Merge built-ins with transformed (proto overrides take precedence).
718
+ // Names are unique by the time deepagents sees them: since 1.14 it refuses
719
+ // a duplicate sub-agent name and fails the turn, where it used to let the
720
+ // later one win. Nothing upstream of the runner validates the names, so the
721
+ // runner keeps the behaviour agents were written against — a later
722
+ // declaration overrides an earlier one of the same name, as a proto
723
+ // definition overrides a built-in.
724
+ const declared = new Map<string, TransformedSubagent>();
725
+ for (const spec of transformed) {
726
+ if (declared.has(spec.name)) {
727
+ console.warn(
728
+ `[subagent-transformer] Sub-agent name '${spec.name}' is declared more than once. ` +
729
+ "The later declaration overrides the earlier one.",
730
+ );
731
+ declared.delete(spec.name);
732
+ }
733
+ declared.set(spec.name, spec);
734
+ }
702
735
  const allSpecs = [
703
- ...builtins.filter((b) => !protoNames.has(b.name)),
704
- ...transformed,
736
+ ...builtins.filter((b) => !declared.has(b.name)),
737
+ ...declared.values(),
705
738
  ];
706
739
 
707
740
  if (allSpecs.length === 0) {
@@ -715,6 +748,7 @@ export async function transformAndCompileSubagents(
715
748
  approvalGate,
716
749
  parentModelName,
717
750
  workspaceRootDir: workspaceBackend.rootDir,
751
+ platformDir: workspaceBackend.platformDir,
718
752
  casObserver,
719
753
  modelFactory,
720
754
  shellEnv,
@@ -2,12 +2,10 @@
2
2
  * Sub-agent middleware composition for ExecuteDeepAgent.
3
3
  *
4
4
  * Each sub-agent gets its own middleware stack with:
5
- * - Path normalization (issue #429), FIRST and only on permission-rule-
6
- * bearing graphs (plan mode) — workspace-relative paths are rewritten to
7
- * workspace-absolute before deepagents' permission validation refuses
8
- * them, exactly as on the parent. `compileSubagents` derives it from the
9
- * same `permissions` option it bakes into the graph, keeping the rules
10
- * and their normalization shim coupled.
5
+ * - Path normalization (issue #429), FIRST, on every graph since issue #754
6
+ * — workspace-relative paths are rewritten to the virtual root's dialect
7
+ * before deepagents' permission validation and the backend see them,
8
+ * exactly as on the parent (`compileSubagents` always passes it).
11
9
  * - Fresh loop detection (independent cycle tracking)
12
10
  * - Tool intent (issue #276) — the shell tool's bind-time schema gains the
13
11
  * optional model-authored `description`, so sub-agent shell rows carry
@@ -94,7 +92,7 @@ export interface SubAgentMiddlewareOptions {
94
92
  * workspace-absolute paths (matching the parent). The gate sits before the
95
93
  * cost-cap view so an approval pause happens before budget accounting, and
96
94
  * error hints come after the gate — both matching the parent order
97
- * (…→ truncation → graceful-stop → approval gate → cost cap → error hints …),
95
+ * (…→ truncation → approval gate → cost cap → error hints …),
98
96
  * which keeps the gate's HITL interrupt outside the hints' try/catch.
99
97
  */
100
98
  export function buildSubAgentMiddleware(
@@ -39,8 +39,12 @@
39
39
  * sub-agent's rows carry none on either harness today (#1133), and one
40
40
  * handler set would otherwise stamp them as a side effect.
41
41
  *
42
- * The scope, in LangGraph 1.3.2's grammar (mirrors deepagents'
43
- * `createSubagentTransformer`): the root's model events arrive under
42
+ * The scope, in LangGraph's v3 grammar (read at @langchain/langgraph 1.3.2,
43
+ * and unchanged at 1.4.18 — the delegation, to-do, tool-call, structured-
44
+ * output and approval-resume streams were recorded on both and compared;
45
+ * deepagents' own `createSubagentTransformer`, which this once mirrored, left
46
+ * with deepagents 1.14 and langchain's `createAgent` registers its
47
+ * successor): the root's model events arrive under
44
48
  * `["model_request:<uuid>"]` and its tool events under `["tools:<uuid>"]`
45
49
  * (one segment); a `task` tool start at that depth opens a sub-agent, and
46
50
  * the segment it arrived under is the prefix every event of that sub-agent's