| eatmycode_version | 1.2.0 |
|---|
The Rust/Python boundary. kerness._core is a PyO3 extension module in which
nothing decides anything: every class wraps a type in the kerness crate and
every function forwards. What lives here is the translation — JSON values across
the boundary, framework errors as exception instances and back, and Python
callables seen as the traits the framework calls.
Above it sits bindings/python/kerness/, the installed package: one shim per
subsystem that re-exports from _core, the handful of declarations that cannot
be made from Rust — the three subclassable base classes, the exception
hierarchy, ToolDialect, AccessPolicy — and the bundled assets. No feature is
implemented here; the root's
System Design states the rule and lists the
four seams that keep it true when a feature needs the interpreter.
This module owns the wheel's manifest and the boundary. It does not own any
behaviour a Python test observes: scheduling, approvals, recovery, budgets,
access, and prompt assembly stay in the crate, and each subsystem doc names its
own pyclass. The owned run and contextual tools are exposed through
Session.start, SessionRun, and ToolContext.
done. The blocking run, the owned-run and the contextual-tool APIs forward
to the Rust engine. .venv/bin/python -m pytest bindings/python/tests -q passes 502 tests
against the extension maturin develop installs;
.venv/bin/python -m kerness.selfcheck reports OK: all core checks passed;
.venv/bin/ruff check bindings/python reports All checks passed!; and
bindings/python/examples/host_control.py runs offline and exits 0.
| File | Role |
|---|---|
bindings/python/src/lib.rs |
the module, bootstrap, and the class registry |
bindings/python/src/convert.rs |
JSON values and rendered chat messages ↔ Python objects |
bindings/python/src/errors.rs |
the exception map, both directions, and the Raise/Catch traits |
bindings/python/src/types.rs |
21 pyclasses: tools, messages, agents, roles, harness specs; the dialect registry |
bindings/python/src/funcs.rs |
free functions and the framework constants |
bindings/python/src/runtime.rs |
Conversation, ToolDispatcher, PromptAssembler, AgentRunner, LoopState, OrchestratorLoop; the parked-exception helpers |
bindings/python/src/run.rs |
SessionRun, RunControl, ToolContext, and contextual/preflight/event callback translation |
bindings/python/src/session.rs |
Session, SessionResult; the keyword constructor, PyFilter, PySource, the consumed-session slot |
bindings/python/src/{provider,access,skill,channel,memory}.rs |
one boundary concern each; documented by their owning subsystem |
bindings/python/kerness/__init__.py |
bootstrap and the public surface |
bindings/python/kerness/<subsystem>.py |
re-export shims, one per crate module |
bindings/python/kerness/{provider,channel,memory}.py |
the three subclassable ABCs |
bindings/python/kerness/{exceptions,_enums,access}.py |
the exception hierarchy, ToolDialect, the AccessPolicy dataclass |
bindings/python/pyproject.toml |
the wheel's manifest; a Python build starts here |
bindings/python/Cargo.toml |
the kerness-py cdylib crate, a workspace member, publish = false |
A Rust cdylib on pyo3 0.23 with extension-module and abi3-py310
(bindings/python/Cargo.toml), plus a Python 3.10+ package linted by ruff
with select = ["E4", "E7", "E9", "F"] (bindings/python/pyproject.toml). The
root's Coding Style and Code Design
rules apply. Local facts:
#[allow(clippy::too_many_arguments)]is a binding idiom. Thirteen of the workspace's fourteen#[allow]attributes on items sit in the binding: twelve pyo3 keyword constructors and methods —bindings/python/src/session.rs:60,:349,:483,:632;bindings/python/src/types.rs:685,:1661;bindings/python/src/runtime.rs:417,:669;bindings/python/src/provider.rs:174,:221,:276,:323— each with its Python signature spelled in the#[pyo3(signature = (...))]directly above, and the privateagenthelper thatadd_agentbuilds its record with (bindings/python/src/session.rs:282). Enforced by clippy-D warnings.- pyclass attribute shapes. Every class is
#[pyclass(name = "...", module = "kerness._core")]; value types addfrozenandget_all; the four channels and three bundled stores addfrozen, subclass(bindings/python/src/channel.rs:176,bindings/python/src/memory.rs:187);PyToolContextandPyRunControlarefrozen(bindings/python/src/run.rs:105,:172);PySessionRunandPySessionare not, so Python's mutable borrow refuses a reentrantstep(). Rust structs arePy<Name>. Observed. - Error crossing is two traits, nothing hand-rolled.
RaiseandCatch(bindings/python/src/errors.rs:146,:157) are used at every pyclass return and every call into Python;to_py/from_py(:55,:92) are the map. - GIL release.
step(bindings/python/src/run.rs:209), contextualrun_command(:166), and the unpatchedhttp_post_json(bindings/python/src/provider.rs:768) run underallow_threads; callbacks reacquire the GIL withPython::with_gil. - Python declarations are minimal and enforced.
E402is ignored only inkerness/__init__.py, becausebootstrapmust run before any shim imports from_core. Every public module carries__all__and every exported name resolves (bindings/python/tests/test_packaging.py:74,:83); every shim is in the self-check's core list (bindings/python/tests/test_selfcheck.py:18). No type checker is configured;_coreships no.pyi. - Tests. pytest with
Test<Behaviour>classes andtest_<sentence>functions; providers areconftest.py'sMockProviderfamily, built withretries=0, backoff_sec=0(bindings/python/tests/conftest.py:9); built-in backends are reached by@patch("kerness.provider.http_post_json")(bindings/python/tests/test_provider.py:95).
kerness-py depends on kerness, pyo3 and serde_json only; the crate has
zero pyo3 references, and the Python package reaches the crate only through
kerness._core (provider.py and toolschema.py also import the package's
own _enums and exceptions). Inside the extension, convert.rs and errors.rs are leaves; types.rs
holds the value pyclasses every other module passes around; session.rs,
run.rs, and runtime.rs compose them. bindings/python/src/lib.rs:52 is the #[pymodule]: VERSION, four
pyfunctions (:54), the explicit add_class list (:62) and
funcs::register (:107) are the extension's whole surface.
import kerness runs bindings/python/kerness/__init__.py:12, which calls
bootstrap(exceptions, dialect, assets_root) (bindings/python/src/lib.rs:36).
The extension cannot declare three things itself and they are handed down: the
exception classes (structured constructors a create_exception! cannot
express), the ToolDialect enum (callers compare members with is, so it must
be a real enum.Enum, bindings/python/kerness/_enums.py:14), and the assets
root (only the package knows where pip put it). bootstrap then installs all
four seams — transport, console writer, logger, console prompt — which is why
an import is enough and no caller wires anything. __version__ is
env!("CARGO_PKG_VERSION") (bindings/python/src/funcs.rs:677), re-exported at
bindings/python/kerness/__init__.py:14, so the number a caller reads is the one the binary was built
at.
Declare the base class in Python, keep the logic in Rust. Provider,
Channel, and MemoryStore are subclassed by user code, so they are Python
classes (bindings/python/kerness/provider.py:99, channel.py:21,
memory.py:31). Provider holds a _core handle and its methods forward
self back down, so a subclass override wins by ordinary Python method
resolution rather than by anything the binding does. The other two have no
logic to forward — the four bundled channels and the three bundled stores are
crate types registered against their ABC at channel.py:50 and memory.py:137,
so isinstance holds without inheritance. Binding takes the exact type only
(native_channel, bindings/python/src/channel.rs:150; bind_memory_store,
bindings/python/src/memory.rs:165): a subclass overriding send or read is a caller's object
that happens to inherit, and the shortcut past it would call the base the
subclass exists to wrap. Test:
test_a_subclass_that_wraps_send_is_not_bypassed
(bindings/python/tests/test_channel.py:28).
Own the pieces, build the borrowing value transiently. PromptAssembler<'a>
and AgentRunner<'a> borrow their inputs, which no #[pyclass] can express.
The Python-facing class stores the pieces and constructs the Rust value inside
each call (bindings/python/src/runtime.rs:266 and :474); the cost is one
construction per turn.
Park the exception. A framework callback type cannot carry a PyErr
(Fn(&Agent) -> String has nowhere to put one), so a raising Python callable is
parked and re-raised at the pyclass boundary rather than read as an empty
answer: park/unpark in bindings/python/src/runtime.rs:47, :57, and
PyChannel::parked (bindings/python/src/channel.rs:71), drained by Session.run, Session.start,
and SessionRun.step. Tool, context-source, preflight, and event-sink callbacks
instead use Catch, and the Rust runtime retains the converted error in its
tool result or typed terminal outcome. See channel.md and
errors.md.
Python callbacks implement Rust traits, each bound where it is registered. A
tool handler is any callable and is bound by Session.add_tool as PyHandler
(bindings/python/src/session.rs:159); a context source by add_context as
PySource (:235, called once per agent at the top of the run); a memory
filter by bind_memory_filter (:209), which refuses a non-callable at
construction rather than at the first note an agent writes.
PyFilter (bindings/python/src/session.rs:179) is the one callback that fails closed rather than parking.
A filter that raises drops the note and logs a warning (:190): the filter is a
trust boundary (memory.md), and a boundary that lets a note through
because the check crashed is not one. Test:
test_the_filter_runs_before_the_store_sees_a_note
(bindings/python/tests/test_session.py:2586).
An agent's tools (bindings/python/src/types.rs:848) and the access policy's
allowed_hosts (bindings/python/src/access.rs:203) are the mirror direction:
plain data, extracted at the boundary, validated by the crate. The Python suite
proves such a value crossed with one case and leaves its semantics to the crate
tests (testing.md).
Session.start(*, mode="automatic", approvals="external", budget=None, pricing=None, event_sink=None, result_validation="strict", binding_version="")
(bindings/python/src/session.rs:632) transfers the Rust session into a
SessionRun. The Python session retains an empty slot afterward; further
configuration or execution through that object raises SessionError naming the
existing run handle (prepared, :263). Invalid Python argument conversion is
rejected before transfer; a Rust preparation failure consumes the configuration,
matching the Rust API. Session.run() (:678) blocks until the run ends and
leaves the session available afterward.
SessionRun.step(input=None) (bindings/python/src/run.rs:189) deserialises a
JSON-compatible dictionary into Rust RunInput; None is continue. Its
returned dictionary is the serialised Rust StepOutcome. Inputs use a kind
field (select_agent, user_message, approve, reconcile, finish);
outcomes use status (progress, waiting, finished). The engine validates
and executes every transition. outcome(), usage(), and drain_events() use
the same JSON conversion; checkpoint() (:222) forwards; control() (:216)
hands out an independent cancellation handle that does not borrow the run, so
cancellation is available to another Python thread without a framework thread.
An optional event_sink(event) receives each Rust event as a dictionary
(PyEventSink, bindings/python/src/run.rs:88). Events observe the run; decisions enter through step or
the control handle. The Rust runtime stops on a sink error and does not replay
a completed tool to redeliver an event. budget, pricing, and
binding_version are forwarded to Rust; no scheduling, pricing, access,
recovery, or budget policy lives in Python.
Session.add_tool_spec(spec) preserves ToolSpec.takes_actor.
add_contextual_tool(name, description, parameters, handler, *, preflight=None) (bindings/python/src/session.rs:556) sits beside add_tool: a plain
handler receives (arguments), and a contextual handler receives
(arguments, context) (PyContextHandler::call, bindings/python/src/run.rs:70).
An optional preflight receives (arguments, identity_dict) and returns None
or the Rust action shape — {"kind": "confirm", "description": "..."} or
{"kind": "command", "command": "...", "cwd": null} (:46). It must be free
of side effects, and an arbitrary synchronous callback cannot suspend mid-stack.
ToolContext (bindings/python/src/run.rs:105) owns a clone of the Rust capability handle, with
read-only actor, run_id, numeric turn_id, and call_id properties. The
call ID is the engine's correlation ID, not the provider's native tool-call ID.
Its file, directory, command, and memory methods use that actor's policy
without borrowing PySession. Capabilities expire when the invocation ends,
even if Python retains the object; identity remains readable. Test:
test_inputs_results_events_and_handles_cross_the_boundary
(bindings/python/tests/test_session.py:403).
- Every
_coreclass is registered inbindings/python/src/lib.rs:52's list; a class missing there is invisible from Python. Enforced bytest_every_exported_name_resolves(bindings/python/tests/test_packaging.py:83). - Dictionary key order survives a round trip:
serde_jsonis built withpreserve_order, andvalue_from_py/value_to_py(bindings/python/src/convert.rs:59,:15) walk in order. Observed; no dedicated test. - The framework's constants are named once.
funcs::register(bindings/python/src/funcs.rs:672) re-exports every constant from the crate values;test_the_constants_carry_the_frameworks_values(bindings/python/tests/test_provider.py:841) and the root's constants table hold them. - The wheel carries the package and distribution metadata.
pyproject.toml'smodule-nameiskerness._coreandpython-source = ".", sotests/andexamples/beside the package do not ship;excludedrops stray__pycache__so a local build cannot embed interpreter-specific bytecode in anabi3wheel. TheLICENSEandREADME.mdsymlinks inbindings/python/are load-bearing becausereadmeandlicense-filesreject a..path (testing.md). kerness.__version__equals the workspace version;test_the_package_reports_the_workspace_version(bindings/python/tests/test_packaging.py:35) detects a version mismatch. It cannot detect source changes within the same version.
bindings/python/src/lib.rs:36—bootstrap(exceptions, dialect, assets_root)— called once frombindings/python/kerness/__init__.py:12; registers the exception classes and the dialect enum, sets the assets root, installs the four seams.bindings/python/src/lib.rs:52—_core(module)— the#[pymodule]:VERSION, the four pyfunctions at:54, the explicitadd_classlist at:62, thenfuncs::registerat:107.bindings/python/src/convert.rs:59—value_from_py(object)— Python toValue, order preserved;value_to_pyat:15is the inverse;chat_message_to_pyat:113owns the shared{role, content}dictionary shape.bindings/python/src/errors.rs:146—Raise<T>/:157Catch<T>— the two extension traits that turnResult<T>intoPyResult<T>and back;to_pyat:55builds the right class with the right arguments,from_pyat:92folds an unrecognised Python exception intoError::Session.bindings/python/src/types.rs:40—register_dialect(class)and thedialect_to_py/dialect_from_pypair — the enum member crossing by value.bindings/python/src/types.rs:166—PyToolHandler— a Rust closure seen from Python as an ordinary callable, sospec.handler(...)works for the built-in andSkilltools.bindings/python/src/funcs.rs:672—register(module)— every free function and every framework constant,__version__first at:677.bindings/python/src/session.rs:251—PySession— the keyword constructor assemblesSessionConfig;add_*return the same object so registration chains;startat:632consumes the slot,runat:678does not.bindings/python/src/run.rs:189—PySessionRun—stepunderallow_threads,control,checkpoint,drain_events,outcome,usage;PyToolContextat:105carries invocation capabilities;PyContextHandlerat:40andPyEventSinkat:88translate the callbacks.bindings/python/src/runtime.rs:47—park/:57unpark— the parked-exception helpers behindPromptAssemblerandAgentRunner;PyChannel::parked(bindings/python/src/channel.rs:71) is the channel's copy of the same idea.
- Wraps every module in
crates/kerness/src/; each subsystem doc names its own pyclass and its binding tests. - Exposes run.md's runtime as owned handles and Rust-serialised
dictionaries; the
kind/statustags are that module's serde contract. - Exposes session.md's configuration as keyword arguments; the
consumed-session slot is the Python face of
Session::starttakingselfby value. - Hands the exception hierarchy to errors.md at bootstrap; the map runs both ways because a Python provider raises into Rust code that reads the status code.
- Installs the patchable HTTP seam described in provider.md
(
install_transport,bindings/python/src/provider.rs:747), the console writer and logger described in channel.md, and the console prompt described in access.md. - Binds Python stores through memory.md's
bind_memory_storeand channels through channel.md'sbind_channel; both take the exact type shortcut for bundled objects. - Ships the assets gameplan.md, role.md,
persona.md, and skills.md load; byte-equality with
the crate's copy is asserted by
test_the_crate_and_the_package_ship_the_same_assets(bindings/python/tests/test_packaging.py:42). - Is proved installable by selfcheck.md and gated by testing.md.
cargo clippy --workspace --all-targets -- -D warnings # pass = exit 0
(cd bindings/python && ../../.venv/bin/maturin develop) # pass = "Installed kerness-<workspace version>"
.venv/bin/python -m pytest bindings/python/tests -q # pass = 502 passed
.venv/bin/python -m kerness.selfcheck # pass = "OK: all core checks passed", exit 0
.venv/bin/ruff check bindings/python # pass = "All checks passed!"
.venv/bin/python bindings/python/examples/host_control.py # pass = exit 0, validated result- Rebuild with
maturin developafter any Rust change; the Python suite tests the installed extension, andbindings/python/tests/test_packaging.py:35detects version mismatches only; rebuilds within a version remain necessary. bindings/python/tests/test_packaging.py:74and:83— every public module declares__all__and every name in it resolves, which is what catches a renamed Rust symbol behind a shim.bindings/python/tests/test_session.py:403—test_inputs_results_events_and_handles_cross_the_boundary— inputs, outcomes, events,ToolContext, andRunControlcross once; Rust owns the loop.bindings/python/tests/test_session.py:2492—test_a_python_store_is_opened_read_written_and_closed— aMemoryStoresubclass driven by Rust through every lifecycle method.bindings/python/tests/test_channel.py:28— the exact-type rule; with a plaindowncastthis fails because the shortcut calls the basesend.bindings/python/tests/test_provider.py:365—test_the_budget_is_spent_only_on_failure_and_then_reported— a Python subclass'schatis what the supplied retry body calls.bindings/python/tests/test_examples.py:132— everykerness.Xa Python example reaches for still exists, by AST walk, since the examples need keys.- Gap:
PyToolHandleris callable but not introspectable (inspect.signaturegives__call__, not the tool's schema); no test asserts either way. Dictionary order preservation acrossconvert.rshas no dedicated test.
- Adding a pyclass →
types.rsor the owning boundary module, then theadd_classlist atbindings/python/src/lib.rs:52, the shim's import and__all__, and (for a new shim)_CORE_MODULESinbindings/python/kerness/selfcheck.py;bindings/python/tests/test_packaging.py:83andbindings/python/tests/test_selfcheck.py:18fail until all three agree. - Adding a constant → declare it in the crate, add one
module.addinfuncs::register(bindings/python/src/funcs.rs:672), re-export it in the shim, and extendtest_the_constants_carry_the_frameworks_values(bindings/python/tests/test_provider.py:841) or the root's table; never spell the value in Python. - Changing
Session.startorSessionRun.step→bindings/python/src/session.rs:632andbindings/python/src/run.rs:189; thekind/statustags are run.md's serde contract, so a variant rename lands here as a public change;bindings/python/tests/test_session.py:403and the README's host-control section. - Changing a callback signature → the trait impl in
bindings/python/src/run.rs:40or:88, orbindings/python/src/session.rs:159,:179,:235;require_callable(bindings/python/src/run.rs:32) is the construction-time check to reuse. - Changing error crossing →
errors.rsonly; every other file usesRaise/Catch. A newErrorvariant needs a class inbindings/python/kerness/exceptions.py, aClassesfield, and arms in bothto_pyandfrom_py; see errors.md. - Safe extension points: a new
#[pyfunction]infuncs.rs; a newwith_*forwarded builder; a new seam installed inbootstrap. - Forbidden coupling: no logic in the Python package beyond ABCs,
dataclasses, enums, exceptions,
inspect, andpydantic; nopyo3incrates/; no directsys.stdout/stderrwrites from Rust — go through the seams; no non-exactdowncastwhen binding a bundled channel or store. - Compatibility checks: keyword names and order in every
#[pyo3(signature = ...)]; the_coreclass names;kerness.__all__(bindings/python/kerness/__init__.py:80); the wheel tagcp310-abi3;requires-python = ">=3.10".
- Ship
.pyistubs for_coreso editors get completion and a type checker can run overbindings/python; success:maturin developinstalls a_core.pyiand a stub-consistency test compares it withdir(_core). - Give
PyToolHandlera__signature__derived from the tool's parameter schema; success:inspect.signature(spec.handler)names the schema's properties in a newtest_toolkit.pycase.
- M4: streaming and broader integrations remain deferred with the core roadmap. New bindings should retain forwarding, error conversion, and handle-lifetime checks here; scheduling, approvals, recovery, and budget behavior remain Rust responsibilities.
- No
.pyistubs, so editors get no completion or type checking for_core. The shims re-export names without annotating them. PyToolHandlerexposes a Rust closure as callable but not introspectable;inspect.signatureon it gives the__call__signature, not the tool's schema.- The wheel is built per platform. There is no
sdist-only fallback for a platform without a prebuilt wheel beyond compiling the crate locally.