@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/agent/loop.ts
CHANGED
|
@@ -493,12 +493,6 @@ export function shouldCaptureAgentLoopError(err: Error): boolean {
|
|
|
493
493
|
return true;
|
|
494
494
|
}
|
|
495
495
|
|
|
496
|
-
export interface ResolvedSystemPrompt {
|
|
497
|
-
systemPrompt: string;
|
|
498
|
-
maxTokens?: number;
|
|
499
|
-
model?: string;
|
|
500
|
-
}
|
|
501
|
-
|
|
502
496
|
export interface AgentLoopRunOptions {
|
|
503
497
|
/** Input history the run starts from; the loop appends its output onto a copy. */
|
|
504
498
|
messages: Message[];
|
|
@@ -506,6 +500,20 @@ export interface AgentLoopRunOptions {
|
|
|
506
500
|
onEvent: (event: AgentEvent) => void | Promise<void>;
|
|
507
501
|
signal?: AbortSignal;
|
|
508
502
|
requestId: string;
|
|
503
|
+
/**
|
|
504
|
+
* System prompt for this run, resolved by the caller (Conversation) before
|
|
505
|
+
* invoking the loop. One turn == one agent loop == one system prompt; the
|
|
506
|
+
* prompt is NOT re-resolved mid-loop (that would bust the provider's
|
|
507
|
+
* prefix cache). When omitted, falls back to the constructor's
|
|
508
|
+
* {@link AgentLoopConstructorOptions.systemPrompt}.
|
|
509
|
+
*/
|
|
510
|
+
systemPrompt?: string;
|
|
511
|
+
/**
|
|
512
|
+
* Explicit model override (provider/model string) for every LLM call in
|
|
513
|
+
* this run. When omitted, the model is resolved through the normal
|
|
514
|
+
* call-site / profile resolution path.
|
|
515
|
+
*/
|
|
516
|
+
model?: string;
|
|
509
517
|
onCheckpoint?: (
|
|
510
518
|
checkpoint: CheckpointInfo,
|
|
511
519
|
) => CheckpointDecision | Promise<CheckpointDecision>;
|
|
@@ -634,7 +642,6 @@ export interface AgentLoopConstructorOptions {
|
|
|
634
642
|
tools?: ToolDefinition[];
|
|
635
643
|
toolExecutor?: LoopToolExecutor;
|
|
636
644
|
resolveTools?: (history: Message[]) => ToolDefinition[];
|
|
637
|
-
resolveSystemPrompt?: (history: Message[]) => ResolvedSystemPrompt;
|
|
638
645
|
/**
|
|
639
646
|
* Conversation this loop drives. Scopes the loop-held compaction circuit
|
|
640
647
|
* breaker and is the source of truth the loop's pipeline contexts and
|
|
@@ -659,9 +666,6 @@ export class AgentLoop {
|
|
|
659
666
|
private config: AgentLoopConfig;
|
|
660
667
|
private tools: ToolDefinition[];
|
|
661
668
|
private resolveTools: ((history: Message[]) => ToolDefinition[]) | null;
|
|
662
|
-
private resolveSystemPrompt:
|
|
663
|
-
| ((history: Message[]) => ResolvedSystemPrompt)
|
|
664
|
-
| null;
|
|
665
669
|
private toolExecutor: LoopToolExecutor | null;
|
|
666
670
|
|
|
667
671
|
/**
|
|
@@ -692,7 +696,6 @@ export class AgentLoop {
|
|
|
692
696
|
tools,
|
|
693
697
|
toolExecutor,
|
|
694
698
|
resolveTools,
|
|
695
|
-
resolveSystemPrompt,
|
|
696
699
|
conversationId,
|
|
697
700
|
resolveConversationDir,
|
|
698
701
|
} = options;
|
|
@@ -701,7 +704,6 @@ export class AgentLoop {
|
|
|
701
704
|
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
702
705
|
this.tools = tools ?? [];
|
|
703
706
|
this.resolveTools = resolveTools ?? null;
|
|
704
|
-
this.resolveSystemPrompt = resolveSystemPrompt ?? null;
|
|
705
707
|
this.toolExecutor = toolExecutor ?? null;
|
|
706
708
|
this.conversationId = conversationId;
|
|
707
709
|
this.resolveConversationDir = resolveConversationDir ?? null;
|
|
@@ -910,7 +912,14 @@ export class AgentLoop {
|
|
|
910
912
|
compactInPlace = false,
|
|
911
913
|
isNonInteractive = false,
|
|
912
914
|
modelProfileKey = null,
|
|
915
|
+
systemPrompt: runSystemPrompt,
|
|
916
|
+
model: runModel,
|
|
913
917
|
} = options;
|
|
918
|
+
// Resolve the system prompt once per run — the caller (Conversation)
|
|
919
|
+
// builds it before invoking the loop. Re-resolving mid-loop would bust
|
|
920
|
+
// the provider's prefix cache. Falls back to the constructor seed when
|
|
921
|
+
// the caller doesn't supply one.
|
|
922
|
+
const resolvedSystemPrompt = runSystemPrompt ?? this.systemPrompt;
|
|
914
923
|
let history = [...messages];
|
|
915
924
|
// Index into `history` where this run's appended output begins. It starts
|
|
916
925
|
// after the input and resets to the new base whenever the loop rewrites the
|
|
@@ -1218,15 +1227,14 @@ export class AgentLoop {
|
|
|
1218
1227
|
? this.resolveTools(history)
|
|
1219
1228
|
: this.tools;
|
|
1220
1229
|
|
|
1221
|
-
//
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
const
|
|
1226
|
-
const turnModel = resolved?.model;
|
|
1230
|
+
// System prompt and model are resolved once per run (by the caller),
|
|
1231
|
+
// not per-LLM-call — re-resolving mid-loop would bust the provider's
|
|
1232
|
+
// prefix cache.
|
|
1233
|
+
const turnSystemPrompt = resolvedSystemPrompt;
|
|
1234
|
+
const turnModel = runModel;
|
|
1227
1235
|
|
|
1228
1236
|
// Field precedence (highest wins):
|
|
1229
|
-
// 1. Per-
|
|
1237
|
+
// 1. Per-run explicit (`runModel`)
|
|
1230
1238
|
// 2. Call-site resolved values (filled by
|
|
1231
1239
|
// `RetryProvider.normalizeSendMessageOptions` from
|
|
1232
1240
|
// `resolveCallSiteConfig(callSite, llm)`)
|
|
@@ -1244,9 +1252,7 @@ export class AgentLoop {
|
|
|
1244
1252
|
// they always come from `this.config` regardless of `callSite`.
|
|
1245
1253
|
const providerConfig: Record<string, unknown> = {};
|
|
1246
1254
|
|
|
1247
|
-
if (
|
|
1248
|
-
providerConfig.max_tokens = resolved.maxTokens;
|
|
1249
|
-
} else if (!callSite) {
|
|
1255
|
+
if (!callSite) {
|
|
1250
1256
|
providerConfig.max_tokens = this.config.maxTokens;
|
|
1251
1257
|
}
|
|
1252
1258
|
|
|
@@ -169,7 +169,7 @@ export const MemoryMaintenanceConfigSchema = z
|
|
|
169
169
|
.positive("memory.maintenance.intervalMs must be a positive integer")
|
|
170
170
|
.default(24 * 60 * 60 * 1000)
|
|
171
171
|
.describe(
|
|
172
|
-
"Minimum interval between database maintenance (
|
|
172
|
+
"Minimum interval between database maintenance (PRAGMA optimize / WAL checkpoint) runs, in milliseconds",
|
|
173
173
|
),
|
|
174
174
|
quietPeriodMs: z
|
|
175
175
|
.number({ error: "memory.maintenance.quietPeriodMs must be a number" })
|
|
@@ -177,10 +177,12 @@ export const MemoryMaintenanceConfigSchema = z
|
|
|
177
177
|
.nonnegative("memory.maintenance.quietPeriodMs must be non-negative")
|
|
178
178
|
.default(3 * 60 * 60 * 1000)
|
|
179
179
|
.describe(
|
|
180
|
-
"Database maintenance is deferred unless at least this many milliseconds have elapsed since the last user message, so
|
|
180
|
+
"Database maintenance is deferred unless at least this many milliseconds have elapsed since the last user message, so maintenance's write locks never collide with an active user (0 disables the quiet-period gate)",
|
|
181
181
|
),
|
|
182
182
|
})
|
|
183
|
-
.describe(
|
|
183
|
+
.describe(
|
|
184
|
+
"Database maintenance (PRAGMA optimize / WAL checkpoint) scheduling",
|
|
185
|
+
);
|
|
184
186
|
|
|
185
187
|
export type MemoryJobsConfig = z.infer<typeof MemoryJobsConfigSchema>;
|
|
186
188
|
export type MemoryRetentionConfig = z.infer<typeof MemoryRetentionConfigSchema>;
|
|
@@ -42,6 +42,30 @@ export const TimeoutConfigSchema = z
|
|
|
42
42
|
.describe(
|
|
43
43
|
"Timeout for waiting on the LLM provider's streaming response (seconds)",
|
|
44
44
|
),
|
|
45
|
+
backgroundTurnTimeoutSec: z
|
|
46
|
+
.number({ error: "timeouts.backgroundTurnTimeoutSec must be a number" })
|
|
47
|
+
.int("timeouts.backgroundTurnTimeoutSec must be an integer")
|
|
48
|
+
.positive("timeouts.backgroundTurnTimeoutSec must be a positive integer")
|
|
49
|
+
.max(
|
|
50
|
+
2147483,
|
|
51
|
+
"timeouts.backgroundTurnTimeoutSec must be at most 2147483 (setTimeout-safe limit)",
|
|
52
|
+
)
|
|
53
|
+
.default(1800)
|
|
54
|
+
.describe(
|
|
55
|
+
"Hard timeout for heartbeat and generic background agent turns (seconds)",
|
|
56
|
+
),
|
|
57
|
+
scheduleTurnTimeoutSec: z
|
|
58
|
+
.number({ error: "timeouts.scheduleTurnTimeoutSec must be a number" })
|
|
59
|
+
.int("timeouts.scheduleTurnTimeoutSec must be an integer")
|
|
60
|
+
.positive("timeouts.scheduleTurnTimeoutSec must be a positive integer")
|
|
61
|
+
.max(
|
|
62
|
+
2147483,
|
|
63
|
+
"timeouts.scheduleTurnTimeoutSec must be at most 2147483 (setTimeout-safe limit)",
|
|
64
|
+
)
|
|
65
|
+
.default(1800)
|
|
66
|
+
.describe(
|
|
67
|
+
"Hard timeout for deliberately-launched scheduled (talk-mode) agent turns (seconds)",
|
|
68
|
+
),
|
|
45
69
|
})
|
|
46
70
|
.describe("Timeout configuration for various operations");
|
|
47
71
|
|
|
@@ -11,11 +11,9 @@
|
|
|
11
11
|
|
|
12
12
|
import { existsSync, type FSWatcher, watch } from "node:fs";
|
|
13
13
|
|
|
14
|
-
import {
|
|
15
|
-
getAppsDir,
|
|
16
|
-
resolveAppIdByDirName,
|
|
17
|
-
} from "../memory/app-store.js";
|
|
14
|
+
import { getAppsDir, resolveAppIdByDirName } from "../memory/app-store.js";
|
|
18
15
|
import { DebouncerMap } from "../util/debounce.js";
|
|
16
|
+
import { attachFsWatcherErrorHandler } from "../util/fs-watcher-error.js";
|
|
19
17
|
import { getLogger } from "../util/logger.js";
|
|
20
18
|
|
|
21
19
|
const log = getLogger("app-source-watcher");
|
|
@@ -51,8 +49,10 @@ function resolveAppIdFromRelPath(relPath: string): string | null {
|
|
|
51
49
|
|
|
52
50
|
// Skip non-source directories (include bare directory names for fs.watch events)
|
|
53
51
|
if (
|
|
54
|
-
innerPath === "records" ||
|
|
55
|
-
innerPath
|
|
52
|
+
innerPath === "records" ||
|
|
53
|
+
innerPath.startsWith("records/") ||
|
|
54
|
+
innerPath === "dist" ||
|
|
55
|
+
innerPath.startsWith("dist/")
|
|
56
56
|
) {
|
|
57
57
|
return null;
|
|
58
58
|
}
|
|
@@ -89,7 +89,9 @@ export class AppSourceWatcher {
|
|
|
89
89
|
try {
|
|
90
90
|
appsDir = getAppsDir();
|
|
91
91
|
} catch {
|
|
92
|
-
log.warn(
|
|
92
|
+
log.warn(
|
|
93
|
+
"Could not resolve apps directory; app source watching disabled",
|
|
94
|
+
);
|
|
93
95
|
return;
|
|
94
96
|
}
|
|
95
97
|
|
|
@@ -102,19 +104,30 @@ export class AppSourceWatcher {
|
|
|
102
104
|
if (!onChange) return;
|
|
103
105
|
|
|
104
106
|
try {
|
|
105
|
-
this.watcher = watch(
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
107
|
+
this.watcher = watch(
|
|
108
|
+
appsDir,
|
|
109
|
+
{ recursive: true },
|
|
110
|
+
(_eventType, filename) => {
|
|
111
|
+
if (!filename) return;
|
|
112
|
+
|
|
113
|
+
const appId = resolveAppIdFromRelPath(filename);
|
|
114
|
+
if (!appId) return;
|
|
115
|
+
|
|
116
|
+
this.debouncer.schedule(`app:${appId}`, () => {
|
|
117
|
+
onChange(appId);
|
|
118
|
+
});
|
|
119
|
+
},
|
|
120
|
+
);
|
|
121
|
+
// Recursive watches over app trees (incl. node_modules) can exhaust the
|
|
122
|
+
// inotify watch limit and emit ENOSPC asynchronously. Without an 'error'
|
|
123
|
+
// listener that unhandled emitter error crashes the daemon.
|
|
124
|
+
attachFsWatcherErrorHandler(this.watcher, log, appsDir);
|
|
115
125
|
log.info("App source watcher started");
|
|
116
126
|
} catch (err) {
|
|
117
|
-
log.warn(
|
|
127
|
+
log.warn(
|
|
128
|
+
{ err },
|
|
129
|
+
"Failed to watch apps directory; source watching disabled",
|
|
130
|
+
);
|
|
118
131
|
}
|
|
119
132
|
}
|
|
120
133
|
|
|
@@ -190,8 +190,8 @@ export interface AssistantSurface {
|
|
|
190
190
|
// ── abort watchdog ───────────────────────────────────────────────────
|
|
191
191
|
|
|
192
192
|
/**
|
|
193
|
-
*
|
|
194
|
-
*
|
|
193
|
+
* Backstop that drives an aborted turn to its `finally` even if some awaited
|
|
194
|
+
* operation fails to observe the abort signal.
|
|
195
195
|
*
|
|
196
196
|
* Abort is otherwise cooperative and already wired into the slow paths: the
|
|
197
197
|
* provider call forwards the signal to its HTTP/streaming fetch, and tool
|
|
@@ -199,10 +199,11 @@ export interface AssistantSurface {
|
|
|
199
199
|
* watchdog only fires when a future code path silently ignores abort — without
|
|
200
200
|
* it, such a path would hang the loop forever and latch the conversation's
|
|
201
201
|
* `processing` flag true (the wedged "Thinking…" indicator). It is
|
|
202
|
-
* defense-in-depth, not the primary mechanism
|
|
203
|
-
*
|
|
202
|
+
* defense-in-depth, not the primary mechanism: in the common case abort settles
|
|
203
|
+
* in-flight work in well under a second, so a few seconds is ample headroom for
|
|
204
|
+
* a cooperative unwind while still releasing a genuinely wedged turn promptly.
|
|
204
205
|
*/
|
|
205
|
-
const ABORT_WATCHDOG_MS =
|
|
206
|
+
const ABORT_WATCHDOG_MS = 5_000;
|
|
206
207
|
|
|
207
208
|
/**
|
|
208
209
|
* Race `work` against an abort watchdog. The watchdog stays disarmed until the
|
|
@@ -980,6 +981,8 @@ export async function runAgentLoopImpl(
|
|
|
980
981
|
compactInPlace,
|
|
981
982
|
isNonInteractive,
|
|
982
983
|
modelProfileKey,
|
|
984
|
+
systemPrompt: ctx.buildCurrentSystemPrompt(),
|
|
985
|
+
...(ctx.modelOverride ? { model: ctx.modelOverride } : {}),
|
|
983
986
|
}),
|
|
984
987
|
abortController.signal,
|
|
985
988
|
watchdogMs,
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* - conversation-usage.ts — recordUsage
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import type { AgentLoopConfig
|
|
18
|
+
import type { AgentLoopConfig } from "../agent/loop.js";
|
|
19
19
|
import { AgentLoop } from "../agent/loop.js";
|
|
20
20
|
import type { AssistantActivityStateEvent } from "../api/events/assistant-activity-state.js";
|
|
21
21
|
import type {
|
|
@@ -537,6 +537,7 @@ export class Conversation {
|
|
|
537
537
|
};
|
|
538
538
|
public readonly traceEmitter: TraceEmitter;
|
|
539
539
|
/** @internal */ hasSystemPromptOverride: boolean;
|
|
540
|
+
/** @internal */ modelOverride: string | undefined;
|
|
540
541
|
/** @internal */ readonly graphMemory: ConversationGraphMemory;
|
|
541
542
|
/** @internal */ activeContextNodeIds?: string[];
|
|
542
543
|
/** @internal */ streamThinking: boolean;
|
|
@@ -666,34 +667,9 @@ export class Conversation {
|
|
|
666
667
|
const hasSystemPromptOverride = systemPrompt !== buildSystemPrompt();
|
|
667
668
|
this.hasSystemPromptOverride = hasSystemPromptOverride;
|
|
668
669
|
|
|
669
|
-
//
|
|
670
|
-
//
|
|
671
|
-
|
|
672
|
-
const resolvedModel: string | undefined = modelOverride;
|
|
673
|
-
|
|
674
|
-
const resolveSystemPromptCallback = (
|
|
675
|
-
_history: Message[],
|
|
676
|
-
): ResolvedSystemPrompt => {
|
|
677
|
-
const resolved: ResolvedSystemPrompt = {
|
|
678
|
-
systemPrompt: this.hasSystemPromptOverride
|
|
679
|
-
? systemPrompt
|
|
680
|
-
: buildSystemPrompt({
|
|
681
|
-
hasNoClient: this.hasNoClient,
|
|
682
|
-
trustContext: this.currentTurnTrustContext,
|
|
683
|
-
channelCapabilities: this.currentTurnChannelCapabilities,
|
|
684
|
-
personaOverride: this.wakePersonaOverride,
|
|
685
|
-
onboardingContext: this.getOnboardingContext(),
|
|
686
|
-
conversationId: this.conversationId,
|
|
687
|
-
}),
|
|
688
|
-
};
|
|
689
|
-
if (configuredMaxTokens !== undefined) {
|
|
690
|
-
resolved.maxTokens = configuredMaxTokens;
|
|
691
|
-
}
|
|
692
|
-
if (resolvedModel !== undefined) {
|
|
693
|
-
resolved.model = resolvedModel;
|
|
694
|
-
}
|
|
695
|
-
return resolved;
|
|
696
|
-
};
|
|
670
|
+
// Store the model override for per-run resolution. The loop receives it
|
|
671
|
+
// as a top-level `model` param on `run()`.
|
|
672
|
+
this.modelOverride = modelOverride;
|
|
697
673
|
|
|
698
674
|
const fastModeEnabled = isAssistantFeatureFlagEnabled("fast-mode", config);
|
|
699
675
|
const resolvedSpeed = speedOverride ?? resolvedMainAgent.speed;
|
|
@@ -726,7 +702,6 @@ export class Conversation {
|
|
|
726
702
|
tools: toolDefs.length > 0 ? toolDefs : undefined,
|
|
727
703
|
toolExecutor: toolDefs.length > 0 ? toolExecutor : undefined,
|
|
728
704
|
resolveTools,
|
|
729
|
-
resolveSystemPrompt: resolveSystemPromptCallback,
|
|
730
705
|
resolveConversationDir: () => {
|
|
731
706
|
const conv = getConversation(this.conversationId);
|
|
732
707
|
if (!conv) return null;
|
|
@@ -738,7 +713,7 @@ export class Conversation {
|
|
|
738
713
|
});
|
|
739
714
|
createContextWindowManager({
|
|
740
715
|
provider,
|
|
741
|
-
systemPrompt: () =>
|
|
716
|
+
systemPrompt: () => this.buildCurrentSystemPrompt(),
|
|
742
717
|
config: initialContextWindowConfig,
|
|
743
718
|
toolTokenBudget: this.agentLoop.getToolTokenBudget(),
|
|
744
719
|
conversationId: this.conversationId,
|
|
@@ -799,6 +774,29 @@ export class Conversation {
|
|
|
799
774
|
this.inferenceProfileExpiresAt = state.expiresAt;
|
|
800
775
|
}
|
|
801
776
|
|
|
777
|
+
/**
|
|
778
|
+
* Build the system prompt for the current conversation state. When a
|
|
779
|
+
* system-prompt override was supplied at construction, use it as-is;
|
|
780
|
+
* otherwise rebuild the full prompt (picks up workspace file changes,
|
|
781
|
+
* live trust/channel context, persona overrides, onboarding context).
|
|
782
|
+
*
|
|
783
|
+
* Called by the caller before invoking `agentLoop.run()` — the loop
|
|
784
|
+
* itself never re-resolves the prompt mid-loop (re-resolving would bust
|
|
785
|
+
* the provider's prefix cache).
|
|
786
|
+
*/
|
|
787
|
+
buildCurrentSystemPrompt(): string {
|
|
788
|
+
return this.hasSystemPromptOverride
|
|
789
|
+
? this.systemPrompt
|
|
790
|
+
: buildSystemPrompt({
|
|
791
|
+
hasNoClient: this.hasNoClient,
|
|
792
|
+
trustContext: this.currentTurnTrustContext,
|
|
793
|
+
channelCapabilities: this.currentTurnChannelCapabilities,
|
|
794
|
+
personaOverride: this.wakePersonaOverride,
|
|
795
|
+
onboardingContext: this.getOnboardingContext(),
|
|
796
|
+
conversationId: this.conversationId,
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
|
|
802
800
|
// ── Prompt Cache Warming ─────────────────────────────────────────
|
|
803
801
|
|
|
804
802
|
/**
|
|
@@ -811,16 +809,7 @@ export class Conversation {
|
|
|
811
809
|
const abort = new AbortController();
|
|
812
810
|
this.cacheWarmAbort = abort;
|
|
813
811
|
|
|
814
|
-
const systemPrompt = this.
|
|
815
|
-
? this.systemPrompt
|
|
816
|
-
: buildSystemPrompt({
|
|
817
|
-
hasNoClient: this.hasNoClient,
|
|
818
|
-
trustContext: this.currentTurnTrustContext,
|
|
819
|
-
channelCapabilities: this.currentTurnChannelCapabilities,
|
|
820
|
-
personaOverride: this.wakePersonaOverride,
|
|
821
|
-
onboardingContext: this.getOnboardingContext(),
|
|
822
|
-
conversationId: this.conversationId,
|
|
823
|
-
});
|
|
812
|
+
const systemPrompt = this.buildCurrentSystemPrompt();
|
|
824
813
|
const tools = getAllToolDefinitions();
|
|
825
814
|
const provider = this.provider;
|
|
826
815
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { v4 as uuid } from "uuid";
|
|
2
2
|
|
|
3
|
+
import { peekAcpSessionManager } from "../../acp/index.js";
|
|
3
4
|
import { clearAll, getConversation } from "../../memory/conversation-crud.js";
|
|
4
5
|
import { resolveConversationId } from "../../memory/conversation-key-store.js";
|
|
5
6
|
import { broadcastMessage } from "../../runtime/assistant-event-hub.js";
|
|
@@ -106,6 +107,12 @@ export function cancelGeneration(conversationId: string): boolean {
|
|
|
106
107
|
// being cancelled, so enqueuing synthetic messages would trigger
|
|
107
108
|
// unwanted model activity after the user pressed stop.
|
|
108
109
|
getSubagentManager().abortAllForParent(conversationId);
|
|
110
|
+
// Cancel any in-flight ACP agent sessions this conversation spawned, for the
|
|
111
|
+
// same reason: a backgrounded ACP prompt would otherwise keep running (and
|
|
112
|
+
// holding a child process) past the stop and, on completion, enqueue a
|
|
113
|
+
// follow-up message back into the conversation the user just cancelled. Peek
|
|
114
|
+
// the singleton so a conversation that never used ACP doesn't spin one up.
|
|
115
|
+
peekAcpSessionManager()?.cancelForParent(conversationId);
|
|
109
116
|
// The processing flag is cleared by the in-flight turn's `finally`, not here.
|
|
110
117
|
// Abort propagates into the provider call and tool execution (and is backed
|
|
111
118
|
// by the agent loop's abort watchdog), so the turn reaches its `finally`
|
|
@@ -39,6 +39,7 @@ import { type FSWatcher, mkdirSync, readdirSync, watch } from "node:fs";
|
|
|
39
39
|
|
|
40
40
|
import { getRegisteredPlugin } from "../plugins/registry.js";
|
|
41
41
|
import { DebouncerMap } from "../util/debounce.js";
|
|
42
|
+
import { attachFsWatcherErrorHandler } from "../util/fs-watcher-error.js";
|
|
42
43
|
import { getLogger } from "../util/logger.js";
|
|
43
44
|
import { getWorkspacePluginsDir } from "../util/platform.js";
|
|
44
45
|
import { reregisterExternalPlugin } from "./external-plugins-bootstrap.js";
|
|
@@ -267,6 +268,10 @@ export class PluginSourceWatcher {
|
|
|
267
268
|
});
|
|
268
269
|
},
|
|
269
270
|
);
|
|
271
|
+
// Recursive watches over plugin trees (incl. node_modules) can exhaust
|
|
272
|
+
// the inotify watch limit and emit ENOSPC asynchronously. Without an
|
|
273
|
+
// 'error' listener that unhandled emitter error crashes the daemon.
|
|
274
|
+
attachFsWatcherErrorHandler(this.watcher, log, pluginsDir);
|
|
270
275
|
log.info({ pluginsDir }, "Plugin source watcher started");
|
|
271
276
|
} catch (err) {
|
|
272
277
|
log.warn(
|
|
@@ -57,6 +57,7 @@ import {
|
|
|
57
57
|
loadSingleWorkspaceTool,
|
|
58
58
|
} from "../tools/workspace-tools/loader.js";
|
|
59
59
|
import { DebouncerMap } from "../util/debounce.js";
|
|
60
|
+
import { attachFsWatcherErrorHandler } from "../util/fs-watcher-error.js";
|
|
60
61
|
import { getLogger } from "../util/logger.js";
|
|
61
62
|
import { getWorkspaceToolsDir } from "../util/platform.js";
|
|
62
63
|
|
|
@@ -154,6 +155,9 @@ export class WorkspaceToolsWatcher {
|
|
|
154
155
|
});
|
|
155
156
|
},
|
|
156
157
|
);
|
|
158
|
+
// Async FSWatcher errors (e.g. ENOSPC, ENXIO) arrive as an 'error' event;
|
|
159
|
+
// without a listener they crash the daemon. Degrade to a dead watcher.
|
|
160
|
+
attachFsWatcherErrorHandler(this.watcher, log, toolsDir);
|
|
157
161
|
log.info({ toolsDir }, "Workspace tools watcher started");
|
|
158
162
|
} catch (err) {
|
|
159
163
|
log.warn(
|
|
@@ -88,6 +88,9 @@ const stubConfig: {
|
|
|
88
88
|
maxDailyRuns: number | null;
|
|
89
89
|
disposition: string;
|
|
90
90
|
};
|
|
91
|
+
timeouts: {
|
|
92
|
+
backgroundTurnTimeoutSec: number;
|
|
93
|
+
};
|
|
91
94
|
} = {
|
|
92
95
|
heartbeat: {
|
|
93
96
|
enabled: true,
|
|
@@ -98,6 +101,9 @@ const stubConfig: {
|
|
|
98
101
|
maxDailyRuns: null,
|
|
99
102
|
disposition: "Default disposition text.",
|
|
100
103
|
},
|
|
104
|
+
timeouts: {
|
|
105
|
+
backgroundTurnTimeoutSec: 1800,
|
|
106
|
+
},
|
|
101
107
|
};
|
|
102
108
|
mock.module("../../config/loader.js", () => ({
|
|
103
109
|
getConfig: () => stubConfig,
|
|
@@ -46,7 +46,6 @@ const DEFAULT_CHECKLIST = `- Check in with yourself. Read NOW.md. Is it still ac
|
|
|
46
46
|
|
|
47
47
|
const EARLY_HEARTBEAT_THRESHOLD = 3;
|
|
48
48
|
const REENGAGEMENT_COOLDOWN_MS = 18 * 60 * 60 * 1000; // 18 hours
|
|
49
|
-
const HEARTBEAT_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
|
|
50
49
|
|
|
51
50
|
// Stripped-comment form of the guardian persona scaffold. Computed
|
|
52
51
|
// once at module load because stripping comment lines is deterministic
|
|
@@ -774,8 +773,8 @@ export class HeartbeatService {
|
|
|
774
773
|
//
|
|
775
774
|
// The runner fires `onConversationCreated` synchronously after
|
|
776
775
|
// bootstrap so the macOS sidebar gets the new conversation
|
|
777
|
-
// immediately rather than waiting up to
|
|
778
|
-
// the LLM turn to finish. If the model judges the run worth
|
|
776
|
+
// immediately rather than waiting up to the full background-turn timeout
|
|
777
|
+
// for the LLM turn to finish. If the model judges the run worth
|
|
779
778
|
// surfacing to the guardian, it calls the `notifications` skill
|
|
780
779
|
// directly — no in-band marker.
|
|
781
780
|
let conversationId: string | undefined;
|
|
@@ -789,7 +788,7 @@ export class HeartbeatService {
|
|
|
789
788
|
trustClass: "guardian",
|
|
790
789
|
},
|
|
791
790
|
callSite: "heartbeatAgent",
|
|
792
|
-
timeoutMs:
|
|
791
|
+
timeoutMs: getConfig().timeouts.backgroundTurnTimeoutSec * 1000,
|
|
793
792
|
origin: "heartbeat",
|
|
794
793
|
deferNotifications: true,
|
|
795
794
|
onConversationCreated: (newConversationId) => {
|
|
@@ -2,14 +2,12 @@
|
|
|
2
2
|
* Tests for `db-maintenance.ts` (orchestration) and the underlying
|
|
3
3
|
* `db-async-query.ts` abstraction.
|
|
4
4
|
*
|
|
5
|
-
* The contract this
|
|
6
|
-
* 1. `runDbMaintenance` runs `
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* 2. The subprocess actually shrinks the on-disk page count when
|
|
12
|
-
* there's reclaimable space.
|
|
5
|
+
* The contract this locks in:
|
|
6
|
+
* 1. `runDbMaintenance` runs `PRAGMA optimize` through the async
|
|
7
|
+
* abstraction and then truncates the WAL on the daemon connection.
|
|
8
|
+
* It intentionally does NOT run a full VACUUM (which in WAL mode
|
|
9
|
+
* inflates the WAL to ~the DB size and needs ~2x the DB size free).
|
|
10
|
+
* 2. The truncating checkpoint shrinks the WAL file once it has grown.
|
|
13
11
|
* 3. `maybeRunDbMaintenance` is genuinely async — callers can `await`
|
|
14
12
|
* it and observe completion.
|
|
15
13
|
* 4. The 24 h interval guard short-circuits a recent re-run.
|
|
@@ -17,7 +15,7 @@
|
|
|
17
15
|
* The per-file temp workspace is set up by `test-preload.ts`; tests just
|
|
18
16
|
* dynamic-import the DB modules so they resolve paths under that temp dir.
|
|
19
17
|
*/
|
|
20
|
-
import {
|
|
18
|
+
import { existsSync, statSync } from "node:fs";
|
|
21
19
|
import { beforeEach, describe, expect, test } from "bun:test";
|
|
22
20
|
|
|
23
21
|
const { getSqlite } = await import("../db-connection.js");
|
|
@@ -56,8 +54,9 @@ function insertMessage(role: "user" | "assistant", createdAt: number): void {
|
|
|
56
54
|
.run(`msg-${createdAt}-${role}`, convId, role, "[]", createdAt);
|
|
57
55
|
}
|
|
58
56
|
|
|
59
|
-
/**
|
|
60
|
-
|
|
57
|
+
/** Pile writes into the WAL (without checkpointing) so a truncating
|
|
58
|
+
* checkpoint has measurable work to do. */
|
|
59
|
+
function inflateWal(byteTarget: number): void {
|
|
61
60
|
const sqlite = getSqlite();
|
|
62
61
|
sqlite.exec(
|
|
63
62
|
"CREATE TABLE IF NOT EXISTS bloat (id INTEGER PRIMARY KEY, payload BLOB)",
|
|
@@ -72,11 +71,10 @@ function inflateAndDelete(byteTarget: number): void {
|
|
|
72
71
|
for (let i = 0; i < rowsTarget; i++) {
|
|
73
72
|
insert.run(payload);
|
|
74
73
|
}
|
|
74
|
+
// Commit, but do not checkpoint — the frames stay in the WAL, leaving it at
|
|
75
|
+
// its high-water mark (a PASSIVE auto-checkpoint resets the WAL for reuse but
|
|
76
|
+
// never shrinks the file). Maintenance is what should truncate it.
|
|
75
77
|
sqlite.exec("COMMIT");
|
|
76
|
-
sqlite.exec("DELETE FROM bloat");
|
|
77
|
-
sqlite.exec("DROP TABLE bloat");
|
|
78
|
-
// Force the WAL onto the main DB file so the bloat is visible on disk.
|
|
79
|
-
sqlite.exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
80
78
|
}
|
|
81
79
|
|
|
82
80
|
describe("maybeRunDbMaintenance", () => {
|
|
@@ -106,36 +104,30 @@ describe("maybeRunDbMaintenance", () => {
|
|
|
106
104
|
expect(getMemoryCheckpoint(MAINTENANCE_CHECKPOINT_KEY)).toBe(String(now));
|
|
107
105
|
});
|
|
108
106
|
|
|
109
|
-
test("
|
|
107
|
+
test("truncates a bloated WAL", async () => {
|
|
110
108
|
const sqlite = getSqlite();
|
|
111
109
|
sqlite.exec("DROP TABLE IF EXISTS bloat");
|
|
112
110
|
sqlite.exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
113
111
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
try {
|
|
122
|
-
return (
|
|
123
|
-
probe.query("PRAGMA page_count").get() as { page_count: number }
|
|
124
|
-
).page_count;
|
|
125
|
-
} finally {
|
|
126
|
-
probe.close();
|
|
127
|
-
}
|
|
128
|
-
};
|
|
129
|
-
const pagesBefore = readPageCount();
|
|
112
|
+
const walPath = `${getDbPath()}-wal`;
|
|
113
|
+
const walSize = (): number =>
|
|
114
|
+
existsSync(walPath) ? statSync(walPath).size : 0;
|
|
115
|
+
|
|
116
|
+
inflateWal(4 * 1024 * 1024);
|
|
117
|
+
// Sanity: the writes really did grow the WAL on disk.
|
|
118
|
+
expect(walSize()).toBeGreaterThan(1024 * 1024);
|
|
130
119
|
|
|
131
120
|
await maybeRunDbMaintenance();
|
|
132
121
|
|
|
133
|
-
|
|
134
|
-
|
|
122
|
+
// Maintenance truncates the WAL back down (no VACUUM, so the data is folded
|
|
123
|
+
// into the main file rather than rebuilt).
|
|
124
|
+
expect(walSize()).toBeLessThan(64 * 1024);
|
|
125
|
+
|
|
126
|
+
sqlite.exec("DROP TABLE IF EXISTS bloat");
|
|
135
127
|
}, 60_000);
|
|
136
128
|
|
|
137
129
|
test("defers maintenance while the last user message is within the quiet period", async () => {
|
|
138
|
-
/**
|
|
130
|
+
/** Maintenance must not fire while the user is active, so a recent user
|
|
139
131
|
* message keeps maintenance deferred. */
|
|
140
132
|
// GIVEN the user sent a message one minute ago (well within the quiet period)
|
|
141
133
|
const now = Date.now();
|
|
@@ -37,6 +37,7 @@ import { publishSyncInvalidation } from "../runtime/sync/sync-publisher.js";
|
|
|
37
37
|
import { UserError } from "../util/errors.js";
|
|
38
38
|
import { safeParseRecord } from "../util/json.js";
|
|
39
39
|
import { getLogger } from "../util/logger.js";
|
|
40
|
+
import { getLogsDbPath } from "../util/logs-db-path.js";
|
|
40
41
|
import { getConversationsDir } from "../util/platform.js";
|
|
41
42
|
import { createRowMapper } from "../util/row-mapper.js";
|
|
42
43
|
import {
|
|
@@ -2140,8 +2141,11 @@ export async function clearAll(): Promise<{
|
|
|
2140
2141
|
// Each DELETE goes through `runAsyncSqlite`. The original code threw
|
|
2141
2142
|
// on rawExec failure; mirror that here by throwing when the async
|
|
2142
2143
|
// result reports `ok: false`, so the route handler still returns 500.
|
|
2143
|
-
const runOrThrow = async (
|
|
2144
|
-
|
|
2144
|
+
const runOrThrow = async (
|
|
2145
|
+
sql: string,
|
|
2146
|
+
options?: { dbPath?: string },
|
|
2147
|
+
): Promise<void> => {
|
|
2148
|
+
const result = await runAsyncSqlite(sql, options);
|
|
2145
2149
|
if (!result.ok) {
|
|
2146
2150
|
throw new Error(
|
|
2147
2151
|
`clearAll: \`${sql}\` failed (${result.backend}): ${result.error ?? "unknown"}`,
|
|
@@ -2163,7 +2167,9 @@ export async function clearAll(): Promise<{
|
|
|
2163
2167
|
await runOrThrow("DELETE FROM memory_embeddings");
|
|
2164
2168
|
await runOrThrow("DELETE FROM memory_jobs");
|
|
2165
2169
|
await runOrThrow("DELETE FROM memory_checkpoints");
|
|
2166
|
-
|
|
2170
|
+
// llm_request_logs lives in the attached logs database; point the sqlite3
|
|
2171
|
+
// subprocess at that file (the in-process fallback resolves it via ATTACH).
|
|
2172
|
+
await runOrThrow("DELETE FROM llm_request_logs", { dbPath: getLogsDbPath() });
|
|
2167
2173
|
await runOrThrow("DELETE FROM llm_usage_events");
|
|
2168
2174
|
await runOrThrow("DELETE FROM message_attachments");
|
|
2169
2175
|
await runOrThrow("DELETE FROM attachments");
|