stalwart_agents

stalwart-agents: declare an agent in one file -- its tools, its rules, its users -- check it, and run it.

  • Agent(...), or Agent.from_file("agent.yaml"): the agent, its tools as plain functions, MCP servers or HTTP operations, its rulebook and the agents it hands off to. agent.chat(text, user=...) runs a turn: routed, every call checked, a write waiting for the person where the check says so, a failed run undone. agent.thumbs(reply, up=...) posts the person's thumb on a reply as a feedback record.
  • tool(...) gives a plain function its words: writes, undo, irreversible, retry_safe, reads_outside, sends_out, after, not_after, at_most.
  • load("agent.yaml") reads the file.
  • verify("agent.yaml") registers the catalogue, verifies the agents' graph and hunts the rulebook; stalwart verify agent.yaml prints the result.
  • The stalwart command: verify, serve, trace, redteam, gateway, doctor, check and licence.

The names below are loaded on first use, so stalwart_agents.client or the runtime can be imported without the agents, their YAML and the graph underneath.

TOOL_WORDS = ('writes', 'undo', 'irreversible', 'retry_safe', 'reads_outside', 'sends_out', 'after', 'not_after', 'at_most', 'requires_privilege', 'description', 'argument_kinds', 'sandboxed', 'sanitised_by', 'confidential', 'confidential_fields', 'subject_argument', 'retrieves', 'persists', 'source', 'server')

The words a tool may carry, as @tool(...) and agent.yaml both spell them.

class Agent:

An agent: its tools, its rules, its model, and the agents it hands off to.

Args: name: the agent's name, as the catalogue and the trace call it. tools: plain functions (marked with @tool for their words), or tools read from the file. rulebook: the rules the agent's writes are checked against: a Colang file (1.0 or 2.x). The graph is proved against it, and at start-up it is registered with the catalogue, compiled by the service into the record the tool check enforces, wherever the newest version does not hold it already. model: "provider:model", read with the team's own key from the environment, or a function taking the conversation and returning the reply. hands_off_to: the agents this one may pass the conversation to. privilege: low, standard or high: what a hand-off from it is trusted with. on_unread: what to do with a turn the router cannot read, once the person was asked to say it again: refuse; model to hand it to the model; or ask to ask the person first, and hand it over on their yes (confirm(user, "approve")). Where not given, each conversation takes the console's setting at its first turn, as the catalogue bundle carries it, or refuse. system: what the model is told before the conversation. client: the connection to Stalwart; one from QLS_API_KEY where not given. storage: where runs are kept: sqlite:///runs.db or postgres://...; in memory where not given. atomic: whether a failed run is undone. max_steps: the most steps one run takes. verify: whether the graph is proved before it runs -- once per graph, so a rebuild of the same graph is free; a way through its checks raises NotVerified and nothing runs. False runs it unproved. verifier: what proves it; the hosted graph mode, through @p client, where not given. replies: the reply check's settings: rules (the reply rules, or their file), phrasings (the outcome phrasings, or their file), composer (model or template) and language. Each turn is screened for personal data and each reply held to what was done (stalwart_agents.replies); no check where not given. answers: grounded answers for a question no tool answers, with on_unread="answer": index (the source index, or its file), k, floor and composer (extract, or compose with this agent's model); those not given are the console's, read as on_unread is. See stalwart_agents.answers.

Example:

from stalwart_agents import tool @tool(writes=True, undo="refund_card") ... def charge_card(order_id: str, amount: float) -> str: ... return "charged" desk = Agent("desk", tools=[charge_card]) desk Agent('desk', tools=['charge_card']) desk.catalogue()["tools"][0]["compensation"] {'tool': 'refund_card'}

Agent( name: str, *, tools: Sequence[Any] = (), rulebook: str | pathlib.Path | None = None, model: Any = None, hands_off_to: Sequence[Agent | str] = (), privilege: str | int | None = None, on_unread: str | None = None, system: str | None = None, client: Any = None, storage: str | None = None, atomic: bool = True, max_steps: int = 60, verify: bool = True, verifier: Any = None, replies: Mapping[str, Any] | None = None, answers: Any = None)
name
replies
answers: Any
tools: dict[str, Tool]
rulebook
model
hands_off_to
privilege
on_unread
system
client
storage
atomic
max_steps
verify
verifier
declared_at
sources: dict[str, dict[str, Any]]
@classmethod
def from_file( cls, path: str | pathlib.Path, *, name: str | None = None, client: Any = None, fetch: bool = True, transport: Any = None, **options: Any) -> Agent:

The agent @p name of agent.yaml (the first it declares where not given), with the agents it hands off to.

Args: path: the agent.yaml. name: the agent to return; the first the file declares where not given. client: the connection to Stalwart, for every agent of the file. fetch: read each MCP server's tools/list over the network. transport: an HTTP transport for the MCP and OpenAPI calls, in a test. options: further arguments for each Agent, such as max_steps.

Returns: The agent, its team made from the file.

Raises: AgentFileError: the file says something this release cannot read.

def team(self) -> dict[str, Agent]:

This agent and every agent it can hand off to, by name, this one first.

Returns: The agents by name.

Raises: StalwartError: an agent hands off to a name rather than to the Agent itself.

def catalogue(self) -> dict[str, Any]:

The catalogue the agents' tools are registered as, in the extracted-model format.

The same agents declared in Python or in agent.yaml give the same catalogue.

Returns: The catalogue: its tools and its agents, each with its allowed_tools.

def catalogue_digest(self) -> str:

SHA-256 of the catalogue's canonical JSON.

Returns: The digest, in hex.

def export(self) -> dict[str, Any]:

The graph as the verifier reads it (qls-graph/1), each node at the line that declared it.

Returns: The export: its nodes, edges and recursion limit.

def graph_digest(self) -> str:

SHA-256 of the export's structure -- its nodes, kinds, tools and edges -- whatever line declared them.

Returns: The digest, in hex.

def graph( self, *, verifier: Any = None, checkpointer: Any = None, tracer: Any = None) -> Any:

What runs underneath: the compiled graph. The one door out, for a team that wants to extend it.

Args: verifier: what verifies the graph as it is compiled; the agent's own where not given (the hosted graph mode, or nothing for an agent made with verify=False). checkpointer: where runs are kept; the agent's own storage where not given. tracer: what records each step of a run.

Returns: The compiled graph, ready to run.

Raises: NotVerified: the graph has a way through its checks, or the verifier could not run. RulebookNotHeld: the agent declares a rulebook the tool check does not hold, and it could not be registered.

def chat(self, text: str, *, user: str = 'user') -> Reply:

One turn from @p user: routed, checked, and run; a write may wait for the person.

Args: text: what the person says. user: whose conversation it is; each user's conversation is kept apart.

Returns: The reply, and anything that waits for the person.

Raises: RunTooLong, StateConflict, StorageError: the run could not finish, saying why.

def confirm( self, user: str, decision: str = 'approve', *, waiting: str | None = None) -> Reply:

The person's decision on what waits in @p user's conversation.

A write waiting for its confirmation takes approve, skip or cancel_all; a turn waiting to be handed to the model (on_unread="ask") takes approve or decline. @p waiting names one of several by its id; the first where not given.

Args: user: whose conversation it is. decision: approve, skip or cancel_all; approve or decline for a hand-over to the model. waiting: the id of the call decided on; the first that waits where not given.

Returns: The reply once the run has gone on.

Raises: StalwartError: nothing in @p user's conversation waits for a decision.

def feedback_record( self, reply: Reply, *, up: bool = True, corrected: str | None = None, step: int | None = None) -> dict[str, Any]:

The feedback record a thumb on @p reply posts, as /v1/feedback takes it.

Only the rated turn's words are in it, with the router's reading of them: no reply text, argument value, tool result or earlier turn.

Args: reply: what chat or confirm returned. up: a thumb up; False for a thumb down. corrected: the right tool, where the person or a reviewer said. step: the step rated, counting from 0, where the turn held several instructions.

Returns: The record.

Raises: ThumbRefused: a whole turn of several instructions; an answer the conversation decided (a reply to a question, a value from context); the reply to a decision on a confirmation; or a reply no router read. The message says what to thumb instead.

def thumbs( self, reply: Reply, *, up: bool = True, corrected: str | None = None, step: int | None = None) -> Mapping[str, Any]:

Posts the person's thumb on @p reply as one feedback record.

The same thumb posted again is not posted twice, and a retry of the post is one record on the service. A thumb changed -- up, then down -- posts the new one, and the miner drops the pair as a contradiction.

Args: reply: what chat or confirm returned. up: a thumb up; False for a thumb down. corrected: the right tool, where the person or a reviewer said. step: the step rated, counting from 0, where the turn held several instructions.

Returns: The service's answer to the record.

Raises: ThumbRefused: a thumb the toolkit refuses (see feedback_record), or one whose turn carries personal data; nothing is posted.

def close(self) -> None:

Ends the MCP sessions this agent and its team hold with their tool servers, every user's.

A closed agent can still run: a later call opens a session again.

Example:

Agent("desk").close()

@dataclass
class AgentFile:

What agent.yaml declares: the agents, their tools, the stores and the rulebook.

Example:

declared = AgentFile(path=Path("agent.yaml"), agents={"desk": Agent("desk", ["get_order"], [], {}, 2, 3)}, ... tools={"get_order": Tool("get_order")}, stores={}, budget={}, modules={}, rulebook=None) declared.file 'agent.yaml' [node["id"] for node in declared.graph()["nodes"]] ['__start__', 'desk_model', 'desk_tools', '__end__']

AgentFile( path: pathlib.Path, agents: dict[str, Agent], tools: dict[str, Tool], stores: dict[str, Any], budget: dict[str, Any], modules: dict[str, Any], rulebook: pathlib.Path | None, runs: str = 'atomic', storage: str | None = None, lines: dict[tuple[str, ...], int] = <factory>, replies: dict[str, Any] | None = None, answers: dict[str, Any] | None = None)
path: pathlib.Path
agents: dict[str, Agent]
tools: dict[str, Tool]
stores: dict[str, Any]
budget: dict[str, Any]
modules: dict[str, Any]
rulebook: pathlib.Path | None
runs: str = 'atomic'
storage: str | None = None
lines: dict[tuple[str, ...], int]
replies: dict[str, Any] | None = None
answers: dict[str, Any] | None = None
def close(self) -> None:

Ends the MCP sessions the file's tools hold with their servers, every user's.

Example:

AgentFile(path=Path("agent.yaml"), agents={}, tools={}, stores={}, budget={}, modules={}, ... rulebook=None).close()

def silent(self, agent: str) -> list[str]:

The words the console's settings decide for @p agent, since the file does not say them.

Returns: Each word the file leaves to the console: on_unread, and answers.k, answers.floor or answers.composer where the file has an answers block that leaves them out.

Example:

AgentFile(path=Path("agent.yaml"), agents={"desk": Agent("desk", [], [], {"on_unread": "model"}, 2, 3)}, ... tools={}, stores={}, budget={}, modules={}, rulebook=None, ... answers={"index": "sources.json", "k": 4}).silent("desk") ['answers.floor', 'answers.composer']

file: str

The file's name, as a finding names it.

Returns: The name, without its directory.

def catalogue(self) -> dict[str, Any]:

The catalogue stalwart verify registers, in the extracted-model format.

Returns: The catalogue: its tools and agents, and the file's stores, budget and modules where it declares them.

def graph(self, *, recursion_limit: int = 25) -> dict[str, Any]:

The graph the file describes, as the verifier reads it (qls-graph/1).

Args: recursion_limit: the most steps one run takes.

Returns: The graph: its nodes, each at the line that declared it, and its edges.

class AgentFileError(stalwart_agents.StalwartError):

agent.yaml says something this release cannot read, at a line of the file.

Example:

failure = AgentFileError("agent 'desk' lists 'refund', which no tools source declares", file="agent.yaml", line=4) print(failure) agent.yaml:4: agent 'desk' lists 'refund', which no tools source declares failure.line 4

AgentFileError(message: str, *, file: str = '', line: int = 0)
file
line
class HunterUnavailable(stalwart_agents.StalwartError):

The checker could not be run, so nothing was checked -- never a clean result.

