niahere 0.4.5 → 0.5.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 +2 -2
- package/src/agent/backends/claude-normalize.ts +5 -6
- package/src/agent/backends/claude.ts +4 -3
- package/src/agent/backends/codex-normalize.ts +19 -3
- package/src/agent/backends/codex.ts +99 -34
- package/src/agent/chain.ts +60 -0
- package/src/agent/failure.ts +90 -0
- package/src/agent/health.ts +41 -0
- package/src/agent/index.ts +3 -1
- package/src/agent/mcp-endpoint.ts +2 -1
- package/src/agent/models.ts +59 -0
- package/src/agent/registry.ts +54 -29
- package/src/agent/types.ts +9 -4
- package/src/chat/engine.ts +67 -27
- package/src/commands/validate.ts +17 -13
- package/src/core/alive.ts +2 -2
- package/src/core/runner.ts +58 -42
- package/src/core/scheduler.ts +18 -4
- package/src/mcp/gate.ts +46 -0
- package/src/mcp/server.ts +2 -1
- package/src/types/config.ts +4 -4
- package/src/utils/config.ts +6 -8
- package/src/utils/retry.ts +0 -17
package/src/agent/registry.ts
CHANGED
|
@@ -1,51 +1,76 @@
|
|
|
1
|
+
import { existsSync } from "fs";
|
|
1
2
|
import type { AgentBackend } from "./types";
|
|
3
|
+
import type { ChainEntry } from "./chain";
|
|
2
4
|
import { ClaudeBackend } from "./backends/claude";
|
|
3
|
-
import { CodexBackend } from "./backends/codex";
|
|
5
|
+
import { CodexBackend, resolveCodexBin } from "./backends/codex";
|
|
4
6
|
import { getConfig } from "../utils/config";
|
|
7
|
+
import { resolveModel, providerDefault, PROVIDER_ORDER, type ProviderName } from "./models";
|
|
5
8
|
|
|
6
|
-
/**
|
|
7
|
-
* Backend selection — the ONE place backend identity is resolved. Consumers call
|
|
8
|
-
* `getBackend()` and depend only on the `AgentBackend` interface, so no
|
|
9
|
-
* `if (backend === …)` ever leaks into the orchestration loop.
|
|
10
|
-
*
|
|
11
|
-
* Phase 1: always the in-process Claude backend. Phase 2+ adds Codex/Gemini and
|
|
12
|
-
* a role/per-job selector; Phase 3 adds the ordered-fallback failover list.
|
|
13
|
-
*/
|
|
9
|
+
/** The ONE place backend identity is resolved. */
|
|
14
10
|
let claudeBackend: ClaudeBackend | null = null;
|
|
15
11
|
let codexBackend: CodexBackend | null = null;
|
|
16
12
|
let override: AgentBackend | null = null;
|
|
17
|
-
let chainOverride:
|
|
13
|
+
let chainOverride: ChainEntry[] | null = null;
|
|
18
14
|
|
|
19
|
-
export function getBackend(name?:
|
|
15
|
+
export function getBackend(name?: ProviderName): AgentBackend {
|
|
20
16
|
if (override) return override;
|
|
21
|
-
if (name === "codex")
|
|
22
|
-
|
|
23
|
-
return codexBackend;
|
|
24
|
-
}
|
|
25
|
-
if (!claudeBackend) claudeBackend = new ClaudeBackend();
|
|
26
|
-
return claudeBackend;
|
|
17
|
+
if (name === "codex") return (codexBackend ??= new CodexBackend());
|
|
18
|
+
return (claudeBackend ??= new ClaudeBackend());
|
|
27
19
|
}
|
|
28
20
|
|
|
29
|
-
/** Test seam: force `getBackend()` to return a specific backend;
|
|
21
|
+
/** Test seam: force `getBackend()` to return a specific backend; null resets. */
|
|
30
22
|
export function setBackend(backend: AgentBackend | null): void {
|
|
31
23
|
override = backend;
|
|
32
24
|
}
|
|
33
25
|
|
|
34
|
-
/** Test seam: force `
|
|
35
|
-
export function setBackendChain(
|
|
36
|
-
chainOverride =
|
|
26
|
+
/** Test seam: force `resolveChain()` to return a specific chain; null resets. */
|
|
27
|
+
export function setBackendChain(chain: ChainEntry[] | null): void {
|
|
28
|
+
chainOverride = chain;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Gemini is resolvable in config but has no adapter yet. */
|
|
32
|
+
const IMPLEMENTED: ProviderName[] = ["claude", "codex"];
|
|
33
|
+
|
|
34
|
+
function isAvailable(provider: ProviderName): boolean {
|
|
35
|
+
if (provider === "claude") return true;
|
|
36
|
+
if (provider === "codex") return existsSync(resolveCodexBin());
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ChainDeps {
|
|
41
|
+
available: (provider: ProviderName) => boolean;
|
|
37
42
|
}
|
|
38
43
|
|
|
39
44
|
/**
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
45
|
+
* Providers the config never named are appended with their default model, so a
|
|
46
|
+
* bare config still has somewhere to go — but only if they can run here.
|
|
47
|
+
* Configured models are always kept, so a misconfiguration surfaces as a real
|
|
48
|
+
* error rather than vanishing.
|
|
43
49
|
*/
|
|
44
|
-
export function
|
|
50
|
+
export function buildChain(
|
|
51
|
+
model: string,
|
|
52
|
+
fallbackModels: string[],
|
|
53
|
+
deps: ChainDeps = { available: isAvailable },
|
|
54
|
+
): ChainEntry[] {
|
|
55
|
+
const configured = [model, ...fallbackModels].map(resolveModel);
|
|
56
|
+
const named = new Set(configured.map((r) => r.provider));
|
|
57
|
+
const implicit = PROVIDER_ORDER.filter((p) => !named.has(p) && deps.available(p)).map(providerDefault);
|
|
58
|
+
|
|
59
|
+
const seen = new Set<string>();
|
|
60
|
+
const entries: ChainEntry[] = [];
|
|
61
|
+
for (const ref of [...configured, ...implicit]) {
|
|
62
|
+
if (!IMPLEMENTED.includes(ref.provider)) continue;
|
|
63
|
+
const key = `${ref.provider}:${ref.model ?? ""}`;
|
|
64
|
+
if (seen.has(key)) continue;
|
|
65
|
+
seen.add(key);
|
|
66
|
+
entries.push({ backend: getBackend(ref.provider), model: ref.model });
|
|
67
|
+
}
|
|
68
|
+
return entries;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function resolveChain(): ChainEntry[] {
|
|
45
72
|
if (chainOverride) return chainOverride;
|
|
46
|
-
if (override) return [override];
|
|
73
|
+
if (override) return [{ backend: override }];
|
|
47
74
|
const cfg = getConfig();
|
|
48
|
-
|
|
49
|
-
const names = [cfg.runner, ...cfg.fallback].filter((n) => !seen.has(n) && seen.add(n));
|
|
50
|
-
return names.map((n) => getBackend(n));
|
|
75
|
+
return buildChain(cfg.model, cfg.fallback_models);
|
|
51
76
|
}
|
package/src/agent/types.ts
CHANGED
|
@@ -32,9 +32,11 @@ export interface AgentUsage {
|
|
|
32
32
|
* - `text`/`thinking`: streamed reply / status (→ onStream / onActivity).
|
|
33
33
|
* - `tool`: a tool-call activity line.
|
|
34
34
|
* - `result`/`error`: terminal events ending a turn.
|
|
35
|
-
* - `error.retryable` (
|
|
36
|
-
*
|
|
37
|
-
*
|
|
35
|
+
* - `error.retryable` (the backend may retry in place) is independent of
|
|
36
|
+
* `error.failover`, which scopes how far the chain skips:
|
|
37
|
+
* `"model"` advances one entry (possibly the same provider, another model),
|
|
38
|
+
* `"provider"` skips that provider's remaining entries, absent stops the
|
|
39
|
+
* chain because the task itself failed.
|
|
38
40
|
*/
|
|
39
41
|
export type AgentEvent =
|
|
40
42
|
| { type: "session"; backendSessionId: string }
|
|
@@ -52,7 +54,10 @@ export type AgentEvent =
|
|
|
52
54
|
* Opaque to the orchestrator. */
|
|
53
55
|
metadata?: Record<string, unknown>;
|
|
54
56
|
}
|
|
55
|
-
| { type: "error"; message: string; retryable: boolean;
|
|
57
|
+
| { type: "error"; message: string; retryable: boolean; failover?: FailoverScope; terminalReason?: string };
|
|
58
|
+
|
|
59
|
+
/** How far the chain skips after a failure. See `AgentEvent`. */
|
|
60
|
+
export type FailoverScope = "model" | "provider";
|
|
56
61
|
|
|
57
62
|
export function isResultEvent(ev: AgentEvent): ev is Extract<AgentEvent, { type: "result" }> {
|
|
58
63
|
return ev.type === "result";
|
package/src/chat/engine.ts
CHANGED
|
@@ -10,9 +10,13 @@ import { finalizeSession, cancelPending } from "../core/finalizer";
|
|
|
10
10
|
import { log } from "../utils/log";
|
|
11
11
|
import { registerActiveHandle, unregisterActiveHandle } from "../core/active-handles";
|
|
12
12
|
import { resolveJobPrompt } from "../core/job-prompt";
|
|
13
|
-
import {
|
|
13
|
+
import { truncate } from "../utils/format-activity";
|
|
14
|
+
import { resolveChain, ChainCursor, describeEntry, type AgentSession, type FailoverScope } from "../agent";
|
|
15
|
+
import { scopeOf, parseFailure } from "../agent/failure";
|
|
14
16
|
|
|
15
17
|
const IDLE_TIMEOUT = 10 * 60 * 1000; // 10 minutes
|
|
18
|
+
const HANDOFF_MESSAGES = 20;
|
|
19
|
+
const HANDOFF_CHARS = 2000;
|
|
16
20
|
const LONG_RUNNING_WARN = 30 * 60 * 1000; // 30 minutes
|
|
17
21
|
const GENERIC_CHAT_ERROR = "💀";
|
|
18
22
|
|
|
@@ -29,8 +33,7 @@ export function formatChatError(rawError: string | null | undefined): string {
|
|
|
29
33
|
}
|
|
30
34
|
|
|
31
35
|
export function getChatErrorSignal(rawError: string | null | undefined): SendResult["signal"] | undefined {
|
|
32
|
-
|
|
33
|
-
return !error || error.toLowerCase() === "unknown error" ? "provider_down" : undefined;
|
|
36
|
+
return scopeOf(parseFailure(rawError), "provider") === "provider" ? "provider_down" : undefined;
|
|
34
37
|
}
|
|
35
38
|
|
|
36
39
|
export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine> {
|
|
@@ -96,11 +99,10 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
|
|
|
96
99
|
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}`;
|
|
97
100
|
}
|
|
98
101
|
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
let backendIndex = 0;
|
|
102
|
+
// A turn that cannot be served moves down the chain and answers the current
|
|
103
|
+
// message there (see send()).
|
|
104
|
+
const chain = resolveChain();
|
|
105
|
+
let cursor = new ChainCursor(chain);
|
|
104
106
|
|
|
105
107
|
let sessionId: string | null = null;
|
|
106
108
|
if (typeof resume === "string") {
|
|
@@ -110,13 +112,15 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
|
|
|
110
112
|
sessionId = await Session.getLatest(room);
|
|
111
113
|
}
|
|
112
114
|
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
if (sessionId && !(await backends[0]!.canResume(sessionId, cwd))) {
|
|
115
|
+
// Only resume if the head of the chain can actually take this session id.
|
|
116
|
+
if (sessionId && !(await cursor.current!.backend.canResume(sessionId, cwd))) {
|
|
116
117
|
sessionId = null;
|
|
117
118
|
}
|
|
118
119
|
|
|
119
120
|
let session: AgentSession | null = null;
|
|
121
|
+
// Prior turns, replayed into the system prompt when a failover starts a fresh
|
|
122
|
+
// session on another backend — the new one has no session to resume.
|
|
123
|
+
let handoff = "";
|
|
120
124
|
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
121
125
|
let longRunningTimer: ReturnType<typeof setTimeout> | null = null;
|
|
122
126
|
let messageCount = 0;
|
|
@@ -171,16 +175,28 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
|
|
|
171
175
|
unregisterActiveHandle(room);
|
|
172
176
|
}
|
|
173
177
|
|
|
174
|
-
/**
|
|
178
|
+
/** Transcript of the conversation so far, for a backend that cannot resume it. */
|
|
179
|
+
async function buildHandoff(currentMessage: string): Promise<string> {
|
|
180
|
+
const recent = await Message.getRecent(HANDOFF_MESSAGES, room).catch(() => []);
|
|
181
|
+
const lines = recent
|
|
182
|
+
.filter((m) => m.content !== currentMessage)
|
|
183
|
+
.map((m) => `${m.sender === "nia" ? "Nia" : "User"}: ${truncate(m.content, HANDOFF_CHARS)}`);
|
|
184
|
+
if (lines.length === 0) return "";
|
|
185
|
+
return `\n\n## Conversation So Far\nYou are continuing this conversation after switching models mid-turn.\n\n${lines.join("\n")}`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Lazily open (and reuse) the current chain entry's session for this engine. */
|
|
175
189
|
async function ensureSession(): Promise<AgentSession> {
|
|
176
190
|
if (session) return session;
|
|
177
|
-
const
|
|
178
|
-
const s = await backend.openSession({
|
|
191
|
+
const entry = cursor.current!;
|
|
192
|
+
const s = await entry.backend.openSession({
|
|
179
193
|
room,
|
|
180
194
|
channel,
|
|
181
|
-
systemPrompt,
|
|
195
|
+
systemPrompt: systemPrompt + handoff,
|
|
182
196
|
cwd,
|
|
183
|
-
model
|
|
197
|
+
// A context override names a model for the configured provider, so it
|
|
198
|
+
// only applies at the head of the chain.
|
|
199
|
+
model: (cursor.atHead ? (contextModel ?? entry.model) : entry.model) ?? undefined,
|
|
184
200
|
mcpServers,
|
|
185
201
|
resume: sessionId ?? false,
|
|
186
202
|
subagents: getAgentDefinitions(),
|
|
@@ -203,6 +219,19 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
|
|
|
203
219
|
},
|
|
204
220
|
|
|
205
221
|
async send(userMessage: string, callbacks?: SendCallbacks, attachments?: Attachment[]) {
|
|
222
|
+
// Re-probe from the top once the failed provider's cooldown lapses, so a
|
|
223
|
+
// brief outage does not pin the conversation to the fallback for good.
|
|
224
|
+
if (!cursor.atHead) {
|
|
225
|
+
const fresh = new ChainCursor(chain);
|
|
226
|
+
if (fresh.current !== cursor.current) {
|
|
227
|
+
log.info({ room, to: fresh.current && describeEntry(fresh.current) }, "chat returning to preferred model");
|
|
228
|
+
cursor = fresh;
|
|
229
|
+
handoff = await buildHandoff("");
|
|
230
|
+
await teardown();
|
|
231
|
+
sessionId = null;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
206
235
|
// Clear idle timer — engine is not idle while processing a request
|
|
207
236
|
clearIdleTimer();
|
|
208
237
|
startLongRunningTimer();
|
|
@@ -227,12 +256,22 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
|
|
|
227
256
|
|
|
228
257
|
let result: SendResult = { result: "", costUsd: 0, turns: 0 };
|
|
229
258
|
|
|
230
|
-
// Run the turn on the current
|
|
231
|
-
//
|
|
259
|
+
// Run the turn on the current chain entry; a turn that cannot be served
|
|
260
|
+
// moves down the chain and answers the current message there.
|
|
232
261
|
while (true) {
|
|
233
|
-
|
|
262
|
+
let sess: AgentSession;
|
|
263
|
+
try {
|
|
264
|
+
sess = await ensureSession();
|
|
265
|
+
} catch (err) {
|
|
266
|
+
// A backend that cannot start must not take the turn down.
|
|
267
|
+
const next = cursor.advance("provider");
|
|
268
|
+
log.warn({ room, err: String(err), to: next && describeEntry(next) }, "chat backend failed to start");
|
|
269
|
+
if (!next) throw err instanceof Error ? err : new Error(String(err));
|
|
270
|
+
handoff = await buildHandoff(userMessage);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
234
273
|
let accumulated = "";
|
|
235
|
-
let
|
|
274
|
+
let failover: FailoverScope | undefined;
|
|
236
275
|
|
|
237
276
|
try {
|
|
238
277
|
for await (const ev of sess.send(userMessage, attachments)) {
|
|
@@ -285,7 +324,7 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
|
|
|
285
324
|
break;
|
|
286
325
|
}
|
|
287
326
|
case "error": {
|
|
288
|
-
|
|
327
|
+
failover = ev.failover;
|
|
289
328
|
log.error(
|
|
290
329
|
{ room, error: ev.message, terminal_reason: ev.terminalReason },
|
|
291
330
|
"chat send failed with backend error",
|
|
@@ -294,7 +333,7 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
|
|
|
294
333
|
result: formatChatError(ev.message),
|
|
295
334
|
costUsd: 0,
|
|
296
335
|
turns: 0,
|
|
297
|
-
signal: ev.
|
|
336
|
+
signal: ev.failover === "provider" ? "provider_down" : undefined,
|
|
298
337
|
};
|
|
299
338
|
break;
|
|
300
339
|
}
|
|
@@ -311,12 +350,13 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
|
|
|
311
350
|
// Re-read the backend session id post-send so finalize/DB target it.
|
|
312
351
|
if (sess.backendSessionId) sessionId = sess.backendSessionId;
|
|
313
352
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
log.warn({ room, to:
|
|
317
|
-
await teardown(); // close the dead session so ensureSession opens the next
|
|
353
|
+
const next = failover && cursor.advance(failover);
|
|
354
|
+
if (next) {
|
|
355
|
+
log.warn({ room, to: describeEntry(next), scope: failover }, "chat failing over to next model");
|
|
356
|
+
await teardown(); // close the dead session so ensureSession opens the next entry
|
|
318
357
|
sessionId = null; // a cross-backend session id is meaningless; start fresh
|
|
319
|
-
userSaved = false; // re-save the user turn under the new
|
|
358
|
+
userSaved = false; // re-save the user turn under the new session
|
|
359
|
+
handoff = await buildHandoff(userMessage);
|
|
320
360
|
continue;
|
|
321
361
|
}
|
|
322
362
|
break;
|
package/src/commands/validate.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from "fs";
|
|
|
2
2
|
import yaml from "js-yaml";
|
|
3
3
|
import { getPaths } from "../utils/paths";
|
|
4
4
|
import { ICON_PASS as PASS, ICON_FAIL as FAIL, ICON_WARN as WARN } from "../utils/cli";
|
|
5
|
+
import { buildChain, describeEntry } from "../agent";
|
|
5
6
|
|
|
6
7
|
interface Result {
|
|
7
8
|
ok: boolean;
|
|
@@ -76,24 +77,27 @@ export function validateConfig(): Result {
|
|
|
76
77
|
messages.push(`${WARN} database_url not set (will use default)`);
|
|
77
78
|
}
|
|
78
79
|
|
|
79
|
-
//
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
} else if (runner) {
|
|
86
|
-
messages.push(`${PASS} runner: ${runner}`);
|
|
80
|
+
// Model chain. Keys removed in favour of model + fallback_models.
|
|
81
|
+
for (const dead of ["runner", "fallback", "codex_model"]) {
|
|
82
|
+
if (raw[dead] !== undefined) {
|
|
83
|
+
messages.push(`${FAIL} "${dead}" is no longer used — name models in model / fallback_models instead`);
|
|
84
|
+
ok = false;
|
|
85
|
+
}
|
|
87
86
|
}
|
|
88
|
-
if (raw.
|
|
89
|
-
const fb = raw.
|
|
90
|
-
if (!Array.isArray(fb) || fb.some((
|
|
91
|
-
messages.push(`${FAIL}
|
|
87
|
+
if (raw.fallback_models !== undefined) {
|
|
88
|
+
const fb = raw.fallback_models;
|
|
89
|
+
if (!Array.isArray(fb) || fb.some((m) => typeof m !== "string")) {
|
|
90
|
+
messages.push(`${FAIL} fallback_models must be an array of model names`);
|
|
92
91
|
ok = false;
|
|
93
92
|
} else {
|
|
94
|
-
messages.push(`${PASS}
|
|
93
|
+
messages.push(`${PASS} fallback_models: [${fb.join(", ")}]`);
|
|
95
94
|
}
|
|
96
95
|
}
|
|
96
|
+
const chain = buildChain(
|
|
97
|
+
typeof raw.model === "string" ? raw.model : "default",
|
|
98
|
+
Array.isArray(raw.fallback_models) ? (raw.fallback_models as string[]).filter((m) => typeof m === "string") : [],
|
|
99
|
+
);
|
|
100
|
+
messages.push(`${PASS} model chain: ${chain.map(describeEntry).join(" → ")}`);
|
|
97
101
|
|
|
98
102
|
// Session finalization
|
|
99
103
|
const sf = raw.session_finalization as Record<string, unknown> | undefined;
|
package/src/core/alive.ts
CHANGED
|
@@ -125,7 +125,7 @@ async function notifyUser(message: string): Promise<void> {
|
|
|
125
125
|
/** Run LLM recovery agent for failures it can fix (e.g. DB down). */
|
|
126
126
|
async function runRecoveryAgent(failures: Check[]): Promise<{ recovered: boolean; report: string }> {
|
|
127
127
|
try {
|
|
128
|
-
const {
|
|
128
|
+
const { runOneShot } = await import("./runner");
|
|
129
129
|
const { homedir } = await import("os");
|
|
130
130
|
|
|
131
131
|
const failureSummary = failures.map((f) => `- ${f.name}: ${f.detail}`).join("\n");
|
|
@@ -149,7 +149,7 @@ async function runRecoveryAgent(failures: Check[]): Promise<{ recovered: boolean
|
|
|
149
149
|
|
|
150
150
|
const jobPrompt = `Health check failures:\n${failureSummary}\n\nDiagnose and fix.`;
|
|
151
151
|
|
|
152
|
-
const result = await
|
|
152
|
+
const result = await runOneShot({ systemPrompt, prompt: jobPrompt, cwd: homedir() });
|
|
153
153
|
|
|
154
154
|
// Re-check after recovery attempt
|
|
155
155
|
const remaining = await getFailures();
|
package/src/core/runner.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { getMcpServers, type McpSourceContext } from "../mcp";
|
|
|
14
14
|
import { ActiveEngine } from "../db/models";
|
|
15
15
|
import { log } from "../utils/log";
|
|
16
16
|
import { registerActiveHandle, unregisterActiveHandle } from "./active-handles";
|
|
17
|
-
import {
|
|
17
|
+
import { resolveChain, ChainCursor, describeEntry, type ChainEntry, type AgentSession, type AgentSessionContext, type FailoverScope } from "../agent";
|
|
18
18
|
|
|
19
19
|
export { buildWorkingMemory } from "./job-prompt";
|
|
20
20
|
|
|
@@ -25,8 +25,8 @@ interface RunnerOutput {
|
|
|
25
25
|
sessionId: string;
|
|
26
26
|
terminalReason?: string;
|
|
27
27
|
error?: string;
|
|
28
|
-
/**
|
|
29
|
-
|
|
28
|
+
/** How far the chain should skip after this run. Absent → a real failure, stop. */
|
|
29
|
+
failover?: FailoverScope;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
// ---------------------------------------------------------------------------
|
|
@@ -55,7 +55,7 @@ async function consumeBackendRun(
|
|
|
55
55
|
let agentText = "";
|
|
56
56
|
let terminalReason: string | undefined;
|
|
57
57
|
let error: string | undefined;
|
|
58
|
-
let
|
|
58
|
+
let failover: FailoverScope | undefined;
|
|
59
59
|
|
|
60
60
|
try {
|
|
61
61
|
for await (const ev of session.send(prompt)) {
|
|
@@ -67,7 +67,7 @@ async function consumeBackendRun(
|
|
|
67
67
|
} else if (ev.type === "error") {
|
|
68
68
|
error = ev.message;
|
|
69
69
|
terminalReason = ev.terminalReason;
|
|
70
|
-
|
|
70
|
+
failover = ev.failover;
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
73
|
} catch (err) {
|
|
@@ -89,58 +89,74 @@ async function consumeBackendRun(
|
|
|
89
89
|
return { agentText: "", sessionId: session.backendSessionId ?? "", terminalReason: "aborted", error: abortReason };
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
return { agentText, sessionId: session.backendSessionId ?? "", terminalReason, error,
|
|
92
|
+
return { agentText, sessionId: session.backendSessionId ?? "", terminalReason, error, failover };
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
/**
|
|
96
|
-
*
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
*/
|
|
100
|
-
export async function runJobAcrossBackends(
|
|
101
|
-
backends: AgentBackend[],
|
|
95
|
+
/** One attempt. A backend that cannot even start (missing CLI, endpoint down)
|
|
96
|
+
* must not take the run with it. */
|
|
97
|
+
async function runEntry(
|
|
98
|
+
entry: ChainEntry,
|
|
102
99
|
sessionCtx: AgentSessionContext,
|
|
103
|
-
|
|
100
|
+
prompt: string,
|
|
104
101
|
onActivity?: ActivityCallback,
|
|
105
102
|
activeRoom?: string,
|
|
106
103
|
): Promise<RunnerOutput> {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
if (next) log.warn({ from: backend.name, to: next.name }, "provider down, failing over to next backend");
|
|
104
|
+
try {
|
|
105
|
+
const session = await entry.backend.openSession({ ...sessionCtx, model: entry.model });
|
|
106
|
+
return await consumeBackendRun(session, prompt, onActivity, activeRoom);
|
|
107
|
+
} catch (err) {
|
|
108
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
109
|
+
log.warn({ entry: describeEntry(entry), err: message }, "backend failed to start");
|
|
110
|
+
return { agentText: "", sessionId: "", error: message, failover: "provider" };
|
|
115
111
|
}
|
|
116
|
-
return output;
|
|
117
112
|
}
|
|
118
113
|
|
|
119
114
|
/**
|
|
120
|
-
* Run a
|
|
121
|
-
*
|
|
115
|
+
* Run a job down the chain. The prompt is replayed as-is: continuity comes from
|
|
116
|
+
* Nia's own context, not a cross-backend session resume.
|
|
122
117
|
*/
|
|
123
|
-
export async function
|
|
124
|
-
|
|
118
|
+
export async function runJobAcrossChain(
|
|
119
|
+
chain: ChainEntry[],
|
|
120
|
+
sessionCtx: AgentSessionContext,
|
|
125
121
|
jobPrompt: string,
|
|
126
|
-
cwd: string,
|
|
127
122
|
onActivity?: ActivityCallback,
|
|
128
|
-
model?: string,
|
|
129
|
-
sourceCtx?: McpSourceContext,
|
|
130
123
|
activeRoom?: string,
|
|
131
124
|
): Promise<RunnerOutput> {
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
125
|
+
const cursor = new ChainCursor(chain);
|
|
126
|
+
let output: RunnerOutput = { agentText: "", sessionId: "", error: "no model configured" };
|
|
127
|
+
|
|
128
|
+
for (let entry = cursor.current; entry; ) {
|
|
129
|
+
output = await runEntry(entry, sessionCtx, jobPrompt, onActivity, activeRoom);
|
|
130
|
+
if (!output.failover) return output;
|
|
131
|
+
|
|
132
|
+
const from = describeEntry(entry);
|
|
133
|
+
entry = cursor.advance(output.failover);
|
|
134
|
+
if (entry) log.warn({ from, to: describeEntry(entry), scope: output.failover }, "failing over to next model");
|
|
135
|
+
}
|
|
136
|
+
return output;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface OneShotOptions {
|
|
140
|
+
systemPrompt: string;
|
|
141
|
+
prompt: string;
|
|
142
|
+
cwd: string;
|
|
143
|
+
onActivity?: ActivityCallback;
|
|
144
|
+
source?: McpSourceContext;
|
|
145
|
+
activeRoom?: string;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** A one-shot run across the chain — no session, no resume. */
|
|
149
|
+
export async function runOneShot(opts: OneShotOptions): Promise<RunnerOutput> {
|
|
150
|
+
const sessionCtx: AgentSessionContext = {
|
|
151
|
+
room: opts.activeRoom ?? `_oneshot/${randomUUID()}`,
|
|
135
152
|
channel: "system",
|
|
136
|
-
systemPrompt,
|
|
137
|
-
cwd,
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
source: sourceCtx,
|
|
153
|
+
systemPrompt: opts.systemPrompt,
|
|
154
|
+
cwd: opts.cwd,
|
|
155
|
+
mcpServers: (getMcpServers(opts.source) as Record<string, unknown> | undefined) ?? undefined,
|
|
156
|
+
source: opts.source,
|
|
141
157
|
resume: false,
|
|
142
|
-
}
|
|
143
|
-
return
|
|
158
|
+
};
|
|
159
|
+
return runJobAcrossChain(resolveChain(), sessionCtx, opts.prompt, opts.onActivity, opts.activeRoom);
|
|
144
160
|
}
|
|
145
161
|
|
|
146
162
|
// ---------------------------------------------------------------------------
|
|
@@ -165,7 +181,7 @@ export async function runTask(opts: TaskOptions): Promise<RunnerOutput> {
|
|
|
165
181
|
await ActiveEngine.register(room, "system").catch(() => {});
|
|
166
182
|
try {
|
|
167
183
|
const systemPrompt = opts.systemPrompt || buildSystemPrompt("job");
|
|
168
|
-
const output = await
|
|
184
|
+
const output = await runOneShot({ systemPrompt, prompt: opts.prompt, cwd: homedir(), activeRoom: room });
|
|
169
185
|
if (output.error) {
|
|
170
186
|
log.error({ task: opts.name, error: output.error }, "task failed");
|
|
171
187
|
} else {
|
|
@@ -243,7 +259,7 @@ export async function runJob(job: JobInput, onActivity?: ActivityCallback): Prom
|
|
|
243
259
|
source: jobSourceCtx,
|
|
244
260
|
resume: false,
|
|
245
261
|
};
|
|
246
|
-
output = await
|
|
262
|
+
output = await runJobAcrossChain(resolveChain(), sessionCtx, jobPrompt, onActivity, room);
|
|
247
263
|
|
|
248
264
|
const duration_ms = Math.round(performance.now() - startMs);
|
|
249
265
|
const ok = !output.error;
|
package/src/core/scheduler.ts
CHANGED
|
@@ -3,9 +3,25 @@ import { runJob } from "./runner";
|
|
|
3
3
|
import { getConfig } from "../utils/config";
|
|
4
4
|
import { log } from "../utils/log";
|
|
5
5
|
import { computeInitialNextRun, computeNextRun } from "../utils/schedule";
|
|
6
|
+
import type { JobResult } from "../types";
|
|
6
7
|
|
|
7
8
|
export { computeInitialNextRun, computeNextRun };
|
|
8
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Log a finished job at a level matching its outcome. `runJob` resolves with
|
|
12
|
+
* `status: "error"` instead of throwing, so the caller's `.catch` only ever sees
|
|
13
|
+
* an infrastructure fault — a job that ran and failed must be branched on here
|
|
14
|
+
* or it is indistinguishable from success in the log.
|
|
15
|
+
*/
|
|
16
|
+
export function logJobOutcome(result: JobResult): void {
|
|
17
|
+
const fields = { job: result.job, status: result.status, duration: result.duration_ms };
|
|
18
|
+
if (result.status === "error") {
|
|
19
|
+
log.error({ ...fields, error: result.error, terminal_reason: result.terminal_reason }, "scheduler: job failed");
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
log.info(fields, "scheduler: job completed");
|
|
23
|
+
}
|
|
24
|
+
|
|
9
25
|
function isWithinActiveHours(): boolean {
|
|
10
26
|
const config = getConfig();
|
|
11
27
|
const { start, end } = config.activeHours;
|
|
@@ -57,11 +73,9 @@ async function tick(): Promise<void> {
|
|
|
57
73
|
runningJobs.add(job.name);
|
|
58
74
|
|
|
59
75
|
runJob(job)
|
|
60
|
-
.then(
|
|
61
|
-
log.info({ job: job.name, status: result.status, duration: result.duration_ms }, "scheduler: job completed");
|
|
62
|
-
})
|
|
76
|
+
.then(logJobOutcome)
|
|
63
77
|
.catch((err) => {
|
|
64
|
-
log.error({ err, job: job.name }, "scheduler: job
|
|
78
|
+
log.error({ err, job: job.name }, "scheduler: job crashed");
|
|
65
79
|
})
|
|
66
80
|
.finally(() => {
|
|
67
81
|
runningJobs.delete(job.name);
|
package/src/mcp/gate.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { NiaTool } from "./tools/types";
|
|
2
|
+
import { log } from "../utils/log";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Per-run budget for tools with real-world consequences.
|
|
6
|
+
*
|
|
7
|
+
* The CLI backends run with their own approvals bypassed, and that flag governs
|
|
8
|
+
* only their built-ins (shell, file writes) — nothing upstream of Nia limits how
|
|
9
|
+
* often an agent may dial a phone or message the owner. Wrapping the table at
|
|
10
|
+
* the point each per-run server is built puts one cap in front of every
|
|
11
|
+
* backend, in-process Claude included.
|
|
12
|
+
*
|
|
13
|
+
* These are runaway stops, not permission checks: a looping agent burns its
|
|
14
|
+
* budget and is told no, while a run doing the job it was asked to do never
|
|
15
|
+
* reaches them.
|
|
16
|
+
*/
|
|
17
|
+
export const SIDE_EFFECT_LIMITS: Record<string, number> = {
|
|
18
|
+
place_call: 3,
|
|
19
|
+
send_message: 25,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Wrap side-effect tools with a budget. Call once per run — the counters live in
|
|
24
|
+
* the returned closures, so two concurrent runs cannot spend each other's.
|
|
25
|
+
*/
|
|
26
|
+
export function gateSideEffects(tools: NiaTool[]): NiaTool[] {
|
|
27
|
+
const used = new Map<string, number>();
|
|
28
|
+
|
|
29
|
+
return tools.map((t) => {
|
|
30
|
+
const limit = SIDE_EFFECT_LIMITS[t.name];
|
|
31
|
+
if (limit === undefined) return t;
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
...t,
|
|
35
|
+
handler: async (args: any, ctx) => {
|
|
36
|
+
const spent = used.get(t.name) ?? 0;
|
|
37
|
+
if (spent >= limit) {
|
|
38
|
+
log.warn({ tool: t.name, limit }, "side-effect tool budget exhausted for this run");
|
|
39
|
+
return `Refused: ${t.name} has already run ${limit} times in this run, which is its limit. Ask the owner if you genuinely need more.`;
|
|
40
|
+
}
|
|
41
|
+
used.set(t.name, spent + 1);
|
|
42
|
+
return t.handler(args, ctx);
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
});
|
|
46
|
+
}
|
package/src/mcp/server.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createSdkMcpServer, tool } from "@anthropic-ai/claude-agent-sdk";
|
|
2
2
|
import { NIA_TOOLS } from "./tools/table";
|
|
3
|
+
import { gateSideEffects } from "./gate";
|
|
3
4
|
import type { McpSourceContext } from "./index";
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -11,7 +12,7 @@ export function createNiaMcpServer(sourceCtx?: McpSourceContext) {
|
|
|
11
12
|
return createSdkMcpServer({
|
|
12
13
|
name: "nia",
|
|
13
14
|
version: "0.1.0",
|
|
14
|
-
tools: NIA_TOOLS.map((t) =>
|
|
15
|
+
tools: gateSideEffects(NIA_TOOLS).map((t) =>
|
|
15
16
|
tool(t.name, t.description, t.schema, async (args: unknown) => ({
|
|
16
17
|
content: [{ type: "text" as const, text: await t.handler(args, sourceCtx) }],
|
|
17
18
|
})),
|