@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.
Files changed (61) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/agent-loop-callsite-precedence.test.ts +1 -40
  3. package/src/__tests__/agent-wake-override-profile.test.ts +2 -0
  4. package/src/__tests__/app-source-watcher.test.ts +30 -10
  5. package/src/__tests__/config-schema.test.ts +34 -0
  6. package/src/__tests__/conversation-agent-loop-disk-pressure.test.ts +3 -0
  7. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +3 -0
  8. package/src/__tests__/conversation-agent-loop-overflow.test.ts +3 -0
  9. package/src/__tests__/conversation-agent-loop.test.ts +3 -0
  10. package/src/__tests__/conversation-process-callsite.test.ts +0 -14
  11. package/src/__tests__/db-llm-request-log-provider-migration.test.ts +6 -1
  12. package/src/__tests__/db-migration-rollback.test.ts +69 -1
  13. package/src/__tests__/heartbeat-disk-pressure.test.ts +3 -0
  14. package/src/__tests__/heartbeat-service.test.ts +6 -0
  15. package/src/__tests__/list-messages-attachments.test.ts +41 -0
  16. package/src/__tests__/plugin-source-watcher.test.ts +33 -1
  17. package/src/__tests__/usage-cache-backfill-migration.test.ts +17 -2
  18. package/src/acp/__tests__/session-manager.test.ts +72 -1
  19. package/src/acp/index.ts +10 -0
  20. package/src/acp/session-manager.ts +35 -0
  21. package/src/agent/loop.ts +28 -22
  22. package/src/cli/commands/db/status.ts +35 -1
  23. package/src/config/schemas/memory-lifecycle.ts +5 -3
  24. package/src/config/schemas/timeouts.ts +24 -0
  25. package/src/daemon/app-source-watcher.ts +31 -18
  26. package/src/daemon/conversation-agent-loop.ts +8 -5
  27. package/src/daemon/conversation.ts +30 -41
  28. package/src/daemon/handlers/conversations.ts +7 -0
  29. package/src/daemon/plugin-source-watcher.ts +5 -0
  30. package/src/daemon/workspace-tools-watcher.ts +4 -0
  31. package/src/heartbeat/__tests__/heartbeat-service.test.ts +6 -0
  32. package/src/heartbeat/heartbeat-service.ts +3 -4
  33. package/src/memory/__tests__/db-maintenance.test.ts +27 -35
  34. package/src/memory/__tests__/db-memory-attach.test.ts +126 -0
  35. package/src/memory/conversation-crud.ts +9 -3
  36. package/src/memory/db-connection.ts +49 -0
  37. package/src/memory/db-init.ts +50 -14
  38. package/src/memory/db-maintenance.ts +43 -38
  39. package/src/memory/job-handlers/cleanup.ts +6 -0
  40. package/src/memory/migrations/{100-core-tables.ts → 000-core-tables.ts} +6 -10
  41. package/src/memory/migrations/103-complex-migrations.ts +13 -12
  42. package/src/memory/migrations/104-core-indexes.ts +1 -1
  43. package/src/memory/migrations/113-late-migrations.ts +16 -14
  44. package/src/memory/migrations/297-move-llm-request-logs-to-logs-db.ts +130 -0
  45. package/src/memory/migrations/__tests__/297-move-llm-request-logs.test.ts +159 -0
  46. package/src/memory/migrations/__tests__/run-migrations.test.ts +147 -5
  47. package/src/memory/migrations/index.ts +5 -4
  48. package/src/memory/migrations/run-migrations.ts +162 -12
  49. package/src/memory/migrations/validate-migration-state.ts +14 -56
  50. package/src/plugin-api/index.ts +7 -0
  51. package/src/plugin-api/vision-support.ts +75 -0
  52. package/src/prompts/system-prompt.ts +1 -1
  53. package/src/runtime/__tests__/agent-wake.test.ts +6 -4
  54. package/src/runtime/agent-wake.ts +15 -7
  55. package/src/runtime/routes/conversation-routes.ts +24 -3
  56. package/src/runtime/routes/migration-routes.ts +35 -39
  57. package/src/schedule/scheduler.ts +5 -9
  58. package/src/tools/ask-question/ask-question-tool.test.ts +60 -52
  59. package/src/tools/ask-question/ask-question-tool.ts +14 -73
  60. package/src/util/fs-watcher-error.ts +36 -0
  61. package/src/util/memory-db-path.ts +23 -0
