@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.
Files changed (77) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/execute-cursor/capture-flow.d.ts +5 -4
  3. package/dist/activities/execute-cursor/capture-flow.js +5 -4
  4. package/dist/activities/execute-cursor/capture-flow.js.map +1 -1
  5. package/dist/activities/execute-cursor/cas-observations.d.ts +10 -4
  6. package/dist/activities/execute-cursor/cas-observations.js +10 -4
  7. package/dist/activities/execute-cursor/cas-observations.js.map +1 -1
  8. package/dist/activities/execute-cursor/hook-script.js +39 -13
  9. package/dist/activities/execute-cursor/hook-script.js.map +1 -1
  10. package/dist/activities/execute-cursor/skill-resolver.d.ts +23 -0
  11. package/dist/activities/execute-cursor/skill-resolver.js +121 -32
  12. package/dist/activities/execute-cursor/skill-resolver.js.map +1 -1
  13. package/dist/activities/execute-deep-agent/__test-utils__/scripted-model.d.ts +9 -1
  14. package/dist/activities/execute-deep-agent/__test-utils__/scripted-model.js +13 -2
  15. package/dist/activities/execute-deep-agent/__test-utils__/scripted-model.js.map +1 -1
  16. package/dist/activities/execute-deep-agent/cas-capture-backend.d.ts +3 -1
  17. package/dist/activities/execute-deep-agent/cas-capture-backend.js +3 -1
  18. package/dist/activities/execute-deep-agent/cas-capture-backend.js.map +1 -1
  19. package/dist/activities/execute-deep-agent/setup.js +8 -0
  20. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  21. package/dist/activities/execute-deep-agent/subagent-wiring.d.ts +5 -1
  22. package/dist/activities/execute-deep-agent/subagent-wiring.js +25 -10
  23. package/dist/activities/execute-deep-agent/subagent-wiring.js.map +1 -1
  24. package/dist/activities/generate-session-subject.d.ts +80 -0
  25. package/dist/activities/generate-session-subject.js +283 -0
  26. package/dist/activities/generate-session-subject.js.map +1 -0
  27. package/dist/client/stigmer-client.d.ts +17 -1
  28. package/dist/client/stigmer-client.js +20 -0
  29. package/dist/client/stigmer-client.js.map +1 -1
  30. package/dist/middleware/approval-gate.d.ts +25 -10
  31. package/dist/middleware/approval-gate.js +33 -14
  32. package/dist/middleware/approval-gate.js.map +1 -1
  33. package/dist/middleware/index.d.ts +6 -5
  34. package/dist/middleware/index.js +8 -5
  35. package/dist/middleware/index.js.map +1 -1
  36. package/dist/middleware/otel-spans.d.ts +2 -1
  37. package/dist/middleware/otel-spans.js +2 -1
  38. package/dist/middleware/otel-spans.js.map +1 -1
  39. package/dist/middleware/tool-intent.d.ts +57 -0
  40. package/dist/middleware/tool-intent.js +152 -0
  41. package/dist/middleware/tool-intent.js.map +1 -0
  42. package/dist/runner-manager.js +3 -1
  43. package/dist/runner-manager.js.map +1 -1
  44. package/dist/runner.js +3 -1
  45. package/dist/runner.js.map +1 -1
  46. package/dist/shared/attachment-vision.js +9 -0
  47. package/dist/shared/attachment-vision.js.map +1 -1
  48. package/dist/shared/mcp-manager.js +8 -0
  49. package/dist/shared/mcp-manager.js.map +1 -1
  50. package/package.json +2 -2
  51. package/src/activities/__tests__/generate-session-subject.test.ts +348 -0
  52. package/src/activities/execute-cursor/__tests__/hook-script.test.ts +60 -9
  53. package/src/activities/execute-cursor/__tests__/skill-resolver.test.ts +267 -8
  54. package/src/activities/execute-cursor/capture-flow.ts +5 -4
  55. package/src/activities/execute-cursor/cas-observations.ts +10 -4
  56. package/src/activities/execute-cursor/hook-script.ts +39 -13
  57. package/src/activities/execute-cursor/skill-resolver.ts +148 -37
  58. package/src/activities/execute-deep-agent/__test-utils__/scripted-model.ts +13 -2
  59. package/src/activities/execute-deep-agent/__tests__/subagent-wiring.test.ts +21 -20
  60. package/src/activities/execute-deep-agent/cas-capture-backend.ts +3 -1
  61. package/src/activities/execute-deep-agent/setup.ts +8 -0
  62. package/src/activities/execute-deep-agent/subagent-wiring.ts +26 -10
  63. package/src/activities/generate-session-subject.ts +370 -0
  64. package/src/client/stigmer-client.ts +23 -1
  65. package/src/middleware/__tests__/approval-gate.test.ts +130 -1
  66. package/src/middleware/__tests__/tool-intent.test.ts +266 -0
  67. package/src/middleware/approval-gate.ts +58 -24
  68. package/src/middleware/index.ts +9 -5
  69. package/src/middleware/otel-spans.ts +2 -1
  70. package/src/middleware/tool-intent.ts +174 -0
  71. package/src/runner-manager.ts +3 -0
  72. package/src/runner.ts +3 -0
  73. package/src/shared/__tests__/attachment-vision.test.ts +4 -0
  74. package/src/shared/__tests__/mcp-manager.test.ts +7 -2
  75. package/src/shared/attachment-vision.ts +9 -0
  76. package/src/shared/filereview/__tests__/capture.test.ts +48 -0
  77. 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 (the git substrate cannot capture or
92
- * revert it) — the exact twin of the Cursor hook's `__stigmer_is_gitignored`
93
- * allow-branch. `shell` and MCP tools are never bypassed by this flag. Off (the
94
- * default) keeps the classic true-pause gate for every mutation.
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
- * TRUE ONLY FOR THE PARENT GATE. Sub-agents build their own plain filesystem
111
- * backends, which the CAS observer does not wrap, so flowing their gitignored
112
- * edits would apply unobserved, unreviewable bytes. `buildSubAgentMiddleware`
113
- * therefore forces this to false for sub-agent gates — it must NOT be inherited
114
- * as true. (Sub-agent git-tracked edits are still captured by the turn-boundary
115
- * git diff, which is backend-agnostic.)
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 path. Only the PARENT gate (captureIgnored) routes it into
217
- // CAS capture; sub-agent gates fall through to the interrupt gate below,
218
- // exactly as before (their backends are not CAS-observed).
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. (A gitignored delete has no backend capture
231
- // path, so it falls through to the interrupt gate.)
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). Deletes carry no
248
- // content and stay on the deny-gate. recordBlockedSecret is a no-op unless a
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") {
@@ -9,11 +9,12 @@
9
9
  * workspace-absolute paths)
10
10
  * 1. Loop detection (always)
11
11
  * 2. Execution budget (always)
12
- * 3. Tool truncation (always)
13
- * 4. Graceful stop (always, inert until activated)
14
- * 5. Cost cap (conditional: only when maxCostUsd > 0)
15
- * 6. Error hints (always)
16
- * 7. OTel spans (always, no-op when OTel not configured)
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 Go workflow-runner schema (pkg/otel/spans.go).
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
+ }
@@ -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);