niahere 0.3.11 → 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/channels/slack.ts +26 -3
- package/src/chat/engine.ts +152 -479
- 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/types/engine.ts +2 -0
- package/src/utils/config.ts +6 -2
- package/src/utils/retry.ts +10 -0
package/src/chat/engine.ts
CHANGED
|
@@ -1,107 +1,25 @@
|
|
|
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
|
-
if (
|
|
22
|
+
if (getChatErrorSignal(error) === "provider_down") {
|
|
105
23
|
return GENERIC_CHAT_ERROR;
|
|
106
24
|
}
|
|
107
25
|
if (error === "oauth_org_not_allowed") {
|
|
@@ -110,66 +28,9 @@ export function formatChatError(rawError: string | null | undefined): string {
|
|
|
110
28
|
return `[error] ${error}`;
|
|
111
29
|
}
|
|
112
30
|
|
|
113
|
-
export function
|
|
114
|
-
const
|
|
115
|
-
return
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Push-based async iterable for streaming user messages to the SDK.
|
|
120
|
-
* Keeps the query subprocess alive between messages.
|
|
121
|
-
*/
|
|
122
|
-
class MessageStream {
|
|
123
|
-
private queue: SDKUserMessage[] = [];
|
|
124
|
-
private waiting: (() => void) | null = null;
|
|
125
|
-
private done = false;
|
|
126
|
-
|
|
127
|
-
push(text: string, attachments?: Attachment[]): void {
|
|
128
|
-
this.queue.push({
|
|
129
|
-
type: "user",
|
|
130
|
-
message: { role: "user", content: buildContentBlocks(text, attachments) },
|
|
131
|
-
parent_tool_use_id: null,
|
|
132
|
-
session_id: "",
|
|
133
|
-
});
|
|
134
|
-
this.waiting?.();
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
end(): void {
|
|
138
|
-
this.done = true;
|
|
139
|
-
this.waiting?.();
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
async *[Symbol.asyncIterator](): AsyncGenerator<SDKUserMessage> {
|
|
143
|
-
while (true) {
|
|
144
|
-
while (this.queue.length > 0) {
|
|
145
|
-
yield this.queue.shift()!;
|
|
146
|
-
}
|
|
147
|
-
if (this.done) return;
|
|
148
|
-
await new Promise<void>((r) => {
|
|
149
|
-
this.waiting = r;
|
|
150
|
-
});
|
|
151
|
-
this.waiting = null;
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
interface PendingResult {
|
|
157
|
-
userMessage: string;
|
|
158
|
-
userSaved: boolean;
|
|
159
|
-
onStream: StreamCallback | null;
|
|
160
|
-
onActivity: ActivityCallback | null;
|
|
161
|
-
accumulatedText: string;
|
|
162
|
-
accumulatedThinking: string;
|
|
163
|
-
lastThinkingLine: string;
|
|
164
|
-
resolve: (value: SendResult) => void;
|
|
165
|
-
reject: (error: Error) => void;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function sessionFileExists(sessionId: string, cwd: string): boolean {
|
|
169
|
-
// SDK stores sessions at ~/.claude/projects/<encoded-cwd>/<session-id>.jsonl
|
|
170
|
-
const encoded = cwd.replace(/\//g, "-");
|
|
171
|
-
const sessionFile = join(homedir(), ".claude", "projects", encoded, `${sessionId}.jsonl`);
|
|
172
|
-
return existsSync(sessionFile);
|
|
31
|
+
export function getChatErrorSignal(rawError: string | null | undefined): SendResult["signal"] | undefined {
|
|
32
|
+
const error = rawError?.trim();
|
|
33
|
+
return !error || error.toLowerCase() === "unknown error" ? "provider_down" : undefined;
|
|
173
34
|
}
|
|
174
35
|
|
|
175
36
|
export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine> {
|
|
@@ -231,6 +92,12 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
|
|
|
231
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}`;
|
|
232
93
|
}
|
|
233
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
|
+
|
|
234
101
|
let sessionId: string | null = null;
|
|
235
102
|
if (typeof resume === "string") {
|
|
236
103
|
// Specific session ID provided
|
|
@@ -239,19 +106,17 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
|
|
|
239
106
|
sessionId = await Session.getLatest(room);
|
|
240
107
|
}
|
|
241
108
|
|
|
242
|
-
// Verify
|
|
243
|
-
|
|
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))) {
|
|
244
112
|
sessionId = null;
|
|
245
113
|
}
|
|
246
|
-
|
|
247
|
-
let
|
|
248
|
-
let pending: PendingResult | null = null;
|
|
114
|
+
|
|
115
|
+
let session: AgentSession | null = null;
|
|
249
116
|
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
250
117
|
let longRunningTimer: ReturnType<typeof setTimeout> | null = null;
|
|
251
|
-
let longRunningWarned = false;
|
|
252
|
-
let alive = false;
|
|
253
118
|
let messageCount = 0;
|
|
254
|
-
let
|
|
119
|
+
let inFlight = false;
|
|
255
120
|
|
|
256
121
|
function clearIdleTimer() {
|
|
257
122
|
if (idleTimer) {
|
|
@@ -263,9 +128,9 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
|
|
|
263
128
|
function resetIdleTimer() {
|
|
264
129
|
clearIdleTimer();
|
|
265
130
|
idleTimer = setTimeout(async () => {
|
|
266
|
-
if (
|
|
131
|
+
if (inFlight) {
|
|
267
132
|
// Don't tear down while a request is in flight
|
|
268
|
-
log.warn({ room }, "idle timer fired while request
|
|
133
|
+
log.warn({ room }, "idle timer fired while request in flight, skipping teardown");
|
|
269
134
|
return;
|
|
270
135
|
}
|
|
271
136
|
// Enqueue finalization before "sleep"
|
|
@@ -274,7 +139,7 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
|
|
|
274
139
|
log.error({ err, room }, "finalization enqueue failed during idle teardown");
|
|
275
140
|
});
|
|
276
141
|
}
|
|
277
|
-
teardown();
|
|
142
|
+
await teardown();
|
|
278
143
|
}, IDLE_TIMEOUT);
|
|
279
144
|
}
|
|
280
145
|
|
|
@@ -283,316 +148,45 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
|
|
|
283
148
|
clearTimeout(longRunningTimer);
|
|
284
149
|
longRunningTimer = null;
|
|
285
150
|
}
|
|
286
|
-
longRunningWarned = false;
|
|
287
151
|
}
|
|
288
152
|
|
|
289
153
|
function startLongRunningTimer() {
|
|
290
154
|
clearLongRunningTimer();
|
|
291
155
|
longRunningTimer = setTimeout(() => {
|
|
292
|
-
|
|
293
|
-
longRunningWarned = true;
|
|
294
|
-
log.warn({ room, elapsed: LONG_RUNNING_WARN / 1000 }, "engine request running for 30+ minutes");
|
|
295
|
-
}
|
|
156
|
+
log.warn({ room, elapsed: LONG_RUNNING_WARN / 1000 }, "engine request running for 30+ minutes");
|
|
296
157
|
}, LONG_RUNNING_WARN);
|
|
297
158
|
}
|
|
298
159
|
|
|
299
|
-
function teardown() {
|
|
160
|
+
async function teardown() {
|
|
300
161
|
clearIdleTimer();
|
|
301
162
|
clearLongRunningTimer();
|
|
302
|
-
if (
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
}
|
|
306
|
-
if (queryHandle) {
|
|
307
|
-
queryHandle.close();
|
|
308
|
-
queryHandle = null;
|
|
163
|
+
if (session) {
|
|
164
|
+
await session.close().catch(() => {});
|
|
165
|
+
session = null;
|
|
309
166
|
}
|
|
310
167
|
unregisterActiveHandle(room);
|
|
311
|
-
alive = false;
|
|
312
168
|
}
|
|
313
169
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
await ActiveEngine.unregister(room).catch(() => {});
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
function startQuery() {
|
|
325
|
-
stream = new MessageStream();
|
|
326
|
-
alive = true;
|
|
327
|
-
|
|
328
|
-
const options: Record<string, unknown> = {
|
|
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,
|
|
329
177
|
systemPrompt,
|
|
330
178
|
cwd,
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
};
|
|
337
|
-
const model = resolveSdkModel(contextModel);
|
|
338
|
-
if (model) {
|
|
339
|
-
options.model = model;
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
if (sessionId) {
|
|
343
|
-
options.resume = sessionId;
|
|
344
|
-
} else {
|
|
345
|
-
// Force a brand-new session with a unique ID so the claude subprocess
|
|
346
|
-
// cannot auto-continue a prior session in the same CWD ($HOME).
|
|
347
|
-
options.continue = false;
|
|
348
|
-
options.sessionId = randomUUID();
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
if (mcpServers) {
|
|
352
|
-
options.mcpServers = mcpServers;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
const agentDefs = getAgentDefinitions();
|
|
356
|
-
if (Object.keys(agentDefs).length > 0) {
|
|
357
|
-
options.agents = agentDefs;
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
queryHandle = query({
|
|
361
|
-
prompt: stream as any,
|
|
362
|
-
options: options as any,
|
|
179
|
+
model: contextModel ?? undefined,
|
|
180
|
+
mcpServers,
|
|
181
|
+
resume: sessionId ?? false,
|
|
182
|
+
subagents: getAgentDefinitions(),
|
|
183
|
+
interactive: true,
|
|
363
184
|
});
|
|
364
|
-
registerActiveHandle(room,
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
for await (const message of queryHandle!) {
|
|
370
|
-
if (message.type === "system" && message.subtype === "init") {
|
|
371
|
-
const newId = message.session_id;
|
|
372
|
-
if (!sessionId || newId !== sessionId) {
|
|
373
|
-
sessionId = newId;
|
|
374
|
-
await Session.create(sessionId, room);
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
if (pending && !pending.userSaved) {
|
|
378
|
-
await Message.save({
|
|
379
|
-
sessionId,
|
|
380
|
-
room,
|
|
381
|
-
sender: "user",
|
|
382
|
-
content: pending.userMessage,
|
|
383
|
-
isFromAgent: false,
|
|
384
|
-
});
|
|
385
|
-
pending.userSaved = true;
|
|
386
|
-
messageCount++;
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
// Stream events: text deltas, thinking deltas, block lifecycle
|
|
391
|
-
if (message.type === "stream_event" && pending) {
|
|
392
|
-
const event = (message as any).event;
|
|
393
|
-
|
|
394
|
-
if (event?.type === "content_block_delta") {
|
|
395
|
-
const delta = event.delta;
|
|
396
|
-
if (delta?.type === "text_delta" && delta.text) {
|
|
397
|
-
pending.accumulatedText += delta.text;
|
|
398
|
-
pending.onStream?.(pending.accumulatedText);
|
|
399
|
-
}
|
|
400
|
-
if (delta?.type === "thinking_delta" && delta.thinking) {
|
|
401
|
-
pending.accumulatedThinking += delta.thinking;
|
|
402
|
-
// Only update on complete lines (newline boundary)
|
|
403
|
-
const lines = pending.accumulatedThinking.split("\n");
|
|
404
|
-
if (lines.length > 1) {
|
|
405
|
-
// Show the last complete line (not the partial one being typed)
|
|
406
|
-
const completeLine = lines[lines.length - 2]?.trim();
|
|
407
|
-
if (completeLine && completeLine !== pending.lastThinkingLine) {
|
|
408
|
-
pending.lastThinkingLine = completeLine;
|
|
409
|
-
pending.onActivity?.(truncate(completeLine, 70));
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
if (event?.type === "content_block_start") {
|
|
416
|
-
const block = event.content_block;
|
|
417
|
-
if (block?.type === "thinking") {
|
|
418
|
-
pending.accumulatedThinking = "";
|
|
419
|
-
pending.lastThinkingLine = "";
|
|
420
|
-
pending.onActivity?.("thinking...");
|
|
421
|
-
}
|
|
422
|
-
// tool_use: don't show here — wait for tool_use_summary with full input
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
if (event?.type === "content_block_stop") {
|
|
426
|
-
pending.accumulatedThinking = "";
|
|
427
|
-
pending.lastThinkingLine = "";
|
|
428
|
-
}
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
if (message.type === "tool_use_summary" && pending) {
|
|
432
|
-
const msg = message as any;
|
|
433
|
-
const name = msg.tool_name || "tool";
|
|
434
|
-
pending.onActivity?.(formatToolUse(name, msg.tool_input));
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
if (message.type === "tool_progress" && pending) {
|
|
438
|
-
const msg = message as any;
|
|
439
|
-
const toolName = msg.tool_name;
|
|
440
|
-
const content = msg.content;
|
|
441
|
-
if (toolName === "Bash" && content) {
|
|
442
|
-
pending.onActivity?.(`$ ${truncate(content, 60)}`);
|
|
443
|
-
} else if (content) {
|
|
444
|
-
pending.onActivity?.(truncate(content, 70));
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
// Task/agent lifecycle
|
|
449
|
-
if (message.type === "system" && pending) {
|
|
450
|
-
const msg = message as any;
|
|
451
|
-
if (msg.subtype === "task_started" && msg.description) {
|
|
452
|
-
pending.onActivity?.(truncate(msg.description, 60));
|
|
453
|
-
}
|
|
454
|
-
if (msg.subtype === "task_progress" && msg.last_tool_name) {
|
|
455
|
-
pending.onActivity?.(msg.summary || msg.last_tool_name);
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
if (message.type === "result" && pending) {
|
|
460
|
-
const msg = message as any;
|
|
461
|
-
if (!message.is_error) {
|
|
462
|
-
const resultText = msg.result as string;
|
|
463
|
-
const costUsd = msg.total_cost_usd as number;
|
|
464
|
-
const turns = msg.num_turns as number;
|
|
465
|
-
|
|
466
|
-
const metadata: Record<string, unknown> = {
|
|
467
|
-
cost_usd: costUsd,
|
|
468
|
-
turns,
|
|
469
|
-
duration_ms: msg.duration_ms,
|
|
470
|
-
duration_api_ms: msg.duration_api_ms,
|
|
471
|
-
stop_reason: msg.stop_reason,
|
|
472
|
-
terminal_reason: msg.terminal_reason,
|
|
473
|
-
session_id: msg.session_id,
|
|
474
|
-
subtype: msg.subtype,
|
|
475
|
-
usage: msg.usage,
|
|
476
|
-
model_usage: msg.modelUsage,
|
|
477
|
-
};
|
|
478
|
-
|
|
479
|
-
let messageId: number | undefined;
|
|
480
|
-
if (sessionId && resultText) {
|
|
481
|
-
const saveParams = {
|
|
482
|
-
sessionId,
|
|
483
|
-
room,
|
|
484
|
-
sender: "nia",
|
|
485
|
-
content: resultText,
|
|
486
|
-
isFromAgent: true,
|
|
487
|
-
deliveryStatus: "pending" as const,
|
|
488
|
-
metadata,
|
|
489
|
-
};
|
|
490
|
-
try {
|
|
491
|
-
messageId = await Message.save(saveParams);
|
|
492
|
-
} catch {
|
|
493
|
-
messageId = await Message.save({
|
|
494
|
-
...saveParams,
|
|
495
|
-
metadata: undefined,
|
|
496
|
-
});
|
|
497
|
-
}
|
|
498
|
-
await Session.touch(sessionId);
|
|
499
|
-
Session.accumulateMetadata(sessionId, {
|
|
500
|
-
...metadata,
|
|
501
|
-
channel,
|
|
502
|
-
}).catch(() => {});
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
await ActiveEngine.unregister(room);
|
|
506
|
-
clearLongRunningTimer();
|
|
507
|
-
retryCount = 0;
|
|
508
|
-
pending.resolve({
|
|
509
|
-
result: resultText,
|
|
510
|
-
costUsd,
|
|
511
|
-
turns,
|
|
512
|
-
messageId,
|
|
513
|
-
});
|
|
514
|
-
pending = null;
|
|
515
|
-
resetIdleTimer();
|
|
516
|
-
} else {
|
|
517
|
-
const errors = msg.errors;
|
|
518
|
-
const rawError = errors?.join(", ") || "unknown error";
|
|
519
|
-
|
|
520
|
-
// Retry on transient API errors (500, overloaded, rate-limit)
|
|
521
|
-
if (retryCount < MAX_SEND_RETRIES && isRetryableApiError(rawError)) {
|
|
522
|
-
const delay = SEND_RETRY_DELAYS[retryCount] ?? 8_000;
|
|
523
|
-
retryCount++;
|
|
524
|
-
log.warn(
|
|
525
|
-
{ room, attempt: retryCount, error: rawError, delayMs: delay },
|
|
526
|
-
"retrying chat send after transient API error",
|
|
527
|
-
);
|
|
528
|
-
const retryPending = pending;
|
|
529
|
-
pending = null;
|
|
530
|
-
clearLongRunningTimer();
|
|
531
|
-
|
|
532
|
-
// Tear down current query and restart after delay
|
|
533
|
-
teardown();
|
|
534
|
-
await sleep(delay);
|
|
535
|
-
startQuery();
|
|
536
|
-
|
|
537
|
-
// Re-send: the user message is already saved in DB, so mark it saved
|
|
538
|
-
pending = {
|
|
539
|
-
...retryPending,
|
|
540
|
-
userSaved: true,
|
|
541
|
-
accumulatedText: "",
|
|
542
|
-
accumulatedThinking: "",
|
|
543
|
-
lastThinkingLine: "",
|
|
544
|
-
};
|
|
545
|
-
retryPending.onActivity?.("retrying after API error...");
|
|
546
|
-
stream!.push(retryPending.userMessage);
|
|
547
|
-
} else {
|
|
548
|
-
const errorText = formatChatError(rawError);
|
|
549
|
-
log.error(
|
|
550
|
-
{
|
|
551
|
-
room,
|
|
552
|
-
error: rawError,
|
|
553
|
-
errors,
|
|
554
|
-
subtype: msg.subtype,
|
|
555
|
-
terminal_reason: msg.terminal_reason,
|
|
556
|
-
session_id: msg.session_id,
|
|
557
|
-
},
|
|
558
|
-
"chat send failed with SDK result error",
|
|
559
|
-
);
|
|
560
|
-
await ActiveEngine.unregister(room);
|
|
561
|
-
clearLongRunningTimer();
|
|
562
|
-
pending.resolve({ result: errorText, costUsd: 0, turns: 0 });
|
|
563
|
-
pending = null;
|
|
564
|
-
retryCount = 0;
|
|
565
|
-
resetIdleTimer();
|
|
566
|
-
}
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
// Stream ended without a result — subprocess exited or was killed
|
|
572
|
-
if (pending) {
|
|
573
|
-
const partial = pending.accumulatedText;
|
|
574
|
-
log.error(
|
|
575
|
-
{ room, partialChars: partial.length },
|
|
576
|
-
"query stream ended without result, rejecting pending request",
|
|
577
|
-
);
|
|
578
|
-
await ActiveEngine.unregister(room).catch(() => {});
|
|
579
|
-
pending.reject(new Error(`stream ended without result (${partial.length} chars accumulated)`));
|
|
580
|
-
pending = null;
|
|
581
|
-
}
|
|
582
|
-
} catch (err) {
|
|
583
|
-
if (pending) {
|
|
584
|
-
await ActiveEngine.unregister(room).catch(() => {});
|
|
585
|
-
pending.reject(err instanceof Error ? err : new Error(String(err)));
|
|
586
|
-
pending = null;
|
|
587
|
-
}
|
|
588
|
-
} finally {
|
|
589
|
-
clearLongRunningTimer();
|
|
590
|
-
unregisterActiveHandle(room);
|
|
591
|
-
alive = false;
|
|
592
|
-
stream = null;
|
|
593
|
-
queryHandle = null;
|
|
594
|
-
}
|
|
595
|
-
})();
|
|
185
|
+
registerActiveHandle(room, (reason) => {
|
|
186
|
+
s.abort(reason);
|
|
187
|
+
});
|
|
188
|
+
session = s;
|
|
189
|
+
return s;
|
|
596
190
|
}
|
|
597
191
|
|
|
598
192
|
return {
|
|
@@ -608,6 +202,7 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
|
|
|
608
202
|
// Clear idle timer — engine is not idle while processing a request
|
|
609
203
|
clearIdleTimer();
|
|
610
204
|
startLongRunningTimer();
|
|
205
|
+
inFlight = true;
|
|
611
206
|
|
|
612
207
|
// Cancel any pending finalization — session is active again
|
|
613
208
|
if (sessionId) {
|
|
@@ -616,52 +211,130 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
|
|
|
616
211
|
|
|
617
212
|
await ActiveEngine.register(room, channel);
|
|
618
213
|
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
// Save user message to DB if session already exists (resumed session).
|
|
624
|
-
// 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.
|
|
625
216
|
let userSaved = false;
|
|
626
217
|
if (sessionId) {
|
|
627
|
-
await Message.save({
|
|
628
|
-
sessionId,
|
|
629
|
-
room,
|
|
630
|
-
sender: "user",
|
|
631
|
-
content: userMessage,
|
|
632
|
-
isFromAgent: false,
|
|
633
|
-
});
|
|
218
|
+
await Message.save({ sessionId, room, sender: "user", content: userMessage, isFromAgent: false });
|
|
634
219
|
await Session.touch(sessionId);
|
|
635
220
|
userSaved = true;
|
|
636
221
|
messageCount++;
|
|
637
222
|
}
|
|
638
223
|
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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;
|
|
653
325
|
},
|
|
654
326
|
|
|
655
327
|
async close() {
|
|
656
328
|
// Enqueue finalization — processed by daemon or inline if we are the daemon
|
|
657
|
-
if (sessionId && messageCount > 0 && !
|
|
329
|
+
if (sessionId && messageCount > 0 && !inFlight) {
|
|
658
330
|
try {
|
|
659
331
|
await finalizeSession(sessionId, room);
|
|
660
332
|
} catch (err) {
|
|
661
333
|
log.error({ err, room }, "finalization enqueue failed during close");
|
|
662
334
|
}
|
|
663
335
|
}
|
|
664
|
-
await
|
|
336
|
+
await teardown();
|
|
337
|
+
await ActiveEngine.unregister(room).catch(() => {});
|
|
665
338
|
},
|
|
666
339
|
};
|
|
667
340
|
}
|