@vellumai/assistant 0.8.10-dev.202606100925.28c1cfb → 0.8.10-dev.202606101324.2fc90b3

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 (32) hide show
  1. package/openapi.yaml +63 -2
  2. package/package.json +1 -1
  3. package/src/__tests__/agent-loop-exit-reason.test.ts +9 -81
  4. package/src/__tests__/agent-loop.test.ts +53 -3
  5. package/src/__tests__/catalog-install-normalize.test.ts +141 -0
  6. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +1 -1
  7. package/src/__tests__/conversation-agent-loop-overflow.test.ts +219 -427
  8. package/src/__tests__/conversation-agent-loop.test.ts +73 -504
  9. package/src/__tests__/conversation-provider-retry-repair.test.ts +11 -336
  10. package/src/__tests__/conversation-tool-setup-tools-disabled.test.ts +34 -0
  11. package/src/__tests__/credential-security-invariants.test.ts +1 -2
  12. package/src/__tests__/{context-overflow-policy.test.ts → overflow-policy.test.ts} +1 -1
  13. package/src/__tests__/tools-get-route.test.ts +202 -0
  14. package/src/agent/loop.ts +207 -104
  15. package/src/cli/commands/tools.ts +106 -0
  16. package/src/cli/program.ts +2 -0
  17. package/src/daemon/conversation-agent-loop-handlers.ts +3 -25
  18. package/src/daemon/conversation-agent-loop.ts +83 -319
  19. package/src/daemon/conversation-tool-setup.ts +11 -0
  20. package/src/daemon/conversation.ts +25 -0
  21. package/src/daemon/external-plugins-bootstrap.ts +3 -8
  22. package/src/memory/v2/__tests__/consolidation-job.test.ts +27 -5
  23. package/src/memory/v2/consolidation-job.ts +90 -22
  24. package/src/plugin-api/types.ts +7 -0
  25. package/src/plugins/defaults/compaction/compact.ts +27 -1
  26. package/src/{daemon/context-overflow-policy.ts → plugins/defaults/compaction/overflow-policy.ts} +1 -1
  27. package/src/plugins/defaults/compaction/window-manager.ts +114 -6
  28. package/src/plugins/defaults/memory-retrieval/hooks/post-compact.ts +4 -4
  29. package/src/runtime/routes/settings-routes.ts +140 -15
  30. package/src/skills/catalog-install.ts +77 -13
  31. package/src/__tests__/plugin-external-api.test.ts +0 -68
  32. package/src/plugins/external-api.ts +0 -114
package/openapi.yaml CHANGED
@@ -24689,13 +24689,74 @@ 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 registered tools. Without `conversationId`, returns every tool in the global registry; `tools`
24695
+ carries per-tool metadata (description, author-asserted risk level, category, and contributing source: core,
24696
+ skill, plugin, or MCP server) and `names`/`schemas` additionally cover skill tools whose manifests are present
24697
+ but not yet loaded, for the permission-simulator catalog. With `conversationId`, scopes the result to the tools
24698
+ available to that conversation as of its most recent turn (including skill/MCP tools registered over its
24699
+ lifecycle); 404 if no such conversation is active."
24694
24700
  tags:
24695
24701
  - tools
24696
24702
  responses:
24697
24703
  "200":
24698
24704
  description: Successful response
24705
+ content:
24706
+ application/json:
24707
+ schema:
24708
+ type: object
24709
+ properties:
24710
+ names:
24711
+ type: array
24712
+ items:
24713
+ type: string
24714
+ schemas:
24715
+ type: object
24716
+ propertyNames:
24717
+ type: string
24718
+ additionalProperties:
24719
+ type: object
24720
+ propertyNames:
24721
+ type: string
24722
+ additionalProperties: {}
24723
+ tools:
24724
+ type: array
24725
+ items:
24726
+ type: object
24727
+ properties:
24728
+ name:
24729
+ type: string
24730
+ description:
24731
+ type: string
24732
+ riskLevel:
24733
+ type: string
24734
+ category:
24735
+ type: string
24736
+ source:
24737
+ type: string
24738
+ description:
24739
+ 'Tool origin: "core" for built-ins, otherwise "<kind>:<id>" (e.g. "plugin:echo", "skill:my-skill",
24740
+ "mcp:server").'
24741
+ required:
24742
+ - name
24743
+ - description
24744
+ - riskLevel
24745
+ - category
24746
+ - source
24747
+ additionalProperties: false
24748
+ required:
24749
+ - names
24750
+ - schemas
24751
+ - tools
24752
+ additionalProperties: false
24753
+ parameters:
24754
+ - name: conversationId
24755
+ in: query
24756
+ required: false
24757
+ schema:
24758
+ type: string
24759
+ description: When set, scope the tool inventory to this conversation's most recent turn instead of the global registry.
24699
24760
  /v1/tools/simulate-permission:
24700
24761
  post:
