@ouro.bot/cli 0.1.0-alpha.865 → 0.1.0-alpha.867
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 +16 -0
- package/deploy/unraid/sanctuary-butler-upgrade.mjs +32 -4
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.xml +1 -1
- package/dist/a2a/replay-harness.js +3 -1
- package/dist/heart/awaiting/a2a-await-delivery.js +12 -3
- package/dist/heart/awaiting/await-alert.js +6 -2
- package/dist/heart/awaiting/await-parser.js +4 -1
- package/dist/heart/daemon/sanctuary-authority-root-lifecycle.js +62 -18
- package/dist/heart/daemon/sanctuary-telegram-authority-entry.js +31 -1
- package/dist/heart/daemon/sanctuary-telegram-authority-service.js +25 -12
- package/dist/repertoire/tools-awaiting.js +186 -6
- package/dist/senses/await-turn-message.js +1 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
package/changelog.json
CHANGED
|
@@ -1,6 +1,22 @@
|
|
|
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.867",
|
|
6
|
+
"changes": [
|
|
7
|
+
"Butler awaits can now ask the owner to decide: resolve_await accepts verdict ask_owner with a plain-words question and 2 to 4 choices, which closes the await as asked_owner (once per await instance, at most 3 per requester per day) and sends the question to the owner's own Telegram chat as the Butler, instead of an await tick repeating verdict no forever on a confirmed dead download.",
|
|
8
|
+
"Butler awaits can now ask the owner to decide: resolve_await accepts verdict ask_owner with a plain-words question and 2 to 4 choices, which closes the await as asked_owner (once per await instance, at most 3 per non-owner requester per day) and sends the question to the owner's own Telegram chat as the Butler, instead of an await tick repeating verdict no forever on a confirmed dead download."
|
|
9
|
+
]
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"version": "0.1.0-alpha.866",
|
|
13
|
+
"changes": [
|
|
14
|
+
"The Sanctuary lifecycle now relaunches a gateway that exits before readiness (backoff of 2, 10 and 30 seconds) within one 15-minute readiness deadline and, if every attempt fails, throws promptly with the gateway.log tail (token-shaped text masked before the tail is cut) instead of waiting out a fresh budget.",
|
|
15
|
+
"The Sanctuary Telegram authority retries getMe with backoff on network-class errors (fetch failed, ECONNRESET, ETIMEDOUT, 5xx) and still fails at once on auth errors such as 401.",
|
|
16
|
+
"The Sanctuary authority socket server handles client connection errors by logging them and skips writes to destroyed connections, so a client that disconnects mid-response no longer crashes the gateway with write EPIPE.",
|
|
17
|
+
"The Butler upgrade script checks that api.telegram.org is reachable before pausing host supervision and aborts early with a clear message if it is not; it skips the check when an upgrade journal exists (resume or rollback)."
|
|
18
|
+
]
|
|
19
|
+
},
|
|
4
20
|
{
|
|
5
21
|
"version": "0.1.0-alpha.865",
|
|
6
22
|
"changes": [
|
|
@@ -909,7 +909,31 @@ function waitForButler(imageRef, seconds) {
|
|
|
909
909
|
return false
|
|
910
910
|
}
|
|
911
911
|
|
|
912
|
-
|
|
912
|
+
// The new gateway asks Telegram who it is (getMe) before it reports ready. When the host cannot reach
|
|
913
|
+
// Telegram, that fails and the whole upgrade rolls back after host supervision was already paused
|
|
914
|
+
// (the 863 -> 864 upgrade). Any HTTP answer from the API host proves the network path; no token is sent.
|
|
915
|
+
export const TELEGRAM_API_URL = "https://api.telegram.org/"
|
|
916
|
+
export async function telegramApiReachable({ fetchImpl = globalThis.fetch, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), tries = 3, timeoutMs = 8000, retryDelayMs = 3000 } = {}) {
|
|
917
|
+
let detail = "no attempt made"
|
|
918
|
+
for (let attempt = 1; attempt <= tries; attempt += 1) {
|
|
919
|
+
try {
|
|
920
|
+
const response = await fetchImpl(TELEGRAM_API_URL, { method: "GET", signal: AbortSignal.timeout(timeoutMs) })
|
|
921
|
+
return { ok: true, detail: `HTTP ${response.status}` }
|
|
922
|
+
} catch (error) {
|
|
923
|
+
detail = error instanceof Error ? (error.cause instanceof Error ? `${error.message}: ${error.cause.message}` : error.message) : String(error)
|
|
924
|
+
if (attempt < tries) await sleep(retryDelayMs)
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
return { ok: false, detail: `${detail} (${tries} attempts)` }
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// A resume or rollback (an upgrade journal exists) must never be blocked by a network probe.
|
|
931
|
+
export async function checkTelegramBeforePause({ journalExists, probe = telegramApiReachable }) {
|
|
932
|
+
if (journalExists) return { skipped: true, ok: true, detail: "upgrade journal present" }
|
|
933
|
+
return { skipped: false, ...(await probe()) }
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
async function upgrade(version, rehearse, { noGate = false, plant } = {}) {
|
|
913
937
|
say(`in-place upgrade to ${version}${rehearse ? ` — REHEARSAL: stop after "${rehearse}", then roll back` : ""}`)
|
|
914
938
|
if (rehearse && !UPGRADE_STEPS.includes(rehearse)) fail(`--rehearse takes one of: ${UPGRADE_STEPS.join(", ")}`)
|
|
915
939
|
const gated = !rehearse && !noGate
|
|
@@ -937,6 +961,10 @@ function upgrade(version, rehearse, { noGate = false, plant } = {}) {
|
|
|
937
961
|
const priorImage = butlerState().split(" ")[0]
|
|
938
962
|
const jellyfinBefore = docker(["inspect", "jellyfin", "--format", "{{.Id}}|{{.Image}}|{{.RestartCount}}|{{.State.StartedAt}}"]).trim()
|
|
939
963
|
const policyBefore = existsSync(POLICY) ? sha12(POLICY) : fail("steward policy missing")
|
|
964
|
+
const telegram = await checkTelegramBeforePause({ journalExists: existsSync(UPGRADE_JOURNAL) })
|
|
965
|
+
if (!telegram.ok) fail(`api.telegram.org is not reachable from this host (${telegram.detail}); the new gateway could not pass its Telegram identity check. Nothing was paused or changed; fix the network and re-run.`)
|
|
966
|
+
if (telegram.skipped) console.log(" note: resuming a pending upgrade; Telegram reachability not re-checked")
|
|
967
|
+
else ok(`api.telegram.org reachable (${telegram.detail})`)
|
|
940
968
|
pauseSupervision()
|
|
941
969
|
let failure = null
|
|
942
970
|
try {
|
|
@@ -1046,7 +1074,7 @@ export function parseUpgradeArgs(argv) {
|
|
|
1046
1074
|
return parsed
|
|
1047
1075
|
}
|
|
1048
1076
|
|
|
1049
|
-
function run(argv) {
|
|
1077
|
+
async function run(argv) {
|
|
1050
1078
|
let args
|
|
1051
1079
|
try { args = parseUpgradeArgs(argv) } catch (error) { console.error(error.message); process.exit(2) }
|
|
1052
1080
|
requireRoot()
|
|
@@ -1054,7 +1082,7 @@ function run(argv) {
|
|
|
1054
1082
|
else if (args.phase === "prepare") prepare(args.version)
|
|
1055
1083
|
else if (args.phase === "install") install(args.version)
|
|
1056
1084
|
else if (args.phase === "verify") verify(args.gate)
|
|
1057
|
-
else if (args.phase === "upgrade") upgrade(args.version, args.rehearse, { noGate: args.noGate, plant: args.plant })
|
|
1085
|
+
else if (args.phase === "upgrade") await upgrade(args.version, args.rehearse, { noGate: args.noGate, plant: args.plant })
|
|
1058
1086
|
}
|
|
1059
1087
|
|
|
1060
|
-
if (process.argv[1] && fileURLToPath(import.meta.url) === realpathSync(process.argv[1])) run(process.argv.slice(2))
|
|
1088
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === realpathSync(process.argv[1])) run(process.argv.slice(2)).catch((error) => { console.error(error); process.exit(1) })
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<?xml version="1.0"?>
|
|
2
2
|
<Container version="2">
|
|
3
3
|
<Name>ouro-butler</Name>
|
|
4
|
-
<Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.
|
|
4
|
+
<Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.867</Repository>
|
|
5
5
|
<Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
|
|
6
6
|
<Network>host</Network>
|
|
7
7
|
<Shell>sh</Shell>
|
|
@@ -112,8 +112,10 @@ function isReplayWindowOpen(agentRoot, friendId, now = Date.now(), trustedUid =
|
|
|
112
112
|
: Number.NaN;
|
|
113
113
|
return Number.isFinite(expiresAt) && expiresAt > now && expiresAt <= now + exports.REPLAY_WINDOW_MAX_MS;
|
|
114
114
|
}
|
|
115
|
-
/** Appends one notice to the sink; throws when it cannot be written, so the caller refuses exactly as a failed Telegram send does. */
|
|
115
|
+
/** Appends one notice to the sink, once per notice id and friend (a retry after a crash adds nothing); throws when it cannot be written, so the caller refuses exactly as a failed Telegram send does. */
|
|
116
116
|
function appendReplayNotice(agentRoot, notice, now = Date.now()) {
|
|
117
|
+
if (replayNoticeRecorded(agentRoot, notice.noticeId, notice.friendId))
|
|
118
|
+
return;
|
|
117
119
|
fs.mkdirSync(replayDir(agentRoot), { recursive: true });
|
|
118
120
|
const line = JSON.stringify({ at: new Date(now).toISOString(), ...notice });
|
|
119
121
|
fs.appendFileSync(replaySinkPath(agentRoot), `${line}\n`, { mode: 0o600 });
|
|
@@ -33,7 +33,9 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.askedOwnerText = askedOwnerText;
|
|
36
37
|
exports.createA2AAwaitOwnerDeliverer = createA2AAwaitOwnerDeliverer;
|
|
38
|
+
exports.defaultNotifyOwner = defaultNotifyOwner;
|
|
37
39
|
const path = __importStar(require("path"));
|
|
38
40
|
const friends_1 = require("@ouro.bot/friends");
|
|
39
41
|
const identity_1 = require("../identity");
|
|
@@ -46,6 +48,13 @@ const runtime_1 = require("../../nerves/runtime");
|
|
|
46
48
|
* sent as the owner or to the peer, and the notice id is the await's delivery id, so a retry cannot double-send.
|
|
47
49
|
*/
|
|
48
50
|
const NOTICE_ID_MAX_BYTES = 512;
|
|
51
|
+
/** A question for the owner reads as the owner's own question, with a pointer to the request it is about; every other outcome is a follow-up report. */
|
|
52
|
+
function askedOwnerText(question, about) {
|
|
53
|
+
return `${question}\n\n(about the request from ${about})`;
|
|
54
|
+
}
|
|
55
|
+
function ownerNoticeText(request, from) {
|
|
56
|
+
return request.noticeKind === "asked_owner" ? askedOwnerText(request.content, from) : `Follow-up on a request from ${from}: ${request.content}`;
|
|
57
|
+
}
|
|
49
58
|
function createA2AAwaitOwnerDeliverer(agentName, notifyOwner = defaultNotifyOwner(agentName)) {
|
|
50
59
|
return async (request) => {
|
|
51
60
|
if (!request.deliveryId || Buffer.byteLength(request.deliveryId) > NOTICE_ID_MAX_BYTES) {
|
|
@@ -54,12 +63,12 @@ function createA2AAwaitOwnerDeliverer(agentName, notifyOwner = defaultNotifyOwne
|
|
|
54
63
|
try {
|
|
55
64
|
const peer = await new friends_1.FileFriendStore(path.join((0, identity_1.getAgentRoot)(agentName), "friends")).get(request.friendId);
|
|
56
65
|
const agentRoot = (0, identity_1.getAgentRoot)(agentName);
|
|
66
|
+
const from = peer?.name ?? "a connected agent";
|
|
57
67
|
if ((0, replay_harness_1.isReplayWindowOpen)(agentRoot, request.friendId)) {
|
|
58
|
-
(0, replay_harness_1.appendReplayNotice)(agentRoot, { noticeId: request.deliveryId, text: request.content, friendId: request.friendId });
|
|
68
|
+
(0, replay_harness_1.appendReplayNotice)(agentRoot, { noticeId: request.deliveryId, text: request.noticeKind === "asked_owner" ? ownerNoticeText(request, from) : request.content, friendId: request.friendId });
|
|
59
69
|
return { status: "delivered_now", detail: "written to the replay sink (replay window open for this peer)" };
|
|
60
70
|
}
|
|
61
|
-
|
|
62
|
-
await notifyOwner({ noticeId: request.deliveryId, text: `Follow-up on a request from ${from}: ${request.content}` });
|
|
71
|
+
await notifyOwner({ noticeId: request.deliveryId, text: ownerNoticeText(request, from) });
|
|
63
72
|
return { status: "delivered_now", detail: "sent to the owner's Telegram chat as the agent" };
|
|
64
73
|
}
|
|
65
74
|
catch (error) {
|
|
@@ -74,6 +74,9 @@ function resolveAlertKey(agentRoot, friendId, channel) {
|
|
|
74
74
|
function buildAlertContent(awaitFile, reason, observation) {
|
|
75
75
|
const condition = awaitFile.condition ?? awaitFile.name;
|
|
76
76
|
const obs = readNonEmpty(observation);
|
|
77
|
+
if (reason === "asked_owner") {
|
|
78
|
+
return obs ? `${condition} — ${obs}` : `${condition} — I have asked the owner how to proceed.`;
|
|
79
|
+
}
|
|
77
80
|
if (reason === "resolved") {
|
|
78
81
|
return obs ? `${condition} — ready. ${obs}` : `${condition} — ready.`;
|
|
79
82
|
}
|
|
@@ -120,14 +123,15 @@ async function deliverAwaitAlert(options) {
|
|
|
120
123
|
});
|
|
121
124
|
return { attempted: false, skipped: "no session key" };
|
|
122
125
|
}
|
|
123
|
-
const content = buildAlertContent(awaitFile, reason, observation);
|
|
126
|
+
const content = options.content ?? buildAlertContent(awaitFile, reason, observation);
|
|
124
127
|
const delivery = await (0, cross_chat_delivery_1.deliverCrossChatMessage)({
|
|
125
128
|
friendId,
|
|
126
129
|
channel,
|
|
127
130
|
key,
|
|
128
131
|
content,
|
|
129
132
|
...(awaitFile.request_id ? { requestId: awaitFile.request_id } : {}),
|
|
130
|
-
deliveryId: `await:${awaitFile.name}:${reason}`,
|
|
133
|
+
deliveryId: options.deliveryId ?? `await:${awaitFile.name}:${reason}`,
|
|
134
|
+
...(reason === "asked_owner" ? { noticeKind: "asked_owner" } : {}),
|
|
131
135
|
intent: "generic_outreach",
|
|
132
136
|
}, deliveryDeps);
|
|
133
137
|
(0, runtime_1.emitNervesEvent)({
|
|
@@ -39,7 +39,7 @@ const path = __importStar(require("path"));
|
|
|
39
39
|
const frontmatter_1 = require("../../util/frontmatter");
|
|
40
40
|
const runtime_1 = require("../../nerves/runtime");
|
|
41
41
|
function isAwaitStatus(value) {
|
|
42
|
-
return value === "pending" || value === "resolved" || value === "expired" || value === "canceled";
|
|
42
|
+
return value === "pending" || value === "resolved" || value === "expired" || value === "canceled" || value === "asked_owner";
|
|
43
43
|
}
|
|
44
44
|
function isAwaitMode(value) {
|
|
45
45
|
return value === "full" || value === "quick";
|
|
@@ -121,6 +121,9 @@ function parseAwaitFile(content, filePath) {
|
|
|
121
121
|
last_observation_at_expiry: nonEmptyString(frontmatter.last_observation_at_expiry),
|
|
122
122
|
canceled_at: nonEmptyString(frontmatter.canceled_at),
|
|
123
123
|
cancel_reason: nonEmptyString(frontmatter.cancel_reason),
|
|
124
|
+
...(nonEmptyString(frontmatter.asked_at) ? { asked_at: nonEmptyString(frontmatter.asked_at) } : {}),
|
|
125
|
+
...(nonEmptyString(frontmatter.ask_question) ? { ask_question: nonEmptyString(frontmatter.ask_question) } : {}),
|
|
126
|
+
...(nonEmptyString(frontmatter.ask_choices) ? { ask_choices: nonEmptyString(frontmatter.ask_choices) } : {}),
|
|
124
127
|
};
|
|
125
128
|
}
|
|
126
129
|
function formatFrontmatterValue(value) {
|
|
@@ -73,6 +73,10 @@ const DIGEST = /^sha256:[a-f0-9]{64}$/u;
|
|
|
73
73
|
// page cache on Unraid's array that takes minutes, so readiness gets a generous budget
|
|
74
74
|
// (a 120 s budget failed a real rollback on 2026-09-24).
|
|
75
75
|
const GATEWAY_READY_TIMEOUT_MS = 900_000;
|
|
76
|
+
const GATEWAY_START_BACKOFF_MS = [2_000, 10_000, 30_000];
|
|
77
|
+
const GATEWAY_LOG_TAIL_CHARS = 2_000;
|
|
78
|
+
// Read more than is shown, so masking sees whole tokens before the tail is cut.
|
|
79
|
+
const GATEWAY_LOG_READ_BYTES = 4_096;
|
|
76
80
|
const GATEWAY_LOG_MAX_BYTES = 5 * 1024 * 1024;
|
|
77
81
|
const TEMPLATE_JOURNAL = "/boot/config/custom/ouro-butler/docker-man-template-transaction.json";
|
|
78
82
|
// In-place upgrade of an installed authority: same epoch (token, issuer, cursor,
|
|
@@ -536,29 +540,69 @@ class SanctuaryAuthorityRootLifecycle {
|
|
|
536
540
|
if (!this.#tokensAbsent() || !this.#rotated())
|
|
537
541
|
throw new Error("Sanctuary root token custody is not exclusive");
|
|
538
542
|
if (this.#pid() === null) {
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
543
|
+
// The gateway can exit within seconds of launch (a transient Telegram network error at
|
|
544
|
+
// getMe did, 863 -> 864). Retry the launch with backoff, and when every attempt fails
|
|
545
|
+
// say so now, with the gateway's own output, instead of waiting out the readiness budget.
|
|
546
|
+
// One deadline bounds every launch: a relaunch only gets the budget that remains.
|
|
547
|
+
const deadline = Date.now() + GATEWAY_READY_TIMEOUT_MS;
|
|
548
|
+
for (let attempt = 0;; attempt += 1) {
|
|
549
|
+
const failure = await this.#launchGatewayOnce(deadline - Date.now());
|
|
550
|
+
if (failure === null)
|
|
551
|
+
return;
|
|
552
|
+
if (attempt >= GATEWAY_START_BACKOFF_MS.length || Date.now() + GATEWAY_START_BACKOFF_MS[attempt] >= deadline) {
|
|
553
|
+
throw new Error(`Sanctuary gateway ${failure} before readiness after ${attempt + 1} attempts; gateway.log tail:\n${this.#gatewayLogTail()}`);
|
|
554
|
+
}
|
|
555
|
+
(0, runtime_1.emitNervesEvent)({ level: "warn", component: "daemon", event: "daemon.sanctuary_gateway_start_retry", message: "Sanctuary gateway exited before readiness; retrying launch", meta: { attempt: attempt + 1, delayMs: GATEWAY_START_BACKOFF_MS[attempt], failure } });
|
|
556
|
+
await new Promise((resolve) => setTimeout(resolve, GATEWAY_START_BACKOFF_MS[attempt]));
|
|
549
557
|
}
|
|
550
|
-
child.unref();
|
|
551
|
-
let failed = false;
|
|
552
|
-
child.once("error", () => { failed = true; });
|
|
553
|
-
await this.#wait(async () => {
|
|
554
|
-
if (failed)
|
|
555
|
-
throw new Error("Sanctuary gateway launch failed");
|
|
556
|
-
return this.#ready();
|
|
557
|
-
}, GATEWAY_READY_TIMEOUT_MS);
|
|
558
558
|
}
|
|
559
559
|
else
|
|
560
560
|
await this.#wait(() => this.#ready(), GATEWAY_READY_TIMEOUT_MS);
|
|
561
561
|
}
|
|
562
|
+
/** One launch: resolves null once the gateway is ready, or "exited"/"failed to launch" if the process ends first. */
|
|
563
|
+
async #launchGatewayOnce(budgetMs) {
|
|
564
|
+
this.#remove(`${this.#epochRoot()}/readiness.json`);
|
|
565
|
+
const log = this.#gatewayLog();
|
|
566
|
+
let child;
|
|
567
|
+
try {
|
|
568
|
+
child = (0, node_child_process_1.spawn)(this.#p("/usr/local/bin/node"), [this.#p(`${ROOT}/package/dist/heart/daemon/sanctuary-telegram-authority-entry.js`), "--config", this.#p(`${ROOT}/active.json`)], {
|
|
569
|
+
cwd: "/", env: { PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" }, detached: true, stdio: ["ignore", log, log],
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
finally {
|
|
573
|
+
fs.closeSync(log);
|
|
574
|
+
}
|
|
575
|
+
child.unref();
|
|
576
|
+
let ended = null;
|
|
577
|
+
child.once("error", () => { ended = "failed to launch"; });
|
|
578
|
+
child.once("exit", (code, signal) => { ended = `exited (${signal ?? `code ${code}`})`; });
|
|
579
|
+
let readiness = "ready";
|
|
580
|
+
await this.#wait(async () => {
|
|
581
|
+
if (ended !== null) {
|
|
582
|
+
readiness = "ended";
|
|
583
|
+
return true;
|
|
584
|
+
}
|
|
585
|
+
return this.#ready();
|
|
586
|
+
}, budgetMs);
|
|
587
|
+
return readiness === "ended" ? ended : null;
|
|
588
|
+
}
|
|
589
|
+
/** The last of gateway.log, with anything token-shaped masked, for an error message. */
|
|
590
|
+
#gatewayLogTail() {
|
|
591
|
+
const file = this.#p(`${ROOT}/gateway.log`);
|
|
592
|
+
if (!fs.existsSync(file))
|
|
593
|
+
return "(no gateway.log)";
|
|
594
|
+
const size = fs.statSync(file).size;
|
|
595
|
+
const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
596
|
+
try {
|
|
597
|
+
const length = Math.min(size, GATEWAY_LOG_READ_BYTES);
|
|
598
|
+
const buffer = Buffer.alloc(length);
|
|
599
|
+
fs.readSync(fd, buffer, 0, length, size - length);
|
|
600
|
+
return buffer.toString("utf8").replace(/[0-9]{5,}:[A-Za-z0-9_-]{20,}/gu, "[token]").slice(-GATEWAY_LOG_TAIL_CHARS).trim() || "(gateway.log is empty)";
|
|
601
|
+
}
|
|
602
|
+
finally {
|
|
603
|
+
fs.closeSync(fd);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
562
606
|
/** The gateway's own output, kept root-only beside the token it guards and capped at two
|
|
563
607
|
* generations. It used to go nowhere, so a gateway that exited at startup said nothing
|
|
564
608
|
* (D-036: finding "host cgroup entry is invalid" took a hand-run gateway). */
|
|
@@ -199,6 +199,36 @@ async function readBoundedFileResponse(response) {
|
|
|
199
199
|
const contentType = response.headers.get("content-type");
|
|
200
200
|
return { body: Buffer.concat(chunks, total), ...(contentType ? { contentType } : {}) };
|
|
201
201
|
}
|
|
202
|
+
const GET_ME_BACKOFF_MS = [5_000, 10_000, 20_000, 30_000];
|
|
203
|
+
const NETWORK_FAILURE = /fetch failed|ECONNRESET|ECONNREFUSED|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|ENETUNREACH|EHOSTUNREACH|EPIPE|socket hang up|network|timed out/iu;
|
|
204
|
+
/** A getMe failure worth another try: Telegram 5xx, or no HTTP answer at all (the client reports a
|
|
205
|
+
* transport failure as a status-less TelegramApiError). Auth and other 4xx answers are never retried. */
|
|
206
|
+
function isTransientTelegramFailure(error) {
|
|
207
|
+
if (!(error instanceof telegram_client_1.TelegramApiError))
|
|
208
|
+
return false;
|
|
209
|
+
if (error.status !== null)
|
|
210
|
+
return error.status >= 500;
|
|
211
|
+
return error.errorCode === null && NETWORK_FAILURE.test(error.message);
|
|
212
|
+
}
|
|
213
|
+
async function getMeWithRetry(api, sleep) {
|
|
214
|
+
for (let attempt = 0;; attempt += 1) {
|
|
215
|
+
try {
|
|
216
|
+
return await api.request("getMe", {});
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
if (attempt >= GET_ME_BACKOFF_MS.length || !isTransientTelegramFailure(error))
|
|
220
|
+
throw error;
|
|
221
|
+
(0, runtime_1.emitNervesEvent)({
|
|
222
|
+
level: "warn",
|
|
223
|
+
component: "daemon",
|
|
224
|
+
event: "daemon.sanctuary_authority_getme_retry",
|
|
225
|
+
message: "Telegram getMe failed with a transient error; retrying",
|
|
226
|
+
meta: { attempt: attempt + 1, delayMs: GET_ME_BACKOFF_MS[attempt] },
|
|
227
|
+
});
|
|
228
|
+
await sleep(GET_ME_BACKOFF_MS[attempt]);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
202
232
|
async function startSanctuaryTelegramAuthority(options) {
|
|
203
233
|
(0, runtime_1.emitNervesEvent)({ component: "daemon", event: "daemon.sanctuary_authority_boot_requested", message: "Sanctuary authority boot requested; prerequisite verification pending" });
|
|
204
234
|
const expectedUid = options.expectedUid ?? 0;
|
|
@@ -245,7 +275,7 @@ async function startSanctuaryTelegramAuthority(options) {
|
|
|
245
275
|
const publicKey = (0, node_crypto_1.createPublicKey)(privateKey);
|
|
246
276
|
if (!options.retireOnly) {
|
|
247
277
|
api = (options.createApi ?? ((value) => (0, telegram_client_1.createTelegramBotApi)({ token: value })))(token);
|
|
248
|
-
const identity = await api.
|
|
278
|
+
const identity = await getMeWithRetry(api, options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))));
|
|
249
279
|
if (!isObject(identity) || String(identity.id) !== config.botId) {
|
|
250
280
|
throw new Error("Sanctuary Telegram authority bot identity changed");
|
|
251
281
|
}
|
|
@@ -520,6 +520,17 @@ function createSanctuaryTelegramAuthorityServer(options) {
|
|
|
520
520
|
connection.setEncoding("utf8");
|
|
521
521
|
connection.setTimeout(connectionTimeoutMs, () => connection.destroy());
|
|
522
522
|
connection.once("close", () => connections.delete(connection));
|
|
523
|
+
// A client that vanishes mid-response (reset, timeout, crash) surfaces as EPIPE/ECONNRESET on this
|
|
524
|
+
// socket; unhandled, that killed the whole gateway. Log it and let the connection close.
|
|
525
|
+
connection.on("error", (error) => {
|
|
526
|
+
(0, runtime_1.emitNervesEvent)({
|
|
527
|
+
level: "warn",
|
|
528
|
+
component: "daemon",
|
|
529
|
+
event: "daemon.sanctuary_authority_connection_error",
|
|
530
|
+
message: "Sanctuary authority client connection failed; dropping it",
|
|
531
|
+
meta: { code: error.code ?? "unknown" },
|
|
532
|
+
});
|
|
533
|
+
});
|
|
523
534
|
let buffer = "";
|
|
524
535
|
connection.on("data", (chunk) => {
|
|
525
536
|
buffer += chunk;
|
|
@@ -573,20 +584,22 @@ function createSanctuaryTelegramAuthorityServer(options) {
|
|
|
573
584
|
const response = dispatchTail.then(async () => {
|
|
574
585
|
try {
|
|
575
586
|
const result = await options.dispatch(frame.method, frame.params);
|
|
576
|
-
connection.
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
587
|
+
if (!connection.destroyed)
|
|
588
|
+
connection.write(`${JSON.stringify({
|
|
589
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
590
|
+
id: frame.id,
|
|
591
|
+
ok: true,
|
|
592
|
+
result,
|
|
593
|
+
})}\n`);
|
|
582
594
|
}
|
|
583
595
|
catch {
|
|
584
|
-
connection.
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
596
|
+
if (!connection.destroyed)
|
|
597
|
+
connection.write(`${JSON.stringify({
|
|
598
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
599
|
+
id: frame.id,
|
|
600
|
+
ok: false,
|
|
601
|
+
error: { message: "Sanctuary authority request failed" },
|
|
602
|
+
})}\n`);
|
|
590
603
|
}
|
|
591
604
|
});
|
|
592
605
|
dispatchTail = response;
|
|
@@ -53,6 +53,7 @@ const session_events_1 = require("../heart/session-events");
|
|
|
53
53
|
const runtime_1 = require("../nerves/runtime");
|
|
54
54
|
const await_parser_1 = require("../heart/awaiting/await-parser");
|
|
55
55
|
const await_alert_1 = require("../heart/awaiting/await-alert");
|
|
56
|
+
const friends_1 = require("@ouro.bot/friends");
|
|
56
57
|
const a2a_await_delivery_1 = require("../heart/awaiting/a2a-await-delivery");
|
|
57
58
|
const pending_1 = require("../mind/pending");
|
|
58
59
|
const router_1 = require("../heart/external-events/router");
|
|
@@ -97,6 +98,15 @@ function relationshipScopeFor(ctx, awaitName) {
|
|
|
97
98
|
}
|
|
98
99
|
return { session: ctx.currentSession, requestId: ctx.relationshipAuthorization?.requestId };
|
|
99
100
|
}
|
|
101
|
+
function readAwaitDoneDefinition(agentRoot, name) {
|
|
102
|
+
const filePath = awaitDoneFilePath(agentRoot, name);
|
|
103
|
+
try {
|
|
104
|
+
return (0, await_parser_1.parseAwaitFile)(fs.readFileSync(filePath, "utf-8"), filePath);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
100
110
|
function readAwaitDefinition(agentRoot, name) {
|
|
101
111
|
const filePath = awaitFilePath(agentRoot, name);
|
|
102
112
|
try {
|
|
@@ -135,6 +145,169 @@ function resolveDeliveryDeps(agentName) {
|
|
|
135
145
|
const deps = injected.buildDeliveryDeps ? injected.buildDeliveryDeps(agentName) : defaultDeliveryDeps(agentName);
|
|
136
146
|
return { ...deps, deliverers: { a2a: (0, a2a_await_delivery_1.createA2AAwaitOwnerDeliverer)(agentName), ...deps.deliverers } };
|
|
137
147
|
}
|
|
148
|
+
const ASK_OWNER_MIN_CHOICES = 2;
|
|
149
|
+
const ASK_OWNER_MAX_CHOICES = 4;
|
|
150
|
+
function oneLine(value) {
|
|
151
|
+
return value.replace(/\s+/gu, " ").trim();
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Validates an ask_owner request. The question and choices are collapsed to single lines because they are archived as
|
|
155
|
+
* frontmatter, and choices may not contain "|" because the archive joins them with " | ".
|
|
156
|
+
*/
|
|
157
|
+
function parseAskOwner(question, choices) {
|
|
158
|
+
const cleanQuestion = typeof question === "string" ? oneLine(question) : "";
|
|
159
|
+
if (!cleanQuestion)
|
|
160
|
+
return { ok: false, error: "question is required for ask_owner: put the decision to the owner in plain words" };
|
|
161
|
+
if (!Array.isArray(choices) || choices.length < ASK_OWNER_MIN_CHOICES || choices.length > ASK_OWNER_MAX_CHOICES) {
|
|
162
|
+
return { ok: false, error: `ask_owner needs ${ASK_OWNER_MIN_CHOICES} to ${ASK_OWNER_MAX_CHOICES} choices` };
|
|
163
|
+
}
|
|
164
|
+
const cleanChoices = choices.map((choice) => typeof choice === "string" ? oneLine(choice) : "");
|
|
165
|
+
if (cleanChoices.some((choice) => !choice))
|
|
166
|
+
return { ok: false, error: "every ask_owner choice must be a non-empty string" };
|
|
167
|
+
if (cleanChoices.some((choice) => choice.includes("|")))
|
|
168
|
+
return { ok: false, error: 'ask_owner choices must not contain "|"' };
|
|
169
|
+
return { ok: true, question: cleanQuestion, choices: cleanChoices };
|
|
170
|
+
}
|
|
171
|
+
function formatOwnerQuestion(question, choices) {
|
|
172
|
+
return `${question}\n\n${choices.map((choice) => `- ${choice}`).join("\n")}`;
|
|
173
|
+
}
|
|
174
|
+
function failedAskOwner(agentName, name, error) {
|
|
175
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
176
|
+
(0, runtime_1.emitNervesEvent)({ level: "error", component: "repertoire", event: "repertoire.await_ask_owner_error", message: "ask_owner could not reach the owner", meta: { agent: agentName, name, error: detail } });
|
|
177
|
+
return JSON.stringify({ error: `the owner message could not be sent (${detail}); the await is still pending, so ask_owner can be retried` });
|
|
178
|
+
}
|
|
179
|
+
/** At most this many ask_owner closures per requester in a rolling day, so one requester cannot make the Butler pester the owner. */
|
|
180
|
+
const ASK_OWNER_DAILY_LIMIT = 3;
|
|
181
|
+
const ASK_OWNER_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
182
|
+
function askLedgerPath(agentRoot) {
|
|
183
|
+
return path.join(awaitingDir(agentRoot), ".asks.jsonl");
|
|
184
|
+
}
|
|
185
|
+
/** Every ask_owner closure is appended here, because the archive is overwritten when an await name is reused. */
|
|
186
|
+
function recordAsk(agentRoot, entry) {
|
|
187
|
+
fs.mkdirSync(awaitingDir(agentRoot), { recursive: true });
|
|
188
|
+
fs.appendFileSync(askLedgerPath(agentRoot), `${JSON.stringify(entry)}\n`, "utf-8");
|
|
189
|
+
}
|
|
190
|
+
function recentAskCount(agentRoot, filer, now) {
|
|
191
|
+
let raw;
|
|
192
|
+
try {
|
|
193
|
+
raw = fs.readFileSync(askLedgerPath(agentRoot), "utf-8");
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
return 0;
|
|
197
|
+
}
|
|
198
|
+
return raw.split("\n").filter((line) => {
|
|
199
|
+
try {
|
|
200
|
+
const entry = JSON.parse(line);
|
|
201
|
+
return (entry.filer ?? null) === filer && typeof entry.at === "string" && now - Date.parse(entry.at) < ASK_OWNER_WINDOW_MS;
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
}).length;
|
|
207
|
+
}
|
|
208
|
+
const askOwnerInFlight = new Set();
|
|
209
|
+
/** A friend store that cannot be read makes the filer unknown, which is never the owner; it must not fail the ask. */
|
|
210
|
+
async function lookupFiler(agentRoot, friendId) {
|
|
211
|
+
try {
|
|
212
|
+
const friend = friendId ? await new friends_1.FileFriendStore(path.join(agentRoot, "friends")).get(friendId) : null;
|
|
213
|
+
return { isOwner: Boolean(friend && friend.admissionState === "active" && friend.trustLevel === "family" && friend.capabilityProfileId === "sanctuary-owner"), name: friend?.name ?? null };
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return { isOwner: false, name: null };
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
function delivered(alert) {
|
|
220
|
+
return alert?.delivery?.status === "delivered_now" || alert?.delivery?.status === "queued_for_later";
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* The await's condition cannot be met without the owner deciding. The owner is asked once, as the agent, in the
|
|
224
|
+
* owner's own chat, and only then is the await archived as asked_owner. One notice id per await instance ties every
|
|
225
|
+
* path together: the A2A owner delivery, the direct owner message and the replay sink all dedupe on it, so a retry after
|
|
226
|
+
* a crash between send and archive sends nothing twice. An await filed from A2A goes through the A2A owner delivery,
|
|
227
|
+
* which writes the owner notice (or the replay sink during a replay window); the direct notice is only the fallback
|
|
228
|
+
* when that path blocked or failed. When someone other than the owner filed the await, the text says whose request it is
|
|
229
|
+
* about, and a non-A2A filer is also told that the owner was asked (the obligation stays open if that could not be said).
|
|
230
|
+
*/
|
|
231
|
+
async function askOwnerTool(name, observation, question, choices, existing, agentRoot, agentName) {
|
|
232
|
+
const parsed = parseAskOwner(question, choices);
|
|
233
|
+
if (!parsed.ok)
|
|
234
|
+
return JSON.stringify({ error: parsed.error });
|
|
235
|
+
const inFlightKey = `${agentRoot}\0${name}`;
|
|
236
|
+
if (askOwnerInFlight.has(inFlightKey))
|
|
237
|
+
return JSON.stringify({ error: `await "${name}" is already being asked; wait for the owner's reply instead of asking again` });
|
|
238
|
+
askOwnerInFlight.add(inFlightKey);
|
|
239
|
+
try {
|
|
240
|
+
const filerId = existing.filed_for_friend_id ?? null;
|
|
241
|
+
const filer = await lookupFiler(agentRoot, filerId);
|
|
242
|
+
if (!filer.isOwner && recentAskCount(agentRoot, filerId, Date.now()) >= ASK_OWNER_DAILY_LIMIT) {
|
|
243
|
+
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` });
|
|
244
|
+
}
|
|
245
|
+
const isA2A = existing.filed_from === "a2a";
|
|
246
|
+
const noticeId = `await:${name}:asked_owner:${String(existing.created_at)}`;
|
|
247
|
+
const question = formatOwnerQuestion(parsed.question, parsed.choices);
|
|
248
|
+
const ownerText = filer.isOwner ? question : (0, a2a_await_delivery_1.askedOwnerText)(question, filer.name ?? (isA2A ? "a connected agent" : "a friend"));
|
|
249
|
+
let alert = null;
|
|
250
|
+
let sent = false;
|
|
251
|
+
if (isA2A) {
|
|
252
|
+
try {
|
|
253
|
+
alert = await (0, await_alert_1.deliverAwaitAlert)({ awaitFile: { ...existing, alert: "a2a" }, reason: "asked_owner", observation: question, content: question, deliveryId: noticeId, agentRoot, agentName, deliveryDeps: resolveDeliveryDeps(agentName) });
|
|
254
|
+
}
|
|
255
|
+
catch (error) {
|
|
256
|
+
(0, runtime_1.emitNervesEvent)({ level: "error", component: "repertoire", event: "repertoire.await_alert_error", message: "await alert delivery threw", meta: { agent: agentName, name, error: error instanceof Error ? error.message : String(error) } });
|
|
257
|
+
}
|
|
258
|
+
sent = delivered(alert);
|
|
259
|
+
}
|
|
260
|
+
if (!sent) {
|
|
261
|
+
try {
|
|
262
|
+
await (injected.notifyOwner ? injected.notifyOwner(agentName) : (0, a2a_await_delivery_1.defaultNotifyOwner)(agentName))({ noticeId, text: ownerText });
|
|
263
|
+
}
|
|
264
|
+
catch (error) {
|
|
265
|
+
return failedAskOwner(agentName, name, error);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
let friendNotice = null;
|
|
269
|
+
const tellsFriend = !isA2A && !filer.isOwner && Boolean(filerId);
|
|
270
|
+
if (tellsFriend) {
|
|
271
|
+
try {
|
|
272
|
+
friendNotice = await (0, await_alert_1.deliverAwaitAlert)({ awaitFile: existing, reason: "asked_owner", observation: observation.trim(), content: `About "${existing.condition ?? name}": I have asked the owner how to proceed and will let you know what they decide.`, deliveryId: `${noticeId}:filer`, agentRoot, agentName, deliveryDeps: resolveDeliveryDeps(agentName) });
|
|
273
|
+
}
|
|
274
|
+
catch (error) {
|
|
275
|
+
(0, runtime_1.emitNervesEvent)({ level: "error", component: "repertoire", event: "repertoire.await_alert_error", message: "await alert delivery threw", meta: { agent: agentName, name, error: error instanceof Error ? error.message : String(error) } });
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
const askedAt = new Date().toISOString();
|
|
279
|
+
const archive = archiveAwait(agentRoot, name, {
|
|
280
|
+
status: "asked_owner",
|
|
281
|
+
asked_at: askedAt,
|
|
282
|
+
ask_question: parsed.question,
|
|
283
|
+
ask_choices: parsed.choices.join(" | "),
|
|
284
|
+
resolution_observation: observation.trim(),
|
|
285
|
+
});
|
|
286
|
+
/* v8 ignore next -- defensive: archiveAwait only fails on the file-disappears-mid-call race already covered by v8 ignore inside archiveAwait @preserve */
|
|
287
|
+
if (!archive.ok)
|
|
288
|
+
return JSON.stringify({ error: archive.error });
|
|
289
|
+
recordAsk(agentRoot, { filer: filerId, name, at: askedAt });
|
|
290
|
+
if (!tellsFriend || delivered(friendNotice))
|
|
291
|
+
fulfillAwaitObligation(agentRoot, archive.file);
|
|
292
|
+
(0, runtime_1.emitNervesEvent)({
|
|
293
|
+
component: "repertoire",
|
|
294
|
+
event: "repertoire.await_asked_owner",
|
|
295
|
+
message: "await closed by asking the owner",
|
|
296
|
+
meta: { agent: agentName, name, choices: parsed.choices.length },
|
|
297
|
+
});
|
|
298
|
+
const summary = (result) => result ? { attempted: result.attempted, status: result.delivery?.status ?? null, skipped: result.skipped ?? null } : null;
|
|
299
|
+
return JSON.stringify({
|
|
300
|
+
verdict: "ask_owner",
|
|
301
|
+
asked: true,
|
|
302
|
+
archived: awaitDoneFilePath(agentRoot, name),
|
|
303
|
+
alert: summary(alert),
|
|
304
|
+
...(tellsFriend ? { filerNotice: summary(friendNotice) } : {}),
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
finally {
|
|
308
|
+
askOwnerInFlight.delete(inFlightKey);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
138
311
|
function fileAwait(args, agentRoot, agentName, sessionFriendId, sessionChannel, sessionKey, requestId) {
|
|
139
312
|
const nameError = validateName(args.name);
|
|
140
313
|
if (nameError)
|
|
@@ -245,23 +418,28 @@ function fulfillAwaitObligation(agentRoot, awaitFile) {
|
|
|
245
418
|
if (awaitFile.obligation_id)
|
|
246
419
|
(0, obligations_1.fulfillObligation)(agentRoot, awaitFile.obligation_id);
|
|
247
420
|
}
|
|
248
|
-
async function resolveAwaitTool(name, verdict, observation, agentRoot, agentName) {
|
|
421
|
+
async function resolveAwaitTool(name, verdict, observation, agentRoot, agentName, ask) {
|
|
249
422
|
const nameError = validateName(name);
|
|
250
423
|
if (nameError)
|
|
251
424
|
return JSON.stringify({ error: nameError });
|
|
252
425
|
const existing = readAwaitDefinition(agentRoot, name);
|
|
253
426
|
if (!existing) {
|
|
427
|
+
if (verdict === "ask_owner" && readAwaitDoneDefinition(agentRoot, name)?.status === "asked_owner") {
|
|
428
|
+
return JSON.stringify({ error: `await "${name}" has already asked the owner and is closed; wait for the owner's reply instead of asking again` });
|
|
429
|
+
}
|
|
254
430
|
return JSON.stringify({ error: `await "${name}" not found in awaiting/` });
|
|
255
431
|
}
|
|
256
432
|
if (existing.status !== "pending") {
|
|
257
433
|
return JSON.stringify({ error: `await "${name}" is not pending (status: ${existing.status})` });
|
|
258
434
|
}
|
|
259
|
-
if (verdict !== "yes" && verdict !== "no") {
|
|
260
|
-
return JSON.stringify({ error: `verdict must be exactly "yes" or "
|
|
435
|
+
if (verdict !== "yes" && verdict !== "no" && verdict !== "ask_owner") {
|
|
436
|
+
return JSON.stringify({ error: `verdict must be exactly "yes", "no", or "ask_owner" (got ${JSON.stringify(verdict)}); use "no" with an observation while still waiting` });
|
|
261
437
|
}
|
|
262
438
|
if (!observation || !observation.trim()) {
|
|
263
439
|
return JSON.stringify({ error: "observation is required" });
|
|
264
440
|
}
|
|
441
|
+
if (verdict === "ask_owner")
|
|
442
|
+
return askOwnerTool(name, observation, ask?.question, ask?.choices, existing, agentRoot, agentName);
|
|
265
443
|
if (verdict === "no") {
|
|
266
444
|
// Update runtime state via recordAwaitCheck-style write
|
|
267
445
|
const { recordAwaitCheck } = await Promise.resolve().then(() => __importStar(require("../heart/awaiting/await-runtime-state")));
|
|
@@ -535,13 +713,15 @@ exports.awaitingToolDefinitions = [
|
|
|
535
713
|
type: "function",
|
|
536
714
|
function: {
|
|
537
715
|
name: "resolve_await",
|
|
538
|
-
description: "Resolve a pending await with a verdict. verdict='yes' archives and fires the alert. verdict='no' records the observation and continues polling; still pending is always 'no'.",
|
|
716
|
+
description: "Resolve a pending await with a verdict. verdict='yes' archives and fires the alert. verdict='no' records the observation and continues polling; still pending is always 'no'. verdict='ask_owner' (with question and choices) closes the await and asks my owner to decide, exactly once: use it when the condition cannot be met without the owner deciding, for example a confirmed dead download. Never use ask_owner for \"not yet\" or a merely suspected problem; that is 'no'.",
|
|
539
717
|
parameters: {
|
|
540
718
|
type: "object",
|
|
541
719
|
properties: {
|
|
542
720
|
name: { type: "string", description: "Await name (filename stem)." },
|
|
543
|
-
verdict: { type: "string", enum: ["yes", "no"], description: "Exactly 'yes' if the condition is met. Exactly 'no' for anything else, including still pending, not yet, or unable to tell: send 'no' with an observation and polling continues." },
|
|
721
|
+
verdict: { type: "string", enum: ["yes", "no", "ask_owner"], description: "Exactly 'yes' if the condition is met. Exactly 'no' for anything else, including still pending, not yet, or unable to tell: send 'no' with an observation and polling continues. 'ask_owner' only when the condition cannot be met without the owner deciding; it needs question and choices." },
|
|
544
722
|
observation: { type: "string", description: "One-line summary of what I saw this tick." },
|
|
723
|
+
question: { type: "string", description: "Only for ask_owner: the decision I need from my owner, in plain words." },
|
|
724
|
+
choices: { type: "array", items: { type: "string" }, minItems: 2, maxItems: 4, description: "Only for ask_owner: 2 to 4 short options the owner can pick from." },
|
|
545
725
|
},
|
|
546
726
|
required: ["name", "verdict", "observation"],
|
|
547
727
|
},
|
|
@@ -575,7 +755,7 @@ exports.awaitingToolDefinitions = [
|
|
|
575
755
|
if (!binding?.active)
|
|
576
756
|
return JSON.stringify({ error: "resolve_await is limited to the current relationship request or bound external event" });
|
|
577
757
|
}
|
|
578
|
-
return resolveAwaitTool(a.name, a.verdict, a.observation, agentRoot, agentName);
|
|
758
|
+
return resolveAwaitTool(a.name, a.verdict, a.observation, agentRoot, agentName, { question: a.question, choices: a.choices });
|
|
579
759
|
},
|
|
580
760
|
riskProfile: {
|
|
581
761
|
mutates: ["durable_state_write", "external_side_effect"],
|
|
@@ -54,5 +54,6 @@ function buildAwaitTurnMessage(options) {
|
|
|
54
54
|
}
|
|
55
55
|
lines.push("");
|
|
56
56
|
lines.push("look around and decide. if the condition is met, call resolve_await with verdict='yes' and a one-line observation. otherwise call resolve_await with verdict='no' and a one-line observation of what i saw this tick.");
|
|
57
|
+
lines.push("verdicts: 'yes' (condition met), 'no' (not yet or unsure), 'ask_owner' (only when the condition cannot be met without my owner deciding, e.g. a confirmed dead download; give a plain question and 2-4 choices).");
|
|
57
58
|
return lines.join("\n");
|
|
58
59
|
}
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ouro.bot/cli",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.867",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@ouro.bot/cli",
|
|
9
|
-
"version": "0.1.0-alpha.
|
|
9
|
+
"version": "0.1.0-alpha.867",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@anthropic-ai/sdk": "^0.78.0",
|
|
12
12
|
"@azure/identity": "^4.13.0",
|