@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 CHANGED
@@ -24689,13 +24689,64 @@ paths:
24689
24689
  /v1/tools:
24690
24690
  get:
24691
24691
  operationId: tools_get
24692
- summary: List tools
24693
- description: Return available tool names with their descriptions, risk levels, and categories.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.8.10-dev.202606100925.28c1cfb",
3
+ "version": "0.8.10-dev.202606101122.0de2aff",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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
- "plugins/external-api.ts", // globalThis runtime bridge that exposes getSecureKeyAsync to dynamically-imported workspace plugins (compiled-binary plugin loading)
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
+ }