package/src/acp/index.ts CHANGED
@@ -18,6 +18,16 @@ export function getAcpSessionManager(): AcpSessionManager {
18
18
  return manager;
19
19
  }
20
20
 
21
+ /**
22
+ * Returns the existing AcpSessionManager singleton, or null when none has been
23
+ * created yet. Use this on cleanup hot paths (e.g. cancelling a conversation)
24
+ * that must not spin up a manager just to discover there are no sessions to
25
+ * act on.
26
+ */
27
+ export function peekAcpSessionManager(): AcpSessionManager | null {
28
+ return manager;
29
+ }
30
+
21
31
  /**
22
32
  * Disposes the singleton AcpSessionManager and nulls the reference.
23
33
  */
@@ -691,6 +691,41 @@ export class AcpSessionManager {
691
691
  }
692
692
  }
693
693
 
694
+ /**
695
+ * Cancels every in-flight session spawned by `parentConversationId`.
696
+ *
697
+ * Mirrors the subagent manager's `abortAllForParent`: when the user cancels
698
+ * a turn, the ACP agents it launched should stop rather than keep running in
699
+ * the background — holding a child process — and then, on completion, enqueue
700
+ * a follow-up message into the conversation the user just stopped. Cancelling
701
+ * settles each in-flight prompt down its `"cancelled"` path, which sends a
702
+ * client event but does NOT notify the parent, so no model activity follows
703
+ * the stop.
704
+ *
705
+ * Each session's `cancel()` runs detached (it awaits a protocol notification
706
+ * to the child) so callers on the cancel hot path never block on an
707
+ * unresponsive agent; failures are logged. Session ids are snapshotted before
708
+ * dispatch so concurrent teardown can't disturb the iteration. Returns the
709
+ * number of sessions a cancel was kicked off for.
710
+ */
711
+ cancelForParent(parentConversationId: string): number {
712
+ const ids: string[] = [];
713
+ for (const [acpSessionId, entry] of this.sessions) {
714
+ if (entry.parentConversationId === parentConversationId) {
715
+ ids.push(acpSessionId);
716
+ }
717
+ }
718
+ for (const acpSessionId of ids) {
719
+ void this.cancel(acpSessionId).catch((err) => {
720
+ log.warn(
721
+ { acpSessionId, parentConversationId, err },
722
+ "Failed to cancel ACP session on parent cancel",
723
+ );
724
+ });
725
+ }
726
+ return ids.length;
727
+ }
728
+
694
729
  /**
695
730
  * Kills the agent process and removes the session from tracking.
696
731
  *
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
- // Resolve system prompt, per-turn maxTokens, and model
1222
- const resolved = this.resolveSystemPrompt
1223
- ? this.resolveSystemPrompt(history)
1224
- : null;
1225
- const turnSystemPrompt = resolved?.systemPrompt ?? this.systemPrompt;
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-turn explicit (`resolved.maxTokens` / `resolved.model`)
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 (resolved?.maxTokens !== undefined) {
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
 
@@ -29,6 +29,7 @@ import { Database } from "bun:sqlite";
29
29
  import type { Command } from "commander";
30
30
 
31
31
  import { getLogsDbPath } from "../../../util/logs-db-path.js";
32
+ import { getMemoryDbPath } from "../../../util/memory-db-path.js";
32
33
  import { getDbPath } from "../../../util/platform.js";
33
34
  import { red } from "../../lib/cli-colors.js";
34
35
  import { shouldOutputJson, writeOutput } from "../../output.js";
@@ -83,6 +84,14 @@ interface StatusReport {
83
84
  */
