@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,152 @@
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
+ import { toJsonSchema } from "@langchain/core/utils/json_schema";
40
+ import { ToolKind } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
41
+ import { classifyTool } from "../shared/tool-kind.js";
42
+ /**
43
+ * The wire name of the intent argument. Shared with the Cursor harness's
44
+ * built-in Shell tool and read by the SDK's tool presentation layer — the
45
+ * three surfaces must agree on this key.
46
+ */
47
+ export const INTENT_ARG = "description";
48
+ /**
49
+ * The behavior-shaping prompt for the intent argument (owner-approved
50
+ * wording, issue #276). This is prompt engineering, not documentation:
51
+ * changing it changes what the model writes into every shell row title.
52
+ */
53
+ export const INTENT_ARG_PROMPT = "A short present-tense phrase describing what this command does and why, " +
54
+ "shown to the user as the title of this action (5-10 words, e.g. " +
55
+ "'Run unit tests for the parser'). Do not restate the command syntax.";
56
+ function isStructuredToolLike(candidate) {
57
+ if (candidate == null || typeof candidate !== "object")
58
+ return false;
59
+ const t = candidate;
60
+ return (typeof t.name === "string" &&
61
+ typeof t.description === "string" &&
62
+ "schema" in t);
63
+ }
64
+ function isJsonObjectSchema(schema) {
65
+ return (schema != null &&
66
+ typeof schema === "object" &&
67
+ schema.type === "object");
68
+ }
69
+ /**
70
+ * Returns a bind-time `StructuredToolParams` declaration for `original`
71
+ * whose schema carries the optional intent argument, or `original` itself
72
+ * when the tool is not a shell tool, is not schema-extendable, or already
73
+ * defines an argument with that name (a real argument must never be
74
+ * shadowed by presentation metadata).
75
+ *
76
+ * Unexpected schemas pass through unchanged on purpose: a missing intent
77
+ * title degrades to today's rendering, while a mangled schema would break
78
+ * the tool for the whole execution.
79
+ */
80
+ function maybeExtendShellTool(original) {
81
+ if (!isStructuredToolLike(original))
82
+ return original;
83
+ if (classifyTool(original.name) !== ToolKind.SHELL)
84
+ return original;
85
+ let jsonSchema;
86
+ try {
87
+ jsonSchema = toJsonSchema(original.schema);
88
+ }
89
+ catch {
90
+ return original;
91
+ }
92
+ if (!isJsonObjectSchema(jsonSchema))
93
+ return original;
94
+ const properties = jsonSchema.properties ?? {};
95
+ if (INTENT_ARG in properties)
96
+ return original;
97
+ // A plain frozen declaration, deliberately NOT an executable tool: the
98
+ // graph's ToolNode executes the ORIGINAL registered tool (it is built from
99
+ // the registered tools, not from the model request), and the agent's
100
+ // wrapModelCall validation only forbids swapping same-name EXECUTABLE
101
+ // instances. `isStructuredToolParams` recognizes this shape, so every
102
+ // provider's bindTools converts it exactly like a structured tool.
103
+ return Object.freeze({
104
+ name: original.name,
105
+ description: original.description,
106
+ schema: {
107
+ ...jsonSchema,
108
+ properties: {
109
+ ...properties,
110
+ [INTENT_ARG]: { type: "string", description: INTENT_ARG_PROMPT },
111
+ },
112
+ },
113
+ });
114
+ }
115
+ /**
116
+ * Creates the middleware. Install on the parent stack AND on every sub-agent
117
+ * stack (subagent-wiring.ts) — sub-agent shell rows render in the same
118
+ * thread and must carry the same titles.
119
+ */
120
+ export function createToolIntentMiddleware() {
121
+ // One clone per original tool instance: repeated model calls (and repeated
122
+ // turns on the same graph) bind a referentially stable clone instead of
123
+ // re-serializing the schema every round.
124
+ const cloneCache = new WeakMap();
125
+ const extendCached = (candidate) => {
126
+ if (candidate == null || typeof candidate !== "object")
127
+ return candidate;
128
+ const cached = cloneCache.get(candidate);
129
+ if (cached !== undefined)
130
+ return cached;
131
+ const extended = maybeExtendShellTool(candidate);
132
+ cloneCache.set(candidate, extended);
133
+ return extended;
134
+ };
135
+ return {
136
+ name: "StigmerToolIntentMiddleware",
137
+ async wrapModelCall(request, handler) {
138
+ const tools = request.tools;
139
+ if (!tools || tools.length === 0)
140
+ return handler(request);
141
+ let changed = false;
142
+ const mapped = tools.map((t) => {
143
+ const extended = extendCached(t);
144
+ if (extended !== t)
145
+ changed = true;
146
+ return extended;
147
+ });
148
+ return handler(changed ? { ...request, tools: mapped } : request);
149
+ },
150
+ };
151
+ }
152
+ //# sourceMappingURL=tool-intent.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tool-intent.js","sourceRoot":"","sources":["../../src/middleware/tool-intent.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stigmer/runner",
3
- "version": "3.12.2",
3
+ "version": "3.12.3",
4
4
  "description": "Embeddable Temporal worker for the Stigmer AI agent platform — handles agent execution, workflow orchestration, and MCP server management",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -90,7 +90,7 @@
