# ana — out-of-the-box NATS agent-to-agent bus

A small Python package that gives you the boring parts of an A2A
(agent-to-agent) protocol so you can focus on what your agents actually do:

- **Envelope schemas** — typed `Query` / `Reply` / `Ack` / `Discovery`
  pydantic models, one canonical wire format.
- **Subject convention** — `<prefix>.<agent>.<verb>.<topic>`, prefix
  configurable per fleet, parseable both directions.
- **Async client (`AgentBus`)** — thin wrapper on `nats-py` with
  publish auditing, a request/reply helper that dodges the core-NATS
  "no-sub means lost" gotcha, and an `InboundContext` that handlers can
  use to reply / ack without juggling state.
- **CLI (`ana`)** — `listen` / `send` / `probe` so shell scripts and
  operators don't need to import Python.

Built on top of *existing* NATS — bring your own cluster, anonymous or
auth, core or JetStream. ana does not deploy anything.

## Install

```bash
pip install a2o-ana          # PyPI distribution
# or, from source:
pip install -e .[dev]
```

(PyPI distribution is `a2o-ana` because the bare `ana` name was squatted in 2015.
The import name stays `ana` — `from ana import AgentBus`.)

## 5-line bot

```python
import asyncio
from ana import AgentBus

async def main():
    async with AgentBus("my-bot") as bus:
        await bus.query("other-bot", "what's your status?")

asyncio.run(main())
```

## Responding bot

```python
import asyncio
from ana import AgentBus, Query
from ana.client import InboundContext

async def handler(env, ctx: InboundContext):
    if isinstance(env, Query):
        await ctx.reply({"status": "alive", "answer": env.query})

async def main():
    async with AgentBus("my-bot") as bus:
        await bus.subscribe_to_me(handler)
        await bus.announce_discovery(role="worker")
        await asyncio.Event().wait()  # serve forever

asyncio.run(main())
```

## Request/reply with timeout

`query_and_wait` subscribes to the responder's reply subject **before**
publishing the query — so the core-NATS "publish-into-the-void" gotcha
can't bite you here.

```python
reply = await bus.query_and_wait("other-bot", "status?", timeout_s=5.0)
if reply is None:
    print("peer didn't answer in time")
else:
    print(reply.data)
```

## CLI

```bash
# Listen for everything addressed to you
ana --identity miraku-home listen --scope self

# Send a query
ana --identity caller send query --to other-bot --query 'status?' --topic default

# Probe (send + wait for one reply)
ana --identity caller probe other-bot --query 'status?' --timeout 5
```

Pass `--prefix myfleet.v1` to switch namespaces. Pass
`--nats nats://10.0.0.1:4222` to point at a non-localhost server. Pass
`--audit-log /var/log/ana.jsonl` to record every publish.

## Subject convention

Default scheme: `<prefix>.<agent>.<verb>.<topic>` where `<topic>` is
optional. Verbs are `query`, `reply`, `ack`, `discovery`, `policy`.

```
cc.fleet.alice.query.status
cc.fleet.alice.reply.status
cc.fleet.alice.ack.status
cc.fleet.alice.discovery
cc.fleet.alice.policy
```

Pick any prefix — change `SubjectScheme(prefix=...)` and you're done.

## Envelope schemas

All four shapes share `from` (sender identity), `ts` (ISO-8601 UTC), and
`type` (used as the discriminator on parse). See
[`docs/protocol.md`](docs/protocol.md) for the full spec.

```json
{"type": "query",     "from": "alice", "to": "bob", "query": "status?",
 "fields": ["uptime", "version"], "request_id": "ab12", "ts": "..."}

{"type": "reply",     "from": "bob", "reply_for": "cc.fleet.bob.query.status",
 "request_id": "ab12", "data": {"alive": true}, "ts": "..."}

{"type": "ack",       "from": "carol", "ack_for": "cc.fleet.carol.query.status",
 "alive": true, "ts": "..."}

{"type": "discovery", "from": "dave", "role": "worker",
 "subjects_owned": ["cc.fleet.dave.>"], "capabilities": {"gpu": "A100"}, "ts": "..."}
```

## Core NATS gotchas

ana defaults to **core NATS** (no JetStream). Operational implications
worth knowing up front:

- A publish made while the target has no active subscription is **lost
  forever**. There's no replay or store-and-forward.
- `query_and_wait()` subscribes-then-publishes, which dodges this for
  the request/reply pattern.
- For fire-and-forget broadcasts where loss matters, point ana at a
  JetStream-enabled NATS and ensure the target has a durable consumer
  before you publish. ana itself doesn't manage streams; do that with
  `nats stream add`.

See [`docs/protocol.md`](docs/protocol.md) for envelope details and
[`docs/nats-notes.md`](docs/nats-notes.md) for deployment notes.

## Examples

- [`examples/echo_bot.py`](examples/echo_bot.py) — minimal responder.
- [`examples/two_bot_dialogue.py`](examples/two_bot_dialogue.py) — two
  bots in one process exchanging query/reply.
- [`examples/send_query.py`](examples/send_query.py) — one-shot probe.

## Tests

```bash
pip install -e .[dev]
pytest                          # unit tests (no NATS needed)
nats-server &                   # start a local server
ANA_NATS_URL=nats://127.0.0.1:4222 pytest tests/test_bus_integration.py
```

## License

MIT
