Skip to content

Commit c4b5d6d

Browse files
committed
scripts: emit JSON Schema + OTel semconv from docs/protocol.md
Add an attribute registry block to docs/protocol.md and a generator that parses it into docs/generated/o2r-attributes.schema.json (JSON Schema) and docs/generated/o2r-semconv.yaml (OTel semantic-conventions shape). Doc is the source of truth; the generated files are committed for downstream tools that want a machine artifact. Routed through coily as `coily exec protocol-artifacts` with a --check mode for CI. resolves #19
1 parent 3fbe35f commit c4b5d6d

6 files changed

Lines changed: 257 additions & 0 deletions

File tree

.coily/coily.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@ commands:
4444
gif-fixture-update-ci-replay:
4545
run: make gif-fixture-update-ci-replay
4646
description: Local docker replay of the regen-gif-baseline workflow's regen step.
47+
protocol-artifacts:
48+
run: make protocol-artifacts
49+
description: Emit JSON Schema + OTel semconv from docs/protocol.md's attribute registry.
50+
protocol-artifacts-check:
51+
run: make protocol-artifacts-check
52+
description: Exit non-zero if docs/generated/ is stale vs docs/protocol.md.
4753

4854
# Catalog metadata for the cross-repo knowledge graph.
4955
# Schema: coilysiren/coilyco-ai#420 (tracker).

.pre-commit-config.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ repos:
1414
- opentelemetry-api>=1.27
1515
- opentelemetry-sdk>=1.27
1616
- opentelemetry-exporter-otlp-proto-http>=1.27
17+
- types-pyyaml>=6.0
1718

1819
- repo: local
1920
hooks:

Makefile

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
luca-demo luca-test luca-snapshots-update \
77
gif-fixture-update gif-fixture-update-ci-replay \
88
protocol-decisions protocol-decisions-check \
9+
protocol-artifacts protocol-artifacts-check \
910
status
1011

1112
# ----------------------------------------------------------------------
@@ -126,6 +127,13 @@ protocol-decisions:
126127
protocol-decisions-check:
127128
uv run python scripts/protocol_decision_log.py --check
128129

130+
# Emit JSON Schema + OTel semconv from docs/protocol.md's attribute registry.
131+
protocol-artifacts:
132+
uv run python scripts/emit_protocol_artifacts.py
133+
134+
protocol-artifacts-check:
135+
uv run python scripts/emit_protocol_artifacts.py --check
136+
129137
# ----------------------------------------------------------------------
130138
# Help
131139
# ----------------------------------------------------------------------

docs/protocol.md

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,97 @@ What v0.3 changed:
213213

214214
If a future Phoenix release changes any of these, the spec backs up again.
215215

