@ouro.bot/cli 0.1.0-alpha.876 → 0.1.0-alpha.878
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 +12 -0
- package/deploy/unraid/sanctuary-butler-upgrade.mjs +23 -0
- package/deploy/unraid/sanctuary-replay-gate.mjs +169 -21
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.ouro/psyche/LORE.md +2 -0
- package/deploy/unraid/sanctuary.ouro/psyche/SOUL.md +15 -13
- package/deploy/unraid/sanctuary.ouro/tool-profiles.json +4 -2
- package/deploy/unraid/sanctuary.xml +1 -1
- package/dist/a2a/client.js +1 -0
- package/dist/a2a/escalation-grants.js +122 -0
- package/dist/a2a/escalation-wiring.js +60 -0
- package/dist/a2a/outbox-client.js +57 -0
- package/dist/a2a/outbox-store.js +188 -0
- package/dist/a2a/outbox-wire.js +95 -0
- package/dist/a2a/resolution-proof.js +53 -0
- package/dist/a2a/server.js +66 -0
- package/dist/a2a/trusted-files.js +98 -0
- package/dist/heart/awaiting/a2a-await-delivery.js +18 -0
- package/dist/heart/daemon/cli-exec.js +56 -2
- package/dist/heart/daemon/cli-parse.js +65 -1
- package/dist/heart/daemon/sanctuary-acceptance-harness.js +1 -1
- package/dist/heart/failure-reports.js +335 -0
- package/dist/heart/file-lock.js +109 -0
- package/dist/repertoire/guardrails.js +4 -0
- package/dist/repertoire/tools-awaiting.js +33 -2
- package/dist/repertoire/tools-base.js +2 -0
- package/dist/repertoire/tools-escalation.js +84 -0
- package/dist/repertoire/tools.js +1 -0
- package/dist/senses/a2a-entry.js +3 -1
- package/dist/senses/private-runtime.js +31 -14
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.CLOSED_REPORT_RETENTION_MS = exports.REPORTS_PER_ORIGIN_DAY = exports.REPORTS_PER_ORIGIN_HOUR = exports.REPORTS_PER_DAY = exports.REPORTS_PER_HOUR = void 0;
|
|
37
|
+
exports.reportsDir = reportsDir;
|
|
38
|
+
exports.errorClass = errorClass;
|
|
39
|
+
exports.failureFingerprint = failureFingerprint;
|
|
40
|
+
exports.readFailureReport = readFailureReport;
|
|
41
|
+
exports.listFailureReports = listFailureReports;
|
|
42
|
+
exports.reportBody = reportBody;
|
|
43
|
+
exports.pruneClosedReports = pruneClosedReports;
|
|
44
|
+
exports.fileFailureReport = fileFailureReport;
|
|
45
|
+
exports.resolveFailureReport = resolveFailureReport;
|
|
46
|
+
exports.fixLiveNoticeText = fixLiveNoticeText;
|
|
47
|
+
exports.confirmResolvedReports = confirmResolvedReports;
|
|
48
|
+
const node_crypto_1 = require("node:crypto");
|
|
49
|
+
const fs = __importStar(require("node:fs"));
|
|
50
|
+
const path = __importStar(require("node:path"));
|
|
51
|
+
const semver = __importStar(require("semver"));
|
|
52
|
+
const a2a_client_1 = require("@ouro.bot/friends/a2a-client");
|
|
53
|
+
const escalation_grants_1 = require("../a2a/escalation-grants");
|
|
54
|
+
const outbox_store_1 = require("../a2a/outbox-store");
|
|
55
|
+
const resolution_proof_1 = require("../a2a/resolution-proof");
|
|
56
|
+
const replay_harness_1 = require("../a2a/replay-harness");
|
|
57
|
+
const runtime_1 = require("../nerves/runtime");
|
|
58
|
+
const file_lock_1 = require("./file-lock");
|
|
59
|
+
exports.REPORTS_PER_HOUR = 5;
|
|
60
|
+
exports.REPORTS_PER_DAY = 20;
|
|
61
|
+
exports.REPORTS_PER_ORIGIN_HOUR = 3;
|
|
62
|
+
exports.REPORTS_PER_ORIGIN_DAY = 10;
|
|
63
|
+
exports.CLOSED_REPORT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
|
64
|
+
const FIELD_MAX_CHARS = 2_000;
|
|
65
|
+
const HOUR_MS = 60 * 60 * 1000;
|
|
66
|
+
const DAY_MS = 24 * HOUR_MS;
|
|
67
|
+
const REPORT_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u;
|
|
68
|
+
function reportsDir(agentRoot) {
|
|
69
|
+
return path.join(agentRoot, "state", "reports");
|
|
70
|
+
}
|
|
71
|
+
function reportFile(agentRoot, id) {
|
|
72
|
+
return path.join(reportsDir(agentRoot), `${id}.json`);
|
|
73
|
+
}
|
|
74
|
+
function cap(value) {
|
|
75
|
+
return value.trim().slice(0, FIELD_MAX_CHARS);
|
|
76
|
+
}
|
|
77
|
+
/** The same failure reads the same: numbers, ids and paths are blanked so a retry with a new request id folds in. */
|
|
78
|
+
function errorClass(error) {
|
|
79
|
+
return error.toLowerCase()
|
|
80
|
+
.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gu, "<id>")
|
|
81
|
+
.replace(/(?:\/[\w.@-]+){2,}/gu, "<path>")
|
|
82
|
+
.replace(/\d+/gu, "<n>")
|
|
83
|
+
.replace(/\s+/gu, " ").trim().slice(0, 80);
|
|
84
|
+
}
|
|
85
|
+
function failureFingerprint(failedTool, error) {
|
|
86
|
+
return (0, node_crypto_1.createHash)("sha256").update(`${(failedTool ?? "").trim().toLowerCase()}\0${errorClass(error)}`).digest("hex").slice(0, 16);
|
|
87
|
+
}
|
|
88
|
+
function readFailureReport(agentRoot, id) {
|
|
89
|
+
if (!REPORT_ID.test(id))
|
|
90
|
+
return null;
|
|
91
|
+
try {
|
|
92
|
+
return JSON.parse(fs.readFileSync(reportFile(agentRoot, id), "utf8"));
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function writeReport(agentRoot, record) {
|
|
99
|
+
fs.mkdirSync(reportsDir(agentRoot), { recursive: true });
|
|
100
|
+
const file = reportFile(agentRoot, record.id);
|
|
101
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
102
|
+
fs.writeFileSync(tmp, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 });
|
|
103
|
+
fs.renameSync(tmp, file);
|
|
104
|
+
}
|
|
105
|
+
function listFailureReports(agentRoot) {
|
|
106
|
+
let names;
|
|
107
|
+
try {
|
|
108
|
+
names = fs.readdirSync(reportsDir(agentRoot));
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
return names.filter((name) => name.endsWith(".json")).flatMap((name) => {
|
|
114
|
+
const record = readFailureReport(agentRoot, name.slice(0, -5));
|
|
115
|
+
return record ? [record] : [];
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
function reportBody(record) {
|
|
119
|
+
const { origin } = record;
|
|
120
|
+
return [
|
|
121
|
+
`Failure report ${record.shortId} (${record.severity})${origin.ownerOrigin ? "" : " - UNTRUSTED ORIGIN: not the owner's session"}`,
|
|
122
|
+
origin.ownerOrigin ? `Ari asked: ${record.ariWords}` : `A session with ${origin.friendName ?? "an unknown friend"} (${origin.trustLevel ?? "unknown trust"}) reported: ${record.ariWords}`,
|
|
123
|
+
`I tried: ${record.tried}`,
|
|
124
|
+
`${record.failedTool ? `${record.failedTool} failed` : "I gave up"}: ${record.error}`,
|
|
125
|
+
`Conversation: ${origin.channel ?? "unknown"}/${origin.key ?? "unknown"}`,
|
|
126
|
+
`Origin: friend ${origin.friendId ?? "unknown"}, trust ${origin.trustLevel ?? "unknown"}, owner ${origin.ownerOrigin ? "yes" : "no"}`,
|
|
127
|
+
].join("\n");
|
|
128
|
+
}
|
|
129
|
+
/** The origin friend, read from the friend store: only an active family friend on the owner profile is the owner. */
|
|
130
|
+
async function resolveOrigin(store, origin) {
|
|
131
|
+
const friend = origin.friendId ? await store.get(origin.friendId) : null;
|
|
132
|
+
const ownerOrigin = Boolean(friend && friend.admissionState === "active" && friend.trustLevel === "family" && friend.capabilityProfileId === "sanctuary-owner");
|
|
133
|
+
return { ...origin, friendName: friend ? friend.name : null, trustLevel: friend ? String(friend.trustLevel) : null, ownerOrigin };
|
|
134
|
+
}
|
|
135
|
+
/** Closed reports are history; they go after a month so the directory does not grow without bound. */
|
|
136
|
+
function pruneClosedReports(agentRoot, now = Date.now()) {
|
|
137
|
+
let pruned = 0;
|
|
138
|
+
for (const record of listFailureReports(agentRoot)) {
|
|
139
|
+
if (record.status === "closed" && record.closedAt && now - Date.parse(record.closedAt) > exports.CLOSED_REPORT_RETENTION_MS) {
|
|
140
|
+
fs.rmSync(reportFile(agentRoot, record.id), { force: true });
|
|
141
|
+
pruned += 1;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return pruned;
|
|
145
|
+
}
|
|
146
|
+
function lockDir(agentRoot) {
|
|
147
|
+
return path.join(reportsDir(agentRoot), ".locks");
|
|
148
|
+
}
|
|
149
|
+
function overBudget(created, now, perHour, perDay) {
|
|
150
|
+
return created.filter((at) => now - at < HOUR_MS).length >= perHour || created.filter((at) => now - at < DAY_MS).length >= perDay;
|
|
151
|
+
}
|
|
152
|
+
async function fileFailureReport(agentRoot, store, input, now = Date.now()) {
|
|
153
|
+
const ariWords = cap(input.ariWords);
|
|
154
|
+
const tried = cap(input.tried);
|
|
155
|
+
const error = cap(input.error);
|
|
156
|
+
if (!ariWords || !tried || !error)
|
|
157
|
+
return { ok: false, reason: "invalid", detail: "ari_words, tried and error are all required" };
|
|
158
|
+
const failedTool = input.failedTool?.trim() ? input.failedTool.trim().slice(0, 120) : null;
|
|
159
|
+
const fingerprint = failureFingerprint(failedTool ?? undefined, error);
|
|
160
|
+
const origin = await resolveOrigin(store, input.origin);
|
|
161
|
+
const replay = origin.friendId !== null && (0, replay_harness_1.isReplayWindowOpen)(agentRoot, origin.friendId, now);
|
|
162
|
+
// A replay identity speaks only inside its window; once the window closes it must not reach a real outbox.
|
|
163
|
+
if (!replay && origin.friendId !== null && (0, replay_harness_1.isReplayIdentity)(agentRoot, origin.friendId))
|
|
164
|
+
return { ok: false, reason: "invalid", detail: "a replay identity can file only while its replay window is open" };
|
|
165
|
+
// One lock around read, rate check and write, so two processes cannot both pass the budget or file the same failure twice.
|
|
166
|
+
return (0, file_lock_1.withFileLock)(lockDir(agentRoot), "file", async () => {
|
|
167
|
+
pruneClosedReports(agentRoot, now);
|
|
168
|
+
const existing = listFailureReports(agentRoot);
|
|
169
|
+
// A replay conversation never folds: each gate run must leave a fresh report to read back.
|
|
170
|
+
const open = replay ? undefined : existing.find((record) => !record.replay && record.fingerprint === fingerprint && record.status !== "closed");
|
|
171
|
+
if (open) {
|
|
172
|
+
const folded = await foldRepeat(agentRoot, open.id, { error, failedTool, origin }, now);
|
|
173
|
+
if (folded)
|
|
174
|
+
return folded;
|
|
175
|
+
}
|
|
176
|
+
const holders = (await (0, escalation_grants_1.escalationHolders)(agentRoot, store)).filter((holder) => replay ? (0, replay_harness_1.isReplayWindowOpen)(agentRoot, holder.id, now) : !(0, replay_harness_1.isReplayIdentity)(agentRoot, holder.id));
|
|
177
|
+
if (holders.length === 0)
|
|
178
|
+
return { ok: false, reason: "no_escalation_peer", detail: replay ? "no replay escalation peer" : "no friend holds the escalation grant" };
|
|
179
|
+
const sameKind = existing.filter((record) => record.replay === replay);
|
|
180
|
+
if (overBudget(sameKind.map((record) => Date.parse(record.createdAt)), now, exports.REPORTS_PER_HOUR, exports.REPORTS_PER_DAY)) {
|
|
181
|
+
return { ok: false, reason: "rate_limited", detail: `at most ${exports.REPORTS_PER_HOUR} new reports an hour and ${exports.REPORTS_PER_DAY} a day` };
|
|
182
|
+
}
|
|
183
|
+
if (overBudget(sameKind.filter((record) => record.origin.friendId === origin.friendId).map((record) => Date.parse(record.createdAt)), now, exports.REPORTS_PER_ORIGIN_HOUR, exports.REPORTS_PER_ORIGIN_DAY)) {
|
|
184
|
+
return { ok: false, reason: "rate_limited", detail: `at most ${exports.REPORTS_PER_ORIGIN_HOUR} new reports an hour and ${exports.REPORTS_PER_ORIGIN_DAY} a day from one origin` };
|
|
185
|
+
}
|
|
186
|
+
const id = (0, node_crypto_1.randomUUID)();
|
|
187
|
+
const record = {
|
|
188
|
+
id, shortId: id.slice(0, 8), fingerprint, createdAt: new Date(now).toISOString(), lastSeenAt: new Date(now).toISOString(), occurrences: 1,
|
|
189
|
+
severity: input.severity, ariWords, tried, error, failedTool, origin, replay,
|
|
190
|
+
recipients: holders.map((holder) => holder.id), outboxEntries: {}, status: "open",
|
|
191
|
+
};
|
|
192
|
+
const outbox = new outbox_store_1.FileOutboxStore(agentRoot);
|
|
193
|
+
for (const holder of holders) {
|
|
194
|
+
record.outboxEntries[holder.id] = outbox.append(holder.id, {
|
|
195
|
+
kind: "failure_report",
|
|
196
|
+
body: reportBody(record),
|
|
197
|
+
meta: {
|
|
198
|
+
reportId: id, shortId: record.shortId, severity: record.severity, failedTool, fingerprint, ariWords: record.ariWords, conversation: { channel: origin.channel, key: origin.key },
|
|
199
|
+
origin: { friendId: origin.friendId, friendName: origin.friendName, trustLevel: origin.trustLevel, ownerOrigin: origin.ownerOrigin },
|
|
200
|
+
},
|
|
201
|
+
}, now).id;
|
|
202
|
+
}
|
|
203
|
+
writeReport(agentRoot, record);
|
|
204
|
+
(0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.failure_report_filed", message: "filed a failure report", meta: { reportId: id, severity: record.severity, recipients: holders.length, replay, ownerOrigin: origin.ownerOrigin } });
|
|
205
|
+
return { ok: true, id, shortId: record.shortId, duplicate: false, recipients: holders.length };
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
/** Counts a repeat on the open report (re-read under the report's own lock) and tells the recipients, at most once a day per report. */
|
|
209
|
+
async function foldRepeat(agentRoot, id, repeat, now) {
|
|
210
|
+
return (0, file_lock_1.withFileLock)(lockDir(agentRoot), `report-${id}`, () => {
|
|
211
|
+
const fresh = readFailureReport(agentRoot, id);
|
|
212
|
+
if (!fresh || fresh.status === "closed")
|
|
213
|
+
return null;
|
|
214
|
+
const occurrences = fresh.occurrences + 1;
|
|
215
|
+
const notify = fresh.lastRepeatNoticeAt === undefined || now - Date.parse(fresh.lastRepeatNoticeAt) >= DAY_MS;
|
|
216
|
+
if (notify) {
|
|
217
|
+
const outbox = new outbox_store_1.FileOutboxStore(agentRoot);
|
|
218
|
+
for (const recipient of fresh.recipients) {
|
|
219
|
+
outbox.append(recipient, {
|
|
220
|
+
kind: "report_repeat",
|
|
221
|
+
body: `Failure report ${fresh.shortId} happened again (${occurrences} times so far)${repeat.origin.ownerOrigin ? "" : " - UNTRUSTED ORIGIN: not the owner's session"}. ${repeat.failedTool ? `${repeat.failedTool} failed` : "I gave up"}: ${repeat.error}`,
|
|
222
|
+
meta: { reportId: fresh.id, shortId: fresh.shortId, occurrences, status: fresh.status, origin: { friendId: repeat.origin.friendId, friendName: repeat.origin.friendName, trustLevel: repeat.origin.trustLevel, ownerOrigin: repeat.origin.ownerOrigin } },
|
|
223
|
+
}, now);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
writeReport(agentRoot, { ...fresh, occurrences, lastSeenAt: new Date(now).toISOString(), ...(notify ? { lastRepeatNoticeAt: new Date(now).toISOString() } : {}) });
|
|
227
|
+
(0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.failure_report_folded", message: "folded a repeat failure into an open report", meta: { reportId: fresh.id, occurrences, notified: notify } });
|
|
228
|
+
return { ok: true, id: fresh.id, shortId: fresh.shortId, duplicate: true, recipients: fresh.recipients.length };
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
/** Records the fix a recipient says is live from `version`; the owner is told only once the running version reaches it. */
|
|
232
|
+
async function resolveFailureReport(agentRoot, input, now = Date.now()) {
|
|
233
|
+
if (!readFailureReport(agentRoot, input.id))
|
|
234
|
+
return { ok: false, reason: "unknown_report" };
|
|
235
|
+
return (0, file_lock_1.withFileLock)(lockDir(agentRoot), `report-${input.id}`, () => resolveLocked(agentRoot, input, now));
|
|
236
|
+
}
|
|
237
|
+
function resolveLocked(agentRoot, input, now) {
|
|
238
|
+
const record = readFailureReport(agentRoot, input.id);
|
|
239
|
+
/* v8 ignore next -- only a report pruned between the check above and taking its lock can be missing here @preserve */
|
|
240
|
+
if (!record)
|
|
241
|
+
return { ok: false, reason: "unknown_report" };
|
|
242
|
+
if (!record.recipients.includes(input.byFriendId))
|
|
243
|
+
return { ok: false, reason: "not_recipient" };
|
|
244
|
+
if (record.status === "closed")
|
|
245
|
+
return { ok: false, reason: "already_closed" };
|
|
246
|
+
const version = semver.valid(input.version);
|
|
247
|
+
if (!version || version !== input.version)
|
|
248
|
+
return { ok: false, reason: "bad_version" };
|
|
249
|
+
const note = cap(input.note);
|
|
250
|
+
if (!note)
|
|
251
|
+
return { ok: false, reason: "bad_note" };
|
|
252
|
+
writeReport(agentRoot, { ...record, status: "resolved", resolution: { version, note, by: input.byFriendId, at: new Date(now).toISOString(), resolvedAt: input.resolvedAt, signedNote: input.note, proof: input.proof } });
|
|
253
|
+
(0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.failure_report_resolved", message: "an escalation peer resolved a failure report", meta: { reportId: record.id, version } });
|
|
254
|
+
return { ok: true, id: record.id, status: "resolved" };
|
|
255
|
+
}
|
|
256
|
+
function fixLiveNoticeText(record) {
|
|
257
|
+
const resolution = record.resolution;
|
|
258
|
+
if (!record.origin.ownerOrigin) {
|
|
259
|
+
return `The fix for something I couldn't do earlier for ${record.origin.friendName ?? "a connected friend"} (report ${record.shortId}) is live now, in version ${resolution.version}. ${resolution.note}`;
|
|
260
|
+
}
|
|
261
|
+
return `The fix for what I couldn't do for you earlier (report ${record.shortId}) is live now, in version ${resolution.version}. ${resolution.note}\n\nYou asked: ${record.ariWords}`;
|
|
262
|
+
}
|
|
263
|
+
// Reports are rate limited and pruned, so this set stays small.
|
|
264
|
+
const warnedSkips = new Set();
|
|
265
|
+
/** The owner hears "fixed" only if the escalation holder signed it and still holds escalation; a file the Butler's own uid can write proves nothing. */
|
|
266
|
+
async function resolutionIsSigned(agentRoot, deps, record) {
|
|
267
|
+
const resolution = record.resolution;
|
|
268
|
+
const skip = (reason) => {
|
|
269
|
+
// The confirm tick runs every few minutes, so each report warns once per process, not once per tick.
|
|
270
|
+
const key = `${record.id}:${reason}`;
|
|
271
|
+
if (warnedSkips.has(key))
|
|
272
|
+
return false;
|
|
273
|
+
warnedSkips.add(key);
|
|
274
|
+
(0, runtime_1.emitNervesEvent)({ level: "warn", component: "senses", event: "senses.failure_report_resolution_unverified", message: "skipped a resolved report that the holder's signature does not vouch for", meta: { reportId: record.id, by: resolution.by, reason } });
|
|
275
|
+
return false;
|
|
276
|
+
};
|
|
277
|
+
// A report that was ever closed already told the owner; a resolved status on it is a forgery or a replay.
|
|
278
|
+
if (record.closedAt !== undefined)
|
|
279
|
+
return skip("report was closed before");
|
|
280
|
+
// The owner is told exactly what was signed: the stored note must be the signed note, and the version its canonical form.
|
|
281
|
+
if (typeof resolution.signedNote !== "string" || resolution.note !== cap(resolution.signedNote))
|
|
282
|
+
return skip("note is not the signed note");
|
|
283
|
+
if (semver.valid(resolution.version) !== resolution.version)
|
|
284
|
+
return skip("version is not canonical");
|
|
285
|
+
const friend = deps.friends ? await deps.friends.get(resolution.by) : null;
|
|
286
|
+
if (!friend || !(0, escalation_grants_1.holdsEscalation)(agentRoot, friend))
|
|
287
|
+
return skip("holder no longer holds escalation");
|
|
288
|
+
// The key comes from the root-owned grant. The friend record is writable by the Butler's own uid and is never consulted for it.
|
|
289
|
+
const checked = (0, resolution_proof_1.verifyResolution)({
|
|
290
|
+
sodium: await (0, a2a_client_1.ready)(),
|
|
291
|
+
claim: { reportId: record.id, version: resolution.version, note: resolution.signedNote, resolvedAt: resolution.resolvedAt ?? "" },
|
|
292
|
+
proof: resolution.proof,
|
|
293
|
+
holderDid: (0, escalation_grants_1.pinnedHolderDid)(agentRoot, resolution.by),
|
|
294
|
+
});
|
|
295
|
+
return checked.ok;
|
|
296
|
+
}
|
|
297
|
+
/** Tells the owner about each resolved report whose fix has reached the running version, then closes it. */
|
|
298
|
+
async function confirmResolvedReports(agentRoot, deps, now = Date.now()) {
|
|
299
|
+
const result = { closed: [], waiting: [], failed: [] };
|
|
300
|
+
for (const listed of listFailureReports(agentRoot)) {
|
|
301
|
+
if (listed.status !== "resolved" || !listed.resolution)
|
|
302
|
+
continue;
|
|
303
|
+
if (!semver.valid(deps.runningVersion) || !semver.gte(deps.runningVersion, listed.resolution.version)) {
|
|
304
|
+
result.waiting.push(listed.id);
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
try {
|
|
308
|
+
// The report is read again under its lock: a resolution or repeat may have landed since the listing.
|
|
309
|
+
const outcome = await (0, file_lock_1.withFileLock)(lockDir(agentRoot), `report-${listed.id}`, async () => {
|
|
310
|
+
const record = readFailureReport(agentRoot, listed.id);
|
|
311
|
+
if (!record || record.status !== "resolved" || !record.resolution)
|
|
312
|
+
return "skipped";
|
|
313
|
+
if (!semver.gte(deps.runningVersion, record.resolution.version))
|
|
314
|
+
return "waiting";
|
|
315
|
+
if (!(await resolutionIsSigned(agentRoot, deps, record)))
|
|
316
|
+
return "skipped";
|
|
317
|
+
if (!record.replay)
|
|
318
|
+
await deps.notifyOwner({ noticeId: `failure-fixed:${record.id}`, text: fixLiveNoticeText(record) });
|
|
319
|
+
writeReport(agentRoot, { ...record, status: "closed", closedAt: new Date(now).toISOString() });
|
|
320
|
+
(0, runtime_1.emitNervesEvent)({ component: "senses", event: "senses.failure_report_closed", message: "closed a failure report after its fix went live", meta: { reportId: record.id, version: record.resolution.version } });
|
|
321
|
+
return "closed";
|
|
322
|
+
});
|
|
323
|
+
if (outcome === "closed")
|
|
324
|
+
result.closed.push(listed.id);
|
|
325
|
+
else if (outcome === "waiting")
|
|
326
|
+
result.waiting.push(listed.id);
|
|
327
|
+
}
|
|
328
|
+
catch (error) {
|
|
329
|
+
(0, runtime_1.emitNervesEvent)({ level: "warn", component: "senses", event: "senses.failure_report_notice_failed", message: "could not tell the owner a fix is live; will retry", meta: { reportId: listed.id, error: error instanceof Error ? error.message : String(error) } });
|
|
330
|
+
result.failed.push(listed.id);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
pruneClosedReports(agentRoot, now);
|
|
334
|
+
return result;
|
|
335
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.withFileLock = withFileLock;
|
|
37
|
+
const fs = __importStar(require("node:fs"));
|
|
38
|
+
const path = __importStar(require("node:path"));
|
|
39
|
+
const runtime_1 = require("../nerves/runtime");
|
|
40
|
+
const DEFAULT_STALE_MS = 120_000;
|
|
41
|
+
const DEFAULT_WAIT_MS = 45_000;
|
|
42
|
+
const DEFAULT_POLL_MS = 25;
|
|
43
|
+
/**
|
|
44
|
+
* Breaks a stale lock by renaming it to a name only this caller knows, then removing that. Two waiters both seeing the
|
|
45
|
+
* same stale lock cannot both break it: only one rename succeeds, and the loser never touches a lock a third process
|
|
46
|
+
* has since taken under the original name.
|
|
47
|
+
*/
|
|
48
|
+
function breakStaleLock(lock) {
|
|
49
|
+
const broken = `${lock}.broken-${process.pid}-${Math.random().toString(36).slice(2)}`;
|
|
50
|
+
try {
|
|
51
|
+
fs.renameSync(lock, broken);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
/* v8 ignore next -- the loser of a break race finds the lock already renamed away, and simply retries @preserve */
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
removeLock(broken);
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
/** The lock directory is always empty, so removing it never recurses; another process may already have broken it as stale. */
|
|
61
|
+
function removeLock(lock) {
|
|
62
|
+
try {
|
|
63
|
+
fs.rmdirSync(lock);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
/* v8 ignore next -- a lock broken as stale by another process is already gone @preserve */
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Runs `fn` while holding a named lock in `dir`. The lock is a directory, because creating one is atomic across
|
|
72
|
+
* processes, which matters here: the Butler's senses (Telegram, A2A, the private runtime) are separate processes that
|
|
73
|
+
* share one bundle on disk.
|
|
74
|
+
*/
|
|
75
|
+
async function withFileLock(dir, name, fn, options = {}) {
|
|
76
|
+
const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
|
|
77
|
+
const waitMs = options.waitMs ?? DEFAULT_WAIT_MS;
|
|
78
|
+
const pollMs = options.pollMs ?? DEFAULT_POLL_MS;
|
|
79
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
80
|
+
const lock = path.join(dir, `${name}.lock`);
|
|
81
|
+
const deadline = Date.now() + waitMs;
|
|
82
|
+
for (;;) {
|
|
83
|
+
try {
|
|
84
|
+
fs.mkdirSync(lock);
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
if (error.code !== "EEXIST")
|
|
89
|
+
throw error;
|
|
90
|
+
/* v8 ignore next -- a holder releasing between the failed mkdir and this stat reads as an old lock, which is then simply retried @preserve */
|
|
91
|
+
const heldSince = fs.statSync(lock, { throwIfNoEntry: false })?.mtimeMs ?? 0;
|
|
92
|
+
if (Date.now() - heldSince > staleMs) {
|
|
93
|
+
/* v8 ignore next -- only the waiter whose rename won reports the break @preserve */
|
|
94
|
+
if (breakStaleLock(lock))
|
|
95
|
+
(0, runtime_1.emitNervesEvent)({ level: "warn", component: "heart", event: "heart.file_lock_broken", message: "broke a lock whose holder never released it", meta: { name, heldMs: Date.now() - heldSince } });
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (Date.now() >= deadline)
|
|
99
|
+
throw new Error(`timed out waiting for the ${name} lock`);
|
|
100
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
return await fn();
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
removeLock(lock);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -63,6 +63,10 @@ const PROTECTED_PATH_SEGMENTS = [
|
|
|
63
63
|
".ouro-cli/vault-unlock/",
|
|
64
64
|
".ouro-cli/vault-unlock-dpapi/",
|
|
65
65
|
"state/policy/",
|
|
66
|
+
// What the Butler tells its escalation peers, and who may hear it: only the harness writes these, never the model.
|
|
67
|
+
"state/a2a/escalation-grants.json",
|
|
68
|
+
"state/outbox/",
|
|
69
|
+
"state/reports/",
|
|
66
70
|
];
|
|
67
71
|
const PROTECTED_FILENAMES = ["agent.json", "tool-profiles.json"];
|
|
68
72
|
/** The agent's psyche folder ships only from the repository (a pull request and the replay gate), so no tool writes into it. */
|
|
@@ -43,6 +43,7 @@ exports.cancelStaleAwait = cancelStaleAwait;
|
|
|
43
43
|
exports.isBrokenBindingReason = isBrokenBindingReason;
|
|
44
44
|
exports.failBrokenAwait = failBrokenAwait;
|
|
45
45
|
exports.inspectRelationshipFollowUp = inspectRelationshipFollowUp;
|
|
46
|
+
exports.inspectPeerAwait = inspectPeerAwait;
|
|
46
47
|
exports.hasActiveRelationshipFollowUp = hasActiveRelationshipFollowUp;
|
|
47
48
|
exports.hasActiveExternalEventAwait = hasActiveExternalEventAwait;
|
|
48
49
|
exports.inspectExternalEventAwait = inspectExternalEventAwait;
|
|
@@ -180,6 +181,8 @@ function failedAskOwner(agentName, name, error) {
|
|
|
180
181
|
/** At most this many ask_owner closures per requester in a rolling day, so one requester cannot make the Butler pester the owner. */
|
|
181
182
|
const ASK_OWNER_DAILY_LIMIT = 3;
|
|
182
183
|
const ASK_OWNER_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
184
|
+
/** Across every requester who is not the owner, so many requesters together cannot flood the owner either. */
|
|
185
|
+
const ASK_OWNER_NON_OWNER_DAILY_LIMIT = 6;
|
|
183
186
|
function askLedgerPath(agentRoot) {
|
|
184
187
|
return path.join(awaitingDir(agentRoot), ".asks.jsonl");
|
|
185
188
|
}
|
|
@@ -230,7 +233,8 @@ function recentAskCount(agentRoot, filer, now) {
|
|
|
230
233
|
return raw.split("\n").filter((line) => {
|
|
231
234
|
try {
|
|
232
235
|
const entry = JSON.parse(line);
|
|
233
|
-
|
|
236
|
+
const who = filer === undefined ? entry.owner === false : (entry.filer ?? null) === filer;
|
|
237
|
+
return who && typeof entry.at === "string" && now - Date.parse(entry.at) < ASK_OWNER_WINDOW_MS;
|
|
234
238
|
}
|
|
235
239
|
catch {
|
|
236
240
|
return false;
|
|
@@ -274,6 +278,9 @@ async function askOwnerTool(name, observation, question, choices, existing, agen
|
|
|
274
278
|
if (!filer.isOwner && recentAskCount(agentRoot, filerId, Date.now()) >= ASK_OWNER_DAILY_LIMIT) {
|
|
275
279
|
return JSON.stringify({ error: `ask_owner limit reached: this requester has already had my owner asked ${ASK_OWNER_DAILY_LIMIT} times in the last day; do not retry ask_owner, resolve with verdict 'no' with an observation and keep polling` });
|
|
276
280
|
}
|
|
281
|
+
if (!filer.isOwner && recentAskCount(agentRoot, undefined, Date.now()) >= ASK_OWNER_NON_OWNER_DAILY_LIMIT) {
|
|
282
|
+
return JSON.stringify({ error: `ask_owner limit reached: my owner has already been asked ${ASK_OWNER_NON_OWNER_DAILY_LIMIT} times today on behalf of other requesters; do not retry ask_owner, resolve with verdict 'no' with an observation and keep polling` });
|
|
283
|
+
}
|
|
277
284
|
const isA2A = existing.filed_from === "a2a";
|
|
278
285
|
const noticeId = `await:${name}:asked_owner:${String(existing.created_at)}`;
|
|
279
286
|
const question = formatOwnerQuestion(parsed.question, parsed.choices);
|
|
@@ -318,7 +325,7 @@ async function askOwnerTool(name, observation, question, choices, existing, agen
|
|
|
318
325
|
/* v8 ignore next -- defensive: archiveAwait only fails on the file-disappears-mid-call race already covered by v8 ignore inside archiveAwait @preserve */
|
|
319
326
|
if (!archive.ok)
|
|
320
327
|
return JSON.stringify({ error: archive.error });
|
|
321
|
-
recordAsk(agentRoot, { filer: filerId, name, at: askedAt, question: parsed.question });
|
|
328
|
+
recordAsk(agentRoot, { filer: filerId, owner: filer.isOwner, name, at: askedAt, question: parsed.question });
|
|
322
329
|
if (!tellsFriend || delivered(friendNotice))
|
|
323
330
|
fulfillAwaitObligation(agentRoot, archive.file);
|
|
324
331
|
(0, runtime_1.emitNervesEvent)({
|
|
@@ -643,6 +650,25 @@ function inspectRelationshipFollowUp(agentRoot, input) {
|
|
|
643
650
|
}
|
|
644
651
|
return { active: true };
|
|
645
652
|
}
|
|
653
|
+
/**
|
|
654
|
+
* The binding of an await filed in a plain A2A conversation: it has no request id and no obligation, only the peer's friend
|
|
655
|
+
* id and conversation key as provenance. It is live while it is still pending, was filed by that peer in that conversation,
|
|
656
|
+
* and has not outlived its max age.
|
|
657
|
+
*/
|
|
658
|
+
function inspectPeerAwait(agentRoot, input) {
|
|
659
|
+
const awaiting = readAwaitDefinition(agentRoot, input.awaitName);
|
|
660
|
+
if (!awaiting)
|
|
661
|
+
throw new Error(`Await ${input.awaitName} could not be verified`);
|
|
662
|
+
const matches = awaiting.status === "pending" && !awaiting.request_id && !awaiting.obligation_id
|
|
663
|
+
&& awaiting.filed_for_friend_id === input.friendId && awaiting.filed_from === "a2a" && awaiting.filed_from_key === input.key;
|
|
664
|
+
if (!matches)
|
|
665
|
+
return { active: false, reason: "request obligation binding no longer matches" };
|
|
666
|
+
const maxAge = (0, cadence_1.parseCadenceToMs)(awaiting.max_age);
|
|
667
|
+
if (input.allowElapsed !== true && maxAge !== null && awaiting.created_at && (input.now ?? Date.now()) >= Date.parse(awaiting.created_at) + maxAge) {
|
|
668
|
+
return { active: false, reason: "request obligation await expired" };
|
|
669
|
+
}
|
|
670
|
+
return { active: true };
|
|
671
|
+
}
|
|
646
672
|
function hasActiveRelationshipFollowUp(agentRoot, input) {
|
|
647
673
|
if (input.awaitName)
|
|
648
674
|
return inspectRelationshipFollowUp(agentRoot, { ...input, awaitName: input.awaitName }).active;
|
|
@@ -770,6 +796,11 @@ exports.awaitingToolDefinitions = [
|
|
|
770
796
|
if (session?.channel === "external-event" && existing?.wake_at) {
|
|
771
797
|
binding = inspectExternalEventAwait(agentName, { recordPath: session.key, awaitName, wakeAt: existing.wake_at });
|
|
772
798
|
}
|
|
799
|
+
else if (session?.channel === "a2a" && ctx.awaitTick && !requestId && existing && !existing.request_id
|
|
800
|
+
&& existing.filed_for_friend_id === session.friendId && existing.filed_from === "a2a" && existing.filed_from_key === session.key) {
|
|
801
|
+
// A plain peer await is resolved only by its own tick, never from a chat turn.
|
|
802
|
+
binding = inspectPeerAwait(agentRoot, { friendId: session.friendId, key: session.key, awaitName });
|
|
803
|
+
}
|
|
773
804
|
else if (session && requestId) {
|
|
774
805
|
if (existing?.filed_for_friend_id !== session.friendId || existing.filed_from !== session.channel
|
|
775
806
|
|| existing.filed_from_key !== session.key || existing.request_id !== requestId) {
|
|
@@ -24,6 +24,7 @@ const tools_trip_1 = require("./tools-trip");
|
|
|
24
24
|
const tools_a2a_1 = require("./tools-a2a");
|
|
25
25
|
const tools_commerce_1 = require("./tools-commerce");
|
|
26
26
|
const tools_awaiting_1 = require("./tools-awaiting");
|
|
27
|
+
const tools_escalation_1 = require("./tools-escalation");
|
|
27
28
|
const tools_obligations_1 = require("./tools-obligations");
|
|
28
29
|
const tools_evolution_1 = require("./tools-evolution");
|
|
29
30
|
const tools_runtime_1 = require("./tools-runtime");
|
|
@@ -75,6 +76,7 @@ exports.baseToolDefinitions = [
|
|
|
75
76
|
...tools_a2a_1.a2aToolDefinitions,
|
|
76
77
|
...tools_commerce_1.commerceToolDefinitions,
|
|
77
78
|
...tools_awaiting_1.awaitingToolDefinitions,
|
|
79
|
+
tools_escalation_1.reportFailureToolDefinition,
|
|
78
80
|
...tools_obligations_1.obligationToolDefinitions,
|
|
79
81
|
...tools_evolution_1.evolutionToolDefinitions,
|
|
80
82
|
...tools_orientation_1.orientationToolDefinitions,
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.reportFailureToolDefinition = void 0;
|
|
37
|
+
const path = __importStar(require("path"));
|
|
38
|
+
const friends_1 = require("@ouro.bot/friends");
|
|
39
|
+
const failure_reports_1 = require("../heart/failure-reports");
|
|
40
|
+
const runtime_1 = require("../nerves/runtime");
|
|
41
|
+
function text(value) {
|
|
42
|
+
return typeof value === "string" ? value : "";
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Lets the agent hand a failure it cannot work around to the peers who fix it. The session comes from the runtime
|
|
46
|
+
* context, never from the model, so a report cannot be addressed to or attributed to anyone else.
|
|
47
|
+
*/
|
|
48
|
+
exports.reportFailureToolDefinition = {
|
|
49
|
+
tool: {
|
|
50
|
+
type: "function",
|
|
51
|
+
function: {
|
|
52
|
+
name: "report_failure",
|
|
53
|
+
description: "File a failure report for the engineers who fix you. Use it when a tool you need is missing, a tool errors in a way you cannot work around, or you are about to give up on what the owner asked. Then tell the owner you have filed it, with the short report id, in your own words. Do not ask the owner to do anything about it.",
|
|
54
|
+
parameters: {
|
|
55
|
+
type: "object",
|
|
56
|
+
properties: {
|
|
57
|
+
ari_words: { type: "string", description: "What the owner asked for, verbatim from the conversation." },
|
|
58
|
+
tried: { type: "string", description: "What you tried: tool names and arguments, summarized." },
|
|
59
|
+
error: { type: "string", description: "The tool error, or the reason you gave up." },
|
|
60
|
+
failed_tool: { type: "string", description: "The tool that failed or that you wished existed, if there is one." },
|
|
61
|
+
severity: { type: "string", enum: ["low", "medium", "high"], description: "How much this blocks the owner." },
|
|
62
|
+
},
|
|
63
|
+
required: ["ari_words", "tried", "error"],
|
|
64
|
+
additionalProperties: false,
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
handler: async (args, ctx) => {
|
|
69
|
+
if (!ctx?.agentRoot)
|
|
70
|
+
return "report_failure is unavailable: no agent runtime.";
|
|
71
|
+
const severity = ["low", "medium", "high"].includes(args.severity) ? args.severity : "medium";
|
|
72
|
+
const store = new friends_1.FileFriendStore(path.join(ctx.agentRoot, "friends"));
|
|
73
|
+
const result = await (0, failure_reports_1.fileFailureReport)(ctx.agentRoot, store, {
|
|
74
|
+
ariWords: text(args.ari_words), tried: text(args.tried), error: text(args.error),
|
|
75
|
+
...(text(args.failed_tool) ? { failedTool: text(args.failed_tool) } : {}),
|
|
76
|
+
severity,
|
|
77
|
+
origin: { friendId: ctx.currentSession?.friendId ?? null, channel: ctx.currentSession?.channel ?? null, key: ctx.currentSession?.key ?? null },
|
|
78
|
+
});
|
|
79
|
+
(0, runtime_1.emitNervesEvent)({ component: "repertoire", event: "repertoire.report_failure", message: "failure report requested", meta: { ok: result.ok, ...(result.ok ? { duplicate: result.duplicate } : { reason: result.reason }) } });
|
|
80
|
+
if (!result.ok)
|
|
81
|
+
return `Not filed (${result.reason}): ${result.detail}`;
|
|
82
|
+
return JSON.stringify({ filed: true, reportId: result.id, shortId: result.shortId, duplicate: result.duplicate, recipients: result.recipients });
|
|
83
|
+
},
|
|
84
|
+
};
|