@vellumai/assistant 0.9.0-dev.202606181713.b269b6a → 0.9.0-dev.202606181735.ae6e1d8
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__/plugin-tool-contribution.test.ts +6 -3
- package/src/plugins/defaults/advisor/__tests__/advisor-state-store.test.ts +43 -0
- package/src/plugins/defaults/advisor/__tests__/agent-loop-integration.test.ts +134 -0
- package/src/plugins/defaults/advisor/__tests__/consult.test.ts +147 -0
- package/src/plugins/defaults/advisor/__tests__/hooks.test.ts +138 -0
- package/src/plugins/defaults/advisor/__tests__/transcript.test.ts +147 -0
- package/src/plugins/defaults/advisor/advisor-state-store.ts +94 -0
- package/src/plugins/defaults/advisor/config.ts +26 -0
- package/src/plugins/defaults/advisor/consult.ts +76 -0
- package/src/plugins/defaults/advisor/hooks/post-model-call.ts +34 -0
- package/src/plugins/defaults/advisor/hooks/pre-model-call.ts +24 -0
- package/src/plugins/defaults/advisor/hooks/user-prompt-submit.ts +19 -0
- package/src/plugins/defaults/advisor/package.json +14 -0
- package/src/plugins/defaults/advisor/steering.ts +51 -0
- package/src/plugins/defaults/advisor/tools/advisor.ts +52 -0
- package/src/plugins/defaults/advisor/transcript.ts +76 -0
- package/src/plugins/defaults/index.ts +35 -0
package/package.json
CHANGED
|
@@ -190,13 +190,16 @@ describe("plugin tool contributions", () => {
|
|
|
190
190
|
registerPlugin(plugin);
|
|
191
191
|
|
|
192
192
|
await bootstrapPlugins();
|
|
193
|
-
//
|
|
194
|
-
|
|
193
|
+
// `bootstrapPlugins` also registers the first-party defaults (the advisor
|
|
194
|
+
// default contributes the `advisor` tool), so the global tool set is not
|
|
195
|
+
// empty. What matters here is that the no-tools plugin contributed nothing
|
|
196
|
+
// of its own — its tool refcount stays at zero.
|
|
197
|
+
expect(getPluginRefCount("no-tools")).toBe(0);
|
|
195
198
|
|
|
196
199
|
// Shutdown must also be safe — `unregisterPluginTools` is idempotent for
|
|
197
200
|
// plugins that never contributed any tools.
|
|
198
201
|
await runShutdownHooks("test-shutdown");
|
|
199
|
-
expect(
|
|
202
|
+
expect(getPluginRefCount("no-tools")).toBe(0);
|
|
200
203
|
});
|
|
201
204
|
|
|
202
205
|
test("tools declared before init() runs are only visible after bootstrap", async () => {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import type { Message } from "../../../../providers/types.js";
|
|
4
|
+
import {
|
|
5
|
+
getCapture,
|
|
6
|
+
recordMessages,
|
|
7
|
+
recordSystemPrompt,
|
|
8
|
+
resetAdvisorStateForTests,
|
|
9
|
+
seedCapture,
|
|
10
|
+
} from "../advisor-state-store.js";
|
|
11
|
+
|
|
12
|
+
const userMsg = (t: string): Message => ({
|
|
13
|
+
role: "user",
|
|
14
|
+
content: [{ type: "text", text: t }],
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
resetAdvisorStateForTests();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("advisor state store", () => {
|
|
22
|
+
test("records system prompt and messages independently per conversation", () => {
|
|
23
|
+
recordSystemPrompt("c1", "system A");
|
|
24
|
+
recordMessages("c1", [userMsg("hello")]);
|
|
25
|
+
recordSystemPrompt("c2", "system B");
|
|
26
|
+
|
|
27
|
+
expect(getCapture("c1")?.systemPrompt).toBe("system A");
|
|
28
|
+
expect(getCapture("c1")?.messages).toEqual([userMsg("hello")]);
|
|
29
|
+
expect(getCapture("c2")?.systemPrompt).toBe("system B");
|
|
30
|
+
expect(getCapture("c2")?.messages).toEqual([]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("seedCapture snapshots (copies) the array", () => {
|
|
34
|
+
const live: Message[] = [userMsg("one")];
|
|
35
|
+
seedCapture("c1", live);
|
|
36
|
+
live.push(userMsg("two"));
|
|
37
|
+
expect(getCapture("c1")?.messages).toEqual([userMsg("one")]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("getCapture returns undefined for an unseen conversation", () => {
|
|
41
|
+
expect(getCapture("nope")).toBeUndefined();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end integration test: drives the REAL agent loop with all first-party
|
|
3
|
+
* defaults registered (so the advisor plugin's hooks + tool are live), and lets
|
|
4
|
+
* the REAL consult run. Only the provider boundary is stubbed:
|
|
5
|
+
* - the executor's provider (a scripted mock provider), and
|
|
6
|
+
* - `getConfiguredProvider`, so the advisor sub-call resolves to a second
|
|
7
|
+
* scripted mock provider instead of a configured one.
|
|
8
|
+
*
|
|
9
|
+
* The advisor's inference genuinely runs through `consult` → `sendMessage`
|
|
10
|
+
* against the injected advisor provider, exercising the full hook-capture →
|
|
11
|
+
* tool.execute → consult → routed-inference path through the loop.
|
|
12
|
+
*/
|
|
13
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
createMockProvider,
|
|
17
|
+
textResponse,
|
|
18
|
+
} from "../../../../__tests__/helpers/mock-provider.js";
|
|
19
|
+
import { AgentLoop } from "../../../../agent/loop.js";
|
|
20
|
+
import type {
|
|
21
|
+
ContentBlock,
|
|
22
|
+
Message,
|
|
23
|
+
ProviderResponse,
|
|
24
|
+
} from "../../../../providers/types.js";
|
|
25
|
+
|
|
26
|
+
const ADVICE = "ADVICE: use a channel-based worker pool with graceful drain.";
|
|
27
|
+
let advisorProvider: ReturnType<typeof createMockProvider> | null = null;
|
|
28
|
+
|
|
29
|
+
const realProviderSendMessage =
|
|
30
|
+
await import("../../../../providers/provider-send-message.js");
|
|
31
|
+
mock.module("../../../../providers/provider-send-message.js", () => ({
|
|
32
|
+
...realProviderSendMessage,
|
|
33
|
+
getConfiguredProvider: async () => advisorProvider?.provider ?? null,
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
const { resetPluginRegistryAndRegisterDefaults } =
|
|
37
|
+
await import("../../index.js");
|
|
38
|
+
const advisorTool = (await import("../tools/advisor.js")).default;
|
|
39
|
+
|
|
40
|
+
const userTurn: Message = {
|
|
41
|
+
role: "user",
|
|
42
|
+
content: [{ type: "text", text: "build a worker pool" }],
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function textOf(content: ReadonlyArray<ContentBlock>): string {
|
|
46
|
+
return content.map((b) => (b.type === "text" ? b.text : "")).join("");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe("advisor — agent-loop integration", () => {
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
resetPluginRegistryAndRegisterDefaults();
|
|
52
|
+
advisorProvider = createMockProvider([textResponse(ADVICE)]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("model calls advisor → hooks capture → consult routes through inference → advice returns", async () => {
|
|
56
|
+
const consultThenText: ProviderResponse = {
|
|
57
|
+
content: [
|
|
58
|
+
{ type: "text", text: "Let me consult the advisor." },
|
|
59
|
+
{ type: "tool_use", id: "call-1", name: "advisor", input: {} },
|
|
60
|
+
],
|
|
61
|
+
model: "mock-model",
|
|
62
|
+
usage: { inputTokens: 10, outputTokens: 5 },
|
|
63
|
+
stopReason: "tool_use",
|
|
64
|
+
};
|
|
65
|
+
const { provider } = createMockProvider([
|
|
66
|
+
consultThenText,
|
|
67
|
+
textResponse("Done — implemented the worker pool."),
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
const conversationId = "advisor-itest";
|
|
71
|
+
const loop = new AgentLoop({
|
|
72
|
+
provider,
|
|
73
|
+
systemPrompt: "You are a coding agent.",
|
|
74
|
+
conversationId,
|
|
75
|
+
tools: [
|
|
76
|
+
{
|
|
77
|
+
name: "advisor",
|
|
78
|
+
description: "",
|
|
79
|
+
input_schema: { type: "object", properties: {} },
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
toolExecutor: async (name, input) =>
|
|
83
|
+
name === "advisor"
|
|
84
|
+
? advisorTool.execute!(input, { conversationId } as never)
|
|
85
|
+
: { content: `unknown tool ${name}`, isError: true },
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const { history } = await loop.run({
|
|
89
|
+
requestId: "req-1",
|
|
90
|
+
messages: [userTurn],
|
|
91
|
+
onEvent: () => {},
|
|
92
|
+
callSite: "mainAgent",
|
|
93
|
+
trust: { sourceChannel: "vellum", trustClass: "unknown" },
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// The advisor sub-call happened, routed through the inference call site.
|
|
97
|
+
expect(advisorProvider!.calls).toHaveLength(1);
|
|
98
|
+
const sub = advisorProvider!.calls[0];
|
|
99
|
+
expect(sub.options?.config?.callSite).toBe("inference");
|
|
100
|
+
expect(sub.options?.config?.overrideProfile).toBe("quality-optimized");
|
|
101
|
+
expect(sub.options?.config?.tool_choice).toEqual({ type: "none" });
|
|
102
|
+
expect(sub.options?.config?.max_tokens).toBe(2048);
|
|
103
|
+
|
|
104
|
+
// The advisor saw the captured transcript (task present; pending tool_use stripped).
|
|
105
|
+
expect(textOf(sub.messages[0].content)).toContain("build a worker pool");
|
|
106
|
+
expect(textOf(sub.messages[sub.messages.length - 1].content)).toContain(
|
|
107
|
+
"80 words",
|
|
108
|
+
);
|
|
109
|
+
// It also saw the model's CURRENT turn — the text it wrote right before the
|
|
110
|
+
// `advisor` tool_use — which `post-model-call` lifts out of `ctx.content`.
|
|
111
|
+
const transcript = sub.messages.map((m) => textOf(m.content)).join("\n");
|
|
112
|
+
expect(transcript).toContain("Let me consult the advisor.");
|
|
113
|
+
|
|
114
|
+
// The advisor saw the executor's system prompt (via pre-model-call).
|
|
115
|
+
expect(sub.options?.systemPrompt).toContain("senior technical advisor");
|
|
116
|
+
expect(sub.options?.systemPrompt).toContain("You are a coding agent.");
|
|
117
|
+
|
|
118
|
+
// The advice flowed back into the executor's history as the tool result.
|
|
119
|
+
const toolResult = history
|
|
120
|
+
.flatMap((m) => m.content)
|
|
121
|
+
.find((b) => b.type === "tool_result");
|
|
122
|
+
expect((toolResult as { content: string }).content).toContain(
|
|
123
|
+
"channel-based worker pool",
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
// The loop completed with the final answer.
|
|
127
|
+
const lastAssistant = [...history]
|
|
128
|
+
.reverse()
|
|
129
|
+
.find((m) => m.role === "assistant")!;
|
|
130
|
+
expect(textOf(lastAssistant.content)).toContain(
|
|
131
|
+
"implemented the worker pool",
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import type { Message } from "../../../../providers/types.js";
|
|
4
|
+
|
|
5
|
+
// Stub the provider resolution; spread the real module so `extractAllText` /
|
|
6
|
+
// `userMessage` (which the consult also uses) keep working.
|
|
7
|
+
let sendMessageArgs: Record<string, unknown> | null = null;
|
|
8
|
+
let responseText = "Use a channel-based worker pool; drain on shutdown.";
|
|
9
|
+
let sendMessageError: Error | null = null;
|
|
10
|
+
let providerResolves = true;
|
|
11
|
+
|
|
12
|
+
const fakeProvider = {
|
|
13
|
+
name: "mock-advisor-provider",
|
|
14
|
+
async sendMessage(messages: unknown, options: unknown) {
|
|
15
|
+
sendMessageArgs = { messages, options } as Record<string, unknown>;
|
|
16
|
+
if (sendMessageError) throw sendMessageError;
|
|
17
|
+
return {
|
|
18
|
+
content: [{ type: "text", text: responseText }],
|
|
19
|
+
model: "mock-model",
|
|
20
|
+
usage: { inputTokens: 1, outputTokens: 1 },
|
|
21
|
+
stopReason: "end_turn",
|
|
22
|
+
};
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const realPsm = await import("../../../../providers/provider-send-message.js");
|
|
27
|
+
mock.module("../../../../providers/provider-send-message.js", () => ({
|
|
28
|
+
...realPsm,
|
|
29
|
+
getConfiguredProvider: async () => (providerResolves ? fakeProvider : null),
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
const { consultAdvisor } = await import("../consult.js");
|
|
33
|
+
const advisorTool = (await import("../tools/advisor.js")).default;
|
|
34
|
+
const { recordSystemPrompt, recordMessages, resetAdvisorStateForTests } =
|
|
35
|
+
await import("../advisor-state-store.js");
|
|
36
|
+
|
|
37
|
+
const userMsg = (t: string): Message => ({
|
|
38
|
+
role: "user",
|
|
39
|
+
content: [{ type: "text", text: t }],
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
function optionConfig(): Record<string, unknown> {
|
|
43
|
+
const options = sendMessageArgs?.options as Record<string, unknown>;
|
|
44
|
+
return options.config as Record<string, unknown>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
sendMessageArgs = null;
|
|
49
|
+
responseText = "Use a channel-based worker pool; drain on shutdown.";
|
|
50
|
+
sendMessageError = null;
|
|
51
|
+
providerResolves = true;
|
|
52
|
+
resetAdvisorStateForTests();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("consultAdvisor", () => {
|
|
56
|
+
test("routes through the inference call site, tools off, capped, returns advice", async () => {
|
|
57
|
+
const messages: Message[] = [
|
|
58
|
+
userMsg("build a worker pool"),
|
|
59
|
+
{
|
|
60
|
+
role: "assistant",
|
|
61
|
+
content: [
|
|
62
|
+
{ type: "thinking", thinking: "secret", signature: "s" },
|
|
63
|
+
{ type: "text", text: "let me consult the advisor" },
|
|
64
|
+
{ type: "tool_use", id: "t1", name: "advisor", input: {} },
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
const advice = await consultAdvisor({
|
|
70
|
+
systemPrompt: "You are a coding agent.",
|
|
71
|
+
messages,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
expect(advice).toBe(responseText);
|
|
75
|
+
|
|
76
|
+
const config = optionConfig();
|
|
77
|
+
expect(config.callSite).toBe("inference");
|
|
78
|
+
expect(config.overrideProfile).toBe("quality-optimized");
|
|
79
|
+
expect(config.tool_choice).toEqual({ type: "none" });
|
|
80
|
+
expect(config.max_tokens).toBe(2048);
|
|
81
|
+
|
|
82
|
+
const sent = sendMessageArgs?.messages as Message[];
|
|
83
|
+
expect(sent[0]).toEqual(userMsg("build a worker pool"));
|
|
84
|
+
expect(sent[1]).toEqual({
|
|
85
|
+
role: "assistant",
|
|
86
|
+
content: [{ type: "text", text: "let me consult the advisor" }],
|
|
87
|
+
});
|
|
88
|
+
const lastText = (sent[sent.length - 1].content[0] as { text: string })
|
|
89
|
+
.text;
|
|
90
|
+
expect(lastText).toContain("80 words");
|
|
91
|
+
|
|
92
|
+
const options = sendMessageArgs?.options as { systemPrompt: string };
|
|
93
|
+
expect(options.systemPrompt).toContain("senior technical advisor");
|
|
94
|
+
expect(options.systemPrompt).toContain("You are a coding agent.");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("soft-fails when no provider is configured", async () => {
|
|
98
|
+
providerResolves = false;
|
|
99
|
+
const advice = await consultAdvisor({
|
|
100
|
+
systemPrompt: null,
|
|
101
|
+
messages: [userMsg("hi")],
|
|
102
|
+
});
|
|
103
|
+
expect(advice).toContain("no inference provider");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("returns a notice when there is no usable transcript", async () => {
|
|
107
|
+
const advice = await consultAdvisor({ systemPrompt: null, messages: [] });
|
|
108
|
+
expect(advice).toContain("no conversation context");
|
|
109
|
+
expect(sendMessageArgs).toBeNull();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("falls back to a notice when the advisor returns blank text", async () => {
|
|
113
|
+
responseText = " ";
|
|
114
|
+
const advice = await consultAdvisor({
|
|
115
|
+
systemPrompt: null,
|
|
116
|
+
messages: [userMsg("hi")],
|
|
117
|
+
});
|
|
118
|
+
expect(advice).toContain("no guidance");
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("advisor tool.execute", () => {
|
|
123
|
+
test("reads the captured transcript and returns guidance as a non-error result", async () => {
|
|
124
|
+
recordSystemPrompt("c1", "You are a coding agent.");
|
|
125
|
+
recordMessages("c1", [userMsg("build a worker pool")]);
|
|
126
|
+
|
|
127
|
+
const result = await advisorTool.execute?.({}, {
|
|
128
|
+
conversationId: "c1",
|
|
129
|
+
} as never);
|
|
130
|
+
|
|
131
|
+
expect(result?.isError).toBe(false);
|
|
132
|
+
expect(result?.content).toBe(responseText);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("degrades to a benign result (never throws) when the consult fails", async () => {
|
|
136
|
+
recordMessages("c2", [userMsg("hi")]);
|
|
137
|
+
sendMessageError = new Error("kaboom");
|
|
138
|
+
|
|
139
|
+
const result = await advisorTool.execute?.({}, {
|
|
140
|
+
conversationId: "c2",
|
|
141
|
+
} as never);
|
|
142
|
+
|
|
143
|
+
expect(result?.isError).toBe(false);
|
|
144
|
+
expect(result?.content).toContain("advisor unavailable");
|
|
145
|
+
expect(result?.content).toContain("kaboom");
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
Message,
|
|
5
|
+
PostModelCallContext,
|
|
6
|
+
PreModelCallContext,
|
|
7
|
+
UserPromptSubmitContext,
|
|
8
|
+
} from "@vellumai/plugin-api";
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
getCapture,
|
|
12
|
+
resetAdvisorStateForTests,
|
|
13
|
+
} from "../advisor-state-store.js";
|
|
14
|
+
import postModelCall from "../hooks/post-model-call.js";
|
|
15
|
+
import preModelCall from "../hooks/pre-model-call.js";
|
|
16
|
+
import userPromptSubmit from "../hooks/user-prompt-submit.js";
|
|
17
|
+
import { STEERING_MARKER } from "../steering.js";
|
|
18
|
+
|
|
19
|
+
const logger = { info() {}, warn() {}, error() {}, debug() {} };
|
|
20
|
+
const userMsg = (t: string): Message => ({
|
|
21
|
+
role: "user",
|
|
22
|
+
content: [{ type: "text", text: t }],
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
resetAdvisorStateForTests();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("pre-model-call hook", () => {
|
|
30
|
+
test("records the original system prompt and injects steering on mainAgent", async () => {
|
|
31
|
+
const ctx = {
|
|
32
|
+
conversationId: "c1",
|
|
33
|
+
callSite: "mainAgent",
|
|
34
|
+
systemPrompt: "BASE PROMPT",
|
|
35
|
+
deferAssistantOutput: false,
|
|
36
|
+
logger,
|
|
37
|
+
} as unknown as PreModelCallContext;
|
|
38
|
+
|
|
39
|
+
await preModelCall(ctx);
|
|
40
|
+
|
|
41
|
+
expect(ctx.systemPrompt).toContain(STEERING_MARKER);
|
|
42
|
+
expect(ctx.systemPrompt?.startsWith("BASE PROMPT")).toBe(true);
|
|
43
|
+
expect(getCapture("c1")?.systemPrompt).toBe("BASE PROMPT");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("ignores non-mainAgent calls", async () => {
|
|
47
|
+
const ctx = {
|
|
48
|
+
conversationId: "bg",
|
|
49
|
+
callSite: "inference",
|
|
50
|
+
systemPrompt: "BG",
|
|
51
|
+
deferAssistantOutput: false,
|
|
52
|
+
logger,
|
|
53
|
+
} as unknown as PreModelCallContext;
|
|
54
|
+
|
|
55
|
+
await preModelCall(ctx);
|
|
56
|
+
expect(ctx.systemPrompt).toBe("BG");
|
|
57
|
+
expect(getCapture("bg")).toBeUndefined();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("post-model-call hook", () => {
|
|
62
|
+
const base = {
|
|
63
|
+
content: [],
|
|
64
|
+
stopReason: "end_turn",
|
|
65
|
+
decision: "stop" as const,
|
|
66
|
+
logger,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
test("snapshots the transcript on mainAgent", async () => {
|
|
70
|
+
const messages = [userMsg("hi")];
|
|
71
|
+
await postModelCall({
|
|
72
|
+
...base,
|
|
73
|
+
conversationId: "c1",
|
|
74
|
+
callSite: "mainAgent",
|
|
75
|
+
messages,
|
|
76
|
+
} as unknown as PostModelCallContext);
|
|
77
|
+
expect(getCapture("c1")?.messages).toEqual(messages);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("appends the model's current turn (ctx.content) to the snapshot", async () => {
|
|
81
|
+
const messages = [userMsg("task")];
|
|
82
|
+
const assistantTurn: Message = {
|
|
83
|
+
role: "assistant",
|
|
84
|
+
content: [
|
|
85
|
+
{ type: "text", text: "thinking about it" },
|
|
86
|
+
{ type: "tool_use", id: "t1", name: "advisor", input: {} },
|
|
87
|
+
],
|
|
88
|
+
};
|
|
89
|
+
await postModelCall({
|
|
90
|
+
...base,
|
|
91
|
+
content: assistantTurn.content,
|
|
92
|
+
conversationId: "c4",
|
|
93
|
+
callSite: "mainAgent",
|
|
94
|
+
messages,
|
|
95
|
+
} as unknown as PostModelCallContext);
|
|
96
|
+
const captured = getCapture("c4")?.messages;
|
|
97
|
+
expect(captured).toHaveLength(2);
|
|
98
|
+
expect(captured?.[1]).toEqual(assistantTurn);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("ignores the advisor's own inference sub-call (recursion safety) and errors", async () => {
|
|
102
|
+
await postModelCall({
|
|
103
|
+
...base,
|
|
104
|
+
conversationId: "c2",
|
|
105
|
+
callSite: "inference",
|
|
106
|
+
messages: [userMsg("hi")],
|
|
107
|
+
} as unknown as PostModelCallContext);
|
|
108
|
+
expect(getCapture("c2")).toBeUndefined();
|
|
109
|
+
|
|
110
|
+
await postModelCall({
|
|
111
|
+
...base,
|
|
112
|
+
conversationId: "c3",
|
|
113
|
+
callSite: "mainAgent",
|
|
114
|
+
messages: [userMsg("hi")],
|
|
115
|
+
error: new Error("boom"),
|
|
116
|
+
stopReason: null,
|
|
117
|
+
} as unknown as PostModelCallContext);
|
|
118
|
+
expect(getCapture("c3")).toBeUndefined();
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("user-prompt-submit hook", () => {
|
|
123
|
+
test("seeds the capture with the inbound history", async () => {
|
|
124
|
+
const messages = [userMsg("task")];
|
|
125
|
+
await userPromptSubmit({
|
|
126
|
+
conversationId: "c1",
|
|
127
|
+
latestMessages: messages,
|
|
128
|
+
originalMessages: messages,
|
|
129
|
+
prompt: "task",
|
|
130
|
+
userMessageId: "u1",
|
|
131
|
+
requestId: "r1",
|
|
132
|
+
modelProfileKey: null,
|
|
133
|
+
isNonInteractive: false,
|
|
134
|
+
logger,
|
|
135
|
+
} as unknown as UserPromptSubmitContext);
|
|
136
|
+
expect(getCapture("c1")?.messages).toEqual(messages);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import type { ContentBlock, Message } from "../../../../providers/types.js";
|
|
4
|
+
import { toAdvisorMessages } from "../transcript.js";
|
|
5
|
+
|
|
6
|
+
const text = (t: string): ContentBlock => ({ type: "text", text: t });
|
|
7
|
+
|
|
8
|
+
describe("toAdvisorMessages", () => {
|
|
9
|
+
test("drops thinking and redacted-thinking blocks", () => {
|
|
10
|
+
const messages: Message[] = [
|
|
11
|
+
{ role: "user", content: [text("do the task")] },
|
|
12
|
+
{
|
|
13
|
+
role: "assistant",
|
|
14
|
+
content: [
|
|
15
|
+
{ type: "thinking", thinking: "secret", signature: "sig" },
|
|
16
|
+
{ type: "redacted_thinking", data: "blob" },
|
|
17
|
+
text("here is my answer"),
|
|
18
|
+
],
|
|
19
|
+
},
|
|
20
|
+
];
|
|
21
|
+
const out = toAdvisorMessages(messages);
|
|
22
|
+
expect(out).toHaveLength(2);
|
|
23
|
+
expect(out[1].content).toEqual([text("here is my answer")]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("strips the pending advisor tool_use from the final assistant turn", () => {
|
|
27
|
+
const messages: Message[] = [
|
|
28
|
+
{ role: "user", content: [text("task")] },
|
|
29
|
+
{
|
|
30
|
+
role: "assistant",
|
|
31
|
+
content: [
|
|
32
|
+
text("let me consult the advisor"),
|
|
33
|
+
{ type: "tool_use", id: "t1", name: "advisor", input: {} },
|
|
34
|
+
],
|
|
35
|
+
},
|
|
36
|
+
];
|
|
37
|
+
const out = toAdvisorMessages(messages);
|
|
38
|
+
expect(out[1].content).toEqual([text("let me consult the advisor")]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("preserves completed client tool_use / tool_result pairs in earlier turns", () => {
|
|
42
|
+
const messages: Message[] = [
|
|
43
|
+
{ role: "user", content: [text("task")] },
|
|
44
|
+
{
|
|
45
|
+
role: "assistant",
|
|
46
|
+
content: [{ type: "tool_use", id: "a", name: "bash", input: {} }],
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
role: "user",
|
|
50
|
+
content: [{ type: "tool_result", tool_use_id: "a", content: "output" }],
|
|
51
|
+
},
|
|
52
|
+
{ role: "assistant", content: [text("done")] },
|
|
53
|
+
];
|
|
54
|
+
const out = toAdvisorMessages(messages);
|
|
55
|
+
expect(out[1].content[0]).toEqual({
|
|
56
|
+
type: "tool_use",
|
|
57
|
+
id: "a",
|
|
58
|
+
name: "bash",
|
|
59
|
+
input: {},
|
|
60
|
+
});
|
|
61
|
+
expect(out[2].content[0]).toEqual({
|
|
62
|
+
type: "tool_result",
|
|
63
|
+
tool_use_id: "a",
|
|
64
|
+
content: "output",
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("drops a web-search server_tool_use AND its result together — no orphan", () => {
|
|
69
|
+
const messages: Message[] = [
|
|
70
|
+
{ role: "user", content: [text("look it up")] },
|
|
71
|
+
{
|
|
72
|
+
role: "assistant",
|
|
73
|
+
content: [
|
|
74
|
+
text("searching"),
|
|
75
|
+
{ type: "server_tool_use", id: "ws1", name: "web_search", input: {} },
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
role: "user",
|
|
80
|
+
content: [
|
|
81
|
+
{
|
|
82
|
+
type: "web_search_tool_result",
|
|
83
|
+
tool_use_id: "ws1",
|
|
84
|
+
content: [{ title: "x", url: "y" }],
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
{ role: "assistant", content: [text("found it; here is the answer")] },
|
|
89
|
+
];
|
|
90
|
+
const out = toAdvisorMessages(messages);
|
|
91
|
+
const flat = out.flatMap((m) => m.content);
|
|
92
|
+
expect(flat.some((b) => b.type === "server_tool_use")).toBe(false);
|
|
93
|
+
expect(flat.some((b) => b.type === "web_search_tool_result")).toBe(false);
|
|
94
|
+
expect(flat).toContainEqual(text("searching"));
|
|
95
|
+
expect(flat).toContainEqual(text("found it; here is the answer"));
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("keeps top-level image blocks", () => {
|
|
99
|
+
const img: ContentBlock = {
|
|
100
|
+
type: "image",
|
|
101
|
+
source: { type: "base64", media_type: "image/png", data: "x" },
|
|
102
|
+
};
|
|
103
|
+
const messages: Message[] = [
|
|
104
|
+
{ role: "user", content: [text("look at this"), img] },
|
|
105
|
+
];
|
|
106
|
+
const out = toAdvisorMessages(messages);
|
|
107
|
+
expect(out[0].content).toEqual([text("look at this"), img]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("keeps image contentBlocks on a tool_result, dropping disallowed ones", () => {
|
|
111
|
+
const img: ContentBlock = {
|
|
112
|
+
type: "image",
|
|
113
|
+
source: { type: "base64", media_type: "image/png", data: "x" },
|
|
114
|
+
};
|
|
115
|
+
const messages: Message[] = [
|
|
116
|
+
{
|
|
117
|
+
role: "user",
|
|
118
|
+
content: [
|
|
119
|
+
{
|
|
120
|
+
type: "tool_result",
|
|
121
|
+
tool_use_id: "a",
|
|
122
|
+
content: "screenshot",
|
|
123
|
+
contentBlocks: [
|
|
124
|
+
img,
|
|
125
|
+
{
|
|
126
|
+
type: "file",
|
|
127
|
+
source: {
|
|
128
|
+
type: "base64",
|
|
129
|
+
media_type: "application/pdf",
|
|
130
|
+
data: "z",
|
|
131
|
+
filename: "f.pdf",
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
],
|
|
135
|
+
},
|
|
136
|
+
],
|
|
137
|
+
},
|
|
138
|
+
];
|
|
139
|
+
const out = toAdvisorMessages(messages);
|
|
140
|
+
expect(out[0].content[0]).toEqual({
|
|
141
|
+
type: "tool_result",
|
|
142
|
+
tool_use_id: "a",
|
|
143
|
+
content: "screenshot",
|
|
144
|
+
contentBlocks: [img],
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-conversation capture store for the advisor plugin.
|
|
3
|
+
*
|
|
4
|
+
* A tool's `execute` does not receive the conversation transcript — only
|
|
5
|
+
* lifecycle hooks do. So the hooks snapshot what the executor saw (the system
|
|
6
|
+
* prompt from `pre-model-call`, the running transcript from `post-model-call`)
|
|
7
|
+
* into this module-level store, keyed by conversation id, and the `advisor`
|
|
8
|
+
* tool reads the latest snapshot when the model calls it. This reproduces the
|
|
9
|
+
* advisor-tool property that the model calls the tool with no arguments and the
|
|
10
|
+
* full context is supplied for it.
|
|
11
|
+
*
|
|
12
|
+
* Bounded by a small LRU so a long-lived daemon does not accumulate state for
|
|
13
|
+
* every conversation it has ever served.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { Message } from "../../../providers/types.js";
|
|
17
|
+
|
|
18
|
+
export interface AdvisorCapture {
|
|
19
|
+
/** The executor's system prompt (steering stripped), or null if unseen. */
|
|
20
|
+
systemPrompt: string | null;
|
|
21
|
+
/** The transcript the executor most recently saw on a `mainAgent` call. */
|
|
22
|
+
messages: Message[];
|
|
23
|
+
/** Last-write timestamp, used for LRU eviction. */
|
|
24
|
+
updatedAt: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const MAX_CONVERSATIONS = 200;
|
|
28
|
+
const store = new Map<string, AdvisorCapture>();
|
|
29
|
+
|
|
30
|
+
/** Move an entry to most-recently-used and evict the oldest past the cap. */
|
|
31
|
+
function bump(conversationId: string, entry: AdvisorCapture): void {
|
|
32
|
+
entry.updatedAt = Date.now();
|
|
33
|
+
store.delete(conversationId);
|
|
34
|
+
store.set(conversationId, entry);
|
|
35
|
+
while (store.size > MAX_CONVERSATIONS) {
|
|
36
|
+
const oldest = store.keys().next().value;
|
|
37
|
+
if (oldest === undefined) break;
|
|
38
|
+
store.delete(oldest);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function ensure(conversationId: string): AdvisorCapture {
|
|
43
|
+
return (
|
|
44
|
+
store.get(conversationId) ?? {
|
|
45
|
+
systemPrompt: null,
|
|
46
|
+
messages: [],
|
|
47
|
+
updatedAt: Date.now(),
|
|
48
|
+
}
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Seed the capture at the start of a user turn with the inbound history, so an
|
|
54
|
+
* advisor call on the very first model turn still has context even before
|
|
55
|
+
* `post-model-call` snapshots the running transcript.
|
|
56
|
+
*/
|
|
57
|
+
export function seedCapture(
|
|
58
|
+
conversationId: string,
|
|
59
|
+
messages: ReadonlyArray<Message>,
|
|
60
|
+
): void {
|
|
61
|
+
const entry = ensure(conversationId);
|
|
62
|
+
entry.messages = [...messages];
|
|
63
|
+
bump(conversationId, entry);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Record the executor's system prompt (steering already stripped). */
|
|
67
|
+
export function recordSystemPrompt(
|
|
68
|
+
conversationId: string,
|
|
69
|
+
systemPrompt: string | null,
|
|
70
|
+
): void {
|
|
71
|
+
const entry = ensure(conversationId);
|
|
72
|
+
entry.systemPrompt = systemPrompt;
|
|
73
|
+
bump(conversationId, entry);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Snapshot the transcript the executor just saw (before tools run). */
|
|
77
|
+
export function recordMessages(
|
|
78
|
+
conversationId: string,
|
|
79
|
+
messages: ReadonlyArray<Message>,
|
|
80
|
+
): void {
|
|
81
|
+
const entry = ensure(conversationId);
|
|
82
|
+
entry.messages = [...messages];
|
|
83
|
+
bump(conversationId, entry);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The latest capture for a conversation, or `undefined` if none recorded. */
|
|
87
|
+
export function getCapture(conversationId: string): AdvisorCapture | undefined {
|
|
88
|
+
return store.get(conversationId);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Test-only: clear all captured state. */
|
|
92
|
+
export function resetAdvisorStateForTests(): void {
|
|
93
|
+
store.clear();
|
|
94
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static behavioral configuration for the advisor plugin.
|
|
3
|
+
*
|
|
4
|
+
* The advisor *model* is the inference profile named by `profile`, applied as
|
|
5
|
+
* an `overrideProfile` on the general-purpose `inference` call site — so the
|
|
6
|
+
* consult routes through the workspace's configured provider (managed-proxy or
|
|
7
|
+
* BYOK) with no separate API key.
|
|
8
|
+
*/
|
|
9
|
+
export const ADVISOR_CONFIG = {
|
|
10
|
+
/**
|
|
11
|
+
* Inference profile the advisor consults. Defaults to the strongest managed
|
|
12
|
+
* profile; the resolver falls back to the active profile if it's absent.
|
|
13
|
+
*/
|
|
14
|
+
profile: "quality-optimized",
|
|
15
|
+
/**
|
|
16
|
+
* Hard cap on advisor output tokens per consult. Mirrors the advisor tool's
|
|
17
|
+
* recommended 2048 (the provider floor is 1024).
|
|
18
|
+
*/
|
|
19
|
+
maxTokens: 2048,
|
|
20
|
+
/** Soft word budget requested of the advisor, biasing toward a focused start. */
|
|
21
|
+
wordLimit: 80,
|
|
22
|
+
/** Abort the consult if the sub-call runs longer than this. */
|
|
23
|
+
timeoutMs: 60_000,
|
|
24
|
+
/** Inject the "call advisor before substantive work" steering. */
|
|
25
|
+
steeringEnabled: true,
|
|
26
|
+
} as const;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run one advisor consult.
|
|
3
|
+
*
|
|
4
|
+
* Routed entirely through the assistant's own inference: `getConfiguredProvider`
|
|
5
|
+
* resolves the general-purpose `inference` call site (with the advisor profile
|
|
6
|
+
* applied as an `overrideProfile`) to a provider/model/credentials from the
|
|
7
|
+
* configured profiles — managed-proxy or BYOK, no separate API key. The consult
|
|
8
|
+
* then runs a tool-less, capped one-shot completion through `provider.sendMessage`
|
|
9
|
+
* and returns the text.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { LLMCallSite } from "../../../config/schemas/llm.js";
|
|
13
|
+
import {
|
|
14
|
+
extractAllText,
|
|
15
|
+
getConfiguredProvider,
|
|
16
|
+
userMessage,
|
|
17
|
+
} from "../../../providers/provider-send-message.js";
|
|
18
|
+
import type { Message } from "../../../providers/types.js";
|
|
19
|
+
import { ADVISOR_CONFIG } from "./config.js";
|
|
20
|
+
import { advisorRequestText, buildAdvisorSystem } from "./steering.js";
|
|
21
|
+
import { toAdvisorMessages } from "./transcript.js";
|
|
22
|
+
|
|
23
|
+
// The general-purpose call site; the model is selected via `overrideProfile`.
|
|
24
|
+
const ADVISOR_CALL_SITE: LLMCallSite = "inference";
|
|
25
|
+
|
|
26
|
+
export interface ConsultParams {
|
|
27
|
+
systemPrompt: string | null;
|
|
28
|
+
messages: ReadonlyArray<Message>;
|
|
29
|
+
signal?: AbortSignal;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Combine the caller's signal with a consult timeout. */
|
|
33
|
+
function withTimeout(signal: AbortSignal | undefined, ms: number): AbortSignal {
|
|
34
|
+
const timeout = AbortSignal.timeout(ms);
|
|
35
|
+
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Returns the advisor's guidance text, or a short benign notice when the
|
|
40
|
+
* advisor can't run. Callers should surface the string as a non-error tool
|
|
41
|
+
* result so the executor continues regardless.
|
|
42
|
+
*/
|
|
43
|
+
export async function consultAdvisor(params: ConsultParams): Promise<string> {
|
|
44
|
+
const history = toAdvisorMessages(params.messages);
|
|
45
|
+
if (history.length === 0) {
|
|
46
|
+
return "(advisor: no conversation context is available yet)";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const provider = await getConfiguredProvider(ADVISOR_CALL_SITE, {
|
|
50
|
+
overrideProfile: ADVISOR_CONFIG.profile,
|
|
51
|
+
});
|
|
52
|
+
if (!provider) {
|
|
53
|
+
return "(advisor unavailable: no inference provider is configured)";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Append the consult instruction as the final user turn, then run a
|
|
57
|
+
// tool-less, capped completion through the resolved provider.
|
|
58
|
+
const messages: Message[] = [
|
|
59
|
+
...history,
|
|
60
|
+
userMessage(advisorRequestText(ADVISOR_CONFIG.wordLimit)),
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
const response = await provider.sendMessage(messages, {
|
|
64
|
+
systemPrompt: buildAdvisorSystem(params.systemPrompt),
|
|
65
|
+
config: {
|
|
66
|
+
callSite: ADVISOR_CALL_SITE,
|
|
67
|
+
overrideProfile: ADVISOR_CONFIG.profile,
|
|
68
|
+
tool_choice: { type: "none" },
|
|
69
|
+
max_tokens: ADVISOR_CONFIG.maxTokens,
|
|
70
|
+
},
|
|
71
|
+
signal: withTimeout(params.signal, ADVISOR_CONFIG.timeoutMs),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const advice = extractAllText(response).trim();
|
|
75
|
+
return advice.length > 0 ? advice : "(advisor returned no guidance)";
|
|
76
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `post-model-call` hook: snapshot the transcript the executor just saw. Fires
|
|
3
|
+
* after the model returns its message (which may carry the `advisor` tool_use)
|
|
4
|
+
* but before tools run, so when `advisor.execute()` runs an instant later it
|
|
5
|
+
* reads exactly this snapshot.
|
|
6
|
+
*
|
|
7
|
+
* Pure observer: never mutates `content` or the continue/stop `decision`. Gated
|
|
8
|
+
* to the user-facing reply, so background/subagent/compaction calls — and the
|
|
9
|
+
* advisor's own `inference`-call-site sub-call — are ignored.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { PluginHookFn, PostModelCallContext } from "@vellumai/plugin-api";
|
|
13
|
+
|
|
14
|
+
import { recordMessages } from "../advisor-state-store.js";
|
|
15
|
+
|
|
16
|
+
const postModelCall: PluginHookFn<PostModelCallContext> = async (ctx) => {
|
|
17
|
+
if (ctx.callSite !== "mainAgent") return;
|
|
18
|
+
if (ctx.error) return;
|
|
19
|
+
// `ctx.messages` is the pre-reply history; the turn the model just produced —
|
|
20
|
+
// including any text/plan it wrote before the `advisor` tool_use — lives in
|
|
21
|
+
// `ctx.content` and is not yet in `messages`. Append it (cloned) so the
|
|
22
|
+
// advisor reviews the full current transcript, not just the prior history.
|
|
23
|
+
// `transcript.ts` strips the pending tool_use from this final assistant turn.
|
|
24
|
+
const messages =
|
|
25
|
+
ctx.content.length > 0
|
|
26
|
+
? [
|
|
27
|
+
...ctx.messages,
|
|
28
|
+
{ role: "assistant" as const, content: [...ctx.content] },
|
|
29
|
+
]
|
|
30
|
+
: ctx.messages;
|
|
31
|
+
recordMessages(ctx.conversationId, messages);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export default postModelCall;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `pre-model-call` hook: for the user-facing reply (`mainAgent`), capture the
|
|
3
|
+
* executor's system prompt (steering stripped) so the advisor can be given it
|
|
4
|
+
* as context, and inject the advisor steering so the model reaches for the
|
|
5
|
+
* tool. Idempotent via the steering marker.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { PluginHookFn, PreModelCallContext } from "@vellumai/plugin-api";
|
|
9
|
+
|
|
10
|
+
import { recordSystemPrompt } from "../advisor-state-store.js";
|
|
11
|
+
import { ADVISOR_CONFIG } from "../config.js";
|
|
12
|
+
import { appendSteering, stripSteering } from "../steering.js";
|
|
13
|
+
|
|
14
|
+
const preModelCall: PluginHookFn<PreModelCallContext> = async (ctx) => {
|
|
15
|
+
if (ctx.callSite !== "mainAgent") return;
|
|
16
|
+
|
|
17
|
+
recordSystemPrompt(ctx.conversationId, stripSteering(ctx.systemPrompt));
|
|
18
|
+
|
|
19
|
+
if (ADVISOR_CONFIG.steeringEnabled) {
|
|
20
|
+
ctx.systemPrompt = appendSteering(ctx.systemPrompt);
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export default preModelCall;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `user-prompt-submit` hook: seed the per-conversation capture at the start of a
|
|
3
|
+
* user turn with the inbound history, so an advisor call on the very first model
|
|
4
|
+
* turn still has context even before `post-model-call` snapshots the running
|
|
5
|
+
* transcript.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
PluginHookFn,
|
|
10
|
+
UserPromptSubmitContext,
|
|
11
|
+
} from "@vellumai/plugin-api";
|
|
12
|
+
|
|
13
|
+
import { seedCapture } from "../advisor-state-store.js";
|
|
14
|
+
|
|
15
|
+
const userPromptSubmit: PluginHookFn<UserPromptSubmitContext> = async (ctx) => {
|
|
16
|
+
seedCapture(ctx.conversationId, ctx.latestMessages);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export default userPromptSubmit;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "default-advisor",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "First-party default plugin that adds an `advisor` tool: a no-argument tool the model calls mid-task to consult a stronger inference profile on the full conversation transcript for strategic guidance, routed through the assistant's own inference.",
|
|
5
|
+
"private": true,
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"engines": {
|
|
9
|
+
"node": ">=20.12.0"
|
|
10
|
+
},
|
|
11
|
+
"peerDependencies": {
|
|
12
|
+
"@vellumai/plugin-api": "^0.8.0"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompt fragments for the advisor plugin:
|
|
3
|
+
* - `appendSteering` / `stripSteering` — the executor-facing steering block the
|
|
4
|
+
* `pre-model-call` hook injects so the model reaches for the advisor tool at
|
|
5
|
+
* the right times.
|
|
6
|
+
* - `buildAdvisorSystem` / `advisorRequestText` — the advisor-facing framing for
|
|
7
|
+
* the consult itself.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Idempotency marker; the steering block is appended at the end of the prompt. */
|
|
11
|
+
export const STEERING_MARKER = "<!-- advisor:steering -->";
|
|
12
|
+
|
|
13
|
+
const STEERING_BODY = `You have an \`advisor\` tool backed by a stronger reviewer model. It takes NO parameters — calling it forwards your entire conversation automatically (the task, every tool call, every result). Call advisor BEFORE substantive work — before writing, before committing to an interpretation, before building on an assumption. Orientation (reading files, fetching a source) is not substantive work; do that first, then call advisor. Also call it when stuck, when changing approach, and once before you declare the task done. Give its guidance serious weight; only override it when primary-source evidence contradicts a specific claim, and say so when you do.`;
|
|
14
|
+
|
|
15
|
+
const ADVISOR_STEERING = `${STEERING_MARKER}\n${STEERING_BODY}`;
|
|
16
|
+
|
|
17
|
+
/** Append the steering block to the executor's system prompt (idempotent). */
|
|
18
|
+
export function appendSteering(systemPrompt: string | null): string | null {
|
|
19
|
+
if (systemPrompt === null) return null;
|
|
20
|
+
if (systemPrompt.includes(STEERING_MARKER)) return systemPrompt;
|
|
21
|
+
return `${systemPrompt}\n\n${ADVISOR_STEERING}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Remove a previously-appended steering block, recovering the original prompt. */
|
|
25
|
+
export function stripSteering(systemPrompt: string | null): string | null {
|
|
26
|
+
if (systemPrompt === null) return null;
|
|
27
|
+
const idx = systemPrompt.indexOf(STEERING_MARKER);
|
|
28
|
+
if (idx === -1) return systemPrompt;
|
|
29
|
+
return systemPrompt.slice(0, idx).trimEnd();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* System prompt for the advisor sub-call. Frames the advisor's role and, for
|
|
34
|
+
* context, quotes the executor's own system prompt (as the advisor tool does —
|
|
35
|
+
* the advisor sees the system prompt as context about the executor's task).
|
|
36
|
+
*/
|
|
37
|
+
export function buildAdvisorSystem(
|
|
38
|
+
originalSystemPrompt: string | null,
|
|
39
|
+
): string {
|
|
40
|
+
const base = `You are a senior technical advisor consulted by another AI agent partway through a task. You can see the agent's full conversation above: its task, every tool call it has made, and every result it has seen. Give concise, high-leverage strategic guidance — the right approach, the key risk or failure mode to avoid, and the single most important next step. Do not role-play as the agent or produce its final deliverable; advise it. If the agent is already on track, say so briefly and sharpen its plan.`;
|
|
41
|
+
if (!originalSystemPrompt) return base;
|
|
42
|
+
return `${base}\n\nFor context, the agent is operating under this system prompt:\n<agent_system_prompt>\n${originalSystemPrompt}\n</agent_system_prompt>`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The final user turn appended to the transcript for the advisor sub-call. Asks
|
|
47
|
+
* for guidance and carries the soft word-limit nudge.
|
|
48
|
+
*/
|
|
49
|
+
export function advisorRequestText(wordLimit: number): string {
|
|
50
|
+
return `Review the conversation above — the task, the tool calls, and their results — and give focused strategic guidance on how to proceed. (Advisor: keep your guidance under ~${wordLimit} words — a focused starting point, not a comprehensive plan.)`;
|
|
51
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `advisor` tool — a no-argument tool the model calls to consult a stronger
|
|
3
|
+
* model for strategic guidance. The model supplies no input; the plugin reads
|
|
4
|
+
* the transcript captured by the lifecycle hooks and runs the consult, routed
|
|
5
|
+
* through the assistant's own inference.
|
|
6
|
+
*
|
|
7
|
+
* Default export = the tool definition. `defaults/index.ts` finalizes it and
|
|
8
|
+
* attaches it to the advisor plugin's `tools` array, which `bootstrapPlugins`
|
|
9
|
+
* registers into the model-visible tool catalog.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type {
|
|
13
|
+
ToolContext,
|
|
14
|
+
ToolDefinition,
|
|
15
|
+
ToolExecutionResult,
|
|
16
|
+
} from "@vellumai/plugin-api";
|
|
17
|
+
import { RiskLevel } from "@vellumai/plugin-api";
|
|
18
|
+
|
|
19
|
+
import { getCapture } from "../advisor-state-store.js";
|
|
20
|
+
import { consultAdvisor } from "../consult.js";
|
|
21
|
+
|
|
22
|
+
const advisorTool: ToolDefinition = {
|
|
23
|
+
name: "advisor",
|
|
24
|
+
description:
|
|
25
|
+
"Consult a stronger advisor model for strategic guidance. Takes NO parameters — your " +
|
|
26
|
+
"full conversation (the task, every tool call, and every result) is forwarded " +
|
|
27
|
+
"automatically. Call it before substantive work, when you're stuck, when changing " +
|
|
28
|
+
"approach, and once before declaring a task complete.",
|
|
29
|
+
input_schema: { type: "object", properties: {}, additionalProperties: false },
|
|
30
|
+
// Read-only advice; low risk so the consult isn't gated behind a prompt.
|
|
31
|
+
defaultRiskLevel: RiskLevel.Low,
|
|
32
|
+
async execute(
|
|
33
|
+
_input: Record<string, unknown>,
|
|
34
|
+
ctx: ToolContext,
|
|
35
|
+
): Promise<ToolExecutionResult> {
|
|
36
|
+
try {
|
|
37
|
+
const capture = getCapture(ctx.conversationId);
|
|
38
|
+
const advice = await consultAdvisor({
|
|
39
|
+
systemPrompt: capture?.systemPrompt ?? null,
|
|
40
|
+
messages: capture?.messages ?? [],
|
|
41
|
+
signal: ctx.signal,
|
|
42
|
+
});
|
|
43
|
+
return { content: advice, isError: false };
|
|
44
|
+
} catch (err) {
|
|
45
|
+
// Degrade like the advisor tool: never fail the turn over a consult.
|
|
46
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
47
|
+
return { content: `(advisor unavailable: ${reason})`, isError: false };
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export default advisorTool;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert a captured executor transcript into the message list sent to the
|
|
3
|
+
* advisor sub-call.
|
|
4
|
+
*
|
|
5
|
+
* Strips blocks the advisor shouldn't (or can't) replay:
|
|
6
|
+
* - thinking / redacted-thinking (the advisor tool drops thinking),
|
|
7
|
+
* - files,
|
|
8
|
+
* - `server_tool_use` AND `web_search_tool_result` — provider-side tool calls
|
|
9
|
+
* (e.g. web search) and their results are dropped *together*. Dropping the
|
|
10
|
+
* result without its paired `server_tool_use` would leave an orphaned call
|
|
11
|
+
* block the provider rejects, so any consult after prior web-search history
|
|
12
|
+
* would fail; dropping both keeps the sequence valid.
|
|
13
|
+
*
|
|
14
|
+
* Images are preserved — top-level and nested inside `tool_result.contentBlocks`
|
|
15
|
+
* (which are recursively sanitized) — so the advisor sees what the executor saw.
|
|
16
|
+
* Visual tasks depend on it; the advisor profile is expected to be vision-capable.
|
|
17
|
+
*
|
|
18
|
+
* It also strips the *pending* client tool calls from the final assistant turn:
|
|
19
|
+
* at capture time (a `post-model-call` before tools run) the last assistant
|
|
20
|
+
* message carries the `advisor` tool_use with no matching `tool_result` yet, so
|
|
21
|
+
* sending it would be a dangling call. Earlier, completed `tool_use` /
|
|
22
|
+
* `tool_result` pairs are preserved intact.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { ContentBlock, Message } from "../../../providers/types.js";
|
|
26
|
+
|
|
27
|
+
/** Drop disallowed blocks; recursively sanitize tool_result content. `null` = drop. */
|
|
28
|
+
function sanitize(block: ContentBlock): ContentBlock | null {
|
|
29
|
+
switch (block.type) {
|
|
30
|
+
case "thinking":
|
|
31
|
+
case "redacted_thinking":
|
|
32
|
+
case "file":
|
|
33
|
+
case "server_tool_use":
|
|
34
|
+
case "web_search_tool_result":
|
|
35
|
+
return null;
|
|
36
|
+
case "tool_result": {
|
|
37
|
+
if (!block.contentBlocks) return block;
|
|
38
|
+
// Keep images (and other allowed blocks) nested in the tool result; drop
|
|
39
|
+
// the disallowed ones.
|
|
40
|
+
const contentBlocks = block.contentBlocks
|
|
41
|
+
.map(sanitize)
|
|
42
|
+
.filter((b): b is ContentBlock => b !== null);
|
|
43
|
+
return {
|
|
44
|
+
...block,
|
|
45
|
+
contentBlocks: contentBlocks.length > 0 ? contentBlocks : undefined,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
default:
|
|
49
|
+
// text, image, tool_use — kept.
|
|
50
|
+
return block;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function toAdvisorMessages(messages: ReadonlyArray<Message>): Message[] {
|
|
55
|
+
const out: Message[] = [];
|
|
56
|
+
const lastIndex = messages.length - 1;
|
|
57
|
+
|
|
58
|
+
messages.forEach((message, index) => {
|
|
59
|
+
let content = message.content
|
|
60
|
+
.map(sanitize)
|
|
61
|
+
.filter((b): b is ContentBlock => b !== null);
|
|
62
|
+
|
|
63
|
+
// The final assistant turn's client tool calls have no results yet — drop
|
|
64
|
+
// them so we never send a dangling tool_use. (server_tool_use is already
|
|
65
|
+
// dropped above.)
|
|
66
|
+
if (index === lastIndex && message.role === "assistant") {
|
|
67
|
+
content = content.filter(
|
|
68
|
+
(b) => b.type !== "tool_use" && b.type !== "server_tool_use",
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (content.length > 0) out.push({ role: message.role, content });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
@@ -24,8 +24,15 @@
|
|
|
24
24
|
* {@link registerDefaultPlugins} at call time.
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
+
import { finalizeTool } from "../../tools/tool-defaults.js";
|
|
27
28
|
import { registerPlugin, resetPluginRegistryForTests } from "../registry.js";
|
|
28
29
|
import { type Plugin, PluginExecutionError } from "../types.js";
|
|
30
|
+
import { resetAdvisorStateForTests } from "./advisor/advisor-state-store.js";
|
|
31
|
+
import advisorPostModelCall from "./advisor/hooks/post-model-call.js";
|
|
32
|
+
import advisorPreModelCall from "./advisor/hooks/pre-model-call.js";
|
|
33
|
+
import advisorUserPromptSubmit from "./advisor/hooks/user-prompt-submit.js";
|
|
34
|
+
import advisorPkg from "./advisor/package.json" with { type: "json" };
|
|
35
|
+
import advisorTool from "./advisor/tools/advisor.js";
|
|
29
36
|
import compactionPkg from "./compaction/package.json" with { type: "json" };
|
|
30
37
|
import emptyResponsePostModelCall from "./empty-response/hooks/post-model-call.js";
|
|
31
38
|
import emptyResponseStop from "./empty-response/hooks/stop.js";
|
|
@@ -297,6 +304,30 @@ export const defaultToolResultTruncatePlugin: Plugin = {
|
|
|
297
304
|
},
|
|
298
305
|
};
|
|
299
306
|
|
|
307
|
+
/**
|
|
308
|
+
* `advisor` — adds the model-visible `advisor` tool: a no-argument tool the
|
|
309
|
+
* model calls to consult a stronger inference profile (the `inference` call
|
|
310
|
+
* site with a `quality-optimized` override) on the full transcript, routed
|
|
311
|
+
* through the assistant's own inference. Three hooks feed it: `user-prompt-submit`
|
|
312
|
+
* seeds the capture, `pre-model-call` records the executor's system prompt and
|
|
313
|
+
* injects the steering that nudges the model to consult, and `post-model-call`
|
|
314
|
+
* snapshots the transcript the tool reads. `finalizeTool` fills the tool's
|
|
315
|
+
* defaults so it satisfies `Tool`, and `bootstrapPlugins` registers it into the
|
|
316
|
+
* catalog.
|
|
317
|
+
*/
|
|
318
|
+
export const defaultAdvisorPlugin: Plugin = {
|
|
319
|
+
manifest: {
|
|
320
|
+
name: advisorPkg.name,
|
|
321
|
+
version: advisorPkg.version,
|
|
322
|
+
},
|
|
323
|
+
hooks: {
|
|
324
|
+
"user-prompt-submit": advisorUserPromptSubmit,
|
|
325
|
+
"pre-model-call": advisorPreModelCall,
|
|
326
|
+
"post-model-call": advisorPostModelCall,
|
|
327
|
+
},
|
|
328
|
+
tools: [finalizeTool(advisorTool, "advisor")],
|
|
329
|
+
};
|
|
330
|
+
|
|
300
331
|
/**
|
|
301
332
|
* Full set of first-party default plugins. Used by
|
|
302
333
|
* {@link registerDefaultPlugins} to drive the registration loop; the array
|
|
@@ -318,6 +349,9 @@ function getAllDefaultPlugins(): readonly Plugin[] {
|
|
|
318
349
|
defaultCompactionPlugin,
|
|
319
350
|
defaultTitleGeneratePlugin,
|
|
320
351
|
memoryV3ShadowPlugin,
|
|
352
|
+
// Registered last so its capture hooks observe the fully-processed turn
|
|
353
|
+
// (memory injections, history repair) that the executor actually sees.
|
|
354
|
+
defaultAdvisorPlugin,
|
|
321
355
|
];
|
|
322
356
|
}
|
|
323
357
|
|
|
@@ -364,5 +398,6 @@ export function resetPluginRegistryAndRegisterDefaults(): void {
|
|
|
364
398
|
resetExplorationDriftStateForTests();
|
|
365
399
|
resetTaskProgressNudgeStateForTests();
|
|
366
400
|
resetSurfaceCompletionNudgeStoreForTests();
|
|
401
|
+
resetAdvisorStateForTests();
|
|
367
402
|
registerDefaultPlugins();
|
|
368
403
|
}
|