@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
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
|
-
//
|
|
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
|
|
|
@@ -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
|
-
|
|
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 (
|
|
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) => {
|