@stigmer/runner 3.12.2 → 3.12.4

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 (125) hide show
  1. package/dist/.build-fingerprint +1 -1
  2. package/dist/activities/call-http.js +12 -0
  3. package/dist/activities/call-http.js.map +1 -1
  4. package/dist/activities/call-llm.d.ts +18 -0
  5. package/dist/activities/call-llm.js +56 -2
  6. package/dist/activities/call-llm.js.map +1 -1
  7. package/dist/activities/execute-cursor/agent-session-cache.d.ts +72 -0
  8. package/dist/activities/execute-cursor/agent-session-cache.js +186 -0
  9. package/dist/activities/execute-cursor/agent-session-cache.js.map +1 -0
  10. package/dist/activities/execute-cursor/index.js +61 -28
  11. package/dist/activities/execute-cursor/index.js.map +1 -1
  12. package/dist/activities/execute-cursor/service-tier.d.ts +5 -15
  13. package/dist/activities/execute-cursor/service-tier.js +5 -21
  14. package/dist/activities/execute-cursor/service-tier.js.map +1 -1
  15. package/dist/activities/execute-cursor/skill-resolver.d.ts +15 -0
  16. package/dist/activities/execute-cursor/skill-resolver.js +45 -7
  17. package/dist/activities/execute-cursor/skill-resolver.js.map +1 -1
  18. package/dist/activities/execute-deep-agent/__test-utils__/scripted-model.d.ts +9 -1
  19. package/dist/activities/execute-deep-agent/__test-utils__/scripted-model.js +13 -2
  20. package/dist/activities/execute-deep-agent/__test-utils__/scripted-model.js.map +1 -1
  21. package/dist/activities/execute-deep-agent/setup.js +14 -0
  22. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  23. package/dist/activities/execute-deep-agent/shell-env.d.ts +8 -5
  24. package/dist/activities/execute-deep-agent/shell-env.js +10 -7
  25. package/dist/activities/execute-deep-agent/shell-env.js.map +1 -1
  26. package/dist/activities/execute-deep-agent/subagent-wiring.d.ts +5 -1
  27. package/dist/activities/execute-deep-agent/subagent-wiring.js +9 -1
  28. package/dist/activities/execute-deep-agent/subagent-wiring.js.map +1 -1
  29. package/dist/client/stigmer-client.d.ts +9 -1
  30. package/dist/client/stigmer-client.js +10 -0
  31. package/dist/client/stigmer-client.js.map +1 -1
  32. package/dist/config.js +10 -5
  33. package/dist/config.js.map +1 -1
  34. package/dist/encryption/config.js +7 -2
  35. package/dist/encryption/config.js.map +1 -1
  36. package/dist/main.js +12 -6
  37. package/dist/main.js.map +1 -1
  38. package/dist/middleware/index.d.ts +6 -5
  39. package/dist/middleware/index.js +8 -5
  40. package/dist/middleware/index.js.map +1 -1
  41. package/dist/middleware/tool-intent.d.ts +57 -0
  42. package/dist/middleware/tool-intent.js +152 -0
  43. package/dist/middleware/tool-intent.js.map +1 -0
  44. package/dist/payload-codecs.js +2 -1
  45. package/dist/payload-codecs.js.map +1 -1
  46. package/dist/runner-manager.js +20 -7
  47. package/dist/runner-manager.js.map +1 -1
  48. package/dist/runner.js +19 -6
  49. package/dist/runner.js.map +1 -1
  50. package/dist/shared/fingerprint-secret.d.ts +3 -2
  51. package/dist/shared/fingerprint-secret.js +5 -3
  52. package/dist/shared/fingerprint-secret.js.map +1 -1
  53. package/dist/shared/llm-backend.js +8 -1
  54. package/dist/shared/llm-backend.js.map +1 -1
  55. package/dist/shared/model-client.d.ts +15 -0
  56. package/dist/shared/model-client.js +57 -13
  57. package/dist/shared/model-client.js.map +1 -1
  58. package/dist/shared/registry-endpoint.d.ts +5 -0
  59. package/dist/shared/registry-endpoint.js +7 -1
  60. package/dist/shared/registry-endpoint.js.map +1 -1
  61. package/dist/shared/runner-credential-keys.d.ts +26 -1
  62. package/dist/shared/runner-credential-keys.js +34 -1
  63. package/dist/shared/runner-credential-keys.js.map +1 -1
  64. package/dist/shared/runner-credential-store.d.ts +77 -0
  65. package/dist/shared/runner-credential-store.js +111 -0
  66. package/dist/shared/runner-credential-store.js.map +1 -0
  67. package/dist/shared/service-tier.d.ts +55 -0
  68. package/dist/shared/service-tier.js +67 -0
  69. package/dist/shared/service-tier.js.map +1 -0
  70. package/dist/shared/skill-writer.js +2 -2
  71. package/dist/shared/skill-writer.js.map +1 -1
  72. package/dist/shared/zip-extract.d.ts +10 -3
  73. package/dist/shared/zip-extract.js +10 -3
  74. package/dist/shared/zip-extract.js.map +1 -1
  75. package/dist/workflow-engine/tasks/call-function.d.ts +14 -0
  76. package/dist/workflow-engine/tasks/call-function.js +49 -5
  77. package/dist/workflow-engine/tasks/call-function.js.map +1 -1
  78. package/dist/workflow-engine/types.d.ts +6 -0
  79. package/dist/workflow-engine/types.js.map +1 -1
  80. package/dist/workflows/engine-core.js +36 -8
  81. package/dist/workflows/engine-core.js.map +1 -1
  82. package/package.json +2 -2
  83. package/src/activities/__tests__/call-http.test.ts +36 -0
  84. package/src/activities/__tests__/call-llm.test.ts +77 -0
  85. package/src/activities/call-http.ts +17 -0
  86. package/src/activities/call-llm.ts +78 -2
  87. package/src/activities/execute-cursor/__tests__/agent-session-cache.test.ts +220 -0
  88. package/src/activities/execute-cursor/__tests__/service-tier.test.ts +1 -1
  89. package/src/activities/execute-cursor/__tests__/skill-resolver.test.ts +104 -1
  90. package/src/activities/execute-cursor/agent-session-cache.ts +229 -0
  91. package/src/activities/execute-cursor/index.ts +66 -20
  92. package/src/activities/execute-cursor/service-tier.ts +5 -29
  93. package/src/activities/execute-cursor/skill-resolver.ts +52 -8
  94. package/src/activities/execute-deep-agent/__test-utils__/scripted-model.ts +13 -2
  95. package/src/activities/execute-deep-agent/__tests__/subagent-wiring.test.ts +21 -20
  96. package/src/activities/execute-deep-agent/setup.ts +15 -0
  97. package/src/activities/execute-deep-agent/shell-env.ts +10 -7
  98. package/src/activities/execute-deep-agent/subagent-wiring.ts +10 -1
  99. package/src/client/stigmer-client.ts +12 -1
  100. package/src/config.ts +10 -5
  101. package/src/encryption/config.ts +8 -2
  102. package/src/main.ts +16 -6
  103. package/src/middleware/__tests__/tool-intent.test.ts +266 -0
  104. package/src/middleware/index.ts +9 -5
  105. package/src/middleware/tool-intent.ts +174 -0
  106. package/src/payload-codecs.ts +2 -1
  107. package/src/runner-manager.ts +29 -6
  108. package/src/runner.ts +25 -6
  109. package/src/shared/__tests__/model-client.test.ts +99 -0
  110. package/src/shared/__tests__/runner-credential-store.test.ts +155 -0
  111. package/src/shared/__tests__/zip-extract.test.ts +46 -11
  112. package/src/shared/fingerprint-secret.ts +5 -3
  113. package/src/shared/llm-backend.ts +7 -1
  114. package/src/shared/model-client.ts +76 -13
  115. package/src/shared/registry-endpoint.ts +9 -1
  116. package/src/shared/runner-credential-keys.ts +36 -1
  117. package/src/shared/runner-credential-store.ts +115 -0
  118. package/src/shared/service-tier.ts +78 -0
  119. package/src/shared/skill-writer.ts +2 -2
  120. package/src/shared/zip-extract.ts +14 -7
  121. package/src/workflow-engine/__tests__/golden-execution.test.ts +20 -1
  122. package/src/workflow-engine/__tests__/tasks/call-function.test.ts +105 -0
  123. package/src/workflow-engine/tasks/call-function.ts +74 -13
  124. package/src/workflow-engine/types.ts +6 -0
  125. package/src/workflows/engine-core.ts +39 -8