24701
24762
  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.202606101324.2fc90b3",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -365,7 +365,7 @@ describe("AgentLoop exit-reason instrumentation", () => {
365
365
  });
366
366
 
367
367
  const onCheckpoint = (_info: CheckpointInfo): CheckpointDecision =>
368
- "budget";
368
+ "handoff";
369
369
 
370
370
  const events: AgentEvent[] = [];
371
371
  await loop.run({
@@ -380,42 +380,9 @@ describe("AgentLoop exit-reason instrumentation", () => {
380
380
  expect(countExitEvents(events)).toBe(0);
381
381
  });
382
382
 
383
- test("yields with 'budget' when the in-loop budget gate trips", async () => {
384
- // GIVEN a provider that calls a tool (reaching a checkpoint) before it
385
- // would continue with a text response.
386
- const { provider } = createMockProvider([
387
- toolUseResponse("t1", "read_file", { path: "/a.txt" }),
388
- textResponse("never reached"),
389
- ]);
390
- const toolExecutor = async () => ({ content: "ok", isError: false });
391
- const loop = new AgentLoop({
392
- provider: provider,
393
- systemPrompt: "system",
394
- conversationId: "test-conversation",
395
- tools: dummyTools,
396
- toolExecutor: toolExecutor,
397
- });
398
-
399
- // AND an effective context window so small that any real token estimate
400
- // of the running history exceeds the mid-loop threshold.
401
- // WHEN the loop checkpoints after the tool results land
402
- const result = await loop.run({
403
- messages: [userMessage],
404
- onEvent: () => {},
405
- trust: { sourceChannel: "vellum", trustClass: "unknown" },
406
- resolveContextWindow: () => ({
407
- maxInputTokens: 10,
408
- overflowRecovery: { enabled: true, safetyMarginRatio: 0 },
409
- }),
410
- });
411
-
412
- // THEN it yields for budget before issuing the next provider call.
413
- expect(result.exitReason).toBe("budget");
414
- });
415
-
416
- test("does not yield for budget when overflow recovery is disabled", async () => {
417
- // GIVEN the same tiny context window but overflow recovery disabled — the
418
- // agent-wake configuration, which must never yield for budget.
383
+ test("runs to a clean exit when overflow recovery is disabled", async () => {
384
+ // GIVEN a tiny context window but overflow recovery disabled — the
385
+ // agent-wake configuration, which must never compact mid-loop.
419
386
  const { provider } = createMockProvider([
420
387
  toolUseResponse("t1", "read_file", { path: "/a.txt" }),
421
388
  textResponse("done"),
@@ -440,8 +407,8 @@ describe("AgentLoop exit-reason instrumentation", () => {
440
407
  }),
441
408
  });
442
409
 
443
- // THEN it never yields for budget.
444
- expect(result.exitReason).not.toBe("budget");
410
+ // THEN it reaches a clean exit without pausing the loop.
411
+ expect(result.exitReason).toBeNull();
445
412
  });
446
413
 
447
414
  test("compacts in place and continues when the budget gate trips with a compaction hook", async () => {
@@ -485,52 +452,13 @@ describe("AgentLoop exit-reason instrumentation", () => {
485
452
  });
486
453
 
487
454
  // THEN the loop runs the compaction ceremony in place and continues to a
488
- // clean exit instead of yielding for budget. The durable commit is
489
- // signalled via a `compaction_completed` event rather than an injected hook.
455
+ // clean exit. The durable commit is signalled via a `compaction_completed`
456
+ // event rather than an injected hook.
490
457
  expect(events.some((event) => event.type === "compaction_completed")).toBe(
491
458
  true,
492
459
  );
493
460
  expect(reinjected).toBe(true);
494
- expect(result.exitReason).not.toBe("budget");
495
- });
496
-
497
- test("yields 'budget' when inline compaction reports exhausted", async () => {
498
- const { provider } = createMockProvider([
499
- toolUseResponse("t1", "read_file", { path: "/a.txt" }),
500
- textResponse("never reached"),
501
- ]);
502
- const toolExecutor = async () => ({ content: "ok", isError: false });
503
- const loop = new AgentLoop({
504
- provider: provider,
505
- systemPrompt: "system",
506
- conversationId: "test-conversation",
507
- tools: dummyTools,
508
- toolExecutor: toolExecutor,
509
- });
510
-
511
- postCompactImpl = async () => {
512
- throw new Error(
513
- "post-compaction re-injection must not run when exhausted",
514
- );
515
- };
516
-
517
- // WHEN compaction exhausts its retry budget
518
- const result = await loop.run({
519
- messages: [userMessage],
520
- onEvent: () => {},
521
- resolveContextWindow: () => ({
522
- maxInputTokens: 10,
523
- overflowRecovery: { enabled: true, safetyMarginRatio: 0 },
524
- }),
525
- compactInPlace: true,
526
- ...fakeCompaction("test-conversation", {
527
- compacted: false,
528
- exhausted: true,
529
- }),
530
- });
531
-
532
- // THEN the loop yields for budget so the orchestrator can escalate.
533
- expect(result.exitReason).toBe("budget");
461
+ expect(result.exitReason).toBeNull();
534
462
  });
535
463
 
536
464
  test("emits 'error' when provider throws an unhandled error", async () => {
@@ -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,55 @@ describe("AgentLoop", () => {
311
312
  ).toBe("API rate limit exceeded");
312
313
  });
313
314
 
315
+ // 5b. Reactive context-overflow recovery
316
+ //
317
+ // When recovery is enabled the loop calibrates the estimator from the
318
+ // rejection, loops back, and the budget gate forwards the overflow signal
319
+ // into the compaction plugin's reduction ladder. That manager-owned path —
320
+ // single-rung recovery, multi-rung escalation, and the terminal
321
+ // `context_too_large` / `budget_yield_unrecovered` exit on exhaustion — is
322
+ // exercised end-to-end against a manager harness in
323
+ // `conversation-agent-loop-overflow.test.ts`. Loop-level coverage against a
324
+ // manager-store stub lands with the suite migration.
325
+ test.todo(
326
+ "drives the reduction ladder on a context-overflow rejection",
327
+ () => {},
328
+ );
329
+
330
+ test("surfaces a context-overflow error when recovery is unavailable", async () => {
331
+ /**
332
+ * Overflow recovery is gated on the budget gate being active. When it is
333
+ * disabled (e.g. agent wakes pass `overflowRecovery.enabled = false`, or no
334
+ * context window resolves) there is no ladder to drive, so a context
335
+ * overflow surfaces as an error on the first call rather than looping.
336
+ */
337
+
338
+ // GIVEN a provider that rejects every call as too large and a loop with no
339
+ // overflow-recovery budget gate
340
+ const { provider, calls } = createMockProvider([
341
+ new ContextOverflowError("prompt is too long", "mock", {
342
+ actualTokens: 250_000,
343
+ }),
344
+ ]);
345
+ const loop = new AgentLoop({
346
+ provider,
347
+ systemPrompt: "system",
348
+ conversationId: "test-conversation",
349
+ });
350
+ const events: AgentEvent[] = [];
351
+
352
+ // WHEN the loop runs
353
+ await loop.run({
354
+ messages: [userMessage],
355
+ onEvent: collectEvents(events),
356
+ trust: { sourceChannel: "vellum", trustClass: "unknown" },
357
+ });
358
+
359
+ // THEN it surfaced the error on the first call without retrying
360
+ expect(calls).toHaveLength(1);
361
+ expect(events.filter((e) => e.type === "error")).toHaveLength(1);
362
+ });
363
+
314
364
  // 6. Abort signal — verify the loop respects AbortSignal
315
365
  test("stops when abort signal is triggered before provider call", async () => {
316
366
  const controller = new AbortController();
@@ -1036,7 +1086,7 @@ describe("AgentLoop", () => {
1036
1086
  toolExecutor: toolExecutor,
1037
1087
  });
1038
1088
 
1039
- const onCheckpoint = (): CheckpointDecision => "budget";
1089
+ const onCheckpoint = (): CheckpointDecision => "handoff";
1040
1090
 
1041
1091
  const { history } = await loop.run({
1042
1092
  messages: [userMessage],
@@ -1235,7 +1285,7 @@ describe("AgentLoop", () => {
1235
1285
  const onCheckpoint = (checkpoint: CheckpointInfo): CheckpointDecision => {
1236
1286
  checkpoints.push(checkpoint);
1237
1287
  // Yield on turn 3 (0-indexed)
1238
- return checkpoint.turnIndex === 3 ? "budget" : "continue";
1288
+ return checkpoint.turnIndex === 3 ? "handoff" : "continue";
1239
1289
  };
1240
1290
 
1241
1291
  const events: AgentEvent[] = [];
@@ -1308,7 +1358,7 @@ describe("AgentLoop", () => {
1308
1358
 
1309
1359
  const onCheckpoint = (checkpoint: CheckpointInfo): CheckpointDecision => {
1310
1360
  // Yield on the second turn (turnIndex 1)
1311
- return checkpoint.turnIndex === 1 ? "budget" : "continue";
1361
+ return checkpoint.turnIndex === 1 ? "handoff" : "continue";
1312
1362
  };
1313
1363
 
1314
1364
  const { history } = await loop.run({
@@ -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
+ });
@@ -123,7 +123,7 @@ mock.module(
123
123
  }),
124
124
  );
125
125
 
126
- mock.module("../daemon/context-overflow-policy.js", () => ({
126
+ mock.module("../plugins/defaults/compaction/overflow-policy.js", () => ({
127
127
  resolveOverflowAction: () => "fail_gracefully",
128
128
  }));
129
129