The Python package is an async-first ergonomic wrapper over the Rust-owned Merry facade. Python owns typed conversion and host-side callable execution; Rust owns sessions, events, artifacts, ledger state, permissions, retries, tool admission, and run lifecycle.
From the repository root:
cd sdks/python
uv sync
uv buildThe package exposes the native extension as merry._merry. Application code
should use the typed merry module instead of constructing native objects.
The examples use the live OpenAI-compatible provider configured through
MERRY_OPENAI_API_KEY, MERRY_OPENAI_MODEL, and the optional
MERRY_OPENAI_BASE_URL:
uv run examples/basic_agent.py # event handling and terminal result
uv run examples/tool_decorator.py # @builder.tool host execution
uv run examples/structured_output.py # two-field typed JSON output
uv run examples/multi_runtime_orchestration.py # compose independent runtimesEach agent has its own session id and Rust-owned lifecycle. Interactive and process-specific examples are intentionally omitted until those adapters are part of the Python capability matrix.
Provider construction is performed by the Rust facade. The Python dataclasses only carry validated application configuration:
from pathlib import Path
import merry
store_root = Path("sessions")
agent = (
merry.Agent.builder(session_id="demo")
.provider(
merry.OpenAICompatible(
api_key="...",
model="gpt-4.1-mini",
base_url="https://api.example.test/v1",
)
)
.workspace(
merry.WorkspaceConfig(
root=Path("."),
patch=merry.PatchConfig(write_scope=["src"]),
)
)
.session_store(store_root)
.max_model_turns(32)
.build()
)WorkspaceConfig maps to the Rust coding profile. A workspace has one root,
which may be absolute or relative; patch and forbidden paths are root-relative
normalized paths. Every workspace limit is positive and is enforced again by
Rust. WorkspaceLimits fields default to None, meaning the native builder uses
Rust's current defaults rather than a separate set of Python defaults. Set only
the fields you want to override; other fields continue to inherit Rust defaults.
Currently, read_text returns up to 2,000 lines per call, subject to the independent
1 MiB scan/return limit. WorkspaceLimits(max_read_lines=...) overrides both the
default read window and the maximum accepted max_lines argument. The Python
configuration retains None for inherited fields; it is not a resolved snapshot.
Anthropic Messages uses the same builder:
agent = (
merry.Agent.builder("anthropic-session")
.provider(merry.Anthropic(api_key="...", model="claude-sonnet-4-5"))
.build()
)Tools use one Pydantic input model, one Pydantic output model, and an async handler. Every field must have a description so the generated provider-neutral schema is explicit:
from pydantic import BaseModel, ConfigDict, Field
class LookupInput(BaseModel):
model_config = ConfigDict(extra="forbid")
order_id: str = Field(description="Stable order identifier.")
class LookupOutput(BaseModel):
model_config = ConfigDict(extra="forbid")
status: str = Field(description="Current fulfillment status.")
builder = merry.Agent.builder("tool-session")
@builder.tool
async def lookup_order(args: LookupInput) -> LookupOutput:
"""Look up an order by id."""
return LookupOutput(status="shipped")
agent = builder.provider(
merry.OpenAICompatible(api_key="...", model="gpt-4.1-mini")
).build()@agent.tool is also available after build() and before the first run. The
decorator returns a typed Tool[InputT, OutputT], which can be passed to
ToolRegistry or called directly in tests. Duplicate names, reserved names,
schema validation, admission, ordering, artifacts, and result persistence
remain Rust responsibilities.
Expected business failures should use ToolDomainError; they become a failed
tool result and let the model continue. Unexpected handler exceptions become
MerryToolError and cancel the run. Cancellation is propagated to the handler.
Agent.run() is the convenient path: it executes registered Python tools and
returns the Rust-owned terminal result. Agent.stream() exposes the explicit
message protocol for hosts that need to render events or control tool calls:
import merry
run = agent.stream("Inspect the repository and summarize the result.")
registry = merry.ToolRegistry([lookup_order])
async for message in run:
if isinstance(message, merry.Event):
print(message.type.value)
elif isinstance(message, merry.ToolCallBatch):
await registry.execute(message)
result = await run.result()
assert result.status is merry.RunStatus.COMPLETEDToolCallBatch is an exclusive Rust-owned lease. The complete result set must
be submitted before reading the next message. result() is valid after EOF;
run.cancel() and close() wait for the durable terminal result. Cancellation
is cooperative, so a run that reached its terminal state first keeps that
result: close() requests cancellation only while the run is unfinished, and
cancel() returns the completed result instead of rewriting its status. Both
wait for Rust to stop provider and tool work, so the returned result is durable.
The Python async task may be cancelled; the SDK requests Rust cancellation and
re-raises asyncio.CancelledError.
Text deltas and output-rate observations are live-only events: consume them from
the run stream. result.events retains terminal evidence and lifecycle events,
not these transient updates, so long streamed output does not accumulate a
second event-by-event copy in the result. Final assistant text remains available
through result.final_output and assistant-message events.
AgentBuilder is single-use for build() and resume(). A native operation
that has consumed the builder also makes the Python builder terminal, including
when that operation later fails. Start a new builder to retry that operation.
Pass a Pydantic model to run() or stream() to install the Rust-owned final
output contract:
class Answer(BaseModel):
model_config = ConfigDict(extra="forbid")
summary: str = Field(description="Concise answer summary.")
next_step: str = Field(description="One practical next step for the reader.")
result = await agent.run(
"Return JSON with exactly two fields: summary and next_step. "
"Summarize the repository and give one practical next step.",
final_output_model=Answer,
)
assert result.structured_output is not None
assert result.structured_output.summary
assert result.structured_output.next_stepfinal_output_json is the exact JSON value recorded by Rust. Invalid Python
model declarations fail before a run starts, a native final-output contract
failure is MerryConfigError, and a recorded JSON value that cannot be decoded
by the requested Pydantic model is MerryOutputError. Output state remains
Rust-owned in every case.
Session persistence is explicit and file-backed in the current SDK:
from pathlib import Path
store_root = Path("sessions")
await agent.save_session()
resumed = await (
merry.Agent.builder("demo")
.provider(merry.OpenAICompatible(api_key="...", model="gpt-4.1-mini"))
.session_store(store_root)
.resume()
)The configured path is a store root directory. Each session is persisted under
<store_root>/<session_id>/state.json. An explicit root may be passed to
resume(path) when it differs from the configured store root.
The session document, ledger, artifact references, and resume validation are owned by Rust. Python only supplies the provider and store configuration.
All native failures are mapped to MerryError subclasses carrying
MerryErrorInfo (code, domain, message, hint, retryability, and
bounded context). Native diagnostic messages are operational details and may
include values derived from the configured environment, such as filesystem
paths. Treat exception messages as sensitive before forwarding or persisting
them.
Deterministic tests use a feature-gated Rust fake provider:
cd sdks/python
maturin develop --uv --features test-utils
uv run --with ruff --with ty ruff check .
uv run --with ruff --with ty ty check
uv run --with pytest python -m pytest tests -q
uv buildThe fake provider is not part of the default wheel feature set. See
CAPABILITY_MATRIX.md for the Rust owner and test
evidence for each public capability.