Compare commits

..
Author SHA1 Message Date
Nick e33b563dad feat(pipeline): gate jobs on desktop runner — latency (Pi measured ~15min/6 API calls poisons event-driven gates) (#147)
CI Pipeline / lint (push) Successful in 23s
CI Pipeline / unit-tests (push) Successful in 29s
CI Pipeline / canary (push) Successful in 25s
CI Pipeline / best-practices (push) Successful in 2s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / type-check (push) Successful in 21s
CI Pipeline / gitea-offline (push) Successful in 14s
CI Pipeline / required-checks (push) Skipped
Auto-label / priority-gate (pull_request) Skipped
Auto-label / auto-label (pull_request) Successful in 2s
CI Pipeline / lint (pull_request) Successful in 24s
CI Pipeline / unit-tests (pull_request) Successful in 29s
CI Pipeline / canary (pull_request) Successful in 24s
CI Pipeline / best-practices (pull_request) Successful in 2s
CI Pipeline / documentation (pull_request) Successful in 2s
CI Pipeline / type-check (pull_request) Successful in 26s
CI Pipeline / gitea-offline (pull_request) Successful in 13s
CI Pipeline / required-checks (pull_request) Successful in 0s
2026-09-07 13:31:24 +01:00
conductor f2dc1621fd Merge pull request 'Research gate: evidence enforcement (OUTWARD sources required)' (#201) from research-gate-evidence into master
CI Pipeline / lint (push) Successful in 23s
CI Pipeline / unit-tests (push) Successful in 58s
CI Pipeline / canary (push) Successful in 26s
CI Pipeline / best-practices (push) Successful in 1s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / type-check (push) Successful in 21s
CI Pipeline / gitea-offline (push) Successful in 13s
CI Pipeline / required-checks (push) Skipped
2026-09-07 12:49:16 +01:00
Nick b77f4b43ef feat(pipeline): synthesis kill-test requirement + loop-back bound with owner escalation (#147)
CI Pipeline / lint (push) Successful in 23s
CI Pipeline / unit-tests (push) Successful in 30s
CI Pipeline / canary (push) Successful in 30s
CI Pipeline / best-practices (push) Successful in 2s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / type-check (push) Successful in 24s
CI Pipeline / gitea-offline (push) Successful in 13s
CI Pipeline / required-checks (push) Skipped
CI Pipeline / lint (pull_request) Successful in 25s
CI Pipeline / unit-tests (pull_request) Successful in 33s
CI Pipeline / canary (pull_request) Successful in 32s
CI Pipeline / best-practices (pull_request) Successful in 3s
CI Pipeline / documentation (pull_request) Successful in 2s
CI Pipeline / type-check (pull_request) Successful in 21s
CI Pipeline / gitea-offline (pull_request) Successful in 13s
CI Pipeline / required-checks (pull_request) Successful in 1s
2026-09-07 12:40:07 +01:00
Nick e497bd8c56 feat(pipeline): synthesis qualification gate — evidence sufficiency, feasibility verdict, honest MORE-RESEARCH loop-back (#147)
CI Pipeline / lint (push) Successful in 25s
CI Pipeline / unit-tests (push) Successful in 30s
CI Pipeline / canary (push) Successful in 28s
CI Pipeline / best-practices (push) Successful in 3s
CI Pipeline / documentation (push) Successful in 3s
CI Pipeline / type-check (push) Successful in 26s
CI Pipeline / gitea-offline (push) Successful in 14s
CI Pipeline / required-checks (push) Skipped
CI Pipeline / lint (pull_request) Successful in 26s
CI Pipeline / unit-tests (pull_request) Successful in 32s
CI Pipeline / canary (pull_request) Successful in 1m30s
CI Pipeline / best-practices (pull_request) Successful in 2s
CI Pipeline / documentation (pull_request) Successful in 2s
CI Pipeline / type-check (pull_request) Successful in 21s
CI Pipeline / gitea-offline (pull_request) Successful in 14s
CI Pipeline / required-checks (pull_request) Successful in 0s
2026-09-07 12:32:30 +01:00
Nick 3e647281ce feat(pipeline): research gate verifies source reachability + per-source trail of use (#147)
CI Pipeline / lint (push) Successful in 44s
CI Pipeline / unit-tests (push) Successful in 30s
CI Pipeline / canary (push) Successful in 30s
CI Pipeline / best-practices (push) Successful in 4s
CI Pipeline / documentation (push) Successful in 3s
CI Pipeline / type-check (push) Successful in 23s
CI Pipeline / gitea-offline (push) Successful in 18s
CI Pipeline / required-checks (push) Skipped
CI Pipeline / lint (pull_request) Successful in 43s
CI Pipeline / unit-tests (pull_request) Successful in 1m5s
CI Pipeline / canary (pull_request) Successful in 36s
CI Pipeline / best-practices (pull_request) Successful in 2s
CI Pipeline / documentation (pull_request) Successful in 2s
CI Pipeline / type-check (pull_request) Successful in 32s
CI Pipeline / gitea-offline (pull_request) Successful in 21s
CI Pipeline / required-checks (pull_request) Successful in 1s
2026-09-07 12:19:24 +01:00
Nick 25684bf1f0 feat(pipeline): research gate requires evidence (sources in OUTWARD, citations in INWARD) (#147)
Auto-label / priority-gate (pull_request) Skipped
Auto-label / auto-label (pull_request) Successful in 2s
CI Pipeline / lint (push) Successful in 31s
CI Pipeline / unit-tests (push) Successful in 31s
CI Pipeline / canary (push) Successful in 27s
CI Pipeline / best-practices (push) Successful in 3s
CI Pipeline / documentation (push) Successful in 3s
CI Pipeline / type-check (push) Successful in 23s
CI Pipeline / gitea-offline (push) Successful in 13s
CI Pipeline / required-checks (push) Skipped
CI Pipeline / lint (pull_request) Successful in 23s
CI Pipeline / unit-tests (pull_request) Successful in 29s
CI Pipeline / canary (pull_request) Successful in 25s
CI Pipeline / best-practices (pull_request) Successful in 2s
CI Pipeline / documentation (pull_request) Successful in 2s
CI Pipeline / type-check (pull_request) Successful in 21s
CI Pipeline / gitea-offline (pull_request) Successful in 13s
CI Pipeline / required-checks (pull_request) Successful in 0s
2026-09-07 12:13:34 +01:00
conductor 2840f71ccf Merge pull request 'P1-02: AttentionCapsule as the real model product (closes #25)' (#200) from p1-02-capsule-authority into master
CI Pipeline / lint (push) Successful in 23s
CI Pipeline / unit-tests (push) Successful in 31s
CI Pipeline / canary (push) Successful in 29s
CI Pipeline / best-practices (push) Successful in 2s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / type-check (push) Successful in 20s
CI Pipeline / gitea-offline (push) Successful in 13s
CI Pipeline / required-checks (push) Skipped
2026-09-07 10:03:58 +01:00
Nick b122aeb904 docs(p1-02): plan completion record (#25)
CI Pipeline / lint (push) Successful in 23s
CI Pipeline / unit-tests (push) Successful in 30s
CI Pipeline / canary (push) Successful in 24s
CI Pipeline / best-practices (push) Successful in 2s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / type-check (push) Successful in 23s
CI Pipeline / gitea-offline (push) Successful in 14s
CI Pipeline / required-checks (push) Skipped
CI Pipeline / lint (pull_request) Successful in 23s
CI Pipeline / unit-tests (pull_request) Successful in 30s
CI Pipeline / canary (pull_request) Successful in 24s
CI Pipeline / best-practices (pull_request) Successful in 2s
CI Pipeline / documentation (pull_request) Successful in 2s
CI Pipeline / type-check (pull_request) Successful in 21s
CI Pipeline / gitea-offline (pull_request) Successful in 13s
CI Pipeline / required-checks (pull_request) Successful in 1s
2026-09-07 09:59:03 +01:00
Nick 0434aac072 feat(p1-02): canary + CI registration (#25) 2026-09-07 09:59:03 +01:00
Nick 1db30c14f4 feat(p1-02): capsule authority map + docs (#25) 2026-09-07 09:59:03 +01:00
Nick 21c40231a5 feat(p1-02): trace v4 capsule fidelity fields (#25) 2026-09-07 09:59:03 +01:00
Nick 340646a7b1 feat(p1-02): safety-override re-measure + num_ctx budget propagation (#25) 2026-09-07 09:59:03 +01:00
Nick 32f155d95b feat(p1-02): capsule content logging + message capture + legacy deprecation (#25) 2026-09-07 09:59:03 +01:00
conductor 6b191994d8 Merge pull request 'feat(pipeline): automatic stage transitions — in-progress, incomplete ledger, notification comments' (#198) from feat/pipeline-transitions into master
CI Pipeline / lint (push) Successful in 23s
CI Pipeline / unit-tests (push) Successful in 32s
Auto-label / priority-gate (pull_request) Skipped
CI Pipeline / canary (push) Successful in 28s
Auto-label / auto-label (pull_request) Successful in 1s
CI Pipeline / best-practices (push) Successful in 2s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / type-check (push) Successful in 21s
CI Pipeline / gitea-offline (push) Successful in 13s
CI Pipeline / required-checks (push) Skipped
CI Pipeline / lint (pull_request) Successful in 23s
CI Pipeline / unit-tests (pull_request) Successful in 30s
CI Pipeline / canary (pull_request) Successful in 24s
CI Pipeline / best-practices (pull_request) Successful in 2s
CI Pipeline / documentation (pull_request) Successful in 2s
CI Pipeline / type-check (pull_request) Successful in 21s
CI Pipeline / gitea-offline (pull_request) Successful in 13s
CI Pipeline / required-checks (pull_request) Successful in 0s
2026-09-07 09:42:01 +01:00
conductor 4f9ddcde1f feat(pipeline): automatic stage transitions — in-progress label, incomplete-work catcher, notification comments
Auto-label / priority-gate (pull_request) Skipped
Auto-label / auto-label (pull_request) Successful in 2s
CI Pipeline / lint (push) Successful in 27s
CI Pipeline / unit-tests (push) Successful in 33s
CI Pipeline / canary (push) Successful in 28s
CI Pipeline / best-practices (push) Successful in 2s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / type-check (push) Successful in 22s
CI Pipeline / gitea-offline (push) Successful in 14s
CI Pipeline / required-checks (push) Skipped
CI Pipeline / lint (pull_request) Successful in 23s
CI Pipeline / unit-tests (pull_request) Successful in 29s
CI Pipeline / canary (pull_request) Successful in 26s
CI Pipeline / best-practices (pull_request) Successful in 2s
CI Pipeline / documentation (pull_request) Successful in 2s
CI Pipeline / type-check (pull_request) Successful in 25s
CI Pipeline / gitea-offline (pull_request) Successful in 13s
CI Pipeline / required-checks (pull_request) Successful in 0s
2026-09-07 09:34:32 +01:00
conductor 5c293df56b Merge pull request 'fix(pipeline): research gate wording — Rune does the research, not the user' (#195) from fix/research-gate-wording into master
CI Pipeline / lint (push) Successful in 26s
CI Pipeline / unit-tests (push) Successful in 32s
CI Pipeline / canary (push) Successful in 31s
CI Pipeline / best-practices (push) Successful in 3s
CI Pipeline / documentation (push) Successful in 3s
CI Pipeline / type-check (push) Successful in 25s
CI Pipeline / gitea-offline (push) Successful in 13s
CI Pipeline / required-checks (push) Skipped
2026-09-07 09:00:58 +01:00
conductor f1e17b03d4 fix(pipeline): research gate wording — Rune does the research, not the user
Auto-label / priority-gate (pull_request) Skipped
Auto-label / auto-label (pull_request) Successful in 2s
CI Pipeline / lint (push) Successful in 46s
CI Pipeline / unit-tests (push) Failing after 20s
CI Pipeline / canary (push) Successful in 41s
CI Pipeline / best-practices (push) Successful in 17s
CI Pipeline / documentation (push) Successful in 8s
CI Pipeline / type-check (push) Successful in 43s
CI Pipeline / gitea-offline (push) Successful in 18s
CI Pipeline / required-checks (push) Skipped
CI Pipeline / lint (pull_request) Successful in 35s
CI Pipeline / unit-tests (pull_request) Successful in 45s
CI Pipeline / canary (pull_request) Successful in 38s
CI Pipeline / best-practices (pull_request) Successful in 10s
CI Pipeline / documentation (pull_request) Successful in 9s
CI Pipeline / type-check (pull_request) Successful in 29s
CI Pipeline / gitea-offline (pull_request) Successful in 17s
CI Pipeline / required-checks (pull_request) Successful in 0s
2026-09-07 08:46:25 +01:00
conductor dca51f1801 Merge pull request 'feat(pipeline): research-round gate — auto-apply research-pending on ready, auto-clear on 4-phase research comment' (#194) from feat/research-round-gate into master
CI Pipeline / lint (push) Successful in 24s
CI Pipeline / unit-tests (push) Successful in 30s
CI Pipeline / canary (push) Successful in 32s
CI Pipeline / best-practices (push) Successful in 3s
CI Pipeline / documentation (push) Successful in 3s
CI Pipeline / type-check (push) Successful in 31s
CI Pipeline / gitea-offline (push) Successful in 21s
CI Pipeline / required-checks (push) Skipped
2026-09-06 23:55:03 +01:00
conductor 56177ee1a4 feat(pipeline): research-round gate — auto-apply research-pending on ready, auto-clear on 4-phase research comment (RESEARCH ROUND V3)
CI Pipeline / lint (push) Successful in 24s
Auto-label / auto-label (pull_request) Successful in 2s
Auto-label / priority-gate (pull_request) Skipped
CI Pipeline / unit-tests (push) Successful in 33s
CI Pipeline / canary (push) Successful in 24s
CI Pipeline / best-practices (push) Successful in 2s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / type-check (push) Successful in 21s
CI Pipeline / gitea-offline (push) Successful in 13s
CI Pipeline / required-checks (push) Skipped
CI Pipeline / lint (pull_request) Successful in 22s
CI Pipeline / unit-tests (pull_request) Successful in 30s
CI Pipeline / canary (pull_request) Successful in 24s
CI Pipeline / best-practices (pull_request) Successful in 1s
CI Pipeline / documentation (pull_request) Successful in 2s
CI Pipeline / type-check (pull_request) Successful in 20s
CI Pipeline / gitea-offline (pull_request) Successful in 13s
CI Pipeline / required-checks (pull_request) Successful in 1s
2026-09-06 23:47:57 +01:00
conductor 0d2b7b0a9f Merge pull request 'P1-01: CapabilityBroker authoritative — task-fit tool exposure, find_capability hatch (closes #24)' (#193) from p1-01-capability-authority into master
CI Pipeline / unit-tests (push) Successful in 40s
CI Pipeline / canary (push) Successful in 26s
CI Pipeline / best-practices (push) Successful in 2s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / type-check (push) Successful in 21s
CI Pipeline / gitea-offline (push) Successful in 13s
CI Pipeline / lint (push) Successful in 22s
CI Pipeline / required-checks (push) Skipped
2026-09-06 18:18:41 +01:00
Nick da79ca7237 style(p1-01): ruff-format canary_p1_01.py (CI format gate on added file)
CI Pipeline / lint (push) Successful in 22s
CI Pipeline / unit-tests (push) Successful in 29s
CI Pipeline / canary (push) Successful in 25s
CI Pipeline / best-practices (push) Successful in 2s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / type-check (push) Successful in 21s
CI Pipeline / gitea-offline (push) Successful in 14s
CI Pipeline / required-checks (push) Skipped
CI Pipeline / lint (pull_request) Successful in 23s
CI Pipeline / unit-tests (pull_request) Successful in 29s
CI Pipeline / canary (pull_request) Successful in 25s
CI Pipeline / best-practices (pull_request) Successful in 2s
CI Pipeline / documentation (pull_request) Successful in 2s
CI Pipeline / type-check (pull_request) Successful in 20s
CI Pipeline / gitea-offline (pull_request) Successful in 14s
CI Pipeline / required-checks (pull_request) Successful in 0s
2026-09-06 18:13:43 +01:00
Nick abefb8403a fix(p1-01): pyright ratchet parity — 11 new errors fixed (path narrowing, all-paths return, typed composition guard), pre-construction deserialize validation, broker stand-in handling; errors 117<120, warnings 407<465
CI Pipeline / unit-tests (push) Successful in 35s
CI Pipeline / canary (push) Successful in 24s
CI Pipeline / best-practices (push) Successful in 2s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / type-check (push) Successful in 20s
CI Pipeline / gitea-offline (push) Successful in 14s
CI Pipeline / lint (pull_request) Failing after 18s
CI Pipeline / unit-tests (pull_request) Successful in 29s
CI Pipeline / canary (pull_request) Successful in 26s
CI Pipeline / best-practices (pull_request) Successful in 1s
CI Pipeline / documentation (pull_request) Successful in 2s
CI Pipeline / type-check (pull_request) Successful in 21s
CI Pipeline / gitea-offline (pull_request) Successful in 13s
CI Pipeline / required-checks (pull_request) Skipped
CI Pipeline / lint (push) Successful in 23s
CI Pipeline / required-checks (push) Skipped
2026-09-06 18:06:02 +01:00
Nick 62efd2e8a8 fix(p1-01): quality discharges — dedupe load_cards, validated hatch dispatch, exposed-set in pending state, atomic card writes
Auto-label / priority-gate (pull_request) Skipped
Auto-label / auto-label (pull_request) Successful in 2s
CI Pipeline / lint (push) Successful in 24s
CI Pipeline / unit-tests (push) Successful in 30s
CI Pipeline / canary (push) Successful in 26s
CI Pipeline / best-practices (push) Successful in 2s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / type-check (push) Failing after 21s
CI Pipeline / gitea-offline (push) Successful in 14s
CI Pipeline / required-checks (push) Skipped
CI Pipeline / lint (pull_request) Successful in 26s
CI Pipeline / unit-tests (pull_request) Successful in 30s
CI Pipeline / canary (pull_request) Successful in 26s
CI Pipeline / best-practices (pull_request) Successful in 3s
CI Pipeline / documentation (pull_request) Successful in 2s
CI Pipeline / type-check (pull_request) Failing after 22s
CI Pipeline / gitea-offline (pull_request) Successful in 13s
CI Pipeline / required-checks (pull_request) Skipped
2026-09-06 17:26:17 +01:00
Nick 22fbf6e6e3 docs(p1-01): record execution deviations + completion in plan tail (#24) 2026-09-06 16:49:35 +01:00
Nick e28d1a57da feat(p1-01): canary + CI registration (#24) 2026-09-06 16:46:27 +01:00
Nick 8ec9f1a46b feat(p1-01): register capability_selection authority + docs (#24) 2026-09-06 16:43:54 +01:00
Nick 185b9f1d69 feat(p1-01): operator/runtime exposure wiring + find_capability escape hatch + trace v3 (#24) 2026-09-06 16:39:28 +01:00
Nick eb88304261 feat(p1-01): select_for_job with verified floor (#24) 2026-09-06 16:19:04 +01:00
Nick a48d8b1579 feat(p1-01): per-tool metadata + capability card synthesis/validation (#24) 2026-09-06 16:10:33 +01:00
24 changed files with 3679 additions and 103 deletions

No files matched your search

+2
View File
@@ -90,8 +90,10 @@ jobs:
src/rune/canary_p0_04.py \
src/rune/canary_p0_05.py \
src/rune/canary_p0_07.py \
src/rune/canary_p1_02.py \
src/rune/canary_p1_07.py \
src/rune/canary_p1_06.py \
src/rune/canary_p1_01.py \
src/rune/canary_query_memory.py \
src/rune/canary_unbounded.py \
src/rune/canary_full_system.py \
+311 -3
View File
@@ -3,6 +3,8 @@ name: Pipeline Order Gate
on:
issues:
types: [labeled, closed]
issue_comment:
types: [created]
schedule:
# Hourly sweep: catches newly-eligible issues when a dependency closed
# while the teacher was down/not yet deployed (temporal gap fix).
@@ -16,7 +18,7 @@ jobs:
# `Depends on: #N` reference in its body is CLOSED. Otherwise Gitea
# blocks the stage (removes ready, adds blocked, names the blocker).
gate:
runs-on: rune-infra-pi
runs-on: rune-ci-desktop
steps:
- name: Verify dependency order before allowing ready
uses: actions/github-script@v7
@@ -58,9 +60,15 @@ jobs:
}
}
if (openDeps.length === 0) {
// Auto-apply research-pending: the issue cannot start until a
// 4-phase research round (INWARD / OUTWARD / INNOVATION / SYNTHESIS)
// is completed and posted as a comment.
await github.rest.issues.addLabels({
owner, repo, issue_number: issue.number, labels: ['research-pending'],
}).catch(e => { if (e.status !== 404) throw e; });
await github.rest.issues.createComment({
owner, repo, issue_number: issue.number,
body: `🟢 **READY — order satisfied; Rune may start.** Dependencies: ${uniqueDeps.length ? uniqueDeps.map(n => '#' + n).join(', ') : 'none'}.`,
body: `🟢 **READY — order satisfied.** Dependencies: ${uniqueDeps.length ? uniqueDeps.map(n => '#' + n).join(', ') : 'none'}.\n\n📋 **Research round required** (RESEARCH ROUND V3 — 4 phases):\n1. **INWARD** — codebase analysis\n2. **OUTWARD** — state-of-the-art survey (libraries, papers, production systems; "is there anything BETTER?")\n3. **INNOVATION** — viability-filtered improvements (evaluate, optionally implement)\n4. **SYNTHESIS** — specific build instructions\n\nRune (the agent) performs the research and posts the findings as a comment on this issue. The \`research-pending\` label will be cleared automatically and the issue becomes startable.`,
});
return;
}
@@ -82,7 +90,7 @@ jobs:
# issue (open, has the orchestration-stage marker in its body, all deps
# closed) and label it ready — the order teacher keeps Rune on task.
teach:
runs-on: rune-infra-pi
runs-on: rune-ci-desktop
steps:
- name: Announce the next issue in dependency order
uses: actions/github-script@v7
@@ -154,3 +162,303 @@ jobs:
body: (() => { const closed = context.payload.issue ? ' (closed: #' + context.payload.issue.number + ')' : ' (scheduled sweep)'; return `🎓 **NEXT: #${next.number} — ${next.title}** — dependencies satisfied${closed}; Rune may start.`; })(),
});
core.info(`Announced #${next.number} as next.`);
# RESEARCH CLEAR: when a comment containing the 4-phase research markers
# lands on a research-pending issue, clear the label and announce start.
research-clear:
runs-on: rune-ci-desktop
if: github.event_name == 'issue_comment' && github.event.action == 'created'
steps:
- name: Clear research-pending when research round is posted
uses: actions/github-script@v7
with:
script: |
const issue = context.payload.issue;
if (!issue) { core.info('no issue in payload'); return; }
const comment = context.payload.comment?.body || '';
// --- Phase header detection (case-insensitive) ---
const hasInward = /\bINWARD\b/i.test(comment);
const hasOutward = /\bOUTWARD\b/i.test(comment);
const hasInnovation = /\bINNOVATION\b/i.test(comment);
const hasSynthesis = /\bSYNTHESIS\b/i.test(comment);
if (!hasInward || !hasOutward || !hasInnovation || !hasSynthesis) {
core.info(`research-clear: comment on #${issue.number} missing phases (inward=${hasInward} outward=${hasOutward} innovation=${hasInnovation} synthesis=${hasSynthesis}) — skip`);
return;
}
// --- Evidence enforcement (per phase, section-scoped) ---
// Split the comment into phase sections on the phase headers.
function section(phase) {
const re = new RegExp(`\\b${phase}\\b[\\s\\S]*?(?=\\b(?:INWARD|OUTWARD|INNOVATION|SYNTHESIS)\\b|$)`, 'i');
const m = comment.match(re);
return m ? m[0] : '';
}
const forgeHost = context.payload.repository?.html_url ? new URL(context.payload.repository.html_url).host : '';
const externalUrlRe = /https?:\/\/[^\s)\]>]+/gi;
const codePathRe = /(?:^|\s)(?:src|tests|scripts|docs|\.gitea)\/[\w./-]+/gi;
const plansPathRe = /(?:^|\s)plans\/[\w./-]+/gi;
function uniqueExternalUrls(text) {
const found = text.match(externalUrlRe) || [];
const uniq = new Map(); // normalized key -> original URL (fetchable)
for (const u of found) {
try {
const parsed = new URL(u);
if (parsed.host && parsed.host !== forgeHost) {
const key = parsed.host + parsed.pathname;
if (!uniq.has(key)) uniq.set(key, u);
}
} catch (e) { /* skip malformed */ }
}
return uniq;
}
function codeCitations(text) {
return new Set((text.match(codePathRe) || []).map(s => s.trim()));
}
const inwardSec = section('INWARD');
const outwardSec = section('OUTWARD');
const innovationSec = section('INNOVATION');
const synthesisSec = section('SYNTHESIS');
const tailSec = innovationSec + '\n' + synthesisSec;
const inwardCites = codeCitations(inwardSec);
const outwardUrls = uniqueExternalUrls(outwardSec);
const tailUrls = uniqueExternalUrls(tailSec);
const tailPlans = (tailSec.match(plansPathRe) || []).length;
const missing = [];
if (inwardCites.size < 2) missing.push(`- **INWARD**: needs ≥2 codebase citations (paths like src/..., tests/..., .gitea/...) — found ${inwardCites.size}.`);
if (outwardUrls.size < 2) missing.push(`- **OUTWARD**: needs ≥2 UNIQUE external source URLs (not this forge) — found ${outwardUrls.size}. Research must pull real sources to learn from so later stages build on evidence.`);
if (tailUrls.size < 1 && tailPlans < 1) missing.push(`- **INNOVATION/SYNTHESIS**: needs ≥1 source URL reference or a plans/ artifact path tying synthesis back to evidence.`);
// --- INNOVATION must be source-tied: ≥1 external URL from OUTWARD
// re-referenced (proving candidates derive from pulled sources, not invention).
const innovationUrlKeys = [...uniqueExternalUrls(innovationSec).keys()];
const tiedToOutward = innovationUrlKeys.filter(k => outwardUrls.has(k));
if (outwardUrls.size >= 2 && tiedToOutward.length < 1) {
missing.push(`- **INNOVATION**: candidate approaches must derive from OUTWARD sources — re-reference ≥1 of the OUTWARD source URLs here and say what it contributed. Found 0 source-tied candidates.`);
}
// --- SYNTHESIS qualification gate: the synthesis must (a) declare its
// evidence sufficiency, (b) judge feasibility REALISTICALLY (mechanism +
// assumptions + failure modes), and (c) either commit to a plan artifact
// or honestly bounce to MORE RESEARCH. Evidence-insufficient synthesis
// MUST declare MORE RESEARCH — bluffing through fails the gate.
const SYNTH_REJECTS = [];
const suffMarker = /\bevidence[^\n]{0,40}(suffic|adequat|insuffic)|insufficient evidence|evidence gap/i;
const hasSufficiency = suffMarker.test(synthesisSec);
const moreResearch = /\bMORE[- ]RESEARCH\b/i.test(synthesisSec);
const hasPlanArtifact = /(?:^|\s)plans\/[\w./-]+/.test(synthesisSec);
if (moreResearch) {
// Honest loop-back path: open questions REQUIRED, feasibility verdict
// explicitly NOT required — a substantiated MORE-RESEARCH is the honest
// outcome; demanding feasibility prose would teach padding.
const openQs = (synthesisSec.match(/\?\s*$/gm) || []).length + (synthesisSec.match(/\bopen question|\bunknown|\bunresolved/gi) || []).length;
if (openQs < 1) {
SYNTH_REJECTS.push(`- **MORE-RESEARCH declared but unsubstantiated**: name the specific open questions/unknowns that require more research (at least one '?', 'open question', or 'unknown').`);
}
if (!hasSufficiency) {
SYNTH_REJECTS.push(`- **MORE-RESEARCH qualification**: state what evidence you have, what is missing, and why the gap blocks a design decision (evidence-sufficiency statement).`);
}
} else {
// Proceed-to-plan path: full qualification — sufficiency + feasibility + kill test.
if (!hasSufficiency) {
SYNTH_REJECTS.push(`- **SYNTHESIS qualification**: no evidence-sufficiency statement. State explicitly whether OUTWARD evidence is sufficient to design from, and why (e.g. 'Evidence sufficient: <2-3 sources converge on the mechanism>' or 'Evidence insufficient: <what specifically is missing>').`);
}
const feasMarker = /\bfeasib|\bwon'?t work\b|\bwill work\b|\brisk\b|\bfailure mode\b|\bassumption\b|\brealistic/i;
const hasFeasibility = feasMarker.test(synthesisSec);
if (!hasFeasibility) {
SYNTH_REJECTS.push(`- **SYNTHESIS qualification**: no feasibility judgement. State the working mechanism, the load-bearing assumptions, and the failure modes — and judge realistically whether the synthesis WILL WORK. Optimism is not evidence.`);
}
// Kill test: a feasibility claim must name its cheapest disproof.
// The plan must carry it as an early frozen test — prose is not a prediction.
const hasKillTest = /\bkill test\b|\bdisprov|\bfalsif/i.test(synthesisSec);
if (hasFeasibility && !hasKillTest) {
SYNTH_REJECTS.push(`- **SYNTHESIS kill test**: name the cheapest observation that would DISPROVE the feasibility claim (a 'kill test:' line). For workflow-only changes the protected CI run may be the kill test — say so explicitly. A claim with no disproof is not a claim.`);
}
if (!hasPlanArtifact) {
SYNTH_REJECTS.push(`- **SYNTHESIS disposition**: must reference a concrete plans/ artifact (ready to implement) or declare **MORE-RESEARCH** with the specific open questions. Neither found.`);
}
}
// --- Trail of use: each OUTWARD source must carry an inline annotation
// (what was learned from it) on the same line — the verifiable trail.
const annotated = [...outwardUrls.entries()].filter(([, url]) => {
const line = outwardSec.split('\n').find(l => l.includes(url));
if (!line) return false;
const prose = line.replace(externalUrlRe, '').replace(/[-*`>\[\]()]/g, '').trim();
return prose.length >= 15;
});
if (outwardUrls.size >= 2 && annotated.length < 2) {
missing.push(`- **OUTWARD trail**: each source needs an inline note of what it taught (URL + ≥15 chars of what was learned, same line) — ${annotated.length}/${outwardUrls.size} annotated. A bare URL list is not a research trail.`);
}
// --- Live verification: fetch up to 5 sources; dead/hallucinated links fail.
// 2xx/3xx = real; 403/405/406/429 = real-but-bot-blocked (count as verified);
// 404/410/DNS/timeout (after 1 retry) = dead.
const toVerify = [...outwardUrls.values()].slice(0, 5);
const dead = [];
for (const u of toVerify) {
let ok = false;
for (let attempt = 0; attempt < 2 && !ok; attempt++) {
try {
const ctl = new AbortController();
const t = setTimeout(() => ctl.abort(), 8000);
const r = await fetch(u, { redirect: 'follow', signal: ctl.signal, headers: { 'User-Agent': 'rune-research-gate/1.0 (source verification)' } });
clearTimeout(t);
ok = (r.status < 400) || [403, 405, 406, 429].includes(r.status);
if (!ok && [404, 410].includes(r.status)) break; // definitively dead, no retry
} catch (e) {
clearTimeout(t);
if (attempt === 1) { /* second failure — stays dead */ }
}
}
if (!ok) dead.push(u);
}
if (dead.length > 0) {
missing.push(`- **OUTWARD verification**: source(s) failed live fetch: ${dead.join(', ')} — dead or unreachable links cannot serve as research evidence.`);
}
const labels0 = (issue.labels || []).map(l => l.name);
if (!labels0.includes('research-pending')) {
core.info(`research-clear: #${issue.number} has no research-pending label — skip`);
return;
}
if (missing.length > 0) {
await github.rest.issues.createComment({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: issue.number,
body: `🚫 **Research round REJECTED — evidence gate.** All 4 phase headers present, but the evidence floor is not met:\n\n${missing.join('\n')}\n\nresearch-pending stays. Post the missing evidence (real sources fetched, real paths read) and the gate will re-run on your next comment.`,
});
core.info(`research-clear: evidence gate rejected #${issue.number}: ${missing.length} gap(s)`);
return;
}
// --- Honest loop-back: synthesis declared MORE-RESEARCH with substantiated
// open questions and all evidence floors met. This is a SUCCESS for honesty:
// research-pending stays, the named unknowns drive the next research pass.
// Bound: after 2 prior loop-backs on this issue, escalate to the owner
// instead of cycling silently — infinite MORE-RESEARCH is dithering.
if (moreResearch) {
let priorLoopBacks = 0;
try {
const comments = await github.rest.issues.listComments({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: issue.number, per_page: 100,
});
priorLoopBacks = comments.data.filter(c => (c.body || '').includes('honest loop-back accepted')).length;
} catch (e) { core.info('loop-back count fetch failed: ' + (e && e.message)); }
if (priorLoopBacks >= 2) {
await github.rest.issues.createComment({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: issue.number,
body: `⏸️ **Loop-back bound reached (${priorLoopBacks} prior MORE-RESEARCH cycles).** The synthesis again declares MORE-RESEARCH with substantiated open questions. Escalating to the owner rather than cycling silently: this issue needs a human decision — narrow the scope, accept a design under uncertainty, or split the unknowns into their own research issues.`,
});
await github.rest.issues.addLabels({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: issue.number, labels: ['blocked'],
}).catch(e => { if (e.status !== 404) throw e; });
core.info(`research-clear: loop-back bound reached on #${issue.number} — escalated with blocked label.`);
return;
}
await github.rest.issues.createComment({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: issue.number,
body: `🔁 **Synthesis not ready — honest loop-back accepted (cycle ${priorLoopBacks + 1}).** Evidence floors met (INWARD citations: ${inwardCites.size}, OUTWARD sources: ${outwardUrls.size}, trail annotated: ${annotated.length}, live-verified: ${outwardUrls.size - dead.length}), but the synthesis itself declares MORE-RESEARCH with substantiated open questions.\n\nresearch-pending stays. Next round must resolve the named unknowns — a later synthesis must either produce a plans/ artifact or identify NEW unknowns. Bound: 2 loop-backs, then escalation.`,
});
core.info(`research-clear: honest MORE-RESEARCH loop-back on #${issue.number} (cycle ${priorLoopBacks + 1}) — research-pending retained.`);
return;
}
// Clear research-pending, add in-progress
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: issue.number, name: 'research-pending',
});
} catch (e) { if (e.status !== 404) throw e; }
await github.rest.issues.addLabels({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: issue.number, labels: ['in-progress'],
}).catch(e => { if (e.status !== 404) throw e; });
await github.rest.issues.createComment({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: issue.number,
body: `🔬 **Research round verified — synthesis qualified.** 4 phases + evidence floor met (INWARD citations: ${inwardCites.size}, OUTWARD sources: ${outwardUrls.size}, trail annotated: ${annotated.length}, live-fetch verified: ${outwardUrls.size - dead.length}); synthesis declared evidence ${hasSufficiency ? 'sufficiency statement ✓' : '✓'} + feasibility judgement ✓ and committed to a plans/ artifact.\n\n▶️ **in-progress applied** — Rune is now working on this issue. The pipeline will notify at each transition.`,
});
core.info(`research-clear: #${issue.number} research verified with evidence, label cleared.`);
# INCOMPLETE CATCHER: detect branches with commits but no PR, belonging to
# in-progress issues — auto-create draft PR with incomplete label + ledger.
# Runs on the hourly sweep (piggybacks on existing schedule trigger).
incomplete-catcher:
runs-on: rune-ci-desktop
steps:
- name: Detect abandoned in-progress work and create draft PRs
uses: actions/github-script@v7
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
// Find open issues with in-progress label
const issues = [];
for (let page = 1; ; page++) {
const res = await github.rest.issues.listForRepo({
owner, repo, state: 'open', labels: 'in-progress', per_page: 50, page,
});
issues.push(...res.data.filter(i => !i.pull_request));
if (res.data.length < 50) break;
}
if (issues.length === 0) { core.info('No in-progress issues.'); return; }
// Find open branches
const branches = await github.rest.repos.listBranches({ owner, repo, per_page: 100 });
const branchNames = branches.data.map(b => b.name);
// Find existing PRs
const prs = await github.rest.pulls.list({ owner, repo, state: 'open', per_page: 100 });
const prHeads = new Set(prs.data.map(p => p.head.ref));
for (const issue of issues) {
// Look for a branch matching the issue pattern
const branch = branchNames.find(b => b.includes(String(issue.number)) || b === issue.head_branch);
if (!branch || prHeads.has(branch)) continue;
// Branch exists, no PR yet — create draft PR with incomplete label
try {
const pr = await github.rest.pulls.create({
owner, repo,
title: `[INCOMPLETE] ${issue.title}`,
head: branch, base: 'master', draft: true,
body: [
'## Incomplete Work Ledger',
'',
`**Issue:** #${issue.number}`,
`**Branch:** ${branch}`,
`**Status:** Work in progress, paused mid-implementation.`,
'',
'### What exists',
'- See commits on this branch',
'',
'### What remains',
'- Resume implementation from where it left off',
'- Run gates when complete',
'',
'### Resume instructions',
'1. Check out this branch',
'2. Read the plan file in the worktree',
'3. Continue from the last commit',
'4. Mark ready when implementation is complete',
].join('\n'),
});
await github.rest.issues.addLabels({
owner, repo, issue_number: pr.data.number, labels: ['incomplete'],
});
await github.rest.issues.createComment({
owner, repo, issue_number: issue.number,
body: `📋 **Incomplete work captured** — draft PR #${pr.data.number} created from branch \`${branch}\`. Resume from there when ready.`,
});
core.info(`Created draft PR #${pr.data.number} for issue #${issue.number}`);
} catch (e) {
if (e.status !== 422) throw e; // 422 = PR already exists
}
}
+21 -15
View File
@@ -103,10 +103,10 @@
"evidence": "REMOVED by P0-04 (#20): _run_warm_router is a documented no-op; the ContextBroker (#19) owns all model-facing context. See docs/pipeline-removal.md."
},
{
"path": "src/rune/attention_capsule.py:96-163",
"role": "Bounded working-set candidate",
"disposition": "dormant-until-dependency",
"evidence": "Creates a bounded capsule but does not own prompt construction."
"path": "src/rune/attention_capsule.py",
"role": "AttentionCapsule — the typed ledger of admitted blocks the model receives (to_messages re-derives the runtime payload)",
"disposition": "retain-authority",
"evidence": "P1-02 (#25): the capsule IS the product the model receives — the broker renders admitted blocks into it, the safety override mutates its user block in place (re-measured), runtime.run consumes capsule.to_messages(), and the trace v4 capsule_messages/capsule_content_hashes prove the logged capsule EXACTLY equals the sent payload. Legacy create_capsule/to_prompt (string-trim fake budgeting) deprecated with no live callers."
},
{
"path": "src/rune/inference_trace.py",
@@ -151,28 +151,34 @@
},
{
"id": "capability_selection",
"current_authority": "All tool schemas in LocalToolRuntime; broker is advisory",
"current_authority": "CapabilityBroker.select_for_job owns tool-schema exposure; executor keeps execution authority",
"v1_owner": "ContextBroker.capability_selector",
"dependency_issue": "#24",
"migration_rule": "Select a bounded tool schema set per CognitiveJob and retain one discovery escape hatch.",
"current_paths": [
{
"path": "src/rune/capability_broker.py:293-394",
"role": "Capability-card generation and relevance matching candidate",
"disposition": "dormant-until-dependency",
"evidence": "Matches cards but is not passed into send_task."
"path": "src/rune/capability_broker.py",
"role": "Authoritative capability selector (select_for_job + verified floor + find_capability escape hatch)",
"disposition": "retain-authority",
"evidence": "P1-01 (#24): select_for_job is the single selection authority — its ordered decision feeds LocalToolRuntime(tools=) at both construction sites and the _contributor_tools prompt block; degrade-not-crash falls back to full TOOLS with a tier-1 record_failure."
},
{
"path": "memory/index/capability_cards.json",
"role": "Packaged capability-card index",
"disposition": "archive-candidate",
"evidence": "Packaged file is an empty array and empty content does not trigger rebuild."
"role": "Capability-card index (rebuilt on empty/stale/corrupt; native cards synthesized from TOOLS metadata)",
"disposition": "migrate-adapter",
"evidence": "P1-01 (#24): load_cards validates and rebuilds — the packaged empty array no longer passes as an index; cards carry risk/preconditions/side_effects copied from tool_executor.TOOLS."
},
{
"path": "src/rune/local_tool_runtime.py:129-152",
"role": "Current effective tool-schema selection",
"path": "src/rune/local_tool_runtime.py",
"role": "Runtime consumes the selected set; find_capability escape hatch exposes discovered tools in-run",
"disposition": "migrate-adapter",
"evidence": "Defaults the runtime tool list to every tool definition."
"evidence": "P1-01 (#24): tools= receives the broker-selected schemas (zero-token guarantee for unselected capabilities); unknown-tool errors name find_capability; resume round-trips rebuild the persisted exposed_tools set."
},
{
"path": "src/rune/rune_operator.py",
"role": "Operator wiring: selection flows to both LocalToolRuntime sites and the tools prompt block",
"disposition": "migrate-adapter",
"evidence": "P1-01 (#24): _capability_selection consults the broker once per task; trace schema v3 records selected_tools / capability_reason / find_capability_calls."
}
]
},
+73
View File
@@ -0,0 +1,73 @@
# Capability Selection — the broker is the authority (#24, P1-01)
## What changed
`capability_broker.py` is now the **single capability-selection authority**
(authority map `capability_selection` → v1_owner
`ContextBroker.capability_selector`). Before #24 every run exposed all 19
tool schemas to the model and the broker was advisory. Now:
- `select_for_job(task_description, need_tools=True)` returns an ordered
tool-schema selection with a recorded reason. The operator feeds that
exact list into `LocalToolRuntime(tools=...)` at both construction sites,
so **unselected capabilities contribute zero prompt tokens** (schemas are
excluded from the native `tools` payload, not just hidden from the prompt
block).
- **Verified floor**: every tool-using job sees at least
`FLOOR_TOOLS = read_file, write_file, edit_file, list_directory,
grep_files` plus the escape hatch — selection is never empty and never
misses the floor for coding tasks. A zero-score task falls back to the
floor with reason `"floor (no matches)"`.
- **Escape hatch**: `find_capability` is a native read-only tool that is
always exposed. Executing it queries the broker (`find_capability(query)`)
and returns matching capability summaries with argument schemas; the
runtime then **dynamically exposes the discovered native tools for the
rest of the run** (within the existing 8-iteration budget — no extra
iterations granted). Unknown-tool errors name the hatch so the model can
recover in one step.
- **Selection is deterministic** (keyword scorer over TOOLS-derived cards,
ties broken by name) and bounded (`MAX_SELECTED = 10`).
- **Task-family input** reuses `infer_task_family` (#29) for coherence with
model routing.
## Ownership boundaries
- Selection authority: `capability_broker.select_for_job`.
- Execution authority: unchanged in `tool_executor` (#21) — the approval
split (`tool_requires_approval` / `PreparedTool`) stays authoritative for
gating; per-tool `risk` metadata informs selection, never bypasses
approval.
- Degrade-not-crash (P0-06): any broker failure falls back to the full
TOOLS set (the pre-#24 behavior) and records a tier-1
`record_failure("capability_selection", ...)`.
## Cards
`load_cards` validates the card index and **rebuilds when the file is
missing, empty, corrupt, or stale** (mtime older than the registry).
Native-tool cards are synthesized from `tool_executor.TOOLS` metadata —
the single source for `risk` (`read-only` | `write` | `irreversible`),
`preconditions`, and `side_effects` — so selection has full coverage even
with no plugins installed.
## Resume round-trip
`tool_names` is validated on resume, so the operator persists
`exposed_tools` (the selected set plus any tool dynamically exposed via
`find_capability` mid-run) into `pending_state`; `_runtime_for_model`
rebuilds exactly that set for the resumed runtime.
## Trace
Schema v3 (additive): `selected_tools`, `capability_reason`,
`find_capability_calls`. v1/v2 rows and readers stay compatible.
## Verification
- Frozen tests: `tests/test_p1_01_capability_authority.py`.
- Canary: `src/rune/canary_p1_06.py`-style self-bootstrap in
`src/rune/canary_p1_01.py` (registered in `.gitea/workflows/ci.yml`) —
asserts ~5–10 exposed schemas, a byte-size drop in the tools payload,
the escape-hatch round-trip, and the floor guarantee.
- Composition: `compose_capability_selection(root, state_dir)` registers
exactly one `CapabilityBroker` per composition root.
+67
View File
@@ -0,0 +1,67 @@
# Capsule Fidelity (P1-02, #25)
The AttentionCapsule is the **product the model receives** — not a
side-report. This document states the invariant, the mechanism, and how to
verify it.
## The equality invariant
> The logged capsule contents EXACTLY equal the bounded dynamic context
> supplied to the model.
Concretely, three renderings of one turn must be identical:
1. `capsule.to_messages()` — re-derived from the admitted-block ledger,
2. the message list passed to `LocalToolRuntime.run(...)` — captured
pre-run via `capsule.capture_messages()` → `capsule.sent_messages`,
3. `InferenceTrace.capsule_messages` — the durable JSONL log.
Token counts alone cannot prove this; the message list and per-block
content hashes can.
## Mechanism
- **Assembly:** `ContextBroker.assemble()` collects typed
`ContributionBlock`s, measures every block (`len//4`, deterministic and
conservative), admits against the input budget, and renders the admitted
blocks into the capsule (`capsule.blocks`).
- **Payload derivation:** `AttentionCapsule.to_messages()` re-derives the
chat message list from `capsule.blocks` on every call — in-place block
mutation always flows through; there is no cached copy to diverge.
- **Capture:** `RuneOperator._execute_on_model` calls
`capsule.capture_messages(capsule.to_messages())` immediately before
`runtime.run`. The capture is a deep copy, so later mutation cannot
alter what was logged.
- **Integrity hashes:** the trace stores `{block_name: sha256(content)}`
(string content hashed as UTF-8; list/history content as canonical
JSON) — full block text is NOT logged raw (JSONL size discipline).
- **Safety-override re-measure:** the override decorates the user block in
place per escalation tier; the operator re-runs the estimator on the
mutated block, updates `block.tokens`, the broker trace's per-block
entry, and `capsule.token_count`. No re-admission (the override grows
≤ ~100 tokens and admission left headroom) — but measurement is always
truthful.
- **num_ctx propagation:** the runtime receives
`num_ctx = capsule.token_count + completion_reserve` where
`completion_reserve = broker window − broker budget`
(`context_broker.default_budget` reserves the completion room). The
runtime therefore respects the broker's admission decision instead of
the raw window. #28 owns the broader model-aware budget work.
## Verification
- Frozen tests: `tests/test_p1_02_capsule_authority.py` — asserts
`trace.capsule_messages == runtime-received messages` (identity),
hash correctness, re-measure truthfulness, and num_ctx propagation.
- Canary: `python3 src/rune/canary_p1_02.py` prints
`P1-02 CAPSULE FIDELITY CANARY PASSED` (registered in
`.gitea/workflows/ci.yml`'s canary loop).
## Retired legacy path
`attention_capsule.create_capsule` and `AttentionCapsule.to_prompt` are
**deprecated** (`DeprecationWarning`): their string-trim "budgeting" was
never real admission. The ContextBroker remains the sole prompt-assembly
authority; the CLI entry point keeps them only for standalone debugging.
`ContributionBlock.to_dict()` now includes `content`, so pending-state
(approval-resume) capsules are verifiable end-to-end.
+13 -2
View File
@@ -17,11 +17,11 @@ is gitignored, P0-05 M6) — never committed. Consumers: #25, #28, #29.
Read back with `rune.inference_trace.read_traces()` or
`python3 -m rune.inference_trace [n]` (tail n records).
## Record schema (version 1)
## Record schema (version 4)
| Field | Source | Meaning |
|---|---|---|
| `schema_version` | constant (`1`) | Record shape version for consumers |
| `schema_version` | constant (`4`) | Record shape version for consumers |
| `provider` | `OllamaChatTransport.provider` | `"ollama"` (class constant) |
| `model` | operator | Model id the run executed on |
| `task_id` / `channel` | operator (TaskState #18) | Joins the trace to TaskState |
@@ -43,6 +43,17 @@ Read back with `rune.inference_trace.read_traces()` or
| `cost_usd` | `price_for(model)` | `None` until the price map gains entries |
| `cost_basis` | derived | `"local-unpriced"` or `"price-map"` |
| `success` / `error` | final `OperatorResponse` | Run outcome; error truncated to 300 chars |
| `routing_reason` / `routing_mode` / `task_family` | router (P1-06, #29, v2) | Start-tier decision evidence; empty on pre-router rows |
| `selected_tools` / `capability_reason` / `find_capability_calls` | broker selection (P1-01, #24, v3) | Capability-authority evidence |
| `capsule_messages` | capsule capture (P1-02, #25, v4) | The EXACT message list `runtime.run` received — equality with the logged capsule is the acceptance invariant |
| `capsule_content_hashes` | operator (P1-02, #25, v4) | `{block_name: sha256(content)}` — integrity without full JSONL bloat |
| `capsule_token_count` | capsule re-measure (P1-02, #25, v4) | Post-safety-override re-measured total |
Schema history: v1 (P0-07 #23), v2 additive routing fields (P1-06 #29),
v3 additive capability fields (P1-01 #24), v4 additive capsule-fidelity
fields (P1-02 #25). All bumps are additive with defaults — older rows
without the new keys still parse (`read_traces` consumers must tolerate
missing keys).
## Authority and single-source rule
+15 -6
View File
@@ -51,7 +51,7 @@ show.
| `task_state` | `RuneState` with competing task ledgers | `TaskStateRepository` | #18 |
| `context_construction` | Operator prompt assembly + parallel TUI preprocessing | `ContextBroker` | #19 |
| `memory_selection` | Multiple direct recall/association paths | `ContextBroker.memory_selector` | #26 |
| `capability_selection` | All runtime tools; broker advisory only | `ContextBroker.capability_selector` | #24 |
| `capability_selection` | `CapabilityBroker.select_for_job` owns tool-schema exposure | `ContextBroker.capability_selector` | #24 |
| `model_routing` | TUI model selection + operator keyword fallback | `ModelRouter` | #29 |
| `provider_adaptation` | Direct Ollama chat transport | `ProviderAdapterRegistry` | #29 |
| `tool_schema_execution` | Tool protocol/runtime/executor trio | `ExecutionCore` | #21 |
@@ -108,12 +108,21 @@ Evidence: `memory_store.py:43-72`, `rune_operator.py:424-669`,
### `capability_selection`
The broker currently displays matches, but `LocalToolRuntime` defaults to every
tool schema and the packaged card index is empty. #24 makes capability selection
causal: a bounded set is exposed, with a small discovery escape hatch.
LIVE as of P1-01 (#24): `capability_broker.select_for_job` is the single
selection authority — its ordered decision feeds `LocalToolRuntime(tools=)`
at both operator construction sites and the tools prompt block. A verified
floor (five T1 file tools + `find_capability`) is never dropped; the
`find_capability` native tool is the discovery escape hatch and dynamically
exposes discovered tools within the running session. The card index is
rebuilt on empty/stale/corrupt with native cards synthesized from
`tool_executor.TOOLS` metadata. Execution authority stays in
`tool_executor` (#21).
Evidence: `capability_broker.py:293-394`,
`local_tool_runtime.py:129-152`, `memory/index/capability_cards.json`.
Evidence: `capability_broker.py` (`select_for_job`, `find_capability`,
`load_cards`), `local_tool_runtime.py` (hatch dispatch, unknown-tool
guidance), `rune_operator.py` (`_capability_selection`, trace v3),
`memory/index/capability_cards.json`,
`docs/capability-selection.md`.
### `model_routing`
@@ -0,0 +1,128 @@
# P1-01 CapabilityBroker Authoritative — Implementation Plan (#24)
> **For agentic workers:** execution contract — FROZEN TESTS ARE THE SPEC. Implement verbatim; record every deviation in the plan tail. TDD red → green. Do not create/complete tasks; controller owns task state.
**Goal:** the model is exposed only the capability schemas that fit the current task (100 installed → coding job sees ~5–10); unused capabilities cost zero prompt tokens; a tiny `find_capability` escape hatch lets the model request an unexposed capability; capability cards gain preconditions/risk/side-effect metadata and stale/empty validation.
**Architecture:** `capability_broker.py` becomes the single selection authority (per `config/v1_authority_map.json` `capability_selection` → v1_owner `ContextBroker.capability_selector`): `select_for_job(task_description, need_tools)` returns an ordered tool-name selection with reason; the operator feeds that selection into `LocalToolRuntime(tools=...)` (both construction sites) and replaces the names-only `_contributor_tools` prompt block with the selected set; a always-exposed tiny `find_capability` native tool is the escape hatch; unknown-tool requests route to a helpful error listing `find_capability`. Cards gain `preconditions`, `side_effects`, per-tool `risk` and rebuild on empty/stale. Composition registers the broker as the single selection authority.
**Tech stack:** stdlib + existing house modules only. No new services.
## Global Constraints
- Authority map rule: capability selection is owned by ContextBroker-side selection; do NOT create a parallel authority; executor (`tool_executor`) keeps execution authority (#21).
- Native schemas ride the `tools` API param — selection MUST flow to `LocalToolRuntime(tools=)`, prompt block alone is insufficient.
- `tool_names` is validated on resume (`local_tool_runtime.py:323`) — resume round-trips must use the same selected set.
- Selection must never return an empty set for a coding-style task: verified floor = T1 file tools (read_file, write_file, edit_file, list_dir, search_files) + find_capability.
- The 8-iteration budget: `find_capability` results must let the model name a tool that the SAME run can then call (dynamic exposure within the run) — otherwise the hatch is useless.
- Degrade-not-crash (P0-06): any broker failure falls back to full TOOLS (today's behavior) + `record_failure("capability_selection", ..., tier=1)`.
- TDD; ruff check + format on touched/added files; canary registered in ci.yml same commit.
## Design Decisions (settled)
1. **Owner boundary:** CapabilityBroker executes selection; the broker's decision is *the* authority for what the runtime exposes (the authority-map's "ContextBroker.capability_selector" names the selection point inside context assembly — implemented as the broker module invoked from context/tool-exposure assembly; no second selector).
2. **Selection basis:** deterministic keyword scorer (existing `score_card`) over per-tool metadata derived from TOOLS + cards; floor set on zero-score. Task-family input via `infer_task_family` (model_routing, #29) for coherence.
3. **Escape hatch:** `find_capability` is a native tool always in the selected set. Executing it queries the broker and returns matching capability summaries (name + description + schema). The runtime's unknown-tool error path suggests `find_capability` and dynamically registers a requested tool into the running session's tool set when it exists (within iteration budget — no extra iterations granted).
4. **Card validation:** `load_cards` rebuilds when file missing OR empty OR stale (mtime older than `.microkernel.json` registry). Cards for native tools are synthesized from TOOLS metadata so selection has full coverage even with no plugins.
5. **Metadata schema:** per-tool `risk` ("read-only"|"write"|"irreversible"), `preconditions` (list), `side_effects` (list) added to TOOLS entries (single source; cards reference tool names). Approval split (`tool_requires_approval`) stays authoritative for gating.
6. **Trace additive fields (v3):** `selected_tools: list[str] = []`, `capability_reason: str = ""`, `find_capability_calls: int = 0` — additive defaults, v1/v2 readers unaffected.
## Tasks
### Task 1 — Per-tool metadata + card synthesis/validation
**Files:** modify `src/rune/tool_executor.py` (metadata on TOOLS), `src/rune/capability_broker.py` (synthesis, rebuild-on-empty/stale), test `tests/test_p1_01_capability_authority.py`.
- [ ] Step 1: failing tests — every TOOLS entry carries risk/preconditions/side_effects; `load_cards` synthesizes native cards when cards file empty; rebuild triggers on empty file and on stale mtime vs registry.
- [ ] Steps 2–4: red → implement → green.
- [ ] Step 5: commit `feat(p1-01): per-tool metadata + capability card synthesis/validation (#24)`
### Task 2 — select_for_job + verified floor
**Files:** modify `src/rune/capability_broker.py`.
- [ ] Step 1: failing tests — coding task returns floor+match set (5–10 tools, never empty); zero-score → floor with reason "floor (no matches)"; reason strings recorded; `find_capability` always present; selection deterministic.
- [ ] Steps 2–4: red → implement (`select_for_job`) → green.
- [ ] Step 5: commit `feat(p1-01): select_for_job with verified floor (#24)`
### Task 3 — Operator + runtime wiring (zero-token guarantee)
**Files:** modify `src/rune/rune_operator.py` (both LocalToolRuntime sites, `_contributor_tools`), `src/rune/local_tool_runtime.py` (find_capability tool + unknown-tool guidance), `src/rune/inference_trace.py` (v3 fields), test file.
- [ ] Step 1: failing tests — run with selected set exposes ONLY selected schemas (assert `to_ollama_tools` payload size shrinks); unused tools contribute zero tokens; trace v3 fields recorded; unknown tool call returns guidance error; find_capability execution returns matching schema summary and the same run can call the discovered tool.
- [ ] Steps 2–4: red → implement → green; full suite regression (baseline 545+84).
- [ ] Step 5: commit `feat(p1-01): operator/runtime exposure wiring + find_capability escape hatch + trace v3 (#24)`
### Task 4 — Composition registration + authority map + docs
**Files:** `src/rune/orchestration/composition.py` (+ kind), `tests/test_orchestration_composition.py`, `config/v1_authority_map.json` (flip capability_selection paths to LIVE), `docs/capability-selection.md`.
- [ ] Steps: red → green → map/docs → commit `feat(p1-01): register capability_selection authority + docs (#24)`
### Task 5 — Canary + CI registration
**Files:** create `src/rune/canary_p1_01.py`; modify `.gitea/workflows/ci.yml` same commit.
- [ ] Canary: scripted operator run asserts ~5–10 exposed (not 18), zero-token claim (schema payload byte-size drop), escape hatch round-trip, floor guarantee; prints `P1-01 CAPABILITY AUTHORITY CANARY PASSED`.
- [ ] Full suite; ruff; henxels; commit `feat(p1-01): canary + CI registration (#24)`
## Acceptance (from #24)
- 100 installed capabilities can exist while a coding job exposes ~5–10 (floor+matches; verified by canary count assertion).
- Unused capabilities add zero prompt tokens (schemas excluded from the tools payload; assert byte-size reduction).
- Model can dynamically request an unexposed capability (find_capability → summary; unknown-tool error names the hatch).
- Cards validated/auto-regenerated; preconditions/risk/side-effect metadata present.
## Pipeline requirements checklist
- [ ] TDD red→green (frozen tests per task)
- [ ] ruff check + format on touched/added files
- [ ] Spec gate PASS; Quality gate Approved (discharges if needed)
- [ ] Full-suite regression green (baseline 545+84)
- [ ] Canary green + registered same commit; henxels exit 0
- [ ] Closed with formatted comment + PR refs
## Execution deviations
1. **Floor name mapping.** Plan floor names `list_dir`/`search_files` do not exist in `tool_executor.TOOLS`; mapped to the actual T1 schema names `list_directory`/`grep_files`. `FLOOR_TOOLS = read_file, write_file, edit_file, list_directory, grep_files` + `find_capability`.
2. **find_capability schema + executor live in tool_executor.py.** Plan's Task 3 file list put the hatch only in `local_tool_runtime.py`, but `tool_protocol.validate_tool_request` validates every request against `tool_executor.TOOLS`/`TOOL_INDEX` — a hatch schema outside TOOLS fails normalization before the runtime could dispatch it. Schema, `_find_capability` handler, and `READ_ONLY_TOOLS` entry are in tool_executor; the runtime-side hatch logic (dispatch interception, in-run dynamic exposure, call counting, unknown-tool guidance) is in local_tool_runtime as planned.
3. **Dynamic-exposure channel narrowed (frozen-test conflict).** Design Decision 3 said the unknown-tool error path "dynamically registers a requested tool into the running session's tool set when it exists". Frozen test `test_local_tool_runtime.py::test_runtime_rejects_globally_valid_tool_outside_its_allowlist` pins rejection of a registry-valid tool outside the allowlist (P0-05 gating contract). Resolution: the unknown-tool path keeps the rejection and adds `find_capability` guidance; dynamic exposure happens exclusively through `find_capability` execution results (still satisfies the same-run discovery requirement and the acceptance bullet).
4. **Frozen tests updated for the planned schema bump v2→v3.** `tests/test_p1_06_model_routing.py` (assertion `schema_version == 2` → `== INFERENCE_TRACE_SCHEMA_VERSION`, test renamed `..._schema_v3`) and `src/rune/canary_p1_06.py` (expects 3). Additive v3 fields keep v1/v2 rows and readers compatible.
5. **Task-family source.** `infer_task_family` lives in `rune_operator.py:55`, not `model_routing.py` as the plan parenthetical says; the broker lazy-imports it from rune_operator (degrade → `"general"`).
6. **Task 2 test refinement during red→green.** Initial draft asserted exact-set equality for coding queries; the plan spec allows floor+matches (5–10), so tests assert floor ⊆ selection, hatch present, size bounds, and exact floor+hatch only for zero-score queries. Plugin cards cannot add tool schemas (plugins have no executor): a test pins that selection never invents non-schema tools; plugin matches surface through `find_capability` results instead.
7. **Resume mechanism.** Global constraint "resume round-trips must use the same selected set" is implemented by persisting an additive `exposed_tools` key into `pending_state` (selected set + any in-run discovered tools); `_runtime_for_model` rebuilds exactly that set, with deterministic re-selection from the task description as fallback for pre-#24 pending states.
8. **Composition signature.** `compose_capability_selection(root, state_dir)` mirrors `compose_model_routing`; `CapabilityBroker` accepts `cards_dir` for symmetry but card I/O stays on the memory_env-derived module paths.
9. **Cards file rewrite on first load.** `load_cards` now rebuilds the packaged empty `[]` index (missing-native/empty/stale/corrupt triggers) and persists plugin + synthesized native cards. Repo copy untouched; runtime writes go to `RUNE_MEMORY_DIR`.
10. **trace v3 writer.** Operator passes `schema_version=INFERENCE_TRACE_SCHEMA_VERSION` (imported) instead of the literal 3, and calls `_capability_selection` (cached per description) for `selected_tools`/`capability_reason`; `find_capability_calls` counted from `response.tool_events`.
## Completion (P1-01 #24)
- **Branch:** `p1-01-capability-authority` @ `2db0c3d` base. **Head after plan-tail commit:** see git log below.
- **Per-task commits:**
- Task 1 `a48d8b1` — feat(p1-01): per-tool metadata + capability card synthesis/validation (#24)
- Task 2 `eb88304` — feat(p1-01): select_for_job with verified floor (#24)
- Task 3 `185b9f1` — feat(p1-01): operator/runtime exposure wiring + find_capability escape hatch + trace v3 (#24)
- Task 4 `8ec9f1a` — feat(p1-01): register capability_selection authority + docs (#24)
- Task 5 `e28d1a5` — feat(p1-01): canary + CI registration (#24)
- **Red evidence (TDD):**
- Task 1: `6 failed, 1 passed` — no risk/preconditions/side_effects on TOOLS, no native synthesis, no rebuild-on-empty/stale.
- Task 2: `11 failed, 7 passed` — no `FLOOR_TOOLS`/`select_for_job`/degrade path.
- Task 3: `9 failed` — no trace v3 fields, no `_find_capability` executor, no operator `tools=` wiring, hard-fail without hatch guidance.
- Task 4: `4 failed, 32 passed` — no `capability_selection` authority kind, map not flipped, no docs/capability-selection.md.
- Task 5: canary file absent + unregistered in ci.yml (henxels `ci_runs_all_canaries` bites) → green prints `P1-01 CAPABILITY AUTHORITY CANARY PASSED`.
- **Final counts:** `581 passed, 84 subtests passed` (baseline 545 + 84; +36 new frozen tests, zero regressions).
- **Checks:** ruff check PASS on all touched files; ruff format PASS on added files; henxels `check --all --plain` exit 0 (21 pre-existing duplicate warnings); all 22 repo canaries green including new `canary_p1_01.py` registered in `.gitea/workflows/ci.yml` same commit.
- **Not done (out of scope per brief):** push, PR, spec/quality gate comments — controller owns those steps.
## Quality discharges (post-review, P1-01 #24)
All findings from the quality review (Approved WITH findings) discharged on branch `p1-01-capability-authority`. Tests written red-first for behavior changes; deletions/atomicity got targeted tests where meaningful.
1. **IMPORTANT — duplicate `load_cards` (F811, capability_broker.py:474/:577).** Fix: deleted the FIRST definition (the byte-identical copy at :577 remains, preceded by `build_cards`); module now defines `load_cards` exactly once. Test: `test_load_cards_is_defined_once` — `inspect.getsource` counts `def load_cards` == 1.
2. **minor — unused `examples` (capability_broker.py:427).** Fix: deleted the dead `examples = [f"{name} {description}"][:1]` line in `_native_tool_cards` (native cards use empty `semantic_examples` + derived `searchable_text`, unchanged). Test: covered by ruff F841-free check on touched file + existing card-metadata tests (`test_native_cards_carry_tool_metadata`) staying green.
3. **minor — `read_persona` schema lost `{"type":"object","properties":{}}` (tool_executor.py:364).** Fix: restored the valid JSON-schema object shape. Test: `test_every_tool_schema_is_a_valid_json_object_schema` — every TOOLS entry has `parameters.type == "object"` and dict `properties` (red against the old `{}`).
4. **minor — hatch dispatch bypassed `dispatch_validated_tool` (local_tool_runtime.py:521).** Fix: routed find_capability through the SAME `dispatch_validated_tool` as all other tools (validated dispatch requires only schema-validated args, which the hatch schema defines — no docstring exception needed). Test: `test_hatch_dispatch_routes_through_validated_dispatch` — monkeypatched dispatch spy asserts `find_capability` rides the validated path, call counter still increments, `repo_map` exposure still flows. RED verified against pre-fix code (git stash) before green. Note: `dispatch_validated_tool` receives only (request, executor); boundedness of hatch output stays enforced at the source (`find_capability(top_k=5)`, truncated summary lines) and remains pinned by the existing `test_select_for_job_*` bounds tests.
5. **minor — pending_state did not carry the exposed set.** Fix: `_run_loop` now embeds additive `exposed_tools` (sorted `self.tool_names` = selected + dynamically exposed) into the runtime-built pending_state; `resume()` reads the key (fail-closed type check, backward compatible — pre-#24 states lack it) and `_expose_tools` re-adds only names with real schemas before the allowlist check. Test: `test_resume_honors_exposed_tools_in_pending_state` — REAL `LocalToolRuntime` (unmocked), real broker-backed hatch query exposes `git_commit`, run suspends on approval, a FRESH runtime resumes the same state to completion. RED verified against pre-fix code.
6. **minor — triple `_capability_selection` per run (rune_operator.py:1795).** Fix: hoisted to a single run-level call (`capability_selection = self._capability_selection(task.description)` before the tier loop); runtime construction and both trace v3 fields read the hoisted value. Operator-level degrade now propagates `reason: "fallback full toolset (broker unavailable)"` so `capability_reason` is never silently empty while the full toolset is selected. Tests: `test_capability_selection_degrade_reason_propagates` (exact reason string + full-TOOLSET selection under broker failure) and `test_operator_run_selects_capabilities_once` (spy asserts `call_count == 1` across a full scripted `send_task` run).
7. **minor — non-atomic cards rebuild write (capability_broker.py:497).** Fix: rebuild now writes a sibling temp file (`capability_cards.json.tmp`) then `os.replace` into place; OSError cleanup removes the temp. Test: `test_load_cards_rebuild_write_is_atomic` — monkeypatched `os.replace` spy asserts exactly one temp+rename sequence with a sibling temp path and the final index parses to the loaded cards.
**Verification:** full suite `588 passed, 84 subtests passed` (baseline 581+84; +7 discharge tests, zero regressions); ruff check PASS on all touched files; ruff format PASS on the touched test file; henxels `check --all --plain` exit 0; `canary_p1_01.py` prints `P1-01 CAPABILITY AUTHORITY CANARY PASSED`.
+127
View File
@@ -0,0 +1,127 @@
# P1-02 AttentionCapsule → Real Model Product — Implementation Plan (#25)
> **For agentic workers:** execution contract — FROZEN TESTS ARE THE SPEC. Implement verbatim; record every deviation in the plan tail. TDD red → green. Do not create/complete tasks; controller owns task state.
**Goal:** the logged capsule contents EXACTLY equal the bounded dynamic context supplied to the model; no token_count-only fake budgeting; the capsule is provably the product the model receives.
**Architecture:** the broker already produces the capsule and derives messages from it (P0-03 did most of the wiring). This plan closes the *evidence and integrity* gaps: (1) a content-complete capsule log on the InferenceTrace (additive v4, hashes + message list, not full block text — size discipline); (2) a capsule-fidelity canary that asserts `capsule.to_messages()` == the messages passed to `runtime.run` == the logged capsule messages; (3) safety-override decoration re-measured into the capsule; (4) legacy `create_capsule`/`to_prompt` retired; (5) `ContributionBlock.to_dict` includes content so pending-state/log capsules are verifiable; (6) authority map flipped to retain-authority.
**Tech stack:** stdlib + existing house modules. No new services, no parallel assembly.
## Global Constraints
- Acceptance criterion (from #25): logged capsule contents EXACTLY equal the bounded dynamic context supplied to model. This is provable ONLY if the log records message content and the canary asserts identity — token counts alone are insufficient.
- No token_count-only fake budgeting: `create_capsule`/`to_prompt` (the legacy string-trim "budgeting" path) retired or clearly marked CLI-only.
- Trace additive discipline: new fields have defaults; v1/v2/v3 readers unaffected.
- Degrade-not-crash: capsule logging must never fail the turn (P0-06).
- Full block content is NOT logged raw (JSONL bloat) — log message list (the actual payload) + per-block content SHA-256 for integrity.
- num_ctx ownership: #28 owns model-aware context budgets; this issue passes the broker budget (not the raw window) as num_ctx — the one-line fix that makes the runtime respect admission.
- Safety-override decorated user block MUST be re-measured so `capsule.token_count` stays truthful post-decoration.
- TDD; ruff check + format on touched/added files; canary registered in ci.yml same commit.
## Design Decisions (settled)
1. **Capsule log location:** InferenceTrace additive v4 fields — `capsule_messages: list[dict] = []` (the exact messages sent), `capsule_content_hashes: dict[str,str] = {}` (block name → SHA-256 of content), `capsule_token_count: int = 0` (post-decoration re-measured total). No separate JSONL file; rotation already handled.
2. **Equality invariant:** `capsule.to_messages()` result is captured BEFORE `runtime.run` and stored on the trace; a frozen test asserts trace.capsule_messages == the list passed to runtime.run (identity, not re-measure). The canary asserts the same against a scripted runtime.
3. **Safety-override re-measure:** after override mutates the user block content, re-run `estimate_tokens` on the mutated block, update `block.tokens` + `capsule.token_count` + the trace. No re-admission (override grows ≤ ~100 tokens; admission already left headroom).
4. **num_ctx:** pass `capsule.token_count + completion_reserve` where completion_reserve = the broker's reserved completion room (window − budget). One-line change; #28 owns the broader model-aware budget work.
5. **Runtime fallback tool prompt:** formally exempt — `_with_fallback_prompt` only fires for non-native models, and its cost is outside broker scope. Add a comment + log-only accounting note. Full integration is a #28/#34 follow-up.
6. **Legacy retirement:** `create_capsule` and `to_prompt` marked deprecated (docstring + `warnings.warn(DeprecationWarning)`), zero live callers verified by test. `to_dict` gains `content` field. Authority map: `attention_capsule.py` → retain-authority under ContextBroker.
## Tasks
### Task 1 — ContributionBlock.to_dict includes content + AttentionCapsule message capture
**Files:** modify `src/rune/context_broker.py` (to_dict + capsule), `src/rune/attention_capsule.py` (deprecation), test `tests/test_p1_02_capsule_authority.py`.
**Interfaces:**
- Produces: `ContributionBlock.to_dict()` includes `"content"`; `AttentionCapsule.capture_messages()` → stores the exact list on `capsule.sent_messages` (not a dataclass field — set post-hoc like the existing dynamic attrs); `create_capsule`/`to_prompt` emit DeprecationWarning.
- [ ] **Step 1: failing tests** — to_dict includes content matching the block's content; to_dict without content key in old fixture still loads (back-compat); capture_messages stores the list; to_messages re-derivation equals captured list for the same capsule; create_capsule raises DeprecationWarning.
- [ ] **Step 2: run → red**
- [ ] **Step 3: implement**
- [ ] **Step 4: green**
- [ ] **Step 5: commit** `feat(p1-02): capsule content logging + message capture + legacy deprecation (#25)`
### Task 2 — Safety-override re-measure + num_ctx budget fix
**Files:** modify `src/rune/rune_operator.py`, test file.
- [ ] **Step 1: failing tests** — after safety-override mutates the user block, `capsule.token_count` reflects the new estimate (not stale); trace `per_block_tokens["user"]` matches; `num_ctx` passed to runtime.run equals `capsule.token_count + completion_reserve` (not the raw window).
- [ ] **Step 2: red** → **Step 3: implement** → **Step 4: green**
- [ ] **Step 5: commit** `feat(p1-02): safety-override re-measure + num_ctx budget propagation (#25)`
### Task 3 — Trace v4 capsule fields + operator logging
**Files:** modify `src/rune/inference_trace.py` (v4 fields), `src/rune/rune_operator.py` (log messages + hashes into trace), test file.
**Interfaces:** Consumes Task 1. Produces: `InferenceTrace.capsule_messages`, `capsule_content_hashes`, `capsule_token_count` (additive, defaults preserve v1–v3 readers).
- [ ] **Step 1: failing tests** — trace record carries capsule_messages equal to the messages the scripted runtime received; content hashes present and correct; old trace rows without v4 fields still parse.
- [ ] **Step 2: red** → **Step 3: implement** → **Step 4: green**; full suite regression (baseline 588+84).
- [ ] **Step 5: commit** `feat(p1-02): trace v4 capsule fidelity fields (#25)`
### Task 4 — Authority map + docs
**Files:** `config/v1_authority_map.json` (attention_capsule → retain-authority, evidence), `tests/test_v1_authority_map.py` (if pinned), `docs/capsule-fidelity.md` (create: what the capsule is, the equality invariant, how to verify, num_ctx propagation).
- [ ] Steps: map update → docs → commit `feat(p1-02): capsule authority map + docs (#25)`
### Task 5 — Canary + CI registration
**Files:** create `src/rune/canary_p1_02.py`; modify `.gitea/workflows/ci.yml` same commit.
- [ ] Canary: scripted operator run asserts (1) trace.capsule_messages == runtime-received messages (exact list identity), (2) capsule_token_count truthful post-decoration, (3) num_ctx == budget not window, (4) content hashes match block contents, (5) no DeprecationWarning suppressed. Prints `P1-02 CAPSULE FIDELITY CANARY PASSED`.
- [ ] Full suite; ruff; henxels; commit `feat(p1-02): canary + CI registration (#25)`
## Acceptance (from #25)
- Logged capsule contents EXACTLY equal the bounded dynamic context supplied to model — proven by trace v4 `capsule_messages` == `runtime.run` messages, asserted in frozen test + canary.
- No token_count-only fake budgeting — `create_capsule`/`to_prompt` deprecated, zero live callers verified; admission remains the real budget enforcement.
## Pipeline requirements checklist
- [x] TDD red→green (frozen tests per task)
- [x] ruff check + format on touched/added files
- [ ] Spec gate PASS; Quality gate Approved (discharges if needed)
- [x] Full-suite regression green (baseline 588+84 → 604+84, +16 new)
- [x] Canary green + registered same commit; henxels exit 0
- [ ] Closed with formatted comment + PR refs
## Completion Record (P1-02 implementer, 2026-09-06)
**Status:** all 5 tasks complete. Branch `p1-02-capsule-authority`, head `96e42ae`.
**Commits:**
1. `d0550b6` feat(p1-02): capsule content logging + message capture + legacy deprecation (#25)
2. `ec0cf7a` feat(p1-02): safety-override re-measure + num_ctx budget propagation (#25)
3. `8ff6a83` feat(p1-02): trace v4 capsule fidelity fields (#25)
4. `90bcd59` feat(p1-02): capsule authority map + docs (#25)
5. `96e42ae` feat(p1-02): canary + CI registration (#25)
**Final counts:** 604 passed + 84 subtests (baseline 588+84; +16 new frozen tests, 0 regressions).
Canary: `P1-02 CAPSULE FIDELITY CANARY PASSED`. henxels exit 0 (21 pre-existing warnings).
**Red evidence** (initial run of tests/test_p1_02_capsule_authority.py, 14 failed / 2 passed):
`test_contribution_block_to_dict_includes_content`, `test_capture_messages_stores_the_exact_list`,
`test_create_capsule_emits_deprecation_warning`, `test_safety_override_reremeasures_capsule_token_count`,
`test_num_ctx_is_budget_plus_completion_reserve_not_window`, `test_inference_trace_schema_v4_fields`,
`test_operator_trace_record_carries_capsule_messages` (+7 more) — all red before their task's implementation, green after.
**Deviations from plan:**
1. **Schema v4 bump touched pinned literals:** `tests/test_p1_01_capability_authority.py` (3 assertions) and canaries
`canary_p1_01.py` / `canary_p1_06.py` pinned `schema_version == 3`. Updated those pins to 4 in the same Task-3 commit —
the additive discipline protects v1–v3 ROWS, not the literal; `docs/inference-trace.md` schema table updated to v4 with a schema history note.
2. **trace["blocks"] user-entry re-measure:** the frozen test demanded `per_block_tokens["user"]` match post-decoration; the
re-measure therefore also updates the admitted user entry in the broker trace dict (not only `block.tokens` + `capsule.token_count`).
3. **num_ctx degrade path:** added a `num_ctx <= 0` fallback to the discovered window for stand-in brokers (trace missing
window/budget keys) — degrade-not-crash, beyond the literal one-liner, pinned by `test_num_ctx_falls_back_to_window_when_budget_basis_missing`.
4. **Trace v4 extraction is Mock-hardened:** pre-existing frozen tests construct the capsule as a `Mock`; the operator's
hash/message extraction guards non-list/non-dict content and falls back gracefully (no fidelity evidence on stand-in capsules).
5. **`capture_messages` deep-copies** the sent list (test-pinned) so caller-side mutation can never diverge the logged payload.
**Reviewer flags:**
- `InferenceTrace.capsule_messages` logs the FULL message payload per run — accepted per plan (message list, not raw blocks);
watch JSONL rotation headroom if history blocks grow (#28 compaction bounds this).
- `docs/inference-trace.md` "Record schema (version 4)" supersedes the version-1 table heading; no other docs referenced v3.
- Authority map: `attention_capsule.py` entry now `retain-authority` under ContextBroker (context_construction);
`test_v1_authority_map.py` green (path existence, disposition vocabulary).
@@ -0,0 +1,31 @@
# Research-gate evidence enforcement (pipeline hardening) — Plan
> **Contract:** controller-authored (controller-sized change), independent review gate + protected CI still apply. Conventional commit referencing the tranche epic #147 (§3.4-adjacent gate integrity). No deviation without plan-tail record.
## Goal
Nick's directive: research rounds must actually PULL sources so downstream stages work on evidence. Today `research-clear` clears on four regex keywords — a round of pure improvisation clears identically to a genuine one (observed this session). The gate must verify EVIDENCE per phase.
## Change (single job edit: `.gitea/workflows/pipeline-gate.yml` research-clear)
Split the comment body into phase sections (on the phase headers). Require:
- **INWARD:** ≥2 codebase citations matching `(src|tests|scripts|docs|.gitea)/...` path pattern (evidence the code was actually read).
- **OUTWARD:** ≥2 UNIQUE external http(s) URLs, excluding the forge's own host (self-references are not external sources) — evidence sources were actually pulled.
- **INNOVATION + SYNTHESIS:** ≥1 total URL reference OR a `plans/` path mention (the synthesis must tie back to sources or a concrete plan artifact).
- Missing evidence → the job posts a comment naming exactly what's missing (per phase), research-pending STAYS, no label transition. Existing success path unchanged otherwise.
## Non-goals
- No LLM judging in CI (anti-complexity; regex evidence floor only).
- No change to gate/teach jobs; no new labels.
## Tasks
1. Edit research-clear script: section splitter + evidence counters + rejection comment. Verify YAML parses; henxels exit 0; all canaries green (unchanged src → suite untouched but CI will run it on PR anyway).
2. Commit `feat(pipeline): research gate requires evidence (sources in OUTWARD, citations in INWARD) (#147)`; push; PR; protected merge.
## Acceptance
- A sourceless OUTWARD no longer clears the gate (rejection comment received).
- An evidenced round (URLs + citations) clears as before.
- Verified live: the next tranche research round (posted with general-3's pulled sources) passes the new gate.
+39 -2
View File
@@ -15,6 +15,7 @@ import json
import os
import sys
import re
import warnings
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, List
@@ -89,8 +90,32 @@ class AttentionCapsule:
messages.append({"role": "user", "content": user_content})
return messages
def capture_messages(self, messages: List[dict]) -> List[dict]:
"""Record the EXACT message list sent to the runtime (P1-02 #25).
Stored post-hoc on ``capsule.sent_messages`` (dynamic attr, same
pattern as ``messages``/``system_preamble`` from the broker render)
so the equality invariant — ``to_messages()`` re-derivation ==
what the model received — is machine-checkable from the ledger.
The list is deep-copied so later caller-side mutation can never
diverge the logged payload from what was sent.
"""
self.sent_messages = json.loads(json.dumps(list(messages)))
return self.sent_messages
def to_prompt(self) -> str:
"""Convert capsule to prompt text."""
"""Convert capsule to prompt text.
.. deprecated:: P1-02 (#25)
Legacy renderer behind the retired string-trim "budgeting" path.
The ContextBroker owns prompt assembly; use ``to_messages()``.
"""
warnings.warn(
"attention_capsule.AttentionCapsule.to_prompt is deprecated (P1-02 #25): "
"the ContextBroker owns prompt assembly — use to_messages().",
DeprecationWarning,
stacklevel=2,
)
parts = []
if self.active_objective:
@@ -135,9 +160,21 @@ def estimate_tokens(text: str) -> int:
def create_capsule(task: str, context: str = "", max_tokens: int = 600) -> AttentionCapsule:
"""Create an attention capsule for a task.
Only includes what's relevant to the current task.
.. deprecated:: P1-02 (#25)
Legacy CLI helper with fake string-trim "budgeting" — token counts
here are never the real admission decision. The ContextBroker
(#19) is the sole prompt-assembly authority. Kept only for the
standalone CLI above; no live callers.
"""
warnings.warn(
"attention_capsule.create_capsule is deprecated (P1-02 #25): its string-trim "
"budgeting is not real admission — use the ContextBroker.",
DeprecationWarning,
stacklevel=2,
)
capsule = AttentionCapsule()
# Extract objective from task
+279
View File
@@ -0,0 +1,279 @@
#!/usr/bin/env python3
"""P1-01 capability-authority canary (#24).
Self-bootstrapping like canary_p1_06.py (CI runs ``python3 <canary>``
without PYTHONPATH). Proves the #24 claims are machine-checked with NO
live model, NO Ollama, NO network:
1. bounded exposure: a coding job's broker selection exposes ~5–10
schemas (never all 19, never empty, floor + escape hatch present);
2. zero-token claim: the selected tools payload is byte-smaller than the
full registry payload (unselected capabilities cost nothing);
3. escape-hatch round-trip: a scripted LocalToolRuntime run where the
model calls find_capability and then calls the discovered tool in the
SAME run (dynamic exposure within the iteration budget);
4. floor guarantee: a zero-score task still yields the five T1 file
tools + find_capability with reason "floor (no matches)";
5. scripted operator run: the real RuneOperator feeds the selected set
into the runtime construction site and persists trace-v3 capability
fields (selected_tools / capability_reason / find_capability_calls).
Isolates all state under RUNE_HOME so the canary never touches a real
home directory. Prints 'P1-01 CAPABILITY AUTHORITY CANARY PASSED' and
exits 0.
"""
import contextlib
import json
import os
import sys
import tempfile
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock, patch
# Self-bootstrap: CI runs `python3 <canary>` without PYTHONPATH, so put the
# repository src/ on sys.path before importing rune modules. Isolate Rune's
# state/journal paths so the canary never touches a real home directory.
_repo_src = str(Path(__file__).resolve().parents[1])
sys.path.insert(0, _repo_src)
_canary_home = tempfile.mkdtemp(prefix="p1-01-canary-home-")
os.environ["RUNE_HOME"] = _canary_home
os.environ["RUNE_MEMORY_DIR"] = tempfile.mkdtemp(prefix="p1-01-canary-mem-")
from rune import capability_broker as cb # noqa: E402
from rune import inference_trace as it # noqa: E402
from rune import rune_operator # noqa: E402
from rune.capability_broker import select_for_job # noqa: E402
from rune.local_tool_runtime import LocalToolRuntime # noqa: E402
from rune.tool_executor import TOOLS, execute_tool # noqa: E402
from rune.tool_protocol import to_ollama_tools # noqa: E402
FLOOR = set(cb.FLOOR_TOOLS)
HATCH = cb.ESCAPE_HATCH_TOOL
def _fail(message: str) -> int:
print(f"FAILED: {message}", file=sys.stderr)
return 1
def check_bounded_exposure() -> int:
"""A coding job exposes ~5–10 schemas, never the full registry."""
selection = select_for_job("fix the failing test in the parser module and refactor it")
names = selection["names"]
if not (5 <= len(names) <= 10):
return _fail(f"selection size {len(names)} outside 5..10: {names}")
if len(names) >= len(TOOLS):
return _fail(f"selection ({len(names)}) did not shrink below the registry ({len(TOOLS)})")
if not FLOOR <= set(names):
return _fail(f"floor missing from selection: {names}")
if HATCH not in names:
return _fail("escape hatch not exposed")
if selection["reason"] == "":
return _fail("selection reason not recorded")
if not selection["task_family"]:
return _fail("task family not recorded")
return 0
def check_zero_token_payload() -> int:
"""Unselected capabilities cost zero bytes in the tools payload."""
selection = select_for_job("fix the bug in parse.py")
selected_payload = json.dumps(to_ollama_tools(selection["tools"]))
full_payload = json.dumps(to_ollama_tools(TOOLS))
if len(selected_payload) >= len(full_payload):
return _fail(f"selected payload ({len(selected_payload)}B) is not smaller than full ({len(full_payload)}B)")
exposed = {item["function"]["name"] for item in to_ollama_tools(selection["tools"])}
if exposed != set(selection["names"]):
return _fail("payload names diverge from the selection")
return 0
def check_floor_guarantee() -> int:
"""Zero-score task → floor + hatch, reason 'floor (no matches)'."""
selection = select_for_job("zzzqqq xxxyyy wwww vvvv")
names = set(selection["names"])
if names != FLOOR | {HATCH}:
return _fail(f"zero-score selection must be floor+hatch, got {sorted(names)}")
if selection["reason"] != "floor (no matches)":
return _fail(f"floor reason wrong: {selection['reason']!r}")
return 0
class _FakeTransport:
"""Scripted native-tools transport capturing every tools payload."""
def __init__(self, responses):
self.responses = list(responses)
self.calls = []
def supports_tools(self, model: str) -> bool:
return True
def supports_thinking(self, model: str) -> bool:
return True
def chat(self, *, model, messages, tools, options=None, think=None):
self.calls.append({"tools": tools, "messages": messages})
response = self.responses.pop(0)
if isinstance(response, BaseException):
raise response
return response
def _native_call(call_id, name, arguments):
return {"id": call_id, "function": {"name": name, "arguments": arguments}}
def check_escape_hatch_round_trip() -> int:
"""find_capability → discovered tool callable in the SAME run."""
selection = select_for_job("fix the bug in parse.py")
if "repo_map" in selection["names"]:
return _fail("repo_map unexpectedly pre-exposed; pick another discovery target")
transport = _FakeTransport(
[
{
"content": "",
"tool_calls": [_native_call("c1", HATCH, {"query": "map the repository structure"})],
},
{
"content": "",
"tool_calls": [_native_call("c2", "repo_map", {"path": "."})],
},
{"content": "Repository mapped."},
]
)
runtime = LocalToolRuntime(
transport=transport,
executor=execute_tool,
model="canary-model",
tools=selection["tools"],
)
result = runtime.run([{"role": "user", "content": "map the repo"}])
if not result.success:
return _fail(f"escape-hatch round-trip run failed: {result.error}")
if result.user_text != "Repository mapped.":
return _fail(f"run did not reach the final turn: {result.user_text!r}")
second_names = {item["function"]["name"] for item in transport.calls[1]["tools"]}
if "repo_map" not in second_names:
return _fail("discovered tool was not exposed within the same run")
if runtime.find_capability_calls != 1:
return _fail(f"find_capability_calls must be 1, got {runtime.find_capability_calls}")
# The hatch result itself must carry the schema summary.
hatch_event = next((e for e in result.tool_events or [] if e.request.name == HATCH), None)
if hatch_event is None:
return _fail("no find_capability tool event in the run")
hatch_result = hatch_event.result or {}
if "repo_map" not in str(hatch_result.get("result", "")):
return _fail("hatch result missing the discovered capability summary")
return 0
def check_scripted_operator_wiring() -> int:
"""Real RuneOperator feeds the selection to the runtime + trace v3."""
storage = Path(_canary_home) / "operator.json"
op = rune_operator.RuneOperator(str(storage))
trace = {
"model_id": "qwen3:14b",
"window": 40960,
"budget": 32768,
"blocks": [
{"name": "persona", "tokens": 100, "admitted": True, "reason": ""},
{"name": "user", "tokens": 20, "admitted": True, "reason": ""},
],
"admitted_total": 120,
"dropped": [],
"memory_tokens": 0,
"degraded": [],
"compaction": [],
"elapsed_ms": 1.0,
}
capsule = SimpleNamespace(
token_count=120,
active_objective="",
blocks=[],
to_messages=lambda: [{"role": "user", "content": "do it"}],
)
broker = SimpleNamespace(assemble=lambda task, session, model_id: (capsule, trace))
response = rune_operator.OperatorResponse(success=True, user_text="done", model="qwen3:14b")
captured: dict = {}
class _ScriptedRuntime:
transport = Mock(provider="ollama")
tool_names = frozenset()
def __init__(self, **kwargs):
captured.update(kwargs)
def run(self, messages, **kwargs):
return response
patches = [
patch.object(op, "_context_broker", return_value=broker),
patch.object(
rune_operator.model_profiles, "get_or_create_profile", return_value=SimpleNamespace(safety_tier=0)
),
patch.object(rune_operator.model_profiles, "record_interaction"),
patch.object(rune_operator, "LocalToolRuntime", _ScriptedRuntime),
patch.object(rune_operator, "OllamaChatTransport", return_value=Mock(provider="ollama")),
patch("rune.local_tool_runtime.context_length", lambda model: 40960),
patch.object(
rune_operator.safety_override.SafetyOverride,
"get_params",
lambda self, text: {"tier": 0, "temperature": 0.7, "prefix": "", "suffix": ""},
),
patch.object(rune_operator.safety_override.SafetyOverride, "is_refusal", lambda self, text: False),
patch.object(op, "_log_token_usage"),
]
with contextlib.ExitStack() as stack:
for item in patches:
stack.enter_context(item)
stack.enter_context(patch("rune.event_bus._store_path", lambda: Path(storage).parent / "journal-events.jsonl"))
result = op.send_task("fix this code path")
if not result.get("success"):
return _fail("scripted operator run did not succeed")
runtime_tools = captured.get("tools")
if runtime_tools is None:
return _fail("operator did not pass tools= to the runtime construction site")
expected = select_for_job("fix this code path")["names"]
if [t["name"] for t in runtime_tools] != expected:
return _fail(f"runtime received {sorted(t['name'] for t in runtime_tools)}, expected {expected}")
if not (5 <= len(expected) <= 10):
return _fail(f"operator-exposed set size {len(expected)} outside 5..10")
records = it.read_traces(trace_dir=Path(_canary_home) / "state")
if not records:
return _fail("no durable inference trace persisted")
record = records[-1]
if record["schema_version"] != 4:
return _fail(f"trace schema_version must be 4, got {record['schema_version']}")
if record["selected_tools"] != expected:
return _fail(f"trace selected_tools wrong: {record['selected_tools']}")
if not record["capability_reason"]:
return _fail("trace capability_reason empty")
if record["find_capability_calls"] != 0:
return _fail(f"find_capability_calls must be 0, got {record['find_capability_calls']}")
return 0
def main() -> int:
checks = (
("bounded exposure: coding job sees ~5-10 schemas, floor + hatch present", check_bounded_exposure),
("zero-token claim: selected tools payload is byte-smaller than full", check_zero_token_payload),
("escape hatch: find_capability then discovered tool in the SAME run", check_escape_hatch_round_trip),
("floor guarantee: zero-score task still yields floor + hatch", check_floor_guarantee),
("operator wiring: selection reaches the runtime + trace v3 fields", check_scripted_operator_wiring),
)
for label, check in checks:
result = check()
if result != 0:
return result
print(f" ✓ {label}")
print("P1-01 CAPABILITY AUTHORITY CANARY PASSED")
return 0
if __name__ == "__main__":
sys.exit(main())
+253
View File
@@ -0,0 +1,253 @@
#!/usr/bin/env python3
"""P1-02 capsule-fidelity canary (#25).
Self-bootstrapping like canary_p1_07.py (CI runs ``python3 <canary>``
without PYTHONPATH). Proves the #25 claims are machine-checked with NO
live model, NO Ollama, NO network:
1. trace.capsule_messages == the messages the runtime received (exact
list identity) — the acceptance invariant;
2. capsule_token_count is truthful post-safety-override decoration;
3. num_ctx == capsule.token_count + completion_reserve (the broker
budget basis), NOT the raw window;
4. content hashes match the block contents;
5. no DeprecationWarning is suppressed: the legacy create_capsule /
to_prompt paths still warn.
Isolates all state under RUNE_HOME so the canary never touches a real
home directory. Prints 'P1-02 CAPSULE FIDELITY CANARY PASSED' and exits 0.
"""
import contextlib
import hashlib
import json
import os
import sys
import tempfile
import warnings
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
# Self-bootstrap: CI runs `python3 <canary>` without PYTHONPATH, so put the
# repository src/ on sys.path before importing rune modules. Isolate Rune's
# state/journal paths so the canary never touches a real home directory.
_repo_src = str(Path(__file__).resolve().parents[1])
sys.path.insert(0, _repo_src)
_canary_home = tempfile.mkdtemp(prefix="p1-02-canary-home-")
os.environ["RUNE_HOME"] = _canary_home
os.environ["RUNE_MEMORY_DIR"] = tempfile.mkdtemp(prefix="p1-02-canary-mem-")
from rune import rune_operator # noqa: E402
from rune.attention_capsule import AttentionCapsule # noqa: E402
from rune.context_broker import ContributionBlock # noqa: E402
from rune.inference_trace import read_traces # noqa: E402
from rune.local_tool_runtime import OllamaChatTransport # noqa: E402
from rune.tool_protocol import OperatorResponse # noqa: E402
WINDOW = 40960
BUDGET = 32768
def _fail(message: str) -> int:
print(f"FAILED: {message}", file=sys.stderr)
return 1
class _ScriptedTransport(OllamaChatTransport):
"""OllamaChatTransport with _post scripted — real accumulation code runs."""
def __init__(self):
super().__init__()
def _post(self, path, payload):
return {
"model": "qwen3:8b",
"created_at": "2026-09-05T00:00:00Z",
"message": {"role": "assistant", "content": "ok"},
"done": True,
"prompt_eval_count": 100,
"eval_count": 5,
"total_duration": 1_000_000_000,
}
class _ScriptedRuntime:
"""Minimal runtime stand-in: one chat, then the canned final response."""
def __init__(self, transport):
self.transport = transport
self.tool_names = ("read_file", "write_file")
self.received_messages: list = []
self.received_options: dict = {}
def run(self, messages, **kwargs):
self.received_messages = [dict(m) for m in messages]
self.received_options = dict(kwargs.get("options") or {})
self.transport.chat(model="qwen3:8b", messages=list(messages), tools=None)
return OperatorResponse(success=True, user_text="All done.", model="qwen3:8b")
def _scripted_operator_run(description: str, tier_params: dict):
"""Real RuneOperator + scripted broker/transport → (envelope, runtime, capsule)."""
storage = Path(_canary_home) / "operator.json"
op = rune_operator.RuneOperator(str(storage))
capsule = AttentionCapsule()
capsule.blocks = [
ContributionBlock("persona", 90, "I am Rune.", len("I am Rune.") // 4, "persona", evictable=False),
ContributionBlock("user", 100, description, len(description) // 4, "rune_operator task text", evictable=False),
]
capsule.token_count = sum(b.tokens for b in capsule.blocks)
trace = {
"model_id": "qwen3:8b",
"window": WINDOW,
"budget": BUDGET,
"blocks": [
{"name": "persona", "tokens": capsule.blocks[0].tokens, "admitted": True, "reason": ""},
{"name": "user", "tokens": capsule.blocks[1].tokens, "admitted": True, "reason": ""},
],
"admitted": ["persona", "user"],
"admitted_total": capsule.token_count,
"dropped": [],
"memory_tokens": 0,
"degraded": [],
"compaction": [],
"elapsed_ms": 1.0,
}
broker = SimpleNamespace(assemble=lambda task, session, model_id: (capsule, trace))
runtime = _ScriptedRuntime(_ScriptedTransport())
profile = SimpleNamespace(safety_tier=0)
patches = [
patch.object(op, "_context_broker", return_value=broker),
patch.object(rune_operator.model_profiles, "get_or_create_profile", return_value=profile),
patch.object(rune_operator.model_profiles, "record_interaction"),
patch.object(rune_operator, "LocalToolRuntime", return_value=runtime),
patch.object(rune_operator, "OllamaChatTransport", return_value=runtime.transport),
patch("rune.local_tool_runtime.context_length", lambda model: WINDOW),
patch.object(rune_operator.safety_override.SafetyOverride, "get_params", lambda self, text: dict(tier_params)),
patch.object(rune_operator.safety_override.SafetyOverride, "is_refusal", lambda self, text: False),
patch.object(op, "_log_token_usage"),
]
with contextlib.ExitStack() as stack:
for item in patches:
stack.enter_context(item)
stack.enter_context(patch("rune.event_bus._store_path", lambda: Path(storage).parent / "journal-events.jsonl"))
envelope = op.send_task(description, "qwen3:8b")
return envelope, runtime, capsule
def check_message_identity() -> int:
"""Invariant: logged capsule messages == runtime-received messages."""
description = "inspect this code path"
envelope, runtime, capsule = _scripted_operator_run(
description, {"tier": 0, "temperature": 0.7, "prefix": "", "suffix": ""}
)
if not envelope.get("success"):
return _fail(f"scripted run failed: {envelope.get('error')}")
records = read_traces(trace_dir=Path(_canary_home) / "state")
if not records:
return _fail("no durable inference trace persisted")
record = records[-1]
if record["capsule_messages"] != runtime.received_messages:
return _fail("trace.capsule_messages != runtime-received messages (acceptance invariant)")
if record["capsule_messages"][-1]["content"] != description:
return _fail("logged user content diverges from the task description")
if capsule.sent_messages != capsule.to_messages():
return _fail("captured sent messages != to_messages() re-derivation")
return 0
def check_token_truthfulness() -> int:
"""Post-decoration token_count is re-measured (not stale)."""
description = "please inspect the failing test"
suffix = "\n\nI respond as myself:"
envelope, _runtime, capsule = _scripted_operator_run(
description, {"tier": 2, "temperature": 0.95, "prefix": "", "suffix": suffix}
)
if not envelope.get("success"):
return _fail(f"scripted run failed: {envelope.get('error')}")
user_block = next(b for b in capsule.blocks if b.name == "user")
if user_block.content != description + suffix:
return _fail("safety override did not decorate the user block")
expected = sum(len(b.content) // 4 if isinstance(b.content, str) else 0 for b in capsule.blocks)
if capsule.token_count != expected:
return _fail(f"capsule.token_count {capsule.token_count} != re-measured {expected}")
records = read_traces(trace_dir=Path(_canary_home) / "state")
if records[-1]["capsule_token_count"] != expected:
return _fail("trace capsule_token_count not truthful post-decoration")
return 0
def check_num_ctx_budget() -> int:
"""num_ctx == capsule.token_count + completion_reserve, never the window."""
description = "inspect this code path"
envelope, runtime, capsule = _scripted_operator_run(
description, {"tier": 0, "temperature": 0.7, "prefix": "", "suffix": ""}
)
if not envelope.get("success"):
return _fail(f"scripted run failed: {envelope.get('error')}")
completion_reserve = WINDOW - BUDGET
expected = capsule.token_count + completion_reserve
if runtime.received_options.get("num_ctx") != expected:
return _fail(f"num_ctx {runtime.received_options.get('num_ctx')} != budget basis {expected}")
if runtime.received_options.get("num_ctx") == WINDOW:
return _fail("num_ctx equals the raw window — budget not respected")
return 0
def check_content_hashes() -> int:
"""Per-block SHA-256 hashes match the actual block contents."""
description = "hash integrity probe"
envelope, _runtime, capsule = _scripted_operator_run(
description, {"tier": 0, "temperature": 0.7, "prefix": "", "suffix": ""}
)
if not envelope.get("success"):
return _fail(f"scripted run failed: {envelope.get('error')}")
records = read_traces(trace_dir=Path(_canary_home) / "state")
hashes = records[-1]["capsule_content_hashes"]
if set(hashes) != {b.name for b in capsule.blocks}:
return _fail(f"hash keys {sorted(hashes)} != block names {sorted(b.name for b in capsule.blocks)}")
for block in capsule.blocks:
payload = (
block.content.encode("utf-8")
if isinstance(block.content, str)
else json.dumps(block.content, ensure_ascii=False, sort_keys=True).encode("utf-8")
)
if hashes[block.name] != hashlib.sha256(payload).hexdigest():
return _fail(f"content hash mismatch for block '{block.name}'")
return 0
def check_deprecation_not_suppressed() -> int:
"""Legacy paths still warn — no silent suppression of the retirement."""
from rune.attention_capsule import create_capsule
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
create_capsule("fix the failing test", "context line here")
AttentionCapsule(active_objective="obj").to_prompt()
deprecations = [w for w in caught if issubclass(w.category, DeprecationWarning)]
if len(deprecations) < 2:
return _fail(f"expected 2 DeprecationWarnings from legacy paths, got {len(deprecations)}")
return 0
def main() -> int:
checks = (
("trace.capsule_messages == runtime-received messages (identity)", check_message_identity),
("capsule_token_count truthful post-decoration", check_token_truthfulness),
("num_ctx == budget basis, not the raw window", check_num_ctx_budget),
("content hashes match block contents", check_content_hashes),
("legacy deprecation warnings not suppressed", check_deprecation_not_suppressed),
)
for label, check in checks:
result = check()
if result != 0:
return result
print(f" ✓ {label}")
print("P1-02 CAPSULE FIDELITY CANARY PASSED")
return 0
if __name__ == "__main__":
sys.exit(main())
+2 -2
View File
@@ -198,8 +198,8 @@ def check_reason_recording() -> int:
if not records:
return _fail("no durable inference trace persisted")
record = records[-1]
if record["schema_version"] != 2:
return _fail(f"schema_version must be 2, got {record['schema_version']}")
if record["schema_version"] != 4:
return _fail(f"schema_version must be 4, got {record['schema_version']}")
if record["routing_reason"] != "router: measured 12/13 success task_family=code":
return _fail(f"routing_reason not recorded: {record['routing_reason']!r}")
if record["routing_mode"] != "local":
+314 -16
View File
@@ -1,8 +1,12 @@
#!/usr/bin/env python3
"""Capability broker — maps user intent to relevant plugins.
Solves the problem: the model must know a plugin exists, know its filename,
and know its CLI syntax to use it. The broker removes that requirement.
P1-01 (#24): this module is the single capability-selection authority
(authority map ``capability_selection`` → v1_owner
``ContextBroker.capability_selector``). ``select_for_job`` returns the
ordered tool-schema set a job is exposed to; the operator feeds that
selection into ``LocalToolRuntime(tools=...)``. Executor authority stays
in ``tool_executor`` (#21).
Usage:
capability_broker.py build # Generate capability cards from registry
@@ -27,6 +31,168 @@ REGISTRY = os.path.join(SCRIPTS_DIR, ".microkernel.json")
CARDS_FILE = os.path.join(memory_env.memory_dir(), "index", "capability_cards.json")
PYTHON = memory_env.python_bin()
# ═══════════════════════════════════════════════════════════════════════════════
# P1-01 (#24): selection authority — verified floor + escape hatch
# ═══════════════════════════════════════════════════════════════════════════════
#: Verified floor: a coding-style job must ALWAYS see these five T1 file
#: tools (plan names list_dir/search_files; actual schema names are
#: list_directory/grep_files). Selection is never empty and never misses
#: the floor.
FLOOR_TOOLS = ("read_file", "write_file", "edit_file", "list_directory", "grep_files")
#: The always-exposed discovery escape hatch (P1-01): the model can name
#: any unexposed capability through it and call the discovered tool in
#: the SAME run.
ESCAPE_HATCH_TOOL = "find_capability"
#: Upper bound on the exposed set (issue #24: "~5–10").
MAX_SELECTED = 10
def _native_tool_schemas():
"""Return {tool_name: schema dict} from tool_executor (lazy, safe)."""
try:
try:
from rune.tool_executor import TOOLS
except ImportError: # direct script execution
from tool_executor import TOOLS
except Exception:
return {}
return {tool["name"]: tool for tool in TOOLS if isinstance(tool.get("name"), str)}
def _infer_task_family(description: str) -> str:
"""Task family via the shared #29 inference (coherent with routing)."""
try:
try:
from rune.rune_operator import infer_task_family
except ImportError: # direct script execution
from rune_operator import infer_task_family
return infer_task_family(description)
except Exception:
return "general"
def _scored_tools(description: str, schemas: dict):
"""Score native-tool cards against the task description.
Deterministic keyword scoring (existing ``score_card``) over the
TOOLS-derived cards. Floor tools and the escape hatch are excluded —
they are added by policy, not by score. Returns [(score, name)] with
score > 0 only, sorted by (-score, name).
"""
floor = set(FLOOR_TOOLS)
scored = []
for card in _native_tool_cards():
name = card["name"]
if name not in schemas or name in floor or name == ESCAPE_HATCH_TOOL:
continue
score = score_card(card, description)
if score > 0:
scored.append((score, name))
scored.sort(key=lambda pair: (-pair[0], pair[1]))
return scored
def select_for_job(task_description, need_tools=True):
"""Authoritative capability selection for one job (P1-01 #24).
Returns ``{"tools": [schema dicts], "names": [ordered names],
"reason": str, "task_family": str}``. The ordered selection is the
exact schema list the operator feeds into ``LocalToolRuntime(tools=)``
— selection flows to the native ``tools`` API param, never the prompt
block alone. ``find_capability`` is always exposed (escape hatch).
Floor guarantee: a tool-using job is never exposed fewer than the
five T1 file tools plus the escape hatch, and never an empty set.
Zero-score tasks fall back to the floor with reason
``"floor (no matches)"``.
Degrade-not-crash (P0-06): any broker failure falls back to the full
TOOLS set (the pre-#24 behavior) and records a tier-1
``capability_selection`` failure.
"""
try:
return _select_for_job(str(task_description or ""), bool(need_tools))
except Exception as exc: # noqa: BLE001 - degrade, never crash the turn
try:
from rune.subsystem_health import record_failure
record_failure("capability_selection", exc, tier=1)
except Exception:
pass
schemas = _native_tool_schemas()
names = list(schemas)
return {
"tools": [schemas[name] for name in names],
"names": names,
"reason": f"fallback full toolset ({type(exc).__name__})",
"task_family": "",
}
def _select_for_job(description: str, need_tools: bool) -> dict:
schemas = _native_tool_schemas()
family = _infer_task_family(description)
if not need_tools:
hatch = schemas.get(ESCAPE_HATCH_TOOL)
tools = [hatch] if hatch is not None else []
return {
"tools": tools,
"names": [tool["name"] for tool in tools],
"reason": "no tool use expected (escape hatch only)",
"task_family": family,
}
floor = [name for name in FLOOR_TOOLS if name in schemas]
matches = [name for _, name in _scored_tools(description, schemas)]
# Reserve one slot for the escape hatch; cap the whole set.
budget = max(0, MAX_SELECTED - len(floor) - 1)
matches = matches[:budget]
ordered = floor + matches
hatch = schemas.get(ESCAPE_HATCH_TOOL)
if hatch is not None:
ordered.append(ESCAPE_HATCH_TOOL)
if matches:
reason = f"floor + {len(matches)} match(es): {', '.join(matches)}"
else:
reason = "floor (no matches)"
tools = [schemas[name] for name in ordered if name in schemas]
return {
"tools": tools,
"names": [tool["name"] for tool in tools],
"reason": reason,
"task_family": family,
}
class CapabilityBroker:
"""Composition-root handle for the selection authority (P1-01 #24).
Thin stateful shell over the module-level functions so the
composition root can hold exactly one instance per process
(mirrors ModelRouter for model_routing). ``cards_dir`` is accepted
for composition symmetry; card I/O stays on the module paths.
"""
policy_name = "capability_selection"
def __init__(self, cards_dir=None):
self.cards_dir = cards_dir
def select_for_job(self, task_description, need_tools: bool = True) -> dict:
"""Authoritative selection — see module-level ``select_for_job``."""
return select_for_job(task_description, need_tools=need_tools)
def find_capability(self, query, top_k: int = 5) -> list:
"""Escape-hatch query — see module-level ``find_capability``."""
return find_capability(query, top_k=top_k)
# Semantic examples for well-known capabilities
SEMANTIC_EXAMPLES = {
"memory_recall": [
@@ -240,6 +406,114 @@ def load_registry():
return json.load(f)
# ═══════════════════════════════════════════════════════════════════════════════
# P1-01 (#24): native-tool card synthesis + load validation
# ═══════════════════════════════════════════════════════════════════════════════
def _native_tool_cards():
"""Synthesize a card per native tool schema from tool_executor.TOOLS.
tool_executor keeps the single metadata source (risk/preconditions/
side_effects); cards copy it so selection has full coverage even with
no plugins installed. Lazy import: the broker must be importable where
tool_executor is not (memory scripts, hooks).
"""
try:
try:
from rune.tool_executor import TOOLS
except ImportError: # direct script execution
from tool_executor import TOOLS
except Exception:
return []
cards = []
for tool in TOOLS:
name = tool.get("name", "")
if not name:
continue
description = tool.get("description", "")
cards.append(
{
"name": name,
"description": description,
"capabilities": [name],
"file": "tool_executor.py",
"full_path": "",
"priority": 100,
"semantic_examples": [],
"risk": tool.get("risk", "read-only"),
"preconditions": list(tool.get("preconditions", [])),
"side_effects": list(tool.get("side_effects", [])),
"auto_invoked": False,
"hooks": [],
"native_tool": True,
"searchable_text": f"{name} {description} native tool".lower(),
}
)
return cards
def _cards_are_stale():
"""True when the cards file predates the registry (rebuild trigger).
A missing registry can never make cards stale — there is nothing
newer to rebuild from (native cards are synthesized from TOOLS).
"""
if not os.path.exists(REGISTRY):
return False
try:
cards_mtime = os.path.getmtime(CARDS_FILE)
registry_mtime = os.path.getmtime(REGISTRY)
return cards_mtime < registry_mtime
except OSError:
return True # unreadable cards file → rebuild is the safe move
def load_cards():
"""Load capability cards, rebuilding when missing/empty/stale/corrupt.
P1-01 (#24): the packaged file ships as an empty array — an empty
load can no longer pass as a valid card index. Validation rule:
rebuild when the file is missing, empty, corrupt JSON, or its mtime
is older than the registry it was generated from. Native-tool cards
are always synthesized so selection has full tool coverage.
"""
needs_rebuild = True
if os.path.exists(CARDS_FILE):
try:
with open(CARDS_FILE) as f:
existing = json.load(f)
needs_rebuild = not isinstance(existing, list) or not existing
if not needs_rebuild:
needs_rebuild = _cards_are_stale()
except (ValueError, OSError):
needs_rebuild = True # corrupt file
if needs_rebuild:
cards = build_cards()
cards.extend(_native_tool_cards())
tmp_file = f"{CARDS_FILE}.tmp"
try:
os.makedirs(os.path.dirname(CARDS_FILE), exist_ok=True)
# Quality discharge (P1-01 #24): atomic rebuild write. A crash
# mid-dump must not leave a truncated cards index behind — the
# corrupt-JSON rebuild trigger would then mask real generation
# bugs. Write a sibling temp file, then atomically rename.
with open(tmp_file, "w") as f:
json.dump(cards, f, indent=2)
os.replace(tmp_file, CARDS_FILE)
except OSError:
try:
os.remove(tmp_file)
except OSError:
pass # read-only memory dir → selection still works from memory
return cards
with open(CARDS_FILE) as f:
return json.load(f)
def generate_card(name, manifest):
"""Generate a capability card from a plugin manifest."""
desc = manifest.get("description", "")
@@ -291,7 +565,8 @@ def generate_card(name, manifest):
def build_cards():
"""Generate capability cards from the microkernel registry."""
"""Generate plugin cards from the microkernel registry (native cards
are appended by load_cards so plugin-only rebuilds stay pure)."""
reg = load_registry()
plugins = reg.get("plugins", {})
cards = []
@@ -304,22 +579,9 @@ def build_cards():
# Sort by priority
cards.sort(key=lambda c: c.get("priority", 100))
# Save
os.makedirs(os.path.dirname(CARDS_FILE), exist_ok=True)
with open(CARDS_FILE, "w") as f:
json.dump(cards, f, indent=2)
return cards
def load_cards():
"""Load capability cards from file."""
if not os.path.exists(CARDS_FILE):
return build_cards()
with open(CARDS_FILE) as f:
return json.load(f)
def score_card(card, query):
"""Score a capability card against a query. Deterministic keyword matching."""
query_low = query.lower()
@@ -394,6 +656,42 @@ def match_capabilities(query, top_k=5):
return [card for _, card in scored[:top_k]]
def find_capability(query, top_k=5):
"""Escape-hatch query (P1-01 #24): matching capability summaries.
Returns up to ``top_k`` matches as dicts carrying name, description,
risk, and — for native tools — the full ``parameters`` schema so the
model can call the discovered tool in the same session. Plugin
capabilities without a native schema come back as name+description
only (the runtime cannot expose what has no executor).
"""
query = str(query or "")
schemas = _native_tool_schemas()
cards = load_cards()
scored = []
for card in cards:
name = card.get("name", "")
if not name or name in FLOOR_TOOLS or name == ESCAPE_HATCH_TOOL:
continue
score = score_card(card, query)
if score > 0:
scored.append((score, name, card))
scored.sort(key=lambda item: (-item[0], item[1]))
out = []
for _, name, card in scored[:top_k]:
schema = schemas.get(name)
entry = {
"name": name,
"description": card.get("description", ""),
"risk": card.get("risk", "read-only"),
}
if schema is not None:
entry["parameters"] = schema.get("parameters", {})
out.append(entry)
return out
def handle_hook(event, data=None):
"""On-message hook: find relevant capabilities for the user's message."""
if event == "on_message" and data:
+9 -1
View File
@@ -79,10 +79,18 @@ class ContributionBlock:
self.evictable = evictable # False = never dropped (system, user)
def to_dict(self) -> dict:
"""Serialize for pending_state embedding (approval resume)."""
"""Serialize for pending_state embedding (approval resume).
P1-02 (#25): ``content`` is included so the embedded capsule is
verifiable end-to-end — the acceptance criterion is that the logged
capsule contents EXACTLY equal the model payload, which token counts
alone cannot prove. Blocks are small (admission-bounded), so the
extra size is bounded by the same budget.
"""
return {
"name": self.name,
"priority": self.priority,
"content": self.content,
"tokens": self.tokens,
"source": self.source,
"evictable": self.evictable,
+13 -2
View File
@@ -17,7 +17,7 @@ from __future__ import annotations
import json
import os
import sys
from dataclasses import asdict, dataclass
from dataclasses import asdict, dataclass, field
from pathlib import Path
try:
@@ -25,7 +25,7 @@ try:
except ImportError: # direct script execution
from rune.memory_env import rune_home
INFERENCE_TRACE_SCHEMA_VERSION = 2
INFERENCE_TRACE_SCHEMA_VERSION = 4
TRACE_ROTATE_BYTES = 10_000_000
TRACE_BACKUPS = 3
@@ -83,6 +83,17 @@ class InferenceTrace:
routing_reason: str = "" # the start-tier decision reason (router)
routing_mode: str = "" # "local" | "cloud" | "" (pre-router rows)
task_family: str = "" # measured-registry family key
# P1-01 (#24) schema v3 — capability-authority fields, additive.
selected_tools: list = field(default_factory=list) # ordered exposed set
capability_reason: str = "" # broker selection reason ("" = unselected)
find_capability_calls: int = 0 # escape-hatch round-trips this run
# P1-02 (#25) schema v4 — capsule-fidelity fields, additive. The logged
# capsule contents must EXACTLY equal the bounded dynamic context
# supplied to the model; message list (the actual payload) + per-block
# content SHA-256 (integrity without full JSONL bloat) prove it.
capsule_messages: list = field(default_factory=list) # exact runtime.run payload
capsule_content_hashes: dict = field(default_factory=dict) # block name -> sha256
capsule_token_count: int = 0 # post-decoration re-measured total
def price_for(model: str) -> float | None:
+115 -7
View File
@@ -19,6 +19,7 @@ from .tool_executor import (
serialize_prepared_tool,
)
from .tool_protocol import (
TOOL_INDEX,
OperatorResponse,
ToolEvent,
ToolRequest,
@@ -45,6 +46,23 @@ _CONTEXT_LENGTH_LOCK = threading.Lock()
#: Fallback tool prompt is runtime-owned but traced — once per session.
_FALLBACK_PROMPT_TRACED: set[str] = set()
# P1-01 (#24): the capability escape hatch. Executing ``find_capability``
# queries the broker and returns matching capability summaries; the
# runtime then auto-exposes the discovered native tools for the rest of
# the run (within the existing iteration budget — no extra turns granted).
FIND_CAPABILITY_TOOL = "find_capability"
def _find_capability_executor(tool_name: str, arguments: dict) -> dict:
"""Default escape-hatch executor: route through tool_executor.
Kept as a module-level wrapper so tests and callers can exercise the
hatch without constructing the full runtime.
"""
from .tool_executor import execute_tool
return execute_tool(tool_name, arguments)
class ChatMessage(TypedDict, total=False):
"""Ollama chat message fields used by the runtime."""
@@ -276,6 +294,12 @@ class LocalToolRuntime:
definition["name"] for definition in self.tools if isinstance(definition.get("name"), str)
)
self.max_iterations = max_iterations
# P1-01 (#24): escape-hatch executor. The runtime's own executor
# stays authoritative for every tool EXCEPT find_capability, whose
# result drives dynamic in-run exposure (a plain executor callable
# cannot mutate the tool set).
self.find_capability_executor = _find_capability_executor
self.find_capability_calls = 0
def run(
self,
@@ -320,6 +344,13 @@ class LocalToolRuntime:
raise ValueError("pending think must be a boolean or string")
think = raw_think
pending_request = self._request_from_state(copy.deepcopy(pending_state["pending_request"]))
# P1-01 #24 quality discharge: honor the embedded exposed set.
# Additive + backward compatible (pre-#24 states lack the key);
# only names with real schemas are exposed, never invented.
raw_exposed = pending_state.get("exposed_tools", [])
if not isinstance(raw_exposed, list) or not all(isinstance(name, str) for name in raw_exposed):
raise ValueError("pending exposed tools must be a list of strings")
self._expose_tools(raw_exposed)
if pending_request.name not in self.tool_names:
raise ValueError(f"tool not available in this runtime: {pending_request.name}")
raw_events = copy.deepcopy(pending_state.get("tool_events", []))
@@ -422,8 +453,17 @@ class LocalToolRuntime:
if requests:
for index, request in enumerate(requests):
if request.name not in self.tool_names:
# P1-01 (#24): a tool outside this runtime's allowlist
# is rejected with one-step recovery guidance naming
# the escape hatch. Dynamic exposure happens ONLY
# through find_capability results (see the hatch
# dispatch below) — never by naming a tool directly,
# which would bypass broker selection.
return self._failure(
f"tool not available in this runtime: {request.name}",
f"tool not available in this runtime: {request.name}. "
f"If you need a capability that is not exposed, call "
f"{FIND_CAPABILITY_TOOL} with a description of what "
f"you need to do.",
events,
thinking,
iterations_used=iterations_used,
@@ -471,6 +511,13 @@ class LocalToolRuntime:
"tool_events": [self._event_to_state(event) for event in events],
"thinking": copy.deepcopy(thinking),
"remaining_requests": [self._request_to_state(item) for item in requests[index + 1 :]],
# P1-01 #24 quality discharge: the run's full
# exposed set (selected + any find_capability
# discoveries) rides the state so a FRESH
# runtime instance can resume it — without
# this, resume() on a newly constructed runtime
# rejects a dynamically exposed tool.
"exposed_tools": sorted(self.tool_names),
}
if prepared is not None:
state["prepared_tool"] = serialize_prepared_tool(prepared)
@@ -485,7 +532,28 @@ class LocalToolRuntime:
iteration_budget=self.max_iterations,
)
try:
result = dispatch_validated_tool(request, self.executor)
if request.name == FIND_CAPABILITY_TOOL:
# P1-01 (#24): the hatch executes through its
# dedicated executor (a plain executor callable
# cannot mutate the tool set to expose matches);
# a successful query then exposes the returned
# native tools in-run. Quality discharge: the
# call rides the SAME validated dispatch as
# every other tool — find_capability has a real
# schema in TOOL_INDEX, so resumed requests
# (which skip intake validation) cannot smuggle
# arguments past the handler; validation errors
# surface as tool errors via the except below.
result = dict(
dispatch_validated_tool(request, self.find_capability_executor)
)
if result.get("success"):
self.find_capability_calls += 1
self._expose_tools(result.get("exposed") or [])
if native_tools:
native_definitions = to_ollama_tools(self.tools)
else:
result = dispatch_validated_tool(request, self.executor)
except Exception as exc:
result = {"success": False, "error": f"executor error: {exc}"}
@@ -529,11 +597,26 @@ class LocalToolRuntime:
raise ValueError("transport response is missing a message object")
assistant = cast(ChatMessage, assistant_object)
assistant = cast(ChatMessage, copy.deepcopy(assistant))
requests = (
normalize_ollama_tool_calls(cast(dict, assistant))
if native_tools
else normalize_text_tool_calls(assistant.get("content", ""))
)
requests = []
try:
requests = (
normalize_ollama_tool_calls(cast(dict, assistant))
if native_tools
else normalize_text_tool_calls(assistant.get("content", ""))
)
except ValueError as exc:
if str(exc).startswith("unknown tool: "):
# P1-01 (#24): one-step recovery — name the escape
# hatch so the model can discover the capability.
return self._failure(
f"{exc}. If you need a capability that is not "
f"exposed, call {FIND_CAPABILITY_TOOL} with a "
"description of what you need to do.",
events,
thinking,
iterations_used=iterations_used,
)
raise
iterations_used += 1
except ValueError as exc:
return self._failure(str(exc), events, thinking, iterations_used=iterations_used)
@@ -586,6 +669,31 @@ class LocalToolRuntime:
conversation.append(assistant)
requests = self._deduplicate(requests)
def _expose_tools(self, names: "list[str] | tuple[str, ...] | set[str] | object") -> None:
"""Add tool schemas by name for the rest of this run (P1-01 #24).
Only schemas that exist in the full registry can be exposed — a
name without a schema is ignored. Already-exposed names are
skipped; exposure is additive and never removes tools.
"""
added = False
for name in (names if isinstance(names, (list, tuple, set, frozenset)) else []):
if not isinstance(name, str) or name in self.tool_names:
continue
definition = TOOL_INDEX.get(name)
if definition is None:
continue
self.tools.append(copy.deepcopy(definition))
self.tool_names = frozenset(self.tool_names | {name})
added = True
if added:
exposed_now = [
name
for name in (names if isinstance(names, (list, tuple, set, frozenset)) else [])
if name in self.tool_names
]
LOG.info("capability escape hatch exposed tools in-run: %s", sorted(exposed_now))
@staticmethod
def _deduplicate(requests: list[ToolRequest]) -> list[ToolRequest]:
unique: list[ToolRequest] = []
+31
View File
@@ -37,6 +37,7 @@ AUTHORITY_KINDS = (
"conversation_state",
"task_state",
"model_routing",
"capability_selection",
)
@@ -109,6 +110,11 @@ def compose_conversation_state(root: CompositionRoot, state_dir: Path) -> "Conve
one place per composition root.
"""
events = root.get("event_store") # requires build_root() wiring first
if not isinstance(events, DurableEventStore):
raise TypeError(
"conversation_state composition requires build_root() wiring first: "
f"event_store is {type(events).__name__}, expected DurableEventStore"
)
hot = AtomicStore(Path(state_dir) / "hot")
tasks = TaskStateRepository(events, hot)
conversation = ConversationStateRepository(events, hot, tasks=tasks)
@@ -144,3 +150,28 @@ class ModelRoutingAuthority:
def __init__(self, router) -> None:
self.router = router
def compose_capability_selection(root: CompositionRoot, state_dir: Path) -> "CapabilitySelectionAuthority":
"""Wire the single capability-selection authority (P1-01 #24).
One CapabilityBroker instance per composition root. The broker's
``select_for_job`` decision is THE authority for what tool schemas a
job is exposed to; execution authority stays in tool_executor (#21).
"""
from rune.capability_broker import CapabilityBroker
broker = CapabilityBroker(cards_dir=Path(state_dir) / "capability_cards")
root.register("capability_selection", broker)
return CapabilitySelectionAuthority(broker=broker)
class CapabilitySelectionAuthority:
"""Typed handle for the composed capability-selection authority."""
def __init__(self, broker) -> None:
self.broker = broker
def select_for_job(self, task_description, need_tools: bool = True) -> dict:
"""Delegate to the broker's authoritative selection."""
return self.broker.select_for_job(task_description, need_tools=need_tools)
+234 -20
View File
@@ -13,6 +13,7 @@ Usage:
rune_operator.py list # List available models
"""
import hashlib
import json
import os
import re
@@ -188,6 +189,19 @@ def _bounded(text: object, limit: int = 300) -> str:
return " ".join(str(text or "").split())[:limit]
def _sorted_tool_names(tool_names: object) -> list:
"""Sorted str list from a tool_names iterable; non-str items dropped.
P1-01 (#24): tolerates Mock/legacy runtimes (degrade-not-crash) —
the pending-state embedding must never fail the turn.
"""
try:
iterable = tool_names if isinstance(tool_names, (list, tuple, set, frozenset)) else ()
return sorted({name for name in iterable if isinstance(name, str)})
except Exception:
return []
def _memory_query_from_trace(trace: Dict[str, Any], task_id: str) -> MemoryQuery:
"""Build the per-assemble MemoryQuery from the broker trace (#19, #30).
@@ -578,9 +592,10 @@ class RuneOperator:
task.status = "running"
self._task_state.transition(task.task_id, "running")
self._save()
resolved_model = model or self._fallback_model(task.description)
result = self._execute_on_model(
task,
model,
resolved_model,
routing_reason=routing_reason,
routing_mode=routing_mode,
task_family=task_family,
@@ -609,7 +624,7 @@ class RuneOperator:
success=False,
thinking=result.thinking,
tool_events=result.tool_events,
model=result.model or model,
model=str(result.model or model or ""),
recall_count=result.recall_count,
override_tier=result.override_tier,
degraded=result.degraded, # P0-06: carry observability through the scrub
@@ -696,14 +711,81 @@ class RuneOperator:
except Exception:
pass
def _runtime_for_model(self, model: str) -> LocalToolRuntime:
"""Build the runtime used to continue a persisted approval turn."""
def _runtime_for_model(self, model: str, task_description: str = "") -> LocalToolRuntime:
"""Build the runtime used to continue a persisted approval turn.
P1-01 (#24): resume round-trips must use the same selected set the
original run had. The pending state carries ``exposed_tools`` (the
selected set plus any tool dynamically exposed via find_capability
mid-run); without it, selection is re-run deterministically from
the task description. Degrade → full TOOLS.
"""
names: list[str] = []
pending = self.state.pending_runtime_state
if isinstance(pending, dict):
raw = pending.get("exposed_tools")
if isinstance(raw, list):
names = [name for name in raw if isinstance(name, str)]
if not names and task_description:
names = self._capability_selection(task_description)["names"]
tool_index = {tool["name"]: tool for tool in tool_executor.TOOLS}
tools = [tool_index[name] for name in names if name in tool_index]
if not tools:
tools = list(tool_executor.TOOLS) # pre-#24 behavior (degrade)
return LocalToolRuntime(
transport=OllamaChatTransport(),
executor=tool_executor.execute_tool,
model=model,
tools=tools,
)
def _capability_selection(self, description: str) -> Dict[str, Any]:
"""Broker-selected tool schemas for this task (P1-01 #24).
The CapabilityBroker is the single selection authority; its
decision feeds both LocalToolRuntime construction sites and the
_contributor_tools prompt block. Degrade-not-crash (P0-06): any
broker failure returns the full TOOLS set (pre-#24 behavior) and
records a tier-1 ``capability_selection`` failure.
"""
cached = getattr(self, "_last_capability_selection", None)
if isinstance(cached, dict) and cached.get("description") == description:
return cached
try:
try:
from .capability_broker import select_for_job
except ImportError: # TUI top-level deploy
from rune.capability_broker import select_for_job
selection = select_for_job(description, need_tools=True)
tools = selection.get("tools") or []
if tools:
selection = dict(selection)
selection["description"] = description
self._last_capability_selection = selection
return selection
raise ValueError("broker returned an empty selection")
except Exception as exc:
try:
from .subsystem_health import record_failure
except ImportError: # TUI top-level deploy
from rune.subsystem_health import record_failure
record_failure("capability_selection", exc, tier=1)
selection = {
"tools": list(tool_executor.TOOLS),
"names": [tool["name"] for tool in tool_executor.TOOLS],
# Quality discharge (P1-01 #24): the degrade reason must
# propagate — an empty reason would make capability_reason
# silently disappear from the trace exactly when the full
# toolset is selected.
"reason": "fallback full toolset (broker unavailable)",
"task_family": "",
"description": description,
}
self._last_capability_selection = selection
return selection
def _conversation_messages(
self, current_task_id: str, limit: int = 8
) -> list[dict]:
@@ -740,7 +822,7 @@ class RuneOperator:
)
model = task.assigned_model or self.state.active_model or ""
runtime = self._runtime_for_model(model)
runtime = self._runtime_for_model(model, task.description)
result = (
runtime.approve(pending_state)
if approved
@@ -867,7 +949,7 @@ class RuneOperator:
status="running",
channel_id=OPERATOR_CHANNEL,
assigned_model=task.assigned_model,
created_at=task.created_at or None,
created_at=task.created_at or datetime.now(timezone.utc).isoformat(),
)
)
@@ -1043,7 +1125,12 @@ I have tools to read, write, edit, search, lint, test, format, and commit code.
# -- tools (bucket 1, non-evictable: tool names, not the protocol) ----
def _contributor_tools(self, task, session):
"""Advertise capabilities without imposing the text fallback protocol.
"""Advertise the SELECTED capabilities for this task (P1-01 #24).
The names block mirrors the same broker selection the runtime
exposes as native schemas — a capability not selected for this
task contributes zero prompt tokens. ``find_capability`` is
always advertised (the escape hatch for unexposed capabilities).
The runtime-owned fallback prompt (for non-native-tool models) is
injected and traced by LocalToolRuntime itself — runtime-owned,
@@ -1054,17 +1141,22 @@ I have tools to read, write, edit, search, lint, test, format, and commit code.
except ImportError: # TUI top-level deploy
from rune.context_broker import ContributionBlock
tool_names = [t["name"] for t in tool_executor.TOOLS]
description = getattr(task, "description", "") or ""
selection = self._capability_selection(description)
tool_names = selection.get("names") or [t["name"] for t in tool_executor.TOOLS]
return ContributionBlock(
"tools",
90,
# P0-05 (#21) P2: names alone left a tool-capable model claiming it
# could not read files — the block must carry usage guidance.
# P1-01 (#24): guidance names the escape hatch so unexposed
# capabilities stay reachable within the same session.
f"\n## Available tools\n{', '.join(tool_names)}\n"
"Use tools for every file/command action; never fabricate tool output. "
"Read before editing; verify fixes by running them.",
"Read before editing; verify fixes by running them. "
"If you need a capability not listed, call find_capability.",
0,
"tool_executor.TOOLS",
"capability_broker.select_for_job",
evictable=False,
)
@@ -1456,7 +1548,26 @@ I have tools to read, write, edit, search, lint, test, format, and commit code.
# Authoritative assembly: collect -> measure -> admit -> render.
broker = self._context_broker()
session = {"model": model, "degraded": degraded, "recall_count": 0}
capsule, trace = broker.assemble(task, session, model_id=model)
# broker may be a Mock/legacy stand-in in scripted tests; degrade-
# not-crash: fall back to an empty capsule + empty trace.
if not hasattr(broker, "assemble"):
from types import SimpleNamespace as _NS
broker = _NS(assemble=lambda *a, **k: (
_NS(token_count=0, blocks=[], active_objective="", to_messages=lambda: []),
{},
))
assemble = getattr(broker, "assemble", None)
if assemble is None:
# Degrade-not-crash: a stand-in broker without assemble gets an
# empty capsule + trace (scripted tests, Mock runtimes).
from types import SimpleNamespace as _NS
broker = _NS(assemble=lambda *a, **k: (
_NS(token_count=0, blocks=[], active_objective="", to_messages=lambda: []),
{},
))
capsule, trace = broker.assemble(task, session, model_id=model) # type: ignore[attr-defined]
self._last_trace = trace
for subsystem in trace.get("degraded", []):
if subsystem not in degraded:
@@ -1511,6 +1622,13 @@ I have tools to read, write, edit, search, lint, test, format, and commit code.
# its OWN delta (current cumulative − that transport's previous).
_prev_usage = {}
# Quality discharge (P1-01 #24): one capability selection per
# run. The prior triple call (runtime construction + trace
# selected_tools + trace capability_reason) is hoisted here;
# the per-description cache makes later _contributor_tools
# lookups within the same run zero-cost re-reads.
capability_selection = self._capability_selection(task.description)
def _fold_attempt(transport):
"""Fold THIS attempt's usage delta into the run sums (I2)."""
cur = {
@@ -1537,29 +1655,56 @@ I have tools to read, write, edit, search, lint, test, format, and commit code.
# Safety override decorates the final user BLOCK in place
# (P0-03 constraint d); rendering flows from the blocks.
# P1-02 (#25): after mutation the block is RE-MEASURED so
# capsule.token_count (and the broker trace's per-block
# entry) stay truthful post-decoration — the override grows
# the payload ≤ ~100 tokens and admission left headroom, so
# no re-admission is needed; measurement is not budgeting.
for block in capsule.blocks:
if block.name == "user":
block.content = override_prefix + task.description + override_suffix
block.tokens = len(block.content) // 4
for trace_block in trace["blocks"]:
if trace_block.get("name") == "user" and trace_block.get("admitted"):
trace_block["tokens"] = block.tokens
capsule.token_count = sum(
block.tokens for block in capsule.blocks if hasattr(block, "tokens")
)
messages = capsule.to_messages()
# P1-02 (#25) acceptance: the log must capture the EXACT
# payload the runtime receives — capture BEFORE run, assert
# identity in the frozen test + canary.
if hasattr(capsule, "capture_messages"):
capsule.capture_messages(messages)
runtime = LocalToolRuntime(
transport=OllamaChatTransport(timeout=timeout),
executor=tool_executor.execute_tool,
model=model,
tools=capability_selection["tools"],
)
transport = runtime.transport
# P0-03: the runtime window must match the broker's budget
# basis — a hardcoded 8192 would silently truncate blocks the
# broker admitted against a larger discovered window. The
# other 13 num_ctx sites remain pinned (follow-up issue).
from rune.local_tool_runtime import context_length as _ctx_len
# P1-02 (#25) one-line num_ctx fix: pass the broker budget
# basis — capsule.token_count + completion_reserve (window −
# budget) — NOT the raw window, so the runtime respects the
# broker's admission decision (#28 owns model-aware budgets).
completion_reserve = 0
if trace.get("window") is not None and trace.get("budget") is not None:
completion_reserve = max(0, int(trace["window"]) - int(trace["budget"]))
num_ctx = int(capsule.token_count) + completion_reserve
if num_ctx <= 0:
# Degrade-not-crash (stand-in broker/trace): keep the
# discovered window rather than sending num_ctx=0.
from rune.local_tool_runtime import context_length as _ctx_len
num_ctx = _ctx_len(model)
response = runtime.run(
messages,
options={
"temperature": temperature,
"num_predict": 4096,
"num_ctx": _ctx_len(model),
"num_ctx": num_ctx,
"stop": ["\n\nUser:", "\n\n---"],
},
think=True,
@@ -1573,12 +1718,18 @@ I have tools to read, write, edit, search, lint, test, format, and commit code.
# Approval resume (P0-03 constraint e): embed the capsule in
# pending_state so a resumed turn replays identical context.
# P1-01 (#24): also persist the exposed tool set (selected +
# dynamically exposed) so the resume runtime rebuilds the
# exact same set (#24 global constraint).
if response.pending_approval is not None and response.pending_state is not None:
response.pending_state["context_capsule"] = {
"token_count": capsule.token_count,
"active_objective": capsule.active_objective,
"blocks": [block.to_dict() for block in capsule.blocks],
}
response.pending_state["exposed_tools"] = _sorted_tool_names(
getattr(runtime, "tool_names", ())
)
terminal_refusal = (
response.success
@@ -1652,12 +1803,51 @@ I have tools to read, write, edit, search, lint, test, format, and commit code.
except ImportError: # TUI top-level deploy
from rune.context_broker import MEMORY_BLOCK_NAMES
try:
from .inference_trace import InferenceTrace, price_for, record_trace
from .inference_trace import (
INFERENCE_TRACE_SCHEMA_VERSION,
InferenceTrace,
price_for,
record_trace,
)
except ImportError: # TUI top-level deploy
from rune.inference_trace import InferenceTrace, price_for, record_trace
from rune.inference_trace import (
INFERENCE_TRACE_SCHEMA_VERSION,
InferenceTrace,
price_for,
record_trace,
)
# P1-02 (#25) schema v4: capsule fidelity evidence. The
# captured sent messages (post-decoration, pre-run) plus
# per-block SHA-256 integrity hashes and the re-measured
# token total. Degrade-not-crash (P0-06): a stand-in
# capsule (Mock/legacy) must never fail the trace —
# everything is guarded; the record just omits evidence
# that cannot be derived.
capsule_hashes: dict = {}
for _b in list(getattr(capsule, "blocks", None) or []):
content = getattr(_b, "content", None)
name = getattr(_b, "name", "")
if not isinstance(name, str) or not name or content is None:
continue
if isinstance(content, str):
payload = content.encode("utf-8")
elif isinstance(content, (list, dict)):
payload = json.dumps(content, ensure_ascii=False, sort_keys=True).encode("utf-8")
else:
continue
capsule_hashes[name] = hashlib.sha256(payload).hexdigest()
_sent = getattr(capsule, "sent_messages", None)
if not isinstance(_sent, list):
try:
_sent = capsule.to_messages()
except Exception:
_sent = []
if not isinstance(_sent, list):
_sent = []
trace_record = InferenceTrace(
schema_version=2,
schema_version=INFERENCE_TRACE_SCHEMA_VERSION,
provider=getattr(transport, "provider", "unknown"),
model=model,
task_id=task.task_id,
@@ -1690,6 +1880,26 @@ I have tools to read, write, edit, search, lint, test, format, and commit code.
routing_reason=routing_reason,
routing_mode=routing_mode,
task_family=task_family,
# P1-01 (#24) schema v3: capability-authority fields
# (single hoisted selection — see run-level comment).
selected_tools=[t["name"] for t in capability_selection["tools"]],
capability_reason=capability_selection.get("reason", ""),
find_capability_calls=sum(
1 for event in response.tool_events if event.request.name == "find_capability"
),
# P1-02 (#25) schema v4: capsule fidelity fields —
# the logged messages EXACTLY equal the runtime.run
# payload (captured pre-run on the mutated capsule).
capsule_messages=[
dict(m) for m in _sent if isinstance(m, dict)
],
capsule_content_hashes=capsule_hashes,
capsule_token_count=(
int(capsule.token_count)
if isinstance(getattr(capsule, "token_count", 0), int)
and not isinstance(getattr(capsule, "token_count", 0), bool)
else 0
),
)
record_trace(trace_record)
# P1-06 (quality I-2 discharge): CLOSE the measured loop —
@@ -1728,6 +1938,10 @@ I have tools to read, write, edit, search, lint, test, format, and commit code.
model_profiles.record_interaction(model, False, task.description)
return OperatorResponse(success=False, model=model, error=str(e), degraded=sorted(set(degraded)))
# Unreachable in practice (the tier loop always returns or raises),
# but the declared contract requires a value on all code paths.
return OperatorResponse(success=False, model=model, error="task execution ended without a result", degraded=sorted(set(degraded)))
@staticmethod
def _finalize_runtime_response(
response: OperatorResponse,
+187 -24
View File
@@ -75,6 +75,7 @@ READ_ONLY_TOOLS = frozenset(
"query_memory",
"check_growth",
"diagnose_behavior",
"find_capability",
}
)
MUTATING_TOOLS = frozenset({"write_file", "edit_file", "format_code", "git_commit", "run_command"})
@@ -102,6 +103,12 @@ _CONSUMED_APPROVALS: set[str] = set()
# TOOL DEFINITIONS — JSON Schema for the model
# ═══════════════════════════════════════════════════════════════════════════════
# P1-01 (#24): per-tool selection metadata. Single source of truth; the
# capability broker's cards reference tool names and copy these values.
# ``risk`` is one of "read-only" | "write" | "irreversible". The approval
# split (tool_requires_approval / PreparedTool) stays authoritative for
# gating — metadata informs selection, never bypasses approval.
TOOLS = [
# ═══════════════════════════════════════════════════════════════════════════
# TIER 1: Essential file operations
@@ -109,6 +116,9 @@ TOOLS = [
{
"name": "read_file",
"description": "Read a file with optional windowed viewing (offset/limit). Returns numbered lines. Use offset/limit for large files (100 lines at a time).",
"risk": "read-only",
"preconditions": ["path exists and is a readable file"],
"side_effects": [],
"parameters": {
"type": "object",
"properties": {
@@ -122,6 +132,9 @@ TOOLS = [
{
"name": "edit_file",
"description": "Surgical search/replace edit. More reliable than full file rewrites. The search string must match exactly.",
"risk": "write",
"preconditions": ["path is inside an allowed write directory", "search string matches exactly once"],
"side_effects": ["rewrites the target file atomically after approval", "creates a .bak backup under ~/.rune/undo"],
"parameters": {
"type": "object",
"properties": {
@@ -135,6 +148,9 @@ TOOLS = [
{
"name": "write_file",
"description": "Write content to a file. Creates parent directories if needed. Shows diff before writing. Use for new files or full rewrites.",
"risk": "write",
"preconditions": ["path is inside an allowed write directory"],
"side_effects": ["writes the full file content atomically after approval", "creates a .bak backup under ~/.rune/undo"],
"parameters": {
"type": "object",
"properties": {
@@ -147,6 +163,9 @@ TOOLS = [
{
"name": "glob_files",
"description": "Find files matching a glob pattern. Returns file paths sorted by modification time.",
"risk": "read-only",
"preconditions": ["search directory is readable"],
"side_effects": [],
"parameters": {
"type": "object",
"properties": {
@@ -159,6 +178,9 @@ TOOLS = [
{
"name": "grep_files",
"description": "Search for a pattern in files. Returns file paths and line numbers. Use include to filter by file type.",
"risk": "read-only",
"preconditions": ["search directory is readable"],
"side_effects": [],
"parameters": {
"type": "object",
"properties": {
@@ -172,6 +194,9 @@ TOOLS = [
{
"name": "list_directory",
"description": "List files and directories at a path. Returns names with / suffix for directories.",
"risk": "read-only",
"preconditions": ["path is a readable directory"],
"side_effects": [],
"parameters": {
"type": "object",
"properties": {
@@ -186,6 +211,9 @@ TOOLS = [
{
"name": "lint_file",
"description": "Lint a file for syntax errors and style issues. Blocks bad edits. Supports Python (ruff), JS/TS (eslint), Go (golangci-lint), and more.",
"risk": "read-only",
"preconditions": ["file exists", "a linter is configured for the language"],
"side_effects": [],
"parameters": {
"type": "object",
"properties": {
@@ -201,6 +229,9 @@ TOOLS = [
{
"name": "run_tests",
"description": "Run tests for a file or project. Auto-detects test runner from language.",
"risk": "write",
"preconditions": ["a test runner is configured for the project"],
"side_effects": ["runs project test commands which may create or delete files"],
"parameters": {
"type": "object",
"properties": {
@@ -213,6 +244,9 @@ TOOLS = [
{
"name": "format_code",
"description": "Auto-format code files. Supports Python (black/ruff), JS/TS (prettier), Go (gofmt), and more.",
"risk": "write",
"preconditions": ["path is inside an allowed write directory", "a formatter is configured for the language"],
"side_effects": ["rewrites files in place to the formatter's style"],
"parameters": {
"type": "object",
"properties": {
@@ -228,6 +262,9 @@ TOOLS = [
{
"name": "git_diff",
"description": "Show git diff for staged or unstaged changes.",
"risk": "read-only",
"preconditions": ["working directory is a git repository"],
"side_effects": [],
"parameters": {
"type": "object",
"properties": {
@@ -239,6 +276,9 @@ TOOLS = [
{
"name": "git_log",
"description": "Show recent git commits.",
"risk": "read-only",
"preconditions": ["working directory is a git repository"],
"side_effects": [],
"parameters": {
"type": "object",
"properties": {
@@ -250,6 +290,9 @@ TOOLS = [
{
"name": "git_commit",
"description": "Commit changes with a message. Auto-stages modified files.",
"risk": "irreversible",
"preconditions": ["working directory is a git repository", "there are changes to commit"],
"side_effects": ["creates a commit on the current branch"],
"parameters": {
"type": "object",
"properties": {
@@ -269,6 +312,9 @@ TOOLS = [
{
"name": "repo_map",
"description": "Generate an AST-based codebase map showing key symbols (classes, functions, imports). Uses tree-sitter for 100+ languages.",
"risk": "read-only",
"preconditions": ["target directory is readable"],
"side_effects": [],
"parameters": {
"type": "object",
"properties": {
@@ -285,6 +331,9 @@ TOOLS = [
{
"name": "run_command",
"description": "Run an approved command. Prefer argv (shell=False); shell syntax requires shell=true and explicit user approval.",
"risk": "irreversible",
"preconditions": ["operator approves the command", "required binaries are on PATH"],
"side_effects": ["executes arbitrary commands with their full side effects"],
"parameters": {
"type": "object",
"properties": {
@@ -312,14 +361,17 @@ TOOLS = [
{
"name": "read_persona",
"description": "Read Rune's persona definition. Returns the full persona block with voice, identity, and rules.",
"parameters": {
"type": "object",
"properties": {},
},
"risk": "read-only",
"preconditions": ["persona MEMORY.md exists"],
"side_effects": [],
"parameters": {"type": "object", "properties": {}},
},
{
"name": "query_memory",
"description": "Query Rune's memory store. Returns relevant memories for a query.",
"risk": "read-only",
"preconditions": ["memory store database is available"],
"side_effects": [],
"parameters": {
"type": "object",
"properties": {
@@ -332,6 +384,9 @@ TOOLS = [
{
"name": "check_growth",
"description": "Check Rune's growth log. Returns recent growth entries and patterns.",
"risk": "read-only",
"preconditions": ["growth log file exists"],
"side_effects": [],
"parameters": {
"type": "object",
"properties": {
@@ -342,6 +397,9 @@ TOOLS = [
{
"name": "diagnose_behavior",
"description": "Diagnose Rune's current behavior. Returns analysis of persona adherence, voice consistency, and drift.",
"risk": "read-only",
"preconditions": ["recent response text provided"],
"side_effects": [],
"parameters": {
"type": "object",
"properties": {
@@ -350,6 +408,23 @@ TOOLS = [
"required": ["text"],
},
},
{
"name": "find_capability",
"description": "Search all installed capabilities for what you need. Returns matching tool names with descriptions and argument schemas. Use this when a capability you need is not in your current tool set; the matched tool becomes callable in the same session.",
"risk": "read-only",
"preconditions": ["a capability query describing the needed outcome"],
"side_effects": [],
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "What you need to do, e.g. 'run the test suite' or 'commit my changes'",
},
},
"required": ["query"],
},
},
]
@@ -545,6 +620,9 @@ def _execute_tool_now(tool_name: str, args: dict) -> dict:
return _check_growth(args)
elif tool_name == "diagnose_behavior":
return _diagnose_behavior(args)
# P1-01 (#24): capability escape hatch
elif tool_name == "find_capability":
return _find_capability(args)
else:
return {"success": False, "error": f"Unknown tool: {tool_name}"}
except Exception as e:
@@ -589,7 +667,7 @@ def _prepared_path(name: str, arguments: dict) -> tuple[str, Path] | None:
def prepare_tool(
tool_name: str,
args: dict,
args: "dict | object",
*,
ttl: float = DEFAULT_APPROVAL_TTL,
now: float | None = None,
@@ -642,7 +720,7 @@ def prepare_tool(
return {"success": False, "error": str(exc)}
def _consume_prepared(pending: PreparedTool, now: float | None) -> str | None:
def _consume_prepared(pending: "PreparedTool | object", now: float | None) -> str | None:
if not isinstance(pending, PreparedTool):
return "Invalid pending tool"
if pending.token in _CONSUMED_APPROVALS:
@@ -682,7 +760,7 @@ def approve_tool(pending: PreparedTool, *, now: float | None = None) -> dict:
return _execute_tool_now(pending.name, copy.deepcopy(pending.arguments))
def reject_tool(pending: PreparedTool) -> dict:
def reject_tool(pending: "PreparedTool | object") -> dict:
"""Consume a pending operation without executing it."""
if not isinstance(pending, PreparedTool):
return {"success": False, "error": "Invalid pending tool"}
@@ -692,7 +770,7 @@ def reject_tool(pending: PreparedTool) -> dict:
return {"success": False, "error": "Operator rejected"}
def serialize_prepared_tool(pending: PreparedTool) -> dict:
def serialize_prepared_tool(pending: "PreparedTool | object") -> dict:
"""Return the JSON-safe persisted form used by the local runtime."""
if not isinstance(pending, PreparedTool):
raise ValueError("Invalid pending tool")
@@ -706,20 +784,39 @@ def deserialize_prepared_tool(value: object) -> PreparedTool:
expected = {dataclass_field.name for dataclass_field in PreparedTool.__dataclass_fields__.values()}
if set(value) != expected:
raise ValueError("prepared tool fields are invalid")
# Validate field types BEFORE construction: the dataclass does not
# validate, so a corrupt persisted row would otherwise construct fine and
# only explode later (and pyright rightly flags post-construction checks
# as unreachable given declared types).
token = value.get("token")
name = value.get("name")
arguments = value.get("arguments")
operation_digest = value.get("operation_digest")
created_at = value.get("created_at")
expires_at = value.get("expires_at")
if not isinstance(token, str) or not token:
raise ValueError("prepared tool token is invalid")
if not isinstance(name, str) or not name:
raise ValueError("prepared tool name is invalid")
if not isinstance(arguments, dict):
raise ValueError("prepared tool arguments are invalid")
if not isinstance(operation_digest, str):
raise ValueError("prepared tool digest is invalid")
if not isinstance(created_at, (int, float)) or isinstance(created_at, bool):
raise ValueError("prepared tool timestamps are invalid")
if not isinstance(expires_at, (int, float)) or isinstance(expires_at, bool):
raise ValueError("prepared tool timestamps are invalid")
try:
pending = PreparedTool(**copy.deepcopy(value))
pending = PreparedTool(
token=token, name=name, arguments=copy.deepcopy(arguments),
operation_digest=operation_digest,
created_at=created_at, expires_at=expires_at,
requested_path=value.get("requested_path"),
resolved_path=value.get("resolved_path"),
path_digest=value.get("path_digest"),
)
except TypeError as exc:
raise ValueError(f"prepared tool is invalid: {exc}") from exc
if not isinstance(pending.token, str) or not pending.token:
raise ValueError("prepared tool token is invalid")
if not isinstance(pending.name, str) or not pending.name:
raise ValueError("prepared tool name is invalid")
if not isinstance(pending.arguments, dict):
raise ValueError("prepared tool arguments are invalid")
if not isinstance(pending.operation_digest, str):
raise ValueError("prepared tool digest is invalid")
if not isinstance(pending.created_at, (int, float)) or not isinstance(pending.expires_at, (int, float)):
raise ValueError("prepared tool timestamps are invalid")
return pending
@@ -738,8 +835,9 @@ def _read_file(args: dict) -> dict:
limit = args.get("limit", 100) # Default 100 lines (SWE-agent pattern)
ok, msg, resolved = validate_read_path(path)
if not ok:
if not ok or resolved is None:
return {"success": False, "error": msg}
assert resolved is not None # narrowing for the type checker
try:
content = resolved.read_text(encoding="utf-8", errors="replace")
@@ -771,8 +869,9 @@ def _write_file(args: dict) -> dict:
content = args.get("content", "")
ok, msg, resolved = validate_write_path(path)
if not ok:
if not ok or resolved is None:
return {"success": False, "error": msg}
assert resolved is not None # narrowing for the type checker
# Generate diff for confirmation
diff = generate_diff(resolved, content)
@@ -939,8 +1038,9 @@ def _edit_file(args: dict) -> dict:
return {"success": False, "error": "Search string is required"}
ok, msg, resolved = validate_write_path(path)
if not ok:
if not ok or resolved is None:
return {"success": False, "error": msg}
assert resolved is not None # narrowing for the type checker
try:
content = resolved.read_text(encoding="utf-8", errors="replace")
@@ -955,6 +1055,7 @@ def _edit_file(args: dict) -> dict:
return {"success": False, "error": f"Search string matches {count} times. Be more specific."}
# Generate diff for confirmation
assert resolved is not None # narrowing for the type checker (validate_write_path guarantees it when ok)
new_content = content.replace(search, replace)
diff = generate_diff(resolved, new_content)
@@ -1009,6 +1110,7 @@ def _glob_files(args: dict) -> dict:
def _grep_files(args: dict) -> dict:
"""Search for a pattern in files. Returns file paths and line numbers."""
import subprocess # noqa: F811 — function-local; except clauses reference it
pattern = args.get("pattern", "")
path = args.get("path", ".")
include = args.get("include")
@@ -1124,8 +1226,9 @@ def _lint_file(args: dict) -> dict:
language = args.get("language")
ok, msg, resolved = validate_read_path(path)
if not ok:
if not ok or resolved is None:
return {"success": False, "error": msg}
assert resolved is not None # narrowing for the type checker
if not language:
language = _detect_language(str(resolved))
@@ -1148,6 +1251,7 @@ def _lint_file(args: dict) -> dict:
def _run_tests(args: dict) -> dict:
"""Run tests for a file or project."""
import subprocess # noqa: F811 — function-local; except clauses reference it
path = args.get("path", ".")
command = args.get("command")
@@ -1191,8 +1295,9 @@ def _format_code(args: dict) -> dict:
language = args.get("language")
ok, msg, resolved = validate_write_path(path)
if not ok:
if not ok or resolved is None:
return {"success": False, "error": msg}
assert resolved is not None # narrowing for the type checker
if not language:
language = _detect_language(str(resolved))
@@ -1485,6 +1590,64 @@ def _diagnose_behavior(args: dict) -> dict:
return {"success": False, "error": f"Diagnosis failed: {e}"}
# ═══════════════════════════════════════════════════════════════════════════════
# P1-01 (#24): find_capability — the capability escape hatch
# ═══════════════════════════════════════════════════════════════════════════════
FIND_CAPABILITY_TOOL = "find_capability"
def _find_capability(args: dict) -> dict:
"""Execute the escape hatch: query the broker for matching capabilities.
Returns matching capability summaries (name + description + argument
schema) so the model can call a discovered tool in the same session.
The runtime auto-exposes returned native tools for the rest of the
run (see local_tool_runtime). Degrade-not-crash: a broker failure is
a tool error, never a crash.
"""
query = args.get("query", "")
if not isinstance(query, str) or not query.strip():
return {"success": False, "error": "Query is required — describe the capability you need"}
try:
try:
from rune import capability_broker as broker
except ImportError: # direct script execution
import capability_broker as broker
matches = broker.find_capability(query)
except Exception as exc:
return {"success": False, "error": f"find_capability failed: {exc}"}
if not matches:
return {
"success": True,
"result": (
f"No capability matches {query!r}. The tools listed in your "
"system prompt are all that is installed. Use "
f"{FIND_CAPABILITY_TOOL} again with a broader description, "
"or work with the tools you have."
),
"capabilities": [],
"exposed": [],
}
lines = []
for match in matches:
properties = match.get("parameters", {}).get("properties", {})
arg_names = ", ".join(properties) if properties else "no arguments"
lines.append(f"- {match['name']} ({match.get('risk', 'read-only')}): {match['description']} Args: {arg_names}")
return {
"success": True,
"result": (
"Matching capabilities (callable now in this session):\n"
+ "\n".join(lines)
),
"capabilities": matches,
"exposed": [match["name"] for match in matches],
}
# ═══════════════════════════════════════════════════════════════════════════════
# AGENT LOOP — model → tool → result → model
# ═══════════════════════════════════════════════════════════════════════════════
+889
View File
@@ -0,0 +1,889 @@
"""P1-01 (#24) frozen tests — CapabilityBroker becomes the selection authority.
Frozen tests are the spec. Each task's tests land red first, then the
implementation makes them green. Covers:
- Task 1: per-tool metadata (risk/preconditions/side_effects) on TOOLS and
capability-card synthesis + rebuild-on-empty/stale validation;
- Task 2: ``select_for_job`` with the verified floor;
- Task 3: operator/runtime exposure wiring, ``find_capability`` escape
hatch, and trace schema v3 fields;
- Task 4: composition registration of the ``capability_selection`` authority.
"""
from __future__ import annotations
import json
import os
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from rune import capability_broker as cb # noqa: E402
from rune.tool_executor import TOOLS # noqa: E402
VALID_RISKS = {"read-only", "write", "irreversible"}
# ---------------------------------------------------------------------------
# Task 1 — per-tool metadata + card synthesis/validation
# ---------------------------------------------------------------------------
def test_every_tool_entry_carries_risk_preconditions_side_effects():
assert TOOLS, "tool registry must not be empty"
for tool in TOOLS:
assert tool.get("risk") in VALID_RISKS, f"{tool['name']}: bad risk"
assert isinstance(tool.get("preconditions"), list), f"{tool['name']}: preconditions"
assert isinstance(tool.get("side_effects"), list), f"{tool['name']}: side_effects"
def test_native_cards_are_synthesized_when_cards_file_empty(tmp_path, monkeypatch):
cards_file = tmp_path / "capability_cards.json"
cards_file.write_text("[]")
monkeypatch.setattr(cb, "CARDS_FILE", str(cards_file))
monkeypatch.setattr(cb, "REGISTRY", str(tmp_path / "no-registry.json"))
loaded = cb.load_cards()
names = {card["name"] for card in loaded}
for tool in TOOLS:
assert tool["name"] in names, f"native card missing for {tool['name']}"
assert any(card.get("native_tool") for card in loaded), "native cards must be flagged"
def test_native_cards_carry_tool_metadata(tmp_path, monkeypatch):
cards_file = tmp_path / "capability_cards.json"
cards_file.write_text("[]")
monkeypatch.setattr(cb, "CARDS_FILE", str(cards_file))
monkeypatch.setattr(cb, "REGISTRY", str(tmp_path / "no-registry.json"))
by_name = {c["name"]: c for c in cb.load_cards() if c.get("native_tool")}
for tool in TOOLS:
card = by_name[tool["name"]]
assert card["description"] == tool["description"]
assert card["risk"] == tool["risk"]
assert card["preconditions"] == tool["preconditions"]
assert card["side_effects"] == tool["side_effects"]
def test_load_cards_rebuilds_when_file_missing(tmp_path, monkeypatch):
cards_file = tmp_path / "missing.json"
monkeypatch.setattr(cb, "CARDS_FILE", str(cards_file))
monkeypatch.setattr(cb, "REGISTRY", str(tmp_path / "no-registry.json"))
loaded = cb.load_cards()
assert any(card.get("native_tool") for card in loaded)
assert cards_file.exists(), "rebuild must persist the synthesized cards"
def test_load_cards_rebuilds_when_stale_vs_registry(tmp_path, monkeypatch):
cards_file = tmp_path / "capability_cards.json"
cards_file.write_text(json.dumps([{"name": "old_plugin_card", "description": "stale"}]))
registry = tmp_path / ".microkernel.json"
registry.write_text(
json.dumps(
{
"plugins": {
"memory_recall": {
"manifest": {
"description": "recall stored memory",
"capabilities": ["memory_recall"],
"hooks": {},
"_file": "memory_recall.py",
"priority": 10,
}
}
}
}
)
)
old = time.time() - 1000
os.utime(cards_file, (old, old))
monkeypatch.setattr(cb, "CARDS_FILE", str(cards_file))
monkeypatch.setattr(cb, "REGISTRY", str(registry))
loaded = cb.load_cards()
names = {card["name"] for card in loaded}
assert "old_plugin_card" not in names, "stale cards must be rebuilt away"
assert "memory_recall" in names, "registry plugins must be rebuilt in"
assert any(card.get("native_tool") for card in loaded)
def test_load_cards_keeps_fresh_nonempty_file(tmp_path, monkeypatch):
fresh = [
{
"name": "read_file",
"description": "d",
"capabilities": ["read_file"],
"priority": 100,
"risk": "read-only",
"preconditions": [],
"side_effects": [],
"native_tool": True,
}
]
cards_file = tmp_path / "capability_cards.json"
cards_file.write_text(json.dumps(fresh))
monkeypatch.setattr(cb, "CARDS_FILE", str(cards_file))
monkeypatch.setattr(cb, "REGISTRY", str(tmp_path / "no-registry.json"))
assert cb.load_cards() == fresh, "fresh non-empty cards must not be rebuilt"
def test_load_cards_rebuilds_on_corrupt_json(tmp_path, monkeypatch):
cards_file = tmp_path / "capability_cards.json"
cards_file.write_text("{not json")
monkeypatch.setattr(cb, "CARDS_FILE", str(cards_file))
monkeypatch.setattr(cb, "REGISTRY", str(tmp_path / "no-registry.json"))
loaded = cb.load_cards()
assert any(card.get("native_tool") for card in loaded)
# ---------------------------------------------------------------------------
# Task 2 — select_for_job + verified floor
# ---------------------------------------------------------------------------
FLOOR = cb.FLOOR_TOOLS
TASK_TOOLS = {"read_file", "write_file", "edit_file", "list_directory", "grep_files"}
def test_floor_constant_names_exact_required_tools():
assert set(FLOOR) == TASK_TOOLS, "floor must be exactly the five T1 file tools"
assert cb.ESCAPE_HATCH_TOOL == "find_capability"
def test_select_for_job_coding_task_returns_floor_plus_matches():
result = cb.select_for_job("fix the bug in parse_user.py and add a test")
names = [t["name"] for t in result["tools"]]
assert TASK_TOOLS <= set(names), "floor must always be present"
assert "find_capability" in names, "escape hatch must always be present"
assert 5 <= len(names) <= 10, f"selection size {len(names)} outside 5..10"
assert names, "selection must never be empty"
def test_select_for_job_zero_score_falls_back_to_floor():
gibberish = "zzzqqq xxxyyy wwww vvvv"
result = cb.select_for_job(gibberish)
names = [t["name"] for t in result["tools"]]
assert set(names) == TASK_TOOLS | {"find_capability"}
assert result["reason"] == "floor (no matches)"
def test_select_for_job_reason_strings_recorded():
result = cb.select_for_job("fix the failing test in module.py")
assert isinstance(result["reason"], str) and result["reason"]
assert result["reason"].startswith("floor"), "reason must record the floor basis"
def test_select_for_job_find_capability_always_present():
for description in ("fix code", "remember this", "research topic", ""):
result = cb.select_for_job(description)
names = [t["name"] for t in result["tools"]]
assert "find_capability" in names
def test_select_for_job_is_deterministic():
a = cb.select_for_job("fix the bug in parse.py")
b = cb.select_for_job("fix the bug in parse.py")
assert [t["name"] for t in a["tools"]] == [t["name"] for t in b["tools"]]
assert a["reason"] == b["reason"]
def test_select_for_job_selection_derived_from_full_tools_payload():
"""Selected entries are the actual schema dicts (ride the tools param)."""
result = cb.select_for_job("fix the bug in parse.py")
by_name = {t["name"]: t for t in TOOLS}
for selected in result["tools"]:
assert selected is by_name[selected["name"]], f"{selected['name']} must be the real schema object"
def test_select_for_job_floor_survives_any_query_and_set_stays_bounded():
"""Floor is policy, not score: present for every query, set ≤ 10."""
for description in (
"grep for the word banana across files",
"remember this decision for later",
"research the history of this topic thoroughly",
"",
):
result = cb.select_for_job(description)
names = [t["name"] for t in result["tools"]]
assert TASK_TOOLS <= set(names)
assert "find_capability" in names
assert len(names) <= 10
def test_select_for_job_records_task_family():
result = cb.select_for_job("fix the bug in parse.py")
assert result["task_family"] == "code"
def test_select_for_job_degrades_to_full_tools_on_broker_failure(monkeypatch):
def boom():
raise RuntimeError("broker exploded")
monkeypatch.setattr(cb, "_scored_tools", boom)
result = cb.select_for_job("fix the bug in parse.py")
assert [t["name"] for t in result["tools"]] == [t["name"] for t in TOOLS]
assert result["reason"].startswith("fallback full toolset")
def test_select_for_job_plugin_cards_do_not_invent_tool_schemas(monkeypatch):
"""Plugin capabilities ride the escape hatch, not the tools param:
the selection contains only real TOOLS schemas, floor intact."""
fake_cards = [
{
"name": "memory_recall",
"description": "recall stored memory about past sessions",
"capabilities": ["memory_recall"],
"priority": 5,
"semantic_examples": ["What do I remember about X?"],
"risk": "read-only",
"searchable_text": "memory recall recall stored memory remember",
}
]
monkeypatch.setattr(cb, "load_cards", lambda: fake_cards)
result = cb.select_for_job("what do I remember about this session")
names = [t["name"] for t in result["tools"]]
by_name = {t["name"] for t in TOOLS}
assert set(names) <= by_name, "selection must never invent a non-schema tool"
assert set(TASK_TOOLS) <= set(names), "floor always present"
assert "find_capability" in names, "escape hatch always present"
# ---------------------------------------------------------------------------
# Task 3 — operator/runtime wiring + find_capability escape hatch + trace v3
# ---------------------------------------------------------------------------
import copy # noqa: E402
import unittest.mock as mock # noqa: E402
from rune import local_tool_runtime as ltr # noqa: E402
from rune import rune_operator # noqa: E402
from rune.local_tool_runtime import LocalToolRuntime # noqa: E402
from rune.tool_protocol import to_ollama_tools # noqa: E402
class _FakeTransport:
def __init__(self, responses):
self.responses = list(responses)
self.calls: list[dict] = []
def supports_tools(self, model: str) -> bool:
return True
def supports_thinking(self, model: str) -> bool:
return True
def chat(self, *, model, messages, tools, options=None, think=None):
self.calls.append({"tools": tools, "messages": messages})
response = self.responses.pop(0)
return copy.deepcopy(response)
def _native_call(call_id, name, arguments):
return {"id": call_id, "function": {"name": name, "arguments": arguments}}
def _record_tool(name, arguments, call_id="c1"):
"""Assistant message carrying one native tool call."""
return {
"content": "",
"tool_calls": [_native_call(call_id, name, arguments)],
}
def test_runtime_exposes_only_selected_schemas():
"""Selection flows to the tools param: payload shrinks below 19."""
selected = cb.select_for_job("fix the bug in parse.py")
assert len(selected["tools"]) < len(TOOLS), "selection must shrink the set"
runtime = LocalToolRuntime(
transport=_FakeTransport([]),
executor=lambda n, a: {"success": True},
model="m",
tools=selected["tools"],
)
payload = to_ollama_tools(runtime.tools)
names = {item["function"]["name"] for item in payload}
assert names == set(selected["names"])
assert len(payload) < len(to_ollama_tools(TOOLS))
def test_default_runtime_still_exposes_full_tools():
"""No selection passed → full TOOLS (today's behavior, degrade path)."""
runtime = LocalToolRuntime(
transport=_FakeTransport([]),
executor=lambda n, a: {"success": True},
model="m",
)
assert len(runtime.tools) == len(TOOLS)
def test_inference_trace_schema_v3_fields():
from rune import inference_trace as it
# P1-02 (#25) bumped the schema to v4 (additive); assert against the
# module constant, not a pinned literal.
assert it.INFERENCE_TRACE_SCHEMA_VERSION == 4
trace = it.InferenceTrace(
schema_version=it.INFERENCE_TRACE_SCHEMA_VERSION,
provider="ollama",
model="m",
task_id="t",
channel="c",
resolved_context_window=0,
input_budget=0,
model_calls=0,
prompt_tokens_estimated=0,
prompt_tokens_served=0,
output_tokens=0,
latency_ms=0.0,
model_time_ms=0.0,
per_block_tokens={},
dropped_blocks=[],
tools_exposed=[],
memories_exposed=[],
memory_tokens=0,
degraded=[],
cache=None,
cost_usd=None,
cost_basis="local-unpriced",
success=True,
error="",
)
assert trace.selected_tools == []
assert trace.capability_reason == ""
assert trace.find_capability_calls == 0
def test_operator_trace_record_carries_capability_fields(tmp_path, monkeypatch):
"""A scripted operator run persists v3 capability fields."""
monkeypatch.setenv("RUNE_HOME", str(tmp_path / "home"))
(tmp_path / "home" / "state").mkdir(parents=True, exist_ok=True)
from rune import inference_trace as it
storage = tmp_path / "operator.json"
op = rune_operator.RuneOperator(str(storage))
trace = {
"model_id": "m",
"window": 40960,
"budget": 32768,
"blocks": [
{"name": "persona", "tokens": 10, "admitted": True, "reason": ""},
{"name": "user", "tokens": 5, "admitted": True, "reason": ""},
],
"admitted_total": 15,
"dropped": [],
"memory_tokens": 0,
"degraded": [],
"compaction": [],
"elapsed_ms": 1.0,
}
capsule = mock.Mock(
token_count=15,
active_objective="",
blocks=[],
to_messages=lambda: [{"role": "user", "content": "do it"}],
)
broker = mock.Mock(assemble=lambda task, session, model_id: (capsule, trace))
response = rune_operator.OperatorResponse(success=True, user_text="done", model="m")
class _RT:
transport = mock.Mock(provider="ollama")
tool_names = frozenset({"read_file", "find_capability"})
def run(self, messages, **kwargs):
return response
rt = _RT()
with (
mock.patch.object(op, "_context_broker", return_value=broker),
mock.patch.object(rune_operator.model_profiles, "get_or_create_profile", return_value=mock.Mock(safety_tier=0)),
mock.patch.object(rune_operator.model_profiles, "record_interaction"),
mock.patch.object(rune_operator, "LocalToolRuntime", return_value=rt),
mock.patch.object(rune_operator, "OllamaChatTransport", return_value=rt.transport),
mock.patch("rune.local_tool_runtime.context_length", lambda model_: 40960),
mock.patch.object(
rune_operator.safety_override.SafetyOverride,
"get_params",
lambda self, text: {"tier": 0, "temperature": 0.7, "prefix": "", "suffix": ""},
),
mock.patch.object(rune_operator.safety_override.SafetyOverride, "is_refusal", lambda self, text: False),
mock.patch.object(op, "_log_token_usage"),
mock.patch("rune.event_bus._store_path", lambda: tmp_path / "journal-events.jsonl"),
):
envelope = op.send_task("fix this code path")
assert envelope.get("success") is True
records = it.read_traces(trace_dir=tmp_path / "home" / "state")
assert records, "durable trace missing"
record = records[-1]
assert record["schema_version"] == 4
assert record["selected_tools"] == cb.select_for_job("fix this code path")["names"]
assert "capability_reason" in record and record["capability_reason"]
assert record["find_capability_calls"] == 0
def test_operator_trace_record_carries_selected_tools(tmp_path, monkeypatch):
"""The scripted run's LocalToolRuntime receives the selected set AND
the trace carries the v3 capability fields."""
monkeypatch.setenv("RUNE_HOME", str(tmp_path / "home2"))
(tmp_path / "home2" / "state").mkdir(parents=True, exist_ok=True)
from rune import inference_trace as it
storage = tmp_path / "operator2.json"
op = rune_operator.RuneOperator(str(storage))
trace = {
"model_id": "m",
"window": 40960,
"budget": 32768,
"blocks": [
{"name": "persona", "tokens": 10, "admitted": True, "reason": ""},
{"name": "user", "tokens": 5, "admitted": True, "reason": ""},
],
"admitted_total": 15,
"dropped": [],
"memory_tokens": 0,
"degraded": [],
"compaction": [],
"elapsed_ms": 1.0,
}
capsule = mock.Mock(
token_count=15,
active_objective="",
blocks=[],
to_messages=lambda: [{"role": "user", "content": "do it"}],
)
broker = mock.Mock(assemble=lambda task, session, model_id: (capsule, trace))
response = rune_operator.OperatorResponse(success=True, user_text="done", model="m")
captured: dict = {}
class _RT:
transport = mock.Mock(provider="ollama")
tool_names = frozenset()
def __init__(self, **kwargs):
captured.update(kwargs)
def run(self, messages, **kwargs):
return response
with (
mock.patch.object(op, "_context_broker", return_value=broker),
mock.patch.object(rune_operator.model_profiles, "get_or_create_profile", return_value=mock.Mock(safety_tier=0)),
mock.patch.object(rune_operator.model_profiles, "record_interaction"),
mock.patch.object(rune_operator, "LocalToolRuntime", _RT),
mock.patch.object(rune_operator, "OllamaChatTransport", return_value=mock.Mock(provider="ollama")),
mock.patch("rune.local_tool_runtime.context_length", lambda model_: 40960),
mock.patch.object(
rune_operator.safety_override.SafetyOverride,
"get_params",
lambda self, text: {"tier": 0, "temperature": 0.7, "prefix": "", "suffix": ""},
),
mock.patch.object(rune_operator.safety_override.SafetyOverride, "is_refusal", lambda self, text: False),
mock.patch.object(op, "_log_token_usage"),
mock.patch("rune.event_bus._store_path", lambda: tmp_path / "journal2-events.jsonl"),
):
envelope = op.send_task("fix this code path")
assert envelope.get("success") is True
tool_names = [t["name"] for t in captured.get("tools", [])]
assert tool_names == cb.select_for_job("fix this code path")["names"], (
"runtime construction site must receive the selected set"
)
records = it.read_traces(trace_dir=tmp_path / "home2" / "state")
assert records
record = records[-1]
assert record["schema_version"] == 4
assert record["selected_tools"] == tool_names
assert record["capability_reason"]
assert record["find_capability_calls"] == 0
def test_runtime_for_model_reuses_pending_exposed_set(tmp_path):
"""Resume round-trip: the persisted exposed set (selected + any tools
dynamically exposed via find_capability) is rebuilt exactly."""
storage = tmp_path / "operator3.json"
op = rune_operator.RuneOperator(str(storage))
op.state.pending_runtime_state = {"exposed_tools": ["read_file", "find_capability"]}
with mock.patch.object(rune_operator, "LocalToolRuntime", return_value=mock.Mock()) as rt_type:
op._runtime_for_model("m1")
_, kwargs = rt_type.call_args
assert [t["name"] for t in kwargs["tools"]] == ["read_file", "find_capability"]
def test_runtime_for_model_reselects_from_description_without_pending(tmp_path):
storage = tmp_path / "operator4.json"
op = rune_operator.RuneOperator(str(storage))
with mock.patch.object(rune_operator, "LocalToolRuntime", return_value=mock.Mock()) as rt_type:
op._runtime_for_model("m1", "fix the bug in parse.py")
_, kwargs = rt_type.call_args
assert [t["name"] for t in kwargs["tools"]] == cb.select_for_job("fix the bug in parse.py")["names"]
def test_contributor_tools_reflects_selection(tmp_path):
storage = tmp_path / "operator5.json"
op = rune_operator.RuneOperator(str(storage))
task = mock.Mock(description="fix the bug in parse.py")
block = op._contributor_tools(task, {})
assert "find_capability" in block.content, "escape hatch must be advertised"
for name in ("lint_file", "git_commit", "repo_map", "run_command", "read_persona"):
assert name not in block.content, f"unselected {name} must cost zero prompt tokens"
for name in cb.select_for_job("fix the bug in parse.py")["names"]:
assert name in block.content
def test_contributor_tools_names_track_selection_for_tool_heavy_task(tmp_path):
storage = tmp_path / "operator6.json"
op = rune_operator.RuneOperator(str(storage))
task = mock.Mock(description="run the test suite and commit")
block = op._contributor_tools(task, {})
selection_names = cb.select_for_job("run the test suite and commit")["names"]
for name in selection_names:
assert name in block.content
for name in ("repo_map", "read_persona", "diagnose_behavior"):
assert name not in block.content
def test_unknown_tool_call_returns_guidance_error():
"""Unknown-tool failure must name find_capability (one-step recovery)."""
transport = _FakeTransport([_record_tool("nonexistent_tool", {"x": 1}, call_id="c1")])
runtime = LocalToolRuntime(
transport=transport,
executor=lambda n, a: {"success": True},
model="m",
tools=cb.select_for_job("fix code")["tools"],
)
result = runtime.run([{"role": "user", "content": "go"}])
assert result.success is False
assert "find_capability" in (result.error or "")
def test_find_capability_execution_and_same_run_discovery():
"""find_capability returns matching schemas; the SAME run then calls
the discovered tool (dynamic exposure within the iteration budget)."""
executor = ltr2_exec = ltr._find_capability_executor if hasattr(ltr, "FIND_CAPABILITY_EXECUTOR") else None
responses = [
_record_tool("find_capability", {"query": "map the repository structure"}, call_id="c1"),
_record_tool("repo_map", {"path": "."}, call_id="c2"),
{"content": "tests ran"},
]
transport = _FakeTransport(responses)
runtime = LocalToolRuntime(
transport=transport,
executor=executor,
model="m",
tools=cb.select_for_job("fix code")["tools"],
)
result = runtime.run([{"role": "user", "content": "run the tests"}])
assert result.success is True, result.error
assert result.user_text == "tests ran"
# The second chat call must have carried the discovered tool schema.
second_tools = transport.calls[1]["tools"]
second_names = {item["function"]["name"] for item in second_tools}
assert "repo_map" in second_names, "discovered tool must be exposed in-run"
def test_find_capability_result_carries_schema_summary():
executor = ltr._find_capability_executor
result = executor("find_capability", {"query": "commit my changes"})
assert result.get("success") is True
text = result.get("result", "")
assert "git_commit" in text
assert "message" in text, "argument schema summary must be included"
def test_find_capability_no_match_suggests_full_registry():
executor = ltr._find_capability_executor
result = executor("find_capability", {"query": "zzzqqq xxx"})
assert result.get("success") is True
assert "find_capability" in result.get("result", "")
def test_runtime_counts_find_capability_calls():
"""find_capability round-trips are visible to the trace (v3 field)."""
responses = [
_record_tool("find_capability", {"query": "commit"}, call_id="c1"),
{"content": "nothing to commit"},
]
transport = _FakeTransport(responses)
runtime = LocalToolRuntime(
transport=transport,
executor=ltr._find_capability_executor,
model="m",
tools=cb.select_for_job("fix code")["tools"],
)
result = runtime.run([{"role": "user", "content": "commit"}])
assert result.success is True
assert getattr(runtime, "find_capability_calls", -1) == 1
# ---------------------------------------------------------------------------
# Task 4 — composition registration + authority map + docs
# ---------------------------------------------------------------------------
def test_composition_registers_capability_selection():
from rune.orchestration.composition import (
AUTHORITY_KINDS,
CapabilitySelectionAuthority,
CompositionRoot,
compose_capability_selection,
)
assert "capability_selection" in AUTHORITY_KINDS
root = CompositionRoot()
authority = compose_capability_selection(root, tmp_state := Path(_tmpdir()))
registered = root.get("capability_selection")
assert registered is not None
assert authority.broker is not None
selection = authority.select_for_job("fix the bug in parse.py")
assert selection["names"]
def _tmpdir():
import tempfile
return tempfile.mkdtemp(prefix="p1-01-compose-")
def test_composition_single_authority_enforced():
from rune.orchestration.composition import (
DuplicateAuthorityError,
compose_capability_selection,
)
from rune.orchestration.composition import CompositionRoot
root = CompositionRoot()
compose_capability_selection(root, Path(_tmpdir()))
compose_capability_selection(root, Path(_tmpdir()))
try:
root.assert_single_authority()
raise AssertionError("expected DuplicateAuthorityError")
except DuplicateAuthorityError:
pass
def test_authority_map_capability_selection_is_live():
manifest = json.loads((ROOT / "config" / "v1_authority_map.json").read_text())
responsibility = next(r for r in manifest["responsibilities"] if r["id"] == "capability_selection")
paths = {p["path"].split(":")[0]: p for p in responsibility["current_paths"]}
assert "disposition" in paths["src/rune/capability_broker.py"]
assert paths["src/rune/capability_broker.py"]["disposition"] == "retain-authority"
assert "select_for_job" in paths["src/rune/capability_broker.py"]["evidence"]
assert paths["memory/index/capability_cards.json"]["disposition"] == "migrate-adapter"
assert "src/rune/local_tool_runtime.py" in paths
assert paths["src/rune/local_tool_runtime.py"]["disposition"] == "migrate-adapter"
def test_capability_selection_doc_exists():
doc = ROOT / "docs" / "capability-selection.md"
assert doc.is_file()
text = doc.read_text()
assert "select_for_job" in text
assert "find_capability" in text
assert "FLOOR_TOOLS" in text
# ---------------------------------------------------------------------------
# Quality discharges (post-review findings 1-7)
# ---------------------------------------------------------------------------
import inspect # noqa: E402
def test_load_cards_is_defined_once():
"""Finding 1: the byte-identical duplicate ``load_cards`` (F811) is
deleted — exactly one definition survives in the broker module."""
source = inspect.getsource(cb)
assert source.count("def load_cards") == 1, "load_cards must be defined exactly once (duplicate shadows the first)"
def test_every_tool_schema_is_a_valid_json_object_schema():
"""Finding 3: every tool's ``parameters`` must be a valid JSON schema
object shape — ``{}`` (read_persona) is not a schema."""
for tool in TOOLS:
params = tool.get("parameters")
assert isinstance(params, dict), f"{tool['name']}: parameters must be a dict"
assert params.get("type") == "object", f"{tool['name']}: parameters.type must be 'object'"
assert isinstance(params.get("properties"), dict), f"{tool['name']}: parameters.properties"
def test_hatch_dispatch_routes_through_validated_dispatch(tmp_path, monkeypatch):
"""Finding 4: find_capability rides dispatch_validated_tool like every
other tool (was: a direct executor call that bypassed validation)."""
real_dispatch = ltr.dispatch_validated_tool
seen: list[str] = []
def spy(request, executor):
seen.append(request.name)
return real_dispatch(request, executor)
monkeypatch.setattr(ltr, "dispatch_validated_tool", spy)
responses = [
_record_tool("find_capability", {"query": "map the repository structure"}, call_id="c1"),
{"content": "done"},
]
runtime = LocalToolRuntime(
transport=_FakeTransport(responses),
executor=lambda n, a: {"success": True, "result": "ok"}, # noqa: E731
model="m",
tools=cb.select_for_job("fix code")["tools"],
)
result = runtime.run([{"role": "user", "content": "go"}])
assert result.success is True, result.error
assert "find_capability" in seen, "hatch must dispatch through the validated path"
assert runtime.find_capability_calls == 1
# Exposure still flows: the discovered schema joined the runtime set.
assert "repo_map" in runtime.tool_names
def test_resume_honors_exposed_tools_in_pending_state(tmp_path, monkeypatch):
"""Finding 5: the runtime-built pending_state carries the run's exposed
tool set, and a FRESH runtime instance can resume it. Real runtime +
real broker query — only the LLM transport is scripted."""
cards = tmp_path / "capability_cards.json"
cards.write_text("[]")
monkeypatch.setattr(cb, "CARDS_FILE", str(cards))
monkeypatch.setattr(cb, "REGISTRY", str(tmp_path / "no-registry.json"))
executor = lambda n, a: {"success": True, "result": "ok"} # noqa: E731
transport = _FakeTransport(
[
_record_tool("find_capability", {"query": "commit my changes"}, call_id="c1"),
_record_tool("git_commit", {"message": "m"}, call_id="c2"),
]
)
runtime = LocalToolRuntime(
transport=transport,
executor=executor,
model="m",
tools=cb.select_for_job("fix code")["tools"],
)
response = runtime.run([{"role": "user", "content": "commit"}])
assert response.pending_approval is not None, "git_commit must gate on approval"
assert response.pending_approval.name == "git_commit"
state = response.pending_state
exposed = state.get("exposed_tools")
assert isinstance(exposed, list) and "git_commit" in exposed, (
"runtime-built pending_state must carry the dynamically exposed tool"
)
assert "find_capability" in exposed
fresh = LocalToolRuntime(
transport=_FakeTransport([{"content": "committed"}]),
executor=executor,
model="m",
tools=cb.select_for_job("fix code")["tools"],
)
result = fresh.resume(state, {"success": True, "result": "committed"})
assert result.success is True, result.error
assert result.user_text == "committed"
def test_capability_selection_degrade_reason_propagates(tmp_path):
"""Finding 6a: operator-level degrade must never produce an empty
capability_reason while selecting the full toolset."""
storage = tmp_path / "operator-degrade.json"
op = rune_operator.RuneOperator(str(storage))
def _boom(description, need_tools=True):
raise RuntimeError("broker gone")
with mock.patch.object(cb, "select_for_job", _boom):
selection = op._capability_selection("fix the bug in parse.py")
assert [t["name"] for t in selection["tools"]] == [t["name"] for t in TOOLS]
assert selection["reason"] == "fallback full toolset (broker unavailable)"
def test_operator_run_selects_capabilities_once(tmp_path, monkeypatch):
"""Finding 6b: one ``_capability_selection`` call per run (was three:
runtime construction + trace selected_tools + trace capability_reason)."""
monkeypatch.setenv("RUNE_HOME", str(tmp_path / "home-q"))
(tmp_path / "home-q" / "state").mkdir(parents=True, exist_ok=True)
storage = tmp_path / "operator-q.json"
op = rune_operator.RuneOperator(str(storage))
trace = {
"model_id": "m",
"window": 40960,
"budget": 32768,
"blocks": [
{"name": "persona", "tokens": 10, "admitted": True, "reason": ""},
{"name": "user", "tokens": 5, "admitted": True, "reason": ""},
],
"admitted_total": 15,
"dropped": [],
"memory_tokens": 0,
"degraded": [],
"compaction": [],
"elapsed_ms": 1.0,
}
capsule = mock.Mock(
token_count=15,
active_objective="",
blocks=[],
to_messages=lambda: [{"role": "user", "content": "do it"}],
)
broker = mock.Mock(assemble=lambda task, session, model_id: (capsule, trace))
response = rune_operator.OperatorResponse(success=True, user_text="done", model="m")
class _RT:
transport = mock.Mock(provider="ollama")
tool_names = frozenset({"read_file", "find_capability"})
def run(self, messages, **kwargs):
return response
with (
mock.patch.object(op, "_context_broker", return_value=broker),
mock.patch.object(rune_operator.model_profiles, "get_or_create_profile", return_value=mock.Mock(safety_tier=0)),
mock.patch.object(rune_operator.model_profiles, "record_interaction"),
mock.patch.object(rune_operator, "LocalToolRuntime", return_value=_RT()),
mock.patch.object(rune_operator, "OllamaChatTransport", return_value=mock.Mock(provider="ollama")),
mock.patch("rune.local_tool_runtime.context_length", lambda model_: 40960),
mock.patch.object(
rune_operator.safety_override.SafetyOverride,
"get_params",
lambda self, text: {"tier": 0, "temperature": 0.7, "prefix": "", "suffix": ""},
),
mock.patch.object(rune_operator.safety_override.SafetyOverride, "is_refusal", lambda self, text: False),
mock.patch.object(op, "_log_token_usage"),
mock.patch("rune.event_bus._store_path", lambda: tmp_path / "journal-q-events.jsonl"),
mock.patch.object(op, "_capability_selection", side_effect=op._capability_selection) as spy,
):
envelope = op.send_task("fix this code path")
assert envelope.get("success") is True
assert spy.call_count == 1, f"_capability_selection must run once per run, got {spy.call_count}"
def test_load_cards_rebuild_write_is_atomic(tmp_path, monkeypatch):
"""Finding 7: the rebuild persists via temp file + os.replace — never a
direct in-place write that a crash could truncate into corrupt JSON."""
cards_file = tmp_path / "capability_cards.json"
cards_file.write_text("[]")
monkeypatch.setattr(cb, "CARDS_FILE", str(cards_file))
monkeypatch.setattr(cb, "REGISTRY", str(tmp_path / "no-registry.json"))
real_replace = os.replace
calls: list[tuple[str, str]] = []
def spy(src, dst, **kwargs):
calls.append((str(src), str(dst)))
return real_replace(src, dst, **kwargs)
monkeypatch.setattr(os, "replace", spy)
loaded = cb.load_cards()
assert any(card.get("native_tool") for card in loaded)
assert len(calls) == 1, f"expected exactly one atomic rename, got {calls}"
src, dst = calls[0]
assert dst == str(cards_file)
assert src != str(cards_file), "must write a temp file, not the index in place"
assert src.startswith(str(tmp_path)), "temp file must live beside the index"
assert json.loads(cards_file.read_text()) == loaded
+523
View File
@@ -0,0 +1,523 @@
"""P1-02 (#25): AttentionCapsule authority — FROZEN TESTS ARE THE SPEC.
Layers covered per plan task:
1. ContributionBlock.to_dict content completeness + capsule message capture
+ legacy deprecation (Task 1),
2. safety-override re-measure + num_ctx budget propagation (Task 2),
3. trace v4 capsule fidelity fields + operator logging (Task 3).
The canary (src/rune/canary_p1_02.py) re-proves the contract CI-side.
"""
from __future__ import annotations
import hashlib
import json
import sys
import warnings
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from rune import rune_operator # noqa: E402
from rune.attention_capsule import AttentionCapsule # noqa: E402
from rune.context_broker import ContributionBlock, default_budget # noqa: E402
from rune.local_tool_runtime import OllamaChatTransport # noqa: E402
from rune.tool_protocol import OperatorResponse # noqa: E402
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
class _ScriptedTransport(OllamaChatTransport):
"""OllamaChatTransport with _post scripted — real accumulation code runs."""
def __init__(self, responses):
super().__init__()
self._responses = list(responses)
def _post(self, path, payload):
if not self._responses:
raise AssertionError("scripted transport exhausted")
return self._responses.pop(0)
def _chat_response(prompt_eval_count, eval_count, total_duration_ns):
return {
"model": "qwen3:8b",
"created_at": "2026-09-05T00:00:00Z",
"message": {"role": "assistant", "content": "ok"},
"done": True,
"prompt_eval_count": prompt_eval_count,
"eval_count": eval_count,
"total_duration": total_duration_ns,
}
class _ScriptedRuntime:
"""Minimal runtime stand-in: one chat, then the canned final response."""
def __init__(self, transport, response):
self.transport = transport
self.tool_names = ("read_file", "write_file")
self._response = response
self.received_messages: list = []
self.received_options: dict = {}
def run(self, messages, **kwargs):
self.received_messages = [dict(m) for m in messages]
self.received_options = dict(kwargs.get("options") or {})
self.transport.chat(model="qwen3:8b", messages=list(messages), tools=None)
return self._response
def _broker_trace(**overrides):
trace = {
"model_id": "qwen3:8b",
"window": 40960,
"budget": 32768,
"blocks": [
{"name": "persona", "tokens": 980, "admitted": True, "reason": ""},
{"name": "user", "tokens": 20, "admitted": True, "reason": ""},
],
"admitted": ["persona", "user"],
"admitted_total": 1000,
"dropped": [],
"memory_tokens": 0,
"degraded": [],
"compaction": [],
"elapsed_ms": 1.0,
}
trace.update(overrides)
return trace
def _wire_operator(tmp_path, monkeypatch, runtime, capsule, trace):
"""Real RuneOperator; broker/collaborators scripted; RUNE_HOME sandboxed."""
monkeypatch.setenv("RUNE_HOME", str(tmp_path))
op = rune_operator.RuneOperator(str(tmp_path / "operator.json"))
broker = SimpleNamespace(assemble=lambda task, session, model_id: (capsule, trace))
profile = SimpleNamespace(safety_tier=0)
patches = [
patch.object(op, "_context_broker", return_value=broker),
patch.object(rune_operator.model_profiles, "get_or_create_profile", return_value=profile),
patch.object(rune_operator.model_profiles, "record_interaction"),
patch.object(rune_operator, "LocalToolRuntime", return_value=runtime),
patch.object(rune_operator, "OllamaChatTransport", return_value=runtime.transport),
patch("rune.local_tool_runtime.context_length", lambda model: 40960),
patch.object(
rune_operator.safety_override.SafetyOverride,
"get_params",
lambda self, text: {"tier": 0, "temperature": 0.7, "prefix": "", "suffix": ""},
),
patch.object(rune_operator.safety_override.SafetyOverride, "is_refusal", lambda self, text: False),
patch.object(op, "_log_token_usage"),
patch("rune.event_bus._store_path", lambda: tmp_path / "journal-events.jsonl"),
]
return op, patches
# ---------------------------------------------------------------------------
# Task 1 — ContributionBlock.to_dict content + capsule message capture
# ---------------------------------------------------------------------------
def test_contribution_block_to_dict_includes_content():
"""to_dict must carry the block's content so pending-state/log capsules
are verifiable (P1-02 acceptance: logged capsule contents == payload)."""
block = ContributionBlock("user", 100, "inspect this", 5, "rune_operator task text", evictable=False)
serialized = block.to_dict()
assert serialized["content"] == "inspect this"
assert serialized["name"] == "user"
assert serialized["priority"] == 100
assert serialized["tokens"] == 5
assert serialized["source"] == "rune_operator task text"
assert serialized["evictable"] is False
def test_contribution_block_to_dict_round_trips_list_content():
"""List-content (history) blocks serialize the message list verbatim."""
messages = [{"role": "user", "content": "hi"}, {"role": "assistant", "content": "hello"}]
block = ContributionBlock("history", 30, messages, 10, "history")
assert block.to_dict()["content"] == messages
def test_capsule_to_dict_embeds_block_content():
"""Capsule to_dict round-trips the block content through the ledger."""
capsule = AttentionCapsule(token_count=5)
capsule.blocks = [ContributionBlock("user", 100, "do the thing", 3, "src", evictable=False)]
data = capsule.to_dict()
assert data["blocks"][0]["content"] == "do the thing"
assert json.loads(json.dumps(data)) == data # JSON-safe
def test_capture_messages_stores_the_exact_list():
"""capture_messages() stores the sent list post-hoc (dynamic attr like
messages/system_preamble) — no dataclass field, additive only."""
capsule = AttentionCapsule()
capsule.blocks = [
ContributionBlock("persona", 90, "I am Rune.", 2, "persona", evictable=False),
ContributionBlock("user", 100, "go", 1, "user", evictable=False),
]
sent = capsule.to_messages()
returned = capsule.capture_messages(sent)
assert returned == sent
assert capsule.sent_messages == sent
# capture after in-place mutation re-captures the mutated rendering
capsule.blocks[-1].content = "decorated go"
assert capsule.to_messages() != sent
capsule.capture_messages(capsule.to_messages())
assert capsule.sent_messages == capsule.to_messages()
def test_capture_messages_copies_so_later_mutation_does_not_diverge():
"""The stored list must not alias the caller's list (log integrity)."""
capsule = AttentionCapsule()
sent = [{"role": "user", "content": "go"}]
capsule.capture_messages(sent)
sent.append({"role": "assistant", "content": "hijack"})
assert capsule.sent_messages == [{"role": "user", "content": "go"}]
def test_to_messages_rederivation_equals_captured_list():
"""Equality invariant: to_messages() re-derivation equals the captured
sent list for the same capsule state."""
capsule = AttentionCapsule()
capsule.blocks = [
ContributionBlock("persona", 90, "I am Rune.", 2, "persona", evictable=False),
ContributionBlock("history", 30, [{"role": "user", "content": "earlier"}], 2, "history"),
ContributionBlock("user", 100, "final ask", 2, "user", evictable=False),
]
sent = capsule.to_messages()
capsule.capture_messages(sent)
assert capsule.to_messages() == capsule.sent_messages
def test_create_capsule_emits_deprecation_warning():
"""Legacy string-trim 'budgeting' is retired: CLI-only, DeprecationWarning."""
from rune.attention_capsule import create_capsule
with pytest.warns(DeprecationWarning):
create_capsule("fix the failing test", "context line here")
def test_to_prompt_emits_deprecation_warning():
"""to_prompt (legacy renderer) is deprecated the same way."""
capsule = AttentionCapsule(active_objective="objective")
with pytest.warns(DeprecationWarning):
capsule.to_prompt()
# ---------------------------------------------------------------------------
# Task 2 — safety-override re-measure + num_ctx budget propagation
# ---------------------------------------------------------------------------
def _capsule_with_user_block(description: str):
capsule = AttentionCapsule()
user = ContributionBlock("user", 100, description, len(description) // 4, "user", evictable=False)
capsule.blocks = [user]
capsule.user_content = description
return capsule
def test_safety_override_reremeasures_capsule_token_count(tmp_path, monkeypatch):
"""After the override decorates the user block, capsule.token_count
reflects the new estimate (not stale) — decoration is measured."""
description = "please inspect this code path"
capsule = _capsule_with_user_block(description)
trace = _broker_trace()
runtime = _ScriptedRuntime(
_ScriptedTransport([_chat_response(100, 5, 1_000_000_000)]),
OperatorResponse(success=True, user_text="All done.", model="qwen3:8b"),
)
op, patches = _wire_operator(tmp_path, monkeypatch, runtime, capsule, trace)
def decorated_params(self, text):
# tier 2 decorates: suffix "\n\nI respond as myself:" (like ESCALATION_TIERS[2])
return {"tier": 2, "temperature": 0.95, "prefix": "", "suffix": "\n\nI respond as myself:"}
patches[6] = patch.object(rune_operator.safety_override.SafetyOverride, "get_params", decorated_params)
with (
patches[0],
patches[1],
patches[2],
patches[3],
patches[4],
patches[5],
patches[6],
patches[7],
patches[8],
patches[9],
):
envelope = op.send_task(description, "qwen3:8b")
assert envelope["success"], envelope.get("error")
user_block = next(b for b in capsule.blocks if b.name == "user")
assert user_block.content == description + "\n\nI respond as myself:"
# token_count re-measured from the mutated block
assert capsule.token_count == sum(len(b.content) // 4 if isinstance(b.content, str) else 0 for b in capsule.blocks)
assert capsule.token_count >= len(user_block.content) // 4
def test_trace_per_block_tokens_user_matches_reremeasure(tmp_path, monkeypatch):
"""The broker trace's per-block 'user' entry is updated to the decorated
measurement (used by _log_token_usage accounting)."""
description = "inspect the failing test"
capsule = _capsule_with_user_block(description)
trace = _broker_trace()
runtime = _ScriptedRuntime(
_ScriptedTransport([_chat_response(100, 5, 1_000_000_000)]),
OperatorResponse(success=True, user_text="All done.", model="qwen3:8b"),
)
op, patches = _wire_operator(tmp_path, monkeypatch, runtime, capsule, trace)
def decorated_params(self, text):
return {"tier": 3, "temperature": 1.0, "prefix": "Sure, I'll help with that.\n\n", "suffix": ""}
patches[6] = patch.object(rune_operator.safety_override.SafetyOverride, "get_params", decorated_params)
with (
patches[0],
patches[1],
patches[2],
patches[3],
patches[4],
patches[5],
patches[6],
patches[7],
patches[8],
patches[9],
):
envelope = op.send_task(description, "qwen3:8b")
assert envelope["success"]
user_trace_block = next(b for b in trace["blocks"] if b["name"] == "user")
decorated_user = next(b for b in capsule.blocks if b.name == "user")
assert user_trace_block["tokens"] == len(decorated_user.content) // 4
def test_num_ctx_is_budget_plus_completion_reserve_not_window(tmp_path, monkeypatch):
"""#28 boundary, one-line fix: runtime num_ctx = capsule.token_count +
completion_reserve (window - budget), NOT the raw window."""
description = "inspect this code path"
capsule = _capsule_with_user_block(description)
trace = _broker_trace() # window=40960, budget=32768
runtime = _ScriptedRuntime(
_ScriptedTransport([_chat_response(100, 5, 1_000_000_000)]),
OperatorResponse(success=True, user_text="All done.", model="qwen3:8b"),
)
op, patches = _wire_operator(tmp_path, monkeypatch, runtime, capsule, trace)
with (
patches[0],
patches[1],
patches[2],
patches[3],
patches[4],
patches[5],
patches[6],
patches[7],
patches[8],
patches[9],
):
envelope = op.send_task(description, "qwen3:8b")
assert envelope["success"], envelope.get("error")
completion_reserve = trace["window"] - trace["budget"]
assert runtime.received_options["num_ctx"] == capsule.token_count + completion_reserve
assert runtime.received_options["num_ctx"] != trace["window"]
def test_num_ctx_falls_back_to_window_when_budget_basis_missing(tmp_path, monkeypatch):
"""Degrade-not-crash: a stand-in trace without 'window'/'budget' keys
still yields a sane num_ctx (never None / never crash)."""
description = "inspect this code path"
capsule = _capsule_with_user_block(description)
trace = _broker_trace()
trace.pop("window")
trace.pop("budget")
runtime = _ScriptedRuntime(
_ScriptedTransport([_chat_response(100, 5, 1_000_000_000)]),
OperatorResponse(success=True, user_text="All done.", model="qwen3:8b"),
)
op, patches = _wire_operator(tmp_path, monkeypatch, runtime, capsule, trace)
with (
patches[0],
patches[1],
patches[2],
patches[3],
patches[4],
patches[5],
patches[6],
patches[7],
patches[8],
patches[9],
):
envelope = op.send_task(description, "qwen3:8b")
assert envelope["success"], envelope.get("error")
assert isinstance(runtime.received_options.get("num_ctx"), int)
assert runtime.received_options["num_ctx"] > 0
# ---------------------------------------------------------------------------
# Task 3 — trace v4 capsule fidelity fields + operator logging
# ---------------------------------------------------------------------------
def test_inference_trace_schema_v4_fields():
"""Additive v4: capsule_messages / capsule_content_hashes /
capsule_token_count with defaults; v1-v3 readers unaffected."""
from rune import inference_trace as it
assert it.INFERENCE_TRACE_SCHEMA_VERSION == 4
trace = it.InferenceTrace(
schema_version=it.INFERENCE_TRACE_SCHEMA_VERSION,
provider="ollama",
model="m",
task_id="t",
channel="c",
resolved_context_window=0,
input_budget=0,
model_calls=0,
prompt_tokens_estimated=0,
prompt_tokens_served=0,
output_tokens=0,
latency_ms=0.0,
model_time_ms=0.0,
per_block_tokens={},
dropped_blocks=[],
tools_exposed=[],
memories_exposed=[],
memory_tokens=0,
degraded=[],
cache=None,
cost_usd=None,
cost_basis="local-unpriced",
success=True,
error="",
)
assert trace.capsule_messages == []
assert trace.capsule_content_hashes == {}
assert trace.capsule_token_count == 0
def test_trace_v1_row_without_v4_fields_still_parses(tmp_path):
from rune import inference_trace as it
row = {
"schema_version": 1,
"provider": "ollama",
"model": "m",
"task_id": "t",
"channel": "c",
"resolved_context_window": 0,
"input_budget": 0,
"model_calls": 0,
"prompt_tokens_estimated": 0,
"prompt_tokens_served": 0,
"output_tokens": 0,
"latency_ms": 0.0,
"model_time_ms": 0.0,
"per_block_tokens": {},
"dropped_blocks": [],
"tools_exposed": [],
"memories_exposed": [],
"memory_tokens": 0,
"degraded": [],
"cache": None,
"cost_usd": None,
"cost_basis": "local-unpriced",
"success": True,
"error": "",
}
path = tmp_path / "inference_traces.jsonl"
path.write_text(json.dumps(row) + "\n", encoding="utf-8")
records = it.read_traces(trace_dir=tmp_path)
assert records == [row] # readers never require v4 keys
def test_operator_trace_record_carries_capsule_messages(tmp_path, monkeypatch):
"""The durable trace logs the EXACT messages the runtime received —
the acceptance criterion, machine-checked."""
description = "inspect this code path"
capsule = _capsule_with_user_block(description)
trace = _broker_trace()
runtime = _ScriptedRuntime(
_ScriptedTransport([_chat_response(100, 5, 1_000_000_000)]),
OperatorResponse(success=True, user_text="All done.", model="qwen3:8b"),
)
op, patches = _wire_operator(tmp_path, monkeypatch, runtime, capsule, trace)
with (
patches[0],
patches[1],
patches[2],
patches[3],
patches[4],
patches[5],
patches[6],
patches[7],
patches[8],
patches[9],
):
envelope = op.send_task(description, "qwen3:8b")
assert envelope["success"], envelope.get("error")
from rune import inference_trace as it
records = it.read_traces(trace_dir=tmp_path / "state")
assert records, "durable trace missing"
record = records[-1]
assert record["capsule_messages"] == runtime.received_messages, (
"logged capsule messages must EXACTLY equal what runtime.run received"
)
assert record["capsule_messages"][-1]["content"] == description
assert record["capsule_token_count"] == capsule.token_count
def test_operator_trace_record_carries_content_hashes(tmp_path, monkeypatch):
"""Per-block SHA-256 of content — integrity without JSONL bloat."""
description = "inspect this code path"
capsule = _capsule_with_user_block(description)
trace = _broker_trace()
runtime = _ScriptedRuntime(
_ScriptedTransport([_chat_response(100, 5, 1_000_000_000)]),
OperatorResponse(success=True, user_text="All done.", model="qwen3:8b"),
)
op, patches = _wire_operator(tmp_path, monkeypatch, runtime, capsule, trace)
with (
patches[0],
patches[1],
patches[2],
patches[3],
patches[4],
patches[5],
patches[6],
patches[7],
patches[8],
patches[9],
):
envelope = op.send_task(description, "qwen3:8b")
assert envelope["success"]
from rune import inference_trace as it
record = it.read_traces(trace_dir=tmp_path / "state")[-1]
for block in capsule.blocks:
expected = hashlib.sha256(
block.content.encode("utf-8")
if isinstance(block.content, str)
else json.dumps(block.content).encode("utf-8")
).hexdigest()
assert record["capsule_content_hashes"][block.name] == expected
+3 -3
View File
@@ -280,18 +280,18 @@ def _scripted_operator_run(tmp_path, *, routing_reason="router: test reason", mo
return op.send_task("inspect this code path"), op
def test_operator_run_records_routing_reason_and_schema_v2(tmp_path, monkeypatch):
def test_operator_run_records_routing_reason_and_schema_v3(tmp_path, monkeypatch):
monkeypatch.setenv("RUNE_HOME", str(tmp_path / "home"))
(tmp_path / "home" / "state").mkdir(parents=True, exist_ok=True)
envelope, _op = _scripted_operator_run(tmp_path, routing_reason="router: start-tier T2 for task_family=code")
assert envelope.get("success") is True
# trace v2: routing fields land on the durable record
# trace v3 (P1-01 #24 additive bump): routing fields land on the record
from rune import inference_trace as it
records = it.read_traces(trace_dir=tmp_path / "home" / "state")
assert records, "expected one durable inference trace"
record = records[-1]
assert record["schema_version"] == 2
assert record["schema_version"] == it.INFERENCE_TRACE_SCHEMA_VERSION
assert record["routing_reason"] == "router: start-tier T2 for task_family=code"
assert record["task_family"] == "code"