@vellumai/assistant 0.9.0-dev.202606181513.fd39213 → 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.
Files changed (41) hide show
  1. package/openapi.yaml +82 -0
  2. package/package.json +1 -1
  3. package/src/__tests__/plugin-tool-contribution.test.ts +6 -3
  4. package/src/api/events/workflow-completed.ts +53 -0
  5. package/src/api/events/workflow-leaf-finished.ts +38 -0
  6. package/src/api/events/workflow-leaf-started.ts +35 -0
  7. package/src/api/events/workflow-progress.ts +32 -0
  8. package/src/api/events/workflow-started.ts +31 -0
  9. package/src/api/index.ts +38 -0
  10. package/src/api/responses/workflow-journal.ts +53 -0
  11. package/src/daemon/message-types/workflows.ts +83 -1
  12. package/src/memory/db-init.ts +2 -0
  13. package/src/memory/migrations/293-workflow-journal-leaf-tokens.ts +32 -0
  14. package/src/memory/migrations/index.ts +1 -0
  15. package/src/plugins/defaults/advisor/__tests__/advisor-state-store.test.ts +43 -0
  16. package/src/plugins/defaults/advisor/__tests__/agent-loop-integration.test.ts +134 -0
  17. package/src/plugins/defaults/advisor/__tests__/consult.test.ts +147 -0
  18. package/src/plugins/defaults/advisor/__tests__/hooks.test.ts +138 -0
  19. package/src/plugins/defaults/advisor/__tests__/transcript.test.ts +147 -0
  20. package/src/plugins/defaults/advisor/advisor-state-store.ts +94 -0
  21. package/src/plugins/defaults/advisor/config.ts +26 -0
  22. package/src/plugins/defaults/advisor/consult.ts +76 -0
  23. package/src/plugins/defaults/advisor/hooks/post-model-call.ts +34 -0
  24. package/src/plugins/defaults/advisor/hooks/pre-model-call.ts +24 -0
  25. package/src/plugins/defaults/advisor/hooks/user-prompt-submit.ts +19 -0
  26. package/src/plugins/defaults/advisor/package.json +14 -0
  27. package/src/plugins/defaults/advisor/steering.ts +51 -0
  28. package/src/plugins/defaults/advisor/tools/advisor.ts +52 -0
  29. package/src/plugins/defaults/advisor/transcript.ts +76 -0
  30. package/src/plugins/defaults/index.ts +35 -0
  31. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +3 -3
  32. package/src/runtime/routes/inbound-stages/background-dispatch.ts +1 -1
  33. package/src/runtime/routes/workflow-routes.test.ts +225 -1
  34. package/src/runtime/routes/workflow-routes.ts +131 -1
  35. package/src/tools/workflows/run-workflow.ts +1 -0
  36. package/src/workflows/engine.test.ts +175 -1
  37. package/src/workflows/engine.ts +82 -0
  38. package/src/workflows/journal-store.test.ts +70 -0
  39. package/src/workflows/journal-store.ts +18 -3
  40. package/src/workflows/run-manager.test.ts +171 -3
  41. package/src/workflows/run-manager.ts +63 -0
@@ -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;