@vellumai/assistant 0.10.0-dev.202606200539.c052d10 → 0.10.0-dev.202606201539.0cebd50
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__/db-migration-rollback.test.ts +69 -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/cli/commands/db/status.ts +35 -1
- 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/__tests__/db-memory-attach.test.ts +126 -0
- package/src/memory/conversation-crud.ts +9 -3
- package/src/memory/db-connection.ts +49 -0
- package/src/memory/db-init.ts +50 -14
- package/src/memory/db-maintenance.ts +43 -38
- package/src/memory/job-handlers/cleanup.ts +6 -0
- package/src/memory/migrations/{100-core-tables.ts → 000-core-tables.ts} +6 -10
- package/src/memory/migrations/103-complex-migrations.ts +13 -12
- package/src/memory/migrations/104-core-indexes.ts +1 -1
- package/src/memory/migrations/113-late-migrations.ts +16 -14
- 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/__tests__/run-migrations.test.ts +147 -5
- package/src/memory/migrations/index.ts +5 -4
- package/src/memory/migrations/run-migrations.ts +162 -12
- package/src/memory/migrations/validate-migration-state.ts +14 -56
- 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/util/memory-db-path.ts +23 -0
|
@@ -1,41 +1,191 @@
|
|
|
1
1
|
import { getLogger } from "../../util/logger.js";
|
|
2
|
-
import
|
|
2
|
+
import { getDbPath } from "../../util/platform.js";
|
|
3
|
+
import { type DrizzleDb, getSqliteFrom } from "../db-connection.js";
|
|
3
4
|
|
|
4
5
|
const log = getLogger("db-init");
|
|
5
6
|
|
|
6
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
* A single forward migration step, identified for checkpointing and logging by
|
|
9
|
+
* its `.name`. Anonymous steps (empty `.name`) cannot be tracked and always run.
|
|
10
|
+
*/
|
|
7
11
|
export type MigrationStep = (database: DrizzleDb) => void;
|
|
8
12
|
|
|
9
13
|
export interface MigrationRunResult {
|
|
10
14
|
/** Steps whose body threw. */
|
|
11
15
|
failed: string[];
|
|
16
|
+
/** Steps skipped because a prior run already applied them. */
|
|
17
|
+
skipped: string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Prefix under which forward-step completions are recorded in the shared
|
|
22
|
+
* `memory_checkpoints` ledger. A distinct namespace keeps step bookkeeping from
|
|
23
|
+
* colliding with registry checkpoint keys (`migration_*`, `backfill_*`,
|
|
24
|
+
* `drop_*`) and is deliberately chosen so `validateMigrationState` does not
|
|
25
|
+
* mistake a step record for an unknown registry migration.
|
|
26
|
+
*/
|
|
27
|
+
const STEP_CHECKPOINT_PREFIX = "step:";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Create the migration bookkeeping table if it is missing.
|
|
31
|
+
*
|
|
32
|
+
* The runner records its own checkpoints, so it must not depend on an earlier
|
|
33
|
+
* step having created the ledger. This is the single place `memory_checkpoints`
|
|
34
|
+
* is created; `IF NOT EXISTS` makes it a no-op on an already-migrated database.
|
|
35
|
+
*/
|
|
36
|
+
function ensureCheckpointsTable(raw: ReturnType<typeof getSqliteFrom>): void {
|
|
37
|
+
raw.run(/*sql*/ `
|
|
38
|
+
CREATE TABLE IF NOT EXISTS memory_checkpoints (
|
|
39
|
+
key TEXT PRIMARY KEY,
|
|
40
|
+
value TEXT NOT NULL,
|
|
41
|
+
updated_at INTEGER NOT NULL
|
|
42
|
+
)
|
|
43
|
+
`);
|
|
12
44
|
}
|
|
13
45
|
|
|
14
46
|
/**
|
|
15
|
-
*
|
|
47
|
+
* Recover from crashed migrations before the migration runner executes its steps.
|
|
48
|
+
*
|
|
49
|
+
* Scans memory_checkpoints for entries with value 'started' or 'rolling_back' —
|
|
50
|
+
* these represent migrations that began but never completed (e.g., due to a
|
|
51
|
+
* process crash). Deletes the stalled checkpoint so the migration can re-run
|
|
52
|
+
* from scratch on this startup. Each migration's own idempotency guards (DDL
|
|
53
|
+
* IF NOT EXISTS, transactional rollback) ensure re-running is safe.
|
|
54
|
+
*
|
|
55
|
+
* Runs on every boot — it must observe the state left by *this* boot's prior
|
|
56
|
+
* crash — so it is invoked directly by {@link runMigrationSteps} before the
|
|
57
|
+
* checkpointed step loop rather than being a checkpointed step itself.
|
|
58
|
+
*/
|
|
59
|
+
export function recoverCrashedMigrations(database: DrizzleDb): string[] {
|
|
60
|
+
const raw = getSqliteFrom(database);
|
|
61
|
+
|
|
62
|
+
let rows: Array<{ key: string; value: string }>;
|
|
63
|
+
try {
|
|
64
|
+
rows = raw
|
|
65
|
+
.query(`SELECT key, value FROM memory_checkpoints`)
|
|
66
|
+
.all() as Array<{ key: string; value: string }>;
|
|
67
|
+
} catch {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const crashed = rows
|
|
72
|
+
.filter((r) => r.value === "started" || r.value === "rolling_back")
|
|
73
|
+
.map((r) => r.key);
|
|
74
|
+
if (crashed.length === 0) return [];
|
|
75
|
+
|
|
76
|
+
log.error(
|
|
77
|
+
{ crashed },
|
|
78
|
+
[
|
|
79
|
+
"╔══════════════════════════════════════════════════════════════╗",
|
|
80
|
+
"║ CRASHED MIGRATIONS DETECTED — AUTO-RECOVERING ║",
|
|
81
|
+
"╚══════════════════════════════════════════════════════════════╝",
|
|
82
|
+
"",
|
|
83
|
+
`The following migrations started but never completed: ${crashed.join(", ")}`,
|
|
84
|
+
"",
|
|
85
|
+
"Clearing stalled checkpoints so they can be retried on this startup.",
|
|
86
|
+
"If retries continue to fail, manually inspect the database:",
|
|
87
|
+
` sqlite3 ${getDbPath()} "SELECT * FROM memory_checkpoints"`,
|
|
88
|
+
].join("\n"),
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
for (const key of crashed) {
|
|
92
|
+
raw.query(`DELETE FROM memory_checkpoints WHERE key = ?`).run(key);
|
|
93
|
+
log.info(
|
|
94
|
+
{ key },
|
|
95
|
+
`Cleared stalled checkpoint "${key}" — migration will re-run`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return crashed;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Run the ordered list of forward migration steps against the database, each at
|
|
104
|
+
* most once across boots.
|
|
105
|
+
*
|
|
106
|
+
* A step's name is recorded in `memory_checkpoints` after its body completes; on
|
|
107
|
+
* later boots an applied step is skipped instead of re-executed. This turns the
|
|
108
|
+
* unconditional ~200-step re-probe — which floors daemon startup at tens of
|
|
109
|
+
* seconds on a fully-migrated database — into a single bookkeeping read.
|
|
110
|
+
*
|
|
111
|
+
* Step bookkeeping shares the same `memory_checkpoints` ledger that the
|
|
112
|
+
* registry's `withCrashRecovery` uses, under the {@link STEP_CHECKPOINT_PREFIX}
|
|
113
|
+
* namespace, so applied-state for every migration — DDL guard or registry-backed
|
|
114
|
+
* — lives in one place.
|
|
115
|
+
*
|
|
116
|
+
* Before the step loop runs, the ledger is created if missing and
|
|
117
|
+
* {@link recoverCrashedMigrations} clears any stalled checkpoints left by a
|
|
118
|
+
* prior crash, so a migration interrupted mid-flight re-runs this boot.
|
|
16
119
|
*
|
|
17
120
|
* Individual step failures are caught and logged so one broken migration does
|
|
18
|
-
* not prevent independent later ones from succeeding
|
|
121
|
+
* not prevent independent later ones from succeeding; a failed step is not
|
|
122
|
+
* checkpointed and is retried on the next boot.
|
|
19
123
|
*/
|
|
20
124
|
export function runMigrationSteps(
|
|
21
125
|
database: DrizzleDb,
|
|
22
126
|
steps: MigrationStep[],
|
|
23
127
|
): MigrationRunResult {
|
|
128
|
+
const raw = getSqliteFrom(database);
|
|
129
|
+
|
|
130
|
+
ensureCheckpointsTable(raw);
|
|
131
|
+
recoverCrashedMigrations(database);
|
|
132
|
+
|
|
133
|
+
const applied = new Set(
|
|
134
|
+
(
|
|
135
|
+
raw
|
|
136
|
+
.query(
|
|
137
|
+
`SELECT key FROM memory_checkpoints WHERE key LIKE '${STEP_CHECKPOINT_PREFIX}%'`,
|
|
138
|
+
)
|
|
139
|
+
.all() as Array<{ key: string }>
|
|
140
|
+
).map((row) => row.key.slice(STEP_CHECKPOINT_PREFIX.length)),
|
|
141
|
+
);
|
|
142
|
+
const markApplied = raw.query(
|
|
143
|
+
`INSERT OR REPLACE INTO memory_checkpoints (key, value, updated_at) VALUES (?, '1', ?)`,
|
|
144
|
+
);
|
|
145
|
+
|
|
24
146
|
const failed: string[] = [];
|
|
147
|
+
const skipped: string[] = [];
|
|
25
148
|
|
|
26
149
|
for (const step of steps) {
|
|
150
|
+
const name = step.name;
|
|
151
|
+
const checkpointable = name !== "";
|
|
152
|
+
|
|
153
|
+
if (checkpointable && applied.has(name)) {
|
|
154
|
+
skipped.push(name);
|
|
155
|
+
log.debug({ migration: name }, `Skipping applied migration: ${name}`);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
|
|
27
159
|
try {
|
|
28
|
-
log.debug({ migration:
|
|
160
|
+
log.debug({ migration: name }, `Starting migration: ${name}`);
|
|
29
161
|
step(database);
|
|
30
|
-
log.debug({ migration:
|
|
162
|
+
log.debug({ migration: name }, `Migration succeeded: ${name}`);
|
|
163
|
+
if (checkpointable) {
|
|
164
|
+
markApplied.run(`${STEP_CHECKPOINT_PREFIX}${name}`, Date.now());
|
|
165
|
+
}
|
|
31
166
|
} catch (err) {
|
|
32
|
-
failed.push(
|
|
33
|
-
log.error(
|
|
34
|
-
{ err, migration: step.name },
|
|
35
|
-
`Migration failed: ${step.name}`,
|
|
36
|
-
);
|
|
167
|
+
failed.push(name);
|
|
168
|
+
log.error({ err, migration: name }, `Migration failed: ${name}`);
|
|
37
169
|
}
|
|
38
170
|
}
|
|
39
171
|
|
|
40
|
-
return { failed };
|
|
172
|
+
return { failed, skipped };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Discard every forward-step checkpoint so the next {@link runMigrationSteps}
|
|
177
|
+
* call re-runs and re-records all steps.
|
|
178
|
+
*
|
|
179
|
+
* Migration rollback calls this. A rolled-back step whose body is registry-backed
|
|
180
|
+
* (guarded by `withCrashRecovery`) clears its own `memory_checkpoints` registry
|
|
181
|
+
* entry when its `down()` runs; the `step:` checkpoint recorded here must be
|
|
182
|
+
* discarded in the same operation, otherwise the runner skips the step on the
|
|
183
|
+
* next upgrade and the rolled-back schema is never restored. Only the `step:`
|
|
184
|
+
* namespace is cleared, leaving registry checkpoints and other ledger entries
|
|
185
|
+
* untouched.
|
|
186
|
+
*/
|
|
187
|
+
export function clearMigrationStepCheckpoints(database: DrizzleDb): void {
|
|
188
|
+
getSqliteFrom(database).run(
|
|
189
|
+
`DELETE FROM memory_checkpoints WHERE key LIKE '${STEP_CHECKPOINT_PREFIX}%'`,
|
|
190
|
+
);
|
|
41
191
|
}
|
|
@@ -6,64 +6,10 @@ import {
|
|
|
6
6
|
MIGRATION_REGISTRY,
|
|
7
7
|
type MigrationValidationResult,
|
|
8
8
|
} from "./registry.js";
|
|
9
|
+
import { clearMigrationStepCheckpoints } from "./run-migrations.js";
|
|
9
10
|
|
|
10
11
|
const log = getLogger("memory-db");
|
|
11
12
|
|
|
12
|
-
/**
|
|
13
|
-
* Recover from crashed migrations before the migration runner executes.
|
|
14
|
-
*
|
|
15
|
-
* Scans memory_checkpoints for entries with value 'started' — these represent
|
|
16
|
-
* migrations that began but never completed (e.g., due to a process crash).
|
|
17
|
-
* Deletes the stalled checkpoint so the migration can re-run from scratch on
|
|
18
|
-
* this startup. Each migration's own idempotency guards (DDL IF NOT EXISTS,
|
|
19
|
-
* transactional rollback) ensure re-running is safe.
|
|
20
|
-
*
|
|
21
|
-
* Call this BEFORE running migrations so that stalled checkpoints don't block
|
|
22
|
-
* re-execution.
|
|
23
|
-
*/
|
|
24
|
-
export function recoverCrashedMigrations(database: DrizzleDb): string[] {
|
|
25
|
-
const raw = getSqliteFrom(database);
|
|
26
|
-
|
|
27
|
-
let rows: Array<{ key: string; value: string }>;
|
|
28
|
-
try {
|
|
29
|
-
rows = raw
|
|
30
|
-
.query(`SELECT key, value FROM memory_checkpoints`)
|
|
31
|
-
.all() as Array<{ key: string; value: string }>;
|
|
32
|
-
} catch {
|
|
33
|
-
return [];
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const crashed = rows
|
|
37
|
-
.filter((r) => r.value === "started" || r.value === "rolling_back")
|
|
38
|
-
.map((r) => r.key);
|
|
39
|
-
if (crashed.length === 0) return [];
|
|
40
|
-
|
|
41
|
-
log.error(
|
|
42
|
-
{ crashed },
|
|
43
|
-
[
|
|
44
|
-
"╔══════════════════════════════════════════════════════════════╗",
|
|
45
|
-
"║ CRASHED MIGRATIONS DETECTED — AUTO-RECOVERING ║",
|
|
46
|
-
"╚══════════════════════════════════════════════════════════════╝",
|
|
47
|
-
"",
|
|
48
|
-
`The following migrations started but never completed: ${crashed.join(", ")}`,
|
|
49
|
-
"",
|
|
50
|
-
"Clearing stalled checkpoints so they can be retried on this startup.",
|
|
51
|
-
"If retries continue to fail, manually inspect the database:",
|
|
52
|
-
` sqlite3 ${getDbPath()} "SELECT * FROM memory_checkpoints"`,
|
|
53
|
-
].join("\n"),
|
|
54
|
-
);
|
|
55
|
-
|
|
56
|
-
for (const key of crashed) {
|
|
57
|
-
raw.query(`DELETE FROM memory_checkpoints WHERE key = ?`).run(key);
|
|
58
|
-
log.info(
|
|
59
|
-
{ key },
|
|
60
|
-
`Cleared stalled checkpoint "${key}" — migration will re-run`,
|
|
61
|
-
);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
return crashed;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
13
|
/**
|
|
68
14
|
* Wrap a migration function with crash-recovery bookkeeping.
|
|
69
15
|
*
|
|
@@ -260,7 +206,9 @@ export function validateMigrationState(
|
|
|
260
206
|
* **Checkpoint state**: Each rolled-back migration's checkpoint is deleted
|
|
261
207
|
* from `memory_checkpoints`. If the process crashes mid-rollback, the
|
|
262
208
|
* `"rolling_back"` marker is detected and cleared by
|
|
263
|
-
* `recoverCrashedMigrations` on the next startup.
|
|
209
|
+
* `recoverCrashedMigrations` on the next startup. The forward-step checkpoints
|
|
210
|
+
* recorded by the migration runner (the `step:` namespace) are also discarded
|
|
211
|
+
* so a later upgrade re-applies (and re-validates) every step.
|
|
264
212
|
*
|
|
265
213
|
* **Warning — data loss**: Some down() migrations may not fully restore the
|
|
266
214
|
* original state (e.g., DROP TABLE migrations recreate the table but cannot
|
|
@@ -303,6 +251,16 @@ export function rollbackMemoryMigration(
|
|
|
303
251
|
(entry) => entry.version > targetVersion && completedKeys.has(entry.key),
|
|
304
252
|
).sort((a, b) => b.version - a.version); // reverse version order
|
|
305
253
|
|
|
254
|
+
// Forward-step checkpoints recorded by the migration runner gate whether each
|
|
255
|
+
// step re-runs on the next upgrade. A rolled-back registry-backed step clears
|
|
256
|
+
// its own memory_checkpoints entry via down(), so its step checkpoint must be
|
|
257
|
+
// discarded too — otherwise the runner skips the step on re-upgrade and the
|
|
258
|
+
// rolled-back schema is never restored. Clearing before the loop keeps this
|
|
259
|
+
// correct even if the process crashes partway through the rollback.
|
|
260
|
+
if (toRollback.length > 0) {
|
|
261
|
+
clearMigrationStepCheckpoints(database);
|
|
262
|
+
}
|
|
263
|
+
|
|
306
264
|
const rolledBack: string[] = [];
|
|
307
265
|
|
|
308
266
|
for (const entry of toRollback) {
|
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.
|
|
@@ -597,13 +597,14 @@ export async function wakeAgentForOpportunity(
|
|
|
597
597
|
});
|
|
598
598
|
|
|
599
599
|
// Apply the caller's persona override for the duration of the run. The
|
|
600
|
-
//
|
|
601
|
-
//
|
|
602
|
-
// before drainQueue) so a queued user turn never builds
|
|
603
|
-
// the wake's override. Assigned only AFTER the
|
|
604
|
-
// — those can throw, and they run before the
|
|
605
|
-
// override, so an earlier assignment would
|
|
606
|
-
// cached Conversation and corrupt every later
|
|
600
|
+
// prompt is built once before `agentLoop.run()` (via
|
|
601
|
+
// `conversation.buildCurrentSystemPrompt()`), which reads this field;
|
|
602
|
+
// cleared (below, before drainQueue) so a queued user turn never builds
|
|
603
|
+
// its prompt under the wake's override. Assigned only AFTER the
|
|
604
|
+
// profile/config reads above — those can throw, and they run before the
|
|
605
|
+
// try/finally that clears the override, so an earlier assignment would
|
|
606
|
+
// strand the override on the cached Conversation and corrupt every later
|
|
607
|
+
// prompt build on it.
|
|
607
608
|
if (opts.personaOverride) {
|
|
608
609
|
conversation.wakePersonaOverride = opts.personaOverride;
|
|
609
610
|
}
|
|
@@ -1146,6 +1147,13 @@ export async function wakeAgentForOpportunity(
|
|
|
1146
1147
|
maxInputTokens: effectiveContextWindow.maxInputTokens,
|
|
1147
1148
|
overflowRecovery: { enabled: false, safetyMarginRatio: 0 },
|
|
1148
1149
|
}),
|
|
1150
|
+
// Resolve the system prompt once before the run — the loop does
|
|
1151
|
+
// not re-resolve mid-loop. `wakePersonaOverride` (if set above)
|
|
1152
|
+
// is baked into the prompt at this point.
|
|
1153
|
+
systemPrompt: conversation.buildCurrentSystemPrompt(),
|
|
1154
|
+
...(conversation.modelOverride
|
|
1155
|
+
? { model: conversation.modelOverride }
|
|
1156
|
+
: {}),
|
|
1149
1157
|
}));
|
|
1150
1158
|
} catch (err) {
|
|
1151
1159
|
// An over-window throw on a compaction-suppressed wake is the
|
|
@@ -10,7 +10,10 @@ import {
|
|
|
10
10
|
createAssistantMessage,
|
|
11
11
|
createUserMessage,
|
|
12
12
|
} from "../../agent/message-types.js";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
type ConversationContentBlock,
|
|
15
|
+
ConversationMessageSchema,
|
|
16
|
+
} from "../../api/responses/conversation-message.js";
|
|
14
17
|
import {
|
|
15
18
|
CHANNEL_IDS,
|
|
16
19
|
INTERFACE_IDS,
|
|
@@ -943,7 +946,25 @@ export function handleListMessages({
|
|
|
943
946
|
.filter((block) => block.type !== "text" || block.text.length > 0);
|
|
944
947
|
}
|
|
945
948
|
|
|
946
|
-
|
|
949
|
+
// Ensure every hydrated attachment has a corresponding content block.
|
|
950
|
+
// renderHistoryContent inlines attachment blocks only when it has
|
|
951
|
+
// file-block refs with matching DB rows; directives (assistant-authored
|
|
952
|
+
// <vellum-attachment/> tags) don't leave a file block after stripping,
|
|
953
|
+
// so their attachments end up in the flat `attachments` array but not in
|
|
954
|
+
// `contentBlocks`. Append any that are missing so the canonical
|
|
955
|
+
// projection is complete.
|
|
956
|
+
const existingAttachmentIds = new Set(
|
|
957
|
+
contentBlocks
|
|
958
|
+
.filter((b): b is Extract<ConversationContentBlock, { type: "attachment" }> => b.type === "attachment")
|
|
959
|
+
.map((b) => b.attachment.id),
|
|
960
|
+
);
|
|
961
|
+
for (const att of msgAttachments) {
|
|
962
|
+
if (!existingAttachmentIds.has(att.id)) {
|
|
963
|
+
contentBlocks.push({ type: "attachment", attachment: att });
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
const alignedContentOrder = aligned.rewriteContentOrder(contentOrder);
|
|
947
968
|
|
|
948
969
|
// Use sentAt (actual event time) for the display timestamp when available,
|
|
949
970
|
// falling back to createdAt (persistence time). Clients use this display
|
|
@@ -971,7 +992,7 @@ export function handleListMessages({
|
|
|
971
992
|
...(alignedContentOrder.length > 0
|
|
972
993
|
? { contentOrder: alignedContentOrder }
|
|
973
994
|
: {}),
|
|
974
|
-
|
|
995
|
+
contentBlocks,
|
|
975
996
|
...(m.subagentNotification
|
|
976
997
|
? { subagentNotification: m.subagentNotification }
|
|
977
998
|
: {}),
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
upsertCredentialMetadata,
|
|
39
39
|
} from "../../tools/credentials/metadata-store.js";
|
|
40
40
|
import { getLogger } from "../../util/logger.js";
|
|
41
|
+
import { getLogsDbPath } from "../../util/logs-db-path.js";
|
|
41
42
|
import { getWorkspaceDir, getWorkspaceHooksDir } from "../../util/platform.js";
|
|
42
43
|
import { APP_VERSION } from "../../version.js";
|
|
43
44
|
import { DAEMON_INTERNAL_ASSISTANT_ID } from "../assistant-scope.js";
|
|
@@ -151,6 +152,38 @@ export async function reconcileVellumMetadataFromCes(warningSink: {
|
|
|
151
152
|
|
|
152
153
|
const log = getLogger("migration-routes");
|
|
153
154
|
|
|
155
|
+
/**
|
|
156
|
+
* Flush both database files' WALs before an export copies them. The bundle
|
|
157
|
+
* walker includes `*.db` but skips `*.db-wal`, so committed frames still sitting
|
|
158
|
+
* in a WAL would be missing from the bundle.
|
|
159
|
+
*
|
|
160
|
+
* Dispatched through `runAsyncSqlite` so the flush runs in a sqlite3 subprocess
|
|
161
|
+
* where available — a multi-GB WAL flush would otherwise stall the event loop.
|
|
162
|
+
* The in-process fallback runs on the daemon connection, where one unqualified
|
|
163
|
+
* checkpoint already covers every attached database; the subprocess opens a
|
|
164
|
+
* single file, so we issue a second checkpoint targeting the logs file. FULL
|
|
165
|
+
* (not TRUNCATE) writes committed frames back to the main file, which is all the
|
|
166
|
+
* copy needs.
|
|
167
|
+
*/
|
|
168
|
+
async function checkpointDbsForExport(): Promise<void> {
|
|
169
|
+
const targets: Array<{ label: string; dbPath?: string }> = [
|
|
170
|
+
{ label: "main" },
|
|
171
|
+
{ label: "logs", dbPath: getLogsDbPath() },
|
|
172
|
+
];
|
|
173
|
+
for (const { label, dbPath } of targets) {
|
|
174
|
+
const result = await runAsyncSqlite(
|
|
175
|
+
"PRAGMA wal_checkpoint(FULL)",
|
|
176
|
+
dbPath ? { dbPath } : undefined,
|
|
177
|
+
);
|
|
178
|
+
if (!result.ok) {
|
|
179
|
+
log.warn(
|
|
180
|
+
{ error: result.error, backend: result.backend, db: label },
|
|
181
|
+
"WAL checkpoint failed — exporting without checkpoint",
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
154
187
|
/**
|
|
155
188
|
* Fields the export pipeline must populate on the v1 manifest.
|
|
156
189
|
*
|
|
@@ -353,28 +386,7 @@ export async function handleMigrationExport(
|
|
|
353
386
|
...manifestInputs,
|
|
354
387
|
secretsRedacted,
|
|
355
388
|
credentials,
|
|
356
|
-
checkpoint:
|
|
357
|
-
// Dispatch through `runAsyncSqlite` so the WAL checkpoint runs
|
|
358
|
-
// in a sqlite3 subprocess on hosts where it's available. A WAL
|
|
359
|
-
// flush on a multi-GB WAL file can otherwise stall the daemon's
|
|
360
|
-
// event loop for the full duration of the flush.
|
|
361
|
-
//
|
|
362
|
-
// FULL (not TRUNCATE): FULL blocks until every committed frame is
|
|
363
|
-
// written back to the main `.db` file, which is all the export
|
|
364
|
-
// needs since it copies that file. TRUNCATE additionally restarts
|
|
365
|
-
// the WAL and truncates it to zero bytes — extra work and blocking
|
|
366
|
-
// this path does not need.
|
|
367
|
-
const result = await runAsyncSqlite("PRAGMA wal_checkpoint(FULL)");
|
|
368
|
-
if (!result.ok) {
|
|
369
|
-
// Best-effort: if the DB can't be checkpointed (e.g. not a valid
|
|
370
|
-
// SQLite file, missing WAL, etc.) we still proceed with the export
|
|
371
|
-
// using whatever is on disk.
|
|
372
|
-
log.warn(
|
|
373
|
-
{ error: result.error, backend: result.backend },
|
|
374
|
-
"WAL checkpoint failed — exporting without checkpoint",
|
|
375
|
-
);
|
|
376
|
-
}
|
|
377
|
-
},
|
|
389
|
+
checkpoint: checkpointDbsForExport,
|
|
378
390
|
});
|
|
379
391
|
|
|
380
392
|
cleanup = result.cleanup;
|
|
@@ -582,23 +594,7 @@ export async function handleMigrationExportToGcs({ body }: RouteHandlerArgs) {
|
|
|
582
594
|
...manifestInputs,
|
|
583
595
|
secretsRedacted,
|
|
584
596
|
credentials: collected.credentials,
|
|
585
|
-
checkpoint:
|
|
586
|
-
// Dispatch through `runAsyncSqlite` so the WAL checkpoint runs
|
|
587
|
-
// in a sqlite3 subprocess on hosts where it's available. A
|
|
588
|
-
// WAL flush on a multi-GB WAL file can otherwise stall the
|
|
589
|
-
// daemon's event loop for the full duration of the flush.
|
|
590
|
-
//
|
|
591
|
-
// FULL (not TRUNCATE): see the disk-export checkpoint above
|
|
592
|
-
// for rationale. assistant/AGENTS.md "SQLite WAL
|
|
593
|
-
// checkpointing".
|
|
594
|
-
const result = await runAsyncSqlite("PRAGMA wal_checkpoint(FULL)");
|
|
595
|
-
if (!result.ok) {
|
|
596
|
-
log.warn(
|
|
597
|
-
{ error: result.error, backend: result.backend },
|
|
598
|
-
"WAL checkpoint failed — exporting without checkpoint",
|
|
599
|
-
);
|
|
600
|
-
}
|
|
601
|
-
},
|
|
597
|
+
checkpoint: checkpointDbsForExport,
|
|
602
598
|
});
|
|
603
599
|
|
|
604
600
|
cleanup = result.cleanup;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { getConfig } from "../config/loader.js";
|
|
1
2
|
import {
|
|
2
3
|
checkDiskPressureBackgroundGate,
|
|
3
4
|
diskPressureBackgroundSkipLogFields,
|
|
@@ -85,14 +86,6 @@ const TICK_INTERVAL_MS = 15_000;
|
|
|
85
86
|
*/
|
|
86
87
|
const WAKE_MAX_RETRIES = 20;
|
|
87
88
|
|
|
88
|
-
/**
|
|
89
|
-
* Hard timeout for `talk`-mode scheduled jobs. Schedules can do
|
|
90
|
-
* non-trivial work (research, summarize the day, etc.), so the cap is
|
|
91
|
-
* generous; it exists primarily so a wedged turn cannot block the next
|
|
92
|
-
* scheduler tick indefinitely. Mirrors the heartbeat/filing budgets.
|
|
93
|
-
*/
|
|
94
|
-
const SCHEDULE_TALK_TIMEOUT_MS = 30 * 60 * 1000;
|
|
95
|
-
|
|
96
89
|
/**
|
|
97
90
|
* Apply retry policy on schedule-execution failure. Retries are scheduled by
|
|
98
91
|
* `applyRetryDecision`; once retries are exhausted, the `emitAlert` callback
|
|
@@ -718,7 +711,10 @@ export async function runScheduleDueWorkOnce(
|
|
|
718
711
|
...(job.inferenceProfile
|
|
719
712
|
? { overrideProfile: job.inferenceProfile }
|
|
720
713
|
: {}),
|
|
721
|
-
|
|
714
|
+
// Hard timeout for talk-mode scheduled turns: bounds a wedged turn so
|
|
715
|
+
// it cannot block the next scheduler tick. Configurable via
|
|
716
|
+
// timeouts.scheduleTurnTimeoutSec (default 1800s).
|
|
717
|
+
timeoutMs: getConfig().timeouts.scheduleTurnTimeoutSec * 1000,
|
|
722
718
|
origin: "schedule",
|
|
723
719
|
groupId: "system:scheduled",
|
|
724
720
|
conversationType: "scheduled",
|