@vellumai/assistant 0.10.0-dev.202606200318.c052d10 → 0.10.0-dev.202606201453.1417592
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__/agent-loop-callsite-precedence.test.ts +1 -40
- package/src/__tests__/agent-wake-override-profile.test.ts +2 -0
- package/src/__tests__/app-source-watcher.test.ts +30 -10
- package/src/__tests__/config-schema.test.ts +34 -0
- package/src/__tests__/conversation-agent-loop-disk-pressure.test.ts +3 -0
- package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +3 -0
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +3 -0
- package/src/__tests__/conversation-agent-loop.test.ts +3 -0
- package/src/__tests__/conversation-process-callsite.test.ts +0 -14
- package/src/__tests__/db-llm-request-log-provider-migration.test.ts +6 -1
- package/src/__tests__/heartbeat-disk-pressure.test.ts +3 -0
- package/src/__tests__/heartbeat-service.test.ts +6 -0
- package/src/__tests__/list-messages-attachments.test.ts +41 -0
- package/src/__tests__/plugin-source-watcher.test.ts +33 -1
- package/src/__tests__/usage-cache-backfill-migration.test.ts +17 -2
- package/src/acp/__tests__/session-manager.test.ts +72 -1
- package/src/acp/index.ts +10 -0
- package/src/acp/session-manager.ts +35 -0
- package/src/agent/loop.ts +28 -22
- package/src/config/schemas/memory-lifecycle.ts +5 -3
- package/src/config/schemas/timeouts.ts +24 -0
- package/src/daemon/app-source-watcher.ts +31 -18
- package/src/daemon/conversation-agent-loop.ts +8 -5
- package/src/daemon/conversation.ts +30 -41
- package/src/daemon/handlers/conversations.ts +7 -0
- package/src/daemon/plugin-source-watcher.ts +5 -0
- package/src/daemon/workspace-tools-watcher.ts +4 -0
- package/src/heartbeat/__tests__/heartbeat-service.test.ts +6 -0
- package/src/heartbeat/heartbeat-service.ts +3 -4
- package/src/memory/__tests__/db-maintenance.test.ts +27 -35
- package/src/memory/conversation-crud.ts +9 -3
- package/src/memory/db-init.ts +33 -5
- package/src/memory/db-maintenance.ts +43 -38
- package/src/memory/job-handlers/cleanup.ts +6 -0
- package/src/memory/migrations/297-move-llm-request-logs-to-logs-db.ts +130 -0
- package/src/memory/migrations/__tests__/297-move-llm-request-logs.test.ts +159 -0
- package/src/memory/migrations/index.ts +1 -0
- package/src/plugin-api/index.ts +7 -0
- package/src/plugin-api/vision-support.ts +75 -0
- package/src/prompts/system-prompt.ts +1 -1
- package/src/runtime/__tests__/agent-wake.test.ts +6 -4
- package/src/runtime/agent-wake.ts +15 -7
- package/src/runtime/routes/conversation-routes.ts +24 -3
- package/src/runtime/routes/migration-routes.ts +35 -39
- package/src/schedule/scheduler.ts +5 -9
- package/src/tools/ask-question/ask-question-tool.test.ts +60 -52
- package/src/tools/ask-question/ask-question-tool.ts +14 -73
- package/src/util/fs-watcher-error.ts +36 -0
package/src/memory/db-init.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { tmpdir } from "node:os";
|
|
|
10
10
|
import { dirname, join } from "node:path";
|
|
11
11
|
|
|
12
12
|
import { getLogger } from "../util/logger.js";
|
|
13
|
+
import { getLogsDbPath } from "../util/logs-db-path.js";
|
|
13
14
|
import { ensureDataDir, getDbPath } from "../util/platform.js";
|
|
14
15
|
import { backfillAppConversationIds } from "./app-store.js";
|
|
15
16
|
import { getDb, getSqlite } from "./db-connection.js";
|
|
@@ -154,6 +155,7 @@ import {
|
|
|
154
155
|
migrateMessagesConversationCreatedAtIndex,
|
|
155
156
|
migrateMessagesFtsBackfill,
|
|
156
157
|
migrateMessagesRoleCreatedAtIndex,
|
|
158
|
+
migrateMoveLlmRequestLogsToLogsDb,
|
|
157
159
|
migrateNormalizePhoneIdentities,
|
|
158
160
|
migrateNormalizeSlackExternalContent,
|
|
159
161
|
migrateNormalizeUserFileByPrincipal,
|
|
@@ -265,12 +267,31 @@ function getTemplateDbPath(): string {
|
|
|
265
267
|
);
|
|
266
268
|
}
|
|
267
269
|
|
|
270
|
+
/**
|
|
271
|
+
* Template path for the attached `logs` database, kept alongside the main
|
|
272
|
+
* template. Both files must be captured/restored together: the migrated state
|
|
273
|
+
* now spans two files (llm_request_logs and its indexes live in `logs`), so
|
|
274
|
+
* restoring only the main DB would leave a fresh, empty logs DB with no
|
|
275
|
+
* `llm_request_logs` table.
|
|
276
|
+
*/
|
|
277
|
+
function getLogsTemplateDbPath(): string {
|
|
278
|
+
return `${getTemplateDbPath()}.logs`;
|
|
279
|
+
}
|
|
280
|
+
|
|
268
281
|
function tryRestoreTemplate(): boolean {
|
|
269
282
|
const templatePath = getTemplateDbPath();
|
|
270
283
|
if (!existsSync(templatePath)) return false;
|
|
271
284
|
// getDb() hasn't run yet, so the data directory may not exist.
|
|
272
285
|
ensureDataDir();
|
|
273
286
|
copyFileSync(templatePath, getDbPath());
|
|
287
|
+
// Restore the attached logs DB before getDb() opens (and ATTACHes) it, so the
|
|
288
|
+
// relocated llm_request_logs table is present. Older templates may predate
|
|
289
|
+
// the split; the hash includes the migration files, so a stale template
|
|
290
|
+
// without this sibling won't be reused — but guard anyway.
|
|
291
|
+
const logsTemplate = getLogsTemplateDbPath();
|
|
292
|
+
if (existsSync(logsTemplate)) {
|
|
293
|
+
copyFileSync(logsTemplate, getLogsDbPath());
|
|
294
|
+
}
|
|
274
295
|
// Open the pre-migrated copy — getDb() will set PRAGMAs but skip migrations.
|
|
275
296
|
getDb();
|
|
276
297
|
return true;
|
|
@@ -278,12 +299,18 @@ function tryRestoreTemplate(): boolean {
|
|
|
278
299
|
|
|
279
300
|
function saveTemplate(): void {
|
|
280
301
|
try {
|
|
281
|
-
// Flush WAL to main
|
|
302
|
+
// Flush each DB's WAL to its main file before copying.
|
|
282
303
|
getSqlite().exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
304
|
+
getSqlite().exec("PRAGMA logs.wal_checkpoint(TRUNCATE)");
|
|
305
|
+
|
|
306
|
+
const mainTmp = `${getTemplateDbPath()}.${process.pid}`;
|
|
307
|
+
copyFileSync(getDbPath(), mainTmp);
|
|
308
|
+
const logsTmp = `${getLogsTemplateDbPath()}.${process.pid}`;
|
|
309
|
+
copyFileSync(getLogsDbPath(), logsTmp);
|
|
310
|
+
|
|
311
|
+
// Atomic renames — safe even with parallel test workers.
|
|
312
|
+
renameSync(mainTmp, getTemplateDbPath());
|
|
313
|
+
renameSync(logsTmp, getLogsTemplateDbPath());
|
|
287
314
|
} catch {
|
|
288
315
|
// Best effort — next file will just run migrations normally.
|
|
289
316
|
}
|
|
@@ -531,6 +558,7 @@ export function initializeDb(): void {
|
|
|
531
558
|
migrateDropExternalUserId,
|
|
532
559
|
dropApprovalPromptTsTrackerTable,
|
|
533
560
|
migrateRewriteBalancedEconomyProfilePins,
|
|
561
|
+
migrateMoveLlmRequestLogsToLogsDb,
|
|
534
562
|
];
|
|
535
563
|
|
|
536
564
|
// Run each migration step, catching and logging individual failures so one
|
|
@@ -58,11 +58,10 @@ async function runDbMaintenance(): Promise<void> {
|
|
|
58
58
|
);
|
|
59
59
|
|
|
60
60
|
// Prune finished workflow runs (and their journals) past the retention
|
|
61
|
-
// window
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
// whole routine to an idle window.
|
|
61
|
+
// window. This is a fast bounded DELETE on the small workflow tables, so it
|
|
62
|
+
// runs on the main connection (`rawRun`). SQLite reuses the pages it frees
|
|
63
|
+
// for later writes — we deliberately do not VACUUM to hand them back to the
|
|
64
|
+
// OS (see the WAL note below).
|
|
66
65
|
try {
|
|
67
66
|
const deletedRuns = pruneRuns(getConfig().workflows.journalRetentionDays);
|
|
68
67
|
if (deletedRuns > 0) {
|
|
@@ -72,18 +71,9 @@ async function runDbMaintenance(): Promise<void> {
|
|
|
72
71
|
log.warn({ err }, "Workflow run pruning failed (non-fatal)");
|
|
73
72
|
}
|
|
74
73
|
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
// backend is available.
|
|
79
|
-
const vacuumResult = await runAsyncSqlite("VACUUM");
|
|
80
|
-
if (!vacuumResult.ok) {
|
|
81
|
-
log.warn(
|
|
82
|
-
{ error: vacuumResult.error, backend: vacuumResult.backend },
|
|
83
|
-
"VACUUM failed (non-fatal)",
|
|
84
|
-
);
|
|
85
|
-
}
|
|
86
|
-
|
|
74
|
+
// Refresh the query planner's statistics. PRAGMA optimize is cheap; it is
|
|
75
|
+
// routed through the async path for consistency and to keep it off the main
|
|
76
|
+
// thread when the sqlite3 CLI backend is available.
|
|
87
77
|
const optimizeResult = await runAsyncSqlite("PRAGMA optimize");
|
|
88
78
|
if (!optimizeResult.ok) {
|
|
89
79
|
log.warn(
|
|
@@ -92,26 +82,42 @@ async function runDbMaintenance(): Promise<void> {
|
|
|
92
82
|
);
|
|
93
83
|
}
|
|
94
84
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
85
|
+
// Truncate the WAL so it doesn't sit at its high-water mark. We intentionally
|
|
86
|
+
// do NOT run a full VACUUM: in WAL mode VACUUM rewrites the whole database
|
|
87
|
+
// through the WAL, inflating it to ~the database size and needing up to 2x the
|
|
88
|
+
// DB size in free disk to finish. SQLite already reuses freed pages for new
|
|
89
|
+
// writes, so eager space return isn't worth that cost on a multi-GB database.
|
|
90
|
+
//
|
|
91
|
+
// The checkpoint goes through the async path (sqlite3 subprocess when one is
|
|
92
|
+
// available) for the same reason VACUUM/optimize do: a synchronous
|
|
93
|
+
// wal_checkpoint(TRUNCATE) on the shared connection blocks the event loop
|
|
94
|
+
// while it checkpoints frames and waits out readers — the health/IPC stall
|
|
95
|
+
// runAsyncSqlite exists to avoid. A checkpoint from a separate connection
|
|
96
|
+
// still truncates the shared WAL; if a reader holds it back it's a
|
|
97
|
+
// best-effort no-op and the next maintenance pass retries.
|
|
98
|
+
const checkpointResult = await runAsyncSqlite(
|
|
99
|
+
"PRAGMA wal_checkpoint(TRUNCATE)",
|
|
100
|
+
);
|
|
101
|
+
if (!checkpointResult.ok) {
|
|
102
|
+
log.warn(
|
|
103
|
+
{ error: checkpointResult.error, backend: checkpointResult.backend },
|
|
104
|
+
"WAL checkpoint failed (non-fatal)",
|
|
105
|
+
);
|
|
106
|
+
}
|
|
101
107
|
|
|
108
|
+
const after = getDbStats();
|
|
102
109
|
log.info(
|
|
103
110
|
{
|
|
104
|
-
backend: vacuumResult.backend,
|
|
105
|
-
vacuumOk: vacuumResult.ok,
|
|
106
111
|
optimizeOk: optimizeResult.ok,
|
|
107
|
-
|
|
112
|
+
optimizeBackend: optimizeResult.backend,
|
|
108
113
|
optimizeElapsedMs: optimizeResult.elapsedMs,
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
114
|
+
checkpointOk: checkpointResult.ok,
|
|
115
|
+
checkpointBackend: checkpointResult.backend,
|
|
116
|
+
checkpointResult: checkpointResult.stdout?.trim(),
|
|
117
|
+
checkpointElapsedMs: checkpointResult.elapsedMs,
|
|
118
|
+
pageCount: after.pageCount,
|
|
119
|
+
freelistCount: after.freelistCount,
|
|
120
|
+
fileSizeBytes: after.fileSizeBytes,
|
|
115
121
|
},
|
|
116
122
|
"Database maintenance complete",
|
|
117
123
|
);
|
|
@@ -126,12 +132,11 @@ export async function maybeRunDbMaintenance(nowMs = Date.now()): Promise<void> {
|
|
|
126
132
|
);
|
|
127
133
|
if (nowMs - lastRun < intervalMs) return;
|
|
128
134
|
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
// deferred run is simply retried on a later (still-idle) worker tick.
|
|
135
|
+
// Maintenance still takes brief write locks (PRAGMA optimize and the
|
|
136
|
+
// truncating WAL checkpoint), so defer it until the user has been quiet for
|
|
137
|
+
// `quietPeriodMs` and those locks never land mid-conversation. The checkpoint
|
|
138
|
+
// below is only written once maintenance actually runs, so a deferred run is
|
|
139
|
+
// simply retried on a later (still-idle) worker tick.
|
|
135
140
|
if (quietPeriodMs > 0) {
|
|
136
141
|
const lastUserMessageAt = getLastUserMessageTimestamp();
|
|
137
142
|
if (lastUserMessageAt > 0 && nowMs - lastUserMessageAt < quietPeriodMs) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AssistantConfig } from "../../config/types.js";
|
|
2
2
|
import { getLogger } from "../../util/logger.js";
|
|
3
|
+
import { getLogsDbPath } from "../../util/logs-db-path.js";
|
|
3
4
|
import { runAsyncSqlite } from "../db-async-query.js";
|
|
4
5
|
import { getDb } from "../db-connection.js";
|
|
5
6
|
import { enqueueMemoryJob, type MemoryJob } from "../jobs-store.js";
|
|
@@ -49,9 +50,14 @@ export async function pruneOldLlmRequestLogsJob(
|
|
|
49
50
|
// fallback backend in `db-async-query.ts` synthesizes the same shape
|
|
50
51
|
// by capturing `changes()` atomically after `exec()`. Both backends
|
|
51
52
|
// end up on the parser path below.
|
|
53
|
+
// llm_request_logs lives in the attached logs database. Point the sqlite3
|
|
54
|
+
// subprocess at that file directly (it can't see the daemon connection's
|
|
55
|
+
// ATTACH); the in-process fallback runs on the daemon connection, where the
|
|
56
|
+
// unqualified name already resolves to the attached table.
|
|
52
57
|
const result = await runAsyncSqlite(
|
|
53
58
|
`DELETE FROM llm_request_logs WHERE rowid IN (SELECT rowid FROM llm_request_logs WHERE created_at < ${cutoffMs} LIMIT ${PRUNE_LOG_BATCH_LIMIT});
|
|
54
59
|
SELECT changes();`,
|
|
60
|
+
{ dbPath: getLogsDbPath() },
|
|
55
61
|
);
|
|
56
62
|
if (!result.ok) {
|
|
57
63
|
log.warn(
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type DrizzleDb,
|
|
3
|
+
getSqliteFrom,
|
|
4
|
+
LOGS_DB_SCHEMA,
|
|
5
|
+
} from "../db-connection.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Column names of `llm_request_logs`, in a fixed order used for the
|
|
9
|
+
* cross-database copy. Listed explicitly (rather than `SELECT *`) so the copy
|
|
10
|
+
* is insensitive to the physical column order of the `main` table, which varies
|
|
11
|
+
* with the historical sequence of `ALTER TABLE ... ADD COLUMN` migrations.
|
|
12
|
+
*
|
|
13
|
+
* The first columns are the original base columns; the rest were added by later
|
|
14
|
+
* column migrations.
|
|
15
|
+
*/
|
|
16
|
+
const COLUMN_NAMES = [
|
|
17
|
+
"id",
|
|
18
|
+
"conversation_id",
|
|
19
|
+
"message_id",
|
|
20
|
+
"provider",
|
|
21
|
+
"request_payload",
|
|
22
|
+
"response_payload",
|
|
23
|
+
"created_at",
|
|
24
|
+
"agent_loop_exit_reason",
|
|
25
|
+
"call_site",
|
|
26
|
+
];
|
|
27
|
+
const COLUMNS = COLUMN_NAMES.join(", ");
|
|
28
|
+
|
|
29
|
+
const CREATE_TABLE = (schema: string): string => /*sql*/ `
|
|
30
|
+
CREATE TABLE IF NOT EXISTS ${schema}.llm_request_logs (
|
|
31
|
+
id TEXT PRIMARY KEY,
|
|
32
|
+
conversation_id TEXT NOT NULL,
|
|
33
|
+
message_id TEXT,
|
|
34
|
+
provider TEXT,
|
|
35
|
+
request_payload TEXT NOT NULL,
|
|
36
|
+
response_payload TEXT NOT NULL,
|
|
37
|
+
created_at INTEGER NOT NULL,
|
|
38
|
+
agent_loop_exit_reason TEXT,
|
|
39
|
+
call_site TEXT
|
|
40
|
+
)
|
|
41
|
+
`;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Create the three indexes in `schema`. The index names are schema-qualified;
|
|
45
|
+
* the table name in `CREATE INDEX` must be unqualified and is resolved within
|
|
46
|
+
* the index's schema — so the table must already resolve to `schema` (i.e. no
|
|
47
|
+
* same-named table shadowing it in `main`) when this runs.
|
|
48
|
+
*/
|
|
49
|
+
function createIndexes(raw: ReturnType<typeof getSqliteFrom>, schema: string) {
|
|
50
|
+
raw.exec(
|
|
51
|
+
`CREATE INDEX IF NOT EXISTS ${schema}.idx_llm_request_logs_message_id ON llm_request_logs(message_id)`,
|
|
52
|
+
);
|
|
53
|
+
raw.exec(
|
|
54
|
+
`CREATE INDEX IF NOT EXISTS ${schema}.idx_llm_request_logs_created_at ON llm_request_logs(created_at)`,
|
|
55
|
+
);
|
|
56
|
+
raw.exec(
|
|
57
|
+
`CREATE INDEX IF NOT EXISTS ${schema}.idx_llm_request_logs_conv_created ON llm_request_logs(conversation_id, created_at)`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function tableExists(
|
|
62
|
+
raw: ReturnType<typeof getSqliteFrom>,
|
|
63
|
+
schema: string,
|
|
64
|
+
): boolean {
|
|
65
|
+
return (
|
|
66
|
+
raw
|
|
67
|
+
.query(
|
|
68
|
+
`SELECT name FROM ${schema}.sqlite_master WHERE type='table' AND name='llm_request_logs'`,
|
|
69
|
+
)
|
|
70
|
+
.get() != null
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Copy every row of `llm_request_logs` from `main` into the `logs` database,
|
|
76
|
+
* substituting `NULL` for any target column missing in the source. The newer
|
|
77
|
+
* columns (message_id/provider/agent_loop_exit_reason/call_site) are all
|
|
78
|
+
* nullable, so NULL is correct for a legacy row that predates them.
|
|
79
|
+
*/
|
|
80
|
+
function copyRowsFromMain(raw: ReturnType<typeof getSqliteFrom>): void {
|
|
81
|
+
const presentColumns = new Set(
|
|
82
|
+
(
|
|
83
|
+
raw
|
|
84
|
+
.query(`SELECT name FROM pragma_table_info('llm_request_logs', 'main')`)
|
|
85
|
+
.all() as Array<{ name: string }>
|
|
86
|
+
).map((r) => r.name),
|
|
87
|
+
);
|
|
88
|
+
const selectList = COLUMN_NAMES.map((c) =>
|
|
89
|
+
presentColumns.has(c) ? c : "NULL",
|
|
90
|
+
).join(", ");
|
|
91
|
+
raw.exec(/*sql*/ `
|
|
92
|
+
INSERT OR IGNORE INTO ${LOGS_DB_SCHEMA}.llm_request_logs (${COLUMNS})
|
|
93
|
+
SELECT ${selectList} FROM main.llm_request_logs
|
|
94
|
+
`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Keep `llm_request_logs` housed in the attached append-only `logs` database
|
|
99
|
+
* (`assistant-logs.db`) rather than the main DB. Keeping this heavy table — and
|
|
100
|
+
* the request/response payloads it stores — in its own file stops it from
|
|
101
|
+
* bloating the main DB and its WAL, and lets the two files VACUUM and
|
|
102
|
+
* checkpoint independently. Once it lives only in `logs`, the unqualified name
|
|
103
|
+
* used by the Drizzle store resolves to the attached copy, so query code is
|
|
104
|
+
* unchanged.
|
|
105
|
+
*
|
|
106
|
+
* This step is idempotent and runs on every startup (it is not checkpoint-
|
|
107
|
+
* gated). It must, because the earlier `createWatchersAndLogsTables` migration
|
|
108
|
+
* recreates an empty `main.llm_request_logs` on every boot via
|
|
109
|
+
* `CREATE TABLE IF NOT EXISTS`; this step re-relocates and drops that shadow so
|
|
110
|
+
* the unqualified name keeps resolving to `logs`.
|
|
111
|
+
*
|
|
112
|
+
* Ordering within the step matters:
|
|
113
|
+
* 1. Create the table in `logs` (safe whether or not `main` has it).
|
|
114
|
+
* 2. If `main` still has the table, copy its rows over (`INSERT OR IGNORE` on
|
|
115
|
+
* the id PK, so a re-run is a no-op) and drop it.
|
|
116
|
+
* 3. Create the indexes — only now is `main` guaranteed not to shadow the
|
|
117
|
+
* table, so the unqualified reference resolves to `logs`.
|
|
118
|
+
*/
|
|
119
|
+
export function migrateMoveLlmRequestLogsToLogsDb(database: DrizzleDb): void {
|
|
120
|
+
const raw = getSqliteFrom(database);
|
|
121
|
+
|
|
122
|
+
raw.exec(CREATE_TABLE(LOGS_DB_SCHEMA));
|
|
123
|
+
|
|
124
|
+
if (tableExists(raw, "main")) {
|
|
125
|
+
copyRowsFromMain(raw);
|
|
126
|
+
raw.exec(`DROP TABLE main.llm_request_logs`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
createIndexes(raw, LOGS_DB_SCHEMA);
|
|
130
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for migration 297 — keeping `llm_request_logs` in the attached
|
|
3
|
+
* `logs` database.
|
|
4
|
+
*
|
|
5
|
+
* Covers:
|
|
6
|
+
* 1. End state after a full initializeDb(): the table lives in `logs`, not
|
|
7
|
+
* `main`, and the Drizzle store round-trips against it.
|
|
8
|
+
* 2. The relocation itself: rows in a `main.llm_request_logs` are copied into
|
|
9
|
+
* `logs` and the main-DB copy is dropped, with indexes built in `logs`.
|
|
10
|
+
* 3. A legacy base-only `main` table (predating the newer columns) is copied
|
|
11
|
+
* without error, NULL-filling the absent columns.
|
|
12
|
+
*
|
|
13
|
+
* The step is idempotent and not checkpoint-gated, so each test can drive it
|
|
14
|
+
* directly without clearing any checkpoint.
|
|
15
|
+
*/
|
|
16
|
+
import { describe, expect, test } from "bun:test";
|
|
17
|
+
|
|
18
|
+
const { getDb, getSqlite, LOGS_DB_SCHEMA } =
|
|
19
|
+
await import("../../db-connection.js");
|
|
20
|
+
const { initializeDb } = await import("../../db-init.js");
|
|
21
|
+
const { migrateMoveLlmRequestLogsToLogsDb } =
|
|
22
|
+
await import("../297-move-llm-request-logs-to-logs-db.js");
|
|
23
|
+
const { recordRequestLog, getRequestLogById } =
|
|
24
|
+
await import("../../llm-request-log-store.js");
|
|
25
|
+
|
|
26
|
+
initializeDb();
|
|
27
|
+
|
|
28
|
+
function tableSchemas(name: string): string[] {
|
|
29
|
+
return getSqlite()
|
|
30
|
+
.query<{ schema: string }, []>(
|
|
31
|
+
`SELECT 'main' AS schema FROM main.sqlite_master WHERE type='table' AND name='${name}'
|
|
32
|
+
UNION ALL
|
|
33
|
+
SELECT '${LOGS_DB_SCHEMA}' FROM ${LOGS_DB_SCHEMA}.sqlite_master WHERE type='table' AND name='${name}'`,
|
|
34
|
+
)
|
|
35
|
+
.all()
|
|
36
|
+
.map((r) => r.schema);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("migration 297 — llm_request_logs lives in the logs database", () => {
|
|
40
|
+
test("after init, the table is in logs and not in main", () => {
|
|
41
|
+
expect(tableSchemas("llm_request_logs")).toEqual([LOGS_DB_SCHEMA]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("the store round-trips against the relocated table", () => {
|
|
45
|
+
const id = recordRequestLog(
|
|
46
|
+
"conv-297",
|
|
47
|
+
JSON.stringify({ req: 1 }),
|
|
48
|
+
JSON.stringify({ res: 1 }),
|
|
49
|
+
"msg-297",
|
|
50
|
+
"anthropic",
|
|
51
|
+
"mainAgent",
|
|
52
|
+
);
|
|
53
|
+
const row = getRequestLogById(id);
|
|
54
|
+
expect(row?.conversationId).toBe("conv-297");
|
|
55
|
+
expect(row?.messageId).toBe("msg-297");
|
|
56
|
+
expect(row?.provider).toBe("anthropic");
|
|
57
|
+
expect(row?.callSite).toBe("mainAgent");
|
|
58
|
+
|
|
59
|
+
// The written row physically lives in the logs database.
|
|
60
|
+
const inLogs = getSqlite()
|
|
61
|
+
.query<
|
|
62
|
+
{ c: number },
|
|
63
|
+
[string]
|
|
64
|
+
>(`SELECT COUNT(*) AS c FROM ${LOGS_DB_SCHEMA}.llm_request_logs WHERE id = ?`)
|
|
65
|
+
.get(id);
|
|
66
|
+
expect(inLogs?.c).toBe(1);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("relocates rows from a main-DB table and drops it", () => {
|
|
70
|
+
const sqlite = getSqlite();
|
|
71
|
+
|
|
72
|
+
// Simulate the shadow that createWatchersAndLogsTables recreates each boot:
|
|
73
|
+
// a main-DB table with a row, alongside the logs copy.
|
|
74
|
+
sqlite.exec(`DROP TABLE IF EXISTS ${LOGS_DB_SCHEMA}.llm_request_logs`);
|
|
75
|
+
sqlite.exec(`
|
|
76
|
+
CREATE TABLE main.llm_request_logs (
|
|
77
|
+
id TEXT PRIMARY KEY,
|
|
78
|
+
conversation_id TEXT NOT NULL,
|
|
79
|
+
message_id TEXT,
|
|
80
|
+
provider TEXT,
|
|
81
|
+
request_payload TEXT NOT NULL,
|
|
82
|
+
response_payload TEXT NOT NULL,
|
|
83
|
+
created_at INTEGER NOT NULL,
|
|
84
|
+
agent_loop_exit_reason TEXT,
|
|
85
|
+
call_site TEXT
|
|
86
|
+
)
|
|
87
|
+
`);
|
|
88
|
+
sqlite.exec(
|
|
89
|
+
`INSERT INTO main.llm_request_logs (id, conversation_id, request_payload, response_payload, created_at, provider) VALUES ('legacy-1', 'conv-legacy', '{}', '{}', 123, 'openai')`,
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
migrateMoveLlmRequestLogsToLogsDb(getDb());
|
|
93
|
+
|
|
94
|
+
// Main copy gone, logs copy has the row.
|
|
95
|
+
expect(tableSchemas("llm_request_logs")).toEqual([LOGS_DB_SCHEMA]);
|
|
96
|
+
const moved = sqlite
|
|
97
|
+
.query<
|
|
98
|
+
{ conversation_id: string; provider: string },
|
|
99
|
+
[]
|
|
100
|
+
>(`SELECT conversation_id, provider FROM ${LOGS_DB_SCHEMA}.llm_request_logs WHERE id = 'legacy-1'`)
|
|
101
|
+
.get();
|
|
102
|
+
expect(moved?.conversation_id).toBe("conv-legacy");
|
|
103
|
+
expect(moved?.provider).toBe("openai");
|
|
104
|
+
|
|
105
|
+
// Indexes were created in the logs database.
|
|
106
|
+
const indexes = sqlite
|
|
107
|
+
.query<{ name: string }, []>(
|
|
108
|
+
`SELECT name FROM ${LOGS_DB_SCHEMA}.sqlite_master WHERE type='index' AND tbl_name='llm_request_logs'`,
|
|
109
|
+
)
|
|
110
|
+
.all()
|
|
111
|
+
.map((r) => r.name);
|
|
112
|
+
expect(indexes).toContain("idx_llm_request_logs_message_id");
|
|
113
|
+
expect(indexes).toContain("idx_llm_request_logs_created_at");
|
|
114
|
+
expect(indexes).toContain("idx_llm_request_logs_conv_created");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("copies a legacy base-only table, NULL-filling newer columns", () => {
|
|
118
|
+
const sqlite = getSqlite();
|
|
119
|
+
|
|
120
|
+
// A workspace upgrading from a build that predates the
|
|
121
|
+
// message_id/provider/agent_loop_exit_reason/call_site columns: the main
|
|
122
|
+
// table has only the original base columns.
|
|
123
|
+
sqlite.exec(`DROP TABLE IF EXISTS ${LOGS_DB_SCHEMA}.llm_request_logs`);
|
|
124
|
+
sqlite.exec(`
|
|
125
|
+
CREATE TABLE main.llm_request_logs (
|
|
126
|
+
id TEXT PRIMARY KEY,
|
|
127
|
+
conversation_id TEXT NOT NULL,
|
|
128
|
+
request_payload TEXT NOT NULL,
|
|
129
|
+
response_payload TEXT NOT NULL,
|
|
130
|
+
created_at INTEGER NOT NULL
|
|
131
|
+
)
|
|
132
|
+
`);
|
|
133
|
+
sqlite.exec(
|
|
134
|
+
`INSERT INTO main.llm_request_logs (id, conversation_id, request_payload, response_payload, created_at) VALUES ('base-1', 'conv-base', '{}', '{}', 7)`,
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
// Must not throw on the absent columns.
|
|
138
|
+
migrateMoveLlmRequestLogsToLogsDb(getDb());
|
|
139
|
+
|
|
140
|
+
expect(tableSchemas("llm_request_logs")).toEqual([LOGS_DB_SCHEMA]);
|
|
141
|
+
const moved = sqlite
|
|
142
|
+
.query<
|
|
143
|
+
{
|
|
144
|
+
conversation_id: string;
|
|
145
|
+
message_id: string | null;
|
|
146
|
+
provider: string | null;
|
|
147
|
+
call_site: string | null;
|
|
148
|
+
},
|
|
149
|
+
[]
|
|
150
|
+
>(
|
|
151
|
+
`SELECT conversation_id, message_id, provider, call_site FROM ${LOGS_DB_SCHEMA}.llm_request_logs WHERE id = 'base-1'`,
|
|
152
|
+
)
|
|
153
|
+
.get();
|
|
154
|
+
expect(moved?.conversation_id).toBe("conv-base");
|
|
155
|
+
expect(moved?.message_id).toBeNull();
|
|
156
|
+
expect(moved?.provider).toBeNull();
|
|
157
|
+
expect(moved?.call_site).toBeNull();
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -288,6 +288,7 @@ export { migrateWorkflowJournalLeafTokens } from "./293-workflow-journal-leaf-to
|
|
|
288
288
|
export { migrateDropExternalUserId } from "./294-drop-external-user-id.js";
|
|
289
289
|
export { dropApprovalPromptTsTrackerTable } from "./295-drop-approval-prompt-ts-tracker.js";
|
|
290
290
|
export { migrateRewriteBalancedEconomyProfilePins } from "./296-rewrite-balanced-economy-profile-pins.js";
|
|
291
|
+
export { migrateMoveLlmRequestLogsToLogsDb } from "./297-move-llm-request-logs-to-logs-db.js";
|
|
291
292
|
export {
|
|
292
293
|
MIGRATION_REGISTRY,
|
|
293
294
|
type MigrationRegistryEntry,
|
package/src/plugin-api/index.ts
CHANGED
|
@@ -132,6 +132,13 @@ export type {
|
|
|
132
132
|
export { assistantEventHub } from "../runtime/assistant-event-hub.js";
|
|
133
133
|
export { getSecureKeyAsync } from "../security/secure-keys.js";
|
|
134
134
|
export { getModelProfiles } from "./model-profiles.js";
|
|
135
|
+
// Check whether a profile's resolved model can process image input. Resolves
|
|
136
|
+
// the effective (provider, model) by merging over the workspace default and
|
|
137
|
+
// inferring the provider for model-only profiles, then looks up the model
|
|
138
|
+
// catalog's `supportsVision` flag. Handles mix profiles (true if any arm
|
|
139
|
+
// supports vision). Fail-open for unknown models. Pair with
|
|
140
|
+
// `getModelProfiles()` to inspect the active or candidate profiles.
|
|
141
|
+
export { doesSupportVision } from "./vision-support.js";
|
|
135
142
|
// Resolve a provider for a call site (optionally overriding the profile) so a
|
|
136
143
|
// plugin can run inference through the workspace's configured profiles and
|
|
137
144
|
// credentials — managed-proxy or BYOK — without supplying its own API key.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vision-support resolution for plugin consumption.
|
|
3
|
+
*
|
|
4
|
+
* A plugin that gates image processing on vision capability (e.g. an
|
|
5
|
+
* image-to-text fallback for text-only models) calls {@link doesSupportVision}
|
|
6
|
+
* instead of hardcoding model names. The function resolves the effective
|
|
7
|
+
* (provider, model) for a profile — merging with `llm.default` to fill gaps,
|
|
8
|
+
* inferring the provider for model-only profiles via the catalog — and then
|
|
9
|
+
* looks up `supportsVision` in the model catalog.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { getConfig } from "../config/loader.js";
|
|
13
|
+
import {
|
|
14
|
+
getCatalogProviderForModel,
|
|
15
|
+
PROVIDER_CATALOG,
|
|
16
|
+
} from "../providers/model-catalog.js";
|
|
17
|
+
import type { ModelProfileInfo } from "./types.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Whether a profile's resolved model can process image input.
|
|
21
|
+
*
|
|
22
|
+
* Resolution mirrors the host's call-site resolver:
|
|
23
|
+
* - The profile's `(provider, model)` fields are merged over `llm.default` so
|
|
24
|
+
* a profile that only sets `model` (or only `provider`) inherits the other
|
|
25
|
+
* from the workspace default.
|
|
26
|
+
* - When `provider` is still missing but `model` is a known catalog model,
|
|
27
|
+
* the provider is inferred via `getCatalogProviderForModel` (same logic as
|
|
28
|
+
* the resolver's `withImpliedProviderForKnownModel`).
|
|
29
|
+
* - For a mix profile, returns `true` if any constituent arm supports vision
|
|
30
|
+
* (the mix can route to it) and `false` only if every arm is text-only.
|
|
31
|
+
* - Unknown `(provider, model)` pairs default to `true` (fail-open), matching
|
|
32
|
+
* the config GET route's `enrichProfilesWithVisionFlag`.
|
|
33
|
+
*/
|
|
34
|
+
export function doesSupportVision(profile: ModelProfileInfo): boolean {
|
|
35
|
+
const { llm } = getConfig();
|
|
36
|
+
const entry = llm.profiles[profile.key];
|
|
37
|
+
if (entry == null) return true;
|
|
38
|
+
|
|
39
|
+
// Mix: fail-open if any arm supports vision.
|
|
40
|
+
if (entry.mix != null) {
|
|
41
|
+
return entry.mix.some((arm) => {
|
|
42
|
+
const armEntry = llm.profiles[arm.profile];
|
|
43
|
+
if (armEntry == null) return true;
|
|
44
|
+
return resolveEntrySupportsVision(armEntry, llm);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return resolveEntrySupportsVision(entry, llm);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Resolve whether a concrete (non-mix) profile entry supports vision by
|
|
53
|
+
* merging its fields over `llm.default` and inferring the provider when
|
|
54
|
+
* only the model is set.
|
|
55
|
+
*/
|
|
56
|
+
function resolveEntrySupportsVision(
|
|
57
|
+
entry: { provider?: string; model?: string },
|
|
58
|
+
llm: { default?: { provider?: string; model?: string } },
|
|
59
|
+
): boolean {
|
|
60
|
+
const provider = entry.provider ?? llm.default?.provider;
|
|
61
|
+
const model = entry.model ?? llm.default?.model;
|
|
62
|
+
|
|
63
|
+
// Infer provider from model when missing (mirrors the resolver's
|
|
64
|
+
// withImpliedProviderForKnownModel).
|
|
65
|
+
const effectiveProvider =
|
|
66
|
+
provider ?? (typeof model === "string" ? getCatalogProviderForModel(model) : undefined);
|
|
67
|
+
|
|
68
|
+
if (typeof effectiveProvider !== "string" || typeof model !== "string") {
|
|
69
|
+
return true; // fail-open
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const catalogProvider = PROVIDER_CATALOG.find((p) => p.id === effectiveProvider);
|
|
73
|
+
const catalogModel = catalogProvider?.models.find((m) => m.id === model);
|
|
74
|
+
return catalogModel?.supportsVision ?? true;
|
|
75
|
+
}
|
|
@@ -274,7 +274,7 @@ export function maybeReseedBootstrap(templateFileName: string): boolean {
|
|
|
274
274
|
* Marking happens here — at the single point where the bootstrap selection is
|
|
275
275
|
* known — so it lands BEFORE the agent loop resolves tools and BEFORE the model
|
|
276
276
|
* can call the emit tool on the first activation-rail turn. (`resolveTools`
|
|
277
|
-
* runs before
|
|
277
|
+
* runs before the system-prompt build in the loop, so the system-prompt build is
|
|
278
278
|
* too late to be the *only* marking site.) The marker write is best-effort and
|
|
279
279
|
* idempotent (`markActivationSession` swallows errors and dedups on the PK), so
|
|
280
280
|
* calling this from both `setOnboardingContext` and `buildSystemPrompt` is safe.
|
|
@@ -52,8 +52,8 @@ interface WakeConversationProbe {
|
|
|
52
52
|
allowedTools?: string[];
|
|
53
53
|
/**
|
|
54
54
|
* `conversation.wakePersonaOverride` as observed at run start — the
|
|
55
|
-
* field
|
|
56
|
-
*
|
|
55
|
+
* field `buildCurrentSystemPrompt` reads when building the wake's
|
|
56
|
+
* system prompt before `agentLoop.run()`.
|
|
57
57
|
*/
|
|
58
58
|
personaOverride?: unknown;
|
|
59
59
|
order: number;
|
|
@@ -503,6 +503,8 @@ function makeWakeConversation(options: {
|
|
|
503
503
|
getTurnChannelContext: () => null,
|
|
504
504
|
getTurnInterfaceContext: () => null,
|
|
505
505
|
trustContext: undefined,
|
|
506
|
+
buildCurrentSystemPrompt: () => "mock-system-prompt",
|
|
507
|
+
modelOverride: undefined,
|
|
506
508
|
...(drainQueue ? { drainQueue } : {}),
|
|
507
509
|
};
|
|
508
510
|
|
|
@@ -693,8 +695,8 @@ describe("wakeAgentForOpportunity", () => {
|
|
|
693
695
|
);
|
|
694
696
|
|
|
695
697
|
expect(result.invoked).toBe(true);
|
|
696
|
-
// The override was live on the conversation when the loop ran —
|
|
697
|
-
//
|
|
698
|
+
// The override was live on the conversation when the loop ran —
|
|
699
|
+
// `buildCurrentSystemPrompt` reads this field before `agentLoop.run()`.
|
|
698
700
|
expect(conversation.runCalls[0]!.personaOverride).toEqual(override);
|
|
699
701
|
// Applied exactly once and cleared before the wake released the
|
|
700
702
|
// conversation, so a queued user turn can't build under it.
|