@ouro.bot/cli 0.1.0-alpha.112 → 0.1.0-alpha.114
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/changelog.json +15 -0
- package/dist/heart/core.js +37 -5
- package/dist/heart/daemon/daemon-cli.js +2 -1
- package/dist/heart/provider-failover.js +71 -0
- package/dist/heart/provider-ping.js +132 -0
- package/dist/heart/providers/anthropic.js +33 -11
- package/dist/heart/providers/azure.js +29 -2
- package/dist/heart/providers/github-copilot.js +34 -26
- package/dist/heart/providers/minimax.js +28 -2
- package/dist/heart/providers/openai-codex.js +36 -10
- package/dist/senses/bluebubbles.js +14 -0
- package/dist/senses/cli.js +30 -1
- package/dist/senses/pipeline.js +110 -0
- package/dist/senses/teams.js +31 -1
- package/package.json +1 -1
package/changelog.json
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
|
|
3
3
|
"versions": [
|
|
4
|
+
{
|
|
5
|
+
"version": "0.1.0-alpha.114",
|
|
6
|
+
"changes": [
|
|
7
|
+
"When a model provider fails mid-conversation (auth error, usage limit, outage), the harness now classifies the error, pings alternative configured providers, and surfaces validated failover options to the user in-channel. Reply 'switch to <provider>' to continue on a working provider.",
|
|
8
|
+
"Each provider now has a `classifyError` method that distinguishes auth failures, usage/subscription limits, rate limits, server errors, and network errors. The old auth guidance wrappers are replaced by this unified classification system.",
|
|
9
|
+
"New `pingProvider` function makes a real heartbeat completion call to verify provider credentials and quota are live — no more format-only checks.",
|
|
10
|
+
"Provider factories now accept optional config parameters, enabling credential injection for health inventory pings without touching disk config."
|
|
11
|
+
]
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"version": "0.1.0-alpha.113",
|
|
15
|
+
"changes": [
|
|
16
|
+
"`ouro changelog --from` now uses semver comparison instead of lexicographic string ordering, so alpha prerelease numbers sort correctly."
|
|
17
|
+
]
|
|
18
|
+
},
|
|
4
19
|
{
|
|
5
20
|
"version": "0.1.0-alpha.112",
|
|
6
21
|
"changes": [
|
package/dist/heart/core.js
CHANGED
|
@@ -422,6 +422,14 @@ function classifyTransientError(err) {
|
|
|
422
422
|
}
|
|
423
423
|
const MAX_RETRIES = 3;
|
|
424
424
|
const RETRY_BASE_MS = 2000;
|
|
425
|
+
const TRANSIENT_RETRY_LABELS = {
|
|
426
|
+
"auth-failure": "auth error",
|
|
427
|
+
"usage-limit": "usage limit",
|
|
428
|
+
"rate-limit": "rate limited",
|
|
429
|
+
"server-error": "server error",
|
|
430
|
+
"network-error": "network error",
|
|
431
|
+
"unknown": "error",
|
|
432
|
+
};
|
|
425
433
|
async function runAgent(messages, callbacks, channel, signal, options) {
|
|
426
434
|
const providerRuntime = getProviderRuntime();
|
|
427
435
|
const provider = providerRuntime.id;
|
|
@@ -483,6 +491,8 @@ async function runAgent(messages, callbacks, channel, signal, options) {
|
|
|
483
491
|
let retryCount = 0;
|
|
484
492
|
let outcome = "complete";
|
|
485
493
|
let completion;
|
|
494
|
+
let terminalError;
|
|
495
|
+
let terminalErrorClassification;
|
|
486
496
|
let sawSteeringFollowUp = false;
|
|
487
497
|
let mustResolveBeforeHandoffActive = options?.mustResolveBeforeHandoff === true;
|
|
488
498
|
let currentReasoningEffort = "medium";
|
|
@@ -872,7 +882,16 @@ async function runAgent(messages, callbacks, channel, signal, options) {
|
|
|
872
882
|
if (isTransientError(e) && retryCount < MAX_RETRIES) {
|
|
873
883
|
retryCount++;
|
|
874
884
|
const delay = RETRY_BASE_MS * Math.pow(2, retryCount - 1);
|
|
875
|
-
|
|
885
|
+
let classification;
|
|
886
|
+
try {
|
|
887
|
+
classification = providerRuntime.classifyError(e instanceof Error ? e : /* v8 ignore next -- defensive: errors are always Error instances @preserve */ new Error(String(e)));
|
|
888
|
+
}
|
|
889
|
+
catch {
|
|
890
|
+
/* v8 ignore next -- defensive: classifyError should not throw @preserve */
|
|
891
|
+
classification = classifyTransientError(e);
|
|
892
|
+
}
|
|
893
|
+
/* v8 ignore next -- defensive: all classifications have labels @preserve */
|
|
894
|
+
const cause = TRANSIENT_RETRY_LABELS[classification] ?? classification;
|
|
876
895
|
callbacks.onError(new Error(`${cause}, retrying in ${delay / 1000}s (${retryCount}/${MAX_RETRIES})...`), "transient");
|
|
877
896
|
// Wait with abort support
|
|
878
897
|
const aborted = await new Promise((resolve) => {
|
|
@@ -895,14 +914,22 @@ async function runAgent(messages, callbacks, channel, signal, options) {
|
|
|
895
914
|
providerRuntime.resetTurnState(messages);
|
|
896
915
|
continue;
|
|
897
916
|
}
|
|
898
|
-
|
|
917
|
+
terminalError = e instanceof Error ? e : new Error(String(e));
|
|
918
|
+
try {
|
|
919
|
+
terminalErrorClassification = providerRuntime.classifyError(terminalError);
|
|
920
|
+
}
|
|
921
|
+
catch {
|
|
922
|
+
/* v8 ignore next -- defensive: classifyError should not throw @preserve */
|
|
923
|
+
terminalErrorClassification = "unknown";
|
|
924
|
+
}
|
|
925
|
+
callbacks.onError(terminalError, "terminal");
|
|
899
926
|
(0, runtime_1.emitNervesEvent)({
|
|
900
927
|
level: "error",
|
|
901
928
|
event: "engine.error",
|
|
902
929
|
trace_id: traceId,
|
|
903
930
|
component: "engine",
|
|
904
|
-
message:
|
|
905
|
-
meta: {},
|
|
931
|
+
message: terminalError.message,
|
|
932
|
+
meta: { errorClassification: terminalErrorClassification },
|
|
906
933
|
});
|
|
907
934
|
stripLastToolCalls(messages);
|
|
908
935
|
outcome = "errored";
|
|
@@ -916,5 +943,10 @@ async function runAgent(messages, callbacks, channel, signal, options) {
|
|
|
916
943
|
message: "runAgent turn completed",
|
|
917
944
|
meta: { done, sawGoInward, sawQuerySession, sawBridgeManage },
|
|
918
945
|
});
|
|
919
|
-
return {
|
|
946
|
+
return {
|
|
947
|
+
usage: lastUsage,
|
|
948
|
+
outcome,
|
|
949
|
+
completion,
|
|
950
|
+
...(terminalError ? { error: terminalError, errorClassification: terminalErrorClassification } : {}),
|
|
951
|
+
};
|
|
920
952
|
}
|
|
@@ -46,6 +46,7 @@ const crypto_1 = require("crypto");
|
|
|
46
46
|
const fs = __importStar(require("fs"));
|
|
47
47
|
const os = __importStar(require("os"));
|
|
48
48
|
const path = __importStar(require("path"));
|
|
49
|
+
const semver = __importStar(require("semver"));
|
|
49
50
|
const identity_1 = require("../identity");
|
|
50
51
|
const runtime_1 = require("../../nerves/runtime");
|
|
51
52
|
const store_file_1 = require("../../mind/friends/store-file");
|
|
@@ -2220,7 +2221,7 @@ async function runOuroCli(args, deps = createDefaultOuroCliDeps()) {
|
|
|
2220
2221
|
let filtered = entries;
|
|
2221
2222
|
if (command.from) {
|
|
2222
2223
|
const fromVersion = command.from;
|
|
2223
|
-
filtered = entries.filter((e) => e.version
|
|
2224
|
+
filtered = entries.filter((e) => semver.valid(e.version) && semver.gt(e.version, fromVersion));
|
|
2224
2225
|
}
|
|
2225
2226
|
if (filtered.length === 0) {
|
|
2226
2227
|
const message = "no changelog entries found.";
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.buildFailoverContext = buildFailoverContext;
|
|
4
|
+
exports.handleFailoverReply = handleFailoverReply;
|
|
5
|
+
const runtime_1 = require("../nerves/runtime");
|
|
6
|
+
const CLASSIFICATION_LABELS = {
|
|
7
|
+
"auth-failure": "authentication failed",
|
|
8
|
+
"usage-limit": "hit its usage limit",
|
|
9
|
+
"rate-limit": "is being rate limited",
|
|
10
|
+
"server-error": "is experiencing an outage",
|
|
11
|
+
"network-error": "is unreachable (network error)",
|
|
12
|
+
"unknown": "encountered an error",
|
|
13
|
+
};
|
|
14
|
+
function buildFailoverContext(errorMessage, classification, currentProvider, currentModel, agentName, inventory, providerModels) {
|
|
15
|
+
const label = CLASSIFICATION_LABELS[classification];
|
|
16
|
+
const providerWithModel = currentModel ? `${currentProvider} (${currentModel})` : currentProvider;
|
|
17
|
+
const errorSummary = errorMessage
|
|
18
|
+
? `${providerWithModel} ${label} (${errorMessage})`
|
|
19
|
+
: `${providerWithModel} ${label}`;
|
|
20
|
+
const workingProviders = [];
|
|
21
|
+
const unconfiguredProviders = [];
|
|
22
|
+
for (const [provider, result] of Object.entries(inventory)) {
|
|
23
|
+
if (result.ok) {
|
|
24
|
+
workingProviders.push(provider);
|
|
25
|
+
}
|
|
26
|
+
else if (result.classification === "auth-failure" && result.message === "no credentials configured") {
|
|
27
|
+
unconfiguredProviders.push(provider);
|
|
28
|
+
}
|
|
29
|
+
// Providers that are configured but failing (e.g., also rate-limited) are omitted from both lists
|
|
30
|
+
}
|
|
31
|
+
const lines = [`${errorSummary}.`];
|
|
32
|
+
if (workingProviders.length > 0) {
|
|
33
|
+
const switchDescriptions = workingProviders.map((p) => {
|
|
34
|
+
const model = providerModels[p];
|
|
35
|
+
return model ? `${p} (${model})` : /* v8 ignore next -- defensive: model always present in secrets @preserve */ p;
|
|
36
|
+
});
|
|
37
|
+
const switchOptions = workingProviders.map((p) => `"switch to ${p}"`).join(" or ");
|
|
38
|
+
lines.push(`these providers are ready to go: ${switchDescriptions.join(", ")}.`);
|
|
39
|
+
lines.push(`reply ${switchOptions} to continue.`);
|
|
40
|
+
}
|
|
41
|
+
if (unconfiguredProviders.length > 0) {
|
|
42
|
+
lines.push(`to set up ${unconfiguredProviders.join(", ")}, run \`ouro auth --agent ${agentName}\` in terminal.`);
|
|
43
|
+
}
|
|
44
|
+
if (workingProviders.length === 0 && unconfiguredProviders.length === 0) {
|
|
45
|
+
lines.push(`no other providers are available. run \`ouro auth --agent ${agentName}\` in terminal to configure one.`);
|
|
46
|
+
}
|
|
47
|
+
(0, runtime_1.emitNervesEvent)({
|
|
48
|
+
component: "engine",
|
|
49
|
+
event: "engine.failover_context_built",
|
|
50
|
+
message: "built provider failover context",
|
|
51
|
+
meta: { currentProvider, classification, workingProviders, unconfiguredProviders },
|
|
52
|
+
});
|
|
53
|
+
return {
|
|
54
|
+
errorSummary,
|
|
55
|
+
classification,
|
|
56
|
+
currentProvider,
|
|
57
|
+
agentName,
|
|
58
|
+
workingProviders,
|
|
59
|
+
unconfiguredProviders,
|
|
60
|
+
userMessage: lines.join(" "),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function handleFailoverReply(reply, context) {
|
|
64
|
+
const lower = reply.toLowerCase().trim();
|
|
65
|
+
for (const provider of context.workingProviders) {
|
|
66
|
+
if (lower === `switch to ${provider}` || lower === provider) {
|
|
67
|
+
return { action: "switch", provider };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { action: "dismiss" };
|
|
71
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.pingProvider = pingProvider;
|
|
4
|
+
exports.runHealthInventory = runHealthInventory;
|
|
5
|
+
const anthropic_1 = require("./providers/anthropic");
|
|
6
|
+
const azure_1 = require("./providers/azure");
|
|
7
|
+
const minimax_1 = require("./providers/minimax");
|
|
8
|
+
const openai_codex_1 = require("./providers/openai-codex");
|
|
9
|
+
const github_copilot_1 = require("./providers/github-copilot");
|
|
10
|
+
const auth_flow_1 = require("./daemon/auth-flow");
|
|
11
|
+
const runtime_1 = require("../nerves/runtime");
|
|
12
|
+
const PING_TIMEOUT_MS = 10_000;
|
|
13
|
+
function hasEmptyCredentials(provider, config) {
|
|
14
|
+
switch (provider) {
|
|
15
|
+
case "anthropic":
|
|
16
|
+
return !config.setupToken;
|
|
17
|
+
case "openai-codex":
|
|
18
|
+
return !config.oauthAccessToken;
|
|
19
|
+
case "minimax":
|
|
20
|
+
return !config.apiKey;
|
|
21
|
+
case "azure": {
|
|
22
|
+
const azure = config;
|
|
23
|
+
return !(azure.apiKey && azure.endpoint && azure.deployment);
|
|
24
|
+
}
|
|
25
|
+
case "github-copilot": {
|
|
26
|
+
const copilot = config;
|
|
27
|
+
return !(copilot.githubToken && copilot.baseUrl);
|
|
28
|
+
}
|
|
29
|
+
/* v8 ignore next 2 -- exhaustive: all providers handled above @preserve */
|
|
30
|
+
default:
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function createRuntimeForPing(provider, config) {
|
|
35
|
+
switch (provider) {
|
|
36
|
+
case "anthropic":
|
|
37
|
+
return (0, anthropic_1.createAnthropicProviderRuntime)(config);
|
|
38
|
+
case "azure":
|
|
39
|
+
return (0, azure_1.createAzureProviderRuntime)(config);
|
|
40
|
+
case "minimax":
|
|
41
|
+
return (0, minimax_1.createMinimaxProviderRuntime)(config);
|
|
42
|
+
case "openai-codex":
|
|
43
|
+
return (0, openai_codex_1.createOpenAICodexProviderRuntime)(config);
|
|
44
|
+
case "github-copilot":
|
|
45
|
+
return (0, github_copilot_1.createGithubCopilotProviderRuntime)(config);
|
|
46
|
+
/* v8 ignore next 2 -- exhaustive: all providers handled above @preserve */
|
|
47
|
+
default:
|
|
48
|
+
throw new Error(`unsupported provider for ping: ${provider}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/* v8 ignore start -- no-op stubs: never invoked, ping only needs streamTurn to not throw @preserve */
|
|
52
|
+
const noop = () => { };
|
|
53
|
+
/* v8 ignore stop */
|
|
54
|
+
const noopCallbacks = {
|
|
55
|
+
onModelStart: noop,
|
|
56
|
+
onModelStreamStart: noop,
|
|
57
|
+
onTextChunk: noop,
|
|
58
|
+
onReasoningChunk: noop,
|
|
59
|
+
onToolStart: noop,
|
|
60
|
+
onToolEnd: noop,
|
|
61
|
+
onError: noop,
|
|
62
|
+
};
|
|
63
|
+
async function pingProvider(provider, config) {
|
|
64
|
+
if (hasEmptyCredentials(provider, config)) {
|
|
65
|
+
return { ok: false, classification: "auth-failure", message: "no credentials configured" };
|
|
66
|
+
}
|
|
67
|
+
let runtime;
|
|
68
|
+
try {
|
|
69
|
+
runtime = createRuntimeForPing(provider, config);
|
|
70
|
+
/* v8 ignore start -- factory creation failure: tested via individual provider init tests @preserve */
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
return {
|
|
74
|
+
ok: false,
|
|
75
|
+
classification: "auth-failure",
|
|
76
|
+
message: error instanceof Error ? error.message : String(error),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/* v8 ignore stop */
|
|
80
|
+
try {
|
|
81
|
+
const controller = new AbortController();
|
|
82
|
+
/* v8 ignore next -- timeout callback: only fires after 10s, tests resolve faster @preserve */
|
|
83
|
+
const timeout = setTimeout(() => controller.abort(), PING_TIMEOUT_MS);
|
|
84
|
+
try {
|
|
85
|
+
await runtime.streamTurn({
|
|
86
|
+
messages: [{ role: "user", content: "ping" }],
|
|
87
|
+
activeTools: [],
|
|
88
|
+
callbacks: noopCallbacks,
|
|
89
|
+
signal: controller.signal,
|
|
90
|
+
});
|
|
91
|
+
return { ok: true };
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
clearTimeout(timeout);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
const err = error instanceof Error ? error : /* v8 ignore next -- defensive @preserve */ new Error(String(error));
|
|
99
|
+
let classification;
|
|
100
|
+
try {
|
|
101
|
+
classification = runtime.classifyError(err);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
/* v8 ignore next -- defensive: classifyError should not throw @preserve */
|
|
105
|
+
classification = "unknown";
|
|
106
|
+
}
|
|
107
|
+
(0, runtime_1.emitNervesEvent)({
|
|
108
|
+
component: "engine",
|
|
109
|
+
event: "engine.provider_ping_fail",
|
|
110
|
+
message: `provider ping failed: ${provider}`,
|
|
111
|
+
meta: { provider, classification, error: err.message },
|
|
112
|
+
});
|
|
113
|
+
return { ok: false, classification, message: err.message };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const PINGABLE_PROVIDERS = ["anthropic", "openai-codex", "azure", "minimax", "github-copilot"];
|
|
117
|
+
async function runHealthInventory(agentName, currentProvider, deps = {}) {
|
|
118
|
+
/* v8 ignore next -- default: tests inject ping dep @preserve */
|
|
119
|
+
const ping = deps.ping ?? pingProvider;
|
|
120
|
+
const { secrets } = (0, auth_flow_1.loadAgentSecrets)(agentName);
|
|
121
|
+
const providers = PINGABLE_PROVIDERS.filter((p) => p !== currentProvider);
|
|
122
|
+
const results = await Promise.all(providers.map(async (provider) => {
|
|
123
|
+
const config = secrets.providers[provider];
|
|
124
|
+
const result = await ping(provider, config);
|
|
125
|
+
return [provider, result];
|
|
126
|
+
}));
|
|
127
|
+
const inventory = {};
|
|
128
|
+
for (const [provider, result] of results) {
|
|
129
|
+
inventory[provider] = result;
|
|
130
|
+
}
|
|
131
|
+
return inventory;
|
|
132
|
+
}
|
|
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.toAnthropicMessages = toAnthropicMessages;
|
|
7
|
+
exports.classifyAnthropicError = classifyAnthropicError;
|
|
7
8
|
exports.createAnthropicProviderRuntime = createAnthropicProviderRuntime;
|
|
8
9
|
const sdk_1 = __importDefault(require("@anthropic-ai/sdk"));
|
|
9
10
|
const config_1 = require("../config");
|
|
@@ -187,6 +188,29 @@ function mergeAnthropicToolArguments(current, partial) {
|
|
|
187
188
|
}
|
|
188
189
|
return current + partial;
|
|
189
190
|
}
|
|
191
|
+
/* v8 ignore start -- shared network error utility, tested via classification tests @preserve */
|
|
192
|
+
function isNetworkError(error) {
|
|
193
|
+
const code = error.code || "";
|
|
194
|
+
if (["ECONNRESET", "ECONNREFUSED", "ENOTFOUND", "ETIMEDOUT", "EPIPE",
|
|
195
|
+
"EAI_AGAIN", "EHOSTUNREACH", "ENETUNREACH", "ECONNABORTED"].includes(code))
|
|
196
|
+
return true;
|
|
197
|
+
const msg = error.message || "";
|
|
198
|
+
return msg.includes("fetch failed") || msg.includes("socket hang up") || msg.includes("getaddrinfo");
|
|
199
|
+
}
|
|
200
|
+
/* v8 ignore stop */
|
|
201
|
+
function classifyAnthropicError(error) {
|
|
202
|
+
const status = error.status;
|
|
203
|
+
if (status === 401 || status === 403 || isAnthropicAuthFailure(error))
|
|
204
|
+
return "auth-failure";
|
|
205
|
+
if (status === 429)
|
|
206
|
+
return "rate-limit";
|
|
207
|
+
if (status === 529 || (status && status >= 500))
|
|
208
|
+
return "server-error";
|
|
209
|
+
if (isNetworkError(error))
|
|
210
|
+
return "network-error";
|
|
211
|
+
return "unknown";
|
|
212
|
+
}
|
|
213
|
+
/* v8 ignore start -- auth detection: only called from classifyAnthropicError which always passes Error @preserve */
|
|
190
214
|
function isAnthropicAuthFailure(error) {
|
|
191
215
|
if (!(error instanceof Error))
|
|
192
216
|
return false;
|
|
@@ -199,13 +223,7 @@ function isAnthropicAuthFailure(error) {
|
|
|
199
223
|
lower.includes("unauthorized") ||
|
|
200
224
|
lower.includes("invalid api key"));
|
|
201
225
|
}
|
|
202
|
-
|
|
203
|
-
const base = error instanceof Error ? error.message : String(error);
|
|
204
|
-
if (isAnthropicAuthFailure(error)) {
|
|
205
|
-
return new Error(getAnthropicReauthGuidance(`Anthropic authentication failed (${base}).`));
|
|
206
|
-
}
|
|
207
|
-
return error instanceof Error ? error : new Error(String(error));
|
|
208
|
-
}
|
|
226
|
+
/* v8 ignore stop */
|
|
209
227
|
async function streamAnthropicMessages(client, model, request) {
|
|
210
228
|
const { system, messages } = toAnthropicMessages(request.messages);
|
|
211
229
|
const anthropicTools = toAnthropicTools(request.activeTools);
|
|
@@ -230,7 +248,7 @@ async function streamAnthropicMessages(client, model, request) {
|
|
|
230
248
|
response = await client.messages.create(params, request.signal ? { signal: request.signal } : {});
|
|
231
249
|
}
|
|
232
250
|
catch (error) {
|
|
233
|
-
throw
|
|
251
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
234
252
|
}
|
|
235
253
|
let content = "";
|
|
236
254
|
let streamStarted = false;
|
|
@@ -336,7 +354,7 @@ async function streamAnthropicMessages(client, model, request) {
|
|
|
336
354
|
}
|
|
337
355
|
}
|
|
338
356
|
catch (error) {
|
|
339
|
-
throw
|
|
357
|
+
throw error instanceof Error ? error : /* v8 ignore next -- defensive: stream errors are always Error @preserve */ new Error(String(error));
|
|
340
358
|
}
|
|
341
359
|
// Collect all thinking blocks (regular + redacted) sorted by index to preserve ordering
|
|
342
360
|
const allThinkingIndices = [...thinkingBlocks.keys(), ...redactedBlocks.keys()].sort((a, b) => a - b);
|
|
@@ -354,14 +372,14 @@ async function streamAnthropicMessages(client, model, request) {
|
|
|
354
372
|
finalAnswerStreamed: answerStreamer.streamed,
|
|
355
373
|
};
|
|
356
374
|
}
|
|
357
|
-
function createAnthropicProviderRuntime() {
|
|
375
|
+
function createAnthropicProviderRuntime(config) {
|
|
358
376
|
(0, runtime_1.emitNervesEvent)({
|
|
359
377
|
component: "engine",
|
|
360
378
|
event: "engine.provider_init",
|
|
361
379
|
message: "anthropic provider init",
|
|
362
380
|
meta: { provider: "anthropic" },
|
|
363
381
|
});
|
|
364
|
-
const anthropicConfig = (0, config_1.getAnthropicConfig)();
|
|
382
|
+
const anthropicConfig = config ?? (0, config_1.getAnthropicConfig)();
|
|
365
383
|
if (!(anthropicConfig.model && anthropicConfig.setupToken)) {
|
|
366
384
|
throw new Error(getAnthropicReauthGuidance("provider 'anthropic' is selected in agent.json but providers.anthropic.model/setupToken is incomplete in secrets.json."));
|
|
367
385
|
}
|
|
@@ -393,5 +411,9 @@ function createAnthropicProviderRuntime() {
|
|
|
393
411
|
streamTurn(request) {
|
|
394
412
|
return streamAnthropicMessages(client, anthropicConfig.model, request);
|
|
395
413
|
},
|
|
414
|
+
/* v8 ignore next 3 -- delegation: classification logic tested via classifyAnthropicError @preserve */
|
|
415
|
+
classifyError(error) {
|
|
416
|
+
return classifyAnthropicError(error);
|
|
417
|
+
},
|
|
396
418
|
};
|
|
397
419
|
}
|
|
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.classifyAzureError = classifyAzureError;
|
|
36
37
|
exports.createAzureTokenProvider = createAzureTokenProvider;
|
|
37
38
|
exports.createAzureProviderRuntime = createAzureProviderRuntime;
|
|
38
39
|
const openai_1 = require("openai");
|
|
@@ -41,6 +42,28 @@ const runtime_1 = require("../../nerves/runtime");
|
|
|
41
42
|
const streaming_1 = require("../streaming");
|
|
42
43
|
const model_capabilities_1 = require("../model-capabilities");
|
|
43
44
|
const COGNITIVE_SERVICES_SCOPE = "https://cognitiveservices.azure.com/.default";
|
|
45
|
+
/* v8 ignore start -- shared network error utility, tested via classification tests @preserve */
|
|
46
|
+
function isNetworkError(error) {
|
|
47
|
+
const code = error.code || "";
|
|
48
|
+
if (["ECONNRESET", "ECONNREFUSED", "ENOTFOUND", "ETIMEDOUT", "EPIPE",
|
|
49
|
+
"EAI_AGAIN", "EHOSTUNREACH", "ENETUNREACH", "ECONNABORTED"].includes(code))
|
|
50
|
+
return true;
|
|
51
|
+
const msg = error.message || "";
|
|
52
|
+
return msg.includes("fetch failed") || msg.includes("socket hang up") || msg.includes("getaddrinfo");
|
|
53
|
+
}
|
|
54
|
+
/* v8 ignore stop */
|
|
55
|
+
function classifyAzureError(error) {
|
|
56
|
+
const status = error.status;
|
|
57
|
+
if (status === 401 || status === 403)
|
|
58
|
+
return "auth-failure";
|
|
59
|
+
if (status === 429)
|
|
60
|
+
return "rate-limit";
|
|
61
|
+
if (status && status >= 500)
|
|
62
|
+
return "server-error";
|
|
63
|
+
if (isNetworkError(error))
|
|
64
|
+
return "network-error";
|
|
65
|
+
return "unknown";
|
|
66
|
+
}
|
|
44
67
|
// @azure/identity is imported dynamically (below) rather than at the top level
|
|
45
68
|
// because it's a heavy package (~30+ transitive deps) and we only need it when
|
|
46
69
|
// using the managed-identity auth path. API-key users and other providers
|
|
@@ -69,8 +92,8 @@ function createAzureTokenProvider(managedIdentityClientId) {
|
|
|
69
92
|
}
|
|
70
93
|
};
|
|
71
94
|
}
|
|
72
|
-
function createAzureProviderRuntime() {
|
|
73
|
-
const azureConfig = (0, config_1.getAzureConfig)();
|
|
95
|
+
function createAzureProviderRuntime(config) {
|
|
96
|
+
const azureConfig = config ?? (0, config_1.getAzureConfig)();
|
|
74
97
|
const useApiKey = !!azureConfig.apiKey;
|
|
75
98
|
const authMethod = useApiKey ? "key" : "managed-identity";
|
|
76
99
|
(0, runtime_1.emitNervesEvent)({
|
|
@@ -141,5 +164,9 @@ function createAzureProviderRuntime() {
|
|
|
141
164
|
nativeInput.push(item);
|
|
142
165
|
return result;
|
|
143
166
|
},
|
|
167
|
+
/* v8 ignore next 3 -- delegation: classification logic tested via classifyAzureError @preserve */
|
|
168
|
+
classifyError(error) {
|
|
169
|
+
return classifyAzureError(error);
|
|
170
|
+
},
|
|
144
171
|
};
|
|
145
172
|
}
|
|
@@ -3,45 +3,45 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.classifyGithubCopilotError = classifyGithubCopilotError;
|
|
6
7
|
exports.createGithubCopilotProviderRuntime = createGithubCopilotProviderRuntime;
|
|
7
8
|
const openai_1 = __importDefault(require("openai"));
|
|
8
9
|
const config_1 = require("../config");
|
|
9
|
-
const identity_1 = require("../identity");
|
|
10
10
|
const runtime_1 = require("../../nerves/runtime");
|
|
11
11
|
const streaming_1 = require("../streaming");
|
|
12
12
|
const model_capabilities_1 = require("../model-capabilities");
|
|
13
|
-
/* v8 ignore start --
|
|
14
|
-
function
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const agentName = (0, identity_1.getAgentName)();
|
|
22
|
-
return [
|
|
23
|
-
`provider github-copilot failed (${reason}).`,
|
|
24
|
-
`Run \`ouro auth verify --agent ${agentName}\` to check all configured providers,`,
|
|
25
|
-
`\`ouro auth switch --agent ${agentName} --provider <other>\` to switch,`,
|
|
26
|
-
`or \`ouro auth --agent ${agentName} --provider github-copilot\` to reconfigure.`,
|
|
27
|
-
].join(" ");
|
|
13
|
+
/* v8 ignore start -- duplicated from shared provider utils, tested there @preserve */
|
|
14
|
+
function isNetworkError(error) {
|
|
15
|
+
const code = error.code || "";
|
|
16
|
+
if (["ECONNRESET", "ECONNREFUSED", "ENOTFOUND", "ETIMEDOUT", "EPIPE",
|
|
17
|
+
"EAI_AGAIN", "EHOSTUNREACH", "ENETUNREACH", "ECONNABORTED"].includes(code))
|
|
18
|
+
return true;
|
|
19
|
+
const msg = error.message || "";
|
|
20
|
+
return msg.includes("fetch failed") || msg.includes("socket hang up") || msg.includes("getaddrinfo");
|
|
28
21
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
22
|
+
/* v8 ignore stop */
|
|
23
|
+
/* v8 ignore start -- duplicated classification pattern, tested via provider unit tests @preserve */
|
|
24
|
+
function classifyGithubCopilotError(error) {
|
|
25
|
+
const status = error.status;
|
|
26
|
+
if (status === 401 || status === 403)
|
|
27
|
+
return "auth-failure";
|
|
28
|
+
if (status === 429)
|
|
29
|
+
return "rate-limit";
|
|
30
|
+
if (status && status >= 500)
|
|
31
|
+
return "server-error";
|
|
32
|
+
if (isNetworkError(error))
|
|
33
|
+
return "network-error";
|
|
34
|
+
return "unknown";
|
|
35
35
|
}
|
|
36
36
|
/* v8 ignore stop */
|
|
37
|
-
function createGithubCopilotProviderRuntime() {
|
|
37
|
+
function createGithubCopilotProviderRuntime(injectedConfig) {
|
|
38
38
|
(0, runtime_1.emitNervesEvent)({
|
|
39
39
|
component: "engine",
|
|
40
40
|
event: "engine.provider_init",
|
|
41
41
|
message: "github-copilot provider init",
|
|
42
42
|
meta: { provider: "github-copilot" },
|
|
43
43
|
});
|
|
44
|
-
const config = (0, config_1.getGithubCopilotConfig)();
|
|
44
|
+
const config = injectedConfig ?? (0, config_1.getGithubCopilotConfig)();
|
|
45
45
|
if (!config.githubToken) {
|
|
46
46
|
throw new Error("provider 'github-copilot' is selected in agent.json but providers.github-copilot.githubToken is missing in secrets.json.");
|
|
47
47
|
}
|
|
@@ -91,10 +91,14 @@ function createGithubCopilotProviderRuntime() {
|
|
|
91
91
|
return await (0, streaming_1.streamChatCompletion)(this.client, params, request.callbacks, request.signal, request.eagerFinalAnswerStreaming);
|
|
92
92
|
}
|
|
93
93
|
catch (error) {
|
|
94
|
-
throw
|
|
94
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
95
95
|
}
|
|
96
96
|
},
|
|
97
97
|
/* v8 ignore stop */
|
|
98
|
+
/* v8 ignore next 3 -- delegation: classification logic tested via classifyGithubCopilotError @preserve */
|
|
99
|
+
classifyError(error) {
|
|
100
|
+
return classifyGithubCopilotError(error);
|
|
101
|
+
},
|
|
98
102
|
};
|
|
99
103
|
}
|
|
100
104
|
// Responses API path (GPT models via Copilot)
|
|
@@ -141,9 +145,13 @@ function createGithubCopilotProviderRuntime() {
|
|
|
141
145
|
return result;
|
|
142
146
|
}
|
|
143
147
|
catch (error) {
|
|
144
|
-
throw
|
|
148
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
145
149
|
}
|
|
146
150
|
},
|
|
147
151
|
/* v8 ignore stop */
|
|
152
|
+
/* v8 ignore next 3 -- delegation: classification logic tested via classifyGithubCopilotError @preserve */
|
|
153
|
+
classifyError(error) {
|
|
154
|
+
return classifyGithubCopilotError(error);
|
|
155
|
+
},
|
|
148
156
|
};
|
|
149
157
|
}
|
|
@@ -3,20 +3,43 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.classifyMinimaxError = classifyMinimaxError;
|
|
6
7
|
exports.createMinimaxProviderRuntime = createMinimaxProviderRuntime;
|
|
7
8
|
const openai_1 = __importDefault(require("openai"));
|
|
8
9
|
const config_1 = require("../config");
|
|
9
10
|
const runtime_1 = require("../../nerves/runtime");
|
|
11
|
+
/* v8 ignore start -- shared network error utility, tested via classification tests @preserve */
|
|
12
|
+
function isNetworkError(error) {
|
|
13
|
+
const code = error.code || "";
|
|
14
|
+
if (["ECONNRESET", "ECONNREFUSED", "ENOTFOUND", "ETIMEDOUT", "EPIPE",
|
|
15
|
+
"EAI_AGAIN", "EHOSTUNREACH", "ENETUNREACH", "ECONNABORTED"].includes(code))
|
|
16
|
+
return true;
|
|
17
|
+
const msg = error.message || "";
|
|
18
|
+
return msg.includes("fetch failed") || msg.includes("socket hang up") || msg.includes("getaddrinfo");
|
|
19
|
+
}
|
|
20
|
+
/* v8 ignore stop */
|
|
21
|
+
function classifyMinimaxError(error) {
|
|
22
|
+
const status = error.status;
|
|
23
|
+
if (status === 401 || status === 403)
|
|
24
|
+
return "auth-failure";
|
|
25
|
+
if (status === 429)
|
|
26
|
+
return "rate-limit";
|
|
27
|
+
if (status && status >= 500)
|
|
28
|
+
return "server-error";
|
|
29
|
+
if (isNetworkError(error))
|
|
30
|
+
return "network-error";
|
|
31
|
+
return "unknown";
|
|
32
|
+
}
|
|
10
33
|
const streaming_1 = require("../streaming");
|
|
11
34
|
const model_capabilities_1 = require("../model-capabilities");
|
|
12
|
-
function createMinimaxProviderRuntime() {
|
|
35
|
+
function createMinimaxProviderRuntime(config) {
|
|
13
36
|
(0, runtime_1.emitNervesEvent)({
|
|
14
37
|
component: "engine",
|
|
15
38
|
event: "engine.provider_init",
|
|
16
39
|
message: "minimax provider init",
|
|
17
40
|
meta: { provider: "minimax" },
|
|
18
41
|
});
|
|
19
|
-
const minimaxConfig = (0, config_1.getMinimaxConfig)();
|
|
42
|
+
const minimaxConfig = config ?? (0, config_1.getMinimaxConfig)();
|
|
20
43
|
if (!minimaxConfig.apiKey) {
|
|
21
44
|
throw new Error("provider 'minimax' is selected in agent.json but providers.minimax.apiKey is missing in secrets.json.");
|
|
22
45
|
}
|
|
@@ -53,5 +76,8 @@ function createMinimaxProviderRuntime() {
|
|
|
53
76
|
params.tool_choice = "required";
|
|
54
77
|
return (0, streaming_1.streamChatCompletion)(this.client, params, request.callbacks, request.signal, request.eagerFinalAnswerStreaming);
|
|
55
78
|
},
|
|
79
|
+
classifyError(error) {
|
|
80
|
+
return classifyMinimaxError(error);
|
|
81
|
+
},
|
|
56
82
|
};
|
|
57
83
|
}
|
|
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.classifyOpenAICodexError = classifyOpenAICodexError;
|
|
6
7
|
exports.createOpenAICodexProviderRuntime = createOpenAICodexProviderRuntime;
|
|
7
8
|
const openai_1 = __importDefault(require("openai"));
|
|
8
9
|
const config_1 = require("../config");
|
|
@@ -42,6 +43,33 @@ function getOpenAICodexReauthGuidance(reason) {
|
|
|
42
43
|
getOpenAICodexOAuthInstructions(),
|
|
43
44
|
].join("\n");
|
|
44
45
|
}
|
|
46
|
+
/* v8 ignore start -- shared network error utility, tested via classification tests @preserve */
|
|
47
|
+
function isNetworkError(error) {
|
|
48
|
+
const code = error.code || "";
|
|
49
|
+
if (["ECONNRESET", "ECONNREFUSED", "ENOTFOUND", "ETIMEDOUT", "EPIPE",
|
|
50
|
+
"EAI_AGAIN", "EHOSTUNREACH", "ENETUNREACH", "ECONNABORTED"].includes(code))
|
|
51
|
+
return true;
|
|
52
|
+
const msg = error.message || "";
|
|
53
|
+
return msg.includes("fetch failed") || msg.includes("socket hang up") || msg.includes("getaddrinfo");
|
|
54
|
+
}
|
|
55
|
+
/* v8 ignore stop */
|
|
56
|
+
function classifyOpenAICodexError(error) {
|
|
57
|
+
const status = error.status;
|
|
58
|
+
if (status === 401 || status === 403 || isOpenAICodexAuthFailure(error))
|
|
59
|
+
return "auth-failure";
|
|
60
|
+
if (status === 429) {
|
|
61
|
+
const lower = error.message.toLowerCase();
|
|
62
|
+
if (lower.includes("usage") || lower.includes("quota") || lower.includes("exceeded your"))
|
|
63
|
+
return "usage-limit";
|
|
64
|
+
return "rate-limit";
|
|
65
|
+
}
|
|
66
|
+
if (status && status >= 500)
|
|
67
|
+
return "server-error";
|
|
68
|
+
if (isNetworkError(error))
|
|
69
|
+
return "network-error";
|
|
70
|
+
return "unknown";
|
|
71
|
+
}
|
|
72
|
+
/* v8 ignore start -- auth detection: only called from classifyOpenAICodexError which always passes Error @preserve */
|
|
45
73
|
function isOpenAICodexAuthFailure(error) {
|
|
46
74
|
if (!(error instanceof Error))
|
|
47
75
|
return false;
|
|
@@ -51,13 +79,7 @@ function isOpenAICodexAuthFailure(error) {
|
|
|
51
79
|
const lower = error.message.toLowerCase();
|
|
52
80
|
return OPENAI_CODEX_AUTH_FAILURE_MARKERS.some((marker) => lower.includes(marker));
|
|
53
81
|
}
|
|
54
|
-
|
|
55
|
-
const base = error instanceof Error ? error.message : String(error);
|
|
56
|
-
if (isOpenAICodexAuthFailure(error)) {
|
|
57
|
-
return new Error(getOpenAICodexReauthGuidance(`OpenAI Codex authentication failed (${base}).`));
|
|
58
|
-
}
|
|
59
|
-
return error instanceof Error ? error : new Error(String(error));
|
|
60
|
-
}
|
|
82
|
+
/* v8 ignore stop */
|
|
61
83
|
function decodeJwtPayload(token) {
|
|
62
84
|
const parts = token.split(".");
|
|
63
85
|
if (parts.length < 2)
|
|
@@ -88,14 +110,14 @@ function getChatGPTAccountIdFromToken(token) {
|
|
|
88
110
|
return "";
|
|
89
111
|
return accountId.trim();
|
|
90
112
|
}
|
|
91
|
-
function createOpenAICodexProviderRuntime() {
|
|
113
|
+
function createOpenAICodexProviderRuntime(config) {
|
|
92
114
|
(0, runtime_1.emitNervesEvent)({
|
|
93
115
|
component: "engine",
|
|
94
116
|
event: "engine.provider_init",
|
|
95
117
|
message: "openai-codex provider init",
|
|
96
118
|
meta: { provider: "openai-codex" },
|
|
97
119
|
});
|
|
98
|
-
const codexConfig = (0, config_1.getOpenAICodexConfig)();
|
|
120
|
+
const codexConfig = config ?? (0, config_1.getOpenAICodexConfig)();
|
|
99
121
|
if (!(codexConfig.model && codexConfig.oauthAccessToken)) {
|
|
100
122
|
throw new Error(getOpenAICodexReauthGuidance("provider 'openai-codex' is selected in agent.json but providers.openai-codex.model/oauthAccessToken is incomplete in secrets.json."));
|
|
101
123
|
}
|
|
@@ -164,8 +186,12 @@ function createOpenAICodexProviderRuntime() {
|
|
|
164
186
|
return result;
|
|
165
187
|
}
|
|
166
188
|
catch (error) {
|
|
167
|
-
throw
|
|
189
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
168
190
|
}
|
|
169
191
|
},
|
|
192
|
+
/* v8 ignore next 3 -- delegation: classification logic tested via classifyOpenAICodexError @preserve */
|
|
193
|
+
classifyError(error) {
|
|
194
|
+
return classifyOpenAICodexError(error);
|
|
195
|
+
},
|
|
170
196
|
};
|
|
171
197
|
}
|
|
@@ -67,6 +67,7 @@ const bluebubbles_session_cleanup_1 = require("./bluebubbles-session-cleanup");
|
|
|
67
67
|
const debug_activity_1 = require("./debug-activity");
|
|
68
68
|
const trust_gate_1 = require("./trust-gate");
|
|
69
69
|
const pipeline_1 = require("./pipeline");
|
|
70
|
+
const bbFailoverStates = new Map();
|
|
70
71
|
const defaultDeps = {
|
|
71
72
|
getAgentName: identity_1.getAgentName,
|
|
72
73
|
buildSystem: prompt_1.buildSystem,
|
|
@@ -671,7 +672,20 @@ async function handleBlueBubblesNormalizedEvent(event, resolvedDeps, source) {
|
|
|
671
672
|
accumulateFriendTokens: resolvedDeps.accumulateFriendTokens,
|
|
672
673
|
signal: controller.signal,
|
|
673
674
|
runAgentOptions: { mcpManager },
|
|
675
|
+
failoverState: (() => {
|
|
676
|
+
if (!bbFailoverStates.has(event.chat.sessionKey)) {
|
|
677
|
+
bbFailoverStates.set(event.chat.sessionKey, { pending: null });
|
|
678
|
+
}
|
|
679
|
+
return bbFailoverStates.get(event.chat.sessionKey);
|
|
680
|
+
})(),
|
|
674
681
|
});
|
|
682
|
+
/* v8 ignore start -- failover display: tested via pipeline integration tests @preserve */
|
|
683
|
+
if (result.failoverMessage) {
|
|
684
|
+
await client.sendText({ chat: event.chat, text: result.failoverMessage });
|
|
685
|
+
}
|
|
686
|
+
/* v8 ignore stop */
|
|
687
|
+
// switchedProvider: no separate confirmation — the agent's context message
|
|
688
|
+
// tells it about the switch, and it acknowledges naturally in its response.
|
|
675
689
|
// ── Handle gate result ────────────────────────────────────────
|
|
676
690
|
if (!result.gateResult.allowed) {
|
|
677
691
|
// Send auto-reply via BB API if the gate provides one
|
package/dist/senses/cli.js
CHANGED
|
@@ -808,6 +808,7 @@ async function main(agentName, options) {
|
|
|
808
808
|
const currentAgentName = (0, identity_1.getAgentName)();
|
|
809
809
|
const pendingDir = (0, pending_1.getPendingDir)(currentAgentName, friendId, "cli", "session");
|
|
810
810
|
const summarize = (0, core_1.createSummarize)();
|
|
811
|
+
const cliFailoverState = { pending: null };
|
|
811
812
|
try {
|
|
812
813
|
await runCliSession({
|
|
813
814
|
agentName: currentAgentName,
|
|
@@ -819,13 +820,30 @@ async function main(agentName, options) {
|
|
|
819
820
|
runTurn: async (messages, userInput, callbacks, signal, toolContext) => {
|
|
820
821
|
// Run the full per-turn pipeline: resolve -> gate -> session -> drain -> runAgent -> postTurn -> tokens
|
|
821
822
|
// User message passed via input.messages so the pipeline can prepend pending messages to it.
|
|
823
|
+
const failoverState = cliFailoverState;
|
|
824
|
+
// Capture terminal errors instead of displaying immediately — the failover
|
|
825
|
+
// message replaces the raw error if failover triggers successfully.
|
|
826
|
+
let capturedTerminalError = null;
|
|
827
|
+
/* v8 ignore start -- failover-aware callback wrapper: tested via pipeline integration @preserve */
|
|
828
|
+
const failoverAwareCallbacks = {
|
|
829
|
+
...callbacks,
|
|
830
|
+
onError: (error, severity) => {
|
|
831
|
+
if (severity === "terminal" && failoverState) {
|
|
832
|
+
capturedTerminalError = error;
|
|
833
|
+
callbacks.onError(new Error(""), "transient");
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
callbacks.onError(error, severity);
|
|
837
|
+
},
|
|
838
|
+
};
|
|
839
|
+
/* v8 ignore stop */
|
|
822
840
|
const result = await (0, pipeline_1.handleInboundTurn)({
|
|
823
841
|
channel: "cli",
|
|
824
842
|
sessionKey: "session",
|
|
825
843
|
capabilities: cliCapabilities,
|
|
826
844
|
messages: [{ role: "user", content: userInput }],
|
|
827
845
|
continuityIngressTexts: getCliContinuityIngressTexts(userInput),
|
|
828
|
-
callbacks,
|
|
846
|
+
callbacks: failoverAwareCallbacks,
|
|
829
847
|
friendResolver: { resolve: () => Promise.resolve(resolvedContext) },
|
|
830
848
|
sessionLoader: {
|
|
831
849
|
loadOrCreate: () => Promise.resolve({
|
|
@@ -862,7 +880,18 @@ async function main(agentName, options) {
|
|
|
862
880
|
mcpManager,
|
|
863
881
|
toolContext,
|
|
864
882
|
},
|
|
883
|
+
failoverState,
|
|
865
884
|
});
|
|
885
|
+
/* v8 ignore start -- failover display: tested via pipeline integration tests @preserve */
|
|
886
|
+
if (result.failoverMessage) {
|
|
887
|
+
// Failover handled it — show the actionable message instead of the raw error
|
|
888
|
+
process.stdout.write(`\x1b[33m${result.failoverMessage}\x1b[0m\n`);
|
|
889
|
+
}
|
|
890
|
+
else if (capturedTerminalError) {
|
|
891
|
+
// Failover didn't trigger (no failoverState, or sequence failed) — show the raw error
|
|
892
|
+
process.stderr.write(`\x1b[31m${(0, format_1.formatError)(capturedTerminalError)}\x1b[0m\n`);
|
|
893
|
+
}
|
|
894
|
+
/* v8 ignore stop */
|
|
866
895
|
// Handle gate rejection: display auto-reply if present
|
|
867
896
|
if (!result.gateResult.allowed) {
|
|
868
897
|
if ("autoReply" in result.gateResult && result.gateResult.autoReply) {
|
package/dist/senses/pipeline.js
CHANGED
|
@@ -19,6 +19,9 @@ const target_resolution_1 = require("../heart/target-resolution");
|
|
|
19
19
|
const thoughts_1 = require("../heart/daemon/thoughts");
|
|
20
20
|
const pending_1 = require("../mind/pending");
|
|
21
21
|
const obligations_1 = require("../heart/obligations");
|
|
22
|
+
const provider_failover_1 = require("../heart/provider-failover");
|
|
23
|
+
const provider_ping_1 = require("../heart/provider-ping");
|
|
24
|
+
const auth_flow_1 = require("../heart/daemon/auth-flow");
|
|
22
25
|
function emptyTaskBoard() {
|
|
23
26
|
return {
|
|
24
27
|
compact: "",
|
|
@@ -102,6 +105,66 @@ function readInnerWorkState() {
|
|
|
102
105
|
}
|
|
103
106
|
// ── Pipeline ──────────────────────────────────────────────────────
|
|
104
107
|
async function handleInboundTurn(input) {
|
|
108
|
+
// Step 0: Check for pending failover reply
|
|
109
|
+
if (input.failoverState?.pending) {
|
|
110
|
+
const userText = input.messages
|
|
111
|
+
.filter((m) => m.role === "user")
|
|
112
|
+
.map((m) => typeof m.content === "string" ? m.content : /* v8 ignore next -- defensive: multipart content fallback @preserve */ "")
|
|
113
|
+
.join(" ")
|
|
114
|
+
.trim();
|
|
115
|
+
const pendingContext = input.failoverState.pending;
|
|
116
|
+
const failoverAction = (0, provider_failover_1.handleFailoverReply)(userText, pendingContext);
|
|
117
|
+
const failoverAgentName = pendingContext.agentName;
|
|
118
|
+
input.failoverState.pending = null; // always clear before acting
|
|
119
|
+
if (failoverAction.action === "switch") {
|
|
120
|
+
let switchSucceeded = false;
|
|
121
|
+
try {
|
|
122
|
+
(0, auth_flow_1.writeAgentProviderSelection)(failoverAgentName, failoverAction.provider);
|
|
123
|
+
switchSucceeded = true;
|
|
124
|
+
/* v8 ignore start -- defensive: write failure during provider switch @preserve */
|
|
125
|
+
}
|
|
126
|
+
catch (switchError) {
|
|
127
|
+
(0, runtime_1.emitNervesEvent)({
|
|
128
|
+
level: "error",
|
|
129
|
+
component: "senses",
|
|
130
|
+
event: "senses.failover_switch_error",
|
|
131
|
+
message: `failed to switch provider to ${failoverAction.provider}`,
|
|
132
|
+
meta: { agentName: failoverAgentName, provider: failoverAction.provider, error: switchError instanceof Error ? switchError.message : String(switchError) },
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
/* v8 ignore stop */
|
|
136
|
+
/* v8 ignore next -- false branch: write-failure fallthrough @preserve */
|
|
137
|
+
if (switchSucceeded) {
|
|
138
|
+
(0, runtime_1.emitNervesEvent)({
|
|
139
|
+
component: "senses",
|
|
140
|
+
event: "senses.failover_switch",
|
|
141
|
+
message: `switched provider to ${failoverAction.provider} via failover`,
|
|
142
|
+
meta: { agentName: failoverAgentName, provider: failoverAction.provider },
|
|
143
|
+
});
|
|
144
|
+
// Replace "switch to <provider>" with a context message for the agent.
|
|
145
|
+
// The session already has the user's original question from the failed turn.
|
|
146
|
+
// The agent needs to know what happened so it can respond appropriately.
|
|
147
|
+
const newProviderSecrets = (() => {
|
|
148
|
+
try {
|
|
149
|
+
const { secrets } = (0, auth_flow_1.loadAgentSecrets)(failoverAgentName);
|
|
150
|
+
const cfg = secrets.providers[failoverAction.provider];
|
|
151
|
+
return cfg?.model ?? cfg?.modelName ?? "";
|
|
152
|
+
/* v8 ignore next 2 -- defensive: secrets read failure @preserve */
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return "";
|
|
156
|
+
}
|
|
157
|
+
})();
|
|
158
|
+
const newProviderLabel = newProviderSecrets ? `${failoverAction.provider} (${newProviderSecrets})` : failoverAction.provider;
|
|
159
|
+
input.messages = [{
|
|
160
|
+
role: "user",
|
|
161
|
+
content: `[provider switch: ${pendingContext.errorSummary}. switched to ${newProviderLabel}. your conversation history is intact — respond to the user's last message.]`,
|
|
162
|
+
}];
|
|
163
|
+
input.switchedProvider = failoverAction.provider;
|
|
164
|
+
}
|
|
165
|
+
// Switch failed OR succeeded — either way, fall through to normal processing.
|
|
166
|
+
}
|
|
167
|
+
}
|
|
105
168
|
// Step 1: Resolve friend
|
|
106
169
|
const resolvedContext = await input.friendResolver.resolve();
|
|
107
170
|
(0, runtime_1.emitNervesEvent)({
|
|
@@ -299,6 +362,52 @@ async function handleInboundTurn(input) {
|
|
|
299
362
|
},
|
|
300
363
|
};
|
|
301
364
|
const result = await input.runAgent(sessionMessages, input.callbacks, input.channel, input.signal, runAgentOptions);
|
|
365
|
+
// Step 5b: Failover on terminal error
|
|
366
|
+
if (result.outcome === "errored" && input.failoverState) {
|
|
367
|
+
try {
|
|
368
|
+
const agentName = (0, identity_1.getAgentName)();
|
|
369
|
+
const agentConfig = (0, identity_1.loadAgentConfig)();
|
|
370
|
+
const currentProvider = agentConfig.provider;
|
|
371
|
+
/* v8 ignore next -- defensive: errorClassification always set when errored @preserve */
|
|
372
|
+
const classification = result.errorClassification ?? "unknown";
|
|
373
|
+
const inventory = await (0, provider_ping_1.runHealthInventory)(agentName, currentProvider);
|
|
374
|
+
const { secrets } = (0, auth_flow_1.loadAgentSecrets)(agentName);
|
|
375
|
+
const providerModels = {};
|
|
376
|
+
for (const [p, cfg] of Object.entries(secrets.providers)) {
|
|
377
|
+
const model = cfg.model ?? cfg.modelName;
|
|
378
|
+
if (typeof model === "string" && model)
|
|
379
|
+
providerModels[p] = model;
|
|
380
|
+
}
|
|
381
|
+
/* v8 ignore next -- defensive: current provider always in secrets @preserve */
|
|
382
|
+
const currentModel = providerModels[currentProvider] ?? "";
|
|
383
|
+
const failoverContext = (0, provider_failover_1.buildFailoverContext)(
|
|
384
|
+
/* v8 ignore next -- defensive: error always set when errored @preserve */
|
|
385
|
+
result.error?.message ?? "unknown error", classification, currentProvider, currentModel, agentName, inventory, providerModels);
|
|
386
|
+
input.failoverState.pending = failoverContext;
|
|
387
|
+
input.postTurn(sessionMessages, session.sessionPath, result.usage);
|
|
388
|
+
return {
|
|
389
|
+
resolvedContext,
|
|
390
|
+
gateResult,
|
|
391
|
+
usage: result.usage,
|
|
392
|
+
turnOutcome: result.outcome,
|
|
393
|
+
sessionPath: session.sessionPath,
|
|
394
|
+
messages: sessionMessages,
|
|
395
|
+
drainedPending: pending,
|
|
396
|
+
failoverMessage: failoverContext.userMessage,
|
|
397
|
+
};
|
|
398
|
+
/* v8 ignore start -- failover catch: tested via pipeline failover sequence throws test but v8 under-reports catch coverage @preserve */
|
|
399
|
+
}
|
|
400
|
+
catch (failoverError) {
|
|
401
|
+
(0, runtime_1.emitNervesEvent)({
|
|
402
|
+
level: "warn",
|
|
403
|
+
component: "senses",
|
|
404
|
+
event: "senses.failover_error",
|
|
405
|
+
message: "failover sequence failed, falling through",
|
|
406
|
+
meta: { error: failoverError instanceof Error ? failoverError.message : String(failoverError) },
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
/* v8 ignore stop */
|
|
410
|
+
}
|
|
302
411
|
// Step 6: postTurn
|
|
303
412
|
const continuingState = {
|
|
304
413
|
...(mustResolveBeforeHandoff ? { mustResolveBeforeHandoff: true } : {}),
|
|
@@ -330,5 +439,6 @@ async function handleInboundTurn(input) {
|
|
|
330
439
|
sessionPath: session.sessionPath,
|
|
331
440
|
messages: sessionMessages,
|
|
332
441
|
drainedPending: pending,
|
|
442
|
+
...(input.switchedProvider ? { switchedProvider: input.switchedProvider } : {}),
|
|
333
443
|
};
|
|
334
444
|
}
|
package/dist/senses/teams.js
CHANGED
|
@@ -70,6 +70,7 @@ const http = __importStar(require("http"));
|
|
|
70
70
|
const path = __importStar(require("path"));
|
|
71
71
|
const trust_gate_1 = require("./trust-gate");
|
|
72
72
|
const pipeline_1 = require("./pipeline");
|
|
73
|
+
const teamsFailoverStates = new Map();
|
|
73
74
|
const pending_1 = require("../mind/pending");
|
|
74
75
|
const continuity_1 = require("./continuity");
|
|
75
76
|
// Strip @mention markup from incoming messages.
|
|
@@ -549,13 +550,33 @@ async function handleTeamsMessage(text, stream, conversationId, teamsContext, se
|
|
|
549
550
|
if (channelConfig.skipConfirmation)
|
|
550
551
|
agentOptions.skipConfirmation = true;
|
|
551
552
|
// ── Call shared pipeline ──────────────────────────────────────────
|
|
553
|
+
// Capture terminal errors — failover message replaces the error card if it triggers
|
|
554
|
+
let capturedTerminalError = null;
|
|
555
|
+
const teamsFailoverState = (() => {
|
|
556
|
+
if (!teamsFailoverStates.has(conversationId)) {
|
|
557
|
+
teamsFailoverStates.set(conversationId, { pending: null });
|
|
558
|
+
}
|
|
559
|
+
return teamsFailoverStates.get(conversationId);
|
|
560
|
+
})();
|
|
561
|
+
/* v8 ignore start -- failover-aware callback wrapper: tested via pipeline integration @preserve */
|
|
562
|
+
const failoverAwareCallbacks = {
|
|
563
|
+
...callbacks,
|
|
564
|
+
onError: (error, severity) => {
|
|
565
|
+
if (severity === "terminal" && teamsFailoverState) {
|
|
566
|
+
capturedTerminalError = error;
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
callbacks.onError(error, severity);
|
|
570
|
+
},
|
|
571
|
+
};
|
|
572
|
+
/* v8 ignore stop */
|
|
552
573
|
const result = await (0, pipeline_1.handleInboundTurn)({
|
|
553
574
|
channel: "teams",
|
|
554
575
|
sessionKey: conversationId,
|
|
555
576
|
capabilities: teamsCapabilities,
|
|
556
577
|
messages: [{ role: "user", content: currentText }],
|
|
557
578
|
continuityIngressTexts: [currentText],
|
|
558
|
-
callbacks,
|
|
579
|
+
callbacks: failoverAwareCallbacks,
|
|
559
580
|
friendResolver: { resolve: () => Promise.resolve(resolvedContext) },
|
|
560
581
|
sessionLoader: {
|
|
561
582
|
loadOrCreate: async () => {
|
|
@@ -595,7 +616,16 @@ async function handleTeamsMessage(text, stream, conversationId, teamsContext, se
|
|
|
595
616
|
accumulateFriendTokens: tokens_1.accumulateFriendTokens,
|
|
596
617
|
signal: controller.signal,
|
|
597
618
|
runAgentOptions: agentOptions,
|
|
619
|
+
failoverState: teamsFailoverState,
|
|
598
620
|
});
|
|
621
|
+
/* v8 ignore start -- failover display: tested via pipeline integration tests @preserve */
|
|
622
|
+
if (result.failoverMessage) {
|
|
623
|
+
stream.emit(result.failoverMessage);
|
|
624
|
+
}
|
|
625
|
+
else if (capturedTerminalError) {
|
|
626
|
+
callbacks.onError(capturedTerminalError, "terminal");
|
|
627
|
+
}
|
|
628
|
+
/* v8 ignore stop */
|
|
599
629
|
// ── Handle gate result ────────────────────────────────────────
|
|
600
630
|
if (!result.gateResult.allowed) {
|
|
601
631
|
if ("autoReply" in result.gateResult && result.gateResult.autoReply) {
|