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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "niahere",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "A personal AI assistant daemon — chat, scheduled jobs, persona system, extensible via skills.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"typecheck": "tsc --noEmit",
|
|
14
14
|
"check:cycles": "madge --circular --extensions ts src/",
|
|
15
15
|
"seed": "bun run src/db/seed.ts",
|
|
16
|
-
"release": "npm version patch && npm publish && git push"
|
|
16
|
+
"release": "npm version patch && npm publish && git push --follow-tags"
|
|
17
17
|
},
|
|
18
18
|
"keywords": [
|
|
19
19
|
"ai",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { AgentEvent, Normalizer } from "../types";
|
|
2
2
|
import { truncate } from "../../utils/format-activity";
|
|
3
|
-
import {
|
|
3
|
+
import { isRetryable, scopeOf, parseFailure } from "../failure";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Pure reducer: Claude Agent SDK messages → normalized `AgentEvent`s.
|
|
@@ -120,14 +120,13 @@ export class SdkNormalizer implements Normalizer {
|
|
|
120
120
|
};
|
|
121
121
|
}
|
|
122
122
|
const raw = (msg.errors?.join(", ") as string) || "unknown error";
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
// - providerDown: blank/"unknown error" → the provider is down → failover.
|
|
123
|
+
// A transient error carries no scope yet — it only becomes one once the
|
|
124
|
+
// session has burned its retries.
|
|
126
125
|
return {
|
|
127
126
|
type: "error",
|
|
128
127
|
message: raw,
|
|
129
|
-
retryable:
|
|
130
|
-
|
|
128
|
+
retryable: isRetryable(raw),
|
|
129
|
+
failover: scopeOf(parseFailure(raw), "provider"),
|
|
131
130
|
terminalReason: msg.terminal_reason,
|
|
132
131
|
};
|
|
133
132
|
}
|
|
@@ -151,11 +151,12 @@ class ClaudeSession implements AgentSession {
|
|
|
151
151
|
retry = true;
|
|
152
152
|
break;
|
|
153
153
|
}
|
|
154
|
-
// A retryable error that survived
|
|
155
|
-
//
|
|
154
|
+
// A retryable error that survived every retry is a capacity problem
|
|
155
|
+
// with THIS model, not proof the provider is gone — scope it to the
|
|
156
|
+
// model so a second model on the same provider still gets its turn.
|
|
156
157
|
const out =
|
|
157
158
|
ev.type === "error" && ev.retryable && this.retryCount >= MAX_SEND_RETRIES
|
|
158
|
-
? { ...ev,
|
|
159
|
+
? { ...ev, failover: ev.failover ?? ("model" as const) }
|
|
159
160
|
: ev;
|
|
160
161
|
yield out;
|
|
161
162
|
if (out.type === "result" || out.type === "error") {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AgentEvent, Normalizer } from "../types";
|
|
2
2
|
import { truncate } from "../../utils/format-activity";
|
|
3
|
+
import { scopeOf, parseFailure } from "../failure";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Pure reducer: Codex `codex exec --json` JSONL events → normalized `AgentEvent`s.
|
|
@@ -7,13 +8,15 @@ import { truncate } from "../../utils/format-activity";
|
|
|
7
8
|
* Codex is batch (no token streaming): the assistant message arrives whole in a
|
|
8
9
|
* single `item.completed`/`agent_message`, and `turn.completed` carries token
|
|
9
10
|
* usage. So `text` is emitted once (full), then `result` on `turn.completed`.
|
|
10
|
-
* No I/O — the session that drives it owns process lifecycle.
|
|
11
|
-
*
|
|
12
|
-
* items are
|
|
11
|
+
* No I/O — the session that drives it owns process lifecycle. A failed turn
|
|
12
|
+
* arrives as a top-level `error` and/or `turn.failed` carrying the upstream
|
|
13
|
+
* message; `error` *items* are non-fatal warnings (service tier, model metadata,
|
|
14
|
+
* skill budget) and are dropped.
|
|
13
15
|
*/
|
|
14
16
|
export class CodexNormalizer implements Normalizer {
|
|
15
17
|
private threadId = "";
|
|
16
18
|
private agentText = "";
|
|
19
|
+
private failed = false;
|
|
17
20
|
|
|
18
21
|
get backendSessionId(): string {
|
|
19
22
|
return this.threadId;
|
|
@@ -28,6 +31,10 @@ export class CodexNormalizer implements Normalizer {
|
|
|
28
31
|
case "item.started":
|
|
29
32
|
case "item.completed":
|
|
30
33
|
return this.consumeItem(e.type === "item.completed", e.item);
|
|
34
|
+
case "error":
|
|
35
|
+
return this.fail(typeof e.message === "string" ? e.message : "");
|
|
36
|
+
case "turn.failed":
|
|
37
|
+
return this.fail(typeof e.error?.message === "string" ? e.error.message : "");
|
|
31
38
|
case "turn.completed":
|
|
32
39
|
return [
|
|
33
40
|
{
|
|
@@ -47,6 +54,15 @@ export class CodexNormalizer implements Normalizer {
|
|
|
47
54
|
}
|
|
48
55
|
}
|
|
49
56
|
|
|
57
|
+
/** Codex reports the same failure twice (`error` then `turn.failed`); only the
|
|
58
|
+
* first ends the turn. */
|
|
59
|
+
private fail(raw: string): AgentEvent[] {
|
|
60
|
+
if (this.failed) return [];
|
|
61
|
+
this.failed = true;
|
|
62
|
+
const failure = parseFailure(raw);
|
|
63
|
+
return [{ type: "error", message: failure.message, retryable: false, failover: scopeOf(failure) }];
|
|
64
|
+
}
|
|
65
|
+
|
|
50
66
|
private consumeItem(completed: boolean, item: any): AgentEvent[] {
|
|
51
67
|
if (!item) return [];
|
|
52
68
|
switch (item.type) {
|
|
@@ -6,6 +6,7 @@ import type { Attachment } from "../../types/attachment";
|
|
|
6
6
|
import type { McpSourceContext } from "../../mcp";
|
|
7
7
|
import { CodexNormalizer } from "./codex-normalize";
|
|
8
8
|
import { mintRun, revokeRun } from "../mcp-endpoint";
|
|
9
|
+
import { scopeOf, parseFailure } from "../failure";
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Resolve the codex binary's absolute path. The daemon runs under launchd with a
|
|
@@ -41,21 +42,30 @@ export interface CliProc {
|
|
|
41
42
|
}
|
|
42
43
|
export type SpawnFn = (args: string[], opts: { cwd: string; env: Record<string, string> }) => CliProc;
|
|
43
44
|
|
|
44
|
-
//
|
|
45
|
-
// authenticates via its own
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
"
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
-
"
|
|
52
|
-
"
|
|
45
|
+
// An allowlist, so a newly-added secret is excluded by default rather than
|
|
46
|
+
// leaking to a third-party subprocess. Codex authenticates via its own
|
|
47
|
+
// ~/.codex login, so it needs no credential of ours.
|
|
48
|
+
const ENV_ALLOW = new Set([
|
|
49
|
+
"PATH",
|
|
50
|
+
"HOME",
|
|
51
|
+
"USER",
|
|
52
|
+
"LOGNAME",
|
|
53
|
+
"SHELL",
|
|
54
|
+
"TERM",
|
|
55
|
+
"TMPDIR",
|
|
56
|
+
"TZ",
|
|
57
|
+
"LANG",
|
|
58
|
+
"XDG_CONFIG_HOME",
|
|
59
|
+
"XDG_CACHE_HOME",
|
|
60
|
+
"XDG_DATA_HOME",
|
|
53
61
|
]);
|
|
62
|
+
const ENV_ALLOW_PREFIX = ["LC_", "CODEX_"];
|
|
54
63
|
|
|
55
|
-
function
|
|
64
|
+
function subprocessEnv(extra: Record<string, string>): Record<string, string> {
|
|
56
65
|
const env: Record<string, string> = {};
|
|
57
66
|
for (const [k, v] of Object.entries(process.env)) {
|
|
58
|
-
if (
|
|
67
|
+
if (v == null) continue;
|
|
68
|
+
if (ENV_ALLOW.has(k) || ENV_ALLOW_PREFIX.some((p) => k.startsWith(p))) env[k] = v;
|
|
59
69
|
}
|
|
60
70
|
return { ...env, ...extra };
|
|
61
71
|
}
|
|
@@ -75,8 +85,9 @@ function defaultSpawn(args: string[], opts: { cwd: string; env: Record<string, s
|
|
|
75
85
|
};
|
|
76
86
|
}
|
|
77
87
|
|
|
78
|
-
|
|
79
|
-
|
|
88
|
+
/** Takes the reader rather than the stream so the caller can cancel a read that
|
|
89
|
+
* would otherwise never resolve. */
|
|
90
|
+
async function* readLines(reader: ReadableStreamDefaultReader<Uint8Array>): AsyncGenerator<string> {
|
|
80
91
|
const decoder = new TextDecoder();
|
|
81
92
|
let buf = "";
|
|
82
93
|
while (true) {
|
|
@@ -92,28 +103,53 @@ async function* readLines(stream: ReadableStream<Uint8Array>): AsyncGenerator<st
|
|
|
92
103
|
if (buf.trim()) yield buf;
|
|
93
104
|
}
|
|
94
105
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}
|
|
104
|
-
|
|
106
|
+
const IDLE = Symbol("idle");
|
|
107
|
+
|
|
108
|
+
/** A cancellable deadline, used to race a read that may never resolve. */
|
|
109
|
+
function idleAfter(ms: number): { promise: Promise<typeof IDLE>; cancel: () => void } {
|
|
110
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
111
|
+
const promise = new Promise<typeof IDLE>((resolve) => {
|
|
112
|
+
timer = setTimeout(() => resolve(IDLE), ms);
|
|
113
|
+
});
|
|
114
|
+
return { promise, cancel: () => clearTimeout(timer) };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const STDERR_CAP = 16_000;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Consume stderr to EOF, keeping only the tail. Must run alongside the process:
|
|
121
|
+
* an undrained pipe blocks the child once its buffer fills.
|
|
122
|
+
*/
|
|
123
|
+
function drainStderr(stream: ReadableStream<Uint8Array>): Promise<string> {
|
|
124
|
+
return (async () => {
|
|
125
|
+
let out = "";
|
|
126
|
+
const decoder = new TextDecoder();
|
|
127
|
+
const reader = stream.getReader();
|
|
128
|
+
while (true) {
|
|
129
|
+
const { value, done } = await reader.read();
|
|
130
|
+
if (done) break;
|
|
131
|
+
out += decoder.decode(value, { stream: true });
|
|
132
|
+
if (out.length > STDERR_CAP) out = out.slice(-STDERR_CAP);
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
})().catch(() => "");
|
|
105
136
|
}
|
|
106
137
|
|
|
138
|
+
/** A codex that emits nothing for this long is wedged, not working. */
|
|
139
|
+
const DEFAULT_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
|
|
140
|
+
|
|
107
141
|
export class CodexBackend implements AgentBackend {
|
|
108
142
|
readonly name = "codex" as const;
|
|
109
143
|
private spawnFn: SpawnFn;
|
|
144
|
+
private idleTimeoutMs: number;
|
|
110
145
|
|
|
111
|
-
constructor(deps?: { spawnFn?: SpawnFn }) {
|
|
146
|
+
constructor(deps?: { spawnFn?: SpawnFn; idleTimeoutMs?: number }) {
|
|
112
147
|
this.spawnFn = deps?.spawnFn ?? defaultSpawn;
|
|
148
|
+
this.idleTimeoutMs = deps?.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
|
|
113
149
|
}
|
|
114
150
|
|
|
115
151
|
async openSession(ctx: AgentSessionContext): Promise<AgentSession> {
|
|
116
|
-
return new CodexSession(ctx, this.spawnFn);
|
|
152
|
+
return new CodexSession(ctx, this.spawnFn, this.idleTimeoutMs);
|
|
117
153
|
}
|
|
118
154
|
|
|
119
155
|
async canResume(): Promise<boolean> {
|
|
@@ -126,10 +162,12 @@ class CodexSession implements AgentSession {
|
|
|
126
162
|
private _sessionId: string | null = null;
|
|
127
163
|
private aborted: string | null = null;
|
|
128
164
|
private proc: CliProc | null = null;
|
|
165
|
+
private idledOut = false;
|
|
129
166
|
|
|
130
167
|
constructor(
|
|
131
168
|
private ctx: AgentSessionContext,
|
|
132
169
|
private spawnFn: SpawnFn,
|
|
170
|
+
private idleTimeoutMs: number,
|
|
133
171
|
) {}
|
|
134
172
|
|
|
135
173
|
get backendSessionId(): string | null {
|
|
@@ -156,15 +194,32 @@ class CodexSession implements AgentSession {
|
|
|
156
194
|
];
|
|
157
195
|
if (this.ctx.model && this.ctx.model !== "default") args.push("-m", this.ctx.model);
|
|
158
196
|
|
|
159
|
-
const proc = this.spawnFn(args, { cwd: this.ctx.cwd, env:
|
|
197
|
+
const proc = this.spawnFn(args, { cwd: this.ctx.cwd, env: subprocessEnv({ NIA_MCP_TOKEN: token }) });
|
|
160
198
|
this.proc = proc;
|
|
161
199
|
|
|
200
|
+
// Started now, not after exit: an undrained pipe blocks the child.
|
|
201
|
+
const stderr = drainStderr(proc.stderr);
|
|
202
|
+
|
|
162
203
|
const normalizer = new CodexNormalizer();
|
|
163
|
-
|
|
204
|
+
const stdout = proc.stdout.getReader();
|
|
205
|
+
const lines = readLines(stdout)[Symbol.asyncIterator]();
|
|
206
|
+
let sawTerminal = false;
|
|
164
207
|
try {
|
|
165
|
-
|
|
208
|
+
while (true) {
|
|
209
|
+
// Race the read rather than trusting kill() to close the pipe — a child
|
|
210
|
+
// that ignores the signal would otherwise hang the run forever.
|
|
211
|
+
const idle = idleAfter(this.idleTimeoutMs);
|
|
212
|
+
const next = await Promise.race([lines.next(), idle.promise]);
|
|
213
|
+
idle.cancel();
|
|
214
|
+
if (next === IDLE) {
|
|
215
|
+
this.idledOut = true;
|
|
216
|
+
proc.kill();
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
if (next.done) break;
|
|
220
|
+
|
|
166
221
|
if (this.aborted) throw new Error(this.aborted);
|
|
167
|
-
const trimmed =
|
|
222
|
+
const trimmed = next.value.trim();
|
|
168
223
|
if (!trimmed) continue;
|
|
169
224
|
let parsed: unknown;
|
|
170
225
|
try {
|
|
@@ -176,22 +231,32 @@ class CodexSession implements AgentSession {
|
|
|
176
231
|
if (ev.type === "session" || ev.type === "result") {
|
|
177
232
|
this._sessionId = ev.backendSessionId || this._sessionId;
|
|
178
233
|
}
|
|
179
|
-
if (ev.type === "result")
|
|
234
|
+
if (ev.type === "result" || ev.type === "error") sawTerminal = true;
|
|
180
235
|
yield ev;
|
|
181
236
|
}
|
|
182
237
|
}
|
|
238
|
+
if (this.idledOut) {
|
|
239
|
+
yield {
|
|
240
|
+
type: "error",
|
|
241
|
+
message: `codex produced no output for ${Math.round(this.idleTimeoutMs / 1000)}s`,
|
|
242
|
+
retryable: false,
|
|
243
|
+
failover: "provider",
|
|
244
|
+
};
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
183
247
|
const exit = await proc.exited;
|
|
184
248
|
if (this.aborted) throw new Error(this.aborted);
|
|
185
|
-
if (exit !== 0 && !
|
|
186
|
-
const
|
|
249
|
+
if (exit !== 0 && !sawTerminal) {
|
|
250
|
+
const text = await stderr;
|
|
187
251
|
yield {
|
|
188
252
|
type: "error",
|
|
189
|
-
message:
|
|
253
|
+
message: text.trim() || `codex exited ${exit}`,
|
|
190
254
|
retryable: false,
|
|
191
|
-
|
|
255
|
+
failover: scopeOf(parseFailure(text), "provider"),
|
|
192
256
|
};
|
|
193
257
|
}
|
|
194
258
|
} finally {
|
|
259
|
+
await stdout.cancel().catch(() => {});
|
|
195
260
|
revokeRun(token);
|
|
196
261
|
this.proc = null;
|
|
197
262
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { AgentBackend, FailoverScope } from "./types";
|
|
2
|
+
import { providerHealth, type ProviderHealth } from "./health";
|
|
3
|
+
|
|
4
|
+
/** One attempt: a backend paired with the model to run it on. */
|
|
5
|
+
export interface ChainEntry {
|
|
6
|
+
backend: AgentBackend;
|
|
7
|
+
/** Absent → let the backend pick its own default. */
|
|
8
|
+
model?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** `provider:model` for logs. */
|
|
12
|
+
export function describeEntry(entry: ChainEntry): string {
|
|
13
|
+
return `${entry.backend.name}:${entry.model ?? "default"}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Walks the resolved chain. A model-scoped failure advances one entry — possibly
|
|
18
|
+
* the same provider on another model; a provider-scoped one writes that provider
|
|
19
|
+
* off for the rest of the run.
|
|
20
|
+
*/
|
|
21
|
+
export class ChainCursor {
|
|
22
|
+
private index: number;
|
|
23
|
+
private readonly down = new Set<string>();
|
|
24
|
+
|
|
25
|
+
constructor(
|
|
26
|
+
private readonly chain: ChainEntry[],
|
|
27
|
+
private readonly health: ProviderHealth = providerHealth,
|
|
28
|
+
) {
|
|
29
|
+
// Skip a provider that recently failed, but never strand the run: if every
|
|
30
|
+
// provider is cooling down, start at the head and let it try.
|
|
31
|
+
const usable = chain.findIndex((e) => !health.isDown(e.backend.name));
|
|
32
|
+
this.index = usable === -1 ? 0 : usable;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
get current(): ChainEntry | undefined {
|
|
36
|
+
return this.chain[this.index];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
get atHead(): boolean {
|
|
40
|
+
return this.index === 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Position in the chain, for deciding whether a fresh cursor would differ. */
|
|
44
|
+
get entry(): ChainEntry | undefined {
|
|
45
|
+
return this.chain[this.index];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Record the failure and move on. Undefined when nothing usable is left. */
|
|
49
|
+
advance(scope: FailoverScope): ChainEntry | undefined {
|
|
50
|
+
if (scope === "provider") {
|
|
51
|
+
const name = this.chain[this.index]!.backend.name;
|
|
52
|
+
this.down.add(name);
|
|
53
|
+
this.health.markDown(name);
|
|
54
|
+
}
|
|
55
|
+
const next = this.chain.findIndex((e, i) => i > this.index && !this.down.has(e.backend.name));
|
|
56
|
+
if (next === -1) return undefined;
|
|
57
|
+
this.index = next;
|
|
58
|
+
return this.chain[next];
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { FailoverScope } from "./types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Failure classification shared by every backend adapter.
|
|
5
|
+
*
|
|
6
|
+
* Prefer the structured signal. Agents talk about logins, credentials and API
|
|
7
|
+
* keys all day, and the codex binary bundles an AWS SDK that mentions them too,
|
|
8
|
+
* so prose is only consulted when there is no HTTP status to go on — and the
|
|
9
|
+
* patterns that read prose are anchored to failure wording rather than to the
|
|
10
|
+
* bare nouns.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const RETRYABLE = [/\b500\b/i, /internal server error/i, /overloaded/i, /529/, /rate limit/i];
|
|
14
|
+
|
|
15
|
+
const MODEL_SCOPED = [
|
|
16
|
+
/model .*not (found|supported|available|allowed)/i,
|
|
17
|
+
/(unknown|invalid|unsupported) model/i,
|
|
18
|
+
/model .*(does not exist|is not supported)/i,
|
|
19
|
+
/context ?window ?exceeded/i,
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
const PROVIDER_SCOPED = [
|
|
23
|
+
/not logged in/i,
|
|
24
|
+
/failed to (authenticate|authorize)/i,
|
|
25
|
+
/(authentication|authorization) (failed|required|error)/i,
|
|
26
|
+
/\b(authorizationfailed|authorizationrequired|noauthorizationsupport)\b/i,
|
|
27
|
+
/failed to (load|refresh)\b.{0,40}\b(credential|token|auth)/i,
|
|
28
|
+
/(session|token|refresh token|credentials) (has |have )?expired/i,
|
|
29
|
+
/\b(tokenexpired|tokenrefreshfailed|refreshtokenfailed|tokenexchangefailed)\b/i,
|
|
30
|
+
/invalid (api[ -]?key|credentials|token)/i,
|
|
31
|
+
/\b(401 unauthorized|403 forbidden)\b/i,
|
|
32
|
+
/\bstatus:? (401|403)\b/i,
|
|
33
|
+
/(usage limit reached|quota exceeded)/i,
|
|
34
|
+
/\b(usagelimitreached|quotaexceeded)\b/i,
|
|
35
|
+
/\b(econnrefused|enotfound|etimedout|econnreset)\b/i,
|
|
36
|
+
/connection (refused|reset|timed out)/i,
|
|
37
|
+
/network (error|unreachable)/i,
|
|
38
|
+
/failed to refresh available models/i,
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
export interface Failure {
|
|
42
|
+
message: string;
|
|
43
|
+
/** HTTP status, when the backend reported a structured error. */
|
|
44
|
+
status?: number;
|
|
45
|
+
/** Provider's own error type, e.g. `invalid_request_error`. */
|
|
46
|
+
type?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Unwrap a JSON error envelope; plain text passes through. */
|
|
50
|
+
export function parseFailure(raw: string | null | undefined): Failure {
|
|
51
|
+
const text = (raw ?? "").trim();
|
|
52
|
+
try {
|
|
53
|
+
const parsed = JSON.parse(text);
|
|
54
|
+
const inner = parsed?.error?.message ?? parsed?.message;
|
|
55
|
+
return {
|
|
56
|
+
message: typeof inner === "string" && inner.trim() ? inner : text,
|
|
57
|
+
status: typeof parsed?.status === "number" ? parsed.status : undefined,
|
|
58
|
+
type: typeof parsed?.error?.type === "string" ? parsed.error.type : undefined,
|
|
59
|
+
};
|
|
60
|
+
} catch {
|
|
61
|
+
return { message: text };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Transient enough for the backend to retry in place. */
|
|
66
|
+
export function isRetryable(text: string | null | undefined): boolean {
|
|
67
|
+
const t = text?.trim();
|
|
68
|
+
return !!t && RETRYABLE.some((p) => p.test(t));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function scopeOfStatus(status: number, message: string): FailoverScope | undefined {
|
|
72
|
+
if (status === 404) return "model";
|
|
73
|
+
if (status === 401 || status === 403 || status === 408 || status === 429 || status >= 500) return "provider";
|
|
74
|
+
if (MODEL_SCOPED.some((p) => p.test(message))) return "model";
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* How far the chain should skip. `blank` is what an empty or opaque message
|
|
80
|
+
* implies: a run that reported nothing points at the provider, a structured
|
|
81
|
+
* error that merely lacks a message does not.
|
|
82
|
+
*/
|
|
83
|
+
export function scopeOf(failure: Failure, blank?: FailoverScope): FailoverScope | undefined {
|
|
84
|
+
const t = failure.message.trim();
|
|
85
|
+
if (!t || t.toLowerCase() === "unknown error") return blank;
|
|
86
|
+
if (failure.status !== undefined) return scopeOfStatus(failure.status, t);
|
|
87
|
+
if (MODEL_SCOPED.some((p) => p.test(t))) return "model";
|
|
88
|
+
if (PROVIDER_SCOPED.some((p) => p.test(t))) return "provider";
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process-wide provider cooldown. Without it every job and every chat turn
|
|
3
|
+
* re-discovers the same outage from scratch, paying the full retry ladder before
|
|
4
|
+
* failing over; with it a provider that just failed is skipped until it has had
|
|
5
|
+
* time to recover, and picked up again once the cooldown lapses.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const DEFAULT_COOLDOWN_MS = 5 * 60 * 1000;
|
|
9
|
+
|
|
10
|
+
export interface ProviderHealth {
|
|
11
|
+
markDown(provider: string): void;
|
|
12
|
+
isDown(provider: string): boolean;
|
|
13
|
+
/** Test seam. */
|
|
14
|
+
clear(): void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function createProviderHealth(
|
|
18
|
+
cooldownMs: number = DEFAULT_COOLDOWN_MS,
|
|
19
|
+
now: () => number = Date.now,
|
|
20
|
+
): ProviderHealth {
|
|
21
|
+
const downUntil = new Map<string, number>();
|
|
22
|
+
return {
|
|
23
|
+
markDown(provider) {
|
|
24
|
+
downUntil.set(provider, now() + cooldownMs);
|
|
25
|
+
},
|
|
26
|
+
isDown(provider) {
|
|
27
|
+
const until = downUntil.get(provider);
|
|
28
|
+
if (until === undefined) return false;
|
|
29
|
+
if (now() > until) {
|
|
30
|
+
downUntil.delete(provider);
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
return true;
|
|
34
|
+
},
|
|
35
|
+
clear() {
|
|
36
|
+
downUntil.clear();
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const providerHealth = createProviderHealth();
|
package/src/agent/index.ts
CHANGED
|
@@ -8,5 +8,7 @@ export type {
|
|
|
8
8
|
Normalizer,
|
|
9
9
|
} from "./types";
|
|
10
10
|
export { isResultEvent } from "./types";
|
|
11
|
-
export {
|
|
11
|
+
export type { FailoverScope } from "./types";
|
|
12
|
+
export { getBackend, setBackend, setBackendChain, resolveChain, buildChain } from "./registry";
|
|
13
|
+
export { ChainCursor, describeEntry, type ChainEntry } from "./chain";
|
|
12
14
|
export { resolveSdkModel } from "./backends/claude";
|
|
@@ -4,6 +4,7 @@ import { randomBytes, randomUUID } from "crypto";
|
|
|
4
4
|
import type { NiaTool } from "../mcp/tools/types";
|
|
5
5
|
import type { McpSourceContext } from "../mcp";
|
|
6
6
|
import { log } from "../utils/log";
|
|
7
|
+
import { gateSideEffects } from "../mcp/gate";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Loopback MCP endpoint — how out-of-process CLI backends (Codex/Gemini) reach
|
|
@@ -33,7 +34,7 @@ let endpointTools: NiaTool[] = [];
|
|
|
33
34
|
/** Build a per-run MCP server whose tool closures bake in the frozen context. */
|
|
34
35
|
function buildRunServer(ctx: McpSourceContext, tools: NiaTool[]): McpServer {
|
|
35
36
|
const mcp = new McpServer({ name: "nia", version: "0.1.0" });
|
|
36
|
-
for (const t of tools) {
|
|
37
|
+
for (const t of gateSideEffects(tools)) {
|
|
37
38
|
mcp.registerTool(t.name, { description: t.description, inputSchema: t.schema }, async (args: unknown) => ({
|
|
38
39
|
content: [{ type: "text" as const, text: await t.handler(args, ctx) }],
|
|
39
40
|
}));
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { BackendName } from "../types/config";
|
|
2
|
+
|
|
3
|
+
/** Model → provider resolution. Config names models, never backends, so one
|
|
4
|
+
* provider's model id can never reach another. */
|
|
5
|
+
|
|
6
|
+
export type ProviderName = BackendName;
|
|
7
|
+
|
|
8
|
+
export interface ModelRef {
|
|
9
|
+
provider: ProviderName;
|
|
10
|
+
/** Absent → let the provider pick. */
|
|
11
|
+
model?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Order of the implicit cross-provider tail. */
|
|
15
|
+
export const PROVIDER_ORDER: readonly ProviderName[] = ["claude", "codex", "gemini"];
|
|
16
|
+
|
|
17
|
+
/** Selects a provider without naming a model. */
|
|
18
|
+
const BARE_PROVIDERS: Record<string, ProviderName> = {
|
|
19
|
+
default: "claude",
|
|
20
|
+
claude: "claude",
|
|
21
|
+
codex: "codex",
|
|
22
|
+
gemini: "gemini",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Short names the provider understands directly. */
|
|
26
|
+
const ALIASES: Record<string, ProviderName> = {
|
|
27
|
+
sonnet: "claude",
|
|
28
|
+
opus: "claude",
|
|
29
|
+
opusplan: "claude",
|
|
30
|
+
haiku: "claude",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const PREFIXES: [RegExp, ProviderName][] = [
|
|
34
|
+
[/^claude[-.]/, "claude"],
|
|
35
|
+
[/^(gpt|o[134]|codex)[-.]/, "codex"],
|
|
36
|
+
[/^gemini[-.]/, "gemini"],
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
/** Unrecognized names are assumed to be Claude's — a rejected model is a
|
|
40
|
+
* model-scoped failure, so a typo degrades to the next entry. */
|
|
41
|
+
export function resolveModel(name: string): ModelRef {
|
|
42
|
+
const model = name.trim();
|
|
43
|
+
const key = model.toLowerCase();
|
|
44
|
+
|
|
45
|
+
const bare = BARE_PROVIDERS[key];
|
|
46
|
+
if (bare) return { provider: bare };
|
|
47
|
+
|
|
48
|
+
const alias = ALIASES[key];
|
|
49
|
+
if (alias) return { provider: alias, model };
|
|
50
|
+
|
|
51
|
+
for (const [pattern, provider] of PREFIXES) {
|
|
52
|
+
if (pattern.test(key)) return { provider, model };
|
|
53
|
+
}
|
|
54
|
+
return { provider: "claude", model };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function providerDefault(provider: ProviderName): ModelRef {
|
|
58
|
+
return { provider };
|
|
59
|
+
}
|