negotium 0.6.13 → 0.6.17
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/dist/agent-helpers.js +760 -142
- package/dist/agent-helpers.js.map +13 -12
- package/dist/{chunk-sr48tc2c.js → chunk-ax6tqx0s.js} +2 -2
- package/dist/{chunk-sr48tc2c.js.map → chunk-ax6tqx0s.js.map} +2 -2
- package/dist/hosted-agent.js +64 -3
- package/dist/hosted-agent.js.map +4 -4
- package/dist/main.js +783 -297
- package/dist/main.js.map +18 -17
- package/dist/mcp-factories.js +850 -261
- package/dist/mcp-factories.js.map +13 -12
- package/dist/registry.js +1 -1
- package/dist/runtime/cron/background-sessions.ts +5 -2
- package/dist/runtime/src/agents/archiver.ts +8 -5
- package/dist/runtime/src/agents/claude-provider.ts +88 -1
- package/dist/runtime/src/agents/idle-compact.ts +178 -0
- package/dist/runtime/src/node-host.ts +8 -1
- package/dist/runtime/src/runtime/background-sessions.ts +21 -8
- package/dist/runtime/src/runtime/turn-event-stream.ts +54 -1
- package/dist/runtime/src/runtime/turn-runner.ts +63 -0
- package/dist/runtime/src/topics/lifecycle.ts +2 -0
- package/dist/runtime/src/topics/session.ts +60 -3
- package/dist/runtime/src/version.ts +1 -1
- package/dist/types/packages/core/src/agents/archiver.d.ts +2 -2
- package/dist/types/packages/core/src/agents/idle-compact.d.ts +28 -0
- package/dist/types/packages/core/src/runtime/background-sessions.d.ts +10 -2
- package/dist/types/packages/core/src/runtime/turn-event-stream.d.ts +14 -0
- package/dist/types/packages/core/src/runtime/turn-runner.d.ts +2 -0
- package/dist/types/packages/core/src/topics/session.d.ts +11 -0
- package/dist/types/packages/core/src/version.d.ts +1 -1
- package/dist/types/packages/module-cron/src/background-sessions.d.ts +1 -1
- package/package.json +1 -1
package/dist/registry.js
CHANGED
|
@@ -133,7 +133,7 @@ var codexRegistryOperations = {
|
|
|
133
133
|
return { sessionId: threadId, rolloutPath };
|
|
134
134
|
},
|
|
135
135
|
async forkSession({ parentSessionId }) {
|
|
136
|
-
const { forkCodexSession } = await import("./chunk-
|
|
136
|
+
const { forkCodexSession } = await import("./chunk-ax6tqx0s.js");
|
|
137
137
|
return await forkCodexSession(parentSessionId);
|
|
138
138
|
},
|
|
139
139
|
async cleanupRollouts({ sessionIds }) {
|
|
@@ -27,11 +27,14 @@ function latestRun(jobs: CronJobRecord[]): DisplayRun | undefined {
|
|
|
27
27
|
.sort((left, right) => right.run.scheduledAt.localeCompare(left.run.scheduledAt))[0];
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
export function listCronBackgroundSessions(
|
|
30
|
+
export function listCronBackgroundSessions(
|
|
31
|
+
userId: string,
|
|
32
|
+
allUsers = false,
|
|
33
|
+
): BackgroundSessionDto[] {
|
|
31
34
|
const jobsByTopic = new Map<string, CronJobRecord[]>();
|
|
32
35
|
for (const job of listCronJobs()) {
|
|
33
36
|
const topic = getTopic(job.topicId);
|
|
34
|
-
if (!topic || !isParticipant(topic, userId)) continue;
|
|
37
|
+
if (!topic || !(allUsers || isParticipant(topic, userId))) continue;
|
|
35
38
|
const jobs = jobsByTopic.get(job.topicId) ?? [];
|
|
36
39
|
jobs.push(job);
|
|
37
40
|
jobsByTopic.set(job.topicId, jobs);
|
|
@@ -149,7 +149,7 @@ export interface ArchiverHost {
|
|
|
149
149
|
|
|
150
150
|
export interface ArchiverRuntime {
|
|
151
151
|
runArchiverTurn(params: RunArchiverTurnParams): boolean;
|
|
152
|
-
listActiveMemoryArchiverSessions(userId: string): BackgroundSessionDto[];
|
|
152
|
+
listActiveMemoryArchiverSessions(userId: string, allUsers?: boolean): BackgroundSessionDto[];
|
|
153
153
|
}
|
|
154
154
|
|
|
155
155
|
export function createArchiverRuntime(host: ArchiverHost): ArchiverRuntime {
|
|
@@ -169,7 +169,7 @@ export function createArchiverRuntime(host: ArchiverHost): ArchiverRuntime {
|
|
|
169
169
|
}
|
|
170
170
|
};
|
|
171
171
|
|
|
172
|
-
const listSessions = (userId: string): BackgroundSessionDto[] => {
|
|
172
|
+
const listSessions = (userId: string, allUsers = false): BackgroundSessionDto[] => {
|
|
173
173
|
const now = host.config.now().getTime();
|
|
174
174
|
for (const [id, session] of activeSessions) {
|
|
175
175
|
if (session.expiresAt !== undefined && session.expiresAt <= now) {
|
|
@@ -180,7 +180,7 @@ export function createArchiverRuntime(host: ArchiverHost): ArchiverRuntime {
|
|
|
180
180
|
}
|
|
181
181
|
}
|
|
182
182
|
return [...activeSessions.values()]
|
|
183
|
-
.filter((session) => session.userId === userId)
|
|
183
|
+
.filter((session) => allUsers || session.userId === userId)
|
|
184
184
|
.map(({ userId: _userId, expiresAt: _expiresAt, expiryTimer: _expiryTimer, ...session }) => ({
|
|
185
185
|
...session,
|
|
186
186
|
steps: [...session.steps],
|
|
@@ -660,6 +660,9 @@ export function runArchiverTurn(params: RunArchiverTurnParams): boolean {
|
|
|
660
660
|
return defaultArchiverRuntime.runArchiverTurn(params);
|
|
661
661
|
}
|
|
662
662
|
|
|
663
|
-
export function listActiveMemoryArchiverSessions(
|
|
664
|
-
|
|
663
|
+
export function listActiveMemoryArchiverSessions(
|
|
664
|
+
userId: string,
|
|
665
|
+
allUsers = false,
|
|
666
|
+
): BackgroundSessionDto[] {
|
|
667
|
+
return defaultArchiverRuntime.listActiveMemoryArchiverSessions(userId, allUsers);
|
|
665
668
|
}
|
|
@@ -43,6 +43,11 @@ const CLAUDE_DEFAULT_DISALLOWED_TOOLS = [
|
|
|
43
43
|
"TaskUpdate",
|
|
44
44
|
"TaskList",
|
|
45
45
|
"TaskGet",
|
|
46
|
+
// Monitor spawns a long-running background watcher tied to the CLI's own
|
|
47
|
+
// interactive session/notification loop, which our headless spawned turns
|
|
48
|
+
// don't have — it doesn't reliably deliver events back into a Negotium
|
|
49
|
+
// topic. Use the background-bash MCP tools (run/watch) instead.
|
|
50
|
+
"Monitor",
|
|
46
51
|
// Native scheduling/cron tools duplicate Negotium's own runtime MCP surface
|
|
47
52
|
// (`mcp__runtime__schedule_self`, the `cron-manager` server) and bypass its
|
|
48
53
|
// persistence/host wiring if used directly.
|
|
@@ -215,6 +220,48 @@ type SpawnClaudeCodeResult = ReturnType<SpawnClaudeCodeProcess>;
|
|
|
215
220
|
|
|
216
221
|
const CLAUDE_ABORT_SIGKILL_DELAY_MS = 2500;
|
|
217
222
|
|
|
223
|
+
type ClaudeProcessExit = { code: number | null; signal: NodeJS.Signals | null };
|
|
224
|
+
|
|
225
|
+
// The Agent SDK can keep its stdout reader open for several minutes after its
|
|
226
|
+
// CLI child has gone away (CLAUDE_CODE_STREAM_CLOSE_TIMEOUT defaults to five
|
|
227
|
+
// minutes in our host environment). Keep a per-query hook so the provider
|
|
228
|
+
// can abort that stuck reader and emit a normal terminal error instead of
|
|
229
|
+
// leaving the room lease occupied indefinitely.
|
|
230
|
+
const claudeProcessExitListeners = new WeakMap<
|
|
231
|
+
AbortSignal,
|
|
232
|
+
Set<(exit: ClaudeProcessExit) => void>
|
|
233
|
+
>();
|
|
234
|
+
|
|
235
|
+
function watchClaudeProcessExit(signal: AbortSignal): {
|
|
236
|
+
exited: Promise<ClaudeProcessExit>;
|
|
237
|
+
dispose: () => void;
|
|
238
|
+
} {
|
|
239
|
+
let listener: ((exit: ClaudeProcessExit) => void) | undefined;
|
|
240
|
+
const exited = new Promise<ClaudeProcessExit>((resolve) => {
|
|
241
|
+
listener = resolve;
|
|
242
|
+
const listeners = claudeProcessExitListeners.get(signal) ?? new Set();
|
|
243
|
+
listeners.add(listener);
|
|
244
|
+
claudeProcessExitListeners.set(signal, listeners);
|
|
245
|
+
});
|
|
246
|
+
return {
|
|
247
|
+
exited,
|
|
248
|
+
dispose: () => {
|
|
249
|
+
if (!listener) return;
|
|
250
|
+
const listeners = claudeProcessExitListeners.get(signal);
|
|
251
|
+
listeners?.delete(listener);
|
|
252
|
+
if (listeners?.size === 0) claudeProcessExitListeners.delete(signal);
|
|
253
|
+
listener = undefined;
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function notifyClaudeProcessExit(signal: AbortSignal, exit: ClaudeProcessExit): void {
|
|
259
|
+
const listeners = claudeProcessExitListeners.get(signal);
|
|
260
|
+
if (!listeners) return;
|
|
261
|
+
claudeProcessExitListeners.delete(signal);
|
|
262
|
+
for (const listener of listeners) listener(exit);
|
|
263
|
+
}
|
|
264
|
+
|
|
218
265
|
function signalProcessTree(pid: number, signal: NodeJS.Signals): boolean {
|
|
219
266
|
// Negative pid = "signal every process in this process group". Falls back
|
|
220
267
|
// to single-pid signaling if the group call fails (e.g. on platforms where
|
|
@@ -285,6 +332,7 @@ export function spawnClaudeCodeProcessWithTreeKill(
|
|
|
285
332
|
clearKillTimer();
|
|
286
333
|
options.signal.removeEventListener("abort", onAbort);
|
|
287
334
|
logger.debug({ pid: child.pid, code, signal }, "Claude Code process exited");
|
|
335
|
+
notifyClaudeProcessExit(options.signal, { code, signal });
|
|
288
336
|
});
|
|
289
337
|
|
|
290
338
|
// Consume the child's 'error' event. Without a listener, a spawn-level error
|
|
@@ -304,6 +352,7 @@ export function spawnClaudeCodeProcessWithTreeKill(
|
|
|
304
352
|
},
|
|
305
353
|
"Claude Code process error event",
|
|
306
354
|
);
|
|
355
|
+
notifyClaudeProcessExit(options.signal, { code: null, signal: null });
|
|
307
356
|
});
|
|
308
357
|
|
|
309
358
|
return {
|
|
@@ -380,6 +429,22 @@ export async function* claudeProvider(opts: AgentQueryOptions): AsyncGenerator<U
|
|
|
380
429
|
// spawns); the `settings` flag layer below covers it a second time.
|
|
381
430
|
cleanEnv.CLAUDE_CODE_DISABLE_WORKFLOWS = "1";
|
|
382
431
|
|
|
432
|
+
// Use a private signal for the SDK. A premature CLI exit must wake its
|
|
433
|
+
// reader, but must not look like a user-initiated abort to the turn runner.
|
|
434
|
+
const sdkAbortController = new AbortController();
|
|
435
|
+
const onCallerAbort = () => sdkAbortController.abort();
|
|
436
|
+
if (opts.abortController?.signal.aborted) sdkAbortController.abort();
|
|
437
|
+
else opts.abortController?.signal.addEventListener("abort", onCallerAbort, { once: true });
|
|
438
|
+
const processExitWatch = watchClaudeProcessExit(sdkAbortController.signal);
|
|
439
|
+
let unexpectedProcessExit: ClaudeProcessExit | undefined;
|
|
440
|
+
void processExitWatch.exited.then((exit) => {
|
|
441
|
+
// An exit caused by an explicit stop/supersede is expected. Its caller
|
|
442
|
+
// signal is the authoritative distinction from a CLI crash.
|
|
443
|
+
if (opts.abortController?.signal.aborted) return;
|
|
444
|
+
unexpectedProcessExit = exit;
|
|
445
|
+
sdkAbortController.abort();
|
|
446
|
+
});
|
|
447
|
+
|
|
383
448
|
const queryOptions: Options = {
|
|
384
449
|
...(claudeExecutable ? { pathToClaudeCodeExecutable: claudeExecutable } : {}),
|
|
385
450
|
spawnClaudeCodeProcess: spawnClaudeCodeProcessWithTreeKill,
|
|
@@ -390,7 +455,7 @@ export async function* claudeProvider(opts: AgentQueryOptions): AsyncGenerator<U
|
|
|
390
455
|
env: cleanEnv,
|
|
391
456
|
mcpServers: hostedMcpServers(opts) as Options["mcpServers"],
|
|
392
457
|
...(claudeBuiltInTools(opts) ? { tools: claudeBuiltInTools(opts) } : {}),
|
|
393
|
-
abortController:
|
|
458
|
+
abortController: sdkAbortController,
|
|
394
459
|
// `AskUserQuestion` is Claude Code's built-in clarification tool, but it
|
|
395
460
|
// expects the SDK CLI to render a TUI prompt and read stdin for the
|
|
396
461
|
// answer — neither exists in this headless Telegram bridge. Without
|
|
@@ -746,12 +811,34 @@ export async function* claudeProvider(opts: AgentQueryOptions): AsyncGenerator<U
|
|
|
746
811
|
}
|
|
747
812
|
}
|
|
748
813
|
}
|
|
814
|
+
if (unexpectedProcessExit) {
|
|
815
|
+
const detail = unexpectedProcessExit.signal
|
|
816
|
+
? `signal ${unexpectedProcessExit.signal}`
|
|
817
|
+
: unexpectedProcessExit.code === null
|
|
818
|
+
? "before it could start"
|
|
819
|
+
: `exit code ${unexpectedProcessExit.code}`;
|
|
820
|
+
logger.error({ detail }, "claudeProvider: CLI exited before terminal SDK event");
|
|
821
|
+
yield { type: "error", content: `Claude CLI exited unexpectedly (${detail}).` };
|
|
822
|
+
}
|
|
749
823
|
} catch (e) {
|
|
750
824
|
// Abort = user moved on; don't surface as an error message. The handler
|
|
751
825
|
// catches the abort signal separately via abortReason, so swallowing
|
|
752
826
|
// here matches both maestro and codex behavior.
|
|
827
|
+
if (unexpectedProcessExit) {
|
|
828
|
+
const detail = unexpectedProcessExit.signal
|
|
829
|
+
? `signal ${unexpectedProcessExit.signal}`
|
|
830
|
+
: unexpectedProcessExit.code === null
|
|
831
|
+
? "before it could start"
|
|
832
|
+
: `exit code ${unexpectedProcessExit.code}`;
|
|
833
|
+
logger.error({ err: e, detail }, "claudeProvider: CLI exited before terminal SDK event");
|
|
834
|
+
yield { type: "error", content: `Claude CLI exited unexpectedly (${detail}).` };
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
753
837
|
if (isAbortError(e) || opts.abortController?.signal.aborted) return;
|
|
754
838
|
logger.error({ err: e }, "claudeProvider: SDK iteration failed");
|
|
755
839
|
yield { type: "error", content: errMsg(e) };
|
|
840
|
+
} finally {
|
|
841
|
+
processExitWatch.dispose();
|
|
842
|
+
opts.abortController?.signal.removeEventListener("abort", onCallerAbort);
|
|
756
843
|
}
|
|
757
844
|
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { logger } from "#platform/logger";
|
|
2
|
+
import { getRoomQuery } from "#query/active-rooms";
|
|
3
|
+
import { getTopic } from "#storage/api-topics";
|
|
4
|
+
import { getRuntimeTurnLease } from "#storage/runtime-leases";
|
|
5
|
+
import { getTopicStats } from "#storage/token-stats";
|
|
6
|
+
import type { RestartTopicSessionResult } from "#topics/session";
|
|
7
|
+
|
|
8
|
+
const DEFAULT_IDLE_DELAY_MS = 6 * 60 * 60 * 1000;
|
|
9
|
+
// Mirrors the 80%-occupancy usage alert in `#runtime/turn-event-stream` at a
|
|
10
|
+
// lower bar: an idle topic gets auto-compacted once its *last known* context
|
|
11
|
+
// usage (as the provider itself reported it) has crossed the halfway mark.
|
|
12
|
+
const DEFAULT_MIN_CONTEXT_PERCENT = 50;
|
|
13
|
+
|
|
14
|
+
type IdleCompactStatus =
|
|
15
|
+
| "scheduled"
|
|
16
|
+
| "disabled"
|
|
17
|
+
| "busy"
|
|
18
|
+
| "topic-not-found"
|
|
19
|
+
| "not-ai-invited"
|
|
20
|
+
| "mention-only-channel"
|
|
21
|
+
| "no-owner"
|
|
22
|
+
| "below-threshold"
|
|
23
|
+
| "compacted"
|
|
24
|
+
| "failed";
|
|
25
|
+
|
|
26
|
+
export interface IdleCompactOptions {
|
|
27
|
+
isBusy?: (topicId: string) => boolean;
|
|
28
|
+
onBusy?: (topicId: string, userId: string) => void;
|
|
29
|
+
minContextPercent?: number;
|
|
30
|
+
/** Injected for tests; defaults to the real `getTopicStats`. */
|
|
31
|
+
getStats?: typeof getTopicStats;
|
|
32
|
+
/** Injected for tests; defaults to the real `compactTopicSession`. */
|
|
33
|
+
compact?: (topicId: string, userId: string, reason: string) => Promise<RestartTopicSessionResult>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
37
|
+
|
|
38
|
+
/** Cancel a pending idle-compact timer, e.g. before a topic is reset or deleted. */
|
|
39
|
+
export function cancelIdleCompactForTopic(topicId: string): boolean {
|
|
40
|
+
const timer = timers.get(topicId);
|
|
41
|
+
if (!timer) return false;
|
|
42
|
+
clearTimeout(timer);
|
|
43
|
+
timers.delete(topicId);
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function envFlagEnabled(name: string, fallback: boolean): boolean {
|
|
48
|
+
const raw = process.env[name]?.trim().toLowerCase();
|
|
49
|
+
if (!raw) return fallback;
|
|
50
|
+
return !["0", "false", "off", "no"].includes(raw);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function envPositiveInt(name: string, fallback: number): number {
|
|
54
|
+
const value = Number.parseInt(process.env[name] ?? "", 10);
|
|
55
|
+
return Number.isFinite(value) && value > 0 ? value : fallback;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function idleCompactDelayMs(): number {
|
|
59
|
+
return envPositiveInt("NEGOTIUM_IDLE_COMPACT_DELAY_MS", DEFAULT_IDLE_DELAY_MS);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Percentage (0–100) of the provider's context window an idle topic must reach before it is auto-compacted. */
|
|
63
|
+
export function idleCompactMinContextPercent(): number {
|
|
64
|
+
return envPositiveInt("NEGOTIUM_IDLE_COMPACT_MIN_CONTEXT_PERCENT", DEFAULT_MIN_CONTEXT_PERCENT);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function idleCompactEnabled(): boolean {
|
|
68
|
+
return envFlagEnabled("NEGOTIUM_IDLE_COMPACT_ENABLED", true);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Schedule a check, `idleCompactDelayMs()` after the topic's last turn, that
|
|
73
|
+
* compacts the session if (and only if) it is still idle and its context has
|
|
74
|
+
* grown past the percent-of-window threshold. Runs alongside the memory
|
|
75
|
+
* idle-archiver — this only trims what the model sees next turn; it does not
|
|
76
|
+
* touch memory.
|
|
77
|
+
*/
|
|
78
|
+
export function scheduleIdleCompactForTopic(topicId: string, userId: string): IdleCompactStatus {
|
|
79
|
+
if (!idleCompactEnabled()) return "disabled";
|
|
80
|
+
|
|
81
|
+
const topic = getTopic(topicId);
|
|
82
|
+
if (!topic) return "topic-not-found";
|
|
83
|
+
if (!topic.agent) return "not-ai-invited";
|
|
84
|
+
if (topic.aiMode === "mention") return "mention-only-channel";
|
|
85
|
+
|
|
86
|
+
const existing = timers.get(topicId);
|
|
87
|
+
if (existing) clearTimeout(existing);
|
|
88
|
+
|
|
89
|
+
const timer = setTimeout(() => {
|
|
90
|
+
timers.delete(topicId);
|
|
91
|
+
void runIdleCompactForTopic(topicId, userId);
|
|
92
|
+
}, idleCompactDelayMs());
|
|
93
|
+
timer.unref?.();
|
|
94
|
+
timers.set(topicId, timer);
|
|
95
|
+
return "scheduled";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function runIdleCompactForTopic(
|
|
99
|
+
topicId: string,
|
|
100
|
+
userId: string,
|
|
101
|
+
options: IdleCompactOptions = {},
|
|
102
|
+
): Promise<IdleCompactStatus> {
|
|
103
|
+
if (!idleCompactEnabled()) return "disabled";
|
|
104
|
+
|
|
105
|
+
const busy = options.isBusy
|
|
106
|
+
? options.isBusy(topicId)
|
|
107
|
+
: Boolean(getRoomQuery(topicId) || getRuntimeTurnLease(topicId));
|
|
108
|
+
if (busy) {
|
|
109
|
+
if (options.onBusy) options.onBusy(topicId, userId);
|
|
110
|
+
else scheduleIdleCompactForTopic(topicId, userId);
|
|
111
|
+
return "busy";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const topic = getTopic(topicId);
|
|
115
|
+
if (!topic) return "topic-not-found";
|
|
116
|
+
if (!topic.agent) return "not-ai-invited";
|
|
117
|
+
if (topic.aiMode === "mention") return "mention-only-channel";
|
|
118
|
+
|
|
119
|
+
const owner = topic.participants.find((participant) => participant.role === "owner")?.userId;
|
|
120
|
+
if (!owner) return "no-owner";
|
|
121
|
+
|
|
122
|
+
const stats = (options.getStats ?? getTopicStats)(owner, topicId);
|
|
123
|
+
const currentSession = stats.currentSession;
|
|
124
|
+
if (!currentSession || currentSession.contextWindow <= 0) {
|
|
125
|
+
logger.debug({ topicId }, "idle-compact: no provider-reported context usage yet, skipping");
|
|
126
|
+
return "below-threshold";
|
|
127
|
+
}
|
|
128
|
+
const percent = (currentSession.contextTokens / currentSession.contextWindow) * 100;
|
|
129
|
+
const minPercent = options.minContextPercent ?? idleCompactMinContextPercent();
|
|
130
|
+
if (percent < minPercent) {
|
|
131
|
+
logger.debug(
|
|
132
|
+
{ topicId, percent: Math.round(percent), minPercent },
|
|
133
|
+
"idle-compact: skipped below context-usage threshold",
|
|
134
|
+
);
|
|
135
|
+
return "below-threshold";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const compact =
|
|
139
|
+
options.compact ??
|
|
140
|
+
(async (id: string, actorId: string, reason: string) => {
|
|
141
|
+
// `preemptive: false`: idle-compact must never abort or cancel a turn
|
|
142
|
+
// already in flight, local or remote — only a human explicitly running
|
|
143
|
+
// `/compact` gets to interrupt work. See `compactTopicSession`.
|
|
144
|
+
const { compactTopicSession } = await import("#topics/session");
|
|
145
|
+
return compactTopicSession(id, actorId, reason, { preemptive: false });
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
const result = await compact(topicId, owner, "idle-compact");
|
|
150
|
+
if (result.busy) {
|
|
151
|
+
logger.debug(
|
|
152
|
+
{ topicId, percent: Math.round(percent) },
|
|
153
|
+
"idle-compact: topic became busy, rescheduling",
|
|
154
|
+
);
|
|
155
|
+
if (options.onBusy) options.onBusy(topicId, owner);
|
|
156
|
+
else scheduleIdleCompactForTopic(topicId, owner);
|
|
157
|
+
return "busy";
|
|
158
|
+
}
|
|
159
|
+
if (result.isError) {
|
|
160
|
+
logger.warn(
|
|
161
|
+
{ topicId, percent: Math.round(percent), text: result.text },
|
|
162
|
+
"idle-compact: failed",
|
|
163
|
+
);
|
|
164
|
+
return "failed";
|
|
165
|
+
}
|
|
166
|
+
logger.info(
|
|
167
|
+
{ topicId, percent: Math.round(percent) },
|
|
168
|
+
"idle-compact: compacted an idle topic's context",
|
|
169
|
+
);
|
|
170
|
+
return "compacted";
|
|
171
|
+
} catch (error) {
|
|
172
|
+
logger.warn(
|
|
173
|
+
{ err: error, topicId, percent: Math.round(percent) },
|
|
174
|
+
"idle-compact: unexpected failure while compacting an idle topic",
|
|
175
|
+
);
|
|
176
|
+
return "failed";
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -77,8 +77,15 @@ export {
|
|
|
77
77
|
getLastMessagePreviews,
|
|
78
78
|
listApiMessages,
|
|
79
79
|
listThreadMessages,
|
|
80
|
+
setApiMessageReactions,
|
|
80
81
|
softDeleteApiMessage,
|
|
82
|
+
updateApiMessageText,
|
|
81
83
|
} from "#storage/api-messages";
|
|
84
|
+
export {
|
|
85
|
+
getApiTopicConfig,
|
|
86
|
+
setApiTopicConfig,
|
|
87
|
+
type TopicConfig,
|
|
88
|
+
} from "#storage/api-topic-config";
|
|
82
89
|
export { getTopic, upsertTopic } from "#storage/api-topics";
|
|
83
90
|
export { getGlobalAiName, setGlobalAiName } from "#storage/app-settings";
|
|
84
91
|
export { readDecisions, writeDecisionGraphSvg } from "#storage/decisions";
|
|
@@ -103,6 +110,6 @@ export {
|
|
|
103
110
|
export { ensurePersonalGeneral } from "#topics/personal-general";
|
|
104
111
|
export { compactTopicSession } from "#topics/session";
|
|
105
112
|
export { TopicUpdateConflictError, updateTopicSettings } from "#topics/update";
|
|
106
|
-
export type { AgentKind } from "#types";
|
|
113
|
+
export type { AgentKind, EffortLevel } from "#types";
|
|
107
114
|
export type { AiMode, AttachmentDto, TopicDto, TopicSurface } from "#types/api";
|
|
108
115
|
export { NEGOTIUM_VERSION } from "#version";
|
|
@@ -6,7 +6,10 @@ import { listRuntimeTurnLeases } from "#storage/runtime-leases";
|
|
|
6
6
|
import type { BackgroundSessionDto } from "#types/api";
|
|
7
7
|
import { COMPLETED_BACKGROUND_SESSION_RETENTION_MS } from "./background-session-policy";
|
|
8
8
|
|
|
9
|
-
export type BackgroundSessionProvider = (
|
|
9
|
+
export type BackgroundSessionProvider = (
|
|
10
|
+
userId: string,
|
|
11
|
+
allUsers?: boolean,
|
|
12
|
+
) => BackgroundSessionDto[];
|
|
10
13
|
|
|
11
14
|
const providers = new Set<BackgroundSessionProvider>();
|
|
12
15
|
interface TransientBackgroundSessionRecord extends BackgroundSessionDto {
|
|
@@ -220,8 +223,19 @@ export function backgroundSessionProgress(
|
|
|
220
223
|
return { status, steps };
|
|
221
224
|
}
|
|
222
225
|
|
|
223
|
-
|
|
224
|
-
|
|
226
|
+
/**
|
|
227
|
+
* `allUsers` widens every section — memory archiver, compact/transient, and
|
|
228
|
+
* cron — past the caller's own participation. Meant for single-owner
|
|
229
|
+
* surfaces (terminal, Telegram) where "my view" and "everything on this
|
|
230
|
+
* node" are the same person anyway, so idle background upkeep (and compact,
|
|
231
|
+
* manual or idle-triggered) triggered under a different local userId is
|
|
232
|
+
* still visible.
|
|
233
|
+
*/
|
|
234
|
+
export function listBackgroundSessionsForUser(
|
|
235
|
+
userId: string,
|
|
236
|
+
allUsers = false,
|
|
237
|
+
): BackgroundSessionDto[] {
|
|
238
|
+
const memory = listActiveMemoryArchiverSessions(userId, allUsers);
|
|
225
239
|
const now = Date.now();
|
|
226
240
|
for (const [id, session] of transientSessions) {
|
|
227
241
|
if (session.expiresAt !== undefined && session.expiresAt <= now) {
|
|
@@ -230,12 +244,12 @@ export function listBackgroundSessionsForUser(userId: string): BackgroundSession
|
|
|
230
244
|
}
|
|
231
245
|
}
|
|
232
246
|
const transient = [...transientSessions.values()]
|
|
233
|
-
.filter((session) => session.userId === userId)
|
|
247
|
+
.filter((session) => allUsers || session.userId === userId)
|
|
234
248
|
.map(({ userId: _userId, expiresAt: _expiresAt, expiryTimer: _expiryTimer, ...session }) => ({
|
|
235
249
|
...session,
|
|
236
250
|
steps: [...session.steps],
|
|
237
251
|
}));
|
|
238
|
-
const provided = [...providers].flatMap((provider) => provider(userId));
|
|
252
|
+
const provided = [...providers].flatMap((provider) => provider(userId, allUsers));
|
|
239
253
|
const providedCronTopicIds = new Set(
|
|
240
254
|
provided
|
|
241
255
|
.filter((session) => session.kind === "cron")
|
|
@@ -246,9 +260,8 @@ export function listBackgroundSessionsForUser(userId: string): BackgroundSession
|
|
|
246
260
|
.filter((lease) => lease.origin.startsWith("cron:"))
|
|
247
261
|
.flatMap((lease): BackgroundSessionDto[] => {
|
|
248
262
|
const topic = getTopic(lease.topicId);
|
|
249
|
-
const isParticipant =
|
|
250
|
-
(participant) => participant.userId === userId
|
|
251
|
-
);
|
|
263
|
+
const isParticipant =
|
|
264
|
+
allUsers || topic?.participants.some((participant) => participant.userId === userId);
|
|
252
265
|
if (!topic || !isParticipant || providedCronTopicIds.has(lease.topicId)) {
|
|
253
266
|
return [];
|
|
254
267
|
}
|
|
@@ -4,6 +4,7 @@ import { isAbsolute, resolve } from "node:path";
|
|
|
4
4
|
import { estimateTextTokens } from "#agents/compaction-support";
|
|
5
5
|
import { cleanupAgentFork } from "#agents/fork";
|
|
6
6
|
import { scheduleIdleArchiveForTopic } from "#agents/idle-archiver";
|
|
7
|
+
import { scheduleIdleCompactForTopic } from "#agents/idle-compact";
|
|
7
8
|
import { cancelPendingAskUserQuestions } from "#agents/mcp-tools/ask-user";
|
|
8
9
|
import {
|
|
9
10
|
settleSubagentFailure,
|
|
@@ -83,7 +84,18 @@ export type StreamAgentOutcome =
|
|
|
83
84
|
| { kind: "aborted" }
|
|
84
85
|
| { kind: "budget-capped"; reason: "cost"; usage?: TokenUsage }
|
|
85
86
|
| { kind: "session-expired"; error: string }
|
|
86
|
-
| { kind: "provider-error"; error: string }
|
|
87
|
+
| { kind: "provider-error"; error: string }
|
|
88
|
+
/**
|
|
89
|
+
* The provider's terminal result carried no assistant text at all — the
|
|
90
|
+
* "silent turn" bug: a session-level race (e.g. a queued background-task
|
|
91
|
+
* notification flushing into the CLI session at the same instant a new
|
|
92
|
+
* user turn is dispatched) makes the underlying CLI self-interrupt within
|
|
93
|
+
* ~1-2s and report a trivial zero-usage "success". Distinct from
|
|
94
|
+
* provider-error so the caller can retry once before surfacing a failure —
|
|
95
|
+
* a manual resend has reliably produced a normal reply in every observed
|
|
96
|
+
* case.
|
|
97
|
+
*/
|
|
98
|
+
| { kind: "empty-response"; error: string };
|
|
87
99
|
|
|
88
100
|
export interface TurnEventStreamHooks {
|
|
89
101
|
appendSystemMessage: (topicId: string, text: string) => MessageDto;
|
|
@@ -146,6 +158,7 @@ export async function runTurnEventStream(
|
|
|
146
158
|
const peerBridge = execution?.peerBridge ?? control.injectParams?.peerBridge;
|
|
147
159
|
let errorOccurred = false;
|
|
148
160
|
let terminalEmitted = false;
|
|
161
|
+
const streamStartedAt = Date.now();
|
|
149
162
|
let lastEventType: UnifiedEvent["type"] | null = null;
|
|
150
163
|
let pendingSawDelta = false;
|
|
151
164
|
let accumulatedText = "";
|
|
@@ -632,6 +645,13 @@ export async function runTurnEventStream(
|
|
|
632
645
|
break;
|
|
633
646
|
case "result":
|
|
634
647
|
if (event.usage) recordEventUsage(event.usage);
|
|
648
|
+
// Most providers emit assistant text before the terminal result, but
|
|
649
|
+
// accept result-only providers too. Without this fallback a valid
|
|
650
|
+
// final payload was indistinguishable from the silent-turn bug.
|
|
651
|
+
if (!accumulatedText.trim() && event.content.trim()) {
|
|
652
|
+
accumulatedText = event.content;
|
|
653
|
+
pendingText = event.content;
|
|
654
|
+
}
|
|
635
655
|
{
|
|
636
656
|
const usage: MessageDto["usage"] = event.usage
|
|
637
657
|
? {
|
|
@@ -654,8 +674,26 @@ export async function runTurnEventStream(
|
|
|
654
674
|
}
|
|
655
675
|
}
|
|
656
676
|
}
|
|
677
|
+
if (!accumulatedText.trim()) {
|
|
678
|
+
const error = "Provider completed without an assistant response";
|
|
679
|
+
terminalEmitted = true;
|
|
680
|
+
// Silent turns (asks, subagent calls) delivered their error to the
|
|
681
|
+
// caller synchronously below and have no user-facing chat to
|
|
682
|
+
// retry into, so they skip the retry path the caller applies to
|
|
683
|
+
// "empty-response" and go straight to a reported provider-error.
|
|
684
|
+
outcome = silent
|
|
685
|
+
? { kind: "provider-error", error }
|
|
686
|
+
: { kind: "empty-response", error };
|
|
687
|
+
logger.warn(
|
|
688
|
+
{ topicId, queryId, agentType, model, silent },
|
|
689
|
+
"ai: provider completed without assistant text",
|
|
690
|
+
);
|
|
691
|
+
if (silent) void deliverAskError(queryId, topicTitle, error);
|
|
692
|
+
return outcome;
|
|
693
|
+
}
|
|
657
694
|
if (!silent) {
|
|
658
695
|
scheduleIdleArchiveForTopic(topicId, execution?.actorUserId ?? userId);
|
|
696
|
+
scheduleIdleCompactForTopic(topicId, execution?.actorUserId ?? userId);
|
|
659
697
|
hub.broadcastDone(
|
|
660
698
|
topicId,
|
|
661
699
|
queryId,
|
|
@@ -873,6 +911,7 @@ export async function runTurnEventStream(
|
|
|
873
911
|
const discardIncompleteSegments =
|
|
874
912
|
outcome.kind === "session-expired" ||
|
|
875
913
|
outcome.kind === "provider-error" ||
|
|
914
|
+
outcome.kind === "empty-response" ||
|
|
876
915
|
(!terminalEmitted && !abortController.signal.aborted);
|
|
877
916
|
if (discardIncompleteSegments) discardVisibleAssistantMessages();
|
|
878
917
|
// Release the room slot — but only if a newer turn hasn't already taken it
|
|
@@ -891,6 +930,7 @@ export async function runTurnEventStream(
|
|
|
891
930
|
if (
|
|
892
931
|
forkHandle &&
|
|
893
932
|
outcome.kind !== "session-expired" &&
|
|
933
|
+
outcome.kind !== "empty-response" &&
|
|
894
934
|
(!forkRequestId || !interSessionQueue.hasRequest(topicId, forkRequestId))
|
|
895
935
|
) {
|
|
896
936
|
cleanupAgentFork(forkHandle);
|
|
@@ -903,12 +943,25 @@ export async function runTurnEventStream(
|
|
|
903
943
|
if (
|
|
904
944
|
roomId === topicId &&
|
|
905
945
|
outcome.kind !== "session-expired" &&
|
|
946
|
+
outcome.kind !== "empty-response" &&
|
|
906
947
|
!getRoomQuery(topicId) &&
|
|
907
948
|
!hasReplacementUserTurn
|
|
908
949
|
) {
|
|
909
950
|
const next = takeDeferredInject(topicId);
|
|
910
951
|
if (next) redispatchInject(next);
|
|
911
952
|
}
|
|
953
|
+
logger.info(
|
|
954
|
+
{
|
|
955
|
+
topicId,
|
|
956
|
+
queryId,
|
|
957
|
+
outcomeKind: outcome.kind,
|
|
958
|
+
assistantMessageCount: visibleMessageIds.length,
|
|
959
|
+
assistantTextChars: accumulatedText.length,
|
|
960
|
+
durationMs: Date.now() - streamStartedAt,
|
|
961
|
+
lastEventType,
|
|
962
|
+
},
|
|
963
|
+
"ai: turn settled",
|
|
964
|
+
);
|
|
912
965
|
}
|
|
913
966
|
return outcome;
|
|
914
967
|
}
|
|
@@ -734,6 +734,8 @@ export interface StartAiTurnParams extends AiTurnExecutionOptions {
|
|
|
734
734
|
/** Durable rows whose envelopes are already present in _userMessages. */
|
|
735
735
|
_durableRequestIds?: string[];
|
|
736
736
|
_sessionRetried?: boolean;
|
|
737
|
+
/** Internal only. Caps the empty-provider-response retry to a single attempt. */
|
|
738
|
+
_emptyResponseRetried?: boolean;
|
|
737
739
|
}
|
|
738
740
|
|
|
739
741
|
export interface TriggerTopicAiTurnOptions extends AiTurnExecutionOptions {
|
|
@@ -1028,6 +1030,7 @@ export function startAiTurn(params: StartAiTurnParams): string | null {
|
|
|
1028
1030
|
const peerBridge = params.peerBridge;
|
|
1029
1031
|
const askReplySources = params.askReplySources;
|
|
1030
1032
|
const sessionRetried = params._sessionRetried === true;
|
|
1033
|
+
const emptyResponseRetried = params._emptyResponseRetried === true;
|
|
1031
1034
|
const queryId = params._queryId ?? randomUUID();
|
|
1032
1035
|
const roomId = turnConcurrency === "isolated" ? isolatedTurnRoomId(topicId, queryId) : topicId;
|
|
1033
1036
|
const currentRuntimeEpoch = getRuntimeTopicEpoch(topic.id);
|
|
@@ -1822,6 +1825,66 @@ export function startAiTurn(params: StartAiTurnParams): string | null {
|
|
|
1822
1825
|
}
|
|
1823
1826
|
}
|
|
1824
1827
|
|
|
1828
|
+
if (outcome.kind === "empty-response") {
|
|
1829
|
+
if (emptyResponseRetried) {
|
|
1830
|
+
// Already retried once and still got nothing back — stop masking it
|
|
1831
|
+
// and report a normal provider failure.
|
|
1832
|
+
outcome = { kind: "provider-error", error: outcome.error };
|
|
1833
|
+
} else {
|
|
1834
|
+
// Observed cause: a queued background-task notification flushes into
|
|
1835
|
+
// the CLI session at the same instant this turn is dispatched, and
|
|
1836
|
+
// the CLI self-interrupts within ~1-2s with a zero-usage "success".
|
|
1837
|
+
// A same-session retry has reliably produced a real reply in every
|
|
1838
|
+
// case seen so far, so retry once before surfacing a failure.
|
|
1839
|
+
if (!silent) WsHub.get().broadcastAborted(topicId, queryId, "stopped");
|
|
1840
|
+
logger.info(
|
|
1841
|
+
{ topicId, prevQueryId: queryId, agent: agentKind },
|
|
1842
|
+
"ai: retrying query after empty provider response",
|
|
1843
|
+
);
|
|
1844
|
+
startAiTurn({
|
|
1845
|
+
topic,
|
|
1846
|
+
userId,
|
|
1847
|
+
vaultUserId,
|
|
1848
|
+
prompt,
|
|
1849
|
+
_userMessages: userMessages,
|
|
1850
|
+
_conversationPrompts: conversationPrompts,
|
|
1851
|
+
_loggedUserMessageCount: loggedUserMessageCount,
|
|
1852
|
+
_durableRequestIds: durableRequestIds,
|
|
1853
|
+
attachments,
|
|
1854
|
+
allowAutoContinue,
|
|
1855
|
+
origin,
|
|
1856
|
+
onDispatched,
|
|
1857
|
+
requestId,
|
|
1858
|
+
depth,
|
|
1859
|
+
silent,
|
|
1860
|
+
contextId,
|
|
1861
|
+
agentOverride,
|
|
1862
|
+
modelOverride,
|
|
1863
|
+
effortOverride,
|
|
1864
|
+
sessionId,
|
|
1865
|
+
sessionScope,
|
|
1866
|
+
turnConcurrency,
|
|
1867
|
+
forkHandle,
|
|
1868
|
+
prepareSession,
|
|
1869
|
+
cwd,
|
|
1870
|
+
sessionName,
|
|
1871
|
+
sessionType,
|
|
1872
|
+
visualTools,
|
|
1873
|
+
fileDeliveryTools,
|
|
1874
|
+
onSessionId,
|
|
1875
|
+
onSessionReset,
|
|
1876
|
+
bridgeSessionFromHistory,
|
|
1877
|
+
onSettled,
|
|
1878
|
+
peerBridge,
|
|
1879
|
+
askReplySources,
|
|
1880
|
+
_runtimeEpoch: runtimeEpoch,
|
|
1881
|
+
_sessionRetried: sessionRetried,
|
|
1882
|
+
_emptyResponseRetried: true,
|
|
1883
|
+
});
|
|
1884
|
+
return;
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1825
1888
|
if (outcome.kind === "budget-capped") {
|
|
1826
1889
|
const error = "The job reached its cost limit";
|
|
1827
1890
|
if (!silent) {
|