@vellumai/assistant 0.8.10-dev.202606100925.28c1cfb → 0.8.10-dev.202606101122.0de2aff
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/openapi.yaml +53 -2
- package/package.json +1 -1
- package/src/__tests__/agent-loop.test.ts +101 -0
- package/src/__tests__/catalog-install-normalize.test.ts +141 -0
- package/src/__tests__/credential-security-invariants.test.ts +1 -2
- package/src/__tests__/tools-get-route.test.ts +126 -0
- package/src/agent/loop.ts +61 -0
- package/src/cli/commands/tools.ts +92 -0
- package/src/cli/program.ts +2 -0
- package/src/daemon/external-plugins-bootstrap.ts +3 -8
- package/src/memory/v2/__tests__/consolidation-job.test.ts +27 -5
- package/src/memory/v2/consolidation-job.ts +90 -22
- package/src/plugins/defaults/compaction/window-manager.ts +6 -6
- package/src/runtime/routes/settings-routes.ts +55 -8
- package/src/skills/catalog-install.ts +77 -13
- package/src/__tests__/plugin-external-api.test.ts +0 -68
- package/src/plugins/external-api.ts +0 -114
package/openapi.yaml
CHANGED
|
@@ -24689,13 +24689,64 @@ paths:
|
|
|
24689
24689
|
/v1/tools:
|
|
24690
24690
|
get:
|
|
24691
24691
|
operationId: tools_get
|
|
24692
|
-
summary: List tools
|
|
24693
|
-
description:
|
|
24692
|
+
summary: List registered tools with metadata and schemas
|
|
24693
|
+
description:
|
|
24694
|
+
"Return every registered tool. `tools` carries per-tool metadata (description, author-asserted risk level,
|
|
24695
|
+
category, and contributing source: core, skill, plugin, or MCP server). `names`/`schemas` additionally cover
|
|
24696
|
+
skill tools whose manifests are present but not yet loaded, for the permission-simulator catalog."
|
|
24694
24697
|
tags:
|
|
24695
24698
|
- tools
|
|
24696
24699
|
responses:
|
|
24697
24700
|
"200":
|
|
24698
24701
|
description: Successful response
|
|
24702
|
+
content:
|
|
24703
|
+
application/json:
|
|
24704
|
+
schema:
|
|
24705
|
+
type: object
|
|
24706
|
+
properties:
|
|
24707
|
+
names:
|
|
24708
|
+
type: array
|
|
24709
|
+
items:
|
|
24710
|
+
type: string
|
|
24711
|
+
schemas:
|
|
24712
|
+
type: object
|
|
24713
|
+
propertyNames:
|
|
24714
|
+
type: string
|
|
24715
|
+
additionalProperties:
|
|
24716
|
+
type: object
|
|
24717
|
+
propertyNames:
|
|
24718
|
+
type: string
|
|
24719
|
+
additionalProperties: {}
|
|
24720
|
+
tools:
|
|
24721
|
+
type: array
|
|
24722
|
+
items:
|
|
24723
|
+
type: object
|
|
24724
|
+
properties:
|
|
24725
|
+
name:
|
|
24726
|
+
type: string
|
|
24727
|
+
description:
|
|
24728
|
+
type: string
|
|
24729
|
+
riskLevel:
|
|
24730
|
+
type: string
|
|
24731
|
+
category:
|
|
24732
|
+
type: string
|
|
24733
|
+
source:
|
|
24734
|
+
type: string
|
|
24735
|
+
description:
|
|
24736
|
+
'Tool origin: "core" for built-ins, otherwise "<kind>:<id>" (e.g. "plugin:echo", "skill:my-skill",
|
|
24737
|
+
"mcp:server").'
|
|
24738
|
+
required:
|
|
24739
|
+
- name
|
|
24740
|
+
- description
|
|
24741
|
+
- riskLevel
|
|
24742
|
+
- category
|
|
24743
|
+
- source
|
|
24744
|
+
additionalProperties: false
|
|
24745
|
+
required:
|
|
24746
|
+
- names
|
|
24747
|
+
- schemas
|
|
24748
|
+
- tools
|
|
24749
|
+
additionalProperties: false
|
|
24699
24750
|
/v1/tools/simulate-permission:
|
|
24700
24751
|
post:
|
|
24701
24752
|
operationId: tools_simulatepermission_post
|
package/package.json
CHANGED
|
@@ -14,6 +14,7 @@ import type {
|
|
|
14
14
|
ProviderResponse,
|
|
15
15
|
ToolDefinition,
|
|
16
16
|
} from "../providers/types.js";
|
|
17
|
+
import { ContextOverflowError } from "../providers/types.js";
|
|
17
18
|
import {
|
|
18
19
|
createMockProvider,
|
|
19
20
|
textResponse,
|
|
@@ -311,6 +312,106 @@ describe("AgentLoop", () => {
|
|
|
311
312
|
).toBe("API rate limit exceeded");
|
|
312
313
|
});
|
|
313
314
|
|
|
315
|
+
// 5b. Reactive context-overflow recovery
|
|
316
|
+
test("recovers from a context-overflow rejection by re-running the call", async () => {
|
|
317
|
+
/**
|
|
318
|
+
* The provider rejects the first call with `context_too_large`; the loop
|
|
319
|
+
* should calibrate the estimator from the error and re-run instead of
|
|
320
|
+
* surfacing an error, succeeding on the retry.
|
|
321
|
+
*/
|
|
322
|
+
|
|
323
|
+
// GIVEN a provider that rejects the first call as too large, then succeeds
|
|
324
|
+
const { provider, calls } = createMockProvider([
|
|
325
|
+
new ContextOverflowError("prompt is too long", "mock", {
|
|
326
|
+
actualTokens: 250_000,
|
|
327
|
+
}),
|
|
328
|
+
textResponse("recovered"),
|
|
329
|
+
]);
|
|
330
|
+
const loop = new AgentLoop({
|
|
331
|
+
provider,
|
|
332
|
+
systemPrompt: "system",
|
|
333
|
+
conversationId: "test-conversation",
|
|
334
|
+
});
|
|
335
|
+
const events: AgentEvent[] = [];
|
|
336
|
+
|
|
337
|
+
// WHEN the loop runs
|
|
338
|
+
const { history } = await loop.run({
|
|
339
|
+
messages: [userMessage],
|
|
340
|
+
onEvent: collectEvents(events),
|
|
341
|
+
trust: { sourceChannel: "vellum", trustClass: "unknown" },
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
// THEN the call was retried and the recovery response landed in history
|
|
345
|
+
expect(calls).toHaveLength(2);
|
|
346
|
+
expect(history).toHaveLength(2);
|
|
347
|
+
expect(history[1].content).toEqual([{ type: "text", text: "recovered" }]);
|
|
348
|
+
|
|
349
|
+
// AND no error event was emitted — the overflow was handled in-loop
|
|
350
|
+
expect(events.filter((e) => e.type === "error")).toHaveLength(0);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test("surfaces the overflow error after exhausting reactive retries", async () => {
|
|
354
|
+
/**
|
|
355
|
+
* When every re-run still overflows, the loop must stop retrying after a
|
|
356
|
+
* bounded number of attempts and surface the error rather than looping
|
|
357
|
+
* forever.
|
|
358
|
+
*/
|
|
359
|
+
|
|
360
|
+
// GIVEN a provider that rejects every call as too large
|
|
361
|
+
const { provider, calls } = createMockProvider([
|
|
362
|
+
new ContextOverflowError("prompt is too long", "mock", {
|
|
363
|
+
actualTokens: 250_000,
|
|
364
|
+
}),
|
|
365
|
+
]);
|
|
366
|
+
const loop = new AgentLoop({
|
|
367
|
+
provider,
|
|
368
|
+
systemPrompt: "system",
|
|
369
|
+
conversationId: "test-conversation",
|
|
370
|
+
});
|
|
371
|
+
const events: AgentEvent[] = [];
|
|
372
|
+
|
|
373
|
+
// WHEN the loop runs
|
|
374
|
+
await loop.run({
|
|
375
|
+
messages: [userMessage],
|
|
376
|
+
onEvent: collectEvents(events),
|
|
377
|
+
trust: { sourceChannel: "vellum", trustClass: "unknown" },
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
// THEN it tried the initial call plus the bounded retries, then gave up
|
|
381
|
+
expect(calls).toHaveLength(3);
|
|
382
|
+
expect(events.filter((e) => e.type === "error")).toHaveLength(1);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test("does not retry an overflow with no recoverable token count", async () => {
|
|
386
|
+
/**
|
|
387
|
+
* Without a parseable actual-token count there is nothing to calibrate
|
|
388
|
+
* against, so the loop surfaces the error immediately instead of
|
|
389
|
+
* re-running blindly.
|
|
390
|
+
*/
|
|
391
|
+
|
|
392
|
+
// GIVEN an overflow error carrying neither a typed count nor a parseable one
|
|
393
|
+
const { provider, calls } = createMockProvider([
|
|
394
|
+
new ContextOverflowError("context window exceeded", "mock"),
|
|
395
|
+
]);
|
|
396
|
+
const loop = new AgentLoop({
|
|
397
|
+
provider,
|
|
398
|
+
systemPrompt: "system",
|
|
399
|
+
conversationId: "test-conversation",
|
|
400
|
+
});
|
|
401
|
+
const events: AgentEvent[] = [];
|
|
402
|
+
|
|
403
|
+
// WHEN the loop runs
|
|
404
|
+
await loop.run({
|
|
405
|
+
messages: [userMessage],
|
|
406
|
+
onEvent: collectEvents(events),
|
|
407
|
+
trust: { sourceChannel: "vellum", trustClass: "unknown" },
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
// THEN it surfaced the error on the first call without retrying
|
|
411
|
+
expect(calls).toHaveLength(1);
|
|
412
|
+
expect(events.filter((e) => e.type === "error")).toHaveLength(1);
|
|
413
|
+
});
|
|
414
|
+
|
|
314
415
|
// 6. Abort signal — verify the loop respects AbortSignal
|
|
315
416
|
test("stops when abort signal is triggered before provider call", async () => {
|
|
316
417
|
const controller = new AbortController();
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for catalog entry normalization in catalog-install.ts.
|
|
3
|
+
*
|
|
4
|
+
* The platform `/v1/skills/` API flattens skill metadata into top-level
|
|
5
|
+
* fields (`category`, `display_name`, `icon`) rather than the nested
|
|
6
|
+
* `metadata.vellum.*` shape used by the local `catalog.json`. Both
|
|
7
|
+
* `fetchCatalog` (remote) and `readLocalCatalog` (local file) must produce a
|
|
8
|
+
* canonical `CatalogSkill` whose `metadata.vellum.category` is populated, so
|
|
9
|
+
* downstream category assignment doesn't fall back to "system" for every skill.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { afterEach, describe, expect, mock, test } from "bun:test";
|
|
16
|
+
|
|
17
|
+
mock.module("../util/logger.js", () => ({
|
|
18
|
+
getLogger: () =>
|
|
19
|
+
new Proxy({} as Record<string, unknown>, {
|
|
20
|
+
get: () => () => {},
|
|
21
|
+
}),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
mock.module("../config/env.js", () => ({
|
|
25
|
+
getPlatformBaseUrl: () => "https://platform.example.com",
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
import { fetchCatalog, readLocalCatalog } from "../skills/catalog-install.js";
|
|
29
|
+
|
|
30
|
+
const originalFetch = globalThis.fetch;
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
globalThis.fetch = originalFetch;
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Build a `fetch` stub that responds with `payload` serialized as JSON.
|
|
38
|
+
*
|
|
39
|
+
* `Object.assign` attaches the `preconnect` static that Bun's `fetch` type
|
|
40
|
+
* requires, so the stub satisfies `typeof fetch` without an `as unknown` cast.
|
|
41
|
+
*/
|
|
42
|
+
function stubFetchJson(payload: unknown): typeof fetch {
|
|
43
|
+
const respond = async () =>
|
|
44
|
+
new Response(JSON.stringify(payload), {
|
|
45
|
+
status: 200,
|
|
46
|
+
headers: { "content-type": "application/json" },
|
|
47
|
+
});
|
|
48
|
+
return Object.assign(respond, { preconnect: () => {} });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("fetchCatalog normalization", () => {
|
|
52
|
+
test("re-nests the platform's flattened category under metadata.vellum", async () => {
|
|
53
|
+
globalThis.fetch = stubFetchJson({
|
|
54
|
+
skills: [
|
|
55
|
+
{
|
|
56
|
+
id: "amazon",
|
|
57
|
+
name: "amazon",
|
|
58
|
+
display_name: "Amazon",
|
|
59
|
+
description: "Shop on Amazon",
|
|
60
|
+
icon: "🛒",
|
|
61
|
+
category: "commerce",
|
|
62
|
+
updated_at: "2026-04-19T19:26:17Z",
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const catalog = await fetchCatalog();
|
|
68
|
+
|
|
69
|
+
expect(catalog).toHaveLength(1);
|
|
70
|
+
const [skill] = catalog;
|
|
71
|
+
expect(skill.id).toBe("amazon");
|
|
72
|
+
expect(skill.metadata?.vellum?.category).toBe("commerce");
|
|
73
|
+
expect(skill.metadata?.vellum?.["display-name"]).toBe("Amazon");
|
|
74
|
+
expect(skill.icon).toBe("🛒");
|
|
75
|
+
expect(skill.metadata?.icon).toBe("🛒");
|
|
76
|
+
expect(skill.updatedAt).toBe("2026-04-19T19:26:17Z");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("leaves category undefined when the API omits it", async () => {
|
|
80
|
+
globalThis.fetch = stubFetchJson({
|
|
81
|
+
skills: [{ id: "no-cat", name: "no-cat", description: "d" }],
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const catalog = await fetchCatalog();
|
|
85
|
+
|
|
86
|
+
expect(catalog).toHaveLength(1);
|
|
87
|
+
expect(catalog[0].metadata?.vellum?.category).toBeUndefined();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("drops malformed entries without throwing", async () => {
|
|
91
|
+
globalThis.fetch = stubFetchJson({
|
|
92
|
+
skills: [
|
|
93
|
+
null,
|
|
94
|
+
undefined,
|
|
95
|
+
"not-an-object",
|
|
96
|
+
{ name: "missing-id", description: "d" },
|
|
97
|
+
{ id: 42 },
|
|
98
|
+
{ id: "valid", name: "valid", description: "d", category: "email" },
|
|
99
|
+
],
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const catalog = await fetchCatalog();
|
|
103
|
+
|
|
104
|
+
expect(catalog).toHaveLength(1);
|
|
105
|
+
expect(catalog[0].id).toBe("valid");
|
|
106
|
+
expect(catalog[0].metadata?.vellum?.category).toBe("email");
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("readLocalCatalog normalization", () => {
|
|
111
|
+
test("reads the nested metadata.vellum.category from catalog.json", () => {
|
|
112
|
+
const dir = mkdtempSync(join(tmpdir(), "vellum-catalog-"));
|
|
113
|
+
try {
|
|
114
|
+
writeFileSync(
|
|
115
|
+
join(dir, "catalog.json"),
|
|
116
|
+
JSON.stringify({
|
|
117
|
+
version: 1,
|
|
118
|
+
skills: [
|
|
119
|
+
{
|
|
120
|
+
id: "tasks",
|
|
121
|
+
name: "tasks",
|
|
122
|
+
description: "Task management",
|
|
123
|
+
metadata: {
|
|
124
|
+
icon: "✅",
|
|
125
|
+
vellum: { "display-name": "Tasks", category: "productivity" },
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
}),
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const catalog = readLocalCatalog(dir);
|
|
133
|
+
|
|
134
|
+
expect(catalog).toHaveLength(1);
|
|
135
|
+
expect(catalog[0].metadata?.vellum?.category).toBe("productivity");
|
|
136
|
+
expect(catalog[0].metadata?.vellum?.["display-name"]).toBe("Tasks");
|
|
137
|
+
} finally {
|
|
138
|
+
rmSync(dir, { recursive: true, force: true });
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -230,8 +230,7 @@ describe("Invariant 2: no generic plaintext secret read API", () => {
|
|
|
230
230
|
"runtime/routes/platform-routes.ts", // CLI platform connect/disconnect/status routes (CLI-migrated to IPC)
|
|
231
231
|
"ipc/skill-routes/providers.ts", // host.providers.secureKeys.getProviderKey IPC route (out-of-process SkillHost companion)
|
|
232
232
|
"daemon/external-plugins-bootstrap.ts", // reads credentials at plugin init (manifest.requiresCredential) via the CES-mediated getSecureKeyAsync path
|
|
233
|
-
"
|
|
234
|
-
"plugin-api/index.ts", // public @vellumai/plugin-api surface re-exports getSecureKeyAsync to workspace plugins via the boot-time shim (same exposure as the external-api bridge, routed through the typed public package)
|
|
233
|
+
"plugin-api/index.ts", // public @vellumai/plugin-api surface re-exports getSecureKeyAsync to dynamically-imported workspace plugins via the boot-time shim (compiled-binary plugin loading)
|
|
235
234
|
"inbound/platform-callback-registration.ts", // managed credential lookup for platform base URL, assistant ID, and API key
|
|
236
235
|
"tts/providers/elevenlabs-provider.ts", // ElevenLabs TTS API key lookup
|
|
237
236
|
"tts/providers/deepgram-provider.ts", // Deepgram TTS API key lookup
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the `tools` route handler (operationId `tools_get`), which backs
|
|
3
|
+
* both the macOS permission-simulator catalog (`names`/`schemas`) and the
|
|
4
|
+
* `assistant tools list` CLI command (`tools`). The `tools` array carries
|
|
5
|
+
* every registered tool with its description, author-asserted risk band,
|
|
6
|
+
* category, and the source (core / skill / plugin / mcp) that contributed it
|
|
7
|
+
* — with the source read from the registry's ownership map rather than off
|
|
8
|
+
* the tool object.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
12
|
+
|
|
13
|
+
import { RiskLevel } from "../permissions/types.js";
|
|
14
|
+
import { ROUTES } from "../runtime/routes/settings-routes.js";
|
|
15
|
+
import {
|
|
16
|
+
__resetRegistryForTesting,
|
|
17
|
+
registerMcpTools,
|
|
18
|
+
registerPluginTools,
|
|
19
|
+
registerSkillTools,
|
|
20
|
+
registerTool,
|
|
21
|
+
} from "../tools/registry.js";
|
|
22
|
+
import type { Tool, ToolContext, ToolExecutionResult } from "../tools/types.js";
|
|
23
|
+
|
|
24
|
+
interface ToolListEntry {
|
|
25
|
+
name: string;
|
|
26
|
+
description: string;
|
|
27
|
+
riskLevel: string;
|
|
28
|
+
category: string;
|
|
29
|
+
source: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface ToolsGetResponse {
|
|
33
|
+
names: string[];
|
|
34
|
+
schemas: Record<string, unknown>;
|
|
35
|
+
tools: ToolListEntry[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function makeFakeTool(name: string, extras: Partial<Tool> = {}): Tool {
|
|
39
|
+
return {
|
|
40
|
+
name,
|
|
41
|
+
description: `Fake ${name}`,
|
|
42
|
+
defaultRiskLevel: RiskLevel.Low,
|
|
43
|
+
executionTarget: "sandbox",
|
|
44
|
+
input_schema: { type: "object", properties: {}, required: [] },
|
|
45
|
+
category: "",
|
|
46
|
+
async execute(
|
|
47
|
+
_input: Record<string, unknown>,
|
|
48
|
+
_context: ToolContext,
|
|
49
|
+
): Promise<ToolExecutionResult> {
|
|
50
|
+
return { content: "ok", isError: false };
|
|
51
|
+
},
|
|
52
|
+
...extras,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const handler = (() => {
|
|
57
|
+
const route = ROUTES.find(
|
|
58
|
+
(r) => r.endpoint === "tools" && r.method === "GET",
|
|
59
|
+
);
|
|
60
|
+
if (!route) {
|
|
61
|
+
throw new Error("No route found for GET tools");
|
|
62
|
+
}
|
|
63
|
+
return route.handler;
|
|
64
|
+
})();
|
|
65
|
+
|
|
66
|
+
describe("GET /tools", () => {
|
|
67
|
+
beforeEach(() => {
|
|
68
|
+
__resetRegistryForTesting();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("returns each registered tool's metadata, sorted by name, with core source", async () => {
|
|
72
|
+
// GIVEN two core tools registered out of alphabetical order
|
|
73
|
+
registerTool(
|
|
74
|
+
makeFakeTool("b_core_tool", {
|
|
75
|
+
description: "Beta tool",
|
|
76
|
+
defaultRiskLevel: RiskLevel.High,
|
|
77
|
+
category: "testing",
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
registerTool(makeFakeTool("a_core_tool"));
|
|
81
|
+
|
|
82
|
+
// WHEN the tools handler runs
|
|
83
|
+
const { names, schemas, tools } = (await handler({})) as ToolsGetResponse;
|
|
84
|
+
|
|
85
|
+
// THEN the catalog fields the permission simulator reads are present
|
|
86
|
+
expect(names).toContain("a_core_tool");
|
|
87
|
+
expect(names).toContain("b_core_tool");
|
|
88
|
+
expect(schemas.a_core_tool).toBeDefined();
|
|
89
|
+
|
|
90
|
+
// AND the metadata array is sorted by name with full per-tool metadata
|
|
91
|
+
const toolNames = tools.map((t) => t.name);
|
|
92
|
+
expect(toolNames.indexOf("a_core_tool")).toBeLessThan(
|
|
93
|
+
toolNames.indexOf("b_core_tool"),
|
|
94
|
+
);
|
|
95
|
+
expect([...toolNames]).toEqual(
|
|
96
|
+
[...toolNames].sort((a, b) => a.localeCompare(b)),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
// AND each core tool reports "core" as its source plus its metadata
|
|
100
|
+
const beta = tools.find((t) => t.name === "b_core_tool");
|
|
101
|
+
expect(beta).toEqual({
|
|
102
|
+
name: "b_core_tool",
|
|
103
|
+
description: "Beta tool",
|
|
104
|
+
riskLevel: RiskLevel.High,
|
|
105
|
+
category: "testing",
|
|
106
|
+
source: "core",
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("reports plugin, mcp, and skill tools as <kind>:<id>", async () => {
|
|
111
|
+
// GIVEN one tool from each non-core source
|
|
112
|
+
registerPluginTools("echo", [makeFakeTool("p_tool")]);
|
|
113
|
+
registerMcpTools("linear", [makeFakeTool("m_tool")]);
|
|
114
|
+
registerSkillTools("my-skill", [makeFakeTool("s_tool")]);
|
|
115
|
+
|
|
116
|
+
// WHEN the tools handler runs
|
|
117
|
+
const { tools } = (await handler({})) as ToolsGetResponse;
|
|
118
|
+
|
|
119
|
+
// THEN each tool's source encodes its owning extension
|
|
120
|
+
const sourceOf = (name: string) =>
|
|
121
|
+
tools.find((t) => t.name === name)?.source;
|
|
122
|
+
expect(sourceOf("p_tool")).toBe("plugin:echo");
|
|
123
|
+
expect(sourceOf("m_tool")).toBe("mcp:linear");
|
|
124
|
+
expect(sourceOf("s_tool")).toBe("skill:my-skill");
|
|
125
|
+
});
|
|
126
|
+
});
|
package/src/agent/loop.ts
CHANGED
|
@@ -3,6 +3,7 @@ import * as Sentry from "@sentry/node";
|
|
|
3
3
|
import { isAssistantFeatureFlagEnabled } from "../config/assistant-feature-flags.js";
|
|
4
4
|
import { getConfig } from "../config/loader.js";
|
|
5
5
|
import type { LLMCallSite } from "../config/schemas/llm.js";
|
|
6
|
+
import { recordEstimate } from "../context/estimator-calibration.js";
|
|
6
7
|
import { stripInjectionsForCompaction } from "../context/strip-injections.js";
|
|
7
8
|
import {
|
|
8
9
|
estimatePromptTokensRaw,
|
|
@@ -11,6 +12,7 @@ import {
|
|
|
11
12
|
getCalibrationProviderKey,
|
|
12
13
|
} from "../context/token-estimator.js";
|
|
13
14
|
import type { ToolActivityMetadata } from "../daemon/message-types/web-activity.js";
|
|
15
|
+
import { parseActualTokensFromError } from "../daemon/parse-actual-tokens-from-error.js";
|
|
14
16
|
import type { TrustContext } from "../daemon/trust-context.js";
|
|
15
17
|
import { stripHistoricalWebSearchResults } from "../daemon/web-search-history.js";
|
|
16
18
|
import { HOOKS } from "../plugin-api/constants.js";
|
|
@@ -35,6 +37,7 @@ import type {
|
|
|
35
37
|
ToolDefinition,
|
|
36
38
|
ToolResultContent,
|
|
37
39
|
} from "../providers/types.js";
|
|
40
|
+
import { isContextOverflowError } from "../providers/types.js";
|
|
38
41
|
import type { SensitiveOutputBinding } from "../tools/sensitive-output-placeholders.js";
|
|
39
42
|
import {
|
|
40
43
|
applyStreamingSubstitution,
|
|
@@ -54,6 +57,15 @@ const MID_LOOP_YIELD_THRESHOLD_RATIO = 0.85;
|
|
|
54
57
|
const LONG_HISTORY_MESSAGE_THRESHOLD = 50;
|
|
55
58
|
const LONG_HISTORY_SAFETY_MARGIN_FLOOR = 0.15;
|
|
56
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Consecutive context-overflow rejections the loop recovers from in place
|
|
62
|
+
* before letting the error propagate. Each recovery calibrates the estimator
|
|
63
|
+
* from the provider's real token count and re-fires the budget gate, which
|
|
64
|
+
* compacts before the next call; the cap stops an unbounded
|
|
65
|
+
* reject → compact → reject cycle when compaction cannot get under the limit.
|
|
66
|
+
*/
|
|
67
|
+
const MAX_REACTIVE_OVERFLOW_RETRIES = 2;
|
|
68
|
+
|
|
57
69
|
export interface AgentLoopConfig {
|
|
58
70
|
maxTokens: number;
|
|
59
71
|
maxInputTokens?: number; // context window size for tool result truncation
|
|
@@ -805,6 +817,14 @@ export class AgentLoop {
|
|
|
805
817
|
// prior post-call placement, plus the first call when
|
|
806
818
|
// `compactInPlace` is set (the primary run's turn-start compaction).
|
|
807
819
|
let budgetGateArmed = compactInPlace;
|
|
820
|
+
// Raw pre-send estimate for the most recent provider call, captured so the
|
|
821
|
+
// overflow catch can calibrate the estimator against the provider's actual
|
|
822
|
+
// token count. Reset to the success path's value on every call.
|
|
823
|
+
let lastPreSendEstimatedTokens = 0;
|
|
824
|
+
// Consecutive context-overflow rejections recovered in place this run,
|
|
825
|
+
// reset after any successful provider call. Bounded by
|
|
826
|
+
// MAX_REACTIVE_OVERFLOW_RETRIES.
|
|
827
|
+
let reactiveOverflowRetries = 0;
|
|
808
828
|
const rlog = requestId ? log.child({ requestId }) : log;
|
|
809
829
|
|
|
810
830
|
// Resolve the inference-profile override that applies right now. The
|
|
@@ -1078,6 +1098,7 @@ export class AgentLoop {
|
|
|
1078
1098
|
toolTokenBudget,
|
|
1079
1099
|
},
|
|
1080
1100
|
);
|
|
1101
|
+
lastPreSendEstimatedTokens = preSendEstimatedTokens;
|
|
1081
1102
|
rlog.info({ turn: toolUseTurns }, "LLM call start");
|
|
1082
1103
|
|
|
1083
1104
|
// Sanitize the outbound history right before sending: drop accumulated
|
|
@@ -1246,6 +1267,10 @@ export class AgentLoop {
|
|
|
1246
1267
|
throw llmCallError;
|
|
1247
1268
|
}
|
|
1248
1269
|
|
|
1270
|
+
// The call fit — clear the reactive-overflow budget so a later
|
|
1271
|
+
// independent overflow this run gets its own recovery attempts.
|
|
1272
|
+
reactiveOverflowRetries = 0;
|
|
1273
|
+
|
|
1249
1274
|
const providerDurationMs = Date.now() - providerStart;
|
|
1250
1275
|
|
|
1251
1276
|
onEvent({
|
|
@@ -1716,6 +1741,42 @@ export class AgentLoop {
|
|
|
1716
1741
|
await emitExit("aborted_via_error");
|
|
1717
1742
|
break;
|
|
1718
1743
|
}
|
|
1744
|
+
|
|
1745
|
+
// Reactive context-overflow recovery. The provider rejected the call
|
|
1746
|
+
// because the prompt exceeded its window — ground truth the estimator
|
|
1747
|
+
// under-counted. Fold the provider's actual token count into the
|
|
1748
|
+
// per-provider calibration so the next iteration's budget gate sees a
|
|
1749
|
+
// corrected (higher) estimate, crosses its threshold, and compacts in
|
|
1750
|
+
// place before re-issuing the call. Bounded so an overflow that
|
|
1751
|
+
// compaction cannot resolve falls through to the error path below
|
|
1752
|
+
// instead of looping forever.
|
|
1753
|
+
if (isContextOverflowError(error)) {
|
|
1754
|
+
const actualTokens = parseActualTokensFromError(error);
|
|
1755
|
+
if (
|
|
1756
|
+
actualTokens !== null &&
|
|
1757
|
+
reactiveOverflowRetries < MAX_REACTIVE_OVERFLOW_RETRIES
|
|
1758
|
+
) {
|
|
1759
|
+
reactiveOverflowRetries++;
|
|
1760
|
+
recordEstimate(
|
|
1761
|
+
getCalibrationProviderKey(this.provider),
|
|
1762
|
+
"",
|
|
1763
|
+
lastPreSendEstimatedTokens,
|
|
1764
|
+
actualTokens,
|
|
1765
|
+
);
|
|
1766
|
+
budgetGateArmed = true;
|
|
1767
|
+
rlog.warn(
|
|
1768
|
+
{
|
|
1769
|
+
turn: toolUseTurns,
|
|
1770
|
+
attempt: reactiveOverflowRetries,
|
|
1771
|
+
estimated: lastPreSendEstimatedTokens,
|
|
1772
|
+
actualTokens,
|
|
1773
|
+
},
|
|
1774
|
+
"Context too large — calibrated estimator and re-running to compact",
|
|
1775
|
+
);
|
|
1776
|
+
continue;
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1719
1780
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
1720
1781
|
rlog.error(
|
|
1721
1782
|
{ err, turn: toolUseTurns, messageCount: history.length },
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `assistant tools` — inspect the tools registered with the running
|
|
3
|
+
* assistant (core built-ins plus skill-, plugin-, and MCP-contributed
|
|
4
|
+
* tools).
|
|
5
|
+
*
|
|
6
|
+
* Thin `ipc` wrapper: each subcommand forwards to a single daemon route and
|
|
7
|
+
* renders the response. The registry lives in the daemon, so these commands
|
|
8
|
+
* require the daemon to be running.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Command } from "commander";
|
|
12
|
+
|
|
13
|
+
import { cliIpcCall, exitFromIpcResult } from "../../ipc/cli-client.js";
|
|
14
|
+
import { registerCommand } from "../lib/register-command.js";
|
|
15
|
+
|
|
16
|
+
interface ToolListEntry {
|
|
17
|
+
name: string;
|
|
18
|
+
description: string;
|
|
19
|
+
riskLevel: string;
|
|
20
|
+
category: string;
|
|
21
|
+
source: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface ToolsGetResponse {
|
|
25
|
+
names: string[];
|
|
26
|
+
schemas: Record<string, unknown>;
|
|
27
|
+
tools: ToolListEntry[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function registerToolsCommand(program: Command): void {
|
|
31
|
+
registerCommand(program, {
|
|
32
|
+
name: "tools",
|
|
33
|
+
transport: "ipc",
|
|
34
|
+
description: "Inspect tools registered with the running assistant",
|
|
35
|
+
build: (tools) => {
|
|
36
|
+
tools.addHelpText(
|
|
37
|
+
"after",
|
|
38
|
+
`
|
|
39
|
+
Tools are registered with the daemon from four sources: core built-ins,
|
|
40
|
+
skills, external plugins, and MCP servers. The "source" column reports the
|
|
41
|
+
origin as "core" or "<kind>:<id>" (e.g. "plugin:echo", "skill:my-skill",
|
|
42
|
+
"mcp:linear"). The risk level is the author-asserted default band used for
|
|
43
|
+
permission gating, not the runtime-classified risk of a specific call.
|
|
44
|
+
|
|
45
|
+
Examples:
|
|
46
|
+
$ assistant tools list
|
|
47
|
+
$ assistant tools ls
|
|
48
|
+
$ assistant tools list --json`,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
tools
|
|
52
|
+
.command("list")
|
|
53
|
+
.alias("ls")
|
|
54
|
+
.description(
|
|
55
|
+
"List all registered tools with their source and risk level",
|
|
56
|
+
)
|
|
57
|
+
.option("--json", "Emit machine-readable JSON instead of a table")
|
|
58
|
+
.action(async (opts: { json?: boolean }) => {
|
|
59
|
+
const response = await cliIpcCall<ToolsGetResponse>("tools_get");
|
|
60
|
+
if (!response.ok) {
|
|
61
|
+
return exitFromIpcResult(response);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const tools = response.result?.tools ?? [];
|
|
65
|
+
|
|
66
|
+
if (opts.json) {
|
|
67
|
+
console.log(JSON.stringify(tools, null, 2));
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (tools.length === 0) {
|
|
72
|
+
console.log("No tools registered.");
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const nameW = Math.max(4, ...tools.map((t) => t.name.length));
|
|
77
|
+
const sourceW = Math.max(6, ...tools.map((t) => t.source.length));
|
|
78
|
+
const riskW = Math.max(4, ...tools.map((t) => t.riskLevel.length));
|
|
79
|
+
|
|
80
|
+
console.log(
|
|
81
|
+
`${"NAME".padEnd(nameW)} ${"SOURCE".padEnd(sourceW)} ${"RISK".padEnd(riskW)} DESCRIPTION`,
|
|
82
|
+
);
|
|
83
|
+
for (const t of tools) {
|
|
84
|
+
console.log(
|
|
85
|
+
`${t.name.padEnd(nameW)} ${t.source.padEnd(sourceW)} ${t.riskLevel.padEnd(riskW)} ${t.description}`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
console.log(`\n${tools.length} tool(s)`);
|
|
89
|
+
});
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
}
|