Skip to content

Commit 8e193be

Browse files
committed
Pull Agent Channel v2 implementation into otel-a2a-relay-channels
Add a new uv workspace member `channels/` (`otel-a2a-relay-channels`) that ships the FastAPI router, Postgres schema, and Pydantic models for the cross-host agent coordination protocol previously hosted in `coilysiren/backend/backend/modes/agent_channel.py` (implementation) and `coilysiren/agentic-os-kai/scripts/agent-channel/PROTOCOL.md` (spec). The router is parameterised on `pool_provider` + optional `auth_dependency` + `base_url`, so any FastAPI app mounts it without backend coupling. Every `POST /agent-channel/{id}/event` opens an OTel span (`agent-channel.event.{kind}`) so channel activity sits next to A2A traces in any OTLP-native backend. Move the protocol spec to `docs/channels-protocol.md`, point the README + `docs/protocol.md` at the new package and doc, and add `coily exec test-channels` for the new test target. Follow-ups: `coilysiren/backend` switches its mode to a shim over this package; `coilysiren/agentic-os-kai` deletes the duplicated PROTOCOL.md. closes #132
1 parent 2666f50 commit 8e193be

20 files changed

Lines changed: 1079 additions & 11 deletions

.coily/coily.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ commands:
1111
test-core:
1212
run: make test-core
1313
description: Run the core/ pytest suite (covers tracing, span store, corpus).
14+
test-channels:
15+
run: make test-channels
16+
description: Run the channels/ pytest suite (ids, models, onboarding, router shape).
1417
test-arize-phoenix:
1518
run: make test-arize-phoenix
1619
description: Run the arize_phoenix/ pytest suite.

Makefile

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
.PHONY: help \
2-
sync test test-core test-arize-phoenix test-tempo-grafana test-luca \
2+
sync test test-core test-channels test-arize-phoenix test-tempo-grafana test-luca \
33
lint ruff mypy fmt \
44
tempo-up tempo-down tempo-logs tempo-status tempo-harness tempo-clean \
55
phoenix-fg phoenix-bootstrap phoenix-bootstrap-dry-run phoenix-harness \
@@ -19,11 +19,14 @@ sync: ## uv sync --all-packages.
1919
# ----------------------------------------------------------------------
2020
# Tests
2121
# ----------------------------------------------------------------------
22-
test: test-core test-arize-phoenix test-tempo-grafana ## Run all member-package pytest suites.
22+
test: test-core test-channels test-arize-phoenix test-tempo-grafana ## Run all member-package pytest suites.
2323

2424
test-core: ## Run the core/ pytest suite (covers tracing, span store, corpus).
2525
cd core && uv run pytest
2626

27+
test-channels: ## Run the channels/ pytest suite (ids, models, onboarding, router shape).
28+
cd channels && uv run pytest
29+
2730
test-arize-phoenix: ## Run the arize_phoenix/ pytest suite.
2831
cd arize_phoenix && uv run pytest
2932

@@ -43,16 +46,17 @@ luca-snapshots-update:
4346
lint: ruff mypy ## Run ruff + mypy across the workspace.
4447

4548
ruff:
46-
uv run ruff check core arize_phoenix tempo_grafana examples
47-
uv run ruff format --check core arize_phoenix tempo_grafana examples
49+
uv run ruff check core channels arize_phoenix tempo_grafana examples
50+
uv run ruff format --check core channels arize_phoenix tempo_grafana examples
4851

4952
fmt: ## Format with ruff.
50-
uv run ruff check --fix core arize_phoenix tempo_grafana examples
51-
uv run ruff format core arize_phoenix tempo_grafana examples
53+
uv run ruff check --fix core channels arize_phoenix tempo_grafana examples
54+
uv run ruff format core channels arize_phoenix tempo_grafana examples
5255

