| eatmycode_version | 1.2.0 |
|---|
An agent is a name, a model, a provider, and a system prompt — the unit a session takes turns between. This module owns the agent record and the assembly of its system prompt from the pieces the session supplies: persona text, skills index, memory block, tool instructions, and the reasoning-visibility flag.
Almost every field is an Option, and the absence is load-bearing: None means
the session answers this, and Some — including Some("") — means the agent
did. Collapsing the two into an empty-string sentinel would make "this agent
deliberately sets an empty system prompt" inexpressible.
role and position are the open and closed halves of one pair: role is the
spec the caller wrote — a built-in name, a .md path, or prose — and position
is the chair it selected, written back by Session::add_agent. The distinction
between the two positions is not cosmetic: an orchestrator gets a different
prompt, a different loop, and the authority to address participants by name. See
role.md.
This module does not own inheritance timing, workspace confinement, or tool
narrowing: it supplies inherit and the tri-state lists, and
session.md decides when to apply them and against what.
done. cargo test -p kerness agent:: passes 14 tests and
.venv/bin/python -m pytest bindings/python/tests/test_agent.py -q passes 14;
the session-level consequences — defaults filling, provider/model pairing, role
seating, tool narrowing — pass in crates/kerness/tests/session_run.rs and
crates/kerness/tests/tools_e2e.rs.
| File | Role |
|---|---|
crates/kerness/src/agent.rs |
Agent, AgentDefaults, system prompt and message assembly, inherit |
bindings/python/src/types.rs:645 |
PyAgent, the pyclass callers construct; getters for every field and setters for all but position |
bindings/python/kerness/agent.py |
re-export shim |
One Rust crate module and one pyclass inside the binding's types.rs; the
Python file is a three-line shim. The root's
Coding Style and Code Design
rules apply. Local facts:
- Builder style, owned
self.with_model,with_role,with_provider(crates/kerness/src/agent.rs:103,:114,:123) take and returnSelf; every field is alsopub, so a literal works too. Observed, not enforced. - Hand-written
Debug(crates/kerness/src/agent.rs:325) because the provider is a trait object with no useful representation; it prints identity and the inheritable options and omits skills, tools, memory, and workspace. Cowin prompt decoration (crates/kerness/src/agent.rs:157): a prompt is copied once, at the end (:194), rather than once per placeholder or decoration; each addition promotes toOwned.- The pyclass constructor carries
#[allow(clippy::too_many_arguments)](bindings/python/src/types.rs:685) — twelve keyword parameters in#[pyo3(signature = ...)]order, every one optional butname.positionis a getter with no setter (:791);reasoning_effortcrosses as a validated string throughReasoningEffort::parse. - Tests. Unit tests use
alice()(crates/kerness/src/agent.rs:346),crate::testing::TempDir(:344) for persona files, and aStubProviderwhosechatisunreachable!because inheritance never calls a backend. The Python suite usestempfilefor persona files and sentence-style test names underTest<Behaviour>classes.
agent depends on error, persona, provider, pyfmt, and role, and on
nothing above them; it does not know sessions exist. Above it,
session.md calls add_agent (crates/kerness/src/session.rs:737)
to seat the chair, resolve_agents (:1527) to inherit and confine, and
resolve_agent_tools (:1460) to narrow; agent-runtime.md
reads effort() on every provider call
(crates/kerness/src/agent_runtime.rs:461).
Two prompt paths share one decorator. build_system_prompt (crates/kerness/src/agent.rs:141) picks the
base — the agent's own prompt or the session's default — and
decorate_system_prompt (:157) layers persona, reasoning note, language,
skills index, and placeholder substitution on top. The split exists because the
session decorates an orchestrator prompt it built itself; keeping the decoration
in one place is what stops participant and orchestrator prompts drifting apart.
Nonemeans the session answers;Some("")is an answer. No field collapses absence into an empty string. Test:a_session_that_pins_nothing_leaves_the_agent_pinning_nothing(crates/kerness/src/agent.rs:555) andtest_an_unnamed_role_is_none_rather_than_a_default_string(bindings/python/tests/test_agent.py:119).- Provider and model inherit as a pair.
inherit(crates/kerness/src/agent.rs:274) refuses an agent that setsproviderand leavesmodelunset, naming the agent, and refuses a model named nowhere, naming both places to write one. A model name is only meaningful on the backend it was written for; a session-level"gpt-5"silently inherited by an agent pointed at Anthropic is a wrong answer that surfaces as an opaque provider error.reasoning_effortdoes inherit independently — it is a portable enum, andProvider::effective_efforthandles a backend that refuses it (provider.md). Tests:an_agent_with_its_own_provider_must_bring_its_own_model(:573),a_model_named_nowhere_is_an_error_that_says_both_places_to_set_it(:597), and through a sessioncrates/kerness/tests/session_run.rs:1327,:1360. - Inheritance runs once, at run start, never at
add_agent.resolve_agents(crates/kerness/src/session.rs:1527) callsinheritso defaults set after registration still apply;add_agentsettles only the position, which has no session-level default to wait for. Test:a_session_default_fills_every_agent_that_named_nothing(crates/kerness/tests/session_run.rs:1277). AgentDefaultscarries nosystem_prompt. That option already falls back throughbuild_system_prompt'sdefault_promptargument; a second mechanism for the same fallback is the dead configuration the project rules out (crates/kerness/src/agent.rs:313). No test enforces the absence; the struct definition is the evidence.- Position comes from a declaration, never from prose or a constructor.
with_role(crates/kerness/src/agent.rs:114) stores the spec verbatim and does not setposition; onlySession::add_agentreads a role file's frontmatter into it, so an agent constructed and added nowhere is a participant. The Python getter has no setter (bindings/python/src/types.rs:791). Tests:a_named_role_is_kept_verbatim_until_the_session_reads_it(crates/kerness/src/agent.rs:609),the_two_positions_are_exclusive_and_participant_is_the_default(:481),test_position_is_read_only(bindings/python/tests/test_agent.py:136),a_role_seats_an_agent_by_declaration_and_never_by_prose(crates/kerness/tests/session_run.rs:1197). - A missing persona file fails; it is never pasted as text.
resolve_persona(crates/kerness/src/agent.rs:208) loads any.mdand propagatesNotFound, becausePersona: ./personas/typo.mdin a system prompt lets a run complete looking healthy while costing real provider calls. Test:a_missing_persona_file_fails_and_names_what_it_tried(:436). toolsnarrows and never widens. A name the gameplan did not permit is refused inresolve_agent_tools(crates/kerness/src/session.rs:1460) before the first provider call, named with the agent. The narrowing binds the dispatcher as well as the prompt — a tool the agent gave up is neither advertised nor callable — and is outranked only by a skill'srequires-tools:, a skill that agent chose to load;Shared::active_tools(:457) is the composition order. UnderToolDialect::Textevery permitted schema is written into the system prompt, so narrowing is also what keeps thirty descriptions off the prompt of an agent that calls two. Tests:crates/kerness/tests/tools_e2e.rs:677,:695,:713,:738.skillsunions;toolsintersects. Deliberately asymmetric: a skill is capability a session offers, a tool is capability a harness grants. Both are tri-state —Nonetakes the session's, a list selects,[]opts out (crates/kerness/src/agent.rs:61,:72).workspacecomposes by intersection, not override.inheritdoes not touch it;resolve_agentssettles it against the access manager (crates/kerness/src/session.rs:1540), which can refuse it — see access.md. Tests:bindings/python/tests/test_session.py:1177,:1194.- Message order is system first, then history verbatim.
build_messages(crates/kerness/src/agent.rs:218). Test:the_system_prompt_leads_and_history_follows_in_order(:461) and its Python twin (bindings/python/tests/test_agent.py:88).
- A new inheritable option is a field on
Agent, a field onAgentDefaults, and oneif self.x.is_none()arm ininherit; a new decoration is oneCow::Ownedstep indecorate_system_prompt. - A placeholder is one entry in the substitution table at
crates/kerness/src/agent.rs:185; the three are{bot_id},{bot_name},{model}.
crates/kerness/src/agent.rs:22—Agent— name, model, reasoning effort, persona, role and position, language, system prompt, provider, skills, tools, memory scope, workspace.new(name)at:84.crates/kerness/src/agent.rs:103—with_model(model)/:123with_provider(provider, model)— the two ways to answer for an agent; the second takes both because a model name and a backend are a pair.crates/kerness/src/agent.rs:114—with_role(role)— stores the spec verbatim and leavespositionalone.crates/kerness/src/agent.rs:130—model_name()/:136effort()— the resolved value or the default, never anOption;effort()is what every provider call reads.crates/kerness/src/agent.rs:141—build_system_prompt(default_prompt, show_reasoning, skills_prompt)— the base prompt plus every decoration;Resultbecause a persona file may be missing.crates/kerness/src/agent.rs:157—decorate_system_prompt(prompt, ...)— persona, reasoning note, language, skills index, placeholders, in that order; the session calls this on an orchestrator prompt it already built.crates/kerness/src/agent.rs:218—build_messages(history, ...)— the system message followed by history; exactly what goes to the provider.crates/kerness/src/agent.rs:240—resolve_role()— the role file's body, the prose itself, or the built-inparticipantrole when none was named.crates/kerness/src/agent.rs:274—inherit(defaults)— fills every unset option fromAgentDefaults(:313);Error::Sessionfor a provider without a model or a model named nowhere.bindings/python/src/types.rs:645—PyAgent— holds the crateAgentplus the caller's provider object so the attribute reads back as what was set;build_system_promptat:894forwards;__eq__at:927compares the configuration.
- Held by session.md, which owns the agent list, seats the chair
in
add_agent, inherits and confines inresolve_agents, narrows tools inresolve_agent_tools, and decides turn order. Contract: the agent is read afterinherit, so aNoneseen mid-run means the session declared nothing either. Tested end to end incrates/kerness/tests/session_run.rs. - Its prompt parts come from prompting.md,
persona.md, memory.md, and skills.md;
PromptAssemblersupplies them as callbacks and this module orders them. - Its
provideris a provider.md trait object, and itsreasoning_effortis that module's enum, sent per turn. - Its
positionis role.md's closed enum, read from a role file's frontmatter. - Driven one turn at a time by agent-runtime.md, which
borrows the agent and reads
effort()on every call. - Its
workspaceis settled by access.md'sconfine_agent.
cargo test -p kerness agent:: # pass = 14 passed, 0 failed
.venv/bin/python -m pytest bindings/python/tests/test_agent.py -q # pass = 14 passed
cargo test -p kerness --test session_run # pass = 24 passed, 0 failed
cargo test -p kerness --test tools_e2e # pass = 18 passed, 0 failedcrates/kerness/src/agent.rs:351—an_undecorated_agent_gets_the_prompt_it_was_givenand:372every_decoration_is_appended_to_the_base: decoration adds, never replaces.bindings/python/tests/test_agent.py:12and:21are the Python twins.crates/kerness/src/agent.rs:531—an_unset_option_takes_the_sessions_and_a_set_one_keeps_its_own— the inheritance table, option by option.bindings/python/tests/test_agent.py:88—test_the_system_prompt_leads_and_history_follows_in_order— whatbuild_messagesguarantees.bindings/python/tests/test_agent.py:105—test_an_agent_on_its_own_is_always_a_participant— and:136test_position_is_read_only: the chair is something a session grants, not something a constructor claims.bindings/python/tests/test_agent.py:127—test_any_string_is_a_role_because_prose_is_one— there is nothing to reject in a role, which is whypositionis the closed half.bindings/python/tests/test_agent.py:145—test_the_level_is_unset_until_named_and_round_trips_as_its_name— and:158test_an_unknown_level_is_rejected:reasoning_effortcrosses the boundary as a validated string, the waypositiondoes.bindings/python/tests/test_session.py:1093—TestSessionDefaults: a session model filling the agents that named none (:1096), an agent on a second provider refused for naming no model (:1125), and a model named nowhere naming both places to write one (:1141). The Rust counterparts arecrates/kerness/tests/session_run.rs:1277,:1327,:1360.bindings/python/tests/test_session.py:815—TestPerAgentTools— andcrates/kerness/tests/tools_e2e.rs:677,:695,:713,:738: whattoolsnarrows, and that it cannot widen. Tested through a session because narrowing is resolved there, not on the record.crates/kerness/tests/session_run.rs:1006—an_agent_provider_overrides_the_session_one— a per-agent provider is the one that is called.- Gap:
AgentDefaultshaving nosystem_promptfield is asserted by no test; the invariant rests on the struct definition.
- Adding an inheritable option →
Agent(crates/kerness/src/agent.rs:22),AgentDefaults(:313),inherit(:274), the hand-writtenDebug(:325), thePyAgentconstructor signature and its getter/setter pair (bindings/python/src/types.rs:686), andPyAgent::__eq__(:927); extendan_unset_option_takes_the_sessions_and_a_set_one_keeps_its_own(crates/kerness/src/agent.rs:531). The session'sAgentDefaultsconstruction in session.md must supply the new default. - Changing prompt decoration order or wording →
decorate_system_prompt(crates/kerness/src/agent.rs:157); dependent modules prompting.md (which measures the result as compaction overhead) and role.md (the orchestrator prompt is decorated by the same function); testsevery_decoration_is_appended_to_the_base(:372) andshow_reasoning_says_yes_no_or_nothing_at_all(:389). - Changing the provider/model pairing rule →
inherit(crates/kerness/src/agent.rs:274) and the four session-level tests named above; the error text names both places to write a model, anda_model_named_nowhere_says_where_to_write_one(crates/kerness/tests/session_run.rs:1360) asserts it. - Changing what
toolsorskillsmeans → the field docs (crates/kerness/src/agent.rs:61,:72),resolve_agent_tools(crates/kerness/src/session.rs:1460),Shared::active_tools(:457), and the fourtools_e2e.rscases; the narrowing must still bind dispatch, not only the prompt. - Safe extension points: a new placeholder in the substitution table; a new
decoration step; a new builder method mirroring
with_model. - Forbidden coupling:
agent.rsmust not importsession,access, ortoolkit; confinement and narrowing are the session's to apply.with_rolemust never setposition. - Compatibility checks: the Python constructor's keyword order at
bindings/python/src/types.rs:686is public;positionreads as a string ("participant"/"orchestrator");reasoning_effortround-trips as its lowercase name (bindings/python/tests/test_agent.py:145).
- Assert
AgentDefaultshas exactly the four inheritable fields, so an addedsystem_promptfallback fails a test rather than a review; success: a unit test inagent.rsthat constructsAgentDefaultswith a struct literal naming every field. - Validate a model name against the provider at
inherittime where the backend can answer; success: a session with a misspelled model fails before the first turn in a newsession_run.rscase. The first call is the only check (see Open Gaps).
- An agent's model is a plain string handed to its provider; there is no
validation that the provider knows the model. What
inheritrefuses is a model silently crossing a provider boundary, which is the failure the framework can see; whether a given backend has heard of a given name is still answered by the first call. - Per-agent memory scopes are supported, but the session's shared memory is the common case; two agents pointed at one scope both see each other's writes (see memory.md).