84
85
  logsFile?: FileFacts;
85
86
  logsDb?: DbFacts | null;
87
+ /**
88
+ * The secondary memory database (`assistant-memory.db`). Like `logsFile`, it
89
+ * is omitted from the report until the file exists on disk — it is created
90
+ * the first time the daemon opens (and ATTACHes) the DB, so a fresh install
91
+ * that has never run the daemon won't have one.
92
+ */
93
+ memoryFile?: FileFacts;
94
+ memoryDb?: DbFacts | null;
86
95
  }
87
96
 
88
97
  // ---------------------------------------------------------------------------
@@ -320,6 +329,13 @@ function renderHuman(report: StatusReport): string {
320
329
  out += renderDbBlock(report.logsFile, report.logsDb ?? null);
321
330
  }
322
331
 
332
+ // The secondary memory file is likewise only reported once it exists.
333
+ if (report.memoryFile?.exists) {
334
+ out += "\n";
335
+ out += "Memory database (high-churn memory tables)\n";
336
+ out += renderDbBlock(report.memoryFile, report.memoryDb ?? null);
337
+ }
338
+
323
339
  return out;
324
340
  }
325
341
 
@@ -390,7 +406,25 @@ export function registerDbStatus(parent: Command): void {
390
406
  }
391
407
  }
392
408
 
393
- const report: StatusReport = { file, db, logsFile, logsDb };
409
+ // Same best-effort probe for the secondary memory DB.
410
+ const memoryFile = readFileFacts(getMemoryDbPath());
411
+ let memoryDb: DbFacts | null = null;
412
+ if (memoryFile.exists) {
413
+ try {
414
+ memoryDb = readDbFacts(memoryFile.path);
415
+ } catch {
416
+ memoryDb = null;
417
+ }
418
+ }
419
+
420
+ const report: StatusReport = {
421
+ file,
422
+ db,
423
+ logsFile,
424
+ logsDb,
425
+ memoryFile,
426
+ memoryDb,
427
+ };
394
428
  if (shouldOutputJson(this)) {
395
429
  writeOutput(this, report);
396
430
  } else {
@@ -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 (VACUUM / PRAGMA optimize) runs, in milliseconds",
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 the VACUUM's exclusive lock never collides with an active user (0 disables the quiet-period gate)",
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("Database maintenance (VACUUM / PRAGMA optimize) scheduling");
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" || innerPath.startsWith("records/") ||
55
- innerPath === "dist" || innerPath.startsWith("dist/")
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("Could not resolve apps directory; app source watching disabled");
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(appsDir, { recursive: true }, (_eventType, filename) => {
106
- if (!filename) return;
107
-
108
- const appId = resolveAppIdFromRelPath(filename);
109
- if (!appId) return;
110
-
111
- this.debouncer.schedule(`app:${appId}`, () => {
112
- onChange(appId);
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({ err }, "Failed to watch apps directory; source watching disabled");
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
- * Generous backstop that drives an aborted turn to its `finally` even if some
194
- * awaited operation fails to observe the abort signal.
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, so the timeout is deliberately
203
- * generous; in the common case abort settles in-flight work well before it.
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 = 45_000;
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, ResolvedSystemPrompt } from "../agent/loop.js";
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
- // If an explicit modelOverride is supplied, use it verbatim. Otherwise
670
- // leave the model unset and let `RetryProvider`'s call-site resolver pick
671
- // it up from `llm.default` / `llm.callSites.<id>` on every turn.
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: () => resolveSystemPromptCallback([]).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.hasSystemPromptOverride
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 HEARTBEAT_TIMEOUT_MS for
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: HEARTBEAT_TIMEOUT_MS,
791
+ timeoutMs: getConfig().timeouts.backgroundTurnTimeoutSec * 1000,
793
792
  origin: "heartbeat",
794
793
  deferNotifications: true,
795
794
  onConversationCreated: (newConversationId) => {