@stigmer/runner 3.12.1 → 3.12.3
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-cursor/capture-flow.d.ts +5 -4
- package/dist/activities/execute-cursor/capture-flow.js +5 -4
- package/dist/activities/execute-cursor/capture-flow.js.map +1 -1
- package/dist/activities/execute-cursor/cas-observations.d.ts +10 -4
- package/dist/activities/execute-cursor/cas-observations.js +10 -4
- package/dist/activities/execute-cursor/cas-observations.js.map +1 -1
- package/dist/activities/execute-cursor/hook-script.js +39 -13
- package/dist/activities/execute-cursor/hook-script.js.map +1 -1
- package/dist/activities/execute-cursor/skill-resolver.d.ts +23 -0
- package/dist/activities/execute-cursor/skill-resolver.js +121 -32
- package/dist/activities/execute-cursor/skill-resolver.js.map +1 -1
- package/dist/activities/execute-deep-agent/__test-utils__/scripted-model.d.ts +9 -1
- package/dist/activities/execute-deep-agent/__test-utils__/scripted-model.js +13 -2
- package/dist/activities/execute-deep-agent/__test-utils__/scripted-model.js.map +1 -1
- package/dist/activities/execute-deep-agent/cas-capture-backend.d.ts +3 -1
- package/dist/activities/execute-deep-agent/cas-capture-backend.js +3 -1
- package/dist/activities/execute-deep-agent/cas-capture-backend.js.map +1 -1
- package/dist/activities/execute-deep-agent/setup.js +8 -0
- package/dist/activities/execute-deep-agent/setup.js.map +1 -1
- package/dist/activities/execute-deep-agent/subagent-wiring.d.ts +5 -1
- package/dist/activities/execute-deep-agent/subagent-wiring.js +25 -10
- package/dist/activities/execute-deep-agent/subagent-wiring.js.map +1 -1
- package/dist/activities/generate-session-subject.d.ts +80 -0
- package/dist/activities/generate-session-subject.js +283 -0
- package/dist/activities/generate-session-subject.js.map +1 -0
- package/dist/client/stigmer-client.d.ts +17 -1
- package/dist/client/stigmer-client.js +20 -0
- package/dist/client/stigmer-client.js.map +1 -1
- package/dist/middleware/approval-gate.d.ts +25 -10
- package/dist/middleware/approval-gate.js +33 -14
- package/dist/middleware/approval-gate.js.map +1 -1
- package/dist/middleware/index.d.ts +6 -5
- package/dist/middleware/index.js +8 -5
- package/dist/middleware/index.js.map +1 -1
- package/dist/middleware/otel-spans.d.ts +2 -1
- package/dist/middleware/otel-spans.js +2 -1
- package/dist/middleware/otel-spans.js.map +1 -1
- package/dist/middleware/tool-intent.d.ts +57 -0
- package/dist/middleware/tool-intent.js +152 -0
- package/dist/middleware/tool-intent.js.map +1 -0
- package/dist/runner-manager.js +3 -1
- package/dist/runner-manager.js.map +1 -1
- package/dist/runner.js +3 -1
- package/dist/runner.js.map +1 -1
- package/dist/shared/attachment-vision.js +9 -0
- package/dist/shared/attachment-vision.js.map +1 -1
- package/dist/shared/mcp-manager.js +8 -0
- package/dist/shared/mcp-manager.js.map +1 -1
- package/package.json +2 -2
- package/src/activities/__tests__/generate-session-subject.test.ts +348 -0
- package/src/activities/execute-cursor/__tests__/hook-script.test.ts +60 -9
- package/src/activities/execute-cursor/__tests__/skill-resolver.test.ts +267 -8
- package/src/activities/execute-cursor/capture-flow.ts +5 -4
- package/src/activities/execute-cursor/cas-observations.ts +10 -4
- package/src/activities/execute-cursor/hook-script.ts +39 -13
- package/src/activities/execute-cursor/skill-resolver.ts +148 -37
- package/src/activities/execute-deep-agent/__test-utils__/scripted-model.ts +13 -2
- package/src/activities/execute-deep-agent/__tests__/subagent-wiring.test.ts +21 -20
- package/src/activities/execute-deep-agent/cas-capture-backend.ts +3 -1
- package/src/activities/execute-deep-agent/setup.ts +8 -0
- package/src/activities/execute-deep-agent/subagent-wiring.ts +26 -10
- package/src/activities/generate-session-subject.ts +370 -0
- package/src/client/stigmer-client.ts +23 -1
- package/src/middleware/__tests__/approval-gate.test.ts +130 -1
- package/src/middleware/__tests__/tool-intent.test.ts +266 -0
- package/src/middleware/approval-gate.ts +58 -24
- package/src/middleware/index.ts +9 -5
- package/src/middleware/otel-spans.ts +2 -1
- package/src/middleware/tool-intent.ts +174 -0
- package/src/runner-manager.ts +3 -0
- package/src/runner.ts +3 -0
- package/src/shared/__tests__/attachment-vision.test.ts +4 -0
- package/src/shared/__tests__/mcp-manager.test.ts +7 -2
- package/src/shared/attachment-vision.ts +9 -0
- package/src/shared/filereview/__tests__/capture.test.ts +48 -0
- package/src/shared/mcp-manager.ts +8 -0
|
@@ -7,10 +7,19 @@
|
|
|
7
7
|
* - Uses a platform-managed directory outside the workspace
|
|
8
8
|
* - Ensures the workspace `.stigmer` symlink (see stigmer-link.ts)
|
|
9
9
|
* - Returns metadata for prompt injection
|
|
10
|
+
*
|
|
11
|
+
* The mount is cached by the skill's content-addressed version hash
|
|
12
|
+
* (stigmer/stigmer#672): metadata is fetched on every execution (that keeps
|
|
13
|
+
* latest-version freshness), but the artifact download and file rewrite are
|
|
14
|
+
* skipped when the mounted content's hash already matches. The session's
|
|
15
|
+
* platform dir survives across executions, so on an active session every
|
|
16
|
+
* message after the first pays a metadata read instead of a full artifact
|
|
17
|
+
* transfer.
|
|
10
18
|
*/
|
|
11
19
|
|
|
12
|
-
import { mkdir, writeFile, rm } from "node:fs/promises";
|
|
20
|
+
import { mkdir, readFile, writeFile, rm } from "node:fs/promises";
|
|
13
21
|
import { join, dirname } from "node:path";
|
|
22
|
+
import { ConnectError, Code } from "@connectrpc/connect";
|
|
14
23
|
import type { StigmerClient } from "../../client/stigmer-client.js";
|
|
15
24
|
import type { Skill } from "@stigmer/protos/ai/stigmer/agentic/skill/v1/api_pb";
|
|
16
25
|
import type { ApiResourceReference } from "@stigmer/protos/ai/stigmer/commons/apiresource/io_pb";
|
|
@@ -21,6 +30,25 @@ import { ensureStigmerSymlink, STIGMER_LOCAL_STATE_DIR } from "../../shared/work
|
|
|
21
30
|
|
|
22
31
|
const SKILLS_SUBDIR = "skills";
|
|
23
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Marker recording what a skill's mount directory currently holds. Written
|
|
35
|
+
* LAST, after every file of the mount landed — a crash mid-write leaves no
|
|
36
|
+
* marker, so the next execution remounts instead of trusting a partial tree.
|
|
37
|
+
*/
|
|
38
|
+
const MOUNT_MARKER_FILE = ".stigmer-mount.json";
|
|
39
|
+
|
|
40
|
+
interface MountMarker {
|
|
41
|
+
/** Content-addressed version hash (`Skill.status.version_hash`) of the mounted content. */
|
|
42
|
+
versionHash: string;
|
|
43
|
+
/**
|
|
44
|
+
* Whether the artifact's files are part of the mount. `false` when the
|
|
45
|
+
* skill has no artifact OR when the download failed and the mount fell
|
|
46
|
+
* back to SKILL.md only — the latter makes the next execution retry the
|
|
47
|
+
* download rather than cache the degraded mount.
|
|
48
|
+
*/
|
|
49
|
+
artifactMounted: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
24
52
|
export interface SkillResolverOptions {
|
|
25
53
|
sessionId: string;
|
|
26
54
|
primaryWorkspaceDir: string;
|
|
@@ -61,29 +89,49 @@ export async function resolveSkills(
|
|
|
61
89
|
for (const ref of skillRefs) {
|
|
62
90
|
try {
|
|
63
91
|
const skill = await client.getSkillByReference(ref);
|
|
92
|
+
const spec = skill.spec;
|
|
93
|
+
if (!spec?.skillMd) {
|
|
94
|
+
console.warn(`[resolveSkills] skill ${ref.org}/${ref.slug} fetched but had no skillMd content`);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const name = spec.name || skill.metadata?.slug || "unknown";
|
|
99
|
+
const skillDir = join(skillsDir, name);
|
|
100
|
+
const versionHash = skill.status?.versionHash ?? "";
|
|
101
|
+
const wantsArtifact = Boolean(skill.status?.artifactStorageKey);
|
|
102
|
+
const meta: SkillMetadata = {
|
|
103
|
+
name,
|
|
104
|
+
description: spec.description || `Skill: ${name}`,
|
|
105
|
+
path: join(STIGMER_LOCAL_STATE_DIR, SKILLS_SUBDIR, name, "SKILL.md"),
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
if (versionHash !== "" && (await mountIsFresh(skillDir, versionHash, wantsArtifact))) {
|
|
109
|
+
results.push(meta);
|
|
110
|
+
console.log(
|
|
111
|
+
`[resolveSkills] mount cache hit: ${name} (version ${versionHash.slice(0, 12)}) — skipping artifact transfer`,
|
|
112
|
+
);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
64
115
|
|
|
65
116
|
let artifactBytes: Uint8Array | undefined;
|
|
66
|
-
if (
|
|
117
|
+
if (wantsArtifact) {
|
|
67
118
|
try {
|
|
68
|
-
|
|
69
|
-
if (resp.artifact && resp.artifact.length > 0) {
|
|
70
|
-
artifactBytes = resp.artifact;
|
|
71
|
-
}
|
|
119
|
+
artifactBytes = await downloadArtifact(client, skill.status!.artifactStorageKey);
|
|
72
120
|
} catch (err) {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
121
|
+
// Deliberate degradation, but LOUD (#675): the session still gets
|
|
122
|
+
// SKILL.md (better than a dead run), yet a skill silently missing
|
|
123
|
+
// its scripts/references was exactly how oversized artifacts hid.
|
|
124
|
+
console.error(
|
|
125
|
+
`[resolveSkills] artifact download FAILED for ${ref.org || "(default)"}/${ref.slug} ` +
|
|
126
|
+
`(key=${skill.status!.artifactStorageKey}) — mounting SKILL.md WITHOUT the skill's ` +
|
|
127
|
+
`supporting files (scripts/references will be missing): ${err instanceof Error ? err.message : err}`,
|
|
76
128
|
);
|
|
77
129
|
}
|
|
78
130
|
}
|
|
79
131
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
console.log(`[resolveSkills] wrote skill: ${meta.name} -> ${meta.path}`);
|
|
84
|
-
} else {
|
|
85
|
-
console.warn(`[resolveSkills] skill ${ref.org}/${ref.slug} fetched but had no skillMd content`);
|
|
86
|
-
}
|
|
132
|
+
await writeSkillMount(skill, skillDir, artifactBytes);
|
|
133
|
+
results.push(meta);
|
|
134
|
+
console.log(`[resolveSkills] wrote skill: ${name} -> ${meta.path}`);
|
|
87
135
|
} catch (err) {
|
|
88
136
|
console.warn(
|
|
89
137
|
`[resolveSkills] failed to resolve skill ${ref.org}/${ref.slug}: ${err instanceof Error ? err.message : err}`,
|
|
@@ -98,24 +146,89 @@ export async function resolveSkills(
|
|
|
98
146
|
return results;
|
|
99
147
|
}
|
|
100
148
|
|
|
101
|
-
|
|
149
|
+
/**
|
|
150
|
+
* Whether the mount at `skillDir` already holds this version's content.
|
|
151
|
+
*
|
|
152
|
+
* Fresh means: the marker's hash matches AND the mount isn't a degraded
|
|
153
|
+
* SKILL.md-only fallback when the skill does carry an artifact. Any read or
|
|
154
|
+
* parse failure counts as stale — the remount is the safe default.
|
|
155
|
+
*/
|
|
156
|
+
async function mountIsFresh(skillDir: string, versionHash: string, wantsArtifact: boolean): Promise<boolean> {
|
|
157
|
+
try {
|
|
158
|
+
const raw = await readFile(join(skillDir, MOUNT_MARKER_FILE), "utf-8");
|
|
159
|
+
const marker = JSON.parse(raw) as Partial<MountMarker>;
|
|
160
|
+
return marker.versionHash === versionHash && (marker.artifactMounted === true || !wantsArtifact);
|
|
161
|
+
} catch {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Download a skill artifact's ZIP bytes, transfer lane first (#675).
|
|
168
|
+
*
|
|
169
|
+
* The URL lane (getArtifactDownloadUrl → HTTP GET) carries any valid skill
|
|
170
|
+
* size; the unary getArtifact response is capped by the server's 10MB gRPC
|
|
171
|
+
* message limit. Servers that predate the lane (and cloud until its sibling
|
|
172
|
+
* lands) answer the mint with UNIMPLEMENTED — those fall back to the unary
|
|
173
|
+
* path, which behaves exactly as before for ≤10MB artifacts.
|
|
174
|
+
*
|
|
175
|
+
* Runs only on a mount-cache miss (#672's hash-keyed marker above) — a hit
|
|
176
|
+
* skips the transfer entirely, whichever lane would have carried it.
|
|
177
|
+
*
|
|
178
|
+
* Exported for tests.
|
|
179
|
+
*/
|
|
180
|
+
export async function downloadArtifact(
|
|
181
|
+
client: StigmerClient,
|
|
182
|
+
artifactStorageKey: string,
|
|
183
|
+
): Promise<Uint8Array | undefined> {
|
|
184
|
+
let minted;
|
|
185
|
+
try {
|
|
186
|
+
minted = await client.getSkillArtifactDownloadUrl(artifactStorageKey);
|
|
187
|
+
} catch (err) {
|
|
188
|
+
if (err instanceof ConnectError && err.code === Code.Unimplemented) {
|
|
189
|
+
const resp = await client.getSkillArtifact(artifactStorageKey);
|
|
190
|
+
return resp.artifact && resp.artifact.length > 0 ? resp.artifact : undefined;
|
|
191
|
+
}
|
|
192
|
+
throw err;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const resp = await fetch(minted.url);
|
|
196
|
+
if (!resp.ok) {
|
|
197
|
+
throw new Error(`artifact fetch failed: HTTP ${resp.status} from ${minted.url}`);
|
|
198
|
+
}
|
|
199
|
+
const bytes = new Uint8Array(await resp.arrayBuffer());
|
|
200
|
+
if (minted.sizeBytes > 0n && BigInt(bytes.length) !== minted.sizeBytes) {
|
|
201
|
+
throw new Error(
|
|
202
|
+
`artifact fetch truncated: got ${bytes.length} bytes, expected ${minted.sizeBytes}`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
return bytes.length > 0 ? bytes : undefined;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* (Re)write a skill's mount directory from scratch.
|
|
210
|
+
*
|
|
211
|
+
* The directory is removed first so files deleted between versions don't
|
|
212
|
+
* linger in the mount, then SKILL.md and the artifact files are written, and
|
|
213
|
+
* the marker is stamped LAST (see MOUNT_MARKER_FILE for the crash-safety
|
|
214
|
+
* contract). SKILL.md always comes from `spec.skillMd` — the server's
|
|
215
|
+
* authoritative copy — never from the zip; both the zip's SKILL.md and any
|
|
216
|
+
* stray marker-named entry are excluded from extraction so the mount's
|
|
217
|
+
* ownership of those two files is unconditional.
|
|
218
|
+
*/
|
|
219
|
+
async function writeSkillMount(
|
|
102
220
|
skill: Skill,
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const spec = skill.spec;
|
|
108
|
-
if (!spec?.skillMd) return null;
|
|
109
|
-
|
|
110
|
-
const name = spec.name || skill.metadata?.slug || "unknown";
|
|
111
|
-
const skillDir = join(skillsDir, name);
|
|
221
|
+
skillDir: string,
|
|
222
|
+
artifactBytes: Uint8Array | undefined,
|
|
223
|
+
): Promise<void> {
|
|
224
|
+
await rm(skillDir, { recursive: true, force: true });
|
|
112
225
|
await mkdir(skillDir, { recursive: true });
|
|
113
226
|
|
|
114
|
-
|
|
115
|
-
await writeFile(skillMdPath, spec.skillMd, "utf-8");
|
|
227
|
+
await writeFile(join(skillDir, "SKILL.md"), skill.spec!.skillMd, "utf-8");
|
|
116
228
|
|
|
117
|
-
|
|
118
|
-
|
|
229
|
+
const artifactMounted = artifactBytes !== undefined && artifactBytes.length > 0;
|
|
230
|
+
if (artifactMounted) {
|
|
231
|
+
const entries = await extractZipFileEntries(artifactBytes, { exclude: ["SKILL.md", MOUNT_MARKER_FILE] });
|
|
119
232
|
for (const entry of entries) {
|
|
120
233
|
const filePath = join(skillDir, entry.path);
|
|
121
234
|
await mkdir(dirname(filePath), { recursive: true });
|
|
@@ -123,13 +236,11 @@ async function writeSkill(
|
|
|
123
236
|
}
|
|
124
237
|
}
|
|
125
238
|
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
path: relativePath,
|
|
132
|
-
};
|
|
239
|
+
const versionHash = skill.status?.versionHash ?? "";
|
|
240
|
+
if (versionHash !== "") {
|
|
241
|
+
const marker: MountMarker = { versionHash, artifactMounted };
|
|
242
|
+
await writeFile(join(skillDir, MOUNT_MARKER_FILE), JSON.stringify(marker), "utf-8");
|
|
243
|
+
}
|
|
133
244
|
}
|
|
134
245
|
|
|
135
246
|
/**
|
|
@@ -50,11 +50,20 @@ export type ScriptSelector = (boundToolNames: string[]) => ScriptStep;
|
|
|
50
50
|
*/
|
|
51
51
|
export class ScriptedModel extends BaseChatModel {
|
|
52
52
|
toolNames: string[] = [];
|
|
53
|
+
/**
|
|
54
|
+
* The tool objects from the most recent `bindTools` call, exactly as the
|
|
55
|
+
* agent bound them (post-middleware). Lets tests assert on the bound
|
|
56
|
+
* SCHEMAS — e.g. the tool-intent middleware's bind-time shell clone — not
|
|
57
|
+
* just the names. The array is shared across the clones `bindTools`
|
|
58
|
+
* returns, so the instance the test holds always sees the latest bind.
|
|
59
|
+
*/
|
|
60
|
+
readonly boundTools: unknown[];
|
|
53
61
|
private readonly select: ScriptSelector;
|
|
54
62
|
|
|
55
|
-
constructor(select: ScriptSelector) {
|
|
63
|
+
constructor(select: ScriptSelector, boundTools: unknown[] = []) {
|
|
56
64
|
super({});
|
|
57
65
|
this.select = select;
|
|
66
|
+
this.boundTools = boundTools;
|
|
58
67
|
}
|
|
59
68
|
|
|
60
69
|
_llmType(): string {
|
|
@@ -62,8 +71,10 @@ export class ScriptedModel extends BaseChatModel {
|
|
|
62
71
|
}
|
|
63
72
|
|
|
64
73
|
bindTools(tools: unknown[]): this {
|
|
65
|
-
const next = new ScriptedModel(this.select);
|
|
74
|
+
const next = new ScriptedModel(this.select, this.boundTools);
|
|
66
75
|
next.toolNames = (tools as Array<{ name?: string }>).map((t) => t?.name ?? "");
|
|
76
|
+
this.boundTools.length = 0;
|
|
77
|
+
this.boundTools.push(...tools);
|
|
67
78
|
return next as unknown as this;
|
|
68
79
|
}
|
|
69
80
|
|
|
@@ -7,11 +7,12 @@ describe("buildSubAgentMiddleware", () => {
|
|
|
7
7
|
it("returns the correct middleware order without cost cap", () => {
|
|
8
8
|
const stack = buildSubAgentMiddleware();
|
|
9
9
|
|
|
10
|
-
expect(stack).toHaveLength(
|
|
10
|
+
expect(stack).toHaveLength(5);
|
|
11
11
|
expect(stack[0].name).toBe("LoopDetectionMiddleware");
|
|
12
12
|
expect(stack[1].name).toBe("ExecutionBudgetMiddleware");
|
|
13
|
-
expect(stack[2].name).toBe("
|
|
14
|
-
expect(stack[3].name).toBe("
|
|
13
|
+
expect(stack[2].name).toBe("StigmerToolIntentMiddleware");
|
|
14
|
+
expect(stack[3].name).toBe("ToolTruncationMiddleware");
|
|
15
|
+
expect(stack[4].name).toBe("ErrorHintsMiddleware");
|
|
15
16
|
});
|
|
16
17
|
|
|
17
18
|
it("includes cost cap view when parent cost cap is provided", () => {
|
|
@@ -25,9 +26,9 @@ describe("buildSubAgentMiddleware", () => {
|
|
|
25
26
|
|
|
26
27
|
const stack = buildSubAgentMiddleware({ costCap: parentCostCap });
|
|
27
28
|
|
|
28
|
-
expect(stack).toHaveLength(
|
|
29
|
-
expect(stack[
|
|
30
|
-
expect(stack[
|
|
29
|
+
expect(stack).toHaveLength(6);
|
|
30
|
+
expect(stack[4].name).toBe("CostCapSubAgentView");
|
|
31
|
+
expect(stack[5].name).toBe("ErrorHintsMiddleware");
|
|
31
32
|
});
|
|
32
33
|
|
|
33
34
|
it("sub-agent cost cap view shares parent state", () => {
|
|
@@ -43,7 +44,7 @@ describe("buildSubAgentMiddleware", () => {
|
|
|
43
44
|
|
|
44
45
|
expect(parentCostCap.runningCost).toBe(0);
|
|
45
46
|
|
|
46
|
-
const subView = stack[
|
|
47
|
+
const subView = stack[4];
|
|
47
48
|
expect(subView.afterModel).toBeDefined();
|
|
48
49
|
expect(subView.wrapToolCall).toBeDefined();
|
|
49
50
|
expect(subView.beforeAgent).toBeUndefined();
|
|
@@ -69,7 +70,7 @@ describe("buildSubAgentMiddleware", () => {
|
|
|
69
70
|
toolTruncation: { maxChars: 5000 },
|
|
70
71
|
});
|
|
71
72
|
|
|
72
|
-
expect(stack[
|
|
73
|
+
expect(stack[3].name).toBe("ToolTruncationMiddleware");
|
|
73
74
|
});
|
|
74
75
|
|
|
75
76
|
it("installs the approval gate when an approvalGate config is provided", () => {
|
|
@@ -77,16 +78,16 @@ describe("buildSubAgentMiddleware", () => {
|
|
|
77
78
|
approvalGate: { policies: new Map(), toolServerMap: new Map() },
|
|
78
79
|
});
|
|
79
80
|
|
|
80
|
-
// loop, budget, truncation, approval gate, error hints
|
|
81
|
-
expect(stack).toHaveLength(
|
|
82
|
-
expect(stack[
|
|
83
|
-
expect(stack[
|
|
81
|
+
// loop, budget, tool intent, truncation, approval gate, error hints
|
|
82
|
+
expect(stack).toHaveLength(6);
|
|
83
|
+
expect(stack[4].name).toBe("ApprovalGateMiddleware");
|
|
84
|
+
expect(stack[4].wrapToolCall).toBeDefined();
|
|
84
85
|
});
|
|
85
86
|
|
|
86
87
|
it("omits the approval gate when approvalGate is null (auto-approve-all parity)", () => {
|
|
87
88
|
const stack = buildSubAgentMiddleware({ approvalGate: null });
|
|
88
89
|
|
|
89
|
-
expect(stack).toHaveLength(
|
|
90
|
+
expect(stack).toHaveLength(5);
|
|
90
91
|
expect(stack.some((m) => m.name === "ApprovalGateMiddleware")).toBe(false);
|
|
91
92
|
});
|
|
92
93
|
|
|
@@ -104,13 +105,13 @@ describe("buildSubAgentMiddleware", () => {
|
|
|
104
105
|
approvalGate: { policies: new Map(), toolServerMap: new Map() },
|
|
105
106
|
});
|
|
106
107
|
|
|
107
|
-
// loop, budget, truncation, approval gate, cost cap view,
|
|
108
|
-
// Hints AFTER the gate matches the parent nesting: the
|
|
109
|
-
// interrupt stays outside the hints' try/catch (issue #255).
|
|
110
|
-
expect(stack).toHaveLength(
|
|
111
|
-
expect(stack[
|
|
112
|
-
expect(stack[
|
|
113
|
-
expect(stack[
|
|
108
|
+
// loop, budget, tool intent, truncation, approval gate, cost cap view,
|
|
109
|
+
// error hints. Hints AFTER the gate matches the parent nesting: the
|
|
110
|
+
// gate's HITL interrupt stays outside the hints' try/catch (issue #255).
|
|
111
|
+
expect(stack).toHaveLength(7);
|
|
112
|
+
expect(stack[4].name).toBe("ApprovalGateMiddleware");
|
|
113
|
+
expect(stack[5].name).toBe("CostCapSubAgentView");
|
|
114
|
+
expect(stack[6].name).toBe("ErrorHintsMiddleware");
|
|
114
115
|
});
|
|
115
116
|
|
|
116
117
|
// captureIgnored is the structural coupling that makes sub-agent gitignored
|
|
@@ -21,7 +21,9 @@
|
|
|
21
21
|
*
|
|
22
22
|
* deepagents mutates only via `write` (create/overwrite) and `edit` (modify);
|
|
23
23
|
* there is no backend delete/rename, so this observes CREATE and MODIFY. A
|
|
24
|
-
*
|
|
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.
|
|
25
27
|
*
|
|
26
28
|
* @since File-Change HITL Redesign (Phase 3 — CAS deep-agent wiring); sub-agent
|
|
27
29
|
* gitignored capture parity (Session 26, DD-19); shell restore (issue #248)
|
|
@@ -676,6 +676,14 @@ export async function performSetup(deps: SetupDependencies): Promise<SetupResult
|
|
|
676
676
|
isCapturablePath,
|
|
677
677
|
captureIgnored: captureMode && !!artifactStorage,
|
|
678
678
|
recordBlockedSecret: (rawPath: string) => casObserver.recordBlockedSecret(rawPath),
|
|
679
|
+
// Pre-delete byte capture (issue #303): deepagents has no backend
|
|
680
|
+
// delete method for the CAS backend to observe, so a flowing delete's
|
|
681
|
+
// before-bytes are recorded by the gate at authorization time, through
|
|
682
|
+
// the SAME shared observer — the turn boundary then reads after=null
|
|
683
|
+
// and authors a reviewable, restorable DELETE. recordBefore's own
|
|
684
|
+
// ownership predicate (gitignored-only in a git tree, everything in
|
|
685
|
+
// non-git) agrees with the gate's not-capturable condition.
|
|
686
|
+
captureDeleteBefore: (rawPath: string) => casObserver.recordBefore(rawPath),
|
|
679
687
|
unattended,
|
|
680
688
|
unattendedSkips,
|
|
681
689
|
}
|
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
* same `permissions` option it bakes into the graph, keeping the rules
|
|
10
10
|
* and their normalization shim coupled.
|
|
11
11
|
* - Fresh loop detection (independent cycle tracking)
|
|
12
|
+
* - Tool intent (issue #276) — the shell tool's bind-time schema gains the
|
|
13
|
+
* optional model-authored `description`, so sub-agent shell rows carry
|
|
14
|
+
* intent titles exactly like the parent's
|
|
12
15
|
* - Fresh tool truncation (same limits as parent)
|
|
13
16
|
* - Periodic execution budget (interval=30, max=4 advisories)
|
|
14
17
|
* - Approval gate (so a mutating tool *inside* a sub-agent is gated, not
|
|
@@ -43,6 +46,7 @@ import { createPathNormalizationMiddleware } from "../../middleware/path-normali
|
|
|
43
46
|
import { createLoopDetectionMiddleware } from "../../middleware/loop-detection.js";
|
|
44
47
|
import { createToolTruncationMiddleware } from "../../middleware/tool-truncation.js";
|
|
45
48
|
import { createExecutionBudgetMiddleware } from "../../middleware/execution-budget.js";
|
|
49
|
+
import { createToolIntentMiddleware } from "../../middleware/tool-intent.js";
|
|
46
50
|
import {
|
|
47
51
|
createApprovalGateMiddleware,
|
|
48
52
|
type ApprovalGateConfig,
|
|
@@ -83,7 +87,8 @@ export interface SubAgentMiddlewareOptions {
|
|
|
83
87
|
*
|
|
84
88
|
* Returns an ordered array mirroring the parent composition:
|
|
85
89
|
* [path normalization] → loop detection → execution budget (periodic) →
|
|
86
|
-
* tool truncation → [approval gate] → cost cap view →
|
|
90
|
+
* tool intent → tool truncation → [approval gate] → cost cap view →
|
|
91
|
+
* error hints.
|
|
87
92
|
* Normalization is outermost so everything downstream observes canonical
|
|
88
93
|
* workspace-absolute paths (matching the parent). The gate sits before the
|
|
89
94
|
* cost-cap view so an approval pause happens before budget accounting, and
|
|
@@ -107,22 +112,33 @@ export function buildSubAgentMiddleware(
|
|
|
107
112
|
maxWarnings: SUB_AGENT_MAX_ADVISORIES,
|
|
108
113
|
}));
|
|
109
114
|
|
|
115
|
+
// Sub-agent shell rows render in the same thread as the parent's and must
|
|
116
|
+
// carry the same model-authored intent titles (issue #276).
|
|
117
|
+
stack.push(createToolIntentMiddleware());
|
|
118
|
+
|
|
110
119
|
stack.push(createToolTruncationMiddleware(options.toolTruncation));
|
|
111
120
|
|
|
112
121
|
if (options.approvalGate) {
|
|
113
|
-
// captureIgnored (DD-19): a sub-agent flows gitignored writes
|
|
114
|
-
// CAS
|
|
122
|
+
// captureIgnored (DD-19): a sub-agent flows gitignored writes — and, since
|
|
123
|
+
// issue #303, non-secret CAS-owned deletes — into CAS iff a CAS observer
|
|
124
|
+
// backs its filesystem backend (compileSubagents passes this as
|
|
115
125
|
// `!!casObserver`). When true, inherit the parent gate verbatim so its
|
|
116
|
-
// captureIgnored + recordBlockedSecret feed the SAME
|
|
117
|
-
// backs the sub-agent's writes. When false (default;
|
|
118
|
-
// observer), force CAS routing off and drop
|
|
119
|
-
// gitignored paths stay on the interrupt gate — a flowed
|
|
120
|
-
// unobserved backend would apply unobserved,
|
|
121
|
-
// git-tracked edits are always captured by
|
|
126
|
+
// captureIgnored + recordBlockedSecret + captureDeleteBefore feed the SAME
|
|
127
|
+
// shared observer that backs the sub-agent's writes. When false (default;
|
|
128
|
+
// non-capture mode, or no observer), force CAS routing off and drop both
|
|
129
|
+
// observer sinks so gitignored paths stay on the interrupt gate — a flowed
|
|
130
|
+
// gitignored edit on an unobserved backend would apply unobserved,
|
|
131
|
+
// unreviewable bytes. Sub-agent git-tracked edits are always captured by
|
|
132
|
+
// the backend-agnostic boundary diff.
|
|
122
133
|
stack.push(createApprovalGateMiddleware(
|
|
123
134
|
options.captureIgnored
|
|
124
135
|
? options.approvalGate
|
|
125
|
-
: {
|
|
136
|
+
: {
|
|
137
|
+
...options.approvalGate,
|
|
138
|
+
captureIgnored: false,
|
|
139
|
+
recordBlockedSecret: undefined,
|
|
140
|
+
captureDeleteBefore: undefined,
|
|
141
|
+
},
|
|
126
142
|
));
|
|
127
143
|
}
|
|
128
144
|
|