@vellumai/assistant 0.9.0-dev.202606191938.289c607 → 0.9.0-dev.202606192038.d313bf2
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/package.json +1 -1
- package/src/__tests__/conversation-skill-tools.test.ts +3 -1
- package/src/__tests__/first-class-skill-defs.test.ts +99 -0
- package/src/__tests__/secret-routes-platform-proxy.test.ts +16 -2
- package/src/__tests__/skill-tool-factory.test.ts +18 -0
- package/src/daemon/conversation-skill-tools.ts +4 -1
- package/src/daemon/conversation-tool-setup.ts +55 -1
- package/src/daemon/memory-v2-startup.test.ts +131 -0
- package/src/daemon/memory-v2-startup.ts +100 -1
- package/src/runtime/routes/secret-routes.ts +5 -0
- package/src/tools/skills/skill-tool-factory.ts +6 -1
package/package.json
CHANGED
|
@@ -372,7 +372,9 @@ describe("projectSkillTools", () => {
|
|
|
372
372
|
previouslyActiveSkillIds: sessionState,
|
|
373
373
|
});
|
|
374
374
|
|
|
375
|
-
// Tool definitions are
|
|
375
|
+
// Tool definitions are not sent to the LLM here — tools are invoked via
|
|
376
|
+
// skill_execute dispatch; weak-open-model first-class exposure resolves
|
|
377
|
+
// defs from the registry by name in createResolveToolsCallback.
|
|
376
378
|
expect(result.toolDefinitions).toEqual([]);
|
|
377
379
|
expect(result.allowedToolNames).toEqual(
|
|
378
380
|
new Set(["deploy_run", "deploy_status"]),
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for resolveFirstClassSkillDefs: weak open models get loaded skill tools
|
|
3
|
+
* exposed first-class (resolved from the registry by name), capable models do
|
|
4
|
+
* not, and the turn allowlist still gates which defs are included.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
8
|
+
|
|
9
|
+
import { resolveFirstClassSkillDefs } from "../daemon/conversation-tool-setup.js";
|
|
10
|
+
import { RiskLevel } from "../permissions/types.js";
|
|
11
|
+
import { registerSkillTools, unregisterSkillTools } from "../tools/registry.js";
|
|
12
|
+
import type { Tool } from "../tools/types.js";
|
|
13
|
+
|
|
14
|
+
const MINIMAX = "accounts/fireworks/models/minimax-m3";
|
|
15
|
+
const CLAUDE = "claude-opus-4-8";
|
|
16
|
+
|
|
17
|
+
const SKILL_ID = "first-class-test-skill";
|
|
18
|
+
|
|
19
|
+
function makeSkillTool(name: string): Tool {
|
|
20
|
+
return {
|
|
21
|
+
name,
|
|
22
|
+
description: `Test tool ${name}`,
|
|
23
|
+
category: "testing",
|
|
24
|
+
defaultRiskLevel: RiskLevel.Low,
|
|
25
|
+
executionTarget: "host",
|
|
26
|
+
input_schema: {
|
|
27
|
+
type: "object",
|
|
28
|
+
properties: { content: { type: "string" } },
|
|
29
|
+
required: ["content"],
|
|
30
|
+
},
|
|
31
|
+
execute: async () => ({ content: "", isError: false }),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe("resolveFirstClassSkillDefs", () => {
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
registerSkillTools(SKILL_ID, [
|
|
38
|
+
makeSkillTool("document_update"),
|
|
39
|
+
makeSkillTool("document_create"),
|
|
40
|
+
]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
unregisterSkillTools(SKILL_ID);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const allowed = new Set(["document_update", "document_create"]);
|
|
48
|
+
const turnAllowed = new Set([
|
|
49
|
+
"document_update",
|
|
50
|
+
"document_create",
|
|
51
|
+
"skill_execute",
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
test("exposes loaded skill tools for a weak open model", () => {
|
|
55
|
+
const defs = resolveFirstClassSkillDefs(allowed, turnAllowed, MINIMAX);
|
|
56
|
+
expect(defs.map((d) => d.name).sort()).toEqual([
|
|
57
|
+
"document_create",
|
|
58
|
+
"document_update",
|
|
59
|
+
]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("returns nothing for a capable model", () => {
|
|
63
|
+
const defs = resolveFirstClassSkillDefs(allowed, turnAllowed, CLAUDE);
|
|
64
|
+
expect(defs).toEqual([]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("returns nothing when the model is unknown/absent", () => {
|
|
68
|
+
expect(resolveFirstClassSkillDefs(allowed, turnAllowed, null)).toEqual([]);
|
|
69
|
+
expect(resolveFirstClassSkillDefs(allowed, turnAllowed, undefined)).toEqual(
|
|
70
|
+
[],
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("respects the turn allowlist (subagent / exclude gating)", () => {
|
|
75
|
+
// Only document_update is allowed this turn — document_create is filtered.
|
|
76
|
+
const restricted = new Set(["document_update", "skill_execute"]);
|
|
77
|
+
const defs = resolveFirstClassSkillDefs(allowed, restricted, MINIMAX);
|
|
78
|
+
expect(defs.map((d) => d.name)).toEqual(["document_update"]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("skips names with no registered tool", () => {
|
|
82
|
+
const withGhost = new Set([...allowed, "not_registered"]);
|
|
83
|
+
const turn = new Set([...turnAllowed, "not_registered"]);
|
|
84
|
+
const defs = resolveFirstClassSkillDefs(withGhost, turn, MINIMAX);
|
|
85
|
+
expect(defs.map((d) => d.name).sort()).toEqual([
|
|
86
|
+
"document_create",
|
|
87
|
+
"document_update",
|
|
88
|
+
]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("each exposed def carries its real scalar schema (single-escape)", () => {
|
|
92
|
+
const defs = resolveFirstClassSkillDefs(allowed, turnAllowed, MINIMAX);
|
|
93
|
+
const update = defs.find((d) => d.name === "document_update");
|
|
94
|
+
expect(update?.input_schema).toMatchObject({
|
|
95
|
+
properties: { content: { type: "string" } },
|
|
96
|
+
required: ["content"],
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -12,7 +12,12 @@ let providerRefreshCalls = 0;
|
|
|
12
12
|
const PLATFORM_BASE_URL = "https://platform.example.com";
|
|
13
13
|
const ASSISTANT_API_KEY_PATH = credentialKey("vellum", "assistant_api_key");
|
|
14
14
|
const PLATFORM_BASE_URL_PATH = credentialKey("vellum", "platform_base_url");
|
|
15
|
-
const MANAGED_PROVIDERS = [
|
|
15
|
+
const MANAGED_PROVIDERS = [
|
|
16
|
+
"anthropic",
|
|
17
|
+
"openai",
|
|
18
|
+
"gemini",
|
|
19
|
+
"fireworks",
|
|
20
|
+
] as const;
|
|
16
21
|
|
|
17
22
|
let platformBaseUrlOverride: string | undefined;
|
|
18
23
|
|
|
@@ -116,6 +121,13 @@ mock.module("../util/logger.js", () => ({
|
|
|
116
121
|
}),
|
|
117
122
|
}));
|
|
118
123
|
|
|
124
|
+
// `handleAddSecret` fires this detached when a managed-proxy credential lands —
|
|
125
|
+
// a v2-memory side effect outside this suite's provider-registry scope. Stub it
|
|
126
|
+
// to a no-op; its behavior is covered by memory-v2-startup.test.ts.
|
|
127
|
+
mock.module("../daemon/memory-v2-startup.js", () => ({
|
|
128
|
+
maybeReseedCapabilitiesAfterManagedCredential: async () => {},
|
|
129
|
+
}));
|
|
130
|
+
|
|
119
131
|
import {
|
|
120
132
|
getProviderRoutingSource,
|
|
121
133
|
initializeProviders,
|
|
@@ -199,7 +211,9 @@ describe("secret routes managed proxy registry sync", () => {
|
|
|
199
211
|
test("provider API key writes notify live-conversation refresh listeners", async () => {
|
|
200
212
|
await addApiKey("fireworks", "fw-key");
|
|
201
213
|
|
|
202
|
-
expect(secureKeyStore[credentialKey("fireworks", "api_key")]).toBe(
|
|
214
|
+
expect(secureKeyStore[credentialKey("fireworks", "api_key")]).toBe(
|
|
215
|
+
"fw-key",
|
|
216
|
+
);
|
|
203
217
|
expect(providerRefreshCalls).toBe(1);
|
|
204
218
|
|
|
205
219
|
await deleteApiKey("fireworks");
|
|
@@ -255,6 +255,24 @@ describe("createSkillTool — unknown parameter validation", () => {
|
|
|
255
255
|
expect(result.isError).toBe(false);
|
|
256
256
|
});
|
|
257
257
|
|
|
258
|
+
test("strips the harness `activity` field before validation", async () => {
|
|
259
|
+
const hash = computeSkillVersionHash(tempDir);
|
|
260
|
+
const tool = createSkillTool(
|
|
261
|
+
makeEntry({ executor: "echo.ts" }),
|
|
262
|
+
tempDir,
|
|
263
|
+
hash,
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
// A first-class skill-tool call may carry `activity` (the harness progress
|
|
267
|
+
// field). It must not be rejected as an unknown parameter.
|
|
268
|
+
const result = await tool.execute(
|
|
269
|
+
{ query: "hello", activity: "Doing the thing" },
|
|
270
|
+
makeContext(),
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
expect(result.isError).toBe(false);
|
|
274
|
+
});
|
|
275
|
+
|
|
258
276
|
test("allows empty input when schema has no required fields", async () => {
|
|
259
277
|
const hash = computeSkillVersionHash(tempDir);
|
|
260
278
|
const tool = createSkillTool(
|
|
@@ -360,7 +360,10 @@ export function projectSkillTools(
|
|
|
360
360
|
return { toolDefinitions: [], allowedToolNames: new Set() };
|
|
361
361
|
}
|
|
362
362
|
|
|
363
|
-
// Tool definitions are
|
|
363
|
+
// Tool definitions are not sent to the LLM here — tools are invoked via
|
|
364
|
+
// skill_execute dispatch (capable models), and the weak-open-model
|
|
365
|
+
// first-class exposure resolves the registered defs from the registry in
|
|
366
|
+
// createResolveToolsCallback by name.
|
|
364
367
|
const allToolNames = new Set<string>();
|
|
365
368
|
const successfulEntries = new Map<string, string>();
|
|
366
369
|
// Track skills already unregistered in the version-change branch so the
|
|
@@ -19,6 +19,7 @@ import type { PermissionPrompter } from "../permissions/prompter.js";
|
|
|
19
19
|
import type { SecretPrompter } from "../permissions/secret-prompter.js";
|
|
20
20
|
import { advisorEnabledForProfile } from "../plugins/defaults/advisor/advisor-gate.js";
|
|
21
21
|
import type { Message, ToolDefinition } from "../providers/types.js";
|
|
22
|
+
import { isWeakOpenModel } from "../providers/weak-open-model.js";
|
|
22
23
|
import { assistantEventHub } from "../runtime/assistant-event-hub.js";
|
|
23
24
|
import { registerConversationSender } from "../tools/browser/browser-screencast.js";
|
|
24
25
|
import type { ToolExecutor } from "../tools/executor.js";
|
|
@@ -681,6 +682,36 @@ export function isToolActiveForContext(
|
|
|
681
682
|
return true;
|
|
682
683
|
}
|
|
683
684
|
|
|
685
|
+
/**
|
|
686
|
+
* Resolve the loaded skill tools to expose first-class to weak open models.
|
|
687
|
+
*
|
|
688
|
+
* Weak open models (MiniMax, Kimi, DeepSeek, GLM) fail to serialize the nested
|
|
689
|
+
* `skill_execute` envelope — a large value double-escaped as a JSON string in
|
|
690
|
+
* `input` — and either drop it or emit it bare. Exposing each loaded skill
|
|
691
|
+
* tool first-class lets the model fill the tool's own scalar params directly
|
|
692
|
+
* (single-escape). Capable models keep the envelope-only contract: this returns
|
|
693
|
+
* `[]` for them, so their wire surface is unchanged.
|
|
694
|
+
*
|
|
695
|
+
* Defs are resolved from the registry by name; `skillToolNames` come from the
|
|
696
|
+
* projection (skill tools only), filtered by `turnAllowed` so subagent
|
|
697
|
+
* allowlists and the tool exclude list still apply. The generic `skill_execute`
|
|
698
|
+
* tool remains available as a fallback for both model tiers.
|
|
699
|
+
*/
|
|
700
|
+
export function resolveFirstClassSkillDefs(
|
|
701
|
+
skillToolNames: Iterable<string>,
|
|
702
|
+
turnAllowed: ReadonlySet<string>,
|
|
703
|
+
resolvedModel: string | null | undefined,
|
|
704
|
+
): ToolDefinition[] {
|
|
705
|
+
if (!isWeakOpenModel(resolvedModel)) return [];
|
|
706
|
+
const defs: ToolDefinition[] = [];
|
|
707
|
+
for (const name of skillToolNames) {
|
|
708
|
+
if (!turnAllowed.has(name)) continue;
|
|
709
|
+
const tool = getTool(name);
|
|
710
|
+
if (tool) defs.push(tool);
|
|
711
|
+
}
|
|
712
|
+
return defs;
|
|
713
|
+
}
|
|
714
|
+
|
|
684
715
|
/**
|
|
685
716
|
* Build a resolveTools callback that merges base tool definitions with
|
|
686
717
|
* dynamically projected skill tools on each agent turn. Also updates
|
|
@@ -810,7 +841,30 @@ export function createResolveToolsCallback(
|
|
|
810
841
|
}
|
|
811
842
|
|
|
812
843
|
ctx.allowedToolNames = turnAllowed;
|
|
813
|
-
|
|
844
|
+
|
|
845
|
+
// Weak open models fail to serialize the nested `skill_execute` envelope
|
|
846
|
+
// (a large value double-escaped as a JSON string in `input`), so expose the
|
|
847
|
+
// loaded skill tools first-class — their scalar params are emitted directly
|
|
848
|
+
// (single-escape). The defs are resolved from the registry by name; the
|
|
849
|
+
// names are already in `turnAllowed`. The generic `skill_execute` stays
|
|
850
|
+
// available as a fallback, and capable models keep the envelope-only
|
|
851
|
+
// contract (this branch is skipped for them).
|
|
852
|
+
const resolvedModel = resolveConversationAttribution({
|
|
853
|
+
conversationId: ctx.conversationId ?? "",
|
|
854
|
+
currentCallSite: ctx.currentCallSite,
|
|
855
|
+
currentTurnOverrideProfile: ctx.currentTurnOverrideProfile,
|
|
856
|
+
})?.resolvedModel;
|
|
857
|
+
const baseDefs = [
|
|
858
|
+
...injectActivityField(allBaseDefs, ACTIVITY_SKIP_SET),
|
|
859
|
+
...injectActivityField(
|
|
860
|
+
resolveFirstClassSkillDefs(
|
|
861
|
+
projection.allowedToolNames,
|
|
862
|
+
turnAllowed,
|
|
863
|
+
resolvedModel,
|
|
864
|
+
),
|
|
865
|
+
ACTIVITY_SKIP_SET,
|
|
866
|
+
),
|
|
867
|
+
];
|
|
814
868
|
|
|
815
869
|
const config = getConfig();
|
|
816
870
|
if (
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `maybeReseedCapabilitiesAfterManagedCredential` in
|
|
3
|
+
* `memory-v2-startup.ts`.
|
|
4
|
+
*
|
|
5
|
+
* The secrets route calls this when a managed-proxy credential lands, to close
|
|
6
|
+
* the first-boot race where the daemon's startup capability seed (skills + CLI
|
|
7
|
+
* commands) runs before the platform provisions the managed embedding
|
|
8
|
+
* credential — the seed's embed throws and the synthetic capability pages never
|
|
9
|
+
* reach the page index. The reseed must fire only when v2 memory is enabled AND
|
|
10
|
+
* the managed-proxy prerequisites are now satisfied, so self-hosted / BYOK
|
|
11
|
+
* assistants (no managed proxy) are never made to run a doomed embed. When v3 is
|
|
12
|
+
* live it then enqueues a `memory_v3_maintain` job so v3 picks up the capability
|
|
13
|
+
* pages immediately instead of waiting out the 6h maintain backstop.
|
|
14
|
+
*
|
|
15
|
+
* Dynamic-imported collaborators are mocked at module scope; `bun:test`
|
|
16
|
+
* isolates `mock.module` per test file.
|
|
17
|
+
*/
|
|
18
|
+
import { afterEach, describe, expect, mock, test } from "bun:test";
|
|
19
|
+
|
|
20
|
+
import { makeMockLogger } from "../__tests__/helpers/mock-logger.js";
|
|
21
|
+
import type { AssistantConfig } from "../config/schema.js";
|
|
22
|
+
|
|
23
|
+
const proxyState = { prereqs: true };
|
|
24
|
+
const v3State = { live: true };
|
|
25
|
+
const seedSkill = mock(async () => {});
|
|
26
|
+
const seedCli = mock(async () => {});
|
|
27
|
+
const enqueueJob = mock(
|
|
28
|
+
(_type: string, _payload: Record<string, unknown>) => 1,
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
mock.module("../util/logger.js", () => ({
|
|
32
|
+
getLogger: () => makeMockLogger(),
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
mock.module("../providers/platform-proxy/context.js", () => ({
|
|
36
|
+
hasManagedProxyPrereqs: async () => proxyState.prereqs,
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
mock.module("../config/memory-v3-gate.js", () => ({
|
|
40
|
+
isMemoryV3Live: () => v3State.live,
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
mock.module("../memory/jobs-store.js", () => ({
|
|
44
|
+
enqueueMemoryJob: enqueueJob,
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
mock.module("../memory/v2/skill-store.js", () => ({
|
|
48
|
+
seedV2SkillEntries: seedSkill,
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
mock.module("../memory/v2/cli-command-store.js", () => ({
|
|
52
|
+
seedV2CliCommandEntries: seedCli,
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
const { maybeReseedCapabilitiesAfterManagedCredential } =
|
|
56
|
+
await import("./memory-v2-startup.js");
|
|
57
|
+
|
|
58
|
+
function configWithV2(enabled: boolean): AssistantConfig {
|
|
59
|
+
return { memory: { v2: { enabled } } } as unknown as AssistantConfig;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
afterEach(() => {
|
|
63
|
+
seedSkill.mockClear();
|
|
64
|
+
seedCli.mockClear();
|
|
65
|
+
enqueueJob.mockClear();
|
|
66
|
+
proxyState.prereqs = true;
|
|
67
|
+
v3State.live = true;
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("maybeReseedCapabilitiesAfterManagedCredential", () => {
|
|
71
|
+
test("reseeds both skill and CLI entries when v2 is enabled and managed-proxy prereqs are satisfied", async () => {
|
|
72
|
+
proxyState.prereqs = true;
|
|
73
|
+
|
|
74
|
+
await maybeReseedCapabilitiesAfterManagedCredential(configWithV2(true));
|
|
75
|
+
|
|
76
|
+
expect(seedSkill).toHaveBeenCalledTimes(1);
|
|
77
|
+
expect(seedCli).toHaveBeenCalledTimes(1);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("enqueues a v3 maintain pass after reseeding when v3 is live", async () => {
|
|
81
|
+
proxyState.prereqs = true;
|
|
82
|
+
v3State.live = true;
|
|
83
|
+
|
|
84
|
+
await maybeReseedCapabilitiesAfterManagedCredential(configWithV2(true));
|
|
85
|
+
|
|
86
|
+
expect(enqueueJob).toHaveBeenCalledTimes(1);
|
|
87
|
+
expect(enqueueJob).toHaveBeenCalledWith("memory_v3_maintain", {});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("reseeds but does not enqueue a v3 maintain pass when v3 is not live", async () => {
|
|
91
|
+
proxyState.prereqs = true;
|
|
92
|
+
v3State.live = false;
|
|
93
|
+
|
|
94
|
+
await maybeReseedCapabilitiesAfterManagedCredential(configWithV2(true));
|
|
95
|
+
|
|
96
|
+
expect(seedSkill).toHaveBeenCalledTimes(1);
|
|
97
|
+
expect(seedCli).toHaveBeenCalledTimes(1);
|
|
98
|
+
expect(enqueueJob).not.toHaveBeenCalled();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("no-op when v2 memory is disabled", async () => {
|
|
102
|
+
await maybeReseedCapabilitiesAfterManagedCredential(configWithV2(false));
|
|
103
|
+
|
|
104
|
+
expect(seedSkill).not.toHaveBeenCalled();
|
|
105
|
+
expect(seedCli).not.toHaveBeenCalled();
|
|
106
|
+
expect(enqueueJob).not.toHaveBeenCalled();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("no-op for non-managed assistants (managed-proxy prereqs not satisfied)", async () => {
|
|
110
|
+
proxyState.prereqs = false;
|
|
111
|
+
|
|
112
|
+
await maybeReseedCapabilitiesAfterManagedCredential(configWithV2(true));
|
|
113
|
+
|
|
114
|
+
expect(seedSkill).not.toHaveBeenCalled();
|
|
115
|
+
expect(seedCli).not.toHaveBeenCalled();
|
|
116
|
+
expect(enqueueJob).not.toHaveBeenCalled();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("swallows a seed failure and still reseeds the other catalog", async () => {
|
|
120
|
+
proxyState.prereqs = true;
|
|
121
|
+
seedSkill.mockImplementationOnce(async () => {
|
|
122
|
+
throw new Error('Embedding backend "gemini" is not configured');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Must not reject — the helper contains each seed's failure so a doomed
|
|
126
|
+
// embed never propagates back to the credential-store caller.
|
|
127
|
+
await maybeReseedCapabilitiesAfterManagedCredential(configWithV2(true));
|
|
128
|
+
|
|
129
|
+
expect(seedCli).toHaveBeenCalledTimes(1);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// ---------------------------------------------------------------------------
|
|
4
4
|
//
|
|
5
5
|
// Small focused module that holds the gating + dispatch logic for v2-specific
|
|
6
|
-
// startup work invoked from `lifecycle.ts
|
|
6
|
+
// startup work invoked from `lifecycle.ts` (and, for the post-credential
|
|
7
|
+
// capability reseed, from the secrets route). Lives in its own file so the unit
|
|
7
8
|
// test for the gate does not have to mount the entire lifecycle import graph.
|
|
8
9
|
|
|
9
10
|
import type { AssistantConfig } from "../config/schema.js";
|
|
@@ -48,6 +49,104 @@ export function maybeSeedMemoryV2CliCommands(config: AssistantConfig): void {
|
|
|
48
49
|
.catch((err) => log.warn({ err }, "Failed to seed v2 CLI-command entries"));
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Re-seed the v2 skill and CLI-command capability entries once a managed-proxy
|
|
54
|
+
* credential lands, closing the first-boot race where the daemon's startup seed
|
|
55
|
+
* runs before the platform has provisioned the managed embedding credential.
|
|
56
|
+
*
|
|
57
|
+
* On a brand-new managed assistant the memory worker fires the startup seed
|
|
58
|
+
* (`maybeSeedMemoryV2Skills` / `maybeSeedMemoryV2CliCommands`) seconds after
|
|
59
|
+
* boot, but the platform pushes `vellum:assistant_api_key` (the credential the
|
|
60
|
+
* managed Gemini embedding backend needs) tens of seconds later. The seed's
|
|
61
|
+
* `embedWithBackend` call throws `EmbeddingBackendUnavailableError` before the
|
|
62
|
+
* skill/CLI `entries` cache is replaced, so `listSkillEntries()` /
|
|
63
|
+
* `listCliCommandEntries()` stay empty and the synthetic `skills/<id>` and
|
|
64
|
+
* `cli-commands/<name>` rows never reach the page index — leaving the v3 needle
|
|
65
|
+
* finder lane and always-candidate skill pinning with nothing to surface until
|
|
66
|
+
* the next daemon restart. Re-running the seed when the credential arrives
|
|
67
|
+
* restores the capability pages without a restart.
|
|
68
|
+
*
|
|
69
|
+
* Gated on the managed-proxy prerequisites now being satisfied (both the
|
|
70
|
+
* platform base URL and the assistant API key present) so a non-managed
|
|
71
|
+
* credential write — or a partial update that has not yet completed the pair —
|
|
72
|
+
* does not kick a doomed embed. Idempotent: `seedV2SkillEntries` /
|
|
73
|
+
* `seedV2CliCommandEntries` atomically replace their caches, so a redundant
|
|
74
|
+
* reseed (the startup seed already succeeded) is cheap and harmless. The two
|
|
75
|
+
* catalogs are independent, so they reseed in parallel. Callers invoke this
|
|
76
|
+
* detached (`void`) — it must not block the credential-store response.
|
|
77
|
+
*
|
|
78
|
+
* Reseeding alone only repopulates the shared page index — v3 reads its
|
|
79
|
+
* synthetic capability rows from the v2 stores, but its memoized lanes and its
|
|
80
|
+
* `memory_v3_sections` dense store refresh only on the v3 maintain pass (6-hour
|
|
81
|
+
* backstop). So when v3 is live, enqueue a `memory_v3_maintain` job after the
|
|
82
|
+
* reseed: its capability-reconcile stage embeds the freshly-seeded rows into the
|
|
83
|
+
* dense store and its lane-invalidation stage forces a rebuild against the now-
|
|
84
|
+
* populated index, so v3 surfaces the skill/CLI pages within seconds instead of
|
|
85
|
+
* waiting out the backstop.
|
|
86
|
+
*/
|
|
87
|
+
export async function maybeReseedCapabilitiesAfterManagedCredential(
|
|
88
|
+
config: AssistantConfig,
|
|
89
|
+
): Promise<void> {
|
|
90
|
+
if (!config.memory.v2.enabled) return;
|
|
91
|
+
|
|
92
|
+
const { hasManagedProxyPrereqs } =
|
|
93
|
+
await import("../providers/platform-proxy/context.js");
|
|
94
|
+
if (!(await hasManagedProxyPrereqs())) return;
|
|
95
|
+
|
|
96
|
+
// Skills and CLI commands are independent catalogs sharing the unified
|
|
97
|
+
// collection — reseed in parallel, each contained so one catalog's embed
|
|
98
|
+
// failure does not abort the other or reject the detached caller.
|
|
99
|
+
const catalogs: ReadonlyArray<[label: string, seed: () => Promise<void>]> = [
|
|
100
|
+
[
|
|
101
|
+
"skill",
|
|
102
|
+
async () => {
|
|
103
|
+
const { seedV2SkillEntries } =
|
|
104
|
+
await import("../memory/v2/skill-store.js");
|
|
105
|
+
await seedV2SkillEntries({ throwOnError: true });
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
[
|
|
109
|
+
"CLI-command",
|
|
110
|
+
async () => {
|
|
111
|
+
const { seedV2CliCommandEntries } =
|
|
112
|
+
await import("../memory/v2/cli-command-store.js");
|
|
113
|
+
await seedV2CliCommandEntries({ throwOnError: true });
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
await Promise.all(
|
|
119
|
+
catalogs.map(async ([label, seed]) => {
|
|
120
|
+
try {
|
|
121
|
+
await seed();
|
|
122
|
+
log.info(
|
|
123
|
+
`Memory v2 ${label} entries seeded after managed proxy credential update`,
|
|
124
|
+
);
|
|
125
|
+
} catch (err) {
|
|
126
|
+
log.warn(
|
|
127
|
+
{ err },
|
|
128
|
+
`Failed to seed v2 ${label} entries after managed proxy credential update`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
}),
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
// The stores (and the page index) are now populated; when v3 is live, kick a
|
|
135
|
+
// maintain pass so it embeds the capability rows into `memory_v3_sections` and
|
|
136
|
+
// invalidates its lanes immediately rather than waiting out the 6h backstop.
|
|
137
|
+
const { isMemoryV3Live } = await import("../config/memory-v3-gate.js");
|
|
138
|
+
if (!isMemoryV3Live(config)) return;
|
|
139
|
+
try {
|
|
140
|
+
const { enqueueMemoryJob } = await import("../memory/jobs-store.js");
|
|
141
|
+
enqueueMemoryJob("memory_v3_maintain", {});
|
|
142
|
+
} catch (err) {
|
|
143
|
+
log.warn(
|
|
144
|
+
{ err },
|
|
145
|
+
"Failed to enqueue memory_v3_maintain after managed proxy credential update",
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
51
150
|
/**
|
|
52
151
|
* Build the v2 BM25 corpus stats (per-token document frequencies + avg doc
|
|
53
152
|
* length), then re-seed the v2 skill entries so any skills written during
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
invalidateConfigCache,
|
|
23
23
|
} from "../../config/loader.js";
|
|
24
24
|
import type { CesClient } from "../../credential-execution/client.js";
|
|
25
|
+
import { maybeReseedCapabilitiesAfterManagedCredential } from "../../daemon/memory-v2-startup.js";
|
|
25
26
|
import { setSentryOrganizationId, setSentryUserId } from "../../instrument.js";
|
|
26
27
|
import { clearEmbeddingBackendCache } from "../../memory/embedding-backend.js";
|
|
27
28
|
import { syncManualTokenConnection } from "../../oauth/manual-token-connection.js";
|
|
@@ -296,6 +297,10 @@ async function handleAddSecret({ body }: RouteHandlerArgs) {
|
|
|
296
297
|
}
|
|
297
298
|
if (isManagedProxyCredential(service, field)) {
|
|
298
299
|
await refreshProvidersAfterSecretChange();
|
|
300
|
+
// Close the first-boot race where the startup capability seed ran before
|
|
301
|
+
// the managed embedding credential was provisioned, leaving skill/CLI
|
|
302
|
+
// pages unseeded until restart. Detached — must not block the response.
|
|
303
|
+
void maybeReseedCapabilitiesAfterManagedCredential(getConfig());
|
|
299
304
|
if (service === "vellum" && field === "assistant_api_key") {
|
|
300
305
|
const generation = ++apiKeyGeneration;
|
|
301
306
|
const deps = getSecretsDeps();
|
|
@@ -46,7 +46,12 @@ export function createSkillTool(
|
|
|
46
46
|
context: ToolContext,
|
|
47
47
|
): Promise<ToolExecutionResult> {
|
|
48
48
|
const schema = entry.input_schema as Record<string, unknown> | undefined;
|
|
49
|
-
|
|
49
|
+
// `activity` is a harness field (the skill_execute envelope and
|
|
50
|
+
// first-class skill-tool exposure both surface it for progress display),
|
|
51
|
+
// never an inner tool parameter. Strip it before validation so a model
|
|
52
|
+
// that includes it on a direct call isn't rejected for an unknown field.
|
|
53
|
+
const { activity: _activity, ...rest } = input;
|
|
54
|
+
const coercedInput = coerceStringBooleans(rest, schema);
|
|
50
55
|
const validation = validateInputAgainstSchema(
|
|
51
56
|
entry.name,
|
|
52
57
|
coercedInput,
|