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/commands/validate.ts
CHANGED
|
@@ -76,14 +76,24 @@ export function validateConfig(): Result {
|
|
|
76
76
|
messages.push(`${WARN} database_url not set (will use default)`);
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
//
|
|
79
|
+
// Backends (primary runner + fallback chain)
|
|
80
|
+
const BACKENDS = ["claude", "codex", "gemini"];
|
|
80
81
|
const runner = raw.runner as string | undefined;
|
|
81
|
-
if (runner && runner
|
|
82
|
-
messages.push(`${FAIL} runner must be
|
|
82
|
+
if (runner && !BACKENDS.includes(runner)) {
|
|
83
|
+
messages.push(`${FAIL} runner must be one of ${BACKENDS.join(", ")}, got "${runner}"`);
|
|
83
84
|
ok = false;
|
|
84
85
|
} else if (runner) {
|
|
85
86
|
messages.push(`${PASS} runner: ${runner}`);
|
|
86
87
|
}
|
|
88
|
+
if (raw.fallback !== undefined) {
|
|
89
|
+
const fb = raw.fallback;
|
|
90
|
+
if (!Array.isArray(fb) || fb.some((b) => !BACKENDS.includes(b as string))) {
|
|
91
|
+
messages.push(`${FAIL} fallback must be an array of ${BACKENDS.join(", ")}`);
|
|
92
|
+
ok = false;
|
|
93
|
+
} else {
|
|
94
|
+
messages.push(`${PASS} fallback: [${fb.join(", ")}]`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
87
97
|
|
|
88
98
|
// Session finalization
|
|
89
99
|
const sf = raw.session_finalization as Record<string, unknown> | undefined;
|
package/src/core/daemon.ts
CHANGED
|
@@ -14,6 +14,8 @@ import { startScheduler, stopScheduler, recomputeAllNextRuns } from "./scheduler
|
|
|
14
14
|
import { startAlive, stopAlive } from "./alive";
|
|
15
15
|
import { createNiaMcpServer } from "../mcp/server";
|
|
16
16
|
import { setMcpFactory } from "../mcp";
|
|
17
|
+
import { startMcpEndpoint, stopMcpEndpoint } from "../agent/mcp-endpoint";
|
|
18
|
+
import { NIA_TOOLS } from "../mcp/tools/table";
|
|
17
19
|
import { processPending, cleanupOldRequests } from "./finalizer";
|
|
18
20
|
import { closeAllActiveHandles } from "./active-handles";
|
|
19
21
|
import { clearForceShutdownRequest, consumeForceShutdownRequest, requestForceShutdown } from "./force-shutdown";
|
|
@@ -275,6 +277,11 @@ export async function runDaemon(): Promise<void> {
|
|
|
275
277
|
setMcpFactory((ctx) => ({ nia: createNiaMcpServer(ctx) }));
|
|
276
278
|
log.info("MCP server factory initialized");
|
|
277
279
|
|
|
280
|
+
// Start the loopback MCP endpoint that out-of-process CLI backends (Codex/
|
|
281
|
+
// Gemini) connect back to for Nia's tools. Tools are injected here (the
|
|
282
|
+
// composition root) so the endpoint module stays free of the handler chain.
|
|
283
|
+
await startMcpEndpoint(NIA_TOOLS);
|
|
284
|
+
|
|
278
285
|
// Register and start channels
|
|
279
286
|
registerAllChannels();
|
|
280
287
|
let channels: Channel[] = [];
|
|
@@ -386,6 +393,7 @@ export async function runDaemon(): Promise<void> {
|
|
|
386
393
|
|
|
387
394
|
stopAlive();
|
|
388
395
|
stopScheduler();
|
|
396
|
+
stopMcpEndpoint();
|
|
389
397
|
await stopChannels(channels);
|
|
390
398
|
|
|
391
399
|
try {
|
package/src/core/runner.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { homedir } from "os";
|
|
2
2
|
import { existsSync } from "fs";
|
|
3
3
|
import { randomUUID } from "crypto";
|
|
4
|
-
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
5
4
|
import type { JobInput, JobResult } from "../types";
|
|
6
5
|
import { appendAudit, readState, writeState } from "../utils/logger";
|
|
7
6
|
import type { AuditEntry, JobState } from "../types";
|
|
@@ -11,14 +10,11 @@ import { buildEmployeePrompt } from "../chat/employee-prompt";
|
|
|
11
10
|
import { getEmployee } from "./employees";
|
|
12
11
|
import { scanAgents } from "./agents";
|
|
13
12
|
import { buildJobPrompt } from "./job-prompt";
|
|
14
|
-
import { truncate, formatToolUse } from "../utils/format-activity";
|
|
15
13
|
import { getMcpServers, type McpSourceContext } from "../mcp";
|
|
16
14
|
import { ActiveEngine } from "../db/models";
|
|
17
15
|
import { log } from "../utils/log";
|
|
18
|
-
import { isRetryableApiError, sleep } from "../utils/retry";
|
|
19
16
|
import { registerActiveHandle, unregisterActiveHandle } from "./active-handles";
|
|
20
|
-
import {
|
|
21
|
-
import { getSdkHooks } from "./sdk-hooks";
|
|
17
|
+
import { getBackend, resolveBackends, type AgentBackend, type AgentSession, type AgentSessionContext } from "../agent";
|
|
22
18
|
|
|
23
19
|
export { buildWorkingMemory } from "./job-prompt";
|
|
24
20
|
|
|
@@ -29,244 +25,122 @@ interface RunnerOutput {
|
|
|
29
25
|
sessionId: string;
|
|
30
26
|
terminalReason?: string;
|
|
31
27
|
error?: string;
|
|
28
|
+
/** The backend reported provider-down — caller may fail over to the next backend. */
|
|
29
|
+
providerDown?: boolean;
|
|
32
30
|
}
|
|
33
31
|
|
|
34
32
|
// ---------------------------------------------------------------------------
|
|
35
|
-
//
|
|
33
|
+
// Shared backend run consumer
|
|
36
34
|
// ---------------------------------------------------------------------------
|
|
37
35
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
async function
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
codexPath,
|
|
47
|
-
"exec",
|
|
48
|
-
fullPrompt,
|
|
49
|
-
"-C",
|
|
50
|
-
cwd,
|
|
51
|
-
"--json",
|
|
52
|
-
"--skip-git-repo-check",
|
|
53
|
-
"--dangerously-bypass-approvals-and-sandbox",
|
|
54
|
-
];
|
|
55
|
-
if (model && model !== "default") {
|
|
56
|
-
args.splice(3, 0, "-m", model);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const CODEX_EXCLUDED = new Set([
|
|
60
|
-
"ANTHROPIC_API_KEY",
|
|
61
|
-
"OPENAI_API_KEY",
|
|
62
|
-
"GEMINI_API_KEY",
|
|
63
|
-
"SLACK_BOT_TOKEN",
|
|
64
|
-
"SLACK_APP_TOKEN",
|
|
65
|
-
"TELEGRAM_BOT_TOKEN",
|
|
66
|
-
"TWILIO_AUTH_TOKEN",
|
|
67
|
-
"DATABASE_URL",
|
|
68
|
-
]);
|
|
69
|
-
const codexEnv = Object.fromEntries(
|
|
70
|
-
Object.entries(process.env).filter(([k]) => !CODEX_EXCLUDED.has(k))
|
|
71
|
-
);
|
|
72
|
-
|
|
73
|
-
const proc = Bun.spawn(args, {
|
|
74
|
-
stdout: "pipe",
|
|
75
|
-
stderr: "pipe",
|
|
76
|
-
env: codexEnv,
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
const stdout = await new Response(proc.stdout).text();
|
|
80
|
-
const stderr = await new Response(proc.stderr).text();
|
|
81
|
-
const exitCode = await proc.exited;
|
|
82
|
-
|
|
83
|
-
let agentText = "";
|
|
84
|
-
let sessionId = "";
|
|
85
|
-
for (const line of stdout.split("\n")) {
|
|
86
|
-
if (!line.trim()) continue;
|
|
87
|
-
try {
|
|
88
|
-
const event = JSON.parse(line);
|
|
89
|
-
if (event.type === "thread.started" && event.thread_id) {
|
|
90
|
-
sessionId = event.thread_id;
|
|
91
|
-
}
|
|
92
|
-
if (event.type === "item.completed" && event.item?.type === "agent_message") {
|
|
93
|
-
agentText = event.item.text || "";
|
|
94
|
-
}
|
|
95
|
-
} catch {}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
if (exitCode !== 0) {
|
|
99
|
-
return {
|
|
100
|
-
agentText,
|
|
101
|
-
sessionId,
|
|
102
|
-
error: stderr.trim() || `exit code ${exitCode}`,
|
|
103
|
-
};
|
|
104
|
-
}
|
|
105
|
-
return { agentText, sessionId };
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// ---------------------------------------------------------------------------
|
|
109
|
-
// Claude Agent SDK runner
|
|
110
|
-
// ---------------------------------------------------------------------------
|
|
111
|
-
|
|
112
|
-
export async function runJobWithClaude(
|
|
113
|
-
systemPrompt: string,
|
|
114
|
-
jobPrompt: string,
|
|
115
|
-
cwd: string,
|
|
36
|
+
/**
|
|
37
|
+
* Drive one backend session to a `RunnerOutput`: map `AgentEvent`s to activity +
|
|
38
|
+
* result/error, and handle abort. Shared by the Claude and Codex job paths so
|
|
39
|
+
* the consume logic lives in exactly one place.
|
|
40
|
+
*/
|
|
41
|
+
async function consumeBackendRun(
|
|
42
|
+
session: AgentSession,
|
|
43
|
+
prompt: string,
|
|
116
44
|
onActivity?: ActivityCallback,
|
|
117
|
-
model?: string,
|
|
118
|
-
sourceCtx?: McpSourceContext,
|
|
119
45
|
activeRoom?: string,
|
|
120
46
|
): Promise<RunnerOutput> {
|
|
121
|
-
const sessionId = randomUUID();
|
|
122
|
-
|
|
123
|
-
// One-shot async iterable: emit a single user message then close
|
|
124
|
-
async function* singleMessage() {
|
|
125
|
-
yield {
|
|
126
|
-
type: "user" as const,
|
|
127
|
-
message: { role: "user" as const, content: jobPrompt },
|
|
128
|
-
parent_tool_use_id: null,
|
|
129
|
-
session_id: "",
|
|
130
|
-
};
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const options: Record<string, unknown> = {
|
|
134
|
-
systemPrompt,
|
|
135
|
-
cwd,
|
|
136
|
-
permissionMode: "bypassPermissions",
|
|
137
|
-
sessionId,
|
|
138
|
-
skills: getSdkSkillsSetting(),
|
|
139
|
-
hooks: getSdkHooks(),
|
|
140
|
-
};
|
|
141
|
-
|
|
142
|
-
if (model && model !== "default") {
|
|
143
|
-
options.model = model;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
const mcpServers = getMcpServers(sourceCtx);
|
|
147
|
-
if (mcpServers) {
|
|
148
|
-
options.mcpServers = mcpServers;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
const handle = query({
|
|
152
|
-
prompt: singleMessage() as any,
|
|
153
|
-
options: options as any,
|
|
154
|
-
});
|
|
155
47
|
let abortReason: string | null = null;
|
|
156
48
|
if (activeRoom) {
|
|
157
49
|
registerActiveHandle(activeRoom, (reason) => {
|
|
158
50
|
abortReason = reason;
|
|
159
|
-
|
|
51
|
+
session.abort(reason);
|
|
160
52
|
});
|
|
161
53
|
}
|
|
162
54
|
|
|
163
55
|
let agentText = "";
|
|
164
|
-
let actualSessionId = sessionId;
|
|
165
56
|
let terminalReason: string | undefined;
|
|
166
|
-
let
|
|
167
|
-
let
|
|
57
|
+
let error: string | undefined;
|
|
58
|
+
let providerDown = false;
|
|
168
59
|
|
|
169
60
|
try {
|
|
170
|
-
for await (const
|
|
171
|
-
if (
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
if (
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
const event = msg.event;
|
|
181
|
-
if (event?.type === "content_block_start" && event.content_block?.type === "thinking") {
|
|
182
|
-
accumulatedThinking = "";
|
|
183
|
-
lastThinkingLine = "";
|
|
184
|
-
onActivity("thinking...");
|
|
185
|
-
}
|
|
186
|
-
if (event?.type === "content_block_delta") {
|
|
187
|
-
const delta = event.delta;
|
|
188
|
-
if (delta?.type === "thinking_delta" && delta.thinking) {
|
|
189
|
-
accumulatedThinking += delta.thinking;
|
|
190
|
-
const lines = accumulatedThinking.split("\n");
|
|
191
|
-
if (lines.length > 1) {
|
|
192
|
-
const completeLine = lines[lines.length - 2]?.trim();
|
|
193
|
-
if (completeLine && completeLine !== lastThinkingLine) {
|
|
194
|
-
lastThinkingLine = completeLine;
|
|
195
|
-
onActivity(truncate(completeLine, 70));
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
if (event?.type === "content_block_stop") {
|
|
201
|
-
accumulatedThinking = "";
|
|
202
|
-
lastThinkingLine = "";
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
if (message.type === "tool_use_summary") {
|
|
207
|
-
const name = msg.tool_name || "tool";
|
|
208
|
-
onActivity(formatToolUse(name, msg.tool_input));
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
if (message.type === "tool_progress") {
|
|
212
|
-
if (msg.tool_name === "Bash" && msg.content) {
|
|
213
|
-
onActivity(`$ ${truncate(msg.content, 60)}`);
|
|
214
|
-
} else if (msg.content) {
|
|
215
|
-
onActivity(truncate(msg.content, 70));
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
if (message.type === "system") {
|
|
220
|
-
if (msg.subtype === "task_started" && msg.description) {
|
|
221
|
-
onActivity(truncate(msg.description, 60));
|
|
222
|
-
}
|
|
223
|
-
if (msg.subtype === "task_progress" && msg.last_tool_name) {
|
|
224
|
-
onActivity(msg.summary || msg.last_tool_name);
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
if (message.type === "result") {
|
|
230
|
-
if (!(message as any).is_error) {
|
|
231
|
-
agentText = (message as any).result || "";
|
|
232
|
-
terminalReason = (message as any).terminal_reason;
|
|
233
|
-
} else {
|
|
234
|
-
const errors = (message as any).errors;
|
|
235
|
-
terminalReason = (message as any).terminal_reason;
|
|
236
|
-
return {
|
|
237
|
-
agentText: "",
|
|
238
|
-
sessionId: actualSessionId,
|
|
239
|
-
terminalReason,
|
|
240
|
-
error: errors?.join(", ") || "unknown error",
|
|
241
|
-
};
|
|
242
|
-
}
|
|
61
|
+
for await (const ev of session.send(prompt)) {
|
|
62
|
+
if (ev.type === "thinking") onActivity?.(ev.delta);
|
|
63
|
+
else if (ev.type === "tool") onActivity?.(ev.summary ?? ev.name);
|
|
64
|
+
else if (ev.type === "result") {
|
|
65
|
+
agentText = ev.text;
|
|
66
|
+
terminalReason = ev.terminalReason;
|
|
67
|
+
} else if (ev.type === "error") {
|
|
68
|
+
error = ev.message;
|
|
69
|
+
terminalReason = ev.terminalReason;
|
|
70
|
+
providerDown = ev.providerDown;
|
|
243
71
|
}
|
|
244
72
|
}
|
|
245
73
|
} catch (err) {
|
|
246
74
|
if (abortReason) {
|
|
247
75
|
return {
|
|
248
76
|
agentText: "",
|
|
249
|
-
sessionId:
|
|
77
|
+
sessionId: session.backendSessionId ?? "",
|
|
250
78
|
terminalReason: "aborted",
|
|
251
79
|
error: abortReason,
|
|
252
80
|
};
|
|
253
81
|
}
|
|
254
82
|
throw err;
|
|
255
83
|
} finally {
|
|
256
|
-
|
|
84
|
+
await session.close();
|
|
257
85
|
if (activeRoom) unregisterActiveHandle(activeRoom);
|
|
258
86
|
}
|
|
259
87
|
|
|
260
88
|
if (abortReason) {
|
|
261
|
-
return {
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
89
|
+
return { agentText: "", sessionId: session.backendSessionId ?? "", terminalReason: "aborted", error: abortReason };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { agentText, sessionId: session.backendSessionId ?? "", terminalReason, error, providerDown };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Run a job across the ordered backend chain: try the primary, and on a
|
|
97
|
+
* provider-down result fail over to the next backend (replaying the same prompt;
|
|
98
|
+
* continuity comes from Nia's own context, not a cross-backend session resume).
|
|
99
|
+
*/
|
|
100
|
+
export async function runJobAcrossBackends(
|
|
101
|
+
backends: AgentBackend[],
|
|
102
|
+
sessionCtx: AgentSessionContext,
|
|
103
|
+
jobPrompt: string,
|
|
104
|
+
onActivity?: ActivityCallback,
|
|
105
|
+
activeRoom?: string,
|
|
106
|
+
): Promise<RunnerOutput> {
|
|
107
|
+
let output: RunnerOutput = { agentText: "", sessionId: "", error: "no backend configured" };
|
|
108
|
+
for (let i = 0; i < backends.length; i++) {
|
|
109
|
+
const backend = backends[i]!;
|
|
110
|
+
const session = await backend.openSession(sessionCtx);
|
|
111
|
+
output = await consumeBackendRun(session, jobPrompt, onActivity, activeRoom);
|
|
112
|
+
if (!output.providerDown) return output;
|
|
113
|
+
const next = backends[i + 1];
|
|
114
|
+
if (next) log.warn({ from: backend.name, to: next.name }, "provider down, failing over to next backend");
|
|
267
115
|
}
|
|
116
|
+
return output;
|
|
117
|
+
}
|
|
268
118
|
|
|
269
|
-
|
|
119
|
+
/**
|
|
120
|
+
* Run a one-shot job on the in-process Claude backend. Kept as a named export
|
|
121
|
+
* (signature stable) because `alive.ts` and `runTask` call it directly.
|
|
122
|
+
*/
|
|
123
|
+
export async function runJobWithClaude(
|
|
124
|
+
systemPrompt: string,
|
|
125
|
+
jobPrompt: string,
|
|
126
|
+
cwd: string,
|
|
127
|
+
onActivity?: ActivityCallback,
|
|
128
|
+
model?: string,
|
|
129
|
+
sourceCtx?: McpSourceContext,
|
|
130
|
+
activeRoom?: string,
|
|
131
|
+
): Promise<RunnerOutput> {
|
|
132
|
+
const mcpServers = (getMcpServers(sourceCtx) as Record<string, unknown> | undefined) ?? undefined;
|
|
133
|
+
const session = await getBackend().openSession({
|
|
134
|
+
room: activeRoom ?? `_oneshot/${randomUUID()}`,
|
|
135
|
+
channel: "system",
|
|
136
|
+
systemPrompt,
|
|
137
|
+
cwd,
|
|
138
|
+
model,
|
|
139
|
+
mcpServers,
|
|
140
|
+
source: sourceCtx,
|
|
141
|
+
resume: false,
|
|
142
|
+
});
|
|
143
|
+
return consumeBackendRun(session, jobPrompt, onActivity, activeRoom);
|
|
270
144
|
}
|
|
271
145
|
|
|
272
146
|
// ---------------------------------------------------------------------------
|
|
@@ -354,27 +228,22 @@ export async function runJob(job: JobInput, onActivity?: ActivityCallback): Prom
|
|
|
354
228
|
// Model priority: job.model > agent.model > config.model
|
|
355
229
|
const resolvedModel = job.model || agentModel || config.model;
|
|
356
230
|
|
|
357
|
-
const MAX_API_RETRIES = 2;
|
|
358
|
-
const RETRY_DELAYS = [3_000, 8_000]; // 3s, then 8s
|
|
359
|
-
|
|
360
231
|
const jobSourceCtx: McpSourceContext = { jobName: job.name, channel: "system" };
|
|
361
232
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
}
|
|
377
|
-
}
|
|
233
|
+
// One context serves every backend: Claude uses the pre-built in-process
|
|
234
|
+
// mcpServers; Codex/Gemini use `source` to wire the loopback endpoint. Run
|
|
235
|
+
// across the configured backend chain so a provider-down primary fails over.
|
|
236
|
+
const sessionCtx: AgentSessionContext = {
|
|
237
|
+
room,
|
|
238
|
+
channel: "system",
|
|
239
|
+
systemPrompt,
|
|
240
|
+
cwd,
|
|
241
|
+
model: resolvedModel,
|
|
242
|
+
mcpServers: (getMcpServers(jobSourceCtx) as Record<string, unknown> | undefined) ?? undefined,
|
|
243
|
+
source: jobSourceCtx,
|
|
244
|
+
resume: false,
|
|
245
|
+
};
|
|
246
|
+
output = await runJobAcrossBackends(resolveBackends(), sessionCtx, jobPrompt, onActivity, room);
|
|
378
247
|
|
|
379
248
|
const duration_ms = Math.round(performance.now() - startMs);
|
|
380
249
|
const ok = !output.error;
|