@@ -1,19 +1,22 @@
1
1
  /**
2
2
  * Shell environment for the native harness `execute` tool.
3
3
  *
4
- * Per-execution snapshot: runner-manager rotates `STIGMER_TOKEN` in
5
- * `process.env` at runtime, so this must run inside setup for each execution,
6
- * never once at process start.
4
+ * Since #508's boot capture, runner secrets never LIVE in `process.env`, so
5
+ * the denylist below is defense-in-depth: it keeps this surface safe even if
6
+ * something re-plants a secret in the environment after boot (a test, an
7
+ * embedder, a future regression). Still built per execution — the snapshot
8
+ * must reflect the env as it is now, not at process start.
7
9
  */
8
10
 
9
- import { RUNNER_CREDENTIAL_ENV_KEYS } from "../../shared/runner-credential-keys.js";
11
+ import { RUNNER_SECRET_ENV_KEYS } from "../../shared/runner-credential-keys.js";
10
12
 
11
13
  /**
12
14
  * Runner-internal keys that must never reach agent shell commands: every
13
- * credential the runner holds for its own outbound calls (issue #385). The
14
- * names and the rule for adding onelive in runner-credential-keys.ts.
15
+ * credential the runner holds for its own outbound calls (issue #385) plus
16
+ * the runner's encryption keys (issue #508). The namesand the rules for
17
+ * adding one — live in runner-credential-keys.ts.
15
18
  */
