@vellumai/assistant 0.10.7-dev.202607101343.e727d4c → 0.10.7-dev.202607101554.2aa9767

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 (29) hide show
  1. package/docs/workspace-tools.md +4 -1
  2. package/package.json +1 -1
  3. package/src/__tests__/conversation-agent-loop.test.ts +25 -0
  4. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  5. package/src/__tests__/llm-request-log-clickhouse-routing.test.ts +117 -0
  6. package/src/__tests__/llm-request-log-sink-clickhouse.test.ts +219 -0
  7. package/src/__tests__/mtime-cache.test.ts +13 -2
  8. package/src/agent/loop.ts +16 -2
  9. package/src/daemon/conversation-agent-loop-handlers.ts +18 -4
  10. package/src/daemon/conversation-tool-setup.ts +10 -5
  11. package/src/daemon/external-plugins-bootstrap.ts +7 -0
  12. package/src/persistence/__tests__/message-content-file.test.ts +130 -35
  13. package/src/persistence/conversation-crud.ts +19 -0
  14. package/src/persistence/llm-request-log-sink-clickhouse.ts +312 -0
  15. package/src/persistence/llm-request-log-store.ts +40 -2
  16. package/src/persistence/message-content-file.ts +129 -73
  17. package/src/persistence/migrations/325-create-config-setting-events.ts +40 -0
  18. package/src/persistence/schema/infrastructure.ts +21 -0
  19. package/src/persistence/steps.ts +2 -0
  20. package/src/plugins/mtime-cache.ts +93 -57
  21. package/src/providers/content-block-schema.ts +149 -0
  22. package/src/telemetry/__tests__/config-setting-events-store.test.ts +134 -0
  23. package/src/telemetry/config-setting-events-store.ts +97 -0
  24. package/src/telemetry/turn-trace-store.test.ts +86 -1
  25. package/src/telemetry/turn-trace-store.ts +29 -1
  26. package/src/telemetry/types.ts +36 -2
  27. package/src/telemetry/usage-telemetry-reporter.test.ts +106 -7
  28. package/src/telemetry/usage-telemetry-reporter.ts +58 -3
  29. package/src/tools/registry.ts +99 -0
@@ -130,14 +130,17 @@ on the incoming tool.
130
130
  initializeTools() # core tools register
131
131
  → loadWorkspaceTools() # initial workspace tool reconcile
132
132
  → MCP tool registration
133
- → loadUserPlugins()
133
+ → loadUserPlugins() # populates the plugin mtime-cache (no registry writes)
134
+ → loadPluginTools() # registry pulls the active user-plugin tool set
134
135
  → bootstrapPlugins()
135
136
 
136
137
  # on every conversation turn (createResolveToolsCallback):
137
138
  resolveTools(history)
138
139
  → loadWorkspaceTools() # reconcile registry against disk (fire-and-forget)
140
+ → loadPluginTools() # pull user-plugin tools from the mtime-cache (same pattern)
139
141
  → getWorkspaceToolDefinitions() # re-read workspace tools from the registry
140
142
  → getMcpToolDefinitions() # re-read MCP tools (same pattern)
