@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.
- package/examples/plugins/echo/README.md +3 -3
- package/openapi.yaml +13 -9
- package/package.json +1 -1
- package/src/__tests__/agent-loop-exit-reason.test.ts +9 -6
- package/src/__tests__/conversation-fork-crud.test.ts +110 -0
- package/src/__tests__/conversation-runtime-assembly.test.ts +53 -5
- package/src/__tests__/external-plugin-loader.test.ts +0 -1
- package/src/__tests__/plugin-bootstrap.test.ts +3 -15
- package/src/__tests__/plugin-types.test.ts +0 -8
- package/src/__tests__/skills.test.ts +123 -0
- package/src/agent/loop.ts +20 -18
- package/src/api/responses/conversation-message.ts +33 -2
- package/src/cli/commands/plugins.ts +2 -2
- package/src/cli/lib/__tests__/install-from-github.test.ts +15 -17
- package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +1 -1
- package/src/cli/lib/__tests__/plugin-details.test.ts +1 -1
- package/src/cli/lib/__tests__/plugin-marketplace.test.ts +1 -1
- package/src/cli/lib/__tests__/search-plugins.test.ts +11 -11
- package/src/cli/lib/install-from-github.ts +9 -9
- package/src/cli/lib/plugin-catalog-cache.ts +1 -1
- package/src/cli/lib/plugin-details.ts +4 -4
- package/src/cli/lib/plugin-marketplace.ts +2 -2
- package/src/cli/lib/search-plugins.ts +6 -6
- package/src/config/skills.ts +101 -47
- package/src/daemon/conversation-agent-loop.ts +10 -7
- package/src/daemon/conversation-runtime-assembly.ts +7 -0
- package/src/daemon/external-plugins-bootstrap.ts +15 -59
- package/src/daemon/handlers/skills.ts +6 -2
- package/src/daemon/message-types/skills.ts +4 -0
- package/src/memory/conversation-crud.ts +44 -1
- package/src/memory/v2/__tests__/injected-block-slugs.test.ts +51 -0
- package/src/memory/v2/activation-store.ts +50 -1
- package/src/memory/v2/injected-block-slugs.ts +30 -0
- package/src/plugin-api/constants.ts +2 -0
- package/src/plugins/defaults/memory-retrieval/hooks/post-compact.ts +65 -40
- package/src/plugins/defaults/memory-retrieval/hooks/user-prompt-submit.ts +5 -1
- package/src/plugins/external-api.ts +1 -1
- package/src/plugins/types.ts +5 -51
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +4 -4
- package/src/runtime/routes/plugins-routes.ts +3 -3
- package/src/tools/skills/load.ts +11 -6
- package/src/__tests__/plugin-skill-contribution.test.ts +0 -356
- 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
|
-
[`
|
|
20
|
-
[`simple-memory`](../../../../
|
|
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 [`
|
|
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 `
|
|
19475
|
-
|
|
19476
|
-
|
|
19477
|
-
|
|
19478
|
-
|
|
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
|
-
|
|
19542
|
-
|
|
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 (`
|
|
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
|
@@ -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.
|
|
43
|
-
//
|
|
44
|
-
// the
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
// ---------------------------------------------------------------------------
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
513
|
-
*
|
|
514
|
-
*
|
|
515
|
-
*
|
|
516
|
-
* re-
|
|
517
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
777
|
-
mode: "full",
|
|
778
|
-
modelProfile,
|
|
777
|
+
modelProfileKey,
|
|
779
778
|
actorContext,
|
|
780
|
-
}
|
|
781
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
379
|
-
|
|
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`.
|