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.
@@ -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: AgentBackend[] | null = null;
13
+ let chainOverride: ChainEntry[] | null = null;
18
14
 
19
- export function getBackend(name?: "claude" | "codex" | "gemini"): AgentBackend {
15
+ export function getBackend(name?: ProviderName): AgentBackend {
20
16
  if (override) return override;
21
- if (name === "codex") {
22
- if (!codexBackend) codexBackend = new CodexBackend();
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; pass null to reset. */
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 `resolveBackends()` to return a specific chain; null resets. */
35
- export function setBackendChain(backends: AgentBackend[] | null): void {
36
- chainOverride = backends;
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
- * The ordered backend chain for a run: the configured primary first, then any
41
- * fallbacks (provider-down failover), de-duplicated. Consumers try each in order
42
- * until one isn't provider-down.
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 resolveBackends(): AgentBackend[] {
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
- const seen = new Set<string>();
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
  }
@@ -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` (transient API failure → the backend may retry internally)
36
- * and `error.providerDown` (the provider is unavailable failover trigger) are
37
- * INDEPENDENT predicates.
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; providerDown: boolean; terminalReason?: string };
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";
@@ -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 { resolveBackends, type AgentSession } from "../agent";
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
- const error = rawError?.trim();
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
- // The backend chain: configured primary first, then provider-down fallbacks.
100
- // Chat normally runs on the primary; a provider-down turn fails over to the
101
- // next backend (answering the current message — see send()).
102
- const backends = resolveBackends();
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
- // Verify the primary backend can actually resume this session before
114
- // attempting it (Claude probes the on-disk jsonl; others use their own check).
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
- /** Lazily open (and reuse) the current backend's session for this engine. */
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 backend = backends[backendIndex] ?? backends[0]!;
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: contextModel ?? undefined,
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 backend; on a provider-down result, fail over
231
- // to the next backend and answer the current message there.
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
- const sess = await ensureSession();
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 providerDown = false;
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
- providerDown = ev.providerDown;
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.providerDown ? "provider_down" : undefined,
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
- if (providerDown && backendIndex < backends.length - 1) {
315
- backendIndex++;
316
- log.warn({ room, to: backends[backendIndex]!.name }, "chat provider down, failing over to next backend");
317
- await teardown(); // close the dead session so ensureSession opens the next backend
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 backend's session
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;
@@ -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
- // Backends (primary runner + fallback chain)
80
- const BACKENDS = ["claude", "codex", "gemini"];
81
- const runner = raw.runner as string | undefined;
82
- if (runner && !BACKENDS.includes(runner)) {
83
- messages.push(`${FAIL} runner must be one of ${BACKENDS.join(", ")}, got "${runner}"`);
84
- ok = false;
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.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(", ")}`);
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} fallback: [${fb.join(", ")}]`);
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 { runJobWithClaude } = await import("./runner");
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 runJobWithClaude(systemPrompt, jobPrompt, homedir());
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();
@@ -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 { getBackend, resolveBackends, type AgentBackend, type AgentSession, type AgentSessionContext } from "../agent";
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
- /** The backend reported provider-down caller may fail over to the next backend. */
29
- providerDown?: boolean;
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 providerDown = false;
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
- providerDown = ev.providerDown;
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, providerDown };
92
+ return { agentText, sessionId: session.backendSessionId ?? "", terminalReason, error, failover };
93
93
  }
94
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[],
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
- jobPrompt: string,
100
+ prompt: string,
104
101
  onActivity?: ActivityCallback,
105
102
  activeRoom?: string,
106
103
  ): 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");
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 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.
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 runJobWithClaude(
124
- systemPrompt: string,
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 mcpServers = (getMcpServers(sourceCtx) as Record<string, unknown> | undefined) ?? undefined;
133
- const session = await getBackend().openSession({
134
- room: activeRoom ?? `_oneshot/${randomUUID()}`,
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
- model,
139
- mcpServers,
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 consumeBackendRun(session, jobPrompt, onActivity, activeRoom);
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 runJobWithClaude(systemPrompt, opts.prompt, homedir(), undefined, undefined, undefined, room);
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 runJobAcrossBackends(resolveBackends(), sessionCtx, jobPrompt, onActivity, room);
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;
@@ -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((result) => {
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 failed");
78
+ log.error({ err, job: job.name }, "scheduler: job crashed");
65
79
  })
66
80
  .finally(() => {
67
81
  runningJobs.delete(job.name);
@@ -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
  })),