143
+ → getPluginToolDefinitions() # re-read plugin tools from the registry
141
144
  ```
142
145
 
143
146
  Workspace tools register _after_ core tools and _before_ every other
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.10.7-dev.202607101343.e727d4c",
3
+ "version": "0.10.7-dev.202607101554.2aa9767",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1606,6 +1606,31 @@ describe("session-agent-loop", () => {
1606
1606
  expect(mainAgentCall?.[2]).toBe(3);
1607
1607
  expect(mainAgentCall?.[3]).toBe("gpt-4.1-2026-03-01");
1608
1608
  });
1609
+
1610
+ test("persists the served model onto the assistant row's metadata at finalize", async () => {
1611
+ const events: ServerMessage[] = [];
1612
+
1613
+ const ctx = makeCtx({
1614
+ providerResponses: [
1615
+ {
1616
+ content: [{ type: "text", text: "Hi there." }],
1617
+ model: "gpt-4.1-2026-03-01",
1618
+ usage: { inputTokens: 12, outputTokens: 3 },
1619
+ stopReason: "end_turn",
1620
+ },
1621
+ ],
1622
+ });
1623
+
1624
+ await runAgentLoopImpl(ctx, "hello", "msg-1", (msg) => events.push(msg));
1625
+
1626
+ // The finalize write carries the `message_complete` event's model
1627
+ // (`response.model`) as metadata alongside the content, in one write.
1628
+ const finalizeCall = updateMessageContentMock.mock.calls.find(
1629
+ (call) => (call as unknown[])[2] !== undefined,
1630
+ ) as unknown[] | undefined;
1631
+ expect(finalizeCall).toBeDefined();
1632
+ expect(finalizeCall?.[2]).toEqual({ model: "gpt-4.1-2026-03-01" });
1633
+ });
1609
1634
  });
1610
1635
 
1611
1636
  describe("checkpoint handoff (infinite loop prevention)", () => {
@@ -208,6 +208,7 @@ describe("Invariant 2: no generic plaintext secret read API", () => {
208
208
  "media/image-credentials.ts", // shared image-gen credential resolver (provider API key lookup)
209
209
  "persistence/embeddings/embedding-backend.ts", // embedding backend API key lookup
210
210
  "persistence/llm-request-log-source-clickhouse.ts", // ClickHouse read source — lazy lookup of clickhouse:url + clickhouse:password + vellum:platform_assistant_id for self-scoped mirror reads
211
+ "persistence/llm-request-log-sink-clickhouse.ts", // ClickHouse write sink — lazy lookup of clickhouse:url + clickhouse:password + vellum:platform_assistant_id for self-scoped log writes
211
212
  "persistence/compaction-log-store-clickhouse.ts", // ClickHouse compaction log writer — lazy lookup of clickhouse:url + clickhouse:password + vellum:platform_assistant_id for self-scoped event writes
212
213
  "daemon/providers-setup.ts", // provider initialization API key lookup
213
214
  "workspace/migrations/006-services-config.ts", // services config migration reads provider API keys
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Tests that the store's write path routes to ClickHouse instead of local
3
+ * SQLite when a ClickHouse sink is configured — and stays on SQLite
4
+ * otherwise. The sink module is mocked so the routing decision is observable
5
+ * without a live ClickHouse.
6
+ */
7
+ import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
8
+
9
+ mock.module("../util/logger.js", () => ({
10
+ getLogger: () =>
11
+ new Proxy({} as Record<string, unknown>, { get: () => () => {} }),
12
+ }));
13
+
14
+ // Logging stays enabled; `getConfigReadOnly` is only consulted by the store.s
15
+ // disabled gate here (the sink factory itself is mocked below).
16
+ mock.module("../config/loader.js", () => ({
17
+ getConfig: () => ({ llmRequestLogs: { readSource: "local" } }),
18
+ getConfigReadOnly: () => ({ llmRequestLogs: { readSource: "local" } }),
19
+ }));
20
+
21
+ // Mutable fake sink: when non-null, the store must route to it.
22
+ interface CapturedRow {
23
+ id: string;
24
+ conversationId: string;
25
+ callSite: string | null;
26
+ }
27
+ let capturedRows: CapturedRow[] = [];
28
+ let sinkActive = false;
29
+ mock.module("../persistence/llm-request-log-sink-clickhouse.js", () => ({
30
+ getClickHouseLlmRequestLogSink: () =>
31
+ sinkActive
32
+ ? {
33
+ recordBestEffort: (row: CapturedRow) => {
34
+ capturedRows.push(row);
35
+ },
36
+ }
37
+ : null,
38
+ }));
39
+
40
+ afterAll(() => {
41
+ sinkActive = false;
42
+ });
43
+
44
+ import { getLogsDb } from "../persistence/db-connection.js";
45
+ import { initializeDb } from "../persistence/db-init.js";
46
+ import {
47
+ getRequestLogsByConversationId,
48
+ recordRequestLog,
49
+ recordSyntheticAgentErrorMessageLog,
50
+ } from "../persistence/llm-request-log-store.js";
51
+ import { llmRequestLogs } from "../persistence/schema/index.js";
52
+
53
+ await initializeDb();
54
+
55
+ function resetLogs(): void {
56
+ getLogsDb()!.delete(llmRequestLogs).run();
57
+ }
58
+
59
+ describe("write routing between SQLite and ClickHouse", () => {
60
+ beforeEach(() => {
61
+ resetLogs();
62
+ capturedRows = [];
63
+ sinkActive = false;
64
+ });
65
+
66
+ test("writes to local SQLite when no ClickHouse sink is configured", () => {
67
+ const id = recordRequestLog(
68
+ "conv-local",
69
+ '{"req":1}',
70
+ '{"res":1}',
71
+ undefined,
72
+ "anthropic",
73
+ "mainAgent",
74
+ );
75
+ expect(id).not.toBeNull();
76
+ expect(capturedRows).toHaveLength(0);
77
+ const rows = getRequestLogsByConversationId("conv-local");
78
+ expect(rows).toHaveLength(1);
79
+ expect(rows[0]!.provider).toBe("anthropic");
80
+ });
81
+
82
+ test("routes to ClickHouse and skips SQLite when a sink is configured", () => {
83
+ sinkActive = true;
84
+ const id = recordRequestLog(
85
+ "conv-ch",
86
+ '{"req":1}',
87
+ '{"res":1}',
88
+ "msg-ch",
89
+ "openai",
90
+ "mainAgent",
91
+ );
92
+ expect(id).not.toBeNull();
93
+ // Nothing landed in the local table.
94
+ expect(getRequestLogsByConversationId("conv-ch")).toEqual([]);
95
+ // The row went to the sink, carrying the same id and call site.
96
+ expect(capturedRows).toHaveLength(1);
97
+ expect(capturedRows[0]!.id).toBe(id!);
98
+ expect(capturedRows[0]!.conversationId).toBe("conv-ch");
99
+ expect(capturedRows[0]!.callSite).toBe("mainAgent");
100
+ });
101
+
102
+ test("routes synthetic error rows to ClickHouse too", () => {
103
+ sinkActive = true;
104
+ const id = recordSyntheticAgentErrorMessageLog({
105
+ conversationId: "conv-ch-2",
106
+ messageId: "msg-ch-2",
107
+ exitReason: "budget_yield_unrecovered",
108
+ noticeText: "Out of budget.",
109
+ preparedRequest: null,
110
+ createdAt: 1_700_000_000_000,
111
+ });
112
+ expect(id).not.toBeNull();
113
+ expect(getRequestLogsByConversationId("conv-ch-2")).toEqual([]);
114
+ expect(capturedRows).toHaveLength(1);
115
+ expect(capturedRows[0]!.callSite).toBe("syntheticAgentErrorMessage");
116
+ });
117
+ });
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Tests the ClickHouse WRITE sink for LLM request logs: the wire shape it
3
+ * POSTs (lazy CREATE TABLE + JSONEachRow INSERT, null → '' sentinels,
4
+ * assistant_id injected from credentials) and the config-gated factory.
5
+ */
6
+ import { afterEach, describe, expect, mock, test } from "bun:test";
7
+
8
+ function makeLoggerStub(): Record<string, unknown> {
9
+ const stub: Record<string, unknown> = {};
10
+ for (const m of [
11
+ "info",
12
+ "warn",
13
+ "error",
14
+ "debug",
15
+ "trace",
16
+ "fatal",
17
+ "silent",
18
+ "child",
19
+ ]) {
20
+ stub[m] = m === "child" ? () => makeLoggerStub() : () => {};
21
+ }
22
+ return stub;
23
+ }
24
+
25
+ mock.module("../util/logger.js", () => ({
26
+ getLogger: () => makeLoggerStub(),
27
+ }));
28
+
29
+ // Mutable config the factory reads via `getConfigReadOnly`.
30
+ let currentConfig: unknown = { llmRequestLogs: { readSource: "local" } };
31
+ mock.module("../config/loader.js", () => ({
32
+ getConfigReadOnly: () => currentConfig,
33
+ }));
34
+
35
+ afterEach(() => {
36
+ currentConfig = { llmRequestLogs: { readSource: "local" } };
37
+ resetClickHouseLlmRequestLogSinkForTests();
38
+ });
39
+
40
+ import {
41
+ ClickHouseLlmRequestLogSink,
42
+ getClickHouseLlmRequestLogSink,
43
+ resetClickHouseLlmRequestLogSinkForTests,
44
+ } from "../persistence/llm-request-log-sink-clickhouse.js";
45
+
46
+ const DEFAULT_CONFIG = {
47
+ database: "default",
48
+ table: "llm_request_logs",
49
+ user: "default",
50
+ };
51
+
52
+ interface FakeFetchCall {
53
+ url: string;
54
+ init: RequestInit | undefined;
55
+ }
56
+
57
+ function fakeFetch(recorder: FakeFetchCall[], status = 200): typeof fetch {
58
+ return ((url: string | URL | Request, init?: RequestInit) => {
59
+ recorder.push({
60
+ url: typeof url === "string" ? url : url.toString(),
61
+ init,
62
+ });
63
+ return Promise.resolve(new Response("", { status }));
64
+ }) as typeof fetch;
65
+ }
66
+
67
+ function makeSink(recorder: FakeFetchCall[], status = 200) {
68
+ return new ClickHouseLlmRequestLogSink(DEFAULT_CONFIG, {
69
+ resolveUrl: async () => "https://ch.example.test:8443",
70
+ resolvePassword: async () => "hunter2",
71
+ resolveAssistantId: async () => "asst-fixture-001",
72
+ fetchImpl: fakeFetch(recorder, status),
73
+ });
74
+ }
75
+
76
+ describe("ClickHouseLlmRequestLogSink.insert", () => {
77
+ test("creates the table then inserts the JSONEachRow wire shape", async () => {
78
+ const calls: FakeFetchCall[] = [];
79
+ const sink = makeSink(calls);
80
+
81
+ await sink.insert({
82
+ id: "log-1",
83
+ conversationId: "conv-1",
84
+ messageId: "msg-1",
85
+ provider: "anthropic",
86
+ requestPayload: '{"req":1}',
87
+ responsePayload: '{"res":1}',
88
+ createdAt: 1_700_000_000_123,
89
+ agentLoopExitReason: "no_tool_calls",
90
+ callSite: "mainAgent",
91
+ });
92
+
93
+ expect(calls).toHaveLength(2);
94
+ // 1) DDL as the request body, no `query` param.
95
+ expect(String(calls[0]!.init?.body)).toContain(
96
+ "CREATE TABLE IF NOT EXISTS",
97
+ );
98
+ expect(new URL(calls[0]!.url).searchParams.get("query")).toBeNull();
99
+
100
+ // 2) INSERT statement in the `query` param, row JSON in the body.
101
+ const insertUrl = new URL(calls[1]!.url);
102
+ expect(insertUrl.searchParams.get("query")).toContain(
103
+ "INSERT INTO `llm_request_logs` FORMAT JSONEachRow",
104
+ );
105
+ expect(insertUrl.searchParams.get("database")).toBe("default");
106
+ const row = JSON.parse(String(calls[1]!.init?.body));
107
+ expect(row).toEqual({
108
+ assistant_id: "asst-fixture-001",
109
+ id: "log-1",
110
+ conversation_id: "conv-1",
111
+ message_id: "msg-1",
112
+ provider: "anthropic",
113
+ request_payload: '{"req":1}',
114
+ response_payload: '{"res":1}',
115
+ created_at: 1_700_000_000_123,
116
+ agent_loop_exit_reason: "no_tool_calls",
117
+ call_site: "mainAgent",
118
+ });
119
+ // Basic auth is sent.
120
+ expect(
121
+ (calls[1]!.init?.headers as Record<string, string>).Authorization,
122
+ ).toStartWith("Basic ");
123
+ });
124
+
125
+ test("maps null string fields to the '' sentinel", async () => {
126
+ const calls: FakeFetchCall[] = [];
127
+ const sink = makeSink(calls);
128
+
129
+ await sink.insert({
130
+ id: "log-2",
131
+ conversationId: "conv-2",
132
+ messageId: null,
133
+ provider: null,
134
+ requestPayload: "{}",
135
+ responsePayload: "{}",
136
+ createdAt: 1,
137
+ agentLoopExitReason: null,
138
+ callSite: null,
139
+ });
140
+
141
+ const row = JSON.parse(String(calls[1]!.init?.body));
142
+ expect(row.message_id).toBe("");
143
+ expect(row.provider).toBe("");
144
+ expect(row.agent_loop_exit_reason).toBe("");
145
+ expect(row.call_site).toBe("");
146
+ });
147
+
148
+ test("insert rejects on a ClickHouse error status", async () => {
149
+ const calls: FakeFetchCall[] = [];
150
+ const sink = makeSink(calls, 500);
151
+ await expect(
152
+ sink.insert({
153
+ id: "log-3",
154
+ conversationId: "conv-3",
155
+ messageId: null,
156
+ provider: null,
157
+ requestPayload: "{}",
158
+ responsePayload: "{}",
159
+ createdAt: 1,
160
+ agentLoopExitReason: null,
161
+ callSite: null,
162
+ }),
163
+ ).rejects.toThrow(/ClickHouse llm-request-log write failed/);
164
+ });
165
+
166
+ test("recordBestEffort never throws synchronously on a failing backend", () => {
167
+ const calls: FakeFetchCall[] = [];
168
+ const sink = makeSink(calls, 500);
169
+ expect(() =>
170
+ sink.recordBestEffort({
171
+ id: "log-4",
172
+ conversationId: "conv-4",
173
+ messageId: null,
174
+ provider: null,
175
+ requestPayload: "{}",
176
+ responsePayload: "{}",
177
+ createdAt: 1,
178
+ agentLoopExitReason: null,
179
+ callSite: null,
180
+ }),
181
+ ).not.toThrow();
182
+ });
183
+ });
184
+
185
+ describe("getClickHouseLlmRequestLogSink factory", () => {
186
+ test("returns null when readSource is 'local'", () => {
187
+ currentConfig = { llmRequestLogs: { readSource: "local" } };
188
+ expect(getClickHouseLlmRequestLogSink()).toBeNull();
189
+ });
190
+
191
+ test("returns null when llmRequestLogs is absent", () => {
192
+ currentConfig = {};
193
+ expect(getClickHouseLlmRequestLogSink()).toBeNull();
194
+ });
195
+
196
+ test("returns a sink when readSource is 'clickhouse'", () => {
197
+ currentConfig = {
198
+ llmRequestLogs: { readSource: "clickhouse", clickhouse: DEFAULT_CONFIG },
199
+ };
200
+ expect(getClickHouseLlmRequestLogSink()).toBeInstanceOf(
201
+ ClickHouseLlmRequestLogSink,
202
+ );
203
+ });
204
+
205
+ test("re-instantiates when the clickhouse config changes", () => {
206
+ currentConfig = {
207
+ llmRequestLogs: { readSource: "clickhouse", clickhouse: DEFAULT_CONFIG },
208
+ };
209
+ const first = getClickHouseLlmRequestLogSink();
210
+ currentConfig = {
211
+ llmRequestLogs: {
212
+ readSource: "clickhouse",
213
+ clickhouse: { ...DEFAULT_CONFIG, table: "other_logs" },
214
+ },
215
+ };
216
+ const second = getClickHouseLlmRequestLogSink();
217
+ expect(second).not.toBe(first);
218
+ });
219
+ });
@@ -43,9 +43,11 @@ import {
43
43
  } from "../plugins/mtime-cache.js";
44
44
  import { getSourceVersionsPath } from "../plugins/source-versions.js";
45
45
  import {
46
+ __resetRegistryForTesting,
46
47
  getAllToolDefinitions,
47
48
  getPluginToolDefinitions,
48
49
  getToolOwner,
50
+ loadPluginTools,
49
51
  } from "../tools/registry.js";
50
52
 
51
53
  // ─── Test fixtures ───────────────────────────────────────────────────────────
@@ -171,6 +173,9 @@ beforeEach(() => {
171
173
  watchState = null;
172
174
  sentinelTouchSeq = 0;
173
175
  resetPluginCacheForTests();
176
+ // The registry pulls plugin tools via loadPluginTools(); clear its side
177
+ // (tools + pulled fingerprints) so registrations never leak across tests.
178
+ __resetRegistryForTesting();
174
179
  });
175
180
 
176
181
  afterAll(() => {
@@ -357,9 +362,11 @@ describe("plugin mtime cache (per-surface)", () => {
357
362
  touchFile(toolFile);
358
363
  publishSourceChanges();
359
364
  await getUserHooksFor("user-prompt-submit");
365
+ await loadPluginTools();
360
366
 
361
367
  // Both the cache and the global registry serve the fresh definition —
362
- // the redeploy unregistered the old tool and registered the new one.
368
+ // the moved source mtime changed the plugin's fingerprint, so the pull
369
+ // reconcile re-registered its tool set.
363
370
  expect(getCachedUserTools()[0]?.description).toBe("v2");
364
371
  expect(
365
372
  getAllToolDefinitions().find((t) => t.name === "edited-tool")
@@ -632,11 +639,13 @@ const TOOL_SRC = (name: string) =>
632
639
  * production sequence: the monitor's pass rewrites the sentinel, and the
633
640
  * next hook dispatch's one-stat gate applies the diff. The hook name is
634
641
  * irrelevant — the reconcile runs regardless of whether a plugin defines
635
- * that hook.
642
+ * that hook. Finish with the registry pull the per-turn tool resolver
643
+ * fires, so registry-facing assertions see the reconciled tool set.
636
644
  */
637
645
  async function publishAndDispatch(): Promise<void> {
638
646
  publishSourceChanges();
639
647
  await getUserHooksFor("user-prompt-submit");
648
+ await loadPluginTools();
640
649
  }
641
650
 
642
651
  describe("plugin runtime activation", () => {
@@ -701,6 +710,7 @@ describe("plugin runtime activation", () => {
701
710
  writeMarkerHook(dir, "shutdown", shutdownMarker, "bye");
702
711
 
703
712
  await populateCacheAtBoot();
713
+ await loadPluginTools(); // boot pull (initializePlugins does this)
704
714
  expect(getToolOwner("temp-tool")?.kind).toBe("plugin");
705
715
 
706
716
  rmSync(dir, { recursive: true, force: true });
@@ -740,6 +750,7 @@ describe("plugin runtime activation", () => {
740
750
  writeMarkerHook(dir, "shutdown", shutdownMarker, "disabled");
741
751
 
742
752
  await populateCacheAtBoot();
753
+ await loadPluginTools(); // boot pull (initializePlugins does this)
743
754
  expect(getToolOwner("disable-tool")?.kind).toBe("plugin");
744
755
 
745
756
  writeFileSync(join(dir, ".disabled"), "");
package/src/agent/loop.ts CHANGED
@@ -240,7 +240,15 @@ export type AgentEvent =
240
240
  | { type: "llm_call_started"; callSite?: LLMCallSite }
241
241
  | { type: "text_delta"; text: string }
242
242
  | { type: "thinking_delta"; thinking: string }
243
- | { type: "message_complete"; message: Message }
243
+ /**
244
+ * Emitted once per LLM call when the assistant message is finalized.
245
+ * `model` is the provider-reported model that served the call
246
+ * (`response.model`) — the same value `llm_usage` records — so downstream
247
+ * persistence can attribute the message to the model that actually ran,
248
+ * including per-call reroutes by a `pre-model-call` hook. Absent on
249
+ * synthesized emissions that have no provider response.
250
+ */
251
+ | { type: "message_complete"; message: Message; model?: string }
244
252
  | { type: "max_tokens_reached"; stopReason: string }
245
253
  | {
246
254
  type: "tool_use";
@@ -1902,6 +1910,7 @@ export class AgentLoop {
1902
1910
  await onEvent({
1903
1911
  type: "message_complete",
1904
1912
  message: safeAssistantMessage,
1913
+ model: response.model,
1905
1914
  });
1906
1915
  history = maxTokensMessages;
1907
1916
  continue;
@@ -1914,6 +1923,7 @@ export class AgentLoop {
1914
1923
  await onEvent({
1915
1924
  type: "message_complete",
1916
1925
  message: safeAssistantMessage,
1926
+ model: response.model,
1917
1927
  });
1918
1928
  await stopTurn("max_tokens_reached");
1919
1929
  break;
@@ -1998,7 +2008,11 @@ export class AgentLoop {
1998
2008
 
1999
2009
  history.push(assistantMessage);
2000
2010
 
2001
- await onEvent({ type: "message_complete", message: assistantMessage });
2011
+ await onEvent({
2012
+ type: "message_complete",
2013
+ message: assistantMessage,
2014
+ model: response.model,
2015
+ });
2002
2016
 
2003
2017
  if (toolUseBlocks.length === 0 || !this.toolExecutor) {
2004
2018
  // The model stopped requesting tools and `post-model-call` settled on
@@ -589,12 +589,20 @@ async function persistLoopMessageContent(
589
589
  contentJson: string,
590
590
  op: string,
591
591
  rlog: pino.Logger,
592
+ metadataUpdates?: Record<string, unknown>,
592
593
  ): Promise<boolean> {
593
594
  try {
594
- await withSqliteRetry(() => updateMessageContent(messageId, contentJson), {
595
- op,
596
- context: { messageId },
597
- });
595
+ // Metadata updates (e.g. the served model at finalize) ride the same
596
+ // write as the content — `updateMessageContent` commits both atomically —
597
+ // so a partial write can't leave them out of sync; the updates
598
+ // shallow-merge onto the channel provenance stamped at reserve.
599
+ await withSqliteRetry(
600
+ () => updateMessageContent(messageId, contentJson, metadataUpdates),
601
+ {
602
+ op,
603
+ context: { messageId },
604
+ },
605
+ );
598
606
  return true;
599
607
  } catch (err) {
600
608
  rlog.error(
@@ -2074,11 +2082,17 @@ export async function handleMessageComplete(
2074
2082
  );
2075
2083
  }
2076
2084
  const contentJson = JSON.stringify(contentForPersistence);
2085
+ // Stamp the served model carried on the event (`response.model`, the same
2086
+ // value `llm_usage` records) onto the row alongside the content, so turn-trace
2087
+ // assembly can attribute each assistant message to the model that actually
2088
+ // ran it — including per-call reroutes by a `pre-model-call` hook. Absent on
2089
+ // synthesized completions with no provider response; the key is omitted then.
2077
2090
  const persisted = await persistLoopMessageContent(
2078
2091
  assistantMessageId,
2079
2092
  contentJson,
2080
2093
  "finalize_assistant_message",
2081
2094
  deps.rlog,
2095
+ event.model ? { model: event.model } : undefined,
2082
2096
  );
2083
2097
  state.assistantRowAwaitingFinalization = false;
2084
2098
  // The assistant row now holds the authoritative content (text + thinking +
@@ -30,6 +30,7 @@ import {
30
30
  getToolOwner,
31
31
  getWorkspaceToolDefinitions,
32
32
  getWorkspaceToolNames,
33
+ loadPluginTools,
33
34
  } from "../tools/registry.js";
34
35
  import {
35
36
  ACTIVITY_SKIP_SET,
@@ -834,11 +835,15 @@ export function createResolveToolsCallback(
834
835
  // serialized, so the registry settles for a subsequent turn to read.
835
836
  void loadWorkspaceTools();
836
837
 
837
- // Re-read plugin tool definitions from the registry each turn so a plugin
838
- // installed/removed at runtime (activated by the per-turn scan in
839
- // `plugins/mtime-cache.ts`) is picked up without recreating the
840
- // conversation. Plugin tools share core's context filter + allowlist path,
841
- // so combine them with the core snapshot before filtering.
838
+ // Same treatment for user-plugin tools: pull the plugin mtime-cache's
839
+ // active tool set into the registry (a no-op costs one sentinel stat +
840
+ // fingerprint compares), so a plugin installed/removed/edited at runtime
841
+ // is picked up without recreating the conversation.
842
+ void loadPluginTools();
843
+
844
+ // Re-read plugin tool definitions from the registry each turn. Plugin
845
+ // tools share core's context filter + allowlist path, so combine them
846
+ // with the core snapshot before filtering.
842
847
  const currentPluginDefs = getPluginToolDefinitions();
843
848
 
844
849
  // Scope plugin tools to the conversation's per-chat plugin set. `null`
@@ -73,6 +73,7 @@ import {
73
73
  unregisterSkillRoute,
74
74
  } from "../runtime/skill-route-registry.js";
75
75
  import {
76
+ loadPluginTools,
76
77
  registerPluginTools,
77
78
  unregisterPluginTools,
78
79
  } from "../tools/registry.js";
@@ -152,6 +153,12 @@ function ensurePluginStorageDir(pluginName: string): string {
152
153
  export async function initializePlugins(): Promise<void> {
153
154
  registerDefaultPlugins();
154
155
  await loadUserPlugins();
156
+ // Pull the user-plugin tool set the cache scan above discovered into the
157
+ // tool registry. Kept in the same slot registration used to occupy (after
158
+ // the user-plugin scan, before the default plugins' `init()` registers
159
+ // their Plugin-object tools) so name-conflict precedence is unchanged.
160
+ // `loadPluginTools` never rejects — a failed pull logs and boots degraded.
161
+ await loadPluginTools();
155
162
  try {
156
163
  await bootstrapPlugins();
157
164
  } catch (err) {