216+
## Attribute registry
217+
218+
The canonical set of span attributes this protocol uses. This YAML block is parsed by `scripts/emit_protocol_artifacts.py`, which writes `docs/generated/o2r-attributes.schema.json` (JSON Schema) and `docs/generated/o2r-semconv.yaml` (OTel semantic-conventions shape). Doc is the source of truth; the generated files are committed for downstream tools that want a machine artifact.
219+
220+
```yaml
221+
# o2r-attributes
222+
attributes:
223+
- id: agent.id
224+
type: string
225+
requirement: required
226+
brief: Stable per-agent identifier inside the deployment.
227+
- id: agent.name
228+
type: string
229+
requirement: required
230+
brief: Human-readable agent name.
231+
- id: agent.role
232+
type: string
233+
requirement: required
234+
brief: Broad role in the topology.
235+
enum: [relay, orchestrator, planner, validator, worker, deployer]
236+
- id: agent.specialization
237+
type: string
238+
requirement: optional
239+
brief: Narrower per-agent specialization. Consumer-defined.
240+
- id: session.id
241+
type: string
242+
requirement: required
243+
brief: Deterministic session identifier. sha256(repo:issue)[:16] for GitHub-rooted channels.
244+
- id: user.id
245+
type: string
246+
requirement: recommended
247+
brief: Sender identity. Propagated via OpenInference using_user().
248+
- id: graph.node.id
249+
type: string
250+
requirement: required
251+
brief: Topology node identifier for cross-trace graph rendering.
252+
- id: graph.node.parent_id
253+
type: string
254+
requirement: recommended
255+
brief: Parent node id in the topology graph.
256+
- id: o2r.task.id
257+
type: string
258+
requirement: required
259+
brief: Task identifier within the session.
260+
- id: o2r.task.state
261+
type: string
262+
requirement: required
263+
brief: Current task state.
264+
- id: o2r.task.state_change
265+
type: string
266+
requirement: optional
267+
brief: State transition recorded as a span event attribute.
268+
- id: o2r.message.text
269+
type: string
270+
requirement: optional
271+
brief: Content of the originating message.
272+
- id: o2r.message.reply_text
273+
type: string
274+
requirement: optional
275+
brief: Content of the completion reply.
276+
- id: o2r.peer.target
277+
type: string
278+
requirement: required
279+
brief: Target peer id for a relay forward.
280+
- id: o2r.peer.sender_role
281+
type: string
282+
requirement: required
283+
brief: Registered role of the sending peer.
284+
- id: o2r.peer.target_role
285+
type: string
286+
requirement: required
287+
brief: Registered role of the target peer.
288+
- id: o2r.relay.mode
289+
type: string
290+
requirement: optional
291+
brief: Relay routing mode in effect for this span.
292+
- id: o2r.relay.reject_reason
293+
type: string
294+
requirement: optional
295+
brief: Reason a relay rejected a message.
296+
- id: o2r.relay.failure_class
297+
type: string
298+
requirement: required
299+
brief: Coarse failure class on any erroring relay span.
300+
enum: [topology_violation, peer_disconnect, peer_404, timeout, peer_jsonrpc_error, unknown]
301+
- id: o2r.method
302+
type: string
303+
requirement: required
304+
brief: A2A JSON-RPC method name driving this span.
305+
```
306+
216307
## Operator surface
217308
218309
`coily channel <verb>` lives in the `coily` repo and talks A2A to a relay. Verbs: `send`, `stream`, `tasks-get`, `tasks-cancel`, `view`. Inherits the audit + gate wrapper pattern. The relay itself ships only a `serve` mode.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ dev = [
2828
"pytest>=8.0",
2929
"pytest-cov>=5.0",
3030
"ruff>=0.15",
31+
"pyyaml>=6.0",
3132
"types-pyyaml>=6.0",
3233
"httpx>=0.28",
3334
# Pillow drives the `arize_phoenix.viz` GIF renderer + its tests.

scripts/emit_protocol_artifacts.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
#!/usr/bin/env python3
2+
"""Emit machine artifacts from docs/protocol.md.
3+
4+
The protocol doc carries a fenced ```yaml block tagged `# o2r-attributes`
5+
that lists the canonical span attributes. This script parses that block
6+
and writes:
7+
8+
- docs/generated/o2r-attributes.schema.json - JSON Schema describing
9+
the attribute set. Useful as a contract for span-validating tooling.
10+
- docs/generated/o2r-semconv.yaml - OTel-semantic-conventions-shaped
11+
YAML so downstream OTel tooling can consume the same names.
12+
13+
Doc is the source of truth. Run after editing the attribute block.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import argparse
19+
import json
20+
import pathlib
21+
import re
22+
import sys
23+
24+
import yaml
25+
26+
DOC = pathlib.Path("docs/protocol.md")
27+
OUT_DIR = pathlib.Path("docs/generated")
28+
SCHEMA_OUT = OUT_DIR / "o2r-attributes.schema.json"
29+
SEMCONV_OUT = OUT_DIR / "o2r-semconv.yaml"
30+
31+
REQUIREMENT_VALUES = {"required", "recommended", "optional"}
32+
33+
34+
def extract_attributes(doc_text: str) -> list[dict[str, object]]:
35+
"""Find the fenced yaml block tagged `# o2r-attributes` and parse it."""
36+
pattern = re.compile(
37+
r"```yaml\s*\n#\s*o2r-attributes\s*\n(.*?)\n```",
38+
re.DOTALL,
39+
)
40+
m = pattern.search(doc_text)
41+
if not m:
42+
raise SystemExit("docs/protocol.md missing the o2r-attributes yaml block")
43+
parsed = yaml.safe_load(m.group(1))
44+
attrs = parsed.get("attributes")
45+
if not isinstance(attrs, list):
46+
raise SystemExit("o2r-attributes block must contain a list under `attributes`")
47+
return attrs
48+
49+
50+
def validate(attrs: list[dict[str, object]]) -> None:
51+
for a in attrs:
52+
for key in ("id", "type", "requirement", "brief"):
53+
if key not in a:
54+
raise SystemExit(f"attribute {a!r} missing required key {key}")
55+
if a["requirement"] not in REQUIREMENT_VALUES:
56+
raise SystemExit(
57+
f"attribute {a['id']} has invalid requirement {a['requirement']!r}; "
58+
f"expected one of {sorted(REQUIREMENT_VALUES)}"
59+
)
60+
61+
62+
def render_schema(attrs: list[dict[str, object]]) -> dict[str, object]:
63+
properties = {}
64+
required = []
65+
for a in attrs:
66+
prop = {"type": a["type"], "description": a["brief"]}
67+
if "enum" in a:
68+
prop["enum"] = a["enum"]
69+
properties[a["id"]] = prop
70+
if a["requirement"] == "required":
71+
required.append(a["id"])
72+
return {
73+
"$schema": "https://json-schema.org/draft/2020-12/schema",
74+
"$id": "https://coilysiren.me/otel-a2a-relay/o2r-attributes.schema.json",
75+
"title": "o2r span attribute registry",
76+
"description": "Generated from docs/protocol.md. Do not hand-edit.",
77+
"type": "object",
78+
"properties": properties,
79+
"required": required,
80+
"additionalProperties": True,
81+
}
82+
83+
84+
def render_semconv(attrs: list[dict[str, object]]) -> dict[str, object]:
85+
return {
86+
"groups": [
87+
{
88+
"id": "registry.o2r",
89+
"type": "attribute_group",
90+
"brief": "o2r span attribute registry. Generated from docs/protocol.md.",
91+
"attributes": [
92+
{
93+
"id": a["id"],
94+
"type": a["type"],
95+
"requirement_level": a["requirement"],
96+
"brief": a["brief"],
97+
**({"members": a["enum"]} if "enum" in a else {}),
98+
}
99+
for a in attrs
100+
],
101+
}
102+
]
103+
}
104+
105+
106+
def main() -> int:
107+
p = argparse.ArgumentParser(description=__doc__)
108+
p.add_argument("--repo-root", default=".", help="repo root (default: cwd)")
109+
p.add_argument("--check", action="store_true", help="exit 1 if outputs would change")
110+
args = p.parse_args()
111+
112+
root = pathlib.Path(args.repo_root).resolve()
113+
doc_path = root / DOC
114+
if not doc_path.exists():
115+
print(f"missing {doc_path}", file=sys.stderr)
116+
return 2
117+
118+
attrs = extract_attributes(doc_path.read_text())
119+
validate(attrs)
120+
121+
schema = render_schema(attrs)
122+
semconv = render_semconv(attrs)
123+
124+
schema_str = json.dumps(schema, indent=2, sort_keys=False) + "\n"
125+
semconv_str = yaml.safe_dump(semconv, sort_keys=False)
126+
127+
schema_path = root / SCHEMA_OUT
128+
semconv_path = root / SEMCONV_OUT
129+
130+
if args.check:
131+
existing_schema = schema_path.read_text() if schema_path.exists() else ""
132+
existing_semconv = semconv_path.read_text() if semconv_path.exists() else ""
133+
if existing_schema != schema_str or existing_semconv != semconv_str:
134+
print(
135+
"generated protocol artifacts are stale - regenerate with"
136+
" `make protocol-artifacts`",
137+
file=sys.stderr,
138+
)
139+
return 1
140+
return 0
141+
142+
(root / OUT_DIR).mkdir(parents=True, exist_ok=True)
143+
schema_path.write_text(schema_str)
144+
semconv_path.write_text(semconv_str)
145+
print(f"wrote {SCHEMA_OUT} and {SEMCONV_OUT} ({len(attrs)} attributes)")
146+
return 0
147+
148+
149+
if __name__ == "__main__":
150+
raise SystemExit(main())

0 commit comments

Comments
 (0)