@ouro.bot/cli 0.1.0-alpha.483 → 0.1.0-alpha.484

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 CHANGED
@@ -1,6 +1,12 @@
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.484",
6
+ "changes": [
7
+ "Agent-driven failover (the `switch to <provider>` reply path) now re-pings the candidate provider before mutating provider state. If credentials are missing or the ping fails, the active lane is left untouched, a `senses.failover_switch_refused` event is emitted, and the agent receives an operational refusal context message naming the lane it is still standing on plus the verified alternatives that remain so the next turn does not re-enter discovery mode."
8
+ ]
9
+ },
4
10
  {
5
11
  "version": "0.1.0-alpha.483",
6
12
  "changes": [
@@ -5,6 +5,7 @@ exports.formatReadyProviderLabel = formatReadyProviderLabel;
5
5
  exports.buildFailoverContext = buildFailoverContext;
6
6
  exports.handleFailoverReply = handleFailoverReply;
7
7
  exports.runMachineProviderFailoverInventory = runMachineProviderFailoverInventory;
8
+ exports.validateFailoverSwitchCandidate = validateFailoverSwitchCandidate;
8
9
  const identity_1 = require("./identity");
9
10
  const provider_ping_1 = require("./provider-ping");
10
11
  const provider_models_1 = require("./provider-models");
@@ -170,6 +171,7 @@ function buildFailoverContext(errorMessage, classification, currentProvider, cur
170
171
  errorSummary,
171
172
  classification,
172
173
  currentProvider,
174
+ currentModel,
173
175
  currentLane,
174
176
  agentName,
175
177
  workingProviders,
@@ -264,3 +266,36 @@ async function runMachineProviderFailoverInventory(agentName, currentProvider, o
264
266
  });
265
267
  return inventory;
266
268
  }
269
+ /**
270
+ * Re-verify a failover candidate is actually reachable right before we mutate
271
+ * provider state. The inventory ping that produced the candidate may be stale
272
+ * (creds revoked between inventory and reply); without this preflight, an
273
+ * agent-driven "switch to <provider>" can move the lane onto an unreachable
274
+ * provider and brick the next turn.
275
+ */
276
+ async function validateFailoverSwitchCandidate(agentName, candidate, options = {}) {
277
+ const ping = options.ping ?? provider_ping_1.pingProvider;
278
+ const refreshPool = options.refreshPool ?? provider_credentials_1.refreshProviderCredentialPool;
279
+ const poolResult = await refreshPool(agentName);
280
+ if (!poolResult.ok) {
281
+ return {
282
+ ok: false,
283
+ classification: "auth-failure",
284
+ message: `provider credential pool unavailable (${poolResult.reason}): ${poolResult.error}`,
285
+ };
286
+ }
287
+ const record = poolResult.pool.providers[candidate.provider];
288
+ if (!record) {
289
+ return {
290
+ ok: false,
291
+ classification: "auth-failure",
292
+ message: `no credentials configured for ${candidate.provider}`,
293
+ };
294
+ }
295
+ const config = { ...record.credentials, ...record.config };
296
+ const result = await ping(candidate.provider, config, { model: candidate.model });
297
+ if (!result.ok) {
298
+ return { ok: false, classification: result.classification, message: result.message };
299
+ }
300
+ return { ok: true };
301
+ }
@@ -97,7 +97,23 @@ function resolveCurrentFailoverBinding(agentName, lane) {
97
97
  const fallback = lane === "inner" ? agentConfig.agentFacing : agentConfig.humanFacing;
98
98
  return { provider: fallback.provider, model: fallback.model };
99
99
  }
100
- function writeFailoverProviderStateSwitch(agentName, action) {
100
+ /**
101
+ * Apply an agent-driven failover switch to provider state, but only after
102
+ * re-pinging the candidate. The inventory ping that produced the candidate
103
+ * may be stale by the time the agent replies — without this preflight, a
104
+ * "switch to <provider>" reply can move the lane onto an unreachable provider.
105
+ *
106
+ * Returns:
107
+ * { ok: true } — preflight passed, state mutated
108
+ * { ok: false, refused } — preflight failed, state untouched, caller should
109
+ * surface the refusal to the agent
110
+ * Throws on disk errors only (caught by caller as before).
111
+ */
112
+ async function writeFailoverProviderStateSwitch(agentName, action) {
113
+ const validation = await (0, provider_failover_1.validateFailoverSwitchCandidate)(agentName, { provider: action.provider, model: action.model });
114
+ if (!validation.ok) {
115
+ return { ok: false, refused: true, classification: validation.classification, message: validation.message };
116
+ }
101
117
  const agentRoot = (0, identity_1.getAgentRoot)(agentName);
102
118
  const stateResult = (0, provider_state_1.readProviderState)(agentRoot);
103
119
  if (!stateResult.ok) {
@@ -125,11 +141,36 @@ function writeFailoverProviderStateSwitch(agentName, action) {
125
141
  lanes,
126
142
  readiness,
127
143
  });
144
+ return { ok: true };
128
145
  }
129
146
  function formatFailoverSwitchLabel(action) {
130
147
  const provenance = (0, provider_failover_1.formatCredentialProvenanceLabel)(action);
131
148
  return `${action.provider} (${action.model}${provenance ? `; ${provenance}` : ""})`;
132
149
  }
150
+ /**
151
+ * Build the operational refusal context message handed back to the agent when
152
+ * a failover switch is rejected by the preflight ping. Slugger-tested format:
153
+ * lead with the refusal + reason, restate the lane that's still standing, then
154
+ * list remaining ready alternatives so the next turn doesn't have to re-enter
155
+ * discovery mode.
156
+ */
157
+ function buildFailoverSwitchRefusedMessage(pendingContext, refusedAction, refusal) {
158
+ const refusedLabel = formatFailoverSwitchLabel(refusedAction);
159
+ const remaining = pendingContext.readyProviders.filter((candidate) => candidate.provider !== refusedAction.provider);
160
+ const alternativesLine = remaining.length > 0
161
+ ? `available verified alternatives right now: ${remaining.map((c) => `${c.provider} (${c.model})`).join(", ")}.`
162
+ : "no other verified alternatives are ready right now.";
163
+ const nextMove = remaining.length > 0
164
+ ? `next move: reply "switch to <provider>" picking one of the alternatives above, or tell the user you cannot continue and why.`
165
+ : `next move: ask the operator to repair credentials for ${refusedAction.provider} (or another provider), or tell the user you cannot continue and why.`;
166
+ return [
167
+ `[provider switch refused: tried to switch ${refusedAction.lane} lane to ${refusedLabel}.`,
168
+ `reason: preflight ping failed (${refusal.classification}: ${refusal.message}).`,
169
+ `current lane unchanged: ${pendingContext.currentProvider} / ${pendingContext.currentModel} on the ${pendingContext.currentLane} lane.`,
170
+ alternativesLine,
171
+ nextMove + "]",
172
+ ].join(" ");
173
+ }
133
174
  function prependTurnSections(message, sections) {
134
175
  /* v8 ignore next -- defensive: only user messages with non-empty sections reach here @preserve */
135
176
  if (message.role !== "user" || sections.length === 0)
@@ -176,10 +217,9 @@ async function handleInboundTurn(input) {
176
217
  const failoverAgentName = pendingContext.agentName;
177
218
  input.failoverState.pending = null; // always clear before acting
178
219
  if (failoverAction.action === "switch") {
179
- let switchSucceeded = false;
220
+ let switchOutcome = null;
180
221
  try {
181
- writeFailoverProviderStateSwitch(failoverAgentName, failoverAction);
182
- switchSucceeded = true;
222
+ switchOutcome = await writeFailoverProviderStateSwitch(failoverAgentName, failoverAction);
183
223
  /* v8 ignore start -- defensive: write failure during provider switch @preserve */
184
224
  }
185
225
  catch (switchError) {
@@ -192,8 +232,7 @@ async function handleInboundTurn(input) {
192
232
  });
193
233
  }
194
234
  /* v8 ignore stop */
195
- /* v8 ignore next -- false branch: write-failure fallthrough @preserve */
196
- if (switchSucceeded) {
235
+ if (switchOutcome?.ok) {
197
236
  (0, runtime_1.emitNervesEvent)({
198
237
  component: "senses",
199
238
  event: "senses.failover_switch",
@@ -217,6 +256,29 @@ async function handleInboundTurn(input) {
217
256
  }];
218
257
  input.switchedProvider = failoverAction.provider;
219
258
  }
259
+ else if (switchOutcome && !switchOutcome.ok) {
260
+ // Preflight refused the switch — the candidate provider is not actually
261
+ // reachable right now. Keep the existing lane intact and tell the agent
262
+ // what happened so it can pick something else next turn.
263
+ (0, runtime_1.emitNervesEvent)({
264
+ level: "warn",
265
+ component: "senses",
266
+ event: "senses.failover_switch_refused",
267
+ message: `refused failover switch of ${failoverAction.lane} lane to ${failoverAction.provider}: ${switchOutcome.message}`,
268
+ meta: {
269
+ agentName: failoverAgentName,
270
+ lane: failoverAction.lane,
271
+ provider: failoverAction.provider,
272
+ model: failoverAction.model,
273
+ classification: switchOutcome.classification,
274
+ error: switchOutcome.message,
275
+ },
276
+ });
277
+ input.messages = [{
278
+ role: "user",
279
+ content: buildFailoverSwitchRefusedMessage(pendingContext, failoverAction, switchOutcome),
280
+ }];
281
+ }
220
282
  // Switch failed OR succeeded — either way, fall through to normal processing.
221
283
  }
222
284
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.483",
3
+ "version": "0.1.0-alpha.484",
4
4
  "main": "dist/heart/daemon/ouro-entry.js",
5
5
  "bin": {
6
6
  "cli": "dist/heart/daemon/ouro-bot-entry.js",