@stigmer/runner 3.12.2 → 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 (29) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/execute-cursor/skill-resolver.d.ts +15 -0
  3. package/dist/activities/execute-cursor/skill-resolver.js +44 -6
  4. package/dist/activities/execute-cursor/skill-resolver.js.map +1 -1
  5. package/dist/activities/execute-deep-agent/__test-utils__/scripted-model.d.ts +9 -1
  6. package/dist/activities/execute-deep-agent/__test-utils__/scripted-model.js +13 -2
  7. package/dist/activities/execute-deep-agent/__test-utils__/scripted-model.js.map +1 -1
  8. package/dist/activities/execute-deep-agent/subagent-wiring.d.ts +5 -1
  9. package/dist/activities/execute-deep-agent/subagent-wiring.js +9 -1
  10. package/dist/activities/execute-deep-agent/subagent-wiring.js.map +1 -1
  11. package/dist/client/stigmer-client.d.ts +9 -1
  12. package/dist/client/stigmer-client.js +10 -0
  13. package/dist/client/stigmer-client.js.map +1 -1
  14. package/dist/middleware/index.d.ts +6 -5
  15. package/dist/middleware/index.js +8 -5
  16. package/dist/middleware/index.js.map +1 -1
  17. package/dist/middleware/tool-intent.d.ts +57 -0
  18. package/dist/middleware/tool-intent.js +152 -0
  19. package/dist/middleware/tool-intent.js.map +1 -0
  20. package/package.json +2 -2
  21. package/src/activities/execute-cursor/__tests__/skill-resolver.test.ts +104 -1
  22. package/src/activities/execute-cursor/skill-resolver.ts +51 -7
  23. package/src/activities/execute-deep-agent/__test-utils__/scripted-model.ts +13 -2
  24. package/src/activities/execute-deep-agent/__tests__/subagent-wiring.test.ts +21 -20
  25. package/src/activities/execute-deep-agent/subagent-wiring.ts +10 -1
  26. package/src/client/stigmer-client.ts +12 -1
  27. package/src/middleware/__tests__/tool-intent.test.ts +266 -0
  28. package/src/middleware/index.ts +9 -5
  29. package/src/middleware/tool-intent.ts +174 -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
+ });
@@ -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();
@@ -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
+ }