@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
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool-intent middleware (issue #276): the shell tool's bind-time schema
|
|
3
|
+
* gains an optional model-authored `description`, execution never sees it,
|
|
4
|
+
* and the argument survives verbatim into the message history.
|
|
5
|
+
*
|
|
6
|
+
* The integration block drives a REAL deepagents graph, because the two
|
|
7
|
+
* claims that matter most are framework claims: (1) `wrapModelCall` receives
|
|
8
|
+
* the library's built-in `execute` tool and the clone reaches `bindTools`,
|
|
9
|
+
* and (2) the original tool's strip-parsing drops the extra argument before
|
|
10
|
+
* the backend's `execute(command)` runs.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
14
|
+
import { mkdtemp, rm, readFile } from "node:fs/promises";
|
|
15
|
+
import { readFileSync } from "node:fs";
|
|
16
|
+
import { join, resolve, dirname } from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import { z } from "zod";
|
|
20
|
+
import { tool } from "@langchain/core/tools";
|
|
21
|
+
import { convertToOpenAITool } from "@langchain/core/utils/function_calling";
|
|
22
|
+
import { AIMessage, HumanMessage } from "@langchain/core/messages";
|
|
23
|
+
import { MemorySaver } from "@langchain/langgraph";
|
|
24
|
+
import { createDeepAgent } from "deepagents";
|
|
25
|
+
import {
|
|
26
|
+
createToolIntentMiddleware,
|
|
27
|
+
INTENT_ARG,
|
|
28
|
+
INTENT_ARG_PROMPT,
|
|
29
|
+
} from "../tool-intent.js";
|
|
30
|
+
import { buildSubAgentMiddleware } from "../../activities/execute-deep-agent/subagent-wiring.js";
|
|
31
|
+
import { createCasCaptureBackend } from "../../activities/execute-deep-agent/cas-capture-backend.js";
|
|
32
|
+
import { CasCaptureObserver } from "../../activities/execute-deep-agent/cas-capture-observer.js";
|
|
33
|
+
import {
|
|
34
|
+
ScriptedModel,
|
|
35
|
+
type ScriptSelector,
|
|
36
|
+
} from "../../activities/execute-deep-agent/__test-utils__/scripted-model.js";
|
|
37
|
+
|
|
38
|
+
/** A stand-in for deepagents' `execute` tool: same name, same schema shape. */
|
|
39
|
+
function makeShellTool(executed: string[]) {
|
|
40
|
+
return tool(
|
|
41
|
+
async ({ command }: { command: string }) => {
|
|
42
|
+
executed.push(command);
|
|
43
|
+
return "ok";
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: "execute",
|
|
47
|
+
description: "Run a shell command",
|
|
48
|
+
schema: z.object({ command: z.string().describe("The shell command to execute") }),
|
|
49
|
+
},
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function makeReadTool() {
|
|
54
|
+
return tool(async () => "contents", {
|
|
55
|
+
name: "read_file",
|
|
56
|
+
description: "Read a file",
|
|
57
|
+
schema: z.object({ file_path: z.string() }),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type BoundSchema = {
|
|
62
|
+
type?: string;
|
|
63
|
+
properties?: Record<string, { type?: string; description?: string }>;
|
|
64
|
+
required?: string[];
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
function schemaOf(t: unknown): BoundSchema {
|
|
68
|
+
return (t as { schema: BoundSchema }).schema;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function runWrap(
|
|
72
|
+
mw: ReturnType<typeof createToolIntentMiddleware>,
|
|
73
|
+
tools: unknown[],
|
|
74
|
+
): Promise<unknown[]> {
|
|
75
|
+
let seen: unknown[] = [];
|
|
76
|
+
const handler = vi.fn(async (req: { tools?: unknown[] }) => {
|
|
77
|
+
seen = req.tools ?? [];
|
|
78
|
+
return new AIMessage({ content: "" });
|
|
79
|
+
});
|
|
80
|
+
await mw.wrapModelCall!(
|
|
81
|
+
{ model: {}, messages: [], tools, state: {}, runtime: {} } as never,
|
|
82
|
+
handler as never,
|
|
83
|
+
);
|
|
84
|
+
return seen;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
describe("ToolIntentMiddleware (unit)", () => {
|
|
88
|
+
it("passes a tool-less request through untouched", async () => {
|
|
89
|
+
const mw = createToolIntentMiddleware();
|
|
90
|
+
const request = { model: {}, messages: [], state: {}, runtime: {} } as never;
|
|
91
|
+
const handler = vi.fn(async () => new AIMessage({ content: "" }));
|
|
92
|
+
await mw.wrapModelCall!(request, handler as never);
|
|
93
|
+
expect(handler).toHaveBeenCalledWith(request);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("extends the shell tool's bound schema with the optional intent arg", async () => {
|
|
97
|
+
const shell = makeShellTool([]);
|
|
98
|
+
const bound = await runWrap(createToolIntentMiddleware(), [shell]);
|
|
99
|
+
|
|
100
|
+
expect(bound).toHaveLength(1);
|
|
101
|
+
expect(bound[0]).not.toBe(shell);
|
|
102
|
+
const schema = schemaOf(bound[0]);
|
|
103
|
+
expect(schema.properties?.command?.type).toBe("string");
|
|
104
|
+
expect(schema.properties?.[INTENT_ARG]).toEqual({
|
|
105
|
+
type: "string",
|
|
106
|
+
description: INTENT_ARG_PROMPT,
|
|
107
|
+
});
|
|
108
|
+
// Optional by construction: required is untouched.
|
|
109
|
+
expect(schema.required ?? []).not.toContain(INTENT_ARG);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("passes non-shell tools through by reference", async () => {
|
|
113
|
+
const read = makeReadTool();
|
|
114
|
+
const shell = makeShellTool([]);
|
|
115
|
+
const bound = await runWrap(createToolIntentMiddleware(), [read, shell]);
|
|
116
|
+
expect(bound[0]).toBe(read);
|
|
117
|
+
expect(bound[1]).not.toBe(shell);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("never shadows a real argument named like the intent arg", async () => {
|
|
121
|
+
const conflicting = tool(async () => "ok", {
|
|
122
|
+
name: "shell",
|
|
123
|
+
description: "A shell tool that already has a description arg",
|
|
124
|
+
schema: z.object({ command: z.string(), [INTENT_ARG]: z.string() }),
|
|
125
|
+
});
|
|
126
|
+
const bound = await runWrap(createToolIntentMiddleware(), [conflicting]);
|
|
127
|
+
expect(bound[0]).toBe(conflicting);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("passes through tools whose schema is not an object schema", async () => {
|
|
131
|
+
const odd = { name: "bash", description: "odd", schema: 42 };
|
|
132
|
+
const bound = await runWrap(createToolIntentMiddleware(), [odd]);
|
|
133
|
+
expect(bound[0]).toBe(odd);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("reuses one clone across model calls (referential stability)", async () => {
|
|
137
|
+
const mw = createToolIntentMiddleware();
|
|
138
|
+
const shell = makeShellTool([]);
|
|
139
|
+
const first = await runWrap(mw, [shell]);
|
|
140
|
+
const second = await runWrap(mw, [shell]);
|
|
141
|
+
expect(first[0]).toBe(second[0]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("emits a non-executable declaration that provider converters accept", async () => {
|
|
145
|
+
const shell = makeShellTool([]);
|
|
146
|
+
const bound = await runWrap(createToolIntentMiddleware(), [shell]);
|
|
147
|
+
const declaration = bound[0] as Record<string, unknown>;
|
|
148
|
+
|
|
149
|
+
// Deliberately NOT an executable tool — the agent's validation forbids
|
|
150
|
+
// swapping same-name executable instances, and execution belongs to the
|
|
151
|
+
// registered original. StructuredToolParams is the sanctioned shape.
|
|
152
|
+
expect(declaration.invoke).toBeUndefined();
|
|
153
|
+
|
|
154
|
+
// The shape every provider's bindTools converts like a structured tool.
|
|
155
|
+
const openAiTool = convertToOpenAITool(declaration as never) as {
|
|
156
|
+
function: { name: string; parameters: { properties: Record<string, unknown> } };
|
|
157
|
+
};
|
|
158
|
+
expect(openAiTool.function.name).toBe("execute");
|
|
159
|
+
expect(openAiTool.function.parameters.properties[INTENT_ARG]).toEqual({
|
|
160
|
+
type: "string",
|
|
161
|
+
description: INTENT_ARG_PROMPT,
|
|
162
|
+
});
|
|
163
|
+
expect(openAiTool.function.parameters.properties.command).toBeDefined();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("is idempotent: an already-extended declaration passes through", async () => {
|
|
167
|
+
const mw = createToolIntentMiddleware();
|
|
168
|
+
const shell = makeShellTool([]);
|
|
169
|
+
const [firstPass] = await runWrap(mw, [shell]);
|
|
170
|
+
// A second middleware instance (e.g. a sub-agent stack composed over the
|
|
171
|
+
// same request) must not re-wrap the extended declaration.
|
|
172
|
+
const [secondPass] = await runWrap(createToolIntentMiddleware(), [firstPass]);
|
|
173
|
+
expect(secondPass).toBe(firstPass);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("ToolIntentMiddleware (real deepagents graph)", () => {
|
|
178
|
+
let root: string;
|
|
179
|
+
|
|
180
|
+
beforeEach(async () => {
|
|
181
|
+
root = await mkdtemp(join(tmpdir(), "tool-intent-"));
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
afterEach(async () => {
|
|
185
|
+
await rm(root, { recursive: true, force: true });
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("binds the extended execute schema to the model and strips the arg at execution", async () => {
|
|
189
|
+
const marker = join(root, "intent-marker.txt");
|
|
190
|
+
const script: ScriptSelector = () => ({
|
|
191
|
+
toolCalls: [{
|
|
192
|
+
name: "execute",
|
|
193
|
+
args: {
|
|
194
|
+
command: "echo ran > intent-marker.txt",
|
|
195
|
+
[INTENT_ARG]: "Write the marker file",
|
|
196
|
+
},
|
|
197
|
+
id: "exec_intent_1",
|
|
198
|
+
}],
|
|
199
|
+
done: "done",
|
|
200
|
+
});
|
|
201
|
+
const model = new ScriptedModel(script);
|
|
202
|
+
|
|
203
|
+
const observer = new CasCaptureObserver({ rootDir: root, isIgnored: async () => false });
|
|
204
|
+
const backend = await createCasCaptureBackend({ rootDir: root, observer, shellEnv: {} });
|
|
205
|
+
|
|
206
|
+
const checkpointer = new MemorySaver();
|
|
207
|
+
const agent = await createDeepAgent({
|
|
208
|
+
model,
|
|
209
|
+
checkpointer: checkpointer as never,
|
|
210
|
+
backend,
|
|
211
|
+
middleware: [createToolIntentMiddleware()],
|
|
212
|
+
} as Parameters<typeof createDeepAgent>[0]);
|
|
213
|
+
|
|
214
|
+
const config = { configurable: { thread_id: "intent-thread" }, recursionLimit: 50 };
|
|
215
|
+
await agent.invoke({ messages: [new HumanMessage({ content: "go" })] }, config);
|
|
216
|
+
|
|
217
|
+
// (1) The model saw the library's execute tool WITH the intent arg.
|
|
218
|
+
const boundExecute = model.boundTools.find(
|
|
219
|
+
(t) => (t as { name?: string }).name === "execute",
|
|
220
|
+
);
|
|
221
|
+
expect(boundExecute).toBeDefined();
|
|
222
|
+
const schema = schemaOf(boundExecute);
|
|
223
|
+
expect(schema.properties?.[INTENT_ARG]?.description).toBe(INTENT_ARG_PROMPT);
|
|
224
|
+
expect(schema.properties?.command).toBeDefined();
|
|
225
|
+
expect(schema.required ?? []).not.toContain(INTENT_ARG);
|
|
226
|
+
|
|
227
|
+
// (2) Execution ran the ORIGINAL tool: strip semantics dropped the intent
|
|
228
|
+
// arg and the command executed normally.
|
|
229
|
+
expect(await readFile(marker, "utf8")).toBe("ran\n");
|
|
230
|
+
|
|
231
|
+
// (3) The intent arg survived verbatim in the message history — the
|
|
232
|
+
// exact bytes the status builder persists onto ToolCall.args.
|
|
233
|
+
const state = (await agent.getState(config)) as unknown as {
|
|
234
|
+
values: { messages: Array<{ tool_calls?: Array<{ name: string; args: Record<string, unknown> }> }> };
|
|
235
|
+
};
|
|
236
|
+
const messages = state.values.messages;
|
|
237
|
+
const toolCall = messages
|
|
238
|
+
.flatMap((m) => m.tool_calls ?? [])
|
|
239
|
+
.find((tc) => tc.name === "execute");
|
|
240
|
+
expect(toolCall).toBeDefined();
|
|
241
|
+
expect(toolCall!.args[INTENT_ARG]).toBe("Write the marker file");
|
|
242
|
+
expect(toolCall!.args.command).toBe("echo ran > intent-marker.txt");
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
describe("sub-agent stack wiring", () => {
|
|
247
|
+
it("includes the tool-intent middleware in every sub-agent stack", () => {
|
|
248
|
+
const stack = buildSubAgentMiddleware({});
|
|
249
|
+
expect(stack.map((m) => m.name)).toContain("StigmerToolIntentMiddleware");
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
describe("wire-contract fixture", () => {
|
|
254
|
+
it("INTENT_ARG matches the cross-surface fixture key the SDK reads", () => {
|
|
255
|
+
// The reader side (sdk/react intent-title tests) asserts against the
|
|
256
|
+
// same file, so the writer and readers cannot drift apart silently.
|
|
257
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
258
|
+
const fixture = JSON.parse(
|
|
259
|
+
readFileSync(
|
|
260
|
+
resolve(here, "../../../../../../test/fixtures/tool-view/intent-title.json"),
|
|
261
|
+
"utf8",
|
|
262
|
+
),
|
|
263
|
+
) as { argField: string };
|
|
264
|
+
expect(INTENT_ARG).toBe(fixture.argField);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
@@ -88,10 +88,11 @@ export interface ApprovalGateConfig {
|
|
|
88
88
|
* `write`/`delete` whose target path is capturable (git-tracked — see
|
|
89
89
|
* {@link isCapturablePath}) FLOWS instead of interrupting: the edit is reviewed
|
|
90
90
|
* post-hoc as a captured `FileChangeSet`, not gated before it runs. A gitignored
|
|
91
|
-
* path is NOT capturable, so it stays gated
|
|
92
|
-
*
|
|
93
|
-
* allow-branch. `shell` and MCP tools
|
|
94
|
-
* default) keeps the classic
|
|
91
|
+
* path is NOT capturable by the git substrate, so it stays gated unless
|
|
92
|
+
* {@link captureIgnored} routes it into CAS capture — the exact twin of the
|
|
93
|
+
* Cursor hook's `__stigmer_is_gitignored` allow-branch. `shell` and MCP tools
|
|
94
|
+
* are never bypassed by this flag. Off (the default) keeps the classic
|
|
95
|
+
* true-pause gate for every mutation.
|
|
95
96
|
*/
|
|
96
97
|
readonly fileCaptureMode?: boolean;
|
|
97
98
|
/**
|
|
@@ -107,12 +108,12 @@ export interface ApprovalGateConfig {
|
|
|
107
108
|
* instead of the interrupt gate, applying the DD-E secret gate. When off, a
|
|
108
109
|
* gitignored path stays on the interrupt gate exactly as before.
|
|
109
110
|
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
111
|
+
* True only for gates whose backend a CAS observer wraps: the parent gate
|
|
112
|
+
* always (setup.ts), and — since DD-19 — sub-agent gates too, because
|
|
113
|
+
* `compileSubagents` gives every sub-agent a CAS-observing backend wired to
|
|
114
|
+
* the SAME shared observer and `buildSubAgentMiddleware` then inherits this
|
|
115
|
+
* config verbatim. A gate over an UNOBSERVED backend must keep this false:
|
|
116
|
+
* flowing its gitignored edits would apply unobserved, unreviewable bytes.
|
|
116
117
|
*/
|
|
117
118
|
readonly captureIgnored?: boolean;
|
|
118
119
|
/**
|
|
@@ -122,6 +123,20 @@ export interface ApprovalGateConfig {
|
|
|
122
123
|
* secret; the CONTENT never leaves the workspace). Absent ⇒ nothing recorded.
|
|
123
124
|
*/
|
|
124
125
|
readonly recordBlockedSecret?: (rawPath: string) => void;
|
|
126
|
+
/**
|
|
127
|
+
* Capture a file's pre-delete bytes into the CAS observer so a CAS-owned
|
|
128
|
+
* (gitignored / non-git) DELETE can flow under {@link captureIgnored} and be
|
|
129
|
+
* reviewed post-hoc exactly like a write (issue #303) — the delete twin of the
|
|
130
|
+
* backend's `recordBefore` write observation. A delete is the one mutation the
|
|
131
|
+
* backend cannot observe (deepagents has no backend delete method to wrap), so
|
|
132
|
+
* the gate captures at authorization time instead: the file's bytes are still
|
|
133
|
+
* on disk here, and the turn boundary then reads after=null and authors a
|
|
134
|
+
* `FILE_CHANGE_KIND_DELETE` with the same discard-restores contract.
|
|
135
|
+
*
|
|
136
|
+
* Absent ⇒ deletes keep today's interrupt-gate behavior (fail-closed: a delete
|
|
137
|
+
* we cannot capture cannot be reviewed, so it must be approved up front).
|
|
138
|
+
*/
|
|
139
|
+
readonly captureDeleteBefore?: (rawPath: string) => Promise<void>;
|
|
125
140
|
/**
|
|
126
141
|
* Unattended approval mode (ExecutionConfig.approval_mode = UNATTENDED):
|
|
127
142
|
* the creating surface — a messaging channel, a guest share — has no
|
|
@@ -213,22 +228,39 @@ export function createApprovalGateMiddleware(
|
|
|
213
228
|
emitExecutionReceipt(config, toolCall, serverSlug, category, "auto_approve", "file_capture");
|
|
214
229
|
return await handler(request);
|
|
215
230
|
}
|
|
216
|
-
// Gitignored
|
|
217
|
-
//
|
|
218
|
-
//
|
|
231
|
+
// Gitignored / non-git path: a gate whose backend the shared CAS
|
|
232
|
+
// observer wraps (captureIgnored — the parent gate, and sub-agent
|
|
233
|
+
// gates since DD-19) routes it into CAS capture; any other gate falls
|
|
234
|
+
// through to the interrupt gate below.
|
|
219
235
|
if (config.captureIgnored) {
|
|
220
|
-
if (isSecretLikePath(path)) {
|
|
221
|
-
// DD-E fail-closed: a secret-like gitignored edit is NEVER applied
|
|
222
|
-
// and NEVER captured. Record it so the turn boundary authors a
|
|
223
|
-
// DIFF_UNREVIEWABLE entry (blocking approval); nothing is written.
|
|
224
|
-
config.recordBlockedSecret?.(path);
|
|
225
|
-
return secretBlockToolMessage(toolName, path, toolCall.id);
|
|
226
|
-
}
|
|
227
236
|
if (category === "write") {
|
|
237
|
+
if (isSecretLikePath(path)) {
|
|
238
|
+
// DD-E fail-closed: a secret-like gitignored WRITE is NEVER
|
|
239
|
+
// applied and NEVER captured — its content must not surface
|
|
240
|
+
// anywhere, approval prompt included. Record it so the turn
|
|
241
|
+
// boundary authors a DIFF_UNREVIEWABLE entry (blocking
|
|
242
|
+
// approval); nothing is written.
|
|
243
|
+
config.recordBlockedSecret?.(path);
|
|
244
|
+
return secretBlockToolMessage(toolName, path, toolCall.id);
|
|
245
|
+
}
|
|
228
246
|
// Non-secret gitignored write/edit: flows (apply-then-review). The
|
|
229
247
|
// CAS observer already holds its before-bytes and the turn boundary
|
|
230
|
-
// captures it into CAS.
|
|
231
|
-
|
|
248
|
+
// captures it into CAS.
|
|
249
|
+
emitExecutionReceipt(config, toolCall, serverSlug, category, "auto_approve", "file_capture");
|
|
250
|
+
return await handler(request);
|
|
251
|
+
}
|
|
252
|
+
if (category === "delete" && config.captureDeleteBefore && !isSecretLikePath(path)) {
|
|
253
|
+
// Non-secret CAS-owned delete: capture the before-bytes NOW (the
|
|
254
|
+
// one moment they still exist on disk — no backend delete method
|
|
255
|
+
// to observe them), then flow. The turn boundary reads after=null
|
|
256
|
+
// and authors a reviewable, restorable DELETE entry (issue #303).
|
|
257
|
+
//
|
|
258
|
+
// A SECRET-LIKE delete deliberately falls through to the interrupt
|
|
259
|
+
// gate instead: unlike a write, its args expose no secret content,
|
|
260
|
+
// so a human may safely approve it — but its before-bytes must
|
|
261
|
+
// never enter CAS, so it cannot flow. (This also aligns the twins:
|
|
262
|
+
// the Cursor hook routes secret deletes to its deny-gate.)
|
|
263
|
+
await config.captureDeleteBefore(path);
|
|
232
264
|
emitExecutionReceipt(config, toolCall, serverSlug, category, "auto_approve", "file_capture");
|
|
233
265
|
return await handler(request);
|
|
234
266
|
}
|
|
@@ -244,8 +276,10 @@ export function createApprovalGateMiddleware(
|
|
|
244
276
|
// continues) exactly like the capture-mode secret block — a secret write is
|
|
245
277
|
// never applied or persisted in ANY mode. Placed AFTER the capture block so
|
|
246
278
|
// capture-mode paths stay byte-identical (a capturable write already flowed;
|
|
247
|
-
// a captureIgnored gitignored secret is already blocked).
|
|
248
|
-
//
|
|
279
|
+
// a captureIgnored gitignored secret write is already blocked). A delete
|
|
280
|
+
// carries no content, so a secret-like delete needs no hard-block: it stays
|
|
281
|
+
// on the interrupt gate below, where a human may still approve it (its
|
|
282
|
+
// bytes are simply never captured). recordBlockedSecret is a no-op unless a
|
|
249
283
|
// turn boundary reads it (the git-no-storage case, where it authors a
|
|
250
284
|
// content-less DIFF_UNREVIEWABLE).
|
|
251
285
|
if (!serverSlug && category === "write") {
|
package/src/middleware/index.ts
CHANGED
|
@@ -9,11 +9,12 @@
|
|
|
9
9
|
* workspace-absolute paths)
|
|
10
10
|
* 1. Loop detection (always)
|
|
11
11
|
* 2. Execution budget (always)
|
|
12
|
-
* 3. Tool
|
|
13
|
-
* 4.
|
|
14
|
-
* 5.
|
|
15
|
-
* 6.
|
|
16
|
-
* 7.
|
|
12
|
+
* 3. Tool intent (always — bind-time shell schema extension, issue #276)
|
|
13
|
+
* 4. Tool truncation (always)
|
|
14
|
+
* 5. Graceful stop (always, inert until activated)
|
|
15
|
+
* 6. Cost cap (conditional: only when maxCostUsd > 0)
|
|
16
|
+
* 7. Error hints (always)
|
|
17
|
+
* 8. OTel spans (always, no-op when OTel not configured)
|
|
17
18
|
*/
|
|
18
19
|
|
|
19
20
|
import type { StigmerMiddleware, MiddlewareStackConfig } from "./types.js";
|
|
@@ -21,6 +22,7 @@ import type { GracefulStopMiddleware } from "./graceful-stop.js";
|
|
|
21
22
|
import { createPathNormalizationMiddleware } from "./path-normalization.js";
|
|
22
23
|
import { createLoopDetectionMiddleware } from "./loop-detection.js";
|
|
23
24
|
import { createExecutionBudgetMiddleware } from "./execution-budget.js";
|
|
25
|
+
import { createToolIntentMiddleware } from "./tool-intent.js";
|
|
24
26
|
import { createToolTruncationMiddleware } from "./tool-truncation.js";
|
|
25
27
|
import { createGracefulStopMiddleware } from "./graceful-stop.js";
|
|
26
28
|
import { createApprovalGateMiddleware } from "./approval-gate.js";
|
|
@@ -51,6 +53,8 @@ export function buildMiddlewareStack(
|
|
|
51
53
|
|
|
52
54
|
stack.push(createExecutionBudgetMiddleware(config.executionBudget));
|
|
53
55
|
|
|
56
|
+
stack.push(createToolIntentMiddleware());
|
|
57
|
+
|
|
54
58
|
stack.push(createToolTruncationMiddleware(config.toolTruncation));
|
|
55
59
|
|
|
56
60
|
const gracefulStop = createGracefulStopMiddleware();
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* OpenTelemetry spans and metrics middleware for deep-agent execution.
|
|
3
3
|
*
|
|
4
4
|
* Creates stigmer.llm.call and stigmer.mcp.tool_call spans with
|
|
5
|
-
* attributes matching the
|
|
5
|
+
* attributes matching the span schema inherited from the retired Go
|
|
6
|
+
* workflow-runner (kept stable so dashboards survived the migration).
|
|
6
7
|
* Also records metrics (histograms + counters) for aggregate dashboards.
|
|
7
8
|
*
|
|
8
9
|
* Graceful no-op when no TracerProvider/MeterProvider is configured.
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool-intent middleware — model-authored intent titles for shell tool calls
|
|
3
|
+
* (issue #276).
|
|
4
|
+
*
|
|
5
|
+
* The thread UI titles every shell row with the bare category label ("Shell");
|
|
6
|
+
* only the model knows *why* it is running a command, so a host-side rename
|
|
7
|
+
* can never close that gap. This middleware lets the model author the title:
|
|
8
|
+
* at model-call time it presents a schema-extended clone of the shell tool
|
|
9
|
+
* that adds one optional `description` argument. The model fills it in, the
|
|
10
|
+
* argument rides the tool call's args verbatim through checkpoints and the
|
|
11
|
+
* persisted ToolCall proto (no new state, no proto change), and the React SDK
|
|
12
|
+
* renders it as the row title with the command as secondary text.
|
|
13
|
+
*
|
|
14
|
+
* Why this seam:
|
|
15
|
+
* - The `execute` tool is owned by the deepagents library; its schema is not
|
|
16
|
+
* ours to edit. `wrapModelCall` is the framework's intended point for
|
|
17
|
+
* reshaping the model-visible tool list — langchain's own llmToolSelector
|
|
18
|
+
* middleware swaps `request.tools` through exactly this hook.
|
|
19
|
+
* - Execution is untouched by construction: the agent's ToolNode is built
|
|
20
|
+
* once from the ORIGINAL tools, and the original schema parses with strip
|
|
21
|
+
* semantics, so the extra argument is dropped before the backend's
|
|
22
|
+
* `execute(command)` ever runs. Approval fingerprints are equally
|
|
23
|
+
* unaffected (`description` is not a salient arg field).
|
|
24
|
+
* - The argument name deliberately matches the Cursor harness, whose built-in
|
|
25
|
+
* Shell tool already carries a model-authored `description` — both
|
|
26
|
+
* harnesses converge on one wire key and the SDK reads a single field.
|
|
27
|
+
*
|
|
28
|
+
* The swapped-in declaration is a `StructuredToolParams` object — langchain's
|
|
29
|
+
* first-class shape for a non-executable, bind-time-only tool definition
|
|
30
|
+
* ("the most minimal interface … to be passed to a LLM for tool calling").
|
|
31
|
+
* A same-name RUNNABLE replacement is rejected by the agent's wrapModelCall
|
|
32
|
+
* validation (it would threaten ToolNode execution identity); a params
|
|
33
|
+
* object is exactly the declaration-without-execution the validation exists
|
|
34
|
+
* to protect, and the graph keeps executing the untouched original. Schema
|
|
35
|
+
* extension happens at the JSON-schema level via @langchain/core's interop
|
|
36
|
+
* serializer — the runner's zod (v3) must never construct fields inside the
|
|
37
|
+
* library's zod (v4) schema object.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { toJsonSchema } from "@langchain/core/utils/json_schema";
|
|
41
|
+
import { ToolKind } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
42
|
+
import { classifyTool } from "../shared/tool-kind.js";
|
|
43
|
+
import type { StigmerMiddleware } from "./types.js";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The wire name of the intent argument. Shared with the Cursor harness's
|
|
47
|
+
* built-in Shell tool and read by the SDK's tool presentation layer — the
|
|
48
|
+
* three surfaces must agree on this key.
|
|
49
|
+
*/
|
|
50
|
+
export const INTENT_ARG = "description";
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The behavior-shaping prompt for the intent argument (owner-approved
|
|
54
|
+
* wording, issue #276). This is prompt engineering, not documentation:
|
|
55
|
+
* changing it changes what the model writes into every shell row title.
|
|
56
|
+
*/
|
|
57
|
+
export const INTENT_ARG_PROMPT =
|
|
58
|
+
"A short present-tense phrase describing what this command does and why, " +
|
|
59
|
+
"shown to the user as the title of this action (5-10 words, e.g. " +
|
|
60
|
+
"'Run unit tests for the parser'). Do not restate the command syntax.";
|
|
61
|
+
|
|
62
|
+
/** Structural shape of a bindable structured tool, checked at runtime. */
|
|
63
|
+
interface StructuredToolLike {
|
|
64
|
+
readonly name: string;
|
|
65
|
+
readonly description: string;
|
|
66
|
+
readonly schema: unknown;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isStructuredToolLike(candidate: unknown): candidate is StructuredToolLike {
|
|
70
|
+
if (candidate == null || typeof candidate !== "object") return false;
|
|
71
|
+
const t = candidate as Record<string, unknown>;
|
|
72
|
+
return (
|
|
73
|
+
typeof t.name === "string" &&
|
|
74
|
+
typeof t.description === "string" &&
|
|
75
|
+
"schema" in t
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface JsonObjectSchema {
|
|
80
|
+
readonly type: "object";
|
|
81
|
+
readonly properties?: Record<string, unknown>;
|
|
82
|
+
readonly [key: string]: unknown;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function isJsonObjectSchema(schema: unknown): schema is JsonObjectSchema {
|
|
86
|
+
return (
|
|
87
|
+
schema != null &&
|
|
88
|
+
typeof schema === "object" &&
|
|
89
|
+
(schema as Record<string, unknown>).type === "object"
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Returns a bind-time `StructuredToolParams` declaration for `original`
|
|
95
|
+
* whose schema carries the optional intent argument, or `original` itself
|
|
96
|
+
* when the tool is not a shell tool, is not schema-extendable, or already
|
|
97
|
+
* defines an argument with that name (a real argument must never be
|
|
98
|
+
* shadowed by presentation metadata).
|
|
99
|
+
*
|
|
100
|
+
* Unexpected schemas pass through unchanged on purpose: a missing intent
|
|
101
|
+
* title degrades to today's rendering, while a mangled schema would break
|
|
102
|
+
* the tool for the whole execution.
|
|
103
|
+
*/
|
|
104
|
+
function maybeExtendShellTool(original: unknown): unknown {
|
|
105
|
+
if (!isStructuredToolLike(original)) return original;
|
|
106
|
+
if (classifyTool(original.name) !== ToolKind.SHELL) return original;
|
|
107
|
+
|
|
108
|
+
let jsonSchema: unknown;
|
|
109
|
+
try {
|
|
110
|
+
jsonSchema = toJsonSchema(original.schema as Parameters<typeof toJsonSchema>[0]);
|
|
111
|
+
} catch {
|
|
112
|
+
return original;
|
|
113
|
+
}
|
|
114
|
+
if (!isJsonObjectSchema(jsonSchema)) return original;
|
|
115
|
+
|
|
116
|
+
const properties = jsonSchema.properties ?? {};
|
|
117
|
+
if (INTENT_ARG in properties) return original;
|
|
118
|
+
|
|
119
|
+
// A plain frozen declaration, deliberately NOT an executable tool: the
|
|
120
|
+
// graph's ToolNode executes the ORIGINAL registered tool (it is built from
|
|
121
|
+
// the registered tools, not from the model request), and the agent's
|
|
122
|
+
// wrapModelCall validation only forbids swapping same-name EXECUTABLE
|
|
123
|
+
// instances. `isStructuredToolParams` recognizes this shape, so every
|
|
124
|
+
// provider's bindTools converts it exactly like a structured tool.
|
|
125
|
+
return Object.freeze({
|
|
126
|
+
name: original.name,
|
|
127
|
+
description: original.description,
|
|
128
|
+
schema: {
|
|
129
|
+
...jsonSchema,
|
|
130
|
+
properties: {
|
|
131
|
+
...properties,
|
|
132
|
+
[INTENT_ARG]: { type: "string", description: INTENT_ARG_PROMPT },
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Creates the middleware. Install on the parent stack AND on every sub-agent
|
|
140
|
+
* stack (subagent-wiring.ts) — sub-agent shell rows render in the same
|
|
141
|
+
* thread and must carry the same titles.
|
|
142
|
+
*/
|
|
143
|
+
export function createToolIntentMiddleware(): StigmerMiddleware {
|
|
144
|
+
// One clone per original tool instance: repeated model calls (and repeated
|
|
145
|
+
// turns on the same graph) bind a referentially stable clone instead of
|
|
146
|
+
// re-serializing the schema every round.
|
|
147
|
+
const cloneCache = new WeakMap<object, unknown>();
|
|
148
|
+
|
|
149
|
+
const extendCached = (candidate: unknown): unknown => {
|
|
150
|
+
if (candidate == null || typeof candidate !== "object") return candidate;
|
|
151
|
+
const cached = cloneCache.get(candidate);
|
|
152
|
+
if (cached !== undefined) return cached;
|
|
153
|
+
const extended = maybeExtendShellTool(candidate);
|
|
154
|
+
cloneCache.set(candidate, extended);
|
|
155
|
+
return extended;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
name: "StigmerToolIntentMiddleware",
|
|
160
|
+
async wrapModelCall(request, handler) {
|
|
161
|
+
const tools = request.tools;
|
|
162
|
+
if (!tools || tools.length === 0) return handler(request);
|
|
163
|
+
|
|
164
|
+
let changed = false;
|
|
165
|
+
const mapped = tools.map((t) => {
|
|
166
|
+
const extended = extendCached(t);
|
|
167
|
+
if (extended !== t) changed = true;
|
|
168
|
+
return extended;
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
return handler(changed ? { ...request, tools: mapped } : request);
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
package/src/runner-manager.ts
CHANGED
|
@@ -703,6 +703,7 @@ async function createAllActivities(config: Config): Promise<WorkerActivities> {
|
|
|
703
703
|
{ createCursorActivities },
|
|
704
704
|
{ createDeepAgentActivities },
|
|
705
705
|
{ createEnsureThreadActivities },
|
|
706
|
+
{ createGenerateSessionSubjectActivities },
|
|
706
707
|
{ createClassifyToolApprovalsActivities },
|
|
707
708
|
{ createDiscoverMcpServerActivities },
|
|
708
709
|
{ createEvaluateExpressionsActivities },
|
|
@@ -721,6 +722,7 @@ async function createAllActivities(config: Config): Promise<WorkerActivities> {
|
|
|
721
722
|
import("./activities/execute-cursor/index.js"),
|
|
722
723
|
import("./activities/execute-deep-agent/index.js"),
|
|
723
724
|
import("./activities/ensure-thread.js"),
|
|
725
|
+
import("./activities/generate-session-subject.js"),
|
|
724
726
|
import("./activities/classify-tool-approvals.js"),
|
|
725
727
|
import("./activities/discover-mcp-server.js"),
|
|
726
728
|
import("./activities/evaluate-expressions.js"),
|
|
@@ -741,6 +743,7 @@ async function createAllActivities(config: Config): Promise<WorkerActivities> {
|
|
|
741
743
|
...createCursorActivities(config),
|
|
742
744
|
...createDeepAgentActivities(config),
|
|
743
745
|
...createEnsureThreadActivities(),
|
|
746
|
+
...createGenerateSessionSubjectActivities(config),
|
|
744
747
|
...createClassifyToolApprovalsActivities(config),
|
|
745
748
|
...createDiscoverMcpServerActivities(config),
|
|
746
749
|
...createEvaluateExpressionsActivities(),
|
package/src/runner.ts
CHANGED
|
@@ -407,6 +407,7 @@ async function createAllActivities(config: Config): Promise<WorkerActivities> {
|
|
|
407
407
|
{ createCursorActivities },
|
|
408
408
|
{ createDeepAgentActivities },
|
|
409
409
|
{ createEnsureThreadActivities },
|
|
410
|
+
{ createGenerateSessionSubjectActivities },
|
|
410
411
|
{ createClassifyToolApprovalsActivities },
|
|
411
412
|
{ createDiscoverMcpServerActivities },
|
|
412
413
|
{ createEvaluateExpressionsActivities },
|
|
@@ -425,6 +426,7 @@ async function createAllActivities(config: Config): Promise<WorkerActivities> {
|
|
|
425
426
|
import("./activities/execute-cursor/index.js"),
|
|
426
427
|
import("./activities/execute-deep-agent/index.js"),
|
|
427
428
|
import("./activities/ensure-thread.js"),
|
|
429
|
+
import("./activities/generate-session-subject.js"),
|
|
428
430
|
import("./activities/classify-tool-approvals.js"),
|
|
429
431
|
import("./activities/discover-mcp-server.js"),
|
|
430
432
|
import("./activities/evaluate-expressions.js"),
|
|
@@ -445,6 +447,7 @@ async function createAllActivities(config: Config): Promise<WorkerActivities> {
|
|
|
445
447
|
...createCursorActivities(config),
|
|
446
448
|
...createDeepAgentActivities(config),
|
|
447
449
|
...createEnsureThreadActivities(),
|
|
450
|
+
...createGenerateSessionSubjectActivities(config),
|
|
448
451
|
...createClassifyToolApprovalsActivities(config),
|
|
449
452
|
...createDiscoverMcpServerActivities(config),
|
|
450
453
|
...createEvaluateExpressionsActivities(),
|
|
@@ -291,6 +291,10 @@ describe("VisionBudget — size and count budgets", () => {
|
|
|
291
291
|
});
|
|
292
292
|
|
|
293
293
|
it("ships the production constants agreed in T04 (raw bytes)", () => {
|
|
294
|
+
// Also the ADVERTISED == ENFORCED drift alarm (stigmer/stigmer#365):
|
|
295
|
+
// the registry document advertises these exact values in its
|
|
296
|
+
// `limits.vision` block, pinned by the cloud codec's own test. If this
|
|
297
|
+
// fails, the budget changed on one side only — update both together.
|
|
294
298
|
expect(MAX_VISION_IMAGE_BYTES).toBe(3 * 1024 * 1024);
|
|
295
299
|
expect(MAX_VISION_TOTAL_BYTES).toBe(4 * 1024 * 1024);
|
|
296
300
|
expect(MAX_VISION_IMAGES).toBe(10);
|