16
- export const SHELL_ENV_DENYLIST: readonly string[] = RUNNER_CREDENTIAL_ENV_KEYS;
19
+ export const SHELL_ENV_DENYLIST: readonly string[] = RUNNER_SECRET_ENV_KEYS;
17
20
 
18
21
  /**
19
22
  * Build the environment map passed to deepagents' LocalShellBackend.
@@ -9,6 +9,9 @@
9
9
  * same `permissions` option it bakes into the graph, keeping the rules
10
10
  * and their normalization shim coupled.
11
11
  * - Fresh loop detection (independent cycle tracking)
12
+ * - Tool intent (issue #276) — the shell tool's bind-time schema gains the
13
+ * optional model-authored `description`, so sub-agent shell rows carry
14
+ * intent titles exactly like the parent's
12
15
  * - Fresh tool truncation (same limits as parent)
13
16
  * - Periodic execution budget (interval=30, max=4 advisories)
14
17
  * - Approval gate (so a mutating tool *inside* a sub-agent is gated, not
@@ -43,6 +46,7 @@ import { createPathNormalizationMiddleware } from "../../middleware/path-normali
43
46
  import { createLoopDetectionMiddleware } from "../../middleware/loop-detection.js";
44
47
  import { createToolTruncationMiddleware } from "../../middleware/tool-truncation.js";
45
48
  import { createExecutionBudgetMiddleware } from "../../middleware/execution-budget.js";
49
+ import { createToolIntentMiddleware } from "../../middleware/tool-intent.js";
46
50
  import {
47
51
  createApprovalGateMiddleware,
48
52
  type ApprovalGateConfig,
@@ -83,7 +87,8 @@ export interface SubAgentMiddlewareOptions {
83
87
  *
84
88
  * Returns an ordered array mirroring the parent composition:
85
89
  * [path normalization] → loop detection → execution budget (periodic) →
86
- * tool truncation → [approval gate] → cost cap view → error hints.
90
+ * tool intent → tool truncation → [approval gate] → cost cap view →
91
+ * error hints.
87
92
  * Normalization is outermost so everything downstream observes canonical
88
93
  * workspace-absolute paths (matching the parent). The gate sits before the
89
94
  * cost-cap view so an approval pause happens before budget accounting, and
@@ -107,6 +112,10 @@ export function buildSubAgentMiddleware(
107
112
  maxWarnings: SUB_AGENT_MAX_ADVISORIES,
108
113
  }));
109
114
 
115
+ // Sub-agent shell rows render in the same thread as the parent's and must
116
+ // carry the same model-authored intent titles (issue #276).
117
+ stack.push(createToolIntentMiddleware());
118
+
110
119
  stack.push(createToolTruncationMiddleware(options.toolTruncation));
111
120
 
112
121
  if (options.approvalGate) {
@@ -35,7 +35,7 @@ import type { AgentInstance } from "@stigmer/protos/ai/stigmer/agentic/agentinst
35
35
  import type { McpServer } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/api_pb";
36
36
  import type { Skill } from "@stigmer/protos/ai/stigmer/agentic/skill/v1/api_pb";
37
37
  import type { ApiResourceReference } from "@stigmer/protos/ai/stigmer/commons/apiresource/io_pb";
38
- import type { GetArtifactResponse } from "@stigmer/protos/ai/stigmer/agentic/skill/v1/io_pb";
38
+ import type { GetArtifactResponse, SkillArtifactDownloadUrl } from "@stigmer/protos/ai/stigmer/agentic/skill/v1/io_pb";
39
39
  import { create } from "@bufbuild/protobuf";
40
40
  import { ConnectInputSchema } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/io_pb";
41
41
  import { ExecutionValueSchema } from "@stigmer/protos/ai/stigmer/agentic/executioncontext/v1/spec_pb";
@@ -583,6 +583,17 @@ export class StigmerClient {
583
583
  return this.skillQuery.getArtifact({ artifactStorageKey });
584
584
  }
585
585
 
586
+ /**
587
+ * Mint an HTTP download URL for a skill artifact (#675). Preferred over
588
+ * getSkillArtifact for the actual bytes: the unary response is capped by
589
+ * the server's 10MB gRPC message limit, while skills may be 100MB.
590
+ * Throws ConnectError with Code.Unimplemented against servers that
591
+ * predate the transfer lane — callers fall back to getSkillArtifact.
592
+ */
593
+ async getSkillArtifactDownloadUrl(artifactStorageKey: string): Promise<SkillArtifactDownloadUrl> {
594
+ return this.skillQuery.getArtifactDownloadUrl({ artifactStorageKey });
595
+ }
596
+
586
597
  async createArtifact(input: CreateArtifactInput): Promise<Artifact> {
587
598
  return this.artifactCommand.create(input);
588
599
  }
