Skip to content

feat(roles): side-role system prompts load from roles/*.md over shared identity (#1729) - #1743

Merged
ozand merged 2 commits into
mainfrom
roles-1729
Sep 18, 2026
Merged

ozand merged 2 commits into
mainfrom
roles-1729

Conversation

@ozand

@ozand ozand commented Sep 18, 2026

Copy link
Copy Markdown
Owner

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>.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 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.

Role Before After Δ identity soul charter role body / budget
proposer 2,045 6,906 +4,861 210 yes yes 2,045 / 2,600
demand-proposer 1,689 6,550 +4,861 210 yes yes 1,689 / 2,200
goal-review 1,343 6,204 +4,861 210 yes yes 1,343 / 1,800
curator 911 2,725 +1,814 210 yes 911 / 1,600
narrator 503 2,317 +1,814 210 yes 503 / 800
strategist 486 703 +217 210 486 / 800
reflector 602 819 +217 210 602 / 900
skill-eval 105 322 +217 210 105 / 300
total 7,684 26,546 +18,862

The three tight-cap roles grow by exactly the short identity plus one separator (+217), which is the bound the revision asked for: strategist runs under _MAX_PAYLOAD 18,000, skill-eval under MAX_TASK_PROMPT_CHARS 4,000, and reflector under input_fit. The short identity form is the first paragraph of IDENTITY.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 and budget_chars in front matter.
  • nanobot/runtime/role_prompt.pybuild_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}; plus load_role_text, resolve_release_root, role_budget, system_chars, and ROLE_FLAGS as 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 at fa5b9926.

Call sites replaced

  • nanobot/runtime/llm_proposer.py_PROPOSER_SYSTEM_PROMPT and _DEMAND_PROPOSER_SYSTEM_PROMPT are now loaded, not authored; propose() prepends identity, soul and charter once via _role_name_for(role_body), so the system_prompt= argument keeps carrying only the role body and every existing caller and test keeps working.
  • nanobot/runtime/goal_review.py_GOAL_REVIEW_SYSTEM_PROMPT loaded from roles/goal-review.md.
  • nanobot/runtime/knowledge_curator.py, nanobot/runtime/reflector.py, nanobot/runtime/strategist.py, nanobot/runtime/skill_eval_harness.py — inline system = (...) replaced by build_role_system_prompt(...).
  • scripts/journal_story.py_NARRATOR_SYSTEM_PROMPT becomes _narrator_system_prompt().

Telemetry

nanobot/observability/llm_telemetry.py:record_llm_call gains system_prompt_chars: int | None = None, written on every row (None when the caller did not measure). All six recording call sites pass it, measured from the system message the caller built via system_chars(messages) — never from state/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_ROOT without roles/ is not a release tree for roles. Several existing fixtures set RELEASE_ROOT to a minimal tree holding only goals.md (for example tests/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 explicit release_root= argument always wins outright. Documented in the function and covered by two tests.

The proposer role files carry a {commit_surfaces} placeholder filled from MUTATION_POLICY at 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-time validate_rendered_surfaces check 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.sh builds the archive with git archive from the commit, so roles/ ships the same way IDENTITY.md and SOUL.md did in #1735.

Tests

tests/test_role_prompt.py                                          62 passed
tests/test_llm_proposer*.py test_fallback_lane test_mutation_policy
  test_goal_review test_proposer_model_split                      394 passed
tests/test_knowledge_curator*.py test_curator_*.py test_reflector*.py
  test_strategist*.py test_skill_eval_harness test_journal_story
  test_publication_intake test_side_role_cliffs                   278 passed, 2 skipped
tests/test_context_prompt_budget.py tests/test_llm_telemetry.py     11 passed
tests/test_llm_calls_report.py test_llm_prompt_recording
  test_cycle_id_join test_doctor test_bridge_executor_llm_error     72 passed

Every pre-existing test passes unchanged. One needed a decision rather than an edit: test_build_strategist_prompt_includes_triz_and_hadi exercised the RELEASE_ROOT-without-roles/ case above, and the resolution rule is what makes it pass.

Follow-ups, not done here

🤖 Generated with Claude Code

@ozand

ozand commented Sep 18, 2026

Copy link
Copy Markdown
Owner Author

Rebased onto 96a450a0 (#1744) and fixed the red check.

tests/test_trainer_no_direct_mutation::test_only_the_named_gate_writer_touches_lessons_or_skills_paths failed because role_prompt imported ContextBuilder for load_block / _trim_lines. That import pulls the memory store, the skills loader and the tool registry into the import closure of every trainer seed module, and the guard then sees nanobot.utils.helpers:sync_workspace_templates as a lessons/skills writer reachable from the reflector. The guard is right: the reachability was real, and widening it to read two markdown files is not a trade worth making.

Fix (39cb0b54): the two primitives move to nanobot/agent/block_loader.py — no state, no I/O beyond the file each call is given. ContextBuilder keeps load_block and _trim_lines as staticmethod aliases, so every existing caller and test is untouched, and role_prompt imports only the small module. 107 tests pass across the trainer guard, the role prompts, the ontology loader, the prompt fit and the system-prompt sections.

The full suite run on this branch hit one unrelated failure, tests/test_deploy_activation_exit_class::test_gate_transport_exit_in_journal_does_not_roll_back (a deploy-script test that touches none of these files); checking whether it is red on main as well and will report before merge.

ozand and others added 2 commits September 18, 2026 19:10
…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>
@ozand
ozand merged commit 824cac9 into main Sep 18, 2026
3 checks passed
@ozand
ozand deleted the roles-1729 branch September 18, 2026 16:20
ozand added a commit that referenced this pull request Sep 19, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Role prompts as files: proposer, curator, reflector, strategist, goal review, narrator, skill-eval load roles/*.md over shared identity and soul

1 participant