@stigmer/runner 3.32.0 → 3.33.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.
- package/dist/.build-fingerprint +1 -1
- package/dist/activities/execute-deep-agent/cas-capture-backend.d.ts +13 -6
- package/dist/activities/execute-deep-agent/cas-capture-backend.js +21 -5
- package/dist/activities/execute-deep-agent/cas-capture-backend.js.map +1 -1
- package/dist/activities/execute-deep-agent/deepagents-profiles.d.ts +21 -7
- package/dist/activities/execute-deep-agent/deepagents-profiles.js +24 -9
- package/dist/activities/execute-deep-agent/deepagents-profiles.js.map +1 -1
- package/dist/activities/execute-deep-agent/platform-route.d.ts +75 -0
- package/dist/activities/execute-deep-agent/platform-route.js +107 -0
- package/dist/activities/execute-deep-agent/platform-route.js.map +1 -0
- package/dist/activities/execute-deep-agent/subagent-transformer.d.ts +2 -0
- package/dist/activities/execute-deep-agent/subagent-transformer.js +29 -5
- package/dist/activities/execute-deep-agent/subagent-transformer.js.map +1 -1
- package/dist/activities/execute-deep-agent/subagent-wiring.d.ts +5 -7
- package/dist/activities/execute-deep-agent/subagent-wiring.js +5 -7
- package/dist/activities/execute-deep-agent/subagent-wiring.js.map +1 -1
- package/dist/activities/execute-deep-agent/translator.d.ts +6 -2
- package/dist/activities/execute-deep-agent/translator.js +6 -2
- package/dist/activities/execute-deep-agent/translator.js.map +1 -1
- package/dist/activities/execute-deep-agent/turn-setup.js +14 -3
- package/dist/activities/execute-deep-agent/turn-setup.js.map +1 -1
- package/dist/middleware/index.d.ts +7 -3
- package/dist/middleware/index.js +7 -3
- package/dist/middleware/index.js.map +1 -1
- package/dist/shared/workspace/stigmer-link.d.ts +15 -3
- package/dist/shared/workspace/stigmer-link.js +22 -3
- package/dist/shared/workspace/stigmer-link.js.map +1 -1
- package/dist/shared/workspace/types.d.ts +4 -3
- package/package.json +15 -9
- package/src/activities/execute-deep-agent/__tests__/builtin-tool-surface.test.ts +107 -0
- package/src/activities/execute-deep-agent/__tests__/cas-capture-backend.test.ts +14 -0
- package/src/activities/execute-deep-agent/__tests__/hermetic/fixtures/paused-session.deepagents-1.10.2.json +592 -0
- package/src/activities/execute-deep-agent/__tests__/hermetic/goldens/sub-agent-delegation.status.json +2 -2
- package/src/activities/execute-deep-agent/__tests__/hermetic/goldens/tool-call.status.json +1 -1
- package/src/activities/execute-deep-agent/__tests__/hermetic/plain-turn.test.ts +10 -13
- package/src/activities/execute-deep-agent/__tests__/hermetic/resume-across-engine-upgrade.test.ts +374 -0
- package/src/activities/execute-deep-agent/__tests__/hermetic/sub-agent-delegation.test.ts +7 -0
- package/src/activities/execute-deep-agent/__tests__/hermetic/tool-call.test.ts +6 -0
- package/src/activities/execute-deep-agent/__tests__/plan-mode-path-normalization.test.ts +16 -8
- package/src/activities/execute-deep-agent/__tests__/platform-route.test.ts +148 -0
- package/src/activities/execute-deep-agent/__tests__/subagent-transformer.test.ts +24 -0
- package/src/activities/execute-deep-agent/__tests__/summarization-verification.test.ts +16 -9
- package/src/activities/execute-deep-agent/cas-capture-backend.ts +25 -6
- package/src/activities/execute-deep-agent/deepagents-profiles.ts +25 -9
- package/src/activities/execute-deep-agent/platform-route.ts +134 -0
- package/src/activities/execute-deep-agent/subagent-transformer.ts +40 -6
- package/src/activities/execute-deep-agent/subagent-wiring.ts +5 -7
- package/src/activities/execute-deep-agent/translator.ts +6 -2
- package/src/activities/execute-deep-agent/turn-setup.ts +17 -3
- package/src/middleware/index.ts +7 -3
- package/src/shared/workspace/__tests__/stigmer-link.test.ts +41 -18
- package/src/shared/workspace/stigmer-link.ts +22 -3
- 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 (
|
|
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
|
-
*
|
|
236
|
-
* todoMiddleware,
|
|
237
|
+
* [
|
|
237
238
|
* ...skillsMiddleware,
|
|
238
239
|
* fsMiddleware,
|
|
239
240
|
* subagentMiddleware,
|
|
240
|
-
* summarizationMiddleware,
|
|
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 (
|
|
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
|
|
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 (
|
|
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 =
|
|
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
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
-
*
|
|
5
|
-
*
|
|
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
|
-
*
|
|
11
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
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) => !
|
|
704
|
-
...
|
|
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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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 →
|
|
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
|
|
43
|
-
*
|
|
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
|