Example:

print(HunterUnavailable("the hunter is not at ./qls")) the hunter is not at ./qls

class LicenceRequired(stalwart_agents.StalwartError):

Nothing in this package answers without an activated licence: the message says how to get one.

The runtime holds the licence's lease in its own memory and refuses every product operation without it -- plans, tool checks, reply checks, answers, the code checks and the rest. Only activating a licence, stalwart doctor and checking a certificate work with no key.

Example:

print(LicenceRequired()) # doctest: +ELLIPSIS this needs an activated Stalwart licence: sign up at https://stalwart.it, set QLS_API_KEY ... issubclass(LicenceRequired, StalwartError) True

LicenceRequired(message: str = '')
class ModelKeyMissing(stalwart_agents.StalwartError):

The model's provider needs a key the environment does not hold: variable names it.

Nothing was sent anywhere: the key is checked before any request.

Example:

print(ModelKeyMissing("anthropic", "ANTHROPIC_API_KEY")) the model's provider 'anthropic' needs a key: set ANTHROPIC_API_KEY in the environment

ModelKeyMissing(provider: str, variable: str)
class ModelUnavailable(stalwart_agents.StalwartError):

The model named cannot be used here: the message says what to install or change.

Example:

print(ModelUnavailable("'acme:large' is not a provider this release knows")) 'acme:large' is not a provider this release knows

class NotVerified(stalwart_agents.StalwartError):

The agent has a way through its checks: findings names each, with its rule and tool.

Example:

NotVerified("the agent has a way through its checks", [{"rule": "QG018", "tool": "charge_card"}]).findings [{'rule': 'QG018', 'tool': 'charge_card'}]

NotVerified(message: str, findings: Sequence[dict[str, Any]] = ())
findings
@dataclass
class Reply:

What the agent said, and anything that waits for the person.

text is the reply; waiting the calls that need a decision before the run goes on. rated is what a thumb on it records (Agent.thumbs): the user's words and the router's reading of them -- each step's tool, verdict and deciding rule, the agent's visible tools -- and the tool the model called where the router handed it the turn. It never holds the reply's text, an argument value or a tool's result; it is None where no router read the turn.

Example:

reply = Reply("Order 4411 is refunded.") print(reply) Order 4411 is refunded. reply.waiting []

Reply( text: str, waiting: list[Waiting] = <factory>, rated: dict[str, Any] | None = None)
text: str
waiting: list[Waiting]
rated: dict[str, Any] | None = None
class RulebookNotHeld(stalwart_agents.StalwartError):

The agent declares a rulebook the tool check does not hold, and it could not be registered.

Nothing runs: a graph proved against rules the checks do not enforce would be trusted for something it does not do. The message says why, and what to register instead.

Example:

print(RulebookNotHeld("policy.co", "the service could not be reached")) the rulebook policy.co is not the one the tool check holds, and it could not be registered: the service could not be reached

RulebookNotHeld(rulebook: str, why: str)
rulebook
class RunTooLong(stalwart_agents.StalwartError):

A run took more steps than its limit: limit and steps say how many.

Example:

print(RunTooLong(60, 61)) the run took 61 steps and its limit is 60: raise max_steps, or give the agent fewer tools to choose between

RunTooLong(limit: int, steps: int)
class StalwartError(builtins.Exception):

Something stopped a command; the message says what to change.

Every error of this package is one, so one except StalwartError catches them all.

Example:

issubclass(AgentFileError, StalwartError), issubclass(RunTooLong, StalwartError) (True, True)

class StateConflict(stalwart_agents.StalwartError):

Two steps of a run wrote to the same part of its state at once: steps names them.

Example:

print(StateConflict("both wrote the messages", ["desk_tools", "billing_tools"])) two steps wrote the run's state at once (desk_tools and billing_tools): both wrote the messages

StateConflict(detail: str, steps: Sequence[str] = ())
steps
class StorageError(stalwart_agents.StalwartError):

The store the runs are kept in could not be read or written: store names it.

Example:

print(StorageError("sqlite:///runs.db", "the database is locked")) the run store sqlite:///runs.db failed: the database is locked