90
90
  "@opentelemetry/sdk-metrics": "^2.0.0",
91
91
  "@opentelemetry/sdk-trace-base": "^2.0.0",
92
92
  "@opentelemetry/sdk-trace-node": "^2.0.0",
93
- "@stigmer/protos": "3.12.2",
93
+ "@stigmer/protos": "3.12.3",
94
94
  "@temporalio/activity": "^1.11.0",
95
95
  "@temporalio/common": "^1.11.0",
96
96
  "@temporalio/interceptors-opentelemetry": "~1.16.0",
@@ -2,9 +2,17 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
2
  import { mkdtempSync, readFileSync, existsSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
- import { resolveSkills } from "../skill-resolver.js";
5
+ import { ConnectError, Code } from "@connectrpc/connect";
6
+ import { resolveSkills, downloadArtifact } from "../skill-resolver.js";
6
7
  import { buildZip } from "../../../__test-utils__/zip-fixtures.js";
7
8
 
9
+ /** A server that predates the transfer lane (#675) answers the mint RPC
10
+ * with UNIMPLEMENTED — the default posture for these tests, which keeps
11
+ * every pre-existing case pinned to the unary getSkillArtifact fallback. */
12
+ function unimplementedMint() {
13
+ return vi.fn().mockRejectedValue(new ConnectError("unimplemented", Code.Unimplemented));
14
+ }
15
+
8
16
  // ─── Helpers ─────────────────────────────────────────────────────────────
9
17
 
10
18
  function makeTempDir(prefix: string): string {
@@ -73,6 +81,7 @@ describe("resolveSkills — artifact extraction", () => {
73
81
  const client = {
74
82
  getSkillByReference: vi.fn(),
75
83
  getSkillArtifact: vi.fn().mockResolvedValue({ artifact: new Uint8Array(0) }),
84
+ getSkillArtifactDownloadUrl: unimplementedMint(),
76
85
  ...clientOverrides,
77
86
  } as any;
78
87
 
@@ -380,3 +389,97 @@ describe("resolveSkills — artifact extraction", () => {
380
389
  }
381
390
  });
382
391
  });
392
+
393
+ // ─── downloadArtifact — transfer lane routing (#675) ─────────────────────
394
+
395
+ describe("downloadArtifact — transfer lane routing", () => {
396
+ afterEach(() => {
397
+ vi.unstubAllGlobals();
398
+ });
399
+
400
+ function makeClient(overrides: Record<string, any> = {}) {
401
+ return {
402
+ getSkillArtifact: vi.fn().mockResolvedValue({ artifact: new Uint8Array(0) }),
403
+ getSkillArtifactDownloadUrl: unimplementedMint(),
404
+ ...overrides,
405
+ } as any;
406
+ }
407
+
408
+ it("fetches bytes over HTTP when the server mints a download URL", async () => {
409
+ const bytes = new Uint8Array([1, 2, 3, 4, 5]);
410
+ const client = makeClient({
411
+ getSkillArtifactDownloadUrl: vi.fn().mockResolvedValue({
412
+ url: "http://localhost:7234/v1/skill-artifacts/skills/abc.zip",
413
+ sizeBytes: 5n,
414
+ ttlSeconds: 0,
415
+ }),
416
+ });
417
+ const fetchMock = vi.fn().mockResolvedValue({
418
+ ok: true,
419
+ arrayBuffer: async () => bytes.buffer,
420
+ });
421
+ vi.stubGlobal("fetch", fetchMock);
422
+
423
+ const got = await downloadArtifact(client, "skills/abc.zip");
424
+
425
+ expect(got).toEqual(bytes);
426
+ expect(fetchMock).toHaveBeenCalledWith("http://localhost:7234/v1/skill-artifacts/skills/abc.zip");
427
+ // The unary lane (10MB-capped) must not be touched when the URL lane works.
428
+ expect(client.getSkillArtifact).not.toHaveBeenCalled();
429
+ });
430
+
431
+ it("falls back to the unary RPC when the server predates the lane", async () => {
432
+ const bytes = new Uint8Array([9, 9]);
433
+ const client = makeClient({
434
+ getSkillArtifact: vi.fn().mockResolvedValue({ artifact: bytes }),
435
+ });
436
+ vi.stubGlobal("fetch", vi.fn()); // must never be called
437
+
438
+ const got = await downloadArtifact(client, "skills/abc.zip");
439
+
440
+ expect(got).toEqual(bytes);
441
+ expect(client.getSkillArtifact).toHaveBeenCalledWith("skills/abc.zip");
442
+ expect(fetch).not.toHaveBeenCalled();
443
+ });
444
+
445
+ it("does NOT fall back on non-Unimplemented mint failures", async () => {
446
+ const client = makeClient({
447
+ getSkillArtifactDownloadUrl: vi.fn().mockRejectedValue(
448
+ new ConnectError("boom", Code.Internal),
449
+ ),
450
+ });
451
+
452
+ await expect(downloadArtifact(client, "skills/abc.zip")).rejects.toThrow("boom");
453
+ // Falling back here would mask real server faults behind the capped lane.
454
+ expect(client.getSkillArtifact).not.toHaveBeenCalled();
455
+ });
456
+
457
+ it("rejects truncated fetches via the minted size", async () => {
458
+ const client = makeClient({
459
+ getSkillArtifactDownloadUrl: vi.fn().mockResolvedValue({
460
+ url: "http://localhost:7234/v1/skill-artifacts/skills/abc.zip",
461
+ sizeBytes: 100n,
462
+ ttlSeconds: 0,
463
+ }),
464
+ });
465
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
466
+ ok: true,
467
+ arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
468
+ }));
469
+
470
+ await expect(downloadArtifact(client, "skills/abc.zip")).rejects.toThrow(/truncated/);
471
+ });
472
+
473
+ it("surfaces HTTP failures with the status code", async () => {
474
+ const client = makeClient({
475
+ getSkillArtifactDownloadUrl: vi.fn().mockResolvedValue({
476
+ url: "http://localhost:7234/v1/skill-artifacts/skills/gone.zip",
477
+ sizeBytes: 0n,
478
+ ttlSeconds: 0,
479
+ }),
480
+ });
481
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 404 }));
482
+
483
+ await expect(downloadArtifact(client, "skills/gone.zip")).rejects.toThrow(/HTTP 404/);
484
+ });
485
+ });
@@ -19,6 +19,7 @@
19
19
 