package/src/config.ts CHANGED
@@ -53,6 +53,7 @@ export const DEFAULT_CURSOR_AGENT_RESOLVE_TIMEOUT_MS = 120_000;
53
53
  // three construction sites — mirrors DEFAULT_CURSOR_STREAM_STALL_TIMEOUT_MS).
54
54
  export { DEFAULT_WORKSPACE_LOCK_TIMEOUT_MS } from "./shared/workspace/workspace-lock.js";
55
55
  import { DEFAULT_WORKSPACE_LOCK_TIMEOUT_MS } from "./shared/workspace/workspace-lock.js";
56
+ import { getRunnerSecret } from "./shared/runner-credential-store.js";
56
57
 
57
58
  export interface Config {
58
59
  readonly taskQueue: string;
@@ -158,9 +159,13 @@ export function loadConfig(): Config {
158
159
  : requireEnv("STIGMER_BACKEND_ENDPOINT"),
159
160
  );
160
161
 
161
- const stigmerToken = (mode === "cloud" || proxyActive)
162
- ? requireEnv("STIGMER_TOKEN")
163
- : (process.env.STIGMER_TOKEN ?? null);
162
+ // Secrets resolve through the credential store, not process.env — the
163
+ // boot capture has already moved them out of the environment (#508).
164
+ const stigmerTokenValue = getRunnerSecret("STIGMER_TOKEN");
165
+ if ((mode === "cloud" || proxyActive) && !stigmerTokenValue) {
166
+ throw new Error("Required environment variable STIGMER_TOKEN is not set");
167
+ }
168
+ const stigmerToken = stigmerTokenValue ?? null;
164
169
 
165
170
  const mcpBridgeEndpoint = process.env.STIGMER_MCP_BRIDGE_ENDPOINT ?? null;
166
171
 
@@ -170,8 +175,8 @@ export function loadConfig(): Config {
170
175
  // the SDK's authorization header (Cursor access token) passes through to
171
176
  // api2.cursor.sh unchanged.
172
177
  const cursorApiKey = proxyActive
173
- ? (process.env.CURSOR_API_KEY ?? stigmerToken ?? "proxy-managed")
174
- : (process.env.CURSOR_API_KEY ?? "");
178
+ ? (getRunnerSecret("CURSOR_API_KEY") ?? stigmerToken ?? "proxy-managed")
179
+ : (getRunnerSecret("CURSOR_API_KEY") ?? "");
175
180
 
176
181
  const workspaceRootDir = resolveWorkspaceRootDir();
177
182
 
@@ -25,6 +25,8 @@
25
25
  * on the next runner boot — there is no live re-key.
26
26
  */
27
27
 
28
+ import { getRunnerSecret } from "../shared/runner-credential-store.js";
29
+
28
30
  export interface EncryptionKey {
29
31
  readonly keyId: string;
30
32
  /** 32-byte AES-256 key. */
@@ -75,14 +77,18 @@ const AES_256_KEY_BYTES = 32;
75
77
  export function loadPayloadEncryptionConfig(
76
78
  bootstrap?: BootstrapKeyMaterial,
77
79
  ): PayloadEncryptionConfig | undefined {
78
- const rawKey = process.env[KEY_ENV];
80
+ // Key VALUES resolve through the credential store (the #508 boot capture
81
+ // moves them out of process.env — agent shells must not read them); the
82
+ // *_KEY_ID companions are rotation bookkeeping, not secrets, and stay
83
+ // plain env reads.
84
+ const rawKey = getRunnerSecret(KEY_ENV);
79
85
  if (rawKey) {
80
86
  const primary: EncryptionKey = {
81
87
  keyId: requireKeyId(KEY_ID_ENV),
82
88
  key: parseKey(rawKey, KEY_ENV),
83
89
  };
84
90
 
85
- const rawSecondary = process.env[SECONDARY_KEY_ENV];
91
+ const rawSecondary = getRunnerSecret(SECONDARY_KEY_ENV);
86
92
  const secondary: EncryptionKey | undefined = rawSecondary
87
93
  ? {
88
94
  keyId: requireKeyId(SECONDARY_KEY_ID_ENV),
package/src/main.ts CHANGED
@@ -32,6 +32,10 @@ import { createInterface } from "node:readline";
32
32
  import { preflightNodeRuntime } from "./preflight.js";
33
33
  import { markBoot, emitRunnerBootTiming } from "./shared/cold-start-timing.js";
34
34
  import { loadConfig } from "./config.js";
35
+ import {
36
+ captureRunnerSecrets,
37
+ getRunnerSecret,
38
+ } from "./shared/runner-credential-store.js";
35
39
  import { initTracing, initMetrics } from "./otel.js";
36
40
  import { createStigmerRunner } from "./runner.js";
37
41
  import { createStigmerRunnerManager } from "./runner-manager.js";
@@ -214,11 +218,12 @@ async function runPoolMode(
214
218
  });
215
219
 
216
220
  // Sandbox credential self-renewal (see sandbox-token-renewal.ts). Watches
217
- // the env var because manager.updateToken keeps it in lockstep with the
218
- // manager's internal ref: a blank member's pool_sandbox token parks the
219
- // loop, the claim's updateToken swaps in a renewable session token, and
220
- // from then on renewal applies fresh tokens back through the same
221
- // updateToken (which also cascades to the proxy-credential coordinator).
221
+ // the credential store because manager.updateToken keeps it in lockstep
222
+ // with the manager's internal ref: a blank member's pool_sandbox token
223
+ // parks the loop, the claim's updateToken swaps in a renewable session
224
+ // token, and from then on renewal applies fresh tokens back through the
225
+ // same updateToken (which also cascades to the proxy-credential
226
+ // coordinator).
222
227
  const { startSandboxTokenRenewal } = await import("./sandbox-token-renewal.js");
223
228
  const { StigmerClient } = await import("./client/stigmer-client.js");
224
229
  const renewalClient = new StigmerClient({
@@ -226,7 +231,7 @@ async function runPoolMode(
226
231
  token: null,
227
232
  });
228
233
  const tokenRenewal = startSandboxTokenRenewal({
229
- getToken: () => process.env.STIGMER_TOKEN ?? null,
234
+ getToken: () => getRunnerSecret("STIGMER_TOKEN") ?? null,
230
235
  renew: (currentToken) =>
231
236
  renewalClient.getRunnerScopedToken({ renewal: true }, currentToken),
232
237
  applyToken: (token) => manager.updateToken(token),
@@ -449,6 +454,11 @@ async function main(): Promise<void> {
449
454
 
450
455
  checkBuildFreshness();
451
456
 
457
+ // Take custody of runner secrets before the config load reads them (#508).
458
+ // The factories capture too (they are the library boot doors); doing it
459
+ // here as well keeps main.ts's own late readers store-only from the start.
460
+ captureRunnerSecrets();
461
+
452
462
  const config = loadConfig();
453
463
  markBoot("config_loaded");
454
464
 
@@ -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();