StorageError(store: str, detail: str)
store
class ThumbRefused(stalwart_agents.StalwartError):

A thumb that would record a false case, refused: the message says what to thumb instead.

Nothing is posted. A whole turn of several instructions is thumbed one step at a time; an answer the conversation decided -- a reply to a question, a confirmation -- is thumbed on the turn that named the tool.

Example:

print(ThumbRefused("a turn of 2 instructions is not one case: thumb the step the user meant")) a turn of 2 instructions is not one case: thumb the step the user meant

@dataclass
class Tool:

A tool as the file declares it: its words, its schema, the line that declared it, and what runs it.

call is the function a run calls -- the Python function, or the request to the MCP server or the HTTP operation -- and None for a tool declared by its words alone.

Example:

entry = Tool("charge_card", {"writes": True, "undo": "refund_card"}).catalogue_entry() entry["read_only"], entry["compensation"], entry["action"], entry["object"] (False, {'tool': 'refund_card'}, 'charge', 'card')

Tool( name: str, words: dict[str, Any] = <factory>, schema: dict[str, Any] = <factory>, line: int = 0, call: Callable[..., Any] | None = None)
name: str
words: dict[str, Any]
schema: dict[str, Any]
line: int = 0
call: Callable[..., Any] | None = None
def catalogue_entry(self) -> dict[str, Any]:

The tool in the extracted-model catalogue format.

Returns: The catalogue entry: its name, description, read_only, input schema, the fields the selector reads, and a field for each word that maps to one.

class ToolUnavailable(stalwart_agents.StalwartError):

An agent lists a tool with nothing to run it: declare its source (python, mcp, openapi).

Example:

print(ToolUnavailable("'refund_card' has nothing to run it")) 'refund_card' has nothing to run it

@dataclass
class VerifyResult:

What verify found: the lints, the findings, the scan's summary and whether the gate held.

Example:

result = VerifyResult(file="agent.yaml", lints=[], findings=[], scan={}, gate_failed=False) result.exit_code 0

VerifyResult( file: str, lints: list[dict[str, Any]] | None, findings: list[dict[str, Any]], scan: dict[str, Any], gate_failed: bool, lint_note: str = '', notes: list[str] = <factory>, export_path: str = '', replay: dict[str, str] = <factory>, registered: int | None = None, registration_note: str = '', rulebook: str = '', code_check: dict[str, Any] = <factory>)
file: str
lints: list[dict[str, Any]] | None
findings: list[dict[str, Any]]
scan: dict[str, Any]
gate_failed: bool
lint_note: str = ''
notes: list[str]
export_path: str = ''
replay: dict[str, str]
registered: int | None = None
registration_note: str = ''
rulebook: str = ''
code_check: dict[str, Any]
exit_code: int

The status stalwart verify exits with.

Returns: 1 where the gate failed; 0 where it held.

@dataclass
class Waiting:

A call that waits for a person: what it is, why, and the decisions it takes.

Example:

waiting = Waiting.of({"id": "i-1", "tool": "charge_card", "wording": "Charge 5.00 to the card?", ... "decisions": ["approve", "skip", "cancel_all"]}) waiting.tool, waiting.irreversible ('charge_card', False)

Waiting( id: str, kind: str, tool: str, arguments: list[dict[str, Any]], reasons: list[str], wording: str, irreversible: bool, decisions: list[str])
id: str
kind: str
tool: str
arguments: list[dict[str, Any]]
reasons: list[str]
wording: str
irreversible: bool
decisions: list[str]
@classmethod
def of(cls, shown: Mapping[str, Any]) -> Waiting:

The call @p shown describes, as the check's confirmation payload gives it.

Args: shown: the payload: id, kind, tool, arguments, reasons, wording, irreversible and decisions; a missing field is empty.

Returns: The waiting call.

__version__ = '0.0.0.dev0'

The package's version: 1.0. in a release, 0.0.0.dev0 in a checkout.

def export(path: str | pathlib.Path, out: str | pathlib.Path) -> pathlib.Path:

Writes the graph agent.yaml builds, as the verifier reads it, to @p out: stalwart verify --export-only.

