AgentM - an agent framework
Project description
AgentM
A pluggable agent framework in Python. The SDK is a mechanism; every policy is a port; every port has a default; every default is a replaceable extension.
See .claude/designs/pluggable-architecture.md for the boundary contract.
Two concepts
Everything in AgentM is built out of two things:
Extension (also called atom). One Python file that registers behavior
on the substrate — a tool, a policy, an LLM provider, a compaction strategy,
an observability subscriber, ... Each file exports a MANIFEST (name, version,
deps) and an install(api, config) function. The §11 contract requires it to
be one file — no atom-to-atom imports, no reach into the runtime substrate.
This is the atomic unit of policy.
Scenario. A YAML file naming which extensions to install, in what order,
with what config. A scenario is just a composition; it has no Python code
of its own (apart from optional scenario-private local: atoms next to the
manifest). Switching scenarios switches the entire policy stack without
changing the substrate.
The substrate (in agentm.core) is the only unreplaceable part. Extensions
reach stateful subsystems only through ExtensionAPI services (api.bus,
api.get_operations(), api.skills, api.catalog, ...). A mechanical
validator rejects any extension that imports core.runtime.* or
core._internal.* directly.
flowchart LR
SC["--scenario rca<br/>(or default: general_purpose)"] --> L["loader.load_scenario(name)"]
EX["--extension <dotted>[:JSON]<br/>(repeatable, appended)"] --> R
UA["<cwd>/.agentm/atoms/*.py<br/>(user atoms, layered on top)"] --> R
L --> R["resolve & topo-sort by MANIFEST.requires"]
R --> F["inject floor atoms<br/>(by provides_role, if missing)"]
F --> I["for each atom:<br/>install(api, config)"]
I --> S["AgentSession ready"]
Extension: one file
# src/agentm/extensions/builtin/my_atom.py
from agentm.core.abi.extension import ExtensionAPI, ExtensionManifest
MANIFEST = ExtensionManifest(
name="my_atom", # must equal the filename stem
version="0.1.0",
requires=("operations_local",), # by atom NAME; topo-sorted
provides_role=(), # optional capability advertisement
)
def install(api: ExtensionAPI, config: dict) -> None:
api.bus.subscribe(SomeEvent, handler)
# `config` came straight from the manifest's per-atom block
Extensions live in three places, all on the same auto-discovery path:
| Location | Who owns it | Mounted by |
|---|---|---|
src/agentm/extensions/builtin/<name>.py |
SDK | scenario manifest, -e, or auto-discovery |
contrib/extensions/<name>.py (flat) |
repo contributors | scenario manifest, -e, or auto-discovery |
contrib/extensions/<pkg>/... (nested) |
third-party packages | scenario manifest or -e only (no auto-discovery) |
<cwd>/.agentm/atoms/*.py |
the running agent itself | auto-layered on top of any scenario |
The extensions.validate checker enforces the §11 contract: allowlist is
core.abi + core.lib + the public extensions surface. Stateful
subsystems are reached only via ExtensionAPI services.
Scenario: a YAML composition of extensions
# contrib/scenarios/general_purpose/manifest.yaml
name: general_purpose
extensions:
- module: agentm.extensions.builtin.operations_local
- module: agentm.extensions.builtin.tool_read
- module: agentm.extensions.builtin.tool_bash
config:
timeout_seconds: 60
- module: agentm.extensions.builtin.observability
Each entry is module: (fully-qualified dotted path) or local:
(scenario-private .py next to the manifest). The optional config: block
is passed verbatim into that extension's install(api, config) call.
Variants live in the same directory as manifest.<variant>.yaml and must
declare name: <scenario>:<variant> (e.g. name: rca:harness.sync). Two
scenarios that share most of their stack typically ship one base manifest +
several variant files differing only in a few extensions or configs.
Order matters. Extensions are installed in the extensions: declaration
order, then re-ordered only as much as MANIFEST.requires forces (a
dependency must install before its dependent — otherwise position is
preserved). Two practical consequences:
- For axes where last registration wins (notably the LLM provider, and
any
api.register_*hook called more than once), an override must appear after the default in the list. - For event-bus subscribers, earlier-installed extensions register their handlers first, so they see events ahead of later ones. This is how interceptors / filters slot in.
Two extensions cannot both claim the same provides_role — that is a
hard load-time error, not a silent override. Use requires to express
"install B after A"; use provides_role to claim a floor capability
(command_parser, system_prompt_provider, ...) so the substrate skips
its default injection.
Extension vs Scenario — recap:
| Extension | Scenario | |
|---|---|---|
| What it is | one Python file | one YAML file |
| What it produces | behavior at runtime (tool / policy / event subscriber / ...) | a list of extensions to install |
| Composition | atomic — does one thing | combines many extensions |
| Mounted by | scenario manifest, --extension, auto-discovery |
--scenario <name> (one at a time) |
| Substitutable | yes, by another extension claiming the same role | yes, by another scenario |
Enabling: from CLI to a running session
The default scenario is general_purpose — no flag needed:
uv run agentm -p "list files in src/"
Pick another by name (resolves to contrib/scenarios/<name>/manifest.yaml):
uv run agentm --scenario rca "diagnose this trace"
uv run agentm --scenario rca:harness.sync "..." # variant shorthand
Stack extra extensions on top with -e / --extension (repeatable). Takes
a fully-qualified dotted module path — there is no short-name resolution.
uv run agentm --scenario rca \
-e contrib.extensions.llmharness.adapters.agentm:'{"mode":"sync"}' \
"..."
Four sources feed the install list, in this precedence order:
| Source | When | Notes |
|---|---|---|
AgentSessionConfig.extensions (programmatic) |
always wins if non-empty | hard override — suppresses scenario, auto-discovery, and --extension |
--scenario <name> (or default) |
when programmatic list is empty | resolves a manifest; declaration order preserved, then topo-sorted |
| Auto-discovery | only when no scenario AND no programmatic list | scans builtin + flat-file contrib + user-atom dirs |
--extension <dotted>[:JSON] |
always appended (except --no-extensions / programmatic override) |
repeatable; cannot remove or reorder, only stack |
After merging, the list is topologically sorted by MANIFEST.requires
(by atom name), and missing floor atoms are injected via provides_role.
You cannot stack scenarios — only one loads at a time; compose by writing
a new manifest.
--no-extensions bypasses everything for kernel-floor diagnosis (only the
LLM provider — no tools, no skills, no observability):
uv run agentm --no-extensions "explain core/abi/loop.py"
Discovering what's available
agentm list-extensions # every auto-discoverable atom, with its
# tier, registered hooks, and the exact
# `-e <dotted.path>` form to mount it
agentm --help # full flag list (--scenario, --extension,
# --resume, --continue, --tools, ...)
ls contrib/scenarios/ # shipped scenarios: general_purpose,
# agent_env, chatbot, format_fix,
# mcp_demo, rca, rca_hfsm, auditor,
# verifier
Beyond the one-shot prompt, agentm has two subcommands: agentm gateway
(single-process gateway holding all chat sessions in memory) and agentm trace
(query the OTLP/JSON session log). The chat-client peers ship as separate
binaries for vendor-SDK isolation only — agentm-terminal and
agentm-feishu. Run agentm <sub> --help or <binary> --help for flags.
Five pluggability axes
Each axis is a typing.Protocol in core.abi. The scenario manifest decides
which extension fills each role; extensions register via api.register_* hooks.
| # | Axis | Protocol / Port | Default impl |
|---|---|---|---|
| 1 | LLM stream | StreamFn |
extensions.builtin.llm_anthropic (also llm_openai) |
| 2 | Tool environment | Tool + *Operations |
LocalFileOperations, LocalBashOperations (via api.get_operations()) |
| 3 | Session state | SessionManager |
InMemorySessionManager |
| 4 | Project context | ResourceLoader |
DefaultResourceLoader |
| 5 | Policy / cross-cut | EventBus + ExtensionAPI |
bus + per-extension install hook |
Every signal — install, LLM request, tool call, mutation, turn summary —
flows through the same EventBus. The observability builtin is a pure
subscriber writing OTel-flavored JSONL to
<cwd>/.agentm/observability/<trace_id>.jsonl.
Showcase
contrib/scenarios/rca/— root-cause-analysis scenario over observability traces, with optionalllmharnessaudit overlay. Manifest variants (manifest.harness.*.yaml) compose the same extension set with different audit topologies. See its README.contrib/extensions/llmharness/— cognitive-audit pipeline. Mounts viallmharness.adapters.agentm; subscribesTurnEndEventto spawn extractor/auditor children andDecideTurnActionEventto inject reminders into the main loop. Loose-coupled — rca scenarios opt in by manifest only. See its README.
Quick start
uv sync
uv run agentm -p "list files in src/"
Model provider settings can be supplied through environment variables or
profiles in ~/.agentm/config.toml ($AGENTM_HOME/config.toml overrides
the directory). A minimal config profile:
default_model = "my-model"
[models.my-model]
provider = "openai"
model = "gpt-4o"
base_url = "https://api.openai.com/v1"
api_key = "..."
Select a profile with uv run agentm --model my-model -p "..."; if
default_model is set, uv run agentm -p "..." uses it.
Programmatic:
from agentm.core.abi.session_config import AgentSessionConfig
from agentm.core.runtime.session import AgentSession
from agentm.extensions.loader import load_scenario
session = await AgentSession.create(AgentSessionConfig(
cwd=".",
extensions=load_scenario("general_purpose"),
provider=("agentm.extensions.builtin.llm_anthropic",
{"model": "claude-sonnet-4-6"}),
))
final = await session.prompt("explain core/abi/loop.py")
await session.shutdown()
Setting extensions=[...] is a hard override — scenario, auto-discovery,
and --extension are all suppressed. Use it for tests; use scenarios in
production.
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file agentm-0.2.1.tar.gz.
File metadata
- Download URL: agentm-0.2.1.tar.gz
- Upload date:
- Size: 432.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.11.19 {"installer":{"name":"uv","version":"0.11.19","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
735db9a374ffc1c1dd35502a6a6654c7483d27197d962d89d11f6b5b6bff2fdf
|
|
| MD5 |
4580205452b3b2a9e53d7ab61ebf2efc
|
|
| BLAKE2b-256 |
aab394c61e80093be0c7d4e9f65932a1c63ba51b4e7f61f7b7aaff6470e1a222
|
File details
Details for the file agentm-0.2.1-py3-none-any.whl.
File metadata
- Download URL: agentm-0.2.1-py3-none-any.whl
- Upload date:
- Size: 530.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.11.19 {"installer":{"name":"uv","version":"0.11.19","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e75e02828da2d854c427ffdaf2c71df7d1794b6ce8e76a4721338ddab81b5102
|
|
| MD5 |
ee6e299d7697fd1008a28fa4698df182
|
|
| BLAKE2b-256 |
17bd6c3572034747d1f0deea2b351c3ce73634d6cf9d09a36b6d87030a37a57b
|