@ouro.bot/cli 0.1.0-alpha.483 → 0.1.0-alpha.485
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 +14 -0
- package/dist/heart/provider-failover.js +35 -0
- package/dist/heart/session-events.js +40 -3
- package/dist/mind/context.js +36 -4
- package/dist/mind/friends/resolver.js +16 -1
- package/dist/senses/inner-dialog-worker.js +38 -2
- package/dist/senses/pipeline.js +68 -6
- package/dist/senses/trust-gate.js +96 -1
- package/package.json +1 -1
package/changelog.json
CHANGED
|
@@ -1,6 +1,20 @@
|
|
|
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.485",
|
|
6
|
+
"changes": [
|
|
7
|
+
"Session JSON storage no longer accumulates duplicate event ids when two writers race for the same session — `parseSessionEnvelope` now dedupes on read (last-occurrence-wins) so existing corrupted sessions self-heal on the next save, `buildCanonicalSessionEnvelope` assigns the next sequence as `max(existing) + 1` instead of `events.length + 1` so pruning gaps cannot collide, and `deferPostTurnPersist` serializes per-`sessPath` through an in-process queue so concurrent BlueBubbles webhooks for the same chat (or CLI postTurn racing the inner-dialog turn for the same MCP session) cannot interleave their writes.",
|
|
8
|
+
"Auto-created BlueBubbles group friends are now marked with a `notes.autoCreatedGroup` flag at resolver time, and the trust gate's family-member bypass surfaces a one-time inner-pending notice the first time messages route through an unacknowledged stranger-trust group so the agent can label, rename, or dismiss the relationship before activity accumulates invisibly.",
|
|
9
|
+
"Inner-dialog worker now caps consecutive `instinct` follow-on turns at `MAX_CONSECUTIVE_INSTINCT_TURNS = 3` to break self-sustaining loops where a tool that writes to the inner-dialog pending dir during a turn would otherwise re-fire the worker indefinitely; externally-queued messages reset the counter so legitimate cascading follow-ups still run, and a new `senses.inner_dialog_worker_instinct_loop_capped` event surfaces when the cap fires."
|
|
10
|
+
]
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"version": "0.1.0-alpha.484",
|
|
14
|
+
"changes": [
|
|
15
|
+
"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."
|
|
16
|
+
]
|
|
17
|
+
},
|
|
4
18
|
{
|
|
5
19
|
"version": "0.1.0-alpha.483",
|
|
6
20
|
"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
|
+
}
|
|
@@ -254,6 +254,35 @@ function messageFingerprint(message) {
|
|
|
254
254
|
function makeEventId(sequence) {
|
|
255
255
|
return `evt-${String(sequence).padStart(6, "0")}`;
|
|
256
256
|
}
|
|
257
|
+
/**
|
|
258
|
+
* Collapse duplicate event ids to a single entry, last-occurrence-wins.
|
|
259
|
+
*
|
|
260
|
+
* Concurrent writers in older versions of postTurnPersist could each load the
|
|
261
|
+
* envelope, compute `events.length + 1` for the next sequence, and both write
|
|
262
|
+
* an event with the same id. The duplicates would persist in the saved JSON
|
|
263
|
+
* and confuse downstream replay (the same outbound message could appear to
|
|
264
|
+
* have been sent twice from the agent's perspective without the agent knowing
|
|
265
|
+
* it sent it). We dedupe defensively on every load so corrupted sessions
|
|
266
|
+
* self-heal on the next save and so any future race produces a consistent
|
|
267
|
+
* view.
|
|
268
|
+
*/
|
|
269
|
+
function dedupeEventsByIdLastWins(events) {
|
|
270
|
+
// Index id → last position so we can preserve original order while
|
|
271
|
+
// collapsing duplicates to their final occurrence.
|
|
272
|
+
const lastIndexById = new Map();
|
|
273
|
+
for (let i = 0; i < events.length; i++) {
|
|
274
|
+
lastIndexById.set(events[i].id, i);
|
|
275
|
+
}
|
|
276
|
+
return events.filter((event, index) => lastIndexById.get(event.id) === index);
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* The next sequence to assign for a freshly-built event. Uses max(existing
|
|
280
|
+
* sequences) + 1 rather than `events.length + 1` so that gaps from earlier
|
|
281
|
+
* pruning, archive replay, or self-heal dedup never produce a colliding id.
|
|
282
|
+
*/
|
|
283
|
+
function nextEventSequence(existing) {
|
|
284
|
+
return existing.reduce((max, event) => Math.max(max, event.sequence), 0) + 1;
|
|
285
|
+
}
|
|
257
286
|
function validateSessionMessages(messages) {
|
|
258
287
|
const violations = [];
|
|
259
288
|
let prevNonToolRole = null;
|
|
@@ -665,7 +694,7 @@ function parseSessionEnvelope(raw, options = {}) {
|
|
|
665
694
|
if (record.version !== 2 || !Array.isArray(record.events) || !record.projection || typeof record.projection !== "object") {
|
|
666
695
|
return null;
|
|
667
696
|
}
|
|
668
|
-
const
|
|
697
|
+
const rawEvents = record.events
|
|
669
698
|
.filter((event) => event != null && typeof event === "object")
|
|
670
699
|
.map((event, index) => {
|
|
671
700
|
const role = normalizeRole(event.role);
|
|
@@ -705,6 +734,12 @@ function parseSessionEnvelope(raw, options = {}) {
|
|
|
705
734
|
},
|
|
706
735
|
};
|
|
707
736
|
});
|
|
737
|
+
// Self-heal duplicate event ids that may have been written by concurrent
|
|
738
|
+
// writers in older harness versions. Last-occurrence-wins by id (later
|
|
739
|
+
// entries in the persisted file are the more recent state for that id).
|
|
740
|
+
// We preserve the original document order otherwise, so projection.eventIds
|
|
741
|
+
// still resolves predictably.
|
|
742
|
+
const events = dedupeEventsByIdLastWins(rawEvents);
|
|
708
743
|
const projection = record.projection;
|
|
709
744
|
return {
|
|
710
745
|
version: 2,
|
|
@@ -821,8 +856,10 @@ function buildCanonicalSessionEnvelope(options) {
|
|
|
821
856
|
else {
|
|
822
857
|
if (!isSystem)
|
|
823
858
|
nonSystemSeen++;
|
|
824
|
-
// Create a new event
|
|
825
|
-
|
|
859
|
+
// Create a new event. Use nextEventSequence(events) instead of
|
|
860
|
+
// `events.length + 1` so that any gap (from pruning, archive replay,
|
|
861
|
+
// or self-heal dedup) cannot collide with an existing id.
|
|
862
|
+
const event = buildEventFromMessage(currentMessages[i], nextEventSequence(events), options.recordedAt, "live", null, null, currentIngressTimes[i]);
|
|
826
863
|
events.push(event);
|
|
827
864
|
currentEventIds.push(event.id);
|
|
828
865
|
}
|
package/dist/mind/context.js
CHANGED
|
@@ -315,11 +315,43 @@ function postTurnPersist(sessPath, prepared, usage, state) {
|
|
|
315
315
|
return envelope.events;
|
|
316
316
|
}
|
|
317
317
|
/**
|
|
318
|
-
*
|
|
319
|
-
*
|
|
318
|
+
* Per-sessPath serialization queue. Without this, two concurrent
|
|
319
|
+
* `deferPostTurnPersist` calls (e.g. two BlueBubbles webhooks for the same
|
|
320
|
+
* chat firing back-to-back, or a CLI postTurn racing the inner-dialog turn
|
|
321
|
+
* for the same MCP session) would each load the envelope, both compute the
|
|
322
|
+
* same "next sequence", and write events with colliding ids. The session
|
|
323
|
+
* file would silently accumulate duplicates and replay would diverge from
|
|
324
|
+
* what was actually sent on the wire.
|
|
325
|
+
*
|
|
326
|
+
* The queue is in-process only — it does not protect against multiple Node
|
|
327
|
+
* processes writing to the same file. The dedup-on-load behaviour in
|
|
328
|
+
* parseSessionEnvelope keeps cross-process races from leaving permanent
|
|
329
|
+
* corruption; this serializer just keeps the common (single-process) case
|
|
330
|
+
* race-free in the first place.
|
|
331
|
+
*/
|
|
332
|
+
const sessionPersistQueues = new Map();
|
|
333
|
+
function enqueueSessionPersist(sessPath, fn) {
|
|
334
|
+
const previous = sessionPersistQueues.get(sessPath) ?? Promise.resolve();
|
|
335
|
+
// Chain on the previous tail. fn runs whether previous resolved or rejected,
|
|
336
|
+
// so one failed turn cannot block subsequent turns on the same session.
|
|
337
|
+
const next = previous.then(fn, fn);
|
|
338
|
+
// Save a swallowed-rejection sentinel as the new tail so the next caller's
|
|
339
|
+
// `previous.then(fn, fn)` sees a clean resolution; the original `next` still
|
|
340
|
+
// propagates rejection to its own caller as expected.
|
|
341
|
+
/* v8 ignore start -- the swallow only matters when fn rejects, which is the failure path covered separately */
|
|
342
|
+
const sentinel = next.then(() => undefined, () => undefined);
|
|
343
|
+
/* v8 ignore stop */
|
|
344
|
+
sessionPersistQueues.set(sessPath, sentinel);
|
|
345
|
+
return next;
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Deferred persist: same as postTurnPersist but runs on the next event loop
|
|
349
|
+
* tick AND serializes against any other deferred persist for the same
|
|
350
|
+
* sessPath, so concurrent turns cannot race and produce duplicate event ids
|
|
351
|
+
* in the saved session.
|
|
320
352
|
*/
|
|
321
353
|
function deferPostTurnPersist(sessPath, prepared, usage, state) {
|
|
322
|
-
return new Promise((resolve) => {
|
|
354
|
+
return enqueueSessionPersist(sessPath, () => new Promise((resolve) => {
|
|
323
355
|
setImmediate(() => {
|
|
324
356
|
try {
|
|
325
357
|
const events = postTurnPersist(sessPath, prepared, usage, state);
|
|
@@ -336,7 +368,7 @@ function deferPostTurnPersist(sessPath, prepared, usage, state) {
|
|
|
336
368
|
resolve([]);
|
|
337
369
|
}
|
|
338
370
|
});
|
|
339
|
-
});
|
|
371
|
+
}));
|
|
340
372
|
}
|
|
341
373
|
function deleteSession(filePath) {
|
|
342
374
|
try {
|
|
@@ -87,6 +87,21 @@ class FriendResolver {
|
|
|
87
87
|
hasAnyFriends = false;
|
|
88
88
|
}
|
|
89
89
|
const isFirstImprint = !hasAnyFriends;
|
|
90
|
+
// BlueBubbles group chats route through here as `imessage-handle` with an
|
|
91
|
+
// externalId of the form `group:any;+;<chatHash>`. When the harness auto-
|
|
92
|
+
// creates the group friend at stranger trust, we mark the record so that
|
|
93
|
+
// the trust gate can surface the relationship for explicit acknowledgment
|
|
94
|
+
// later instead of letting messages accumulate silently.
|
|
95
|
+
const isImessageGroup = this.params.provider === "imessage-handle" &&
|
|
96
|
+
typeof this.params.externalId === "string" &&
|
|
97
|
+
this.params.externalId.startsWith("group:");
|
|
98
|
+
const notes = {};
|
|
99
|
+
if (this.params.displayName !== "Unknown") {
|
|
100
|
+
notes.name = { value: this.params.displayName, savedAt: now };
|
|
101
|
+
}
|
|
102
|
+
if (isImessageGroup && !isFirstImprint) {
|
|
103
|
+
notes.autoCreatedGroup = { value: "true", savedAt: now };
|
|
104
|
+
}
|
|
90
105
|
const friend = {
|
|
91
106
|
id: (0, crypto_1.randomUUID)(),
|
|
92
107
|
name: this.params.displayName,
|
|
@@ -96,7 +111,7 @@ class FriendResolver {
|
|
|
96
111
|
externalIds: [externalId],
|
|
97
112
|
tenantMemberships,
|
|
98
113
|
toolPreferences: {},
|
|
99
|
-
notes
|
|
114
|
+
notes,
|
|
100
115
|
totalTokens: 0,
|
|
101
116
|
createdAt: now,
|
|
102
117
|
updatedAt: now,
|
|
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.MAX_CONSECUTIVE_INSTINCT_TURNS = void 0;
|
|
36
37
|
exports.createInnerDialogWorker = createInnerDialogWorker;
|
|
37
38
|
exports.startInnerDialogWorker = startInnerDialogWorker;
|
|
38
39
|
const path = __importStar(require("path"));
|
|
@@ -41,6 +42,20 @@ const runtime_1 = require("../nerves/runtime");
|
|
|
41
42
|
const identity_1 = require("../heart/identity");
|
|
42
43
|
const pending_1 = require("../mind/pending");
|
|
43
44
|
const habit_runtime_state_1 = require("../heart/habits/habit-runtime-state");
|
|
45
|
+
/**
|
|
46
|
+
* Cap on consecutive `instinct` follow-on turns triggered by `hasPendingWork()`
|
|
47
|
+
* with no externally-queued work in between. Without this cap, a turn that
|
|
48
|
+
* writes anything back into the inner-dialog pending dir as a side effect of
|
|
49
|
+
* processing (e.g. a surface tool routing a response) puts the worker into
|
|
50
|
+
* a self-sustaining loop where the next turn's drain produces another write,
|
|
51
|
+
* and so on. Real workflows rarely chain more than 2–3 instinct turns; an
|
|
52
|
+
* external trigger (habit, poke, chat) resets the counter so legitimate
|
|
53
|
+
* follow-on work is unaffected.
|
|
54
|
+
*
|
|
55
|
+
* Three feels right: legitimate cascading follow-ups (e.g. processing a
|
|
56
|
+
* batch of delegated returns) get through; a true self-loop caps fast.
|
|
57
|
+
*/
|
|
58
|
+
exports.MAX_CONSECUTIVE_INSTINCT_TURNS = 3;
|
|
44
59
|
function createInnerDialogWorker(runTurn = (options) => (0, inner_dialog_1.runInnerDialogTurn)(options), hasPendingWork = () => (0, pending_1.hasPendingMessages)((0, pending_1.getInnerDialogPendingDir)((0, identity_1.getAgentName)()))) {
|
|
45
60
|
let running = false;
|
|
46
61
|
const queue = [];
|
|
@@ -54,6 +69,7 @@ function createInnerDialogWorker(runTurn = (options) => (0, inner_dialog_1.runIn
|
|
|
54
69
|
let nextReason = reason;
|
|
55
70
|
let nextTaskId = taskId;
|
|
56
71
|
let nextHabitName = habitName;
|
|
72
|
+
let consecutiveInstinctTurns = reason === "instinct" ? 1 : 0;
|
|
57
73
|
do {
|
|
58
74
|
try {
|
|
59
75
|
await runTurn({ reason: nextReason, taskId: nextTaskId, habitName: nextHabitName });
|
|
@@ -82,16 +98,36 @@ function createInnerDialogWorker(runTurn = (options) => (0, inner_dialog_1.runIn
|
|
|
82
98
|
// Habit file/state may be unavailable during the turn — skip gracefully
|
|
83
99
|
}
|
|
84
100
|
}
|
|
85
|
-
// Drain queue first
|
|
101
|
+
// Drain queue first. Externally-queued work resets the instinct cap
|
|
102
|
+
// because a real outside trigger arrived between turns.
|
|
86
103
|
if (queue.length > 0) {
|
|
87
104
|
const next = queue.shift();
|
|
88
105
|
nextReason = next.reason;
|
|
89
106
|
nextTaskId = next.taskId;
|
|
90
107
|
nextHabitName = next.habitName;
|
|
108
|
+
consecutiveInstinctTurns = nextReason === "instinct" ? consecutiveInstinctTurns + 1 : 0;
|
|
91
109
|
continue;
|
|
92
110
|
}
|
|
93
|
-
// Then check hasPendingWork fallback
|
|
111
|
+
// Then check hasPendingWork fallback. This is the loop site: any
|
|
112
|
+
// tool that writes to the inner-dialog pending dir during a turn
|
|
113
|
+
// would cause hasPendingWork() to be true here, producing a
|
|
114
|
+
// self-sustaining "instinct" loop with no external input. Cap it.
|
|
94
115
|
if (hasPendingWork()) {
|
|
116
|
+
if (consecutiveInstinctTurns >= exports.MAX_CONSECUTIVE_INSTINCT_TURNS) {
|
|
117
|
+
(0, runtime_1.emitNervesEvent)({
|
|
118
|
+
level: "warn",
|
|
119
|
+
component: "senses",
|
|
120
|
+
event: "senses.inner_dialog_worker_instinct_loop_capped",
|
|
121
|
+
message: "inner dialog worker stopped chaining instinct turns; pending work remains for next external trigger",
|
|
122
|
+
meta: {
|
|
123
|
+
consecutiveInstinctTurns,
|
|
124
|
+
cap: exports.MAX_CONSECUTIVE_INSTINCT_TURNS,
|
|
125
|
+
lastReason: nextReason,
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
consecutiveInstinctTurns += 1;
|
|
95
131
|
nextReason = "instinct";
|
|
96
132
|
nextTaskId = undefined;
|
|
97
133
|
nextHabitName = undefined;
|
package/dist/senses/pipeline.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|
|
@@ -93,6 +93,91 @@ function writeInnerPendingNotice(bundleRoot, noticeContent, nowIso) {
|
|
|
93
93
|
fs.mkdirSync(innerPendingDir, { recursive: true });
|
|
94
94
|
fs.writeFileSync(filePath, JSON.stringify(payload), "utf-8");
|
|
95
95
|
}
|
|
96
|
+
const ACKNOWLEDGED_GROUPS_FILENAME = "acknowledged-auto-groups.json";
|
|
97
|
+
function acknowledgedGroupsPath(bundleRoot) {
|
|
98
|
+
return path.join(bundleRoot, "state", ACKNOWLEDGED_GROUPS_FILENAME);
|
|
99
|
+
}
|
|
100
|
+
function loadAcknowledgedGroupsState(bundleRoot) {
|
|
101
|
+
try {
|
|
102
|
+
const raw = fs.readFileSync(acknowledgedGroupsPath(bundleRoot), "utf-8");
|
|
103
|
+
if (!raw.trim())
|
|
104
|
+
return {};
|
|
105
|
+
const parsed = JSON.parse(raw);
|
|
106
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
107
|
+
return {};
|
|
108
|
+
return parsed;
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return {};
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function persistAcknowledgedGroupsState(bundleRoot, state) {
|
|
115
|
+
const target = acknowledgedGroupsPath(bundleRoot);
|
|
116
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
117
|
+
fs.writeFileSync(target, `${JSON.stringify(state, null, 2)}\n`, "utf-8");
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* For BlueBubbles group chats that were auto-created at stranger trust (no
|
|
121
|
+
* explicit operator/agent action ever bound the harness to this group), the
|
|
122
|
+
* gate's family-member bypass would otherwise let messages flow through
|
|
123
|
+
* silently and the agent would accumulate a session it has no mental model
|
|
124
|
+
* for. Surface the relationship as an inner-pending notice exactly once so
|
|
125
|
+
* the agent can categorize / rename / dismiss the group on its next turn.
|
|
126
|
+
*
|
|
127
|
+
* Returns true if a notice was written so callers can emit a telemetry event.
|
|
128
|
+
*/
|
|
129
|
+
function maybeSurfaceAutoCreatedGroup(input, bundleRoot, nowIso) {
|
|
130
|
+
// Caller guarantees isGroupChat = true (only invoked from the family-member
|
|
131
|
+
// bypass branch); skip a redundant guard here.
|
|
132
|
+
if (input.friend.trustLevel !== "stranger")
|
|
133
|
+
return false;
|
|
134
|
+
if (!input.friend.notes?.["autoCreatedGroup"])
|
|
135
|
+
return false;
|
|
136
|
+
// loadAcknowledgedGroupsState is defensive (its own try/catch returns {})
|
|
137
|
+
// so we don't wrap it in another try here.
|
|
138
|
+
const state = loadAcknowledgedGroupsState(bundleRoot);
|
|
139
|
+
if (state[input.friend.id])
|
|
140
|
+
return false;
|
|
141
|
+
const noticeContent = `New BlueBubbles group "${input.friend.name}" became active without explicit acknowledgment. ` +
|
|
142
|
+
`It was auto-created at stranger trust the first time a message routed through it. ` +
|
|
143
|
+
`If you recognize the group, label or rename it (and consider promoting trust); if not, you can leave it as a stranger group or rename it for clarity. ` +
|
|
144
|
+
`external id: ${input.externalId}; friend id: ${input.friend.id}.`;
|
|
145
|
+
try {
|
|
146
|
+
writeInnerPendingNotice(bundleRoot, noticeContent, nowIso);
|
|
147
|
+
persistAcknowledgedGroupsState(bundleRoot, {
|
|
148
|
+
...state,
|
|
149
|
+
[input.friend.id]: { surfacedAt: nowIso },
|
|
150
|
+
});
|
|
151
|
+
(0, runtime_1.emitNervesEvent)({
|
|
152
|
+
level: "info",
|
|
153
|
+
component: "senses",
|
|
154
|
+
event: "senses.trust_gate_group_acknowledgment_surfaced",
|
|
155
|
+
message: "auto-created group surfaced for agent acknowledgment",
|
|
156
|
+
meta: {
|
|
157
|
+
friendId: input.friend.id,
|
|
158
|
+
friendName: input.friend.name,
|
|
159
|
+
externalId: input.externalId,
|
|
160
|
+
provider: input.provider,
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
return true;
|
|
164
|
+
/* v8 ignore start -- defensive: surfacing failure must not block the gate decision @preserve */
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
(0, runtime_1.emitNervesEvent)({
|
|
168
|
+
level: "error",
|
|
169
|
+
component: "senses",
|
|
170
|
+
event: "senses.trust_gate_error",
|
|
171
|
+
message: "failed to surface auto-created group for acknowledgment",
|
|
172
|
+
meta: {
|
|
173
|
+
friendId: input.friend.id,
|
|
174
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
/* v8 ignore stop */
|
|
180
|
+
}
|
|
96
181
|
function enforceTrustGate(input) {
|
|
97
182
|
const { senseType } = input;
|
|
98
183
|
// Local (CLI) and internal (inner dialog) — always allow
|
|
@@ -104,8 +189,18 @@ function enforceTrustGate(input) {
|
|
|
104
189
|
return { allowed: true };
|
|
105
190
|
}
|
|
106
191
|
// Open senses (BlueBubbles/iMessage) — enforce trust rules
|
|
107
|
-
// Group chat with a family member present — allow regardless of trust level
|
|
192
|
+
// Group chat with a family member present — allow regardless of trust level.
|
|
193
|
+
// BUT if this is an auto-created stranger group (the harness picked it up
|
|
194
|
+
// silently via the family-member shortcut and the agent never explicitly
|
|
195
|
+
// acknowledged it), surface a one-time inner-pending notice so the agent
|
|
196
|
+
// gets a chance to categorize / rename / dismiss the relationship instead
|
|
197
|
+
// of accumulating activity invisibly.
|
|
108
198
|
if (input.isGroupChat && input.groupHasFamilyMember) {
|
|
199
|
+
/* v8 ignore start -- defaults shared with the rest of the gate; tested via the stranger-trust path */
|
|
200
|
+
const bundleRoot = input.bundleRoot ?? (0, identity_1.getAgentRoot)();
|
|
201
|
+
const nowIso = (input.now ?? (() => new Date()))().toISOString();
|
|
202
|
+
/* v8 ignore stop */
|
|
203
|
+
maybeSurfaceAutoCreatedGroup(input, bundleRoot, nowIso);
|
|
109
204
|
return { allowed: true };
|
|
110
205
|
}
|
|
111
206
|
const trustLevel = input.friend.trustLevel ?? "friend";
|