AAAX: the shell module
AAAX, the Advanced Autonomous Agent Executor, is the shell around an
agentic system. It mounts tactics, packages, and channels into one Strategy
and serves them as a single HTTP API: every tactic gets its full service
surface, the strategy's channel store gets the network API, and the strategy
adds a resource table and a top-level /run. In code, AAAX is lllm.shell.
AAAX is an experimental module of LLLM.
pip install "lllm-core[server,client]" # client adds httpx, which TestClient needs
A first strategy
from pydantic import BaseModel
from lllm import Tactic
from lllm.shell import Strategy
class Text(BaseModel):
text: str
class Shout(Tactic[Text, Text]):
name = "shout"
description = "Upper-case a message."
input_type = Text
output_type = Text
def _run(self, input_value, *, context=None):
return Text(text=input_value.text.upper())
strategy = Strategy("loud-desk", description="Shout at things.")
strategy.add_tactic(Shout(), ref="lllm://demo/loud-desk/tactics/shout")
@strategy.runner
async def run(input_value, *, context=None):
first = await strategy.tactics()["shout"].arun(input_value)
return {"once": first.text, "twice": f"{first.text}!!"}
create_strategy_app turns it into a FastAPI app:
from fastapi.testclient import TestClient
from lllm.shell import create_strategy_app
client = TestClient(create_strategy_app(strategy))
print(client.post("/tactics/shout/run", json={"input": {"text": "hello"}}).json()["output"])
print(client.post("/run", json={"input": {"text": "hello"}}).json())
{'text': 'HELLO'}
{'output': {'once': 'HELLO', 'twice': 'HELLO!!'}, 'strategy': 'loud-desk', 'metadata': {}}
Saved as desk.py, the same strategy runs from the shell with
lllm shell serve desk.py (see CLI).
Resources
Strategy(name, *, description="", resources=(), runner=None, metadata=None)
is a table of resources plus an optional runner. The strategy name follows
the same rule as resource names, so Strategy("My Strategy") raises
ValueError. Each resource is a StrategyResource:
| Field | Meaning |
|---|---|
name |
Local name, unique across the whole strategy, used in URLs. A path segment: no whitespace, /, :, \, or %, and not . or ... |
kind |
tactic, channel, service, schema, package, run, config, doc, example, snapshot, asset, or custom. |
ref |
Stable external identity, usually an lllm:// ref. |
entrypoint |
Python import target (module:attr) for package resources. |
description |
Free text. |
metadata |
Any JSON-friendly dict: runtime, schema, package, version, card. |
Names are local and refs are stable: mounting the same package twice under
different prefixes changes the names but keeps the refs. Add resources with
the helpers strategy.tactic(), .channel(), .service(), .package(),
.snapshot(), the general strategy.resource(kind, name, ...), or
add_resource(StrategyResource(...)). Each takes ref, entrypoint,
description, metadata, and an optional handler:
strategy.channel("log", ref="lllm://demo/loud-desk/channels/log", description="Everything shouted.")
strategy.resource("doc", "readme", ref="lllm://demo/loud-desk/docs/readme", metadata={"path": "README.md"})
for resource in strategy.resources:
print(resource.kind, resource.name, resource.ref)
tactic shout lllm://demo/loud-desk/tactics/shout
channel log lllm://demo/loud-desk/channels/log
doc readme lllm://demo/loud-desk/docs/readme
A resource without a handler is metadata: it shows up in /strategy and
/resources, and invoking it echoes the resource record, the input, and the
context back. A handler makes it callable. Channels are the exception: on a
strategy with a store, a channel without a handler is backed by the store
(below).
Tactics
add_tactic(tactic, *, name=None, ref=None, description=None, metadata=None)
mounts an lllm tactic. The name defaults to tactic.info().name, the ref to the
tactic's package_ref, and the runtime kind is recorded in metadata.
A plain function also works as a tactic handler. strategy.tactics() wraps it
with lllm.as_tactic, so it is served like any other tactic:
def count_words(payload: dict) -> dict:
return {"words": len(payload["text"].split())}
strategy.tactic("count", handler=count_words, description="Count words.")
print(sorted(strategy.tactics()))
['count', 'shout']
strategy.tactics() returns every tactic resource that has a handler as an lllm
Tactic. Each one is mounted with create_tactic_app at /tactics/<name>/,
so it gets the full service surface: run,
stream, info, and any custom tactic endpoints, with the services request
and error envelopes.
Channels and stores
attach_store(store) backs the strategy's channels with a store and mounts the
store API at /network/. Every channel resource
without a handler, like log above, is backed by the store channel of the
same name:
from lllm.network import LocalStore
strategy.attach_store(LocalStore(".lllm/network"))
@strategy.runner
async def run(input_value, *, context=None):
output = await strategy.tactics()["shout"].arun(input_value)
strategy.store.append_event({"channel": "log", "kind": "shout", "payload": output})
return output
client = TestClient(create_strategy_app(strategy))
client.post("/run", json={"input": {"text": "hello"}})
print([event["payload"] for event in client.get("/network/events", params={"channel": "log"}).json()])
print([event["payload"] for event in client.get("/channels/log/events").json()["output"]])
[{'text': 'HELLO'}]
[{'text': 'HELLO'}]
The store creates each declared channel on first use: when the app is built or
a store method is called. Channels that already exist are left as they are.
strategy.store wraps the store you attached and forwards store methods to it,
as the runner's append_event shows. Without a store, a channel resource is
metadata like any other. Use a synchronous store, a LocalStore or a
RemoteStore: the channel routes and /network/ do not work with an
AsyncRemoteStore.
A strategy has one store. Attaching a different one raises ValueError
instead of replacing it, so attach yours before mounting packages, which share
it (below). A TacticProcessor over
strategy.store is the usual way to connect a mounted tactic to a channel;
Tutorial 11 does that inside a runner.
Packages
A package folder with an lllm.toml (see The manifest)
mounts in one call:
support-desk/
lllm.toml
tactics.py
support-desk/lllm.toml:
[package]
org = "demo"
name = "support-desk"
version = "0.1.0"
kind = "app"
description = "Triage support messages."
[tactics.triage]
entry = "tactics:triage"
description = "Assign a priority to a support message."
[channels.inbox]
description = "Support messages as they arrive."
support-desk/tactics.py:
def triage(ticket: dict) -> dict:
urgent = "down" in ticket["text"].lower().split()
return {"priority": "urgent" if urgent else "normal"}
from pathlib import Path
from lllm.shell import strategy_from_package
desk = strategy_from_package("support-desk")
for resource in desk.resources:
print(resource.kind, resource.name, resource.ref)
print(desk.store.root.relative_to(Path("support-desk").resolve()))
package support-desk lllm://demo/support-desk
tactic triage lllm://demo/support-desk/tactics/triage
channel inbox lllm://demo/support-desk/channels/inbox
.lllm/network
desk had no store, so mounting the package gave it one at
<package>/.lllm/network. The folder and the inbox channel are created on
first use, here when the app is built, and the shortcuts work:
client = TestClient(create_strategy_app(desk))
client.post(
"/channels/inbox/events",
json={"input": {"kind": "message", "payload": {"text": "Checkout is down"}}},
)
events = client.get("/channels/inbox/events").json()["output"]
print([(event["kind"], event["source"], event["payload"]) for event in events])
print(client.post("/tactics/triage/run", json={"input": events[0]["payload"]}).json()["output"])
[('message', 'lllm.shell', {'text': 'Checkout is down'})]
{'priority': 'urgent'}
Packages load lazily: building or inspecting the strategy never imports
package code or creates the store folder. Building the app, calling
strategy.tactics(), or invoking the resource imports the code. A package
tactic that declares no package_ref reports its manifest ref,
lllm://demo/support-desk/tactics/triage here, in /tactics/triage/info and
as the tactic_ref of the CallContext it receives.
To combine packages and your own resources, mount packages into a strategy
with use_package (or add_package(strategy, path, ...)). A prefix keeps
names apart:
ops = Strategy("ops", description="Support and loud desks in one API.")
ops.use_package("support-desk", prefix="support")
ops.add_tactic(Shout())
print([resource.name for resource in ops.resources])
['support.support-desk', 'support.triage', 'support.inbox', 'shout']
The prefixed tactic is served at /tactics/support.triage/run and the channel
at /channels/support.inbox/events, backed by the store channel
support.inbox.
| Function | What it does |
|---|---|
strategy_from_package(path, *, name=None, prefix=None, store=None, bind=True) |
A new strategy named after the package, with the package card and version in its metadata. |
strategy.use_package(path, *, prefix=None, store=None, bind=True) |
Mount a package into an existing strategy. Same as add_package(strategy, path, ...). |
path is a package folder or an lllm.toml file. store picks the store
folder for a strategy that has no store yet (see below). bind=False mounts
metadata only: no handlers, no store, and no tactic routes.
Every manifest section becomes resources:
| Manifest section | Resource kind | Handler when bound |
|---|---|---|
[package] |
package (named after the package) |
|
[config] |
config named default |
|
[schemas.<name>] |
schema |
|
[tactics.<name>] |
tactic |
the imported entrypoint, as a tactic |
[channels.<name>] |
channel |
append, query, info on the strategy's store |
[snapshots.<name>] |
snapshot |
|
[services.<name>] |
service |
its tactic's handler, when it names one |
[runs.<name>], [docs.<name>], [examples.<name>], [assets.<name>] |
run, doc, example, asset |
A tactic entry loads through
coerce_tactic. A Tactic
instance is mounted, and a class that can be built without arguments is
instantiated. A callable that takes no parameters, or that can be called
without arguments and is annotated to return a Tactic, is a factory: it is
called once, when the tactic loads. Any other callable, like triage, is a
handler called once per input.
Things to know when mounting several packages:
- A strategy has one store. The first package with channels gives the
strategy its store, at
store=or<package>/.lllm/network, unless it already has one. Later packages share it, and/network/serves it; a laterstore=naming another folder raisesValueError. - Package channels are created in the store under their resource names
(
support.inbox), so a prefix keeps two packages' channels apart. - A package whose names clash with resources already in the strategy is
rejected before anything is added; the
ValueErrorsuggests a prefix. - Package code is imported into the server process and stays loaded, so give
packages you mount together distinct top-level module names: two packages
that both ship a
helpers.pyshare one copy of it.
The runner
/run calls the strategy's runner, set with the @strategy.runner decorator or
Strategy(..., runner=fn). The runner may be sync or async and is called as
runner(input_value, context=...). It receives context only if it declares
that parameter (or **kwargs), and the input only if it has a positional
parameter besides context (or *args): def run(*, context=None) is called
without the input. Handlers follow the same rules. Without a runner, /run
returns the strategy name, the input, and the resources, which makes a fresh
mount easy to check. desk has no runner:
output = client.post("/run", json={"input": {"q": 1}}).json()["output"]
print(output["strategy"], output["input"], [resource["name"] for resource in output["resources"]])
support-desk {'q': 1} ['support-desk', 'triage', 'inbox']
await strategy.arun(input_value, context=None) and
await strategy.invoke_resource(name, input_value, context=None) do the same
from Python.
HTTP surface
| Route | What it does |
|---|---|
GET /health |
{"ok": true, "strategy": "<name>"} |
GET /strategy |
The StrategyInfo: name, description, resources, metadata. |
GET /resources |
Every resource. |
GET /packages, GET /tactics, GET /channels |
Resources of one kind. |
POST /run |
Call the runner. |
POST /resources/{name}/invoke |
Call any resource: run its tactic or handler, or use its store channel. |
GET /channels/{name}/events |
Query a channel resource: ?after_cursor=0&limit=100&kind=.... |
POST /channels/{name}/events |
Append to a channel resource. The input is an object of event fields: payload, kind (default event), source (default lllm.shell), schema, metadata, correlation_id, parent_ids. |
/tactics/{name}/... |
The mounted tactic service: POST run, POST stream, GET info, custom endpoints. |
/network/... |
The store API, when the strategy has a store. |
The shell routes (/run, /resources/..., /channels/...) take a
StrategyRunRequest, {"input": ..., "context": {...}} (the GET channel
route takes query parameters), and return a StrategyRunResponse,
{"output": ..., "strategy": ..., "metadata": {...}}:
response = client.post(
"/resources/inbox/invoke",
json={"input": {"op": "info"}, "context": {"request": "demo"}},
)
print(response.json())
{'output': {'name': 'inbox', 'schema': None, 'form': 'log', 'description': 'Support messages as they arrive.', 'metadata': {}}, 'strategy': 'support-desk', 'metadata': {'resource': 'inbox', 'endpoint': 'resource'}}
A tactic resource runs the same way on /resources/{name}/invoke as on
/tactics/{name}/run: context has the shape of a CallContext
(request_id, trace_id, caller, tags, metadata, and the
other fields; keys that are
not CallContext fields are dropped), the tactic receives a CallContext, and
failures get the same status codes. Only the response differs: the
/tactics/{name}/ routes return the services envelope,
{"output": ..., "request_id": ..., "tactic": ...}. The runner and the
handlers of other resources get context as a plain dict; a Tactic used as
such a handler receives it as CallContext.metadata.
/resources/{name}/invoke with a store-backed channel takes an op:
append, query (the default), or info, plus that op's fields.
The shell routes report errors in the services error envelope, as the tactic routes do:
response = client.get("/channels/triage/events")
print(response.status_code, response.json())
404 {'detail': {'error': {'type': 'ResourceNotFoundError', 'message': 'Strategy channel not found: triage', 'tactic': None, 'endpoint': 'channel', 'request_id': None, 'metadata': {'resource': 'triage'}}}}
| Status | When |
|---|---|
| 400 | A malformed request, an appended event whose input is not an object, or a ValueError, SchemaError, or TacticUnsupportedError from the runner or a handler. |
| 404 | The resource named in the URL does not exist, or a /channels/ route names a resource that is not a channel. |
| Its own | An HTTPException from the runner or a handler keeps its status and its plain detail, without the envelope; a store error keeps the store API's code. |
| 500 | Anything else the runner or a handler raises, including a resource lookup that fails inside it. |
A tactic resource fails with the codes of POST /tactics/{name}/run instead,
on both routes: 400 for SchemaError and TacticUnsupportedError, 500 for
anything else, a ValueError, HTTPException, or store error included. The
store API at /network/ uses the same envelope with only type and message.
Loading strategies
load_strategy(target) accepts:
| Target | Loads |
|---|---|
| a directory | strategy.py, else shell.py, else its lllm.toml as a package |
a .py file |
its build_strategy() factory, else its strategy object |
an lllm.toml file |
the package, as with strategy_from_package |
module:attr |
a Strategy or a no-argument factory that returns one, imported from the working directory |
The CLI uses the same loader.
CLI
lllm shell inspect TARGET [--json]
lllm shell serve TARGET [--host 127.0.0.1] [--port 8400] [--log-level info]
inspect prints the resource table without starting a server; --json prints
the full StrategyInfo. With desk.py holding the strategy from the first
section:
lllm shell inspect desk.py
loud-desk: Shout at things.
tactic shout lllm://demo/loud-desk/tactics/shout
serve builds the app and runs it with uvicorn; launch is an alias:
lllm shell serve desk.py --port 8400
From another terminal:
curl -s -w '\n' -X POST http://127.0.0.1:8400/tactics/shout/run \
-H 'content-type: application/json' -d '{"input": {"text": "hello"}}'
{"output":{"text":"HELLO"},"request_id":"af421ae3-e594-4fc9-90af-2694b1b5d25b","tactic":"shout"}
Inspecting a package neither imports its code nor creates its store folder; serving does both.
Status
Names and routes may change between releases. The strategy app has no authentication and handlers run in the server process, so serve it on localhost or behind your own gateway.
Next
- Tutorial 11: Channels with SSSN ends by
serving a package and a
TacticProcessoras one strategy. - SSSN: the channels and stores a strategy mounts.
- Services: the tactic service each tactic gets.