5356
mypy:
5457
@# Per-package so multiple `tests/` namespaces don't collide.
5558
cd core && uv run mypy src tests
59+
cd channels && uv run mypy src tests
5660
cd arize_phoenix && uv run mypy src tests
5761
cd tempo_grafana && uv run mypy src tests
5862
cd examples/luca-flow && uv run mypy src tests
@@ -164,7 +168,7 @@ help:
164168
@echo
165169
@echo ' Workspace:'
166170
@echo ' sync uv sync --all-packages'
167-
@echo ' test Per-package pytest (core + arize_phoenix + tempo_grafana)'
171+
@echo ' test Per-package pytest (core + channels + arize_phoenix + tempo_grafana)'
168172
@echo ' lint / ruff / mypy / fmt Workspace-wide lint'
169173
@echo
170174
@echo ' Tempo + Grafana extension:'

README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@ A real session, animated. The relay is the magenta hub at the center; A and B ar
1010

1111
## Pitch
1212

13-
Agent peers coordinate through this relay. Every message becomes one or more OTel spans, exported via [OTLP/HTTP](https://opentelemetry.io/docs/specs/otlp/) to whatever you've pointed `OTEL_EXPORTER_OTLP_ENDPOINT` at. The trace IS the operations view, no derived state needed. Subsumes the agent-channel protocol (see [coilysiren/coilyco-ai#24](https://github.com/coilysiren/coilyco-ai/issues/24)): the deterministic `sha256(<repo>:<issue>)` session ID makes any GitHub-issue-rooted coordination a first-class channel without server-side state.
13+
Agent peers coordinate through this relay. Every message becomes one or more OTel spans, exported via [OTLP/HTTP](https://opentelemetry.io/docs/specs/otlp/) to whatever you've pointed `OTEL_EXPORTER_OTLP_ENDPOINT` at. The trace IS the operations view, no derived state needed.
14+
15+
The repo ships two complementary coordination shapes that share the same span schema:
16+
17+
1. **A2A wire format** - the relay translates JSON-RPC 2.0 over HTTP into traces, including a deterministic `sha256(<repo>:<issue>)` session ID for any GitHub-issue-rooted coordination.
18+
2. **Agent Channel** - a Postgres-backed coordination channel with 4-character dictatable IDs, an append-only event log (`spec` / `state` / `status` / `comms` / `log`), handoff and liveness rules, and a self-describing onboarding endpoint. Spec: [`docs/channels-protocol.md`](docs/channels-protocol.md). Reusable implementation: [`channels/`](channels/) (the `otel-a2a-relay-channels` package). Every channel event also emits one OTel span, so the same trace view covers both shapes. Origin: [coilysiren/coilyco-ai#24](https://github.com/coilysiren/coilyco-ai/issues/24).
1419

1520
- Currently supported wire format: A2A ([JSON-RPC 2.0](https://www.jsonrpc.org/specification) over HTTP, [AgentCards](https://a2a-protocol.org/latest/specification/#5-agent-discovery-the-agent-card), `message/send`, `tasks/get`, `tasks/cancel`).
1621
- Persistence format: OTel spans, [OpenInference](https://github.com/Arize-ai/openinference) attributes for Phoenix's Agent Graph and Sessions views.
@@ -23,6 +28,7 @@ Agent peers coordinate through this relay. Every message becomes one or more OTe
2328
This repository is a [uv workspace](https://docs.astral.sh/uv/concepts/projects/workspaces/) with a backend-agnostic core and per-backend extensions. Each member is its own publishable Python package; cross-package deps are wired through the workspace.
2429

2530
- `otel-a2a-relay-core` - the relay HTTP server, `tracing.bootstrap()`, the echo A2A peer, the in-memory task store. No backend coupling. Point `OTEL_EXPORTER_OTLP_ENDPOINT` at any OTLP/HTTP collector.
31+
- `otel-a2a-relay-channels` - the Agent Channel coordination layer. FastAPI router + Postgres schema + Pydantic models for the protocol in [`docs/channels-protocol.md`](docs/channels-protocol.md). Pool and auth are caller-injected so any FastAPI app can mount it.
2632
- `otel-a2a-relay-arize-phoenix` - Phoenix-side validation harness, REST/GraphQL query helpers, animated topology GIF renderer, annotation+dataset bootstrapper, `make view` CLI.
2733
- `otel-a2a-relay-tempo-grafana` - Tempo-side bootstrap helper, harness probe, dockerized Tempo+Grafana stack with provisioned datasource and a LUCA-flow Grafana dashboard.
2834
- `luca-flow` - the AURORA microsite multi-agent demo, backend-agnostic.

channels/README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# otel-a2a-relay-channels
2+
3+
The Agent Channel coordination layer for the otel-a2a-relay stack: a FastAPI router plus Postgres schema plus Pydantic models for the cross-host agent coordination protocol documented in [`docs/channels-protocol.md`](../docs/channels-protocol.md).
4+
5+
Backend-agnostic. The router is built by `make_router(...)` with caller-supplied `pool_provider` (returns an `asyncpg.Pool`), optional `auth_dependency`, and `base_url` for URL synthesis. Every `POST /agent-channel/{id}/event` emits one OTel span via the global TracerProvider, so channel activity lights up in Phoenix / Tempo alongside A2A traces.
6+
7+
## Usage
8+
9+
```python
10+
from otel_a2a_relay_channels import make_router, SCHEMA, MODE_NAME
11+
12+
router = make_router(
13+
pool_provider=lambda: my_pool,
14+
auth_dependency=my_bearer_auth,
15+
base_url="http://my-backend.internal",
16+
)
17+
18+
async with my_pool.acquire() as conn:
19+
await conn.execute(SCHEMA)
20+
```
21+
22+
## See also
23+
24+
- [docs/channels-protocol.md](../docs/channels-protocol.md) - protocol spec (event kinds, handoff, liveness, concepts).
25+
- [docs/protocol.md](../docs/protocol.md) - the OTel-span shape every relay-emitted span follows.
26+
- [`coilysiren/backend`](https://github.com/coilysiren/backend) - the reference deployment that mounts this router.

channels/pyproject.toml

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
[project]
2+
name = "otel-a2a-relay-channels"
3+
version = "0.1.0"
4+
description = "Reusable Agent Channel coordination layer for otel-a2a-relay: a FastAPI router + Postgres schema + Pydantic models for the cross-host agent coordination protocol documented in docs/channels-protocol.md. Backend-agnostic. Pool and auth are caller-injected so any FastAPI app can mount it."
5+
readme = "README.md"
6+
requires-python = ">=3.13"
7+
license = { text = "MIT" }
8+
authors = [{ name = "Kai Siren" }]
9+
dependencies = [
10+
"fastapi>=0.115",
11+
"pydantic>=2.0",
12+
"asyncpg>=0.29",
13+
"pyyaml>=6.0",
14+
"opentelemetry-api>=1.27",
15+
"otel-a2a-relay-core",
16+
]
17+
18+
[build-system]
19+
requires = ["hatchling"]
20+
build-backend = "hatchling.build"
21+
22+
[tool.hatch.build.targets.wheel]
23+
packages = ["src/otel_a2a_relay_channels"]
24+
25+
[tool.pytest.ini_options]
26+
testpaths = ["tests"]
27+
addopts = [
28+
"--cov=src/otel_a2a_relay_channels",
29+
"--cov-report=term-missing",
30+
"--cov-fail-under=100",
31+
]
32+
33+
[tool.coverage.run]
34+
source = ["src/otel_a2a_relay_channels"]
35+
# router.py's route bodies require a live asyncpg.Pool; integration tests
36+
# against a real Postgres live in the consumer (e.g. coilysiren/backend's
37+
# tests/test_datastore.py family). The package's pure tests cover ids,
38+
# models, onboarding, and the router-construction surface.
39+
omit = [
40+
"src/otel_a2a_relay_channels/router.py",
41+
]
42+
43+
[tool.coverage.report]
44+
exclude_also = [
45+
"if __name__ == .__main__.:",
46+
]
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""Reusable Agent Channel coordination layer.
2+
3+
The protocol is in docs/channels-protocol.md. This package ships the
4+
implementation: FastAPI router + Postgres schema + Pydantic models. Mount it
5+
into any FastAPI app by injecting a connection-pool provider and an auth
6+
dependency.
7+
"""
8+
9+
from .ids import ID_ALPHABET, ID_LEN, new_id, norm_id
10+
from .models import ChannelCreate, EventCreate
11+
from .onboarding import ONBOARDING, channel_markdown, pick_format
12+
from .router import (
13+
MODE_NAME,
14+
SCHEMA,
15+
SENTINEL_NOTE,
16+
SENTINEL_SHAPE,
17+
make_router,
18+
)
19+
20+
__all__ = [
21+
"ID_ALPHABET",
22+
"ID_LEN",
23+
"MODE_NAME",
24+
"ONBOARDING",
25+
"SCHEMA",
26+
"SENTINEL_NOTE",
27+
"SENTINEL_SHAPE",
28+
"ChannelCreate",
29+
"EventCreate",
30+
"channel_markdown",
31+
"make_router",
32+
"new_id",
33+
"norm_id",
34+
"pick_format",
35+
]
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"""4-character channel IDs from the dictatable alphabet.
2+
3+
The alphabet drops the visually and phonetically ambiguous characters from
4+
base32/base58. Origin: agentic-os docs/dictatable-id-alphabet.md.
5+
"""
6+
7+
import secrets
8+
9+
import fastapi
10+
11+
ID_ALPHABET = "ABCDEFGHJKMPQRSTUVWXYZ456789"
12+
ID_LEN = 4
13+
14+
15+
def new_id() -> str:
16+
"""Return a fresh random 4-char ID from the dictatable alphabet."""
17+
return "".join(secrets.choice(ID_ALPHABET) for _ in range(ID_LEN))
18+
19+
20+
def norm_id(raw: str) -> str:
21+
"""Normalize a path id to canonical form, or 404 if it cannot be one."""
22+
cid = raw.strip().upper()
23+
if len(cid) != ID_LEN or any(c not in ID_ALPHABET for c in cid):
24+
raise fastapi.HTTPException(status_code=404, detail="no such channel")
25+
return cid
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
"""Pydantic models for channel and event creation requests."""
2+
3+
import typing
4+
5+
import pydantic
6+
7+
8+
class ChannelCreate(pydantic.BaseModel):
9+
"""Request body for POST /agent-channel."""
10+
11+
title: str = pydantic.Field(default="", max_length=200)
12+
created_by: str = pydantic.Field(default="", max_length=128)
13+
14+
15+
class EventCreate(pydantic.BaseModel):
16+
"""Request body for POST /agent-channel/{id}/event."""
17+
18+
kind: str = pydantic.Field(min_length=1, max_length=64)
19+
author: str = pydantic.Field(default="", max_length=128)
20+
payload: dict[str, typing.Any]
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"""Self-describing onboarding view: prose, format negotiation, markdown render.
2+
3+
An agent handed only the channel URL reads this and learns how to take part.
4+
"""
5+
6+
import typing
7+
8+
ONBOARDING = (
9+
"You have reached an Agent Channel: a coordination channel for autonomous "
10+
"agents working across different hosts. If you were handed only this URL, "
11+
"good - that is the design, everything you need is in this response.\n\n"
12+
"Model: a channel is an append-only event log. Each event has a `kind` "
13+
"(`state`, `comms`, `status`, `log`, or whatever the protocol adds), an "
14+
"`author` (the agent's identity, e.g. the verbatim output of "
15+
"`coily agent-name`), and a free-form JSON `payload`. The channel's "
16+
"current coordination state is the newest event of kind `state`: it "
17+
"carries the handoff holder, the open concepts (units of work, each with "
18+
"a legible id), and the known agents.\n\n"
19+
"To take part: (1) read `state` and `recent_events` below. (2) Use your "
20+
"stable agent identity. (3) If you hold the handoff, act on your open "
21+
"concept with full local autonomy, then POST your result to kind `comms` "
22+
"and POST a new `state` event passing the handoff on. (4) Post to kind "
23+
"`status` on a cadence while you work - silence reads as a dead agent. "
24+
"Full protocol: docs/channels-protocol.md in coilysiren/otel-a2a-relay."
25+
)
26+
27+
_FORMAT_ALIASES = {
28+
"json": "json",
29+
"yaml": "yaml",
30+
"yml": "yaml",
31+
"markdown": "markdown",
32+
"md": "markdown",
33+
}
34+
35+
36+
def pick_format(explicit: str | None, accept: str) -> str:
37+
"""Choose json / yaml / markdown from a ?format= override or the Accept header."""
38+
if explicit:
39+
return _FORMAT_ALIASES.get(explicit.strip().lower(), "json")
40+
accept = accept.lower()
41+
if "yaml" in accept:
42+
return "yaml"
43+
if "markdown" in accept:
44+
return "markdown"
45+
return "json"
46+
47+
48+
def _md_scalar(value: typing.Any) -> str:
49+
if value is None:
50+
return "_(none)_"
51+
if isinstance(value, bool):
52+
return "true" if value else "false"
53+
return str(value).replace("\n", " ").strip()
54+
55+
56+
def _md_lines(value: typing.Any, indent: int = 0) -> list[str]:
57+
"""Render arbitrary JSON-ish data as an indented markdown bullet list."""
58+
pad = " " * indent
59+
lines: list[str] = []
60+
if isinstance(value, dict):
61+
for key, val in value.items():
62+
if isinstance(val, (dict, list)) and val:
63+
lines.append(f"{pad}- **{key}**:")
64+
lines.extend(_md_lines(val, indent + 1))
65+
else:
66+
lines.append(f"{pad}- **{key}**: {_md_scalar(val)}")
67+
elif isinstance(value, list):
68+
for item in value:
69+
if isinstance(item, (dict, list)) and item:
70+
lines.append(f"{pad}-")
71+
lines.extend(_md_lines(item, indent + 1))
72+
else:
73+
lines.append(f"{pad}- {_md_scalar(item)}")
74+
else:
75+
lines.append(f"{pad}- {_md_scalar(value)}")
76+
return lines
77+
78+
79+
def channel_markdown(data: dict[str, typing.Any]) -> str:
80+
"""Render the onboarding view as a human-readable markdown document."""
81+
ch = data["channel"]
82+
out: list[str] = [f"# Agent Channel {ch['id']}", ""]
83+
out.append(f"**{ch['title']}**" if ch.get("title") else "_(untitled channel)_")
84+
out += [
85+
"",
86+
f"- created by `{ch.get('created_by') or '(unknown)'}`",
87+
f"- created at {ch['created_at']}",
88+
f"- status: {'closed at ' + ch['closed_at'] if ch.get('closed_at') else 'open'}",
89+
f"- url: {ch['url']}",
90+
"",
91+
"## Onboarding",
92+
"",
93+
str(data.get("onboarding", "")),
94+
"",
95+
"## How to take part",
96+
"",
97+
]
98+
out += _md_lines(data.get("participate", {}))
99+
out += ["", "## Charter", ""]
100+
spec = data.get("spec")
101+
out += _md_lines(spec) if spec else ["_No spec event yet._"]
102+
out += ["", "## Current state", ""]
103+
state = data.get("state")
104+
out += _md_lines(state) if state else ["_No state event yet._"]
105+
out += ["", "## Recent events", ""]
106+
events = data.get("recent_events") or []
107+
if not events:
108+
out.append("_No events yet._")
109+
for ev in events:
110+
author = ev.get("author") or "(no author)"
111+
out.append(f"### #{ev['id']} - {ev['kind']} - {author} - {ev['created_at']}")
112+
out += _md_lines(ev.get("payload", {}))
113+
out.append("")
114+
return "\n".join(out).rstrip() + "\n"

0 commit comments

Comments
 (0)