20
20
  import { mkdir, readFile, writeFile, rm } from "node:fs/promises";
21
21
  import { join, dirname } from "node:path";
22
+ import { ConnectError, Code } from "@connectrpc/connect";
22
23
  import type { StigmerClient } from "../../client/stigmer-client.js";
23
24
  import type { Skill } from "@stigmer/protos/ai/stigmer/agentic/skill/v1/api_pb";
24
25
  import type { ApiResourceReference } from "@stigmer/protos/ai/stigmer/commons/apiresource/io_pb";
@@ -115,14 +116,15 @@ export async function resolveSkills(
115
116
  let artifactBytes: Uint8Array | undefined;
116
117
  if (wantsArtifact) {
117
118
  try {
118
- const resp = await client.getSkillArtifact(skill.status!.artifactStorageKey);
119
- if (resp.artifact && resp.artifact.length > 0) {
120
- artifactBytes = resp.artifact;
121
- }
119
+ artifactBytes = await downloadArtifact(client, skill.status!.artifactStorageKey);
122
120
  } catch (err) {
123
- console.warn(
124
- `[resolveSkills] artifact download failed for ${ref.slug}, ` +
125
- `falling back to SKILL.md only: ${err instanceof Error ? err.message : err}`,
121
+ // Deliberate degradation, but LOUD (#675): the session still gets
122
+ // SKILL.md (better than a dead run), yet a skill silently missing
123
+ // its scripts/references was exactly how oversized artifacts hid.
124
+ console.error(
125
+ `[resolveSkills] artifact download FAILED for ${ref.org || "(default)"}/${ref.slug} ` +
126
+ `(key=${skill.status!.artifactStorageKey}) — mounting SKILL.md WITHOUT the skill's ` +
127
+ `supporting files (scripts/references will be missing): ${err instanceof Error ? err.message : err}`,
126
128
  );
127
129
  }
128
130
  }
@@ -161,6 +163,48 @@ async function mountIsFresh(skillDir: string, versionHash: string, wantsArtifact
161
163
  }
162
164
  }
163
165
 
166
+ /**
167
+ * Download a skill artifact's ZIP bytes, transfer lane first (#675).
168
+ *
169
+ * The URL lane (getArtifactDownloadUrl → HTTP GET) carries any valid skill
170
+ * size; the unary getArtifact response is capped by the server's 10MB gRPC
171
+ * message limit. Servers that predate the lane (and cloud until its sibling
172
+ * lands) answer the mint with UNIMPLEMENTED — those fall back to the unary
173
+ * path, which behaves exactly as before for ≤10MB artifacts.
174
+ *
175
+ * Runs only on a mount-cache miss (#672's hash-keyed marker above) — a hit
176
+ * skips the transfer entirely, whichever lane would have carried it.
177
+ *
178
+ * Exported for tests.
179
+ */
180
+ export async function downloadArtifact(
181
+ client: StigmerClient,
182
+ artifactStorageKey: string,
183
+ ): Promise<Uint8Array | undefined> {
184
+ let minted;
185
+ try {
186
+ minted = await client.getSkillArtifactDownloadUrl(artifactStorageKey);
187
+ } catch (err) {
188
+ if (err instanceof ConnectError && err.code === Code.Unimplemented) {
189
+ const resp = await client.getSkillArtifact(artifactStorageKey);
190
+ return resp.artifact && resp.artifact.length > 0 ? resp.artifact : undefined;
191
+ }
192
+ throw err;
193
+ }
194
+
195
+ const resp = await fetch(minted.url);
196
+ if (!resp.ok) {
197
+ throw new Error(`artifact fetch failed: HTTP ${resp.status} from ${minted.url}`);
198
+ }
199
+ const bytes = new Uint8Array(await resp.arrayBuffer());
200
+ if (minted.sizeBytes > 0n && BigInt(bytes.length) !== minted.sizeBytes) {
201
+ throw new Error(
202
+ `artifact fetch truncated: got ${bytes.length} bytes, expected ${minted.sizeBytes}`,
203
+ );
204
+ }
205
+ return bytes.length > 0 ? bytes : undefined;
206
+ }
207
+
164
208
  /**
165
209
  * (Re)write a skill's mount directory from scratch.
166
210
  *
@@ -50,11 +50,20 @@ export type ScriptSelector = (boundToolNames: string[]) => ScriptStep;
50
50
  */
51
51
  export class ScriptedModel extends BaseChatModel {
52
52
  toolNames: string[] = [];
53
+ /**
54
+ * The tool objects from the most recent `bindTools` call, exactly as the
55
+ * agent bound them (post-middleware). Lets tests assert on the bound
56
+ * SCHEMAS — e.g. the tool-intent middleware's bind-time shell clone — not
57
+ * just the names. The array is shared across the clones `bindTools`
58
+ * returns, so the instance the test holds always sees the latest bind.
59
+ */
60
+ readonly boundTools: unknown[];
53
61
  private readonly select: ScriptSelector;
54
62
 
55
- constructor(select: ScriptSelector) {
63
+ constructor(select: ScriptSelector, boundTools: unknown[] = []) {
56
64
  super({});
57
65
  this.select = select;
66
+ this.boundTools = boundTools;
58
67
  }
59
68
 
60
69
  _llmType(): string {
@@ -62,8 +71,10 @@ export class ScriptedModel extends BaseChatModel {
62
71
  }
63
72
 
64
73
  bindTools(tools: unknown[]): this {
65
- const next = new ScriptedModel(this.select);
74
+ const next = new ScriptedModel(this.select, this.boundTools);
66
75
  next.toolNames = (tools as Array<{ name?: string }>).map((t) => t?.name ?? "");
76
+ this.boundTools.length = 0;
77
+ this.boundTools.push(...tools);
67
78
  return next as unknown as this;
68
79
  }
69
80
 
@@ -7,11 +7,12 @@ describe("buildSubAgentMiddleware", () => {
7
7
  it("returns the correct middleware order without cost cap", () => {
8
8
  const stack = buildSubAgentMiddleware();
9
9
 
10
- expect(stack).toHaveLength(4);
10
+ expect(stack).toHaveLength(5);
11
11
  expect(stack[0].name).toBe("LoopDetectionMiddleware");
12
12
  expect(stack[1].name).toBe("ExecutionBudgetMiddleware");
13
- expect(stack[2].name).toBe("ToolTruncationMiddleware");
14
- expect(stack[3].name).toBe("ErrorHintsMiddleware");
13
+ expect(stack[2].name).toBe("StigmerToolIntentMiddleware");
14
+ expect(stack[3].name).toBe("ToolTruncationMiddleware");
15
+ expect(stack[4].name).toBe("ErrorHintsMiddleware");
15
16
  });
16
17
 
17
18
  it("includes cost cap view when parent cost cap is provided", () => {
@@ -25,9 +26,9 @@ describe("buildSubAgentMiddleware", () => {
25
26
 
26
27
  const stack = buildSubAgentMiddleware({ costCap: parentCostCap });
27
28
 
28
- expect(stack).toHaveLength(5);
29
- expect(stack[3].name).toBe("CostCapSubAgentView");
30
- expect(stack[4].name).toBe("ErrorHintsMiddleware");
29
+ expect(stack).toHaveLength(6);
30
+ expect(stack[4].name).toBe("CostCapSubAgentView");
31
+ expect(stack[5].name).toBe("ErrorHintsMiddleware");
31
32
  });
32
33
 
33
34
  it("sub-agent cost cap view shares parent state", () => {
@@ -43,7 +44,7 @@ describe("buildSubAgentMiddleware", () => {
43
44
 
44
45
  expect(parentCostCap.runningCost).toBe(0);
45
46
 
46
- const subView = stack[3];
47
+ const subView = stack[4];
47
48
  expect(subView.afterModel).toBeDefined();
48
49
  expect(subView.wrapToolCall).toBeDefined();
49
50
  expect(subView.beforeAgent).toBeUndefined();
@@ -69,7 +70,7 @@ describe("buildSubAgentMiddleware", () => {
69
70
  toolTruncation: { maxChars: 5000 },
70
71
  });
71
72
 
72
- expect(stack[2].name).toBe("ToolTruncationMiddleware");
73
+ expect(stack[3].name).toBe("ToolTruncationMiddleware");
73
74
  });
74
75
 
75
76
  it("installs the approval gate when an approvalGate config is provided", () => {
@@ -77,16 +78,16 @@ describe("buildSubAgentMiddleware", () => {
77
78
  approvalGate: { policies: new Map(), toolServerMap: new Map() },
78
79
  });
79
80
 
80
- // loop, budget, truncation, approval gate, error hints
81
- expect(stack).toHaveLength(5);
82
- expect(stack[3].name).toBe("ApprovalGateMiddleware");
83
- expect(stack[3].wrapToolCall).toBeDefined();
81
+ // loop, budget, tool intent, truncation, approval gate, error hints
82
+ expect(stack).toHaveLength(6);
83
+ expect(stack[4].name).toBe("ApprovalGateMiddleware");
84
+ expect(stack[4].wrapToolCall).toBeDefined();
84
85
  });
85
86
 
86
87
  it("omits the approval gate when approvalGate is null (auto-approve-all parity)", () => {
87
88
  const stack = buildSubAgentMiddleware({ approvalGate: null });
88
89
 
89
- expect(stack).toHaveLength(4);
90
+ expect(stack).toHaveLength(5);
90
91
  expect(stack.some((m) => m.name === "ApprovalGateMiddleware")).toBe(false);
91
92
  });
92
93
 
@@ -104,13 +105,13 @@ describe("buildSubAgentMiddleware", () => {
104
105
  approvalGate: { policies: new Map(), toolServerMap: new Map() },
105
106
  });
106
107
 
107
- // loop, budget, truncation, approval gate, cost cap view, error hints.
108
- // Hints AFTER the gate matches the parent nesting: the gate's HITL
109
- // interrupt stays outside the hints' try/catch (issue #255).
110
- expect(stack).toHaveLength(6);
111
- expect(stack[3].name).toBe("ApprovalGateMiddleware");
112
- expect(stack[4].name).toBe("CostCapSubAgentView");
113
- expect(stack[5].name).toBe("ErrorHintsMiddleware");
108
+ // loop, budget, tool intent, truncation, approval gate, cost cap view,
109
+ // error hints. Hints AFTER the gate matches the parent nesting: the
110
+ // gate's HITL interrupt stays outside the hints' try/catch (issue #255).
111
+ expect(stack).toHaveLength(7);
112
+ expect(stack[4].name).toBe("ApprovalGateMiddleware");
113
+ expect(stack[5].name).toBe("CostCapSubAgentView");
114
+ expect(stack[6].name).toBe("ErrorHintsMiddleware");
114
115
  });
115
116
 
116
117
  // captureIgnored is the structural coupling that makes sub-agent gitignored
@@ -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
  }