Args: path: the agent.yaml. out: the file the graph is written to.

Returns: The file written.

Raises: AgentFileError: the file says something this release cannot read.

Example:

import tempfile with tempfile.TemporaryDirectory() as folder: ... path = Path(folder, "agent.yaml") ... _ = path.write_text("{agents: {desk: {tools: [get_order], hands_off_to: [billing]}, " ... "billing: {tools: [get_order]}}, tools: [{name: get_order}]}", encoding="utf-8") ... written = export(path, Path(folder, "agent.graph.json")) ... graph = json.loads(written.read_text(encoding="utf-8")) written.name, graph["format"] ('agent.graph.json', 'qls-graph/1')

def load( path: str | pathlib.Path, *, fetch: bool = False, transport: Any = None) -> AgentFile:

Read @p path, an agent.yaml.

Args: fetch: read each MCP server's tools/list over the network; without it a server's tools are known by the names the file lists (a saved listing file is always read). transport: an HTTP transport for the MCP and OpenAPI calls, in a test.

Returns: What the file declares.

Raises: AgentFileError: at the line of anything this release cannot read -- an unknown word, an agent naming a tool no source declares, a hand-off to an agent the file does not have.

Example:

import tempfile with tempfile.TemporaryDirectory() as folder: ... path = Path(folder, "agent.yaml") ... _ = path.write_text("{agents: {desk: {tools: [get_order]}}, tools: [{name: get_order}]}", encoding="utf-8") ... declared = load(path) declared.agents["desk"].tools, declared.runs (['get_order'], 'atomic')

def render(result: VerifyResult) -> str:

The report stalwart verify prints.

Args: result: what verify found.

Returns: The report: the lints, each finding in a block, the rules with nothing to read, and the gate.

Example:

print(render(VerifyResult(file="agent.yaml", lints=[], findings=[], scan={}, gate_failed=False)), end="") stalwart verify agent.yaml Lints at registration: none No findings. Gate: passed

def tool(**words: Any) -> Callable[[Callable[..., Any]], Callable[..., Any]]:

Declares a plain function a tool, with the words of agent.yaml.

@tool(writes=True, undo="refund_card")
def charge_card(order_id: str, amount: Decimal) -> Charge: ...

Args: words: the tool's words, as TOOL_WORDS spells them.

Returns: What marks a function with @p words and returns it unchanged.

Raises: TypeError: a word is not one of TOOL_WORDS.

Example:

@tool(writes=True, undo="refund_card") ... def charge_card(order_id: str) -> str: ... return order_id getattr(charge_card, MARK) {'writes': True, 'undo': 'refund_card'} tool(write=True) Traceback (most recent call last): ... TypeError: @tool does not take write; it takes writes, undo, ...

def verify( path: str | pathlib.Path, *, keep: str | pathlib.Path | None = None, dependencies: str | pathlib.Path | None = None, replay: bool = False, client: Any = None) -> VerifyResult:

Check the agent @p path declares: the code checks here, then the bug hunter on the service.

Args: path: the agent.yaml. keep: a directory to write the export, the catalogue and the findings into. dependencies: the lock file of the packages you install (requirements.txt, pyproject.toml, uv.lock or poetry.lock), for the advisory registry. replay: replay each graph finding's witness on the agents, every tool a stub. client: the connection the catalogue is registered, the hunt run and the replays posted through; one from QLS_API_KEY where not given. Where no lease is open in this process yet, it opens one, before anything runs.

Returns: What the check found, and whether the gate held.

Raises: AgentFileError: the file says something this release cannot read. LicenceRequired: no licence is activated here, the service refused it, or it has lapsed. HunterUnavailable: the hunt could not be run, so nothing was checked.

Example: No licence is an error, never a clean result, and a machine without one is told to activate one:

>>> import tempfile
>>> with tempfile.TemporaryDirectory() as folder:
...     path = Path(folder, "agent.yaml")
...     _ = path.write_text("{agents: {desk: {tools: [get_order]}}, tools: [{name: get_order}]}", encoding="utf-8")
...     try:
...         verify(path)
...     except (LicenceRequired, HunterUnavailable):
...         print("nothing was checked")
nothing was checked