feat(roles): side-role system prompts load from roles/*.md over shared identity (#1729) - #1743
Conversation
|
Rebased onto
Fix ( The full suite run on this branch hit one unrelated failure, |
…d identity (#1729) ADR-022 rule 2: code assembles, code does not author. The eight non-executor callers carried their system prompt as a Python string literal, so none of them shared the agent's identity or soul, and none of the texts could be read or reviewed by the operator without a harness PR. Each literal moves verbatim to roles/<role>.md at the release root, and nanobot/runtime/role_prompt.py assembles IDENTITY.md (short form) -> SOUL.md -> goals.md -> roles/<role>.md reusing ContextBuilder.load_block's [missing: X] marker, truncation notice and per-block accounting, so there is one convention for an absent or oversized block rather than two. Per-role flags follow the issue's 2026-09-18 revision: proposer, demand-proposer and goal-review get identity + soul + charter; curator and narrator get identity + soul; strategist, reflector and skill-eval get the short identity only, because they run under tight input caps and their growth must stay bounded by the first paragraph of IDENTITY.md (+217 chars each). The proposer role files carry a {commit_surfaces} placeholder filled from MUTATION_POLICY at load time, so those prompts cannot drift from the policy the way a copied literal could (the drift #1731 already fixed once). llm_calls rows gain system_prompt_chars, measured from the system message the caller built -- never from state/llm_calls/prompts, which caps well below a full prompt. Meaning is preserved, not reworded: tests/fixtures/role_literals_pre_1729.json holds the eight literals as they stood at fa5b992, and a parity test compares every role file against it after whitespace normalisation. Rewording a role is a later change with its own replay (ADR-011 rule 3). A RELEASE_ROOT that carries no roles/ directory (the shape several existing fixtures build, e.g. a tree holding only goals.md) is not a release tree for roles: resolution falls through to the package tree rather than reporting eight missing files and looking like a broken deploy. The narrator keeps the ADR-016 rule 1 barrier by construction: the assembler reads release files only and has no reader for any channel figure; a test asserts both the prompt and the module. Refs #1729, ADR-022, #1721, #1725.
…o not widen the trainer closure role_prompt imported ContextBuilder for load_block/_trim_lines, which pulled the memory store, the skills loader and the tool registry into every trainer seed module's import closure. tests/test_trainer_no_direct_mutation then read nanobot.utils.helpers:sync_workspace_templates as a lessons/skills writer reachable from the reflector, and failed closed -- correctly: the guard is about reachability, and the reachability was real. The two primitives move to nanobot/agent/block_loader.py (no state, no I/O beyond the file each call is given). ContextBuilder keeps both names as staticmethod aliases so every existing caller and test is untouched. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…PERATING.md for headroom (#1784, #1783) (#1798) ## #1784 Measured on host eeepc: 797 of 797 executor rows in `state/llm_calls` carried `system_prompt_chars: null`, while the proposer, reflector, curator and strategist all carried real numbers. Those four pass the field at their own call sites; the executor reaches telemetry only through `BaseProvider._record`, which never passed it. So the one role whose prompt budget was just raised from 24,000 to 35,000 (#1753) is the one role not measured. The real figure exists in the `phase: "system_prompt"` ledger row (21,477 at measurement time), so the number was reachable in one artifact and null in another for the same cycle. `system_chars` moves from `nanobot/runtime/role_prompt` to `nanobot/observability/llm_telemetry`, beside the field it feeds, and `role_prompt` re-exports it so its four existing callers are untouched. The move is the point: `providers/base` importing a runtime module would invert the dependency direction -- the same import closure that turned `test_trainer_no_direct_mutation` red in #1743. Additive and unread: no dashboard panel, scorecard metric or guard consumes `system_prompt_chars`, so this cannot change behaviour anywhere. ## #1783 `OPERATING.md` was 4,921 of its 5,000-char cap -- 79 spare -- and #1770 must move two rules out of `SOUL.md` into it (+153 chars), which would have put it 74 over. Three levels could have absorbed that: the block cap, the sum of caps, or the prompt ceiling. Measured before choosing: only `AGENTS.md` has ever been truncated in the live ledger (53 times, already resolved by #1752); the assembled prompt is 20,837 against a 35,000 ceiling; and the per-block caps are barely load-bearing. Raising one would buy nothing and would spread a constant across the tests that pin it. So: raise nothing, compress. `## Iteration budget` loses 94 chars of restatement while keeping all four of its rules -- where the number is stated, pace early, verify on a candidate, keep reserve for the commit and the final response. `OPERATING.md` is now 4,827 chars with 173 spare, enough for #1770 with margin. One trap found by the ontology harness: the first compression paraphrased "tool iterations" into "tool-iteration", and ADR-022 rule 4 fingerprints this block on that literal phrase -- so the rule momentarily had no owner at all. The phrase is restored and now pinned in `test_operating_md.py` as well, at the place a future edit will happen rather than only in the harness. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Closes #1729. ADR-022 rule 2: code assembles, code does not author.
The eight non-executor callers carried their system prompt as a Python string literal. None of them shared the agent's identity or soul, and none of the texts could be read or reviewed by the operator without a harness PR. Each literal now lives in
roles/<role>.mdat the release root, andnanobot/runtime/role_prompt.pyassemblesreusing
ContextBuilder.load_block's[missing: X]marker, truncation notice and per-block accounting from #1725 — one convention for an absent or oversized block, not a second one.Per-role sizes, flags and budgets
Flags follow the issue's 2026-09-18 revision. "Before" is the literal at
fa5b9926; "after" is the assembled system message measured from the built string.proposerdemand-proposergoal-reviewcuratornarratorstrategistreflectorskill-evalThe three tight-cap roles grow by exactly the short identity plus one separator (+217), which is the bound the revision asked for:
strategistruns under_MAX_PAYLOAD18,000,skill-evalunderMAX_TASK_PROMPT_CHARS4,000, andreflectorunderinput_fit. The short identity form is the first paragraph ofIDENTITY.md, 210 chars against the 300-char cap. A test asserts the bound per role rather than trusting the table.What changed
New files
roles/proposer.md,roles/demand-proposer.md,roles/goal-review.md,roles/curator.md,roles/narrator.md,roles/strategist.md,roles/reflector.md,roles/skill-eval.md— each the literal verbatim under# Role: <name>, with the output-format contract intact andbudget_charsin front matter.nanobot/runtime/role_prompt.py—build_role_system_prompt(role, *, with_charter, with_soul, identity_short, release_root, role_text, substitutions)returning(text, telemetry)where telemetry is{role, blocks: {name: chars}, missing: [...], truncated: [...], chars, with_charter, with_soul}; plusload_role_text,resolve_release_root,role_budget,system_chars, andROLE_FLAGSas the single place the per-role expectation lives.tests/test_role_prompt.py— 62 tests.tests/fixtures/role_literals_pre_1729.json— the eight literals as they stood atfa5b9926.Call sites replaced
nanobot/runtime/llm_proposer.py—_PROPOSER_SYSTEM_PROMPTand_DEMAND_PROPOSER_SYSTEM_PROMPTare now loaded, not authored;propose()prepends identity, soul and charter once via_role_name_for(role_body), so thesystem_prompt=argument keeps carrying only the role body and every existing caller and test keeps working.nanobot/runtime/goal_review.py—_GOAL_REVIEW_SYSTEM_PROMPTloaded fromroles/goal-review.md.nanobot/runtime/knowledge_curator.py,nanobot/runtime/reflector.py,nanobot/runtime/strategist.py,nanobot/runtime/skill_eval_harness.py— inlinesystem = (...)replaced bybuild_role_system_prompt(...).scripts/journal_story.py—_NARRATOR_SYSTEM_PROMPTbecomes_narrator_system_prompt().Telemetry
nanobot/observability/llm_telemetry.py:record_llm_callgainssystem_prompt_chars: int | None = None, written on every row (Nonewhen the caller did not measure). All six recording call sites pass it, measured from the system message the caller built viasystem_chars(messages)— never fromstate/llm_calls/prompts, which caps well below a full prompt. A test drives a measured row and an unmeasured one and asserts[703, None].Two decisions worth reviewing
Meaning preserved, not reworded. The parity test compares every role file against the fixture after whitespace normalisation; the files are hard-wrapped for a human reader, and only the words are the contract. Rewording a role is a later PR with its own replay (ADR-011 rule 3), and it has to update the fixture deliberately.
A
RELEASE_ROOTwithoutroles/is not a release tree for roles. Several existing fixtures setRELEASE_ROOTto a minimal tree holding onlygoals.md(for exampletests/test_strategist.py:34). Reading roles out of such a tree reports eight missing files and reads like a broken deploy, so resolution falls through to the package tree in that case; an explicitrelease_root=argument always wins outright. Documented in the function and covered by two tests.The proposer role files carry a
{commit_surfaces}placeholder filled fromMUTATION_POLICYat load time, so those two prompts cannot drift from the policy the way a copied literal could — the drift #1731 already fixed once. The import-timevalidate_rendered_surfacescheck now validates the prompt itself as well as the marker, and skips when the role file is absent and the body is a marker.The narrator keeps the ADR-016 rule 1 barrier by construction: the assembler reads the release files and nothing else, so it has no reader for any channel figure. Tests assert it on both the assembled prompt and the module source.
Packaging needs no change:
deploy_release.shbuilds the archive withgit archivefrom the commit, soroles/ships the same wayIDENTITY.mdandSOUL.mddid in #1735.Tests
Every pre-existing test passes unchanged. One needed a decision rather than an edit:
test_build_strategist_prompt_includes_triz_and_hadiexercised theRELEASE_ROOT-without-roles/case above, and the resolution rule is what makes it pass.Follow-ups, not done here
tests/test_prompt_ontology.py; two fingerprints belong in its table and are deliberately not added here:You are the eeebotandReturn ONLY.tests/test_role_prompt.py::TestNoLiteralsLeftcovers the eight role openings in the meantime.nanobot/agent/memory.py(96 chars, "You are a memory consolidation agent") andnanobot/heartbeat/service.py(73 chars). Each is one sentence telling a model what shape to return for one mechanical call — no persona, no procedure, no values. A test asserts they stay under 120 chars, so one growing into a role fails loudly. Their final disposition belongs with Prompt ontology tests: each rule once, AGENTS.md scope, surface parity, no guidance without capability #1726's fingerprint table.nanobot/utils/evaluator.py:38(441 chars, the notification gate) is the one real candidate to becomeroles/notification-gate.md; it is not one of the eight this issue names.build_task(Add OPERATING.md: cycle rules in one release-owned file, build_task and AGENTS.md stop restating them #1723 part b) is untouched; another agent owns it.🤖 Generated with Claude Code