@vellumai/assistant 0.8.9-dev.202606091926.ebb2d62 → 0.8.9-dev.202606092047.e63da55

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 (43) hide show
  1. package/examples/plugins/echo/README.md +3 -3
  2. package/openapi.yaml +13 -9
  3. package/package.json +1 -1
  4. package/src/__tests__/agent-loop-exit-reason.test.ts +9 -6
  5. package/src/__tests__/conversation-fork-crud.test.ts +110 -0
  6. package/src/__tests__/conversation-runtime-assembly.test.ts +53 -5
  7. package/src/__tests__/external-plugin-loader.test.ts +0 -1
  8. package/src/__tests__/plugin-bootstrap.test.ts +3 -15
  9. package/src/__tests__/plugin-types.test.ts +0 -8
  10. package/src/__tests__/skills.test.ts +123 -0
  11. package/src/agent/loop.ts +20 -18
  12. package/src/api/responses/conversation-message.ts +33 -2
  13. package/src/cli/commands/plugins.ts +2 -2
  14. package/src/cli/lib/__tests__/install-from-github.test.ts +15 -17
  15. package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +1 -1
  16. package/src/cli/lib/__tests__/plugin-details.test.ts +1 -1
  17. package/src/cli/lib/__tests__/plugin-marketplace.test.ts +1 -1
  18. package/src/cli/lib/__tests__/search-plugins.test.ts +11 -11
  19. package/src/cli/lib/install-from-github.ts +9 -9
  20. package/src/cli/lib/plugin-catalog-cache.ts +1 -1
  21. package/src/cli/lib/plugin-details.ts +4 -4
  22. package/src/cli/lib/plugin-marketplace.ts +2 -2
  23. package/src/cli/lib/search-plugins.ts +6 -6
  24. package/src/config/skills.ts +101 -47
  25. package/src/daemon/conversation-agent-loop.ts +10 -7
  26. package/src/daemon/conversation-runtime-assembly.ts +7 -0
  27. package/src/daemon/external-plugins-bootstrap.ts +15 -59
  28. package/src/daemon/handlers/skills.ts +6 -2
  29. package/src/daemon/message-types/skills.ts +4 -0
  30. package/src/memory/conversation-crud.ts +44 -1
  31. package/src/memory/v2/__tests__/injected-block-slugs.test.ts +51 -0
  32. package/src/memory/v2/activation-store.ts +50 -1
  33. package/src/memory/v2/injected-block-slugs.ts +30 -0
  34. package/src/plugin-api/constants.ts +2 -0
  35. package/src/plugins/defaults/memory-retrieval/hooks/post-compact.ts +65 -40
  36. package/src/plugins/defaults/memory-retrieval/hooks/user-prompt-submit.ts +5 -1
  37. package/src/plugins/external-api.ts +1 -1
  38. package/src/plugins/types.ts +5 -51
  39. package/src/runtime/routes/__tests__/plugins-routes.test.ts +4 -4
  40. package/src/runtime/routes/plugins-routes.ts +3 -3
  41. package/src/tools/skills/load.ts +11 -6
  42. package/src/__tests__/plugin-skill-contribution.test.ts +0 -356
  43. package/src/plugins/plugin-skill-contributions.ts +0 -292
@@ -16,8 +16,8 @@ to eyeball which hooks fire during a conversation.
16
16
 
17
17
  For the full plugin authoring guide — manifest shape, every contribution
18
18
  surface, hook patterns, and conventions — see
19
- [`experimental/plugins/README.md`](../../../../experimental/plugins/README.md).
20
- [`simple-memory`](../../../../experimental/plugins/simple-memory/) is the
19
+ [`plugins/README.md`](../../../../plugins/README.md).
20
+ [`simple-memory`](../../../../plugins/simple-memory/) is the
21
21
  canonical reference implementation that exercises every wired surface.
22
22
 
23
23
  ## What it does
@@ -115,7 +115,7 @@ vellum restart
115
115
 
116
116
  ## Next steps
117
117
 
118
- - Read [`experimental/plugins/README.md`](../../../../experimental/plugins/README.md)
118
+ - Read [`plugins/README.md`](../../../../plugins/README.md)
119
119
  for the full plugin authoring guide: manifest shape, every contribution
120
120
  surface, hook patterns (observe / transform), tool contributions, and
121
121
  conventions.
package/openapi.yaml CHANGED
@@ -16251,6 +16251,8 @@ paths:
16251
16251
  - name
16252
16252
  - input
16253
16253
  additionalProperties: false
16254
+ deprecated: true
16255
+ description: "Deprecated: superseded by contentBlocks (the tool_use variant). Flat list of tool calls."
16254
16256
  surfaces:
16255
16257
  type: array
16256
16258
  items:
@@ -16302,6 +16304,8 @@ paths:
16302
16304
  - surfaceType
16303
16305
  - data
16304
16306
  additionalProperties: false
16307
+ deprecated: true
16308
+ description: "Deprecated: superseded by contentBlocks (the surface variant). Flat list of surfaces."
16305
16309
  textSegments:
16306
16310
  type: array
16307
16311
  items:
@@ -19471,11 +19475,11 @@ paths:
19471
19475
  operationId: plugins_install_post
19472
19476
  summary: Install a plugin
19473
19477
  description:
19474
- "Install a plugin by name from the canonical source — a whitelisted `experimental/plugins/marketplace.json`
19475
- entry, else the first-party `experimental/plugins/<name>/` convention. Always resolves against the curated
19476
- default git ref (no caller-supplied ref): installing from an unreviewed revision would bypass the
19477
- marketplace/first-party curation boundary and let attacker-controlled code be loaded. Materializes the plugin
19478
- under `<workspaceDir>/plugins/<name>/`; the assistant must be restarted to load it. Mirrors the CLI's `assistant
19478
+ "Install a plugin by name from the canonical source — a whitelisted `plugins/marketplace.json` entry, else
19479
+ the first-party `plugins/<name>/` convention. Always resolves against the curated default git ref (no
19480
+ caller-supplied ref): installing from an unreviewed revision would bypass the marketplace/first-party curation
19481
+ boundary and let attacker-controlled code be loaded. Materializes the plugin under
19482
+ `<workspaceDir>/plugins/<name>/`; the assistant must be restarted to load it. Mirrors the CLI's `assistant
19479
19483
  plugins install <name>`. An already-installed name without `force` returns 409; a name that resolves to nothing
19480
19484
  returns 404. Sibling to `POST /v1/skills/install`."
19481
19485
  tags:
@@ -19537,9 +19541,9 @@ paths:
19537
19541
  operationId: plugins_search_get
19538
19542
  summary: Search the plugin catalog
19539
19543
  description:
19540
- List installable plugins from the canonical `vellum-ai/vellum-assistant` catalog at
19541
- `experimental/plugins/`. The query is an ECMAScript regex matched case-insensitively against the directory name
19542
- (e.g. `memory`, `^simple`). Empty query returns every entry. Mirrors the CLI's `assistant plugins search`.
19544
+ List installable plugins from the canonical `vellum-ai/vellum-assistant` catalog at `plugins/`. The query
19545
+ is an ECMAScript regex matched case-insensitively against the directory name (e.g. `memory`, `^simple`). Empty
19546
+ query returns every entry. Mirrors the CLI's `assistant plugins search`.
19543
19547
  tags:
19544
19548
  - plugins
19545
19549
  responses:
@@ -19567,7 +19571,7 @@ paths:
19567
19571
  path:
19568
19572
  type: string
19569
19573
  description:
19570
- "Human-readable origin: a repo-relative path (`experimental/plugins/<name>`) for first-party plugins or a
19574
+ "Human-readable origin: a repo-relative path (`plugins/<name>`) for first-party plugins or a
19571
19575
  `github:owner/repo@ref` locator for external ones."
19572
19576
  description:
19573
19577
  description: Short description, when known (external entries only today).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.8.9-dev.202606091926.ebb2d62",
3
+ "version": "0.8.9-dev.202606092047.e63da55",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -39,18 +39,21 @@ import type {
39
39
 
40
40
  // The agent loop invokes the default post-compaction re-injection hook directly
41
41
  // when it compacts in place. Stub it so these unit tests can drive the
42
- // re-injection result without the daemon-level injector chain. Tests assign
43
- // `postCompactImpl` to observe the call or force a failure; when unset
44
- // the hook is a no-op that returns the history it was handed.
42
+ // re-injection result without the daemon-level injector chain. The hook writes
43
+ // the re-injected history back onto the context; tests assign `postCompactImpl`
44
+ // to observe the call or force a failure; when unset the hook leaves the
45
+ // history it was handed untouched.
45
46
  let postCompactImpl:
46
47
  | ((input: PostCompactContext) => Promise<Message[]>)
47
48
  | null = null;
48
49
  mock.module(
49
50
  "../plugins/defaults/memory-retrieval/hooks/post-compact.js",
50
51
  () => ({
51
- default: async (input: PostCompactContext) => ({
52
- messages: postCompactImpl ? await postCompactImpl(input) : input.history,
53
- }),
52
+ default: async (input: PostCompactContext): Promise<void> => {
53
+ input.history = postCompactImpl
54
+ ? await postCompactImpl(input)
55
+ : input.history;
56
+ },
54
57
  }),
55
58
  );
56
59
 
@@ -922,6 +922,116 @@ describe("forkConversation", () => {
922
922
  expect(loadGraphMemoryState(fork.id)).toBeNull();
923
923
  });
924
924
 
925
+ test("truncated fork seeds everInjected from inherited memory attachments", async () => {
926
+ const source = createConversation("Truncated seed thread");
927
+ await addMessage(source.id, "user", "first turn", {
928
+ metadata: {
929
+ memoryInjectedBlock:
930
+ "# memory/concepts/topics/page-a.md\nSummary A\n\n# memory/concepts/topics/page-b.md\nSummary B",
931
+ },
932
+ skipIndexing: true,
933
+ });
934
+ await addMessage(source.id, "assistant", "first reply", {
935
+ skipIndexing: true,
936
+ });
937
+ const boundaryMessage = await addMessage(source.id, "user", "second turn", {
938
+ metadata: {
939
+ memoryInjectedBlock: "# memory/concepts/topics/page-c.md\nSummary C",
940
+ },
941
+ skipIndexing: true,
942
+ });
943
+ await addMessage(source.id, "assistant", "second reply", {
944
+ skipIndexing: true,
945
+ });
946
+ // Past the fork boundary — its attachment must NOT be claimed.
947
+ await addMessage(source.id, "user", "third turn", {
948
+ metadata: {
949
+ memoryInjectedBlock: "# memory/concepts/topics/page-d.md\nSummary D",
950
+ },
951
+ skipIndexing: true,
952
+ });
953
+
954
+ const db = getDb();
955
+ db.insert(activationState)
956
+ .values({
957
+ conversationId: source.id,
958
+ messageId: "parent-msg",
959
+ stateJson: JSON.stringify({ "topics/page-d": 0.9 }),
960
+ everInjectedJson: JSON.stringify([
961
+ { slug: "topics/page-a", turn: 1 },
962
+ { slug: "topics/page-b", turn: 1 },
963
+ { slug: "topics/page-c", turn: 2 },
964
+ { slug: "topics/page-d", turn: 3 },
965
+ ]),
966
+ currentTurn: 3,
967
+ updatedAt: 1_700_000_000_000,
968
+ })
969
+ .run();
970
+
971
+ const fork = forkConversation({
972
+ conversationId: source.id,
973
+ throughMessageId: boundaryMessage.id,
974
+ });
975
+
976
+ const childState = await hydrateActivationState(db, fork.id);
977
+ expect(childState).not.toBeNull();
978
+ // Exactly the slugs whose attachments live in the copied history —
979
+ // page-d (injected past the boundary) stays re-injectable.
980
+ expect(childState?.everInjected.map((e) => e.slug).sort()).toEqual([
981
+ "topics/page-a",
982
+ "topics/page-b",
983
+ "topics/page-c",
984
+ ]);
985
+ expect(childState?.everInjected.every((e) => e.turn === 0)).toBe(true);
986
+ // Activation scores and the turn counter start fresh, matching the
987
+ // graph tracker (also not copied for truncated forks).
988
+ expect(childState?.state).toEqual({});
989
+ expect(childState?.currentTurn).toBe(0);
990
+ });
991
+
992
+ test("truncated fork ignores attachments behind an inherited compaction boundary", async () => {
993
+ const source = createConversation("Compacted truncated thread");
994
+ await addMessage(source.id, "user", "compacted turn", {
995
+ metadata: {
996
+ memoryInjectedBlock:
997
+ "# memory/concepts/topics/page-compacted.md\nOld summary",
998
+ },
999
+ skipIndexing: true,
1000
+ });
1001
+ await addMessage(source.id, "assistant", "compacted reply", {
1002
+ skipIndexing: true,
1003
+ });
1004
+ const boundaryMessage = await addMessage(source.id, "user", "live turn", {
1005
+ metadata: {
1006
+ memoryInjectedBlock: "# memory/concepts/topics/page-live.md\nSummary",
1007
+ },
1008
+ skipIndexing: true,
1009
+ });
1010
+ await addMessage(source.id, "assistant", "live reply", {
1011
+ skipIndexing: true,
1012
+ });
1013
+ await addMessage(source.id, "user", "past boundary", {
1014
+ skipIndexing: true,
1015
+ });
1016
+ // First two messages sit behind the compaction boundary: their
1017
+ // attachments are not rendered, so the fork must not claim them.
1018
+ getDb()
1019
+ .update(conversations)
1020
+ .set({ contextCompactedMessageCount: 2 })
1021
+ .where(eq(conversations.id, source.id))
1022
+ .run();
1023
+
1024
+ const fork = forkConversation({
1025
+ conversationId: source.id,
1026
+ throughMessageId: boundaryMessage.id,
1027
+ });
1028
+
1029
+ const childState = await hydrateActivationState(getDb(), fork.id);
1030
+ expect(childState?.everInjected.map((e) => e.slug)).toEqual([
1031
+ "topics/page-live",
1032
+ ]);
1033
+ });
1034
+
925
1035
  test("defaults conversationType to standard and inherits the parent's group", async () => {
926
1036
  const source = createConversation("Default inheritance thread");
927
1037
  await addMessage(source.id, "user", "first message", {
@@ -64,6 +64,7 @@ mock.module("../daemon/date-context.js", () => ({
64
64
  formatTurnTimestamp: () => FIXED_TURN_TIMESTAMP,
65
65
  }));
66
66
 
67
+ import { resolveCallSiteConfig } from "../config/llm-resolver.js";
67
68
  import { LLMSchema } from "../config/schemas/llm.js";
68
69
  import {
69
70
  clearConversations,
@@ -107,7 +108,9 @@ import {
107
108
  writeSlackMetadata,
108
109
  } from "../messaging/providers/slack/message-metadata.js";
109
110
  import { parentAlias } from "../messaging/providers/slack/render-transcript.js";
110
- import postCompact from "../plugins/defaults/memory-retrieval/hooks/post-compact.js";
111
+ import postCompact, {
112
+ type PostCompactContext,
113
+ } from "../plugins/defaults/memory-retrieval/hooks/post-compact.js";
111
114
  import {
112
115
  buildUnifiedTurnContextBlock,
113
116
  type UnifiedTurnContextOptions,
@@ -1029,16 +1032,17 @@ describe("applyRuntimeInjections — injection mode", () => {
1029
1032
  // only resolve when `applyRuntimeInjections` finds it by conversation id
1030
1033
  // AND the agent loop hands the post-compaction hook the turn-identity fields
1031
1034
  // flat (`requestId`, `conversationId`, `trust`)
1032
- const { messages: result } = await postCompact({
1035
+ const postCompactCtx: PostCompactContext = {
1033
1036
  history: baseMessages,
1034
1037
  requestId: "reinject-req",
1035
1038
  conversationId: "injection-mode-conv",
1036
1039
  trust: { sourceChannel: "vellum", trustClass: "guardian" },
1037
1040
  isNonInteractive: false,
1038
- mode: "full",
1039
- modelProfile: null,
1041
+ modelProfileKey: null,
1040
1042
  actorContext: null,
1041
- });
1043
+ };
1044
+ await postCompact(postCompactCtx);
1045
+ const result = postCompactCtx.history;
1042
1046
  const allText = result[0].content
1043
1047
  .filter((b): b is { type: "text"; text: string } => b.type === "text")
1044
1048
  .map((b) => b.text)
@@ -1572,6 +1576,50 @@ describe("resolveTurnModelProfileLabel", () => {
1572
1576
  // THEN the raw key stands in for the missing label
1573
1577
  expect(label).toBe("deep (claude-opus-4)");
1574
1578
  });
1579
+
1580
+ /**
1581
+ * A key naming a `mix` profile must announce the arm the turn's provider
1582
+ * calls actually run on. The conversation id is threaded as the selection
1583
+ * seed so the announced model is a deterministic function of the
1584
+ * conversation, matching the seeded expansion the provider layer performs.
1585
+ */
1586
+ test("announces the seeded mix arm so it matches the turn's model", () => {
1587
+ // GIVEN a mix profile routing between two models
1588
+ const llm = LLMSchema.parse({
1589
+ default: { provider: "anthropic", model: "claude-sonnet-4-7" },
1590
+ profiles: {
1591
+ a: { label: "Arm A", provider: "anthropic", model: "model-a" },
1592
+ b: { label: "Arm B", provider: "anthropic", model: "model-b" },
1593
+ ab: {
1594
+ label: "AB",
1595
+ mix: [
1596
+ { profile: "a", weight: 50 },
1597
+ { profile: "b", weight: 50 },
1598
+ ],
1599
+ },
1600
+ },
1601
+ });
1602
+
1603
+ // AND the model the provider calls resolve to for this conversation's seed
1604
+ const seededModel = resolveCallSiteConfig("mainAgent", llm, {
1605
+ overrideProfile: "ab",
1606
+ selectionSeed: "conv-seed-1",
1607
+ }).model;
1608
+
1609
+ // WHEN the label is resolved with the same conversation seed
1610
+ const label = resolveTurnModelProfileLabel(
1611
+ "ab",
1612
+ "mainAgent",
1613
+ llm,
1614
+ "conv-seed-1",
1615
+ );
1616
+
1617
+ // THEN the announced model is the seeded arm (deterministic, not re-rolled)
1618
+ expect(label).toBe(`AB (${seededModel})`);
1619
+ expect(
1620
+ resolveTurnModelProfileLabel("ab", "mainAgent", llm, "conv-seed-1"),
1621
+ ).toBe(label);
1622
+ });
1575
1623
  });
1576
1624
 
1577
1625
  // ---------------------------------------------------------------------------
@@ -533,7 +533,6 @@ describe("loadExternalPlugin — end-to-end @vellumai/simple-memory", () => {
533
533
  "..",
534
534
  "..",
535
535
  "..",
536
- "experimental",
537
536
  "plugins",
538
537
  "simple-memory",
539
538
  );
@@ -399,11 +399,11 @@ describe("plugin bootstrap", () => {
399
399
  expect(initFired).toBe(true);
400
400
  });
401
401
 
402
- test("requiresFlag disabled: init does not fire and no tools/routes/skills are registered", async () => {
402
+ test("requiresFlag disabled: init does not fire and no tools/routes are registered", async () => {
403
403
  setOverridesForTesting({ "plugin-gated-disabled": false });
404
404
 
405
405
  let initFired = false;
406
- // Attach tool/route/skill contributions alongside init. If gating works,
406
+ // Attach tool/route contributions alongside init. If gating works,
407
407
  // none of them should land in their respective registries.
408
408
  const plugin = buildPlugin(
409
409
  "gated-off",
@@ -430,24 +430,14 @@ describe("plugin bootstrap", () => {
430
430
  handler: async () => new Response("ok"),
431
431
  },
432
432
  ],
433
- skills: [
434
- {
435
- id: "gated-off/skill",
436
- name: "gated-off-skill",
437
- description: "should not be catalogued",
438
- body: "# unused",
439
- },
440
- ],
441
433
  },
442
434
  { requiresFlag: ["plugin-gated-disabled"] },
443
435
  );
444
436
  registerPlugin(plugin);
445
437
 
446
- // Grab tool / route / skill introspection helpers lazily so the import
438
+ // Grab tool / route introspection helpers lazily so the import
447
439
  // side effect happens after `mock.module` has taken effect.
448
440
  const { getTool } = await import("../tools/registry.js");
449
- const { getPluginSkillRefCount } =
450
- await import("../plugins/plugin-skill-contributions.js");
451
441
  const { matchSkillRoute } =
452
442
  await import("../runtime/skill-route-registry.js");
453
443
 
@@ -459,8 +449,6 @@ describe("plugin bootstrap", () => {
459
449
  expect(getTool("gated-off-tool")).toBeUndefined();
460
450
  // No route wired up — `matchSkillRoute` returns null when nothing matches.
461
451
  expect(matchSkillRoute("/_plugin/gated-off/status", "GET")).toBeNull();
462
- // No skill catalogued under this plugin's name — ref count stays 0.
463
- expect(getPluginSkillRefCount("gated-off")).toBe(0);
464
452
  });
465
453
 
466
454
  test("requiresFlag absent: plugin activates unconditionally", async () => {
@@ -78,14 +78,6 @@ describe("plugin core types", () => {
78
78
  handler: async () => new Response("ok", { status: 200 }),
79
79
  },
80
80
  ],
81
- skills: [
82
- {
83
- id: "sample-skill",
84
- name: "Sample Skill",
85
- description: "Demo plugin-contributed skill",
86
- body: "## Sample\n\nPlugin-provided skill body.",
87
- },
88
- ],
89
81
  } satisfies Plugin;
90
82
 
91
83
  // Minimal runtime check so the test body is non-empty.
@@ -252,6 +252,129 @@ describe("workspace skills", () => {
252
252
  });
253
253
  });
254
254
 
255
+ describe("plugin-resident skills", () => {
256
+ const pluginsDir = join(TEST_DIR, "plugins");
257
+
258
+ /**
259
+ * Materialize a skill on disk inside an installed plugin directory:
260
+ * `plugins/<plugin>/skills/<skillId>/SKILL.md`. A `package.json` is written
261
+ * for the plugin by default since that is the install gate the discovery
262
+ * scan keys on; pass `withPackageJson: false` to simulate a stray directory.
263
+ */
264
+ function writePluginSkill(
265
+ pluginName: string,
266
+ skillId: string,
267
+ name: string,
268
+ description: string,
269
+ body: string = "Plugin skill body",
270
+ {
271
+ withPackageJson = true,
272
+ packageName,
273
+ }: { withPackageJson?: boolean; packageName?: string } = {},
274
+ ): void {
275
+ const pluginDir = join(pluginsDir, pluginName);
276
+ mkdirSync(pluginDir, { recursive: true });
277
+ if (withPackageJson) {
278
+ writeFileSync(
279
+ join(pluginDir, "package.json"),
280
+ JSON.stringify({ name: packageName ?? pluginName, version: "1.0.0" }),
281
+ );
282
+ }
283
+ const skillDir = join(pluginDir, "skills", skillId);
284
+ mkdirSync(skillDir, { recursive: true });
285
+ writeFileSync(
286
+ join(skillDir, "SKILL.md"),
287
+ `---\nname: "${name}"\ndescription: "${description}"\n---\n\n${body}\n`,
288
+ );
289
+ }
290
+
291
+ beforeEach(() => {
292
+ mkdirSync(join(TEST_DIR, "skills"), { recursive: true });
293
+ });
294
+
295
+ afterEach(() => {
296
+ for (const dir of [join(TEST_DIR, "skills"), pluginsDir]) {
297
+ if (existsSync(dir)) rmSync(dir, { recursive: true, force: true });
298
+ }
299
+ });
300
+
301
+ test("discovers skills shipped inside an installed plugin, attributed to it", () => {
302
+ writePluginSkill("caveman", "caveman", "Caveman", "Terse mode");
303
+
304
+ const skill = loadSkillCatalog().find((s) => s.id === "caveman");
305
+ expect(skill).toBeDefined();
306
+ expect(skill!.source).toBe("plugin");
307
+ expect(skill!.owner).toEqual({ kind: "plugin", id: "caveman" });
308
+ });
309
+
310
+ test("loadSkillBySelector loads a plugin-resident skill body", () => {
311
+ writePluginSkill(
312
+ "caveman",
313
+ "caveman",
314
+ "Caveman",
315
+ "Terse mode",
316
+ "Full plugin skill body",
317
+ );
318
+
319
+ const result = loadSkillBySelector("caveman");
320
+ expect(result.error).toBeUndefined();
321
+ expect(result.skill).toBeDefined();
322
+ expect(result.skill!.source).toBe("plugin");
323
+ expect(result.skill!.body).toBe("Full plugin skill body");
324
+ expect(result.skill!.owner).toEqual({ kind: "plugin", id: "caveman" });
325
+ });
326
+
327
+ test("ignores plugin directories without a package.json (staging/stray dirs)", () => {
328
+ writePluginSkill(
329
+ "half-installed",
330
+ "ghost",
331
+ "Ghost",
332
+ "Should be skipped",
333
+ "body",
334
+ { withPackageJson: false },
335
+ );
336
+
337
+ const skill = loadSkillCatalog().find((s) => s.id === "ghost");
338
+ expect(skill).toBeUndefined();
339
+ });
340
+
341
+ test("ignores plugin dirs whose package.json name mismatches the directory", () => {
342
+ // Mirrors the loader's recognition gate: an un-adapted clone whose
343
+ // package.json declares `caveman-installer` in a `caveman` dir is skipped.
344
+ writePluginSkill("caveman", "caveman", "Caveman", "Terse mode", "body", {
345
+ packageName: "caveman-installer",
346
+ });
347
+
348
+ const skill = loadSkillCatalog().find((s) => s.id === "caveman");
349
+ expect(skill).toBeUndefined();
350
+ });
351
+
352
+ test("workspace skill overrides a plugin-resident skill with the same id", () => {
353
+ const WORKSPACE_DIR = join(
354
+ tmpdir(),
355
+ `vellum-workspace-test-${crypto.randomUUID()}`,
356
+ );
357
+ const workspaceSkillsDir = join(WORKSPACE_DIR, ".vellum", "skills");
358
+ mkdirSync(join(workspaceSkillsDir, "shared-id"), { recursive: true });
359
+ writeFileSync(
360
+ join(workspaceSkillsDir, "shared-id", "SKILL.md"),
361
+ `---\nname: "Workspace Wins"\ndescription: "Workspace version"\n---\n\nbody\n`,
362
+ );
363
+ writePluginSkill("caveman", "shared-id", "Plugin Loses", "Plugin version");
364
+
365
+ try {
366
+ const skill = loadSkillCatalog(workspaceSkillsDir).find(
367
+ (s) => s.id === "shared-id",
368
+ );
369
+ expect(skill).toBeDefined();
370
+ expect(skill!.source).toBe("workspace");
371
+ expect(skill!.name).toBe("Workspace Wins");
372
+ } finally {
373
+ rmSync(WORKSPACE_DIR, { recursive: true, force: true });
374
+ }
375
+ });
376
+ });
377
+
255
378
  describe("tool manifest detection", () => {
256
379
  beforeEach(() => {
257
380
  mkdirSync(join(TEST_DIR, "skills"), { recursive: true });
package/src/agent/loop.ts CHANGED
@@ -23,7 +23,9 @@ import type {
23
23
  } from "../plugin-api/types.js";
24
24
  import { defaultCompact } from "../plugins/defaults/compaction/compact.js";
25
25
  import type { ContextWindowResult } from "../plugins/defaults/compaction/window-manager.js";
26
- import postCompact from "../plugins/defaults/memory-retrieval/hooks/post-compact.js";
26
+ import postCompact, {
27
+ type PostCompactContext,
28
+ } from "../plugins/defaults/memory-retrieval/hooks/post-compact.js";
27
29
  import { runHook } from "../plugins/pipeline.js";
28
30
  import type { CompactionCircuitEvent } from "../plugins/types.js";
29
31
  import { normalizeThinkingConfigForWire } from "../providers/thinking-config.js";
@@ -509,15 +511,14 @@ export interface AgentLoopRunOptions {
509
511
  */
510
512
  isNonInteractive?: boolean;
511
513
  /**
512
- * The `model_profile:` turn-context label resolved once by the orchestrator
513
- * at turn start, or `null` when the active inference profile is unchanged
514
- * since the last notified one. Forwarded to
515
- * {@link postCompact} so post-compaction re-injection
516
- * re-emits the turn-start value rather than re-deriving the change-detected
517
- * label (which flips once the notification is persisted mid-turn). Defaults to
518
- * `null` when omitted.
514
+ * The turn's resolved inference-profile key, or `null` when the active
515
+ * profile is unchanged since the last notified one. Forwarded to
516
+ * {@link postCompact}, which renders the `model_profile:` label from it so
517
+ * post-compaction re-injection re-emits the turn-start profile rather than
518
+ * re-deriving the change-detected value (which flips once the notification is
519
+ * persisted mid-turn). Defaults to `null` when omitted.
519
520
  */
520
- modelProfile?: string | null;
521
+ modelProfileKey?: string | null;
521
522
  /**
522
523
  * Inbound actor identity and trust fields for the unified `<turn_context>`
523
524
  * block, or `null` on guardian turns. Resolved once by the orchestrator at
@@ -715,7 +716,7 @@ export class AgentLoop {
715
716
  onEvent: (event: AgentEvent) => void | Promise<void>,
716
717
  overrideProfile: string | null,
717
718
  isNonInteractive: boolean,
718
- modelProfile: string | null,
719
+ modelProfileKey: string | null,
719
720
  actorContext: InboundActorContext | null,
720
721
  ): Promise<Message[] | null> {
721
722
  await onEvent({ type: "context_compacting" });
@@ -767,18 +768,19 @@ export class AgentLoop {
767
768
  // Re-inject onto the same base the `compaction_completed` dispatch commits:
768
769
  // the compacted messages when the pipeline compacted, the stripped
769
770
  // pre-compaction history otherwise.
770
- const injection = await postCompact({
771
+ const postCompactCtx: PostCompactContext = {
771
772
  history: compactResult.compacted ? compactResult.messages : rawHistory,
772
773
  requestId,
773
774
  conversationId: this.conversationId,
774
775
  trust,
775
776
  isNonInteractive,
776
- // Mid-loop re-injection always runs at full injection volume.
777
- mode: "full",
778
- modelProfile,
777
+ modelProfileKey,
779
778
  actorContext,
780
- });
781
- return injection.messages;
779
+ };
780
+ // The hook writes the re-injected history back onto the context; read it
781
+ // from there once the hook settles.
782
+ await postCompact(postCompactCtx);
783
+ return postCompactCtx.history;
782
784
  }
783
785
 
784
786
  async run(options: AgentLoopRunOptions): Promise<AgentLoopRunResult> {
@@ -795,7 +797,7 @@ export class AgentLoop {
795
797
  resolveContextWindow,
796
798
  compactInPlace = false,
797
799
  isNonInteractive = false,
798
- modelProfile = null,
800
+ modelProfileKey = null,
799
801
  actorContext = null,
800
802
  } = options;
801
803
  let history = [...messages];
@@ -923,7 +925,7 @@ export class AgentLoop {
923
925
  onEvent,
924
926
  resolveEffectiveOverrideProfile() ?? null,
925
927
  isNonInteractive,
926
- modelProfile,
928
+ modelProfileKey,
927
929
  actorContext,
928
930
  );
929
931
  if (compacted) {
@@ -374,9 +374,40 @@ export const ConversationMessageSchema = z.object({
374
374
  .optional(),
375
375
  /** Display timestamp as an ISO-8601 string. */
376
376
  timestamp: z.string(),
377
+ /**
378
+ * Flat list of attachment metadata for the row. Not yet supersedable by
379
+ * `contentBlocks`: `renderHistoryContent` emits an `attachment` content
380
+ * block only for file-block refs with an inline placement, so orphan rows
381
+ * (unmatched ids, count mismatch, no DB rows — see `alignAttachments`) ship
382
+ * here alone. Kept non-deprecated until `contentBlocks` reaches attachment
383
+ * parity, so clients that migrate off the positional arrays don't drop those
384
+ * chips.
385
+ */
377
386
  attachments: z.array(ConversationMessageAttachmentSchema),
378
- toolCalls: z.array(ConversationMessageToolCallSchema).optional(),
379
- surfaces: z.array(ConversationMessageSurfaceSchema).optional(),
387
+ /**
388
+ * @deprecated Superseded by `contentBlocks` (the `tool_use` variant). Flat
389
+ * list of tool calls for the row.
390
+ */
391
+ toolCalls: z
392
+ .array(ConversationMessageToolCallSchema)
393
+ .meta({
394
+ deprecated: true,
395
+ description:
396
+ "Deprecated: superseded by contentBlocks (the tool_use variant). Flat list of tool calls.",
397
+ })
398
+ .optional(),
399
+ /**
400
+ * @deprecated Superseded by `contentBlocks` (the `surface` variant). Flat
401
+ * list of surfaces for the row.
402
+ */
403
+ surfaces: z
404
+ .array(ConversationMessageSurfaceSchema)
405
+ .meta({
406
+ deprecated: true,
407
+ description:
408
+ "Deprecated: superseded by contentBlocks (the surface variant). Flat list of surfaces.",
409
+ })
410
+ .optional(),
380
411
  /**
381
412
  * @deprecated Superseded by `contentBlocks`. Text split by tool-call
382
413
  * boundaries; positional sibling of `contentOrder`.