@ouro.bot/cli 0.1.0-alpha.853 → 0.1.0-alpha.854

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/changelog.json CHANGED
@@ -1,6 +1,14 @@
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.854",
6
+ "changes": [
7
+ "The Butler gets missing episodes in one call: media_fill_missing re-reads Sonarr, matches releases to episodes only on evidence (episode title, season plus air year, or a Sonarr parse that agrees with the TVDB or scene code), grabs, imports by title, replaces a stalled download only after the stall is confirmed and a seeded replacement is grabbed, searches least recently searched seasons first so repeat calls reach every season, and reports what is on the shelf, in flight, deferred and not found with indexer evidence.",
8
+ "Long tool-heavy turns get more steps while tools make progress (base 8, +4 per extension, ceiling 24); when the budget runs out the agent settles with a progress report instead of saying it ran out of steps.",
9
+ "Tools refuse invented parameters by name: media tool schemas are closed, and the engine names an unknown parameter and an enum's allowed values. MCP tools can declare a longer call timeout for slow indexer searches."
10
+ ]
11
+ },
4
12
  {
5
13
  "version": "0.1.0-alpha.853",
6
14
  "changes": [
@@ -1,5 +1,5 @@
1
1
  {
2
- "runtimeVersion": "0.1.0-alpha.853",
2
+ "runtimeVersion": "0.1.0-alpha.854",
3
3
  "bundleSchemaVersion": 3,
4
4
  "lastUpdated": "2026-09-03T00:00:00.000Z"
5
5
  }
@@ -1145,9 +1145,394 @@ export async function mediaManualImport(a) {
1145
1145
  return { mode: "import", imported: a.files.map((f) => ({ path: f.path, episode_ids: f.episode_ids })), ...(replacing.length ? { replacing } : {}), command: { id: c?.id ?? null, name: c?.name ?? "ManualImport", status: c?.status ?? null } }
1146
1146
  }
1147
1147
 
1148
+ // ------------------------------------------------------- tool: fill missing
1149
+ //
1150
+ // The whole "get the missing episodes" job as one deterministic pass, so a long
1151
+ // request no longer depends on the model chaining a dozen calls correctly
1152
+ // (2026-10-03: The Chef Show took an owner hint, a wrong pack, an invented
1153
+ // parameter and the step cap). Each call re-reads Sonarr, so it is safe to call
1154
+ // again: in-flight grabs are never repeated, and once nothing is missing it says
1155
+ // stop. Release names follow streaming numbering, so a release is matched to
1156
+ // episodes only on evidence: an episode title, a season plus the air year, or a
1157
+ // Sonarr parse whose own numbering agrees with the episode's TVDB or scene code.
1158
+
1159
+ const FILL_MAX_GRABS = 3
1160
+ const FILL_DEADLINE_MS = 150_000
1161
+ const FILL_RECENT_GRAB_MS = 15 * 60_000
1162
+ const FILL_EVIDENCE_ROWS = 5
1163
+ // A torrent with no ETA is called stalled only after its remaining size has not moved for this long.
1164
+ const FILL_STALL_CONFIRM_MS = 30 * 60_000
1165
+
1166
+ // Years in a release name, ignoring resolutions such as 1920x1080 and 2160p.
1167
+ const yearsIn = (title) => new Set((String(title ?? "").match(/(?<![0-9x])(?:19|20)[0-9]{2}(?![0-9px])/gi) ?? []).map(Number))
1168
+ const airYear = (e) => (e.airDate ? Number(String(e.airDate).slice(0, 4)) : null)
1169
+ const hasWord = (haystack, needle) => needle.length > 0 && ` ${haystack} `.includes(` ${needle} `)
1170
+ const rejectionTexts = (r) => (Array.isArray(r.rejections) ? r.rejections.map((x) => (typeof x === "string" ? x : x?.reason ?? String(x))) : [])
1171
+ // "Doctor Who (2005)" and "The Office (US)" are named without the suffix in most releases.
1172
+ export const seriesBaseTitle = (title) => normalizeTitle(String(title ?? "").replace(/\s*\((?:19|20)[0-9]{2}\)\s*$/, "").replace(/\s*\((?:us|uk|au|nz|ca)\)\s*$/i, ""))
1173
+
1174
+ // An episode title is evidence only when it is distinctive enough not to appear by chance:
1175
+ // several words, or one long word that is not a generic release term.
1176
+ const GENERIC_TITLE_WORDS = new Set(["special", "specials", "episode", "finale", "premiere", "extended", "complete", "unknown", "untitled", "trailer", "preview", "bonus"])
1177
+ function episodeTitleIn(releaseNorm, episode) {
1178
+ const t = normalizeTitle(episode.title)
1179
+ const distinctive = t.includes(" ") ? t.length >= 6 : t.length >= 7 && !GENERIC_TITLE_WORDS.has(t) && !/^[0-9]+$/.test(t)
1180
+ return distinctive && hasWord(releaseNorm, t)
1181
+ }
1182
+
1183
+ // Season named in a release title: "Season 2", "Temporada 2", "S02" (not "S02E01").
1184
+ export function seasonInTitle(title) {
1185
+ const s = String(title ?? "")
1186
+ const word = s.match(/\b(?:season|temporada|saison|staffel)\s*0*([0-9]{1,2})\b/i)
1187
+ if (word) return Number(word[1])
1188
+ const code = s.match(/\bS0*([0-9]{1,2})(?![0-9]|\s*E[0-9])\b/i)
1189
+ return code ? Number(code[1]) : null
1190
+ }
1191
+
1192
+ // Sonarr's mapping is trusted only when the numbering in the name is the episode's own or scene code.
1193
+ function parseAgrees(release, episode) {
1194
+ const season = release.seasonNumber
1195
+ const numbers = Array.isArray(release.episodeNumbers) ? release.episodeNumbers : []
1196
+ const codes = [[episode.seasonNumber, episode.episodeNumber], [episode.sceneSeasonNumber, episode.sceneEpisodeNumber]]
1197
+ return codes.some(([s, e]) => Number.isInteger(s) && s === season && (numbers.length ? numbers.includes(e) : Boolean(release.fullSeason)))
1198
+ }
1199
+
1200
+ // Which wanted episodes one release really holds, and on what evidence; or why it is unusable.
1201
+ export function matchReleaseToEpisodes(release, { seriesId, seriesTitle, seriesYear, wanted, have }) {
1202
+ const title = release.title ?? ""
1203
+ const norm = normalizeTitle(title)
1204
+ const base = seriesBaseTitle(seriesTitle)
1205
+ const rejections = rejectionTexts(release)
1206
+ if (!hasWord(norm, base)) return { reject: "other_series" }
1207
+ if (release.mappedSeriesId && seriesId && release.mappedSeriesId !== seriesId) return { reject: "other_series" }
1208
+ if (rejections.some((x) => /blocklist/i.test(x))) return { reject: "blocklisted" }
1209
+ if (rejections.some((x) => /not wanted in profile|below .*minimum|custom format/i.test(x))) return { reject: "quality_not_wanted" }
1210
+ const wantedYears = new Set(wanted.map(airYear).filter(Boolean))
1211
+ // The series' own premiere year ("Show (2019) Season 2") says nothing about which season it is,
1212
+ // unless the missing episodes aired that year too.
1213
+ const years = new Set([...yearsIn(title)].filter((y) => y !== seriesYear || wantedYears.has(y)))
1214
+ if (years.size && ![...years].some((y) => wantedYears.has(y))) return { reject: "year_mismatch", years: [...years] }
1215
+ // Episode titles are looked for in the name with the series name taken out.
1216
+ const rest = ` ${norm} `.replace(` ${base} `, " ").trim()
1217
+ const byTitle = wanted.filter((e) => episodeTitleIn(rest, e))
1218
+ if (byTitle.length) return { episode_ids: byTitle.map((e) => e.id), basis: "episode_title" }
1219
+ if (have.some((e) => episodeTitleIn(rest, e))) return { reject: "holds_episodes_we_have" }
1220
+ const wantedById = new Map(wanted.map((e) => [e.id, e]))
1221
+ const mapped = (release.mappedEpisodeInfo ?? []).map((e) => e.id).filter((id) => id !== undefined && id !== null)
1222
+ const mappedWanted = mapped.filter((id) => wantedById.has(id))
1223
+ if (mappedWanted.length) {
1224
+ if (mappedWanted.every((id) => parseAgrees(release, wantedById.get(id)))) return { episode_ids: mappedWanted, basis: "sonarr_parse" }
1225
+ return { reject: "parse_disagrees_with_numbering" }
1226
+ }
1227
+ // A season pack named with the season and the air year of the missing episodes
1228
+ // ("The Chef Show [Season 2] (2020)"), whatever Sonarr's scene mapping says it holds.
1229
+ const season = seasonInTitle(title) ?? (release.fullSeason ? release.seasonNumber ?? null : null)
1230
+ if (season !== null && years.size && !/\bE[0-9]{1,3}\b/i.test(title)) {
1231
+ const covered = wanted.filter((e) => e.seasonNumber === season && years.has(airYear(e)))
1232
+ if (covered.length) return { episode_ids: covered.map((e) => e.id), basis: "season_and_air_year" }
1233
+ }
1234
+ return { reject: mapped.length ? "holds_episodes_we_have" : "no_episode_match" }
1235
+ }
1236
+
1237
+ // Why a download is not going to finish on its own, or null while it may still be moving.
1238
+ // `confirmed` is false when the only sign is a missing ETA not yet seen to persist; such a
1239
+ // download is reported but never replaced.
1240
+ export function stallReason(row, nowMs, previous) {
1241
+ if (isStalledRow(row, nowMs)) return { stall: (row.sizeleft ?? 0) >= (row.size ?? 0) ? "never_started" : "unfinished_for_days", confirmed: true }
1242
+ const left = row.sizeleft ?? 0
1243
+ const ageHours = row.added ? (nowMs - Date.parse(row.added)) / 3_600_000 : 0
1244
+ const moving = row.status === "downloading" || row.status === "warning"
1245
+ const noEta = row.timeleft === undefined || row.timeleft === null || row.timeleft === "00:00:00"
1246
+ if (!(moving && left > 0 && noEta && ageHours >= STALL_AFTER_HOURS)) return null
1247
+ const unchanged = previous && previous.sizeleft === left && nowMs - previous.at >= FILL_STALL_CONFIRM_MS
1248
+ return { stall: "no_peers", confirmed: Boolean(unchanged) }
1249
+ }
1250
+
1251
+ // Remaining size per download, as first seen at that size, so a later call can tell stopped from slow.
1252
+ const progressSeen = new Map()
1253
+ const PROGRESS_FORGET_MS = 14 * 24 * 3_600_000
1254
+ function observeProgress(downloadId, sizeleft, nowMs) {
1255
+ for (const [k, v] of progressSeen) if (nowMs - v.at > PROGRESS_FORGET_MS) progressSeen.delete(k)
1256
+ const previous = progressSeen.get(downloadId)
1257
+ if (!previous || previous.sizeleft !== sizeleft) progressSeen.set(downloadId, { sizeleft, at: nowMs })
1258
+ return previous
1259
+ }
1260
+
1261
+ const importDone = (row) => (row.sizeleft ?? 0) === 0 || row.status === "completed"
1262
+ const importNeeded = (row) => importDone(row) && ["importPending", "importBlocked", "importFailed"].includes(row.trackedDownloadState)
1263
+
1264
+ async function seriesGrabHistory(seriesId) {
1265
+ const list = await sonarr("/history/series", { query: { seriesId, eventType: "grabbed" } })
1266
+ return Array.isArray(list) ? list : []
1267
+ }
1268
+
1269
+ async function runningImportFor(downloadId) {
1270
+ const list = await sonarr("/command")
1271
+ return (Array.isArray(list) ? list : []).some((c) => c.name === "ManualImport" && ["queued", "started"].includes(c.status)
1272
+ && (c.body?.files ?? []).some((f) => String(f.downloadId ?? "").toUpperCase() === String(downloadId).toUpperCase()))
1273
+ }
1274
+
1275
+ // Map a finished download's files to the wanted episodes by title. Only a file whose name
1276
+ // holds exactly one wanted episode title, and the only file holding it, is imported.
1277
+ async function importByTitle(seriesId, seriesTitle, downloadId, wantedEps) {
1278
+ if (await runningImportFor(downloadId)) return { action: "import_already_running", download_id: downloadId }
1279
+ const rows = await previewRows(seriesId, downloadId)
1280
+ const base = seriesBaseTitle(seriesTitle)
1281
+ const picks = []
1282
+ const unmatched = []
1283
+ for (const r of rows) {
1284
+ const rest = ` ${normalizeTitle(`${r.name ?? ""} ${r.relativePath ?? ""}`)} `.replace(` ${base} `, " ").trim()
1285
+ const ids = wantedEps.filter((e) => episodeTitleIn(rest, e)).map((e) => e.id)
1286
+ if (ids.length === 1) picks.push({ row: r, ids })
1287
+ else unmatched.push(r.name ?? r.path)
1288
+ }
1289
+ const counts = new Map()
1290
+ for (const p of picks) counts.set(p.ids[0], (counts.get(p.ids[0]) ?? 0) + 1)
1291
+ const unique = picks.filter((p) => counts.get(p.ids[0]) === 1)
1292
+ for (const p of picks) if (!unique.includes(p)) unmatched.push(p.row.name ?? p.row.path)
1293
+ if (!unique.length) {
1294
+ return { action: "import_needs_mapping", download_id: downloadId, unmatched_files: unmatched,
1295
+ note: "No file names an episode title. Map them with media_manual_import (preview, then import by title from media_episodes)." }
1296
+ }
1297
+ const files = unique.map(({ row: r, ids }) => ({ path: r.path, seriesId, episodeIds: ids, quality: r.quality, languages: r.languages, releaseGroup: r.releaseGroup ?? "",
1298
+ indexerFlags: r.indexerFlags ?? 0, releaseType: r.releaseType ?? "unknown", downloadId }))
1299
+ const c = await sonarr("/command", { method: "POST", body: { name: "ManualImport", files, importMode: "auto" } })
1300
+ return { action: "imported_by_title", download_id: downloadId, command_id: c?.id ?? null,
1301
+ files: unique.map(({ row: r, ids }) => ({ file: r.name ?? r.path, episode_ids: ids })), ...(unmatched.length ? { unmatched_files: unmatched } : {}) }
1302
+ }
1303
+
1304
+ const pct = (row) => ((row.size ?? 0) > 0 ? Math.round((((row.size ?? 0) - (row.sizeleft ?? 0)) / row.size) * 1000) / 10 : null)
1305
+ const fillRunning = new Set()
1306
+ // When each season or episode of a series was last searched, so repeat calls reach the ones a deadline cut off.
1307
+ const lastSearched = new Map()
1308
+ const FILL_ACTION_GRACE_MS = 45_000
1309
+
1310
+ export async function mediaFillMissing(a, opts = {}) {
1311
+ const found = await resolveLibraryItem({ kind: "series", service_id: a.service_id, title: a.series })
1312
+ if (!found.item) return { result: found.result, candidates: found.candidates, message: "Name the series by its Sonarr service_id or exact title." }
1313
+ const seriesId = found.item.id
1314
+ // One pass per series at a time: a retry after a timeout must not grab beside a pass still running.
1315
+ if (fillRunning.has(seriesId)) return { result: "already_running", series_id: seriesId, message: "A media_fill_missing pass for this series is still working. Wait a minute and call again; do not act by hand." }
1316
+ fillRunning.add(seriesId)
1317
+ try {
1318
+ return await fillPass(a, seriesId, found.item.title, opts)
1319
+ } finally {
1320
+ fillRunning.delete(seriesId)
1321
+ }
1322
+ }
1323
+
1324
+ async function fillPass(a, seriesId, seriesTitle, { nowMs = Date.now(), deadlineMs = FILL_DEADLINE_MS } = {}) {
1325
+ const startedAt = Date.now()
1326
+ const remainingMs = () => deadlineMs - (Date.now() - startedAt)
1327
+ const timeLeft = () => remainingMs() > 0
1328
+ // Grabs and imports may run a little past the search deadline, never past the harness timeout.
1329
+ const mayAct = () => remainingMs() > -FILL_ACTION_GRACE_MS
1330
+ const errors = []
1331
+ // Each step after the first reads is isolated: one failure is reported, the rest of the pass and its record survive.
1332
+ const attempt = async (step, fn) => {
1333
+ try { return await fn() } catch (e) { errors.push({ step, error: e.code ?? "failed", message: e.message }); return undefined }
1334
+ }
1335
+ const seriesYear = Number((await attempt("read series", () => sonarr(`/series/${seriesId}`)))?.year) || null
1336
+ const season = a.season_number === undefined || a.season_number === null ? null : Number(a.season_number)
1337
+ const scope = (await seriesEpisodes(seriesId, season ?? undefined)).filter((e) => (season === null ? e.seasonNumber > 0 : true))
1338
+ const aired = (e) => Boolean(e.airDateUtc ?? e.airDate) && Date.parse(e.airDateUtc ?? e.airDate) <= nowMs
1339
+ const missing = scope.filter((e) => !e.hasFile && aired(e))
1340
+ const wanted = missing.filter((e) => e.monitored)
1341
+ const have = scope.filter((e) => e.hasFile)
1342
+ const code = (e) => ({ episode_id: e.id, code: sxe(e), title: e.title ?? null, air_date: e.airDate ?? null })
1343
+ const base = { series_id: seriesId, series: seriesTitle, season_number: season, checked_at: new Date(nowMs).toISOString(),
1344
+ on_shelf: { count: have.length, of: scope.filter(aired).length },
1345
+ ...(missing.length > wanted.length ? { not_monitored_missing: missing.filter((e) => !e.monitored).map(code) } : {}) }
1346
+ if (!wanted.length) {
1347
+ return { ...base, goal_met: true, in_flight: [], not_found: [], actions: [],
1348
+ summary: `Nothing monitored is missing: ${have.length} of ${base.on_shelf.of} aired episodes are on the shelf.`,
1349
+ next_step: "Goal met. Stop here and tell the owner; do not search or grab anything else." }
1350
+ }
1351
+
1352
+ const actions = []
1353
+ const byId = new Map(wanted.map((e) => [e.id, e]))
1354
+ const queue = await itemQueue("series", seriesId)
1355
+ const history = await seriesGrabHistory(seriesId)
1356
+ const grabbedFor = new Map()
1357
+ for (const h of history) {
1358
+ // A client without download ids (e.g. a blackhole) is tracked by release title instead.
1359
+ const key = h.downloadId ? String(h.downloadId).toUpperCase() : h.sourceTitle ? `TITLE:${normalizeTitle(h.sourceTitle)}` : null
1360
+ if (!key) continue
1361
+ if (!grabbedFor.has(key)) grabbedFor.set(key, { ids: new Set(), date: h.date, title: h.sourceTitle })
1362
+ grabbedFor.get(key).ids.add(h.episodeId)
1363
+ }
1364
+ // Sonarr re-parses a tracked download's name, so its queue rows can point at the
1365
+ // wrong episodes; the grab history keeps the mapping the grab was made with.
1366
+ const downloads = new Map()
1367
+ for (const r of queue) {
1368
+ const key = String(r.downloadId ?? `row:${r.id}`).toUpperCase()
1369
+ if (!downloads.has(key)) downloads.set(key, { key, row: r, ids: new Set(grabbedFor.get(key)?.ids ?? []) })
1370
+ if (!grabbedFor.has(key)) downloads.get(key).ids.add(r.episodeId)
1371
+ }
1372
+ const queuedTitles = new Set(queue.map((r) => normalizeTitle(r.title)))
1373
+ for (const [key, g] of grabbedFor) {
1374
+ const recent = nowMs - Date.parse(g.date) < FILL_RECENT_GRAB_MS
1375
+ if (recent && !downloads.has(key) && !queuedTitles.has(normalizeTitle(g.title))) downloads.set(key, { key, row: null, ids: g.ids, title: g.title })
1376
+ }
1377
+
1378
+ let cutShort = false
1379
+ const covered = new Set()
1380
+ const stalled = []
1381
+ const inFlight = []
1382
+ for (const d of downloads.values()) {
1383
+ const ids = wanted.map((e) => e.id).filter((id) => d.ids.has(id))
1384
+ if (!ids.length) continue
1385
+ const entry = { title: d.row?.title ?? d.title ?? null, download_id: d.row?.downloadId ?? d.key, episodes: ids.map((id) => sxe(byId.get(id))) }
1386
+ if (!d.row) { inFlight.push({ ...entry, state: "just_grabbed" }); ids.forEach((id) => covered.add(id)); continue }
1387
+ if (importNeeded(d.row)) {
1388
+ const done = mayAct() ? await attempt(`import ${d.row.downloadId}`, () => importByTitle(seriesId, seriesTitle, d.row.downloadId, ids.map((id) => byId.get(id)))) : undefined
1389
+ if (done) actions.push(done)
1390
+ inFlight.push({ ...entry, state: done?.action === "import_needs_mapping" ? "downloaded_needs_mapping" : done ? "importing" : mayAct() ? "downloaded_import_failed" : "downloaded_import_next_call" })
1391
+ if (!done) cutShort = true
1392
+ ids.forEach((id) => covered.add(id))
1393
+ continue
1394
+ }
1395
+ const why = stallReason(d.row, nowMs, observeProgress(d.key, d.row.sizeleft ?? 0, nowMs))
1396
+ const info = { ...entry, percent: pct(d.row), queue_id: d.row.id, added: d.row.added ?? null }
1397
+ if (why) stalled.push({ ...info, ids, ...why })
1398
+ else { inFlight.push({ ...info, state: importDone(d.row) ? "importing" : "downloading" }); ids.forEach((id) => covered.add(id)) }
1399
+ }
1400
+
1401
+ // Search for everything not covered by a moving download, stalled ones included.
1402
+ const need = wanted.filter((e) => !covered.has(e.id))
1403
+ const searched = []
1404
+ const searchedSeasons = new Set()
1405
+ const searchedEpisodes = new Set()
1406
+ const candidates = new Map()
1407
+ const rejected = new Map()
1408
+ const inFlightTitles = new Set([...downloads.values()].map((d) => normalizeTitle(d.row?.title ?? d.title)).filter(Boolean))
1409
+ const consider = (raw) => {
1410
+ for (const r of Array.isArray(raw) ? raw : []) {
1411
+ if (!r?.guid || candidates.has(r.guid) || rejected.has(r.guid)) continue
1412
+ const m = matchReleaseToEpisodes(r, { seriesId, seriesTitle, seriesYear, wanted: need, have })
1413
+ const sameAsInFlight = inFlightTitles.has(normalizeTitle(r.title))
1414
+ if (m.reject || sameAsInFlight) { rejected.set(r.guid, { title: r.title ?? null, seeders: r.seeders ?? null, reason: sameAsInFlight ? "same_release_as_stalled_download" : m.reject }); continue }
1415
+ cacheRelease(`series:${r.guid}`, { ...releaseRow("series", r), service_id: seriesId, season_number: null, quality_raw: r.quality ?? null, languages_raw: r.languages ?? null })
1416
+ candidates.set(r.guid, { raw: r, ids: m.episode_ids, basis: m.basis })
1417
+ }
1418
+ }
1419
+ const searchTimeout = () => Math.max(5_000, Math.min(SEARCH_TIMEOUT_MS, remainingMs()))
1420
+ const hasSeededCandidate = (e) => [...candidates.values()].some((c) => (c.raw.seeders ?? 0) > 0 && c.ids.includes(e.id))
1421
+ const searchedAt = (key) => lastSearched.get(`${seriesId}:${key}`) ?? 0
1422
+ const markSearched = (key) => lastSearched.set(`${seriesId}:${key}`, Date.now())
1423
+ // Least recently searched first, so a deadline never starves the same seasons on every call.
1424
+ const seasons = [...new Set(need.map((e) => e.seasonNumber))].sort((x, y) => searchedAt(`s${x}`) - searchedAt(`s${y}`) || x - y)
1425
+ for (const s of seasons) {
1426
+ if (!timeLeft()) break
1427
+ searchedSeasons.add(s)
1428
+ markSearched(`s${s}`)
1429
+ searched.push(`Sonarr season search S${code2(s)}`)
1430
+ consider(await attempt(`season search S${code2(s)}`, () => sonarr("/release", { query: { seriesId, seasonNumber: s }, timeoutMs: searchTimeout() })))
1431
+ }
1432
+ const episodeOrder = need.filter((x) => !hasSeededCandidate(x)).sort((x, y) => searchedAt(`e${x.id}`) - searchedAt(`e${y.id}`))
1433
+ for (const e of episodeOrder.slice(0, MAX_EPISODE_SEARCHES)) {
1434
+ if (!timeLeft()) break
1435
+ searchedEpisodes.add(e.id)
1436
+ markSearched(`e${e.id}`)
1437
+ searched.push(`Sonarr episode search ${sxe(e)}`)
1438
+ consider(await attempt(`episode search ${sxe(e)}`, () => sonarr("/release", { query: { episodeId: e.id }, timeoutMs: searchTimeout() })))
1439
+ }
1440
+
1441
+ // Seeded releases first, then the ones that cover the most, then the best seeded.
1442
+ const ranked = [...candidates.values()].sort((x, y) =>
1443
+ Number((y.raw.seeders ?? 0) > 0) - Number((x.raw.seeders ?? 0) > 0) || y.ids.length - x.ids.length || (y.raw.seeders ?? 0) - (x.raw.seeders ?? 0))
1444
+ const remaining = new Set(need.map((e) => e.id))
1445
+ const stalledIds = new Set(stalled.flatMap((s) => s.ids))
1446
+ // Until a stall is confirmed on a later call, its episodes are not fetched a second time.
1447
+ const unconfirmedIds = new Set(stalled.filter((s) => !s.confirmed).flatMap((s) => s.ids))
1448
+ for (const s of stalled.filter((x) => !x.confirmed)) {
1449
+ s.alternative = ranked.some((c) => (c.raw.seeders ?? 0) > 0 && s.ids.every((id) => c.ids.includes(id)))
1450
+ }
1451
+ const deferred = new Set()
1452
+ let grabs = 0
1453
+ for (const c of ranked) {
1454
+ const seeded = (c.raw.seeders ?? 0) > 0
1455
+ // An unseeded release is no better than a stalled download: never fetch those episodes twice that way.
1456
+ const ids = c.ids.filter((id) => remaining.has(id) && !unconfirmedIds.has(id) && (seeded || !stalledIds.has(id)))
1457
+ if (!ids.length || !c.raw.quality) continue
1458
+ if (grabs >= FILL_MAX_GRABS || !mayAct()) { ids.forEach((id) => deferred.add(id)); cutShort ||= !mayAct(); continue }
1459
+ const body = { guid: c.raw.guid, indexerId: c.raw.indexerId, seriesId, episodeIds: ids, shouldOverride: true, quality: c.raw.quality, languages: Array.isArray(c.raw.languages) ? c.raw.languages : [] }
1460
+ const res = await attempt(`grab ${c.raw.title}`, async () => (await sonarr("/release", { method: "POST", body })) ?? {})
1461
+ if (!res) continue
1462
+ grabs += 1
1463
+ ids.forEach((id) => remaining.delete(id))
1464
+ actions.push({ action: "grabbed", title: c.raw.title ?? null, indexer: c.raw.indexer ?? null, seeders: c.raw.seeders ?? null, matched_by: c.basis,
1465
+ episodes: ids.map((id) => sxe(byId.get(id))), download_id: res.downloadId ?? null, ...(seeded ? {} : { may_never_finish: true }) })
1466
+ inFlight.push({ title: c.raw.title ?? null, download_id: res.downloadId ?? null, episodes: ids.map((id) => sxe(byId.get(id))), state: "just_grabbed" })
1467
+ // Only after the replacement is grabbed, and only for a confirmed stall it fully covers with a seeded release.
1468
+ if (!seeded) continue
1469
+ for (const s of stalled.filter((x) => !x.replaced && x.confirmed && x.ids.every((id) => ids.includes(id)))) {
1470
+ const removed = await attempt(`blocklist ${s.title}`, async () => { await sonarr(`/queue/${s.queue_id}`, { method: "DELETE", query: { removeFromClient: true, blocklist: true, skipRedownload: true } }); return true })
1471
+ if (!removed) continue
1472
+ s.replaced = true
1473
+ actions.push({ action: "blocklisted_stalled", title: s.title, percent: s.percent, stall: s.stall })
1474
+ }
1475
+ }
1476
+ for (const s of stalled.filter((x) => !x.replaced)) {
1477
+ const alsoGrabbed = s.ids.every((id) => !remaining.has(id))
1478
+ inFlight.push({ title: s.title, download_id: s.download_id, episodes: s.episodes, percent: s.percent, queue_id: s.queue_id, added: s.added, state: "stalled", stall: s.stall,
1479
+ ...(s.confirmed ? {} : { stall_confirmed: false }),
1480
+ note: !s.confirmed && s.alternative ? "A seeded alternative exists; a later call replaces this download once the stall is confirmed (no progress for 30 minutes)."
1481
+ : alsoGrabbed ? "Releases for some of these episodes were grabbed; this download was kept for the rest."
1482
+ : "No other usable release was found, so this download was kept in case peers return." })
1483
+ s.ids.forEach((id) => remaining.delete(id))
1484
+ }
1485
+
1486
+ const open = need.filter((e) => remaining.has(e.id) && !deferred.has(e.id))
1487
+ const unsearched = open.filter((e) => !searchedSeasons.has(e.seasonNumber) && !searchedEpisodes.has(e.id))
1488
+ const notFound = open.filter((e) => !unsearched.includes(e)).map(code)
1489
+ const stuck = inFlight.filter((x) => x.state === "stalled")
1490
+ let evidence
1491
+ if ((notFound.length || stuck.length) && timeLeft()) {
1492
+ // What exists on the indexers under any name, so a "can't find it" is backed by a listing. Best effort.
1493
+ evidence = []
1494
+ const target = notFound.length ? need.filter((e) => notFound.some((n) => n.episode_id === e.id)) : wanted
1495
+ const ys = [...new Set(target.map((e) => e.airDate?.slice(0, 4)).filter(Boolean))].slice(0, 2)
1496
+ const scene = [...new Set(target.map((e) => e.sceneSeasonNumber).filter((n) => Number.isInteger(n)))].slice(0, 1)
1497
+ const queries = [...(ys.length ? ys : [""]).map((y) => `${seriesTitle} ${y}`.trim()), ...scene.map((n) => `${seriesTitle} S${code2(n)}`)]
1498
+ for (const q of queries) {
1499
+ if (!timeLeft()) break
1500
+ const raw = await attempt(`indexer evidence "${q}"`, () => prowlarr("/search", { query: { query: q, type: "search", limit: 100 }, timeoutMs: searchTimeout() }))
1501
+ if (!Array.isArray(raw)) continue
1502
+ // Indexers answer free text loosely ("Vegas Chef Prizefight"); keep only releases named for this series.
1503
+ const own = raw.filter((r) => hasWord(normalizeTitle(r.title), seriesBaseTitle(seriesTitle)))
1504
+ const rows = own.sort((x, y) => (y.seeders ?? -1) - (x.seeders ?? -1)).slice(0, FILL_EVIDENCE_ROWS)
1505
+ evidence.push({ query: q, results: own.length, top: rows.map((r) => ({ title: r.title ?? null, seeders: r.seeders ?? null, indexer: r.indexer ?? null })) })
1506
+ }
1507
+ }
1508
+ const incomplete = cutShort || deferred.size > 0 || unsearched.length > 0 || !timeLeft() || errors.length > 0
1509
+ const moving = inFlight.filter((x) => x.state !== "stalled").length
1510
+ const summary = [
1511
+ `${have.length} of ${base.on_shelf.of} aired episodes are on the shelf; ${wanted.length} monitored ${wanted.length === 1 ? "episode is" : "episodes are"} missing.`,
1512
+ grabs ? `Grabbed ${grabs} release${grabs === 1 ? "" : "s"} now.` : "",
1513
+ moving ? `${moving} download(s) in progress.` : "",
1514
+ stuck.length ? `${stuck.length} download(s) stalled with no peers and no other usable release (${stuck.map((s) => `${s.title} at ${s.percent}%`).join("; ")}).` : "",
1515
+ notFound.length ? `No usable release found for ${notFound.map((e) => e.code).join(", ")}.` : "",
1516
+ deferred.size || unsearched.length ? `${deferred.size + unsearched.length} episode(s) not handled yet in this pass.` : "",
1517
+ errors.length ? `${errors.length} step(s) failed: ${errors.map((x) => x.step).join("; ")}.` : "",
1518
+ ].filter(Boolean).join(" ")
1519
+ const nextStep = incomplete
1520
+ ? "This pass did not finish everything. Tell the owner what it did, then call media_fill_missing again to continue; do not act by hand."
1521
+ : stuck.length || notFound.length
1522
+ ? "Tell the owner exactly this outcome with the evidence; do not grab, import or blocklist anything by hand. Call media_fill_missing again later to retry."
1523
+ : "Work is in progress. Tell the owner what is downloading; call media_fill_missing again later to import and confirm. Do not search or grab anything else now."
1524
+ return { ...base, goal_met: false, in_flight: inFlight, not_found: notFound, actions,
1525
+ ...(deferred.size ? { deferred: need.filter((e) => deferred.has(e.id)).map(code) } : {}), ...(unsearched.length ? { unsearched: unsearched.map(code) } : {}),
1526
+ searched, ...(rejected.size ? { rejected_releases: [...rejected.values()].slice(0, 12) } : {}), ...(evidence ? { indexer_evidence: evidence } : {}),
1527
+ ...(errors.length ? { errors } : {}), ...(incomplete ? { incomplete: true } : {}), summary, next_step: nextStep }
1528
+ }
1529
+
1148
1530
  // ------------------------------------------------------------------- schema
1149
1531
 
1150
- export const TOOLS = [
1532
+ // The harness waits 30 s for a tool call unless the tool declares longer; indexer searches can take minutes.
1533
+ const TIMEOUT_META_KEY = "ouro.bot/timeoutMs"
1534
+
1535
+ const TOOL_LIST = [
1151
1536
  {
1152
1537
  name: "media_search",
1153
1538
  annotations: { readOnlyHint: true },
@@ -1226,6 +1611,16 @@ export const TOOLS = [
1226
1611
  research: { type: "boolean", description: "Search again afterwards. Default true." },
1227
1612
  }, required: ["kind", "service_id"] },
1228
1613
  },
1614
+ {
1615
+ name: "media_fill_missing",
1616
+ _meta: { [TIMEOUT_META_KEY]: 240_000 },
1617
+ description: "Get a series' missing episodes, start to finish, in one call. Use this first whenever the owner asks why episodes are missing or asks to get them. It re-reads Sonarr, finds the monitored aired episodes without files, searches the indexers, matches releases to episodes by episode title and air year as well as Sonarr's parse (so streaming-volume numbering cannot fool it), prefers seeded releases, skips packs of episodes already on the shelf, grabs with the right episode mapping, imports finished downloads by title, and replaces a stalled download when a usable alternative exists. Returns on_shelf, in_flight, not_found, actions, indexer_evidence, summary and next_step. Safe to call again: it never grabs what is already in flight. When goal_met is true, stop. Report summary and next_step as they are; do not redo its steps by hand.",
1618
+ inputSchema: { type: "object", properties: {
1619
+ series: { type: "string", description: "Series title as the owner said it." },
1620
+ service_id: { type: "number", description: "Sonarr series id, when known." },
1621
+ season_number: { type: "number", description: "Limit to one season. Omit for the whole series (specials excluded)." },
1622
+ } },
1623
+ },
1229
1624
  {
1230
1625
  name: "media_episodes",
1231
1626
  annotations: { readOnlyHint: true },
@@ -1238,6 +1633,7 @@ export const TOOLS = [
1238
1633
  {
1239
1634
  name: "media_indexer_search",
1240
1635
  annotations: { readOnlyHint: true },
1636
+ _meta: { [TIMEOUT_META_KEY]: 200_000 },
1241
1637
  description: "Free-text search across every indexer (Prowlarr), sorted by seeders. Discovery only, it grabs nothing. Use it when Sonarr/Radarr's own search finds nothing: try the scene code from media_episodes, 'Season N', the year, or an episode title, to learn whether any release exists and how it is named. Zero seeders means it cannot download right now.",
1242
1638
  inputSchema: { type: "object", properties: {
1243
1639
  query: { type: "string" },
@@ -1247,6 +1643,7 @@ export const TOOLS = [
1247
1643
  {
1248
1644
  name: "media_release_search",
1249
1645
  annotations: { readOnlyHint: true },
1646
+ _meta: { [TIMEOUT_META_KEY]: 240_000 },
1250
1647
  description: "Interactive release search for a series or movie already in the library: what the indexers offer and how Sonarr/Radarr parsed each release (full_season, parsed_season, parsed_episodes, mapped_episodes), whether it is rejected and why, and whether it is blocklisted. Series: give season_number or episode_ids. Can take a minute or more. `query` filters returned titles. Use it when the automatic search found nothing, then grab with media_release_grab.",
1251
1648
  inputSchema: { type: "object", properties: {
1252
1649
  kind: { type: "string", enum: ["series", "movie"] },
@@ -1291,6 +1688,25 @@ export const TOOLS = [
1291
1688
  },
1292
1689
  ]
1293
1690
 
1691
+ // Every schema is closed: an argument the tool does not define (an invented dry_run,
1692
+ // a misspelled id) is refused with its name, never silently ignored.
1693
+ function closeSchema(schema) {
1694
+ if (!schema || typeof schema !== "object" || schema.type !== "object") return schema
1695
+ const properties = Object.fromEntries(Object.entries(schema.properties ?? {}).map(([k, v]) => [k, v?.type === "array" && v.items ? { ...v, items: closeSchema(v.items) } : closeSchema(v)]))
1696
+ return { ...schema, properties, additionalProperties: false }
1697
+ }
1698
+
1699
+ export const TOOLS = TOOL_LIST.map((t) => ({ ...t, inputSchema: closeSchema(t.inputSchema) }))
1700
+
1701
+ // The same check inside the server, for any caller that does not validate against the schema.
1702
+ export function unknownArguments(toolName, args) {
1703
+ const tool = TOOLS.find((t) => t.name === toolName)
1704
+ const allowed = Object.keys(tool?.inputSchema?.properties ?? {})
1705
+ const unknown = Object.keys(args ?? {}).filter((k) => !allowed.includes(k))
1706
+ return unknown.length ? { error: "unknown_parameter", unknown, allowed,
1707
+ message: `${toolName} does not take ${unknown.map((k) => `'${k}'`).join(", ")}. It accepts only: ${allowed.join(", ") || "no parameters"}. Call it again without the unknown parameter.` } : null
1708
+ }
1709
+
1294
1710
  const HANDLERS = {
1295
1711
  media_search: mediaSearch,
1296
1712
  media_request: mediaRequest,
@@ -1301,6 +1717,7 @@ const HANDLERS = {
1301
1717
  media_search_now: mediaSearchNow,
1302
1718
  media_blocklist_stalled: mediaBlocklistStalled,
1303
1719
  media_episodes: mediaEpisodes,
1720
+ media_fill_missing: mediaFillMissing,
1304
1721
  media_indexer_search: mediaIndexerSearch,
1305
1722
  media_release_search: mediaReleaseSearch,
1306
1723
  media_release_grab: mediaReleaseGrab,
@@ -1331,6 +1748,8 @@ async function handle(msg) {
1331
1748
  human_action_reason: credError }
1332
1749
  return send({ jsonrpc: "2.0", id, result: { isError: true, content: [{ type: "text", text: JSON.stringify(payload, null, 1) }] } })
1333
1750
  }
1751
+ const unknown = unknownArguments(params.name, params.arguments)
1752
+ if (unknown) return send({ jsonrpc: "2.0", id, result: { isError: true, content: [{ type: "text", text: JSON.stringify(unknown, null, 1) }] } })
1334
1753
  try {
1335
1754
  const out = await fn(params.arguments ?? {})
1336
1755
  return send({ jsonrpc: "2.0", id, result: { content: [{ type: "text", text: JSON.stringify(out, null, 1) }] } })
@@ -15,4 +15,7 @@
15
15
  - When state is ambiguous, investigate safely, say what is known, and ask only if the answer changes the next safe move.
16
16
  - When a tool for the job is missing, say so in one line and route the work: ask Claude Code over A2A or record it as a follow-up. Never ask Ari to click through a web UI for work the house is meant to do.
17
17
  - Answer from primary sources, never from memory. For any factual claim about the world or the house (whether a title or series exists, what is on the shelf, a container's state, a version, a download's progress, a book's metadata, what Ari said before), look it up with the tools first (Sonarr/Radarr/Jellyfin and their TVDB/TMDB lookup, the Unraid tools, calibre-web, libgen, HEY, web_search, your own state files) and answer from what they return. Re-read live state instead of repeating a status from an earlier reply. When no tool can reach the source, say it is unverified rather than asserting it. Never say a title does not exist without a catalog lookup, and when the library holds a strong partial match, offer it first.
18
- - When an automatic search finds nothing for episodes that exist, suspect numbering (streaming vs TVDB order, specials, absolute numbers). Read `media_episodes` (its `scene_code` is the numbering release names use), compare its titles with the `media_release_search` results (how Sonarr parsed each release) and with what `media_blocklist` holds, grab the right pack with `media_release_grab` and an explicit episode mapping, and after it downloads, map the files to episodes by title with `media_manual_import`. When Sonarr's search finds nothing, use `media_indexer_search` with the scene code, 'Season N', the year or an episode title to learn whether any release exists at all; if the only matching release has no seeders, still grab it with the right mapping (peers can reappear) and say plainly it may never finish instead of promising it will arrive. Report each step with what Sonarr returned.
18
+ - When episodes are missing, or Ari asks to get them, call `media_fill_missing` first. It re-reads Sonarr, matches releases by episode title and air year, grabs, imports by title and replaces stalled downloads in one call, and it is safe to call again. Relay its `summary` with the evidence it returns and follow its `next_step`; do not redo its steps by hand. When it reports `goal_met`, or an episode Ari asked for is imported, stop: say so and end the turn without further probing.
19
+ - Before stating whether an episode is monitored, missing, downloading or on disk, read it from a tool in this turn (`media_fill_missing` or `media_episodes`). Never infer it from an earlier reply or from a tool that did not report it.
20
+ - Use only the parameters a tool lists. If a call is refused for an unknown parameter or a wrong value, read the allowed ones from the refusal and call again correctly; never invent flags such as `dry_run`.
21
+ - When an automatic search finds nothing for episodes that exist and `media_fill_missing` cannot place them, suspect numbering (streaming vs TVDB order, specials, absolute numbers). Read `media_episodes` (its `scene_code` is the numbering release names use), compare its titles with the `media_release_search` results (how Sonarr parsed each release) and with what `media_blocklist` holds, grab the right pack with `media_release_grab` and an explicit episode mapping, and after it downloads, map the files to episodes by title with `media_manual_import`. When Sonarr's search finds nothing, use `media_indexer_search` with the scene code, 'Season N', the year or an episode title to learn whether any release exists at all; if the only matching release has no seeders, still grab it with the right mapping (peers can reappear) and say plainly it may never finish instead of promising it will arrive. Report each step with what Sonarr returned.
@@ -2,7 +2,7 @@
2
2
  "version": 2,
3
3
  "profiles": {
4
4
  "sanctuary-owner": {
5
- "version": 12,
5
+ "version": 13,
6
6
  "contextScopes": [
7
7
  "household.status",
8
8
  "household.policy",
@@ -72,7 +72,8 @@
72
72
  "media_release_search",
73
73
  "media_release_grab",
74
74
  "media_blocklist",
75
- "media_manual_import"
75
+ "media_manual_import",
76
+ "media_fill_missing"
76
77
  ],
77
78
  "effectScopes": [
78
79
  "telegram.proactive",
@@ -140,7 +141,7 @@
140
141
  ]
141
142
  },
142
143
  "sanctuary-agent-peer": {
143
- "version": 3,
144
+ "version": 4,
144
145
  "toolNames": [
145
146
  "external_event_disposition",
146
147
  "query_active_work",
@@ -203,7 +204,8 @@
203
204
  "media_release_search",
204
205
  "media_release_grab",
205
206
  "media_blocklist",
206
- "media_manual_import"
207
+ "media_manual_import",
208
+ "media_fill_missing"
207
209
  ],
208
210
  "contextScopes": [
209
211
  "household.status",
@@ -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.853</Repository>
4
+ <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.854</Repository>
5
5
  <Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
6
6
  <Network>host</Network>
7
7
  <Shell>sh</Shell>
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.MAX_PROVIDER_ITERATIONS = void 0;
3
+ exports.MAX_PROVIDER_ITERATIONS_CEILING = exports.STEP_BUDGET_EXTENSION = exports.MAX_PROVIDER_ITERATIONS = void 0;
4
4
  exports.validateToolCallBatchAtProductionBoundary = validateToolCallBatchAtProductionBoundary;
5
5
  exports.createProviderRegistry = createProviderRegistry;
6
6
  exports.getProviderRuntime = getProviderRuntime;
@@ -274,7 +274,13 @@ function createHabitCallbackBuffer(callbacks) {
274
274
  function createFinalOnlyTextBuffer(callbacks) {
275
275
  return createCallbackBuffer(callbacks, "text-only");
276
276
  }
277
+ /** Base per-turn provider-call budget. Grows while tool calls keep making progress. */
277
278
  exports.MAX_PROVIDER_ITERATIONS = 8;
279
+ /** How many provider calls each progress-earned extension adds. */
280
+ exports.STEP_BUDGET_EXTENSION = 4;
281
+ /** Hard ceiling on the per-turn provider-call budget, however much progress is made. */
282
+ exports.MAX_PROVIDER_ITERATIONS_CEILING = 24;
283
+ const STEP_BUDGET_REPORT_INSTRUCTION = "the step budget for this turn is used up. do not call any more work tools; none of the tool calls in your last message were executed. call settle now with a short progress report: what is done (with evidence from tool results above), what is still in flight, and what you will do next. say you can continue where you left off when the conversation resumes.";
278
284
  function continuationCallbacks(callbacks, text) {
279
285
  const noop = () => undefined;
280
286
  return {
@@ -1164,6 +1170,20 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1164
1170
  let unresolvedHistoricalEffects = [];
1165
1171
  let historicalToolFailureRetries = 0;
1166
1172
  let providerIterations = 0;
1173
+ // Adaptive step budget: starts at the base, grows while tool calls make progress.
1174
+ let stepBudget = exports.MAX_PROVIDER_ITERATIONS;
1175
+ let lastIterationMadeProgress = false;
1176
+ let stepReportIteration = null;
1177
+ const budgetExhausted = () => {
1178
+ if (providerIterations < stepBudget)
1179
+ return false;
1180
+ if (lastIterationMadeProgress && stepBudget < exports.MAX_PROVIDER_ITERATIONS_CEILING) {
1181
+ stepBudget = Math.min(exports.MAX_PROVIDER_ITERATIONS_CEILING, stepBudget + exports.STEP_BUDGET_EXTENSION);
1182
+ (0, runtime_1.emitNervesEvent)({ component: "engine", event: "engine.step_budget_extended", message: "tool progress extended the per-turn step budget", meta: { stepBudget } });
1183
+ return providerIterations >= stepBudget;
1184
+ }
1185
+ return true;
1186
+ };
1167
1187
  const requiredToolCallNames = [...new Set(options?.requiredToolCalls?.names ?? [])];
1168
1188
  const dispatchedRequiredToolCalls = new Set();
1169
1189
  const rejectAttempt = (assistant, controls) => {
@@ -1182,8 +1202,8 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1182
1202
  : null;
1183
1203
  };
1184
1204
  const queueRequiredCorrection = (assistant, message, limitContext) => {
1185
- if (providerIterations >= exports.MAX_PROVIDER_ITERATIONS)
1186
- throw new Error(`provider iteration limit exhausted at response ${exports.MAX_PROVIDER_ITERATIONS} ${limitContext}`);
1205
+ if (budgetExhausted())
1206
+ throw new Error(`provider iteration limit exhausted at response ${stepBudget} ${limitContext}`);
1187
1207
  rejectAttempt(assistant, [{ role: "user", content: message }]);
1188
1208
  };
1189
1209
  const toolLoopState = (0, tool_loop_1.createToolLoopState)();
@@ -1323,9 +1343,11 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1323
1343
  .map((effect) => effect.name)
1324
1344
  .filter((name) => candidateToolNames.has(name)));
1325
1345
  const forcingHistoricalEffect = forcedHistoricalToolNames.size > 0;
1326
- const activeTools = forcingHistoricalEffect
1327
- ? candidateActiveTools.filter((tool) => forcedHistoricalToolNames.has(tool.function.name))
1328
- : candidateActiveTools;
1346
+ const activeTools = stepReportIteration !== null
1347
+ ? candidateActiveTools.filter((tool) => tool.function.name === "settle")
1348
+ : forcingHistoricalEffect
1349
+ ? candidateActiveTools.filter((tool) => forcedHistoricalToolNames.has(tool.function.name))
1350
+ : candidateActiveTools;
1329
1351
  const activeToolNames = new Set(activeTools.map((tool) => tool.function.name));
1330
1352
  const steeringFollowUps = options?.drainSteeringFollowUps?.() ?? [];
1331
1353
  if (steeringFollowUps.length > 0) {
@@ -1482,8 +1504,12 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1482
1504
  const result = attempt.value;
1483
1505
  nextAttemptControls = [];
1484
1506
  providerIterations += 1;
1485
- if (providerIterations === exports.MAX_PROVIDER_ITERATIONS && result.toolCalls.length > 0) {
1486
- throw new Error(`provider iteration limit exhausted at response ${exports.MAX_PROVIDER_ITERATIONS} before tool execution`);
1507
+ if (stepReportIteration !== null) {
1508
+ // The progress-report call is the last one: it must settle, or the turn errors.
1509
+ const settledReport = result.toolCalls.length === 1 && result.toolCalls[0].name === "settle";
1510
+ if (providerIterations > stepReportIteration || (result.toolCalls.length > 0 && !settledReport)) {
1511
+ throw new Error(`provider iteration limit exhausted at response ${providerIterations} before the progress report settled`);
1512
+ }
1487
1513
  }
1488
1514
  const streamCallbackBuffer = turnCallbackBufferRef.current;
1489
1515
  turnCallbackBufferRef.current = null;
@@ -1550,6 +1576,24 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1550
1576
  if (thinkingItems.length > 0) {
1551
1577
  msg._thinking_blocks = thinkingItems;
1552
1578
  }
1579
+ const settlingNow = result.toolCalls.length === 1 && result.toolCalls[0].name === "settle";
1580
+ if (stepReportIteration === null && result.toolCalls.length > 0 && !settlingNow && budgetExhausted()) {
1581
+ if (!candidateToolNames.has("settle")) {
1582
+ throw new Error(`provider iteration limit exhausted at response ${stepBudget} before tool execution`);
1583
+ }
1584
+ // Do not error into the reply: give the model one settle-only call to report progress.
1585
+ streamCallbackBuffer?.discard();
1586
+ stepReportIteration = providerIterations + 1;
1587
+ rejectAttempt(msg, [
1588
+ ...result.toolCalls.map((tc) => ({ role: "tool", tool_call_id: tc.id, content: "not executed: the step budget for this turn is used up." })),
1589
+ { role: "user", content: STEP_BUDGET_REPORT_INSTRUCTION },
1590
+ ]);
1591
+ callbacks.onClearText?.();
1592
+ (0, runtime_1.emitNervesEvent)({ level: "warn", component: "engine", event: "engine.step_budget_exhausted", message: "step budget used up; requesting a settle-only progress report", meta: { stepBudget, providerIterations } });
1593
+ continue;
1594
+ }
1595
+ if (result.toolCalls.length > 0)
1596
+ lastIterationMadeProgress = false;
1553
1597
  // Phase annotation for Codex provider
1554
1598
  const hasPhaseAnnotation = providerRuntime.capabilities.has("phase-annotation");
1555
1599
  const isSoleSettle = result.toolCalls.length === 1 && result.toolCalls[0].name === "settle";
@@ -1908,7 +1952,7 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1908
1952
  // answer before ordinary settle handling reaches this point.
1909
1953
  const deliveredAnswer = answer;
1910
1954
  // Never spend the last provider iteration on this optional retry: delivering the recap beats delivering nothing.
1911
- const contentMismatch = sawSettleContentMismatch || providerIterations >= exports.MAX_PROVIDER_ITERATIONS - 1 ? null : settleContentMismatchError(msg.content, deliveredAnswer);
1955
+ const contentMismatch = sawSettleContentMismatch || providerIterations >= stepBudget - 1 ? null : settleContentMismatchError(msg.content, deliveredAnswer);
1912
1956
  if (contentMismatch) {
1913
1957
  sawSettleContentMismatch = true;
1914
1958
  (0, runtime_1.emitNervesEvent)({ level: "warn", component: "engine", event: "engine.settle_content_mismatch", message: "settle answer differs from the reply text written beside it; asking once for the reply as the answer", meta: { answerLength: deliveredAnswer.length, contentLength: msg.content.length } });
@@ -2446,6 +2490,8 @@ async function runAgent(messages, callbacks, channel, signal, options) {
2446
2490
  invoked,
2447
2491
  sideEffect: success && toolRiskProfile?.mutates !== "none",
2448
2492
  });
2493
+ if (success && !(0, tool_loop_1.hasRecordedToolCall)(toolLoopState, tc.name, args))
2494
+ lastIterationMadeProgress = true;
2449
2495
  (0, tool_loop_1.recordToolOutcome)(toolLoopState, tc.name, args, modelResult, success);
2450
2496
  callbacks.onToolEnd(tc.name, (0, tools_1.buildToolResultSummary)(tc.name, args, modelResult, success, toolSelection), success);
2451
2497
  callbacks.onToolResult?.(messages);
@@ -60,7 +60,7 @@ function exactSanctuaryContainmentProfileBoundaries(value) {
60
60
  if (!value || typeof value !== "object" || Array.isArray(value))
61
61
  return false;
62
62
  const boundaries = value;
63
- const versions = { "sanctuary-owner": 12, "sanctuary-household": 5, "sanctuary-event": 4 };
63
+ const versions = { "sanctuary-owner": 13, "sanctuary-household": 5, "sanctuary-event": 4 };
64
64
  if (!(0, node_util_1.isDeepStrictEqual)(Object.keys(boundaries).sort(), Object.keys(versions).sort()))
65
65
  return false;
66
66
  const packageRoot = path.resolve(__dirname, "../../../deploy/unraid/sanctuary.ouro");
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.GLOBAL_CIRCUIT_BREAKER_LIMIT = exports.PING_PONG_WINDOW = exports.GENERIC_REPEAT_LIMIT = exports.POLL_NO_PROGRESS_LIMIT = exports.TOOL_LOOP_HISTORY_LIMIT = void 0;
37
37
  exports.createToolLoopState = createToolLoopState;
38
38
  exports.recordToolOutcome = recordToolOutcome;
39
+ exports.hasRecordedToolCall = hasRecordedToolCall;
39
40
  exports.detectToolLoop = detectToolLoop;
40
41
  const crypto = __importStar(require("crypto"));
41
42
  const runtime_1 = require("../nerves/runtime");
@@ -178,6 +179,11 @@ function recordToolOutcome(state, toolName, args, result, success) {
178
179
  state.history.splice(0, state.history.length - exports.TOOL_LOOP_HISTORY_LIMIT);
179
180
  }
180
181
  }
182
+ /** True when this exact call (tool name + canonicalized arguments) was already recorded this turn. */
183
+ function hasRecordedToolCall(state, toolName, args) {
184
+ const callHash = digest(normalizeArgs(toolName, args));
185
+ return state.history.some((record) => record.toolName === toolName && record.callHash === callHash);
186
+ }
181
187
  // Tools that must never be blocked by the circuit breaker.
182
188
  // settle = end the turn, surface = deliver results outward.
183
189
  // ponder = continue thinking (private runtime) or hand off to private runtime (outer).
@@ -1,14 +1,18 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.McpClient = void 0;
3
+ exports.McpClient = exports.MCP_TOOL_TIMEOUT_META_KEY = void 0;
4
4
  exports.isMcpTransportError = isMcpTransportError;
5
5
  const child_process_1 = require("child_process");
6
6
  const readline_1 = require("readline");
7
7
  const runtime_1 = require("../nerves/runtime");
8
8
  const runtime_cwd_1 = require("../heart/runtime-cwd");
9
+ /** Metadata key a server uses to declare how long one call to a tool may take. */
10
+ exports.MCP_TOOL_TIMEOUT_META_KEY = "ouro.bot/timeoutMs";
11
+ const MAX_TOOL_CALL_TIMEOUT = 600_000;
9
12
  const MCP_PROTOCOL_VERSION = "2024-11-05";
10
13
  const DEFAULT_REQUEST_TIMEOUT = 10_000;
11
14
  const DEFAULT_TOOL_CALL_TIMEOUT = 30_000;
15
+ const MIN_TOOL_CALL_TIMEOUT = 1_000;
12
16
  function isMcpTransportError(error) {
13
17
  const message = error instanceof Error ? error.message : String(error);
14
18
  const normalized = message.toLowerCase();
@@ -97,11 +101,19 @@ class McpClient {
97
101
  this.cachedTools = allTools;
98
102
  return allTools;
99
103
  }
104
+ /** A tool that searches slow services may declare a longer timeout; it is clamped to 1 s .. 10 min. */
105
+ declaredToolTimeout(name) {
106
+ const declared = this.cachedTools?.find((tool) => tool.name === name)?._meta?.[exports.MCP_TOOL_TIMEOUT_META_KEY];
107
+ if (typeof declared !== "number" || !Number.isFinite(declared))
108
+ return DEFAULT_TOOL_CALL_TIMEOUT;
109
+ return Math.min(Math.max(Math.round(declared), MIN_TOOL_CALL_TIMEOUT), MAX_TOOL_CALL_TIMEOUT);
110
+ }
100
111
  async refreshTools() {
101
112
  this.cachedTools = null;
102
113
  return this.listTools();
103
114
  }
104
- async callTool(name, args, timeout = DEFAULT_TOOL_CALL_TIMEOUT) {
115
+ async callTool(name, args, timeoutOverride) {
116
+ const timeout = timeoutOverride ?? this.declaredToolTimeout(name);
105
117
  (0, runtime_1.emitNervesEvent)({
106
118
  event: "mcp.tool_call_start",
107
119
  component: "repertoire",
@@ -66,8 +66,20 @@ function canonicalize(value) {
66
66
  function digest(value) {
67
67
  return (0, node_crypto_1.createHash)("sha256").update(value, "utf8").digest("hex");
68
68
  }
69
+ // The model reads these: name the offending parameter and the allowed values so it
70
+ // can correct the call instead of guessing again (e.g. an invented dry_run flag).
71
+ function renderError(error) {
72
+ const where = error.instancePath || "/";
73
+ if (error.keyword === "additionalProperties") {
74
+ return `${where} has unknown parameter '${String(error.params.additionalProperty)}': this tool does not accept it; use only the parameters in its schema`;
75
+ }
76
+ if (error.keyword === "enum" && Array.isArray(error.params.allowedValues)) {
77
+ return `${where} must be one of: ${error.params.allowedValues.map((value) => JSON.stringify(value)).join(", ")}`;
78
+ }
79
+ return `${where} ${error.message}`;
80
+ }
69
81
  function renderErrors(errors) {
70
- return errors.map((error) => `${error.instancePath || "/"} ${error.message}`).join("; ");
82
+ return errors.map(renderError).join("; ");
71
83
  }
72
84
  function digestJson(value) {
73
85
  return digest(canonicalize(value));
@@ -73,7 +73,7 @@ const SANCTUARY_RELATIONSHIP_BASE_TOOLS = new Set([
73
73
  exports.SANCTUARY_MCP_PROVIDED_TOOLS = new Set([
74
74
  "media_search", "media_request", "media_request_status",
75
75
  "media_diagnose_and_fix", "media_chain_health", "media_play_or_resolve",
76
- "media_search_now", "media_blocklist_stalled", "media_episodes", "media_indexer_search", "media_release_search", "media_release_grab", "media_blocklist", "media_manual_import",
76
+ "media_search_now", "media_blocklist_stalled", "media_episodes", "media_indexer_search", "media_release_search", "media_release_grab", "media_blocklist", "media_manual_import", "media_fill_missing",
77
77
  ]);
78
78
  /** Channels a Sanctuary turn can arrive on from the host itself, not from a remote party. */
79
79
  const SANCTUARY_LOCAL_CHANNELS = new Set(["inner", "cli", "mcp"]);
@@ -342,7 +342,7 @@ function getSenseSessionPath(agentName, friendId, channel, sessionKey, agentRoot
342
342
  */
343
343
  function erroredTurnReply(errorMessage) {
344
344
  return /iteration limit exhausted/u.test(errorMessage ?? "")
345
- ? "I couldn't finish that one: I ran out of steps before I had an answer I could stand behind. Ask me again, or ask something narrower, and I'll take another run at it."
345
+ ? "I hit my step limit in the middle of that job and couldn't finish it. Tell me to continue and I'll pick up from where I stopped."
346
346
  : "I couldn't finish that one: something went wrong before I had an answer. Ask me again and I'll retry.";
347
347
  }
348
348
  async function runSenseTurn(options) {
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.853",
3
+ "version": "0.1.0-alpha.854",
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.853",
9
+ "version": "0.1.0-alpha.854",
10
10
  "dependencies": {
11
11
  "@anthropic-ai/sdk": "^0.78.0",
12
12
  "@azure/identity": "^4.13.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.853",
3
+ "version": "0.1.0-alpha.854",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },