niahere 0.3.12 → 0.4.0
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/agent/backends/claude-normalize.ts +142 -0
- package/src/agent/backends/claude.ts +181 -0
- package/src/agent/backends/codex-normalize.ts +76 -0
- package/src/agent/backends/codex.ts +175 -0
- package/src/agent/index.ts +12 -0
- package/src/agent/mcp-endpoint.ts +102 -0
- package/src/agent/message-stream.ts +106 -0
- package/src/agent/registry.ts +51 -0
- package/src/agent/types.ts +126 -0
- package/src/chat/engine.ts +148 -480
- package/src/commands/validate.ts +13 -3
- package/src/core/daemon.ts +8 -0
- package/src/core/runner.ts +94 -225
- package/src/mcp/server.ts +10 -367
- package/src/mcp/tools/table.ts +258 -0
- package/src/mcp/tools/types.ts +16 -0
- package/src/types/config.ts +7 -1
- package/src/utils/config.ts +6 -2
- package/src/utils/retry.ts +10 -0
package/src/chat/engine.ts
CHANGED
|
@@ -1,104 +1,22 @@
|
|
|
1
|
-
import { query, type Query } from "@anthropic-ai/claude-agent-sdk";
|
|
2
|
-
// @ts-ignore — SDK re-exports this type but tsc can't resolve the path under Bun
|
|
3
|
-
import type { MessageParam } from "@anthropic-ai/sdk/resources";
|
|
4
1
|
import { existsSync } from "fs";
|
|
5
|
-
import { join } from "path";
|
|
6
2
|
import { homedir } from "os";
|
|
7
|
-
import { randomUUID } from "crypto";
|
|
8
3
|
import { buildSystemPrompt, buildContextSuffix, getSessionContext } from "./identity";
|
|
9
4
|
import { buildEmployeePrompt } from "./employee-prompt";
|
|
10
5
|
import { getEmployee } from "../core/employees";
|
|
11
6
|
import { getAgentDefinitions, scanAgents } from "../core/agents";
|
|
12
7
|
import { Session, Message, ActiveEngine, Job } from "../db/models";
|
|
13
|
-
import type {
|
|
14
|
-
Attachment,
|
|
15
|
-
SendResult,
|
|
16
|
-
StreamCallback,
|
|
17
|
-
ActivityCallback,
|
|
18
|
-
SendCallbacks,
|
|
19
|
-
ChatEngine,
|
|
20
|
-
EngineOptions,
|
|
21
|
-
} from "../types";
|
|
22
|
-
import { truncate, formatToolUse } from "../utils/format-activity";
|
|
8
|
+
import type { Attachment, SendResult, SendCallbacks, ChatEngine, EngineOptions } from "../types";
|
|
23
9
|
import { finalizeSession, cancelPending } from "../core/finalizer";
|
|
24
10
|
import { log } from "../utils/log";
|
|
25
|
-
import { getConfig } from "../utils/config";
|
|
26
|
-
import { isRetryableApiError, sleep } from "../utils/retry";
|
|
27
11
|
import { registerActiveHandle, unregisterActiveHandle } from "../core/active-handles";
|
|
28
12
|
import { resolveJobPrompt } from "../core/job-prompt";
|
|
29
|
-
import {
|
|
30
|
-
import { getSdkHooks } from "../core/sdk-hooks";
|
|
13
|
+
import { resolveBackends, type AgentSession } from "../agent";
|
|
31
14
|
|
|
32
15
|
const IDLE_TIMEOUT = 10 * 60 * 1000; // 10 minutes
|
|
33
16
|
const LONG_RUNNING_WARN = 30 * 60 * 1000; // 30 minutes
|
|
34
|
-
const MAX_SEND_RETRIES = 2;
|
|
35
|
-
const SEND_RETRY_DELAYS = [3_000, 8_000];
|
|
36
17
|
const GENERIC_CHAT_ERROR = "💀";
|
|
37
18
|
|
|
38
|
-
|
|
39
|
-
type: "user";
|
|
40
|
-
message: MessageParam;
|
|
41
|
-
parent_tool_use_id: null;
|
|
42
|
-
session_id: string;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** Convert provider-agnostic attachments to Anthropic content blocks. */
|
|
46
|
-
export function buildContentBlocks(text: string, attachments?: Attachment[]): MessageParam["content"] {
|
|
47
|
-
if (!attachments?.length) return text;
|
|
48
|
-
|
|
49
|
-
const blocks: Array<
|
|
50
|
-
| { type: "text"; text: string }
|
|
51
|
-
| {
|
|
52
|
-
type: "image";
|
|
53
|
-
source: { type: "base64"; media_type: string; data: string };
|
|
54
|
-
}
|
|
55
|
-
> = [];
|
|
56
|
-
|
|
57
|
-
const pathHints = attachments
|
|
58
|
-
.map((att, idx) => {
|
|
59
|
-
if (!att.sourcePath) return "";
|
|
60
|
-
const label = att.filename || `${att.type}-${idx + 1}`;
|
|
61
|
-
return `- ${idx + 1}. ${label} (${att.type}, ${att.mimeType}) -> ${att.sourcePath}`;
|
|
62
|
-
})
|
|
63
|
-
.filter(Boolean);
|
|
64
|
-
|
|
65
|
-
if (pathHints.length > 0) {
|
|
66
|
-
blocks.push({
|
|
67
|
-
type: "text",
|
|
68
|
-
text:
|
|
69
|
-
"[Attachment local paths]\n" +
|
|
70
|
-
"Use these absolute paths to inspect attachments. To resend/forward one, call send_message with media_path set to its path.\n" +
|
|
71
|
-
pathHints.join("\n"),
|
|
72
|
-
});
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
for (const att of attachments) {
|
|
76
|
-
if (att.sourcePath) continue;
|
|
77
|
-
|
|
78
|
-
if (att.type === "image") {
|
|
79
|
-
blocks.push({
|
|
80
|
-
type: "image",
|
|
81
|
-
source: {
|
|
82
|
-
type: "base64",
|
|
83
|
-
media_type: att.mimeType,
|
|
84
|
-
data: att.data.toString("base64"),
|
|
85
|
-
},
|
|
86
|
-
});
|
|
87
|
-
} else if (att.type === "document") {
|
|
88
|
-
const docText = att.data.toString("utf8");
|
|
89
|
-
const label = att.filename ? `[${att.filename}]` : "[document]";
|
|
90
|
-
blocks.push({ type: "text", text: `${label}\n${docText}` });
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
if (text) {
|
|
95
|
-
blocks.push({ type: "text", text });
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
return blocks as MessageParam["content"];
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/** Convert SDK error text into a channel-safe chat response. */
|
|
19
|
+
/** Convert backend error text into a channel-safe chat response. */
|
|
102
20
|
export function formatChatError(rawError: string | null | undefined): string {
|
|
103
21
|
const error = rawError?.trim();
|
|
104
22
|
if (getChatErrorSignal(error) === "provider_down") {
|
|
@@ -115,68 +33,6 @@ export function getChatErrorSignal(rawError: string | null | undefined): SendRes
|
|
|
115
33
|
return !error || error.toLowerCase() === "unknown error" ? "provider_down" : undefined;
|
|
116
34
|
}
|
|
117
35
|
|
|
118
|
-
export function resolveSdkModel(contextModel?: string | null): string | undefined {
|
|
119
|
-
const model = contextModel || getConfig().model;
|
|
120
|
-
return model && model !== "default" ? model : undefined;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* Push-based async iterable for streaming user messages to the SDK.
|
|
125
|
-
* Keeps the query subprocess alive between messages.
|
|
126
|
-
*/
|
|
127
|
-
class MessageStream {
|
|
128
|
-
private queue: SDKUserMessage[] = [];
|
|
129
|
-
private waiting: (() => void) | null = null;
|
|
130
|
-
private done = false;
|
|
131
|
-
|
|
132
|
-
push(text: string, attachments?: Attachment[]): void {
|
|
133
|
-
this.queue.push({
|
|
134
|
-
type: "user",
|
|
135
|
-
message: { role: "user", content: buildContentBlocks(text, attachments) },
|
|
136
|
-
parent_tool_use_id: null,
|
|
137
|
-
session_id: "",
|
|
138
|
-
});
|
|
139
|
-
this.waiting?.();
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
end(): void {
|
|
143
|
-
this.done = true;
|
|
144
|
-
this.waiting?.();
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
async *[Symbol.asyncIterator](): AsyncGenerator<SDKUserMessage> {
|
|
148
|
-
while (true) {
|
|
149
|
-
while (this.queue.length > 0) {
|
|
150
|
-
yield this.queue.shift()!;
|
|
151
|
-
}
|
|
152
|
-
if (this.done) return;
|
|
153
|
-
await new Promise<void>((r) => {
|
|
154
|
-
this.waiting = r;
|
|
155
|
-
});
|
|
156
|
-
this.waiting = null;
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
interface PendingResult {
|
|
162
|
-
userMessage: string;
|
|
163
|
-
userSaved: boolean;
|
|
164
|
-
onStream: StreamCallback | null;
|
|
165
|
-
onActivity: ActivityCallback | null;
|
|
166
|
-
accumulatedText: string;
|
|
167
|
-
accumulatedThinking: string;
|
|
168
|
-
lastThinkingLine: string;
|
|
169
|
-
resolve: (value: SendResult) => void;
|
|
170
|
-
reject: (error: Error) => void;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
function sessionFileExists(sessionId: string, cwd: string): boolean {
|
|
174
|
-
// SDK stores sessions at ~/.claude/projects/<encoded-cwd>/<session-id>.jsonl
|
|
175
|
-
const encoded = cwd.replace(/\//g, "-");
|
|
176
|
-
const sessionFile = join(homedir(), ".claude", "projects", encoded, `${sessionId}.jsonl`);
|
|
177
|
-
return existsSync(sessionFile);
|
|
178
|
-
}
|
|
179
|
-
|
|
180
36
|
export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine> {
|
|
181
37
|
const { room, channel, resume, mcpServers } = opts;
|
|
182
38
|
let systemPrompt = buildSystemPrompt("chat", channel);
|
|
@@ -236,6 +92,12 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
|
|
|
236
92
|
systemPrompt += `\n\n## Watch Mode — #${watchChannel}\n\nYou are monitoring this Slack channel. Follow the behavior instructions below.\nRespond with [NO_REPLY] if no action is needed — do not explain why.\n\n${behavior}`;
|
|
237
93
|
}
|
|
238
94
|
|
|
95
|
+
// The backend chain: configured primary first, then provider-down fallbacks.
|
|
96
|
+
// Chat normally runs on the primary; a provider-down turn fails over to the
|
|
97
|
+
// next backend (answering the current message — see send()).
|
|
98
|
+
const backends = resolveBackends();
|
|
99
|
+
let backendIndex = 0;
|
|
100
|
+
|
|
239
101
|
let sessionId: string | null = null;
|
|
240
102
|
if (typeof resume === "string") {
|
|
241
103
|
// Specific session ID provided
|
|
@@ -244,19 +106,17 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
|
|
|
244
106
|
sessionId = await Session.getLatest(room);
|
|
245
107
|
}
|
|
246
108
|
|
|
247
|
-
// Verify
|
|
248
|
-
|
|
109
|
+
// Verify the primary backend can actually resume this session before
|
|
110
|
+
// attempting it (Claude probes the on-disk jsonl; others use their own check).
|
|
111
|
+
if (sessionId && !(await backends[0]!.canResume(sessionId, cwd))) {
|
|
249
112
|
sessionId = null;
|
|
250
113
|
}
|
|
251
|
-
|
|
252
|
-
let
|
|
253
|
-
let pending: PendingResult | null = null;
|
|
114
|
+
|
|
115
|
+
let session: AgentSession | null = null;
|
|
254
116
|
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
255
117
|
let longRunningTimer: ReturnType<typeof setTimeout> | null = null;
|
|
256
|
-
let longRunningWarned = false;
|
|
257
|
-
let alive = false;
|
|
258
118
|
let messageCount = 0;
|
|
259
|
-
let
|
|
119
|
+
let inFlight = false;
|
|
260
120
|
|
|
261
121
|
function clearIdleTimer() {
|
|
262
122
|
if (idleTimer) {
|
|
@@ -268,9 +128,9 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
|
|
|
268
128
|
function resetIdleTimer() {
|
|
269
129
|
clearIdleTimer();
|
|
270
130
|
idleTimer = setTimeout(async () => {
|
|
271
|
-
if (
|
|
131
|
+
if (inFlight) {
|
|
272
132
|
// Don't tear down while a request is in flight
|
|
273
|
-
log.warn({ room }, "idle timer fired while request
|
|
133
|
+
log.warn({ room }, "idle timer fired while request in flight, skipping teardown");
|
|
274
134
|
return;
|
|
275
135
|
}
|
|
276
136
|
// Enqueue finalization before "sleep"
|
|
@@ -279,7 +139,7 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
|
|
|
279
139
|
log.error({ err, room }, "finalization enqueue failed during idle teardown");
|
|
280
140
|
});
|
|
281
141
|
}
|
|
282
|
-
teardown();
|
|
142
|
+
await teardown();
|
|
283
143
|
}, IDLE_TIMEOUT);
|
|
284
144
|
}
|
|
285
145
|
|
|
@@ -288,316 +148,45 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
|
|
|
288
148
|
clearTimeout(longRunningTimer);
|
|
289
149
|
longRunningTimer = null;
|
|
290
150
|
}
|
|
291
|
-
longRunningWarned = false;
|
|
292
151
|
}
|
|
293
152
|
|
|
294
153
|
function startLongRunningTimer() {
|
|
295
154
|
clearLongRunningTimer();
|
|
296
155
|
longRunningTimer = setTimeout(() => {
|
|
297
|
-
|
|
298
|
-
longRunningWarned = true;
|
|
299
|
-
log.warn({ room, elapsed: LONG_RUNNING_WARN / 1000 }, "engine request running for 30+ minutes");
|
|
300
|
-
}
|
|
156
|
+
log.warn({ room, elapsed: LONG_RUNNING_WARN / 1000 }, "engine request running for 30+ minutes");
|
|
301
157
|
}, LONG_RUNNING_WARN);
|
|
302
158
|
}
|
|
303
159
|
|
|
304
|
-
function teardown() {
|
|
160
|
+
async function teardown() {
|
|
305
161
|
clearIdleTimer();
|
|
306
162
|
clearLongRunningTimer();
|
|
307
|
-
if (
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
}
|
|
311
|
-
if (queryHandle) {
|
|
312
|
-
queryHandle.close();
|
|
313
|
-
queryHandle = null;
|
|
163
|
+
if (session) {
|
|
164
|
+
await session.close().catch(() => {});
|
|
165
|
+
session = null;
|
|
314
166
|
}
|
|
315
167
|
unregisterActiveHandle(room);
|
|
316
|
-
alive = false;
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
async function abortActiveQuery(reason: string) {
|
|
320
|
-
const activePending = pending;
|
|
321
|
-
pending = null;
|
|
322
|
-
if (activePending) {
|
|
323
|
-
activePending.reject(new Error(reason));
|
|
324
|
-
}
|
|
325
|
-
teardown();
|
|
326
|
-
await ActiveEngine.unregister(room).catch(() => {});
|
|
327
168
|
}
|
|
328
169
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
const
|
|
170
|
+
/** Lazily open (and reuse) the current backend's session for this engine. */
|
|
171
|
+
async function ensureSession(): Promise<AgentSession> {
|
|
172
|
+
if (session) return session;
|
|
173
|
+
const backend = backends[backendIndex] ?? backends[0]!;
|
|
174
|
+
const s = await backend.openSession({
|
|
175
|
+
room,
|
|
176
|
+
channel,
|
|
334
177
|
systemPrompt,
|
|
335
178
|
cwd,
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
};
|
|
342
|
-
const model = resolveSdkModel(contextModel);
|
|
343
|
-
if (model) {
|
|
344
|
-
options.model = model;
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
if (sessionId) {
|
|
348
|
-
options.resume = sessionId;
|
|
349
|
-
} else {
|
|
350
|
-
// Force a brand-new session with a unique ID so the claude subprocess
|
|
351
|
-
// cannot auto-continue a prior session in the same CWD ($HOME).
|
|
352
|
-
options.continue = false;
|
|
353
|
-
options.sessionId = randomUUID();
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
if (mcpServers) {
|
|
357
|
-
options.mcpServers = mcpServers;
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
const agentDefs = getAgentDefinitions();
|
|
361
|
-
if (Object.keys(agentDefs).length > 0) {
|
|
362
|
-
options.agents = agentDefs;
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
queryHandle = query({
|
|
366
|
-
prompt: stream as any,
|
|
367
|
-
options: options as any,
|
|
179
|
+
model: contextModel ?? undefined,
|
|
180
|
+
mcpServers,
|
|
181
|
+
resume: sessionId ?? false,
|
|
182
|
+
subagents: getAgentDefinitions(),
|
|
183
|
+
interactive: true,
|
|
368
184
|
});
|
|
369
|
-
registerActiveHandle(room,
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
for await (const message of queryHandle!) {
|
|
375
|
-
if (message.type === "system" && message.subtype === "init") {
|
|
376
|
-
const newId = message.session_id;
|
|
377
|
-
if (!sessionId || newId !== sessionId) {
|
|
378
|
-
sessionId = newId;
|
|
379
|
-
await Session.create(sessionId, room);
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
if (pending && !pending.userSaved) {
|
|
383
|
-
await Message.save({
|
|
384
|
-
sessionId,
|
|
385
|
-
room,
|
|
386
|
-
sender: "user",
|
|
387
|
-
content: pending.userMessage,
|
|
388
|
-
isFromAgent: false,
|
|
389
|
-
});
|
|
390
|
-
pending.userSaved = true;
|
|
391
|
-
messageCount++;
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
// Stream events: text deltas, thinking deltas, block lifecycle
|
|
396
|
-
if (message.type === "stream_event" && pending) {
|
|
397
|
-
const event = (message as any).event;
|
|
398
|
-
|
|
399
|
-
if (event?.type === "content_block_delta") {
|
|
400
|
-
const delta = event.delta;
|
|
401
|
-
if (delta?.type === "text_delta" && delta.text) {
|
|
402
|
-
pending.accumulatedText += delta.text;
|
|
403
|
-
pending.onStream?.(pending.accumulatedText);
|
|
404
|
-
}
|
|
405
|
-
if (delta?.type === "thinking_delta" && delta.thinking) {
|
|
406
|
-
pending.accumulatedThinking += delta.thinking;
|
|
407
|
-
// Only update on complete lines (newline boundary)
|
|
408
|
-
const lines = pending.accumulatedThinking.split("\n");
|
|
409
|
-
if (lines.length > 1) {
|
|
410
|
-
// Show the last complete line (not the partial one being typed)
|
|
411
|
-
const completeLine = lines[lines.length - 2]?.trim();
|
|
412
|
-
if (completeLine && completeLine !== pending.lastThinkingLine) {
|
|
413
|
-
pending.lastThinkingLine = completeLine;
|
|
414
|
-
pending.onActivity?.(truncate(completeLine, 70));
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
if (event?.type === "content_block_start") {
|
|
421
|
-
const block = event.content_block;
|
|
422
|
-
if (block?.type === "thinking") {
|
|
423
|
-
pending.accumulatedThinking = "";
|
|
424
|
-
pending.lastThinkingLine = "";
|
|
425
|
-
pending.onActivity?.("thinking...");
|
|
426
|
-
}
|
|
427
|
-
// tool_use: don't show here — wait for tool_use_summary with full input
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
if (event?.type === "content_block_stop") {
|
|
431
|
-
pending.accumulatedThinking = "";
|
|
432
|
-
pending.lastThinkingLine = "";
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
if (message.type === "tool_use_summary" && pending) {
|
|
437
|
-
const msg = message as any;
|
|
438
|
-
const name = msg.tool_name || "tool";
|
|
439
|
-
pending.onActivity?.(formatToolUse(name, msg.tool_input));
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
if (message.type === "tool_progress" && pending) {
|
|
443
|
-
const msg = message as any;
|
|
444
|
-
const toolName = msg.tool_name;
|
|
445
|
-
const content = msg.content;
|
|
446
|
-
if (toolName === "Bash" && content) {
|
|
447
|
-
pending.onActivity?.(`$ ${truncate(content, 60)}`);
|
|
448
|
-
} else if (content) {
|
|
449
|
-
pending.onActivity?.(truncate(content, 70));
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
// Task/agent lifecycle
|
|
454
|
-
if (message.type === "system" && pending) {
|
|
455
|
-
const msg = message as any;
|
|
456
|
-
if (msg.subtype === "task_started" && msg.description) {
|
|
457
|
-
pending.onActivity?.(truncate(msg.description, 60));
|
|
458
|
-
}
|
|
459
|
-
if (msg.subtype === "task_progress" && msg.last_tool_name) {
|
|
460
|
-
pending.onActivity?.(msg.summary || msg.last_tool_name);
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
if (message.type === "result" && pending) {
|
|
465
|
-
const msg = message as any;
|
|
466
|
-
if (!message.is_error) {
|
|
467
|
-
const resultText = msg.result as string;
|
|
468
|
-
const costUsd = msg.total_cost_usd as number;
|
|
469
|
-
const turns = msg.num_turns as number;
|
|
470
|
-
|
|
471
|
-
const metadata: Record<string, unknown> = {
|
|
472
|
-
cost_usd: costUsd,
|
|
473
|
-
turns,
|
|
474
|
-
duration_ms: msg.duration_ms,
|
|
475
|
-
duration_api_ms: msg.duration_api_ms,
|
|
476
|
-
stop_reason: msg.stop_reason,
|
|
477
|
-
terminal_reason: msg.terminal_reason,
|
|
478
|
-
session_id: msg.session_id,
|
|
479
|
-
subtype: msg.subtype,
|
|
480
|
-
usage: msg.usage,
|
|
481
|
-
model_usage: msg.modelUsage,
|
|
482
|
-
};
|
|
483
|
-
|
|
484
|
-
let messageId: number | undefined;
|
|
485
|
-
if (sessionId && resultText) {
|
|
486
|
-
const saveParams = {
|
|
487
|
-
sessionId,
|
|
488
|
-
room,
|
|
489
|
-
sender: "nia",
|
|
490
|
-
content: resultText,
|
|
491
|
-
isFromAgent: true,
|
|
492
|
-
deliveryStatus: "pending" as const,
|
|
493
|
-
metadata,
|
|
494
|
-
};
|
|
495
|
-
try {
|
|
496
|
-
messageId = await Message.save(saveParams);
|
|
497
|
-
} catch {
|
|
498
|
-
messageId = await Message.save({
|
|
499
|
-
...saveParams,
|
|
500
|
-
metadata: undefined,
|
|
501
|
-
});
|
|
502
|
-
}
|
|
503
|
-
await Session.touch(sessionId);
|
|
504
|
-
Session.accumulateMetadata(sessionId, {
|
|
505
|
-
...metadata,
|
|
506
|
-
channel,
|
|
507
|
-
}).catch(() => {});
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
await ActiveEngine.unregister(room);
|
|
511
|
-
clearLongRunningTimer();
|
|
512
|
-
retryCount = 0;
|
|
513
|
-
pending.resolve({
|
|
514
|
-
result: resultText,
|
|
515
|
-
costUsd,
|
|
516
|
-
turns,
|
|
517
|
-
messageId,
|
|
518
|
-
});
|
|
519
|
-
pending = null;
|
|
520
|
-
resetIdleTimer();
|
|
521
|
-
} else {
|
|
522
|
-
const errors = msg.errors;
|
|
523
|
-
const rawError = errors?.join(", ") || "unknown error";
|
|
524
|
-
|
|
525
|
-
// Retry on transient API errors (500, overloaded, rate-limit)
|
|
526
|
-
if (retryCount < MAX_SEND_RETRIES && isRetryableApiError(rawError)) {
|
|
527
|
-
const delay = SEND_RETRY_DELAYS[retryCount] ?? 8_000;
|
|
528
|
-
retryCount++;
|
|
529
|
-
log.warn(
|
|
530
|
-
{ room, attempt: retryCount, error: rawError, delayMs: delay },
|
|
531
|
-
"retrying chat send after transient API error",
|
|
532
|
-
);
|
|
533
|
-
const retryPending = pending;
|
|
534
|
-
pending = null;
|
|
535
|
-
clearLongRunningTimer();
|
|
536
|
-
|
|
537
|
-
// Tear down current query and restart after delay
|
|
538
|
-
teardown();
|
|
539
|
-
await sleep(delay);
|
|
540
|
-
startQuery();
|
|
541
|
-
|
|
542
|
-
// Re-send: the user message is already saved in DB, so mark it saved
|
|
543
|
-
pending = {
|
|
544
|
-
...retryPending,
|
|
545
|
-
userSaved: true,
|
|
546
|
-
accumulatedText: "",
|
|
547
|
-
accumulatedThinking: "",
|
|
548
|
-
lastThinkingLine: "",
|
|
549
|
-
};
|
|
550
|
-
retryPending.onActivity?.("retrying after API error...");
|
|
551
|
-
stream!.push(retryPending.userMessage);
|
|
552
|
-
} else {
|
|
553
|
-
const errorText = formatChatError(rawError);
|
|
554
|
-
log.error(
|
|
555
|
-
{
|
|
556
|
-
room,
|
|
557
|
-
error: rawError,
|
|
558
|
-
errors,
|
|
559
|
-
subtype: msg.subtype,
|
|
560
|
-
terminal_reason: msg.terminal_reason,
|
|
561
|
-
session_id: msg.session_id,
|
|
562
|
-
},
|
|
563
|
-
"chat send failed with SDK result error",
|
|
564
|
-
);
|
|
565
|
-
await ActiveEngine.unregister(room);
|
|
566
|
-
clearLongRunningTimer();
|
|
567
|
-
pending.resolve({ result: errorText, costUsd: 0, turns: 0, signal: getChatErrorSignal(rawError) });
|
|
568
|
-
pending = null;
|
|
569
|
-
retryCount = 0;
|
|
570
|
-
resetIdleTimer();
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
// Stream ended without a result — subprocess exited or was killed
|
|
577
|
-
if (pending) {
|
|
578
|
-
const partial = pending.accumulatedText;
|
|
579
|
-
log.error(
|
|
580
|
-
{ room, partialChars: partial.length },
|
|
581
|
-
"query stream ended without result, rejecting pending request",
|
|
582
|
-
);
|
|
583
|
-
await ActiveEngine.unregister(room).catch(() => {});
|
|
584
|
-
pending.reject(new Error(`stream ended without result (${partial.length} chars accumulated)`));
|
|
585
|
-
pending = null;
|
|
586
|
-
}
|
|
587
|
-
} catch (err) {
|
|
588
|
-
if (pending) {
|
|
589
|
-
await ActiveEngine.unregister(room).catch(() => {});
|
|
590
|
-
pending.reject(err instanceof Error ? err : new Error(String(err)));
|
|
591
|
-
pending = null;
|
|
592
|
-
}
|
|
593
|
-
} finally {
|
|
594
|
-
clearLongRunningTimer();
|
|
595
|
-
unregisterActiveHandle(room);
|
|
596
|
-
alive = false;
|
|
597
|
-
stream = null;
|
|
598
|
-
queryHandle = null;
|
|
599
|
-
}
|
|
600
|
-
})();
|
|
185
|
+
registerActiveHandle(room, (reason) => {
|
|
186
|
+
s.abort(reason);
|
|
187
|
+
});
|
|
188
|
+
session = s;
|
|
189
|
+
return s;
|
|
601
190
|
}
|
|
602
191
|
|
|
603
192
|
return {
|
|
@@ -613,6 +202,7 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
|
|
|
613
202
|
// Clear idle timer — engine is not idle while processing a request
|
|
614
203
|
clearIdleTimer();
|
|
615
204
|
startLongRunningTimer();
|
|
205
|
+
inFlight = true;
|
|
616
206
|
|
|
617
207
|
// Cancel any pending finalization — session is active again
|
|
618
208
|
if (sessionId) {
|
|
@@ -621,52 +211,130 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
|
|
|
621
211
|
|
|
622
212
|
await ActiveEngine.register(room, channel);
|
|
623
213
|
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
// Save user message to DB if session already exists (resumed session).
|
|
629
|
-
// For new sessions, the init handler saves it once sessionId is known.
|
|
214
|
+
// Save the user message eagerly for an already-known (resumed) session;
|
|
215
|
+
// for a brand-new session we save it once on the `session` event below.
|
|
630
216
|
let userSaved = false;
|
|
631
217
|
if (sessionId) {
|
|
632
|
-
await Message.save({
|
|
633
|
-
sessionId,
|
|
634
|
-
room,
|
|
635
|
-
sender: "user",
|
|
636
|
-
content: userMessage,
|
|
637
|
-
isFromAgent: false,
|
|
638
|
-
});
|
|
218
|
+
await Message.save({ sessionId, room, sender: "user", content: userMessage, isFromAgent: false });
|
|
639
219
|
await Session.touch(sessionId);
|
|
640
220
|
userSaved = true;
|
|
641
221
|
messageCount++;
|
|
642
222
|
}
|
|
643
223
|
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
224
|
+
let result: SendResult = { result: "", costUsd: 0, turns: 0 };
|
|
225
|
+
|
|
226
|
+
// Run the turn on the current backend; on a provider-down result, fail over
|
|
227
|
+
// to the next backend and answer the current message there.
|
|
228
|
+
while (true) {
|
|
229
|
+
const sess = await ensureSession();
|
|
230
|
+
let accumulated = "";
|
|
231
|
+
let providerDown = false;
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
for await (const ev of sess.send(userMessage, attachments)) {
|
|
235
|
+
switch (ev.type) {
|
|
236
|
+
case "session": {
|
|
237
|
+
if (!sessionId || ev.backendSessionId !== sessionId) {
|
|
238
|
+
sessionId = ev.backendSessionId;
|
|
239
|
+
await Session.create(sessionId, room);
|
|
240
|
+
}
|
|
241
|
+
if (!userSaved) {
|
|
242
|
+
await Message.save({ sessionId, room, sender: "user", content: userMessage, isFromAgent: false });
|
|
243
|
+
userSaved = true;
|
|
244
|
+
messageCount++;
|
|
245
|
+
}
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
case "text":
|
|
249
|
+
accumulated += ev.delta;
|
|
250
|
+
callbacks?.onStream?.(accumulated);
|
|
251
|
+
break;
|
|
252
|
+
case "thinking":
|
|
253
|
+
callbacks?.onActivity?.(ev.delta);
|
|
254
|
+
break;
|
|
255
|
+
case "tool":
|
|
256
|
+
callbacks?.onActivity?.(ev.summary ?? ev.name);
|
|
257
|
+
break;
|
|
258
|
+
case "result": {
|
|
259
|
+
const costUsd = ev.usage.costUsd ?? 0;
|
|
260
|
+
const turns = ev.usage.turns ?? 0;
|
|
261
|
+
let messageId: number | undefined;
|
|
262
|
+
if (sessionId && ev.text) {
|
|
263
|
+
const saveParams = {
|
|
264
|
+
sessionId,
|
|
265
|
+
room,
|
|
266
|
+
sender: "nia",
|
|
267
|
+
content: ev.text,
|
|
268
|
+
isFromAgent: true,
|
|
269
|
+
deliveryStatus: "pending" as const,
|
|
270
|
+
metadata: ev.metadata,
|
|
271
|
+
};
|
|
272
|
+
try {
|
|
273
|
+
messageId = await Message.save(saveParams);
|
|
274
|
+
} catch {
|
|
275
|
+
messageId = await Message.save({ ...saveParams, metadata: undefined });
|
|
276
|
+
}
|
|
277
|
+
await Session.touch(sessionId);
|
|
278
|
+
Session.accumulateMetadata(sessionId, { ...(ev.metadata ?? {}), channel }).catch(() => {});
|
|
279
|
+
}
|
|
280
|
+
result = { result: ev.text, costUsd, turns, messageId };
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
case "error": {
|
|
284
|
+
providerDown = ev.providerDown;
|
|
285
|
+
log.error(
|
|
286
|
+
{ room, error: ev.message, terminal_reason: ev.terminalReason },
|
|
287
|
+
"chat send failed with backend error",
|
|
288
|
+
);
|
|
289
|
+
result = {
|
|
290
|
+
result: formatChatError(ev.message),
|
|
291
|
+
costUsd: 0,
|
|
292
|
+
turns: 0,
|
|
293
|
+
signal: ev.providerDown ? "provider_down" : undefined,
|
|
294
|
+
};
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
} catch (err) {
|
|
300
|
+
await ActiveEngine.unregister(room).catch(() => {});
|
|
301
|
+
clearLongRunningTimer();
|
|
302
|
+
inFlight = false;
|
|
303
|
+
if (sess.backendSessionId) sessionId = sess.backendSessionId;
|
|
304
|
+
throw err instanceof Error ? err : new Error(String(err));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Re-read the backend session id post-send so finalize/DB target it.
|
|
308
|
+
if (sess.backendSessionId) sessionId = sess.backendSessionId;
|
|
309
|
+
|
|
310
|
+
if (providerDown && backendIndex < backends.length - 1) {
|
|
311
|
+
backendIndex++;
|
|
312
|
+
log.warn({ room, to: backends[backendIndex]!.name }, "chat provider down, failing over to next backend");
|
|
313
|
+
await teardown(); // close the dead session so ensureSession opens the next backend
|
|
314
|
+
sessionId = null; // a cross-backend session id is meaningless; start fresh
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
break;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
await ActiveEngine.unregister(room);
|
|
321
|
+
clearLongRunningTimer();
|
|
322
|
+
inFlight = false;
|
|
323
|
+
resetIdleTimer();
|
|
324
|
+
return result;
|
|
658
325
|
},
|
|
659
326
|
|
|
660
327
|
async close() {
|
|
661
328
|
// Enqueue finalization — processed by daemon or inline if we are the daemon
|
|
662
|
-
if (sessionId && messageCount > 0 && !
|
|
329
|
+
if (sessionId && messageCount > 0 && !inFlight) {
|
|
663
330
|
try {
|
|
664
331
|
await finalizeSession(sessionId, room);
|
|
665
332
|
} catch (err) {
|
|
666
333
|
log.error({ err, room }, "finalization enqueue failed during close");
|
|
667
334
|
}
|
|
668
335
|
}
|
|
669
|
-
await
|
|
336
|
+
await teardown();
|
|
337
|
+
await ActiveEngine.unregister(room).catch(() => {});
|
|
670
338
|
},
|
|
671
339
|
};
|
|
672
340
|
}
|