@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.
- package/dist/.build-fingerprint +1 -1
- package/dist/activities/execute-cursor/skill-resolver.d.ts +15 -0
- package/dist/activities/execute-cursor/skill-resolver.js +44 -6
- package/dist/activities/execute-cursor/skill-resolver.js.map +1 -1
- package/dist/activities/execute-deep-agent/__test-utils__/scripted-model.d.ts +9 -1
- package/dist/activities/execute-deep-agent/__test-utils__/scripted-model.js +13 -2
- package/dist/activities/execute-deep-agent/__test-utils__/scripted-model.js.map +1 -1
- package/dist/activities/execute-deep-agent/subagent-wiring.d.ts +5 -1
- package/dist/activities/execute-deep-agent/subagent-wiring.js +9 -1
- package/dist/activities/execute-deep-agent/subagent-wiring.js.map +1 -1
- package/dist/client/stigmer-client.d.ts +9 -1
- package/dist/client/stigmer-client.js +10 -0
- package/dist/client/stigmer-client.js.map +1 -1
- package/dist/middleware/index.d.ts +6 -5
- package/dist/middleware/index.js +8 -5
- package/dist/middleware/index.js.map +1 -1
- package/dist/middleware/tool-intent.d.ts +57 -0
- package/dist/middleware/tool-intent.js +152 -0
- package/dist/middleware/tool-intent.js.map +1 -0
- package/package.json +2 -2
- package/src/activities/execute-cursor/__tests__/skill-resolver.test.ts +104 -1
- package/src/activities/execute-cursor/skill-resolver.ts +51 -7
- package/src/activities/execute-deep-agent/__test-utils__/scripted-model.ts +13 -2
- package/src/activities/execute-deep-agent/__tests__/subagent-wiring.test.ts +21 -20
- package/src/activities/execute-deep-agent/subagent-wiring.ts +10 -1
- package/src/client/stigmer-client.ts +12 -1
- package/src/middleware/__tests__/tool-intent.test.ts +266 -0
- package/src/middleware/index.ts +9 -5
- 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.
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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(
|
|
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("
|
|
14
|
-
expect(stack[3].name).toBe("
|
|
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(
|
|
29
|
-
expect(stack[
|
|
30
|
-
expect(stack[
|
|
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[
|
|
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[
|
|
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(
|
|
82
|
-
expect(stack[
|
|
83
|
-
expect(stack[
|
|
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(
|
|
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,
|
|
108
|
-
// Hints AFTER the gate matches the parent nesting: the
|
|
109
|
-
// interrupt stays outside the hints' try/catch (issue #255).
|
|
110
|
-
expect(stack).toHaveLength(
|
|
111
|
-
expect(stack[
|
|
112
|
-
expect(stack[
|
|
113
|
-
expect(stack[
|
|
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 →
|
|
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
|
}
|