open-agent-protocol 0.2.2__tar.gz → 0.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. {open_agent_protocol-0.2.2 → open_agent_protocol-0.2.3}/PKG-INFO +28 -3
  2. {open_agent_protocol-0.2.2 → open_agent_protocol-0.2.3}/README.md +27 -2
  3. {open_agent_protocol-0.2.2 → open_agent_protocol-0.2.3}/oap/cli.py +107 -16
  4. {open_agent_protocol-0.2.2 → open_agent_protocol-0.2.3}/oap/registry.py +8 -1
  5. open_agent_protocol-0.2.3/oap-demo/research_agent.py +47 -0
  6. open_agent_protocol-0.2.3/oap-demo/translator_agent.py +47 -0
  7. open_agent_protocol-0.2.3/oap-demo/writer_agent.py +47 -0
  8. {open_agent_protocol-0.2.2 → open_agent_protocol-0.2.3}/pyproject.toml +1 -1
  9. open_agent_protocol-0.2.3/tests/test_register.py +229 -0
  10. {open_agent_protocol-0.2.2 → open_agent_protocol-0.2.3}/tests/test_registry.py +1 -0
  11. {open_agent_protocol-0.2.2 → open_agent_protocol-0.2.3}/.gitignore +0 -0
  12. {open_agent_protocol-0.2.2 → open_agent_protocol-0.2.3}/LICENSE +0 -0
  13. {open_agent_protocol-0.2.2 → open_agent_protocol-0.2.3}/oap/__init__.py +0 -0
  14. {open_agent_protocol-0.2.2 → open_agent_protocol-0.2.3}/oap/adapters/__init__.py +0 -0
  15. {open_agent_protocol-0.2.2 → open_agent_protocol-0.2.3}/oap/adapters/base.py +0 -0
  16. {open_agent_protocol-0.2.2 → open_agent_protocol-0.2.3}/oap/adapters/http.py +0 -0
  17. {open_agent_protocol-0.2.2 → open_agent_protocol-0.2.3}/oap/adapters/mock.py +0 -0
  18. {open_agent_protocol-0.2.2 → open_agent_protocol-0.2.3}/oap/envelope.py +0 -0
  19. {open_agent_protocol-0.2.2 → open_agent_protocol-0.2.3}/oap/router.py +0 -0
  20. {open_agent_protocol-0.2.2 → open_agent_protocol-0.2.3}/oap/transport/__init__.py +0 -0
  21. {open_agent_protocol-0.2.2 → open_agent_protocol-0.2.3}/oap/transport/http.py +0 -0
  22. {open_agent_protocol-0.2.2 → open_agent_protocol-0.2.3}/py.typed +0 -0
  23. {open_agent_protocol-0.2.2 → open_agent_protocol-0.2.3}/tests/__init__.py +0 -0
  24. {open_agent_protocol-0.2.2 → open_agent_protocol-0.2.3}/tests/fake_agent_server.py +0 -0
  25. {open_agent_protocol-0.2.2 → open_agent_protocol-0.2.3}/tests/test_chain.py +0 -0
  26. {open_agent_protocol-0.2.2 → open_agent_protocol-0.2.3}/tests/test_cli.py +0 -0
  27. {open_agent_protocol-0.2.2 → open_agent_protocol-0.2.3}/tests/test_envelope.py +0 -0
  28. {open_agent_protocol-0.2.2 → open_agent_protocol-0.2.3}/tests/test_http_adapter.py +0 -0
  29. {open_agent_protocol-0.2.2 → open_agent_protocol-0.2.3}/tests/test_router.py +0 -0
  30. {open_agent_protocol-0.2.2 → open_agent_protocol-0.2.3}/tests/test_transport.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: open-agent-protocol
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Open Agent Protocol — a routing layer for inter-agent task handoff
5
5
  Project-URL: Repository, https://github.com/Adam-Abinsha-vahab-Baker/OAP
6
6
  Project-URL: Issues, https://github.com/Adam-Abinsha-vahab-Baker/OAP/issues
@@ -64,7 +64,9 @@ print(result.memory["last_result"])
64
64
  # Create a new task envelope
65
65
  oap init "research the best vector databases" --output task.json
66
66
 
67
- # Register an HTTP agent in the local registry
67
+ # Register an HTTP agent OAP discovers capabilities automatically from GET /
68
+ oap register research-agent http://localhost:9000
69
+ # Falls back to manual capabilities if the agent has no GET / endpoint
68
70
  oap register research-agent http://localhost:9000 --capabilities "research,search,find"
69
71
 
70
72
  # Route the envelope to the best matching agent
@@ -109,12 +111,35 @@ The chain stops when:
109
111
 
110
112
  Agents are stored in `~/.oap/agents.json` and persist across commands.
111
113
 
114
+ When an agent implements `GET /` returning `{agent_id, capabilities, description}`, registration is automatic:
115
+
112
116
  ```bash
117
+ oap register my-agent http://localhost:9000
118
+ # → OAP hits GET /, reads capabilities and description, saves everything
119
+
113
120
  oap register my-agent http://localhost:9000 --capabilities "research,find"
114
- oap agents # list all registered agents
121
+ # fallback: use provided capabilities if GET / is unavailable
122
+
123
+ oap agents # list all registered agents with description column
124
+ oap ping # health-check all agents, auto-updates capabilities if changed
115
125
  oap unregister my-agent
116
126
  ```
117
127
 
128
+ ### Agent health endpoint
129
+
130
+ Add `GET /` to your agent to enable self-registration and health checks:
131
+
132
+ ```python
133
+ @app.get("/")
134
+ async def info():
135
+ return {
136
+ "agent_id": "my-agent",
137
+ "capabilities": ["research", "find", "search"],
138
+ "description": "Researches topics and returns structured findings.",
139
+ "status": "ok",
140
+ }
141
+ ```
142
+
118
143
  ## Concepts
119
144
 
120
145
  - **TaskEnvelope** — the standard task object passed between agents. Contains the goal, memory, steps taken, and optional constraints.
@@ -34,7 +34,9 @@ print(result.memory["last_result"])
34
34
  # Create a new task envelope
35
35
  oap init "research the best vector databases" --output task.json
36
36
 
37
- # Register an HTTP agent in the local registry
37
+ # Register an HTTP agent OAP discovers capabilities automatically from GET /
38
+ oap register research-agent http://localhost:9000
39
+ # Falls back to manual capabilities if the agent has no GET / endpoint
38
40
  oap register research-agent http://localhost:9000 --capabilities "research,search,find"
39
41
 
40
42
  # Route the envelope to the best matching agent
@@ -79,12 +81,35 @@ The chain stops when:
79
81
 
80
82
  Agents are stored in `~/.oap/agents.json` and persist across commands.
81
83
 
84
+ When an agent implements `GET /` returning `{agent_id, capabilities, description}`, registration is automatic:
85
+
82
86
  ```bash
87
+ oap register my-agent http://localhost:9000
88
+ # → OAP hits GET /, reads capabilities and description, saves everything
89
+
83
90
  oap register my-agent http://localhost:9000 --capabilities "research,find"
84
- oap agents # list all registered agents
91
+ # fallback: use provided capabilities if GET / is unavailable
92
+
93
+ oap agents # list all registered agents with description column
94
+ oap ping # health-check all agents, auto-updates capabilities if changed
85
95
  oap unregister my-agent
86
96
  ```
87
97
 
98
+ ### Agent health endpoint
99
+
100
+ Add `GET /` to your agent to enable self-registration and health checks:
101
+
102
+ ```python
103
+ @app.get("/")
104
+ async def info():
105
+ return {
106
+ "agent_id": "my-agent",
107
+ "capabilities": ["research", "find", "search"],
108
+ "description": "Researches topics and returns structured findings.",
109
+ "status": "ok",
110
+ }
111
+ ```
112
+
88
113
  ## Concepts
89
114
 
90
115
  - **TaskEnvelope** — the standard task object passed between agents. Contains the goal, memory, steps taken, and optional constraints.
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  import asyncio
2
4
  import time
3
5
  from pathlib import Path
@@ -22,6 +24,29 @@ app = typer.Typer(
22
24
  console = Console()
23
25
 
24
26
 
27
+ # ---------------------------------------------------------------------------
28
+ # Helpers
29
+ # ---------------------------------------------------------------------------
30
+
31
+ async def _fetch_agent_info(url: str, timeout: float = 5.0) -> dict | None:
32
+ """Hit GET / on an agent URL. Returns parsed JSON or None on failure."""
33
+ transport = HTTPTransport(base_url=url, timeout=timeout)
34
+ try:
35
+ response = await transport.get("/")
36
+ if response.status_code == 200:
37
+ try:
38
+ return response.json()
39
+ except Exception:
40
+ return None
41
+ except (httpx.ConnectError, httpx.ReadTimeout, httpx.ConnectTimeout):
42
+ pass
43
+ return None
44
+
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Commands
48
+ # ---------------------------------------------------------------------------
49
+
25
50
  @app.command()
26
51
  def init(
27
52
  goal: str = typer.Argument(..., help="The task goal for this envelope"),
@@ -30,9 +55,7 @@ def init(
30
55
  """Create a new TaskEnvelope and print it."""
31
56
  envelope = TaskEnvelope(goal=goal)
32
57
  json_str = envelope.model_dump_json(indent=2)
33
-
34
58
  console.print(Syntax(json_str, "json", theme="monokai"))
35
-
36
59
  if output:
37
60
  output.write_text(json_str)
38
61
  console.print(f"\n[green]Saved to {output}[/green]")
@@ -94,14 +117,48 @@ def validate(
94
117
  def register(
95
118
  agent_id: str = typer.Argument(..., help="Unique name for this agent"),
96
119
  url: str = typer.Argument(..., help="Base URL of the agent's HTTP server"),
97
- capabilities: str = typer.Option(..., "--capabilities", "-c", help="Comma-separated capability keywords"),
120
+ capabilities: Optional[str] = typer.Option(None, "--capabilities", "-c", help="Comma-separated capability keywords (fallback if GET / returns none)"),
98
121
  timeout: float = typer.Option(60.0, "--timeout", "-t", help="Request timeout in seconds"),
99
122
  ):
100
- """Register an HTTP agent in the local registry (~/.oap/agents.json)."""
101
- caps = [c.strip() for c in capabilities.split(",")]
102
- registry.add(agent_id, url, caps, timeout=timeout)
103
- console.print(f"[green]Registered[/green] [cyan]{agent_id}[/cyan] → {url}")
104
- console.print(f"[dim]Capabilities:[/dim] {', '.join(caps)}")
123
+ """Register an HTTP agent. Discovers capabilities automatically from GET /."""
124
+ info = asyncio.run(_fetch_agent_info(url, timeout=timeout))
125
+
126
+ discovered_caps: list[str] = []
127
+ description = ""
128
+ source = "manual"
129
+
130
+ if info and info.get("capabilities"):
131
+ discovered_caps = [c.strip() for c in info["capabilities"]]
132
+ description = info.get("description", "")
133
+ source = "discovered"
134
+ console.print(f"[dim]Discovered from agent:[/dim] capabilities={discovered_caps}")
135
+ if description:
136
+ console.print(f"[dim]Description:[/dim] {description}")
137
+ elif capabilities:
138
+ discovered_caps = [c.strip() for c in capabilities.split(",")]
139
+ else:
140
+ # GET / either failed or returned no capabilities — and no --capabilities given
141
+ if info is None:
142
+ console.print(
143
+ f"[red]Agent at {url} has no GET / endpoint.[/red]\n"
144
+ f"Add one that returns {{agent_id, capabilities, description}} "
145
+ f"or pass [bold]--capabilities[/bold] manually."
146
+ )
147
+ else:
148
+ console.print(
149
+ f"[red]Agent at {url} returned no capabilities.[/red]\n"
150
+ f"Add a GET / endpoint that returns {{agent_id, capabilities, description}} "
151
+ f"or pass [bold]--capabilities[/bold] manually."
152
+ )
153
+ raise typer.Exit(1)
154
+
155
+ # Use agent_id from GET / response if present, otherwise use CLI argument
156
+ final_agent_id = (info or {}).get("agent_id") or agent_id
157
+
158
+ registry.add(final_agent_id, url, discovered_caps, timeout=timeout, description=description)
159
+
160
+ console.print(f"[green]Registered[/green] [cyan]{final_agent_id}[/cyan] → {url} [dim]({source})[/dim]")
161
+ console.print(f"[dim]Capabilities:[/dim] {', '.join(discovered_caps)}")
105
162
  console.print(f"[dim]Timeout:[/dim] {timeout}s")
106
163
 
107
164
 
@@ -216,7 +273,7 @@ def chain(
216
273
 
217
274
  @app.command()
218
275
  def ping():
219
- """Check reachability of all registered agents."""
276
+ """Check reachability of all registered agents. Updates capabilities if changed."""
220
277
  entries = registry.list_all()
221
278
 
222
279
  if not entries:
@@ -229,17 +286,48 @@ def ping():
229
286
  try:
230
287
  response = await transport.get("/")
231
288
  elapsed = int((time.monotonic() - start) * 1000)
232
- if response.status_code < 400:
233
- return {"id": entry["id"], "status": "alive", "ms": elapsed, "ok": True}
234
- return {"id": entry["id"], "status": "no health endpoint", "ms": elapsed, "ok": True}
289
+
290
+ if response.status_code >= 400:
291
+ return {
292
+ "id": entry["id"], "status": "no health endpoint",
293
+ "ms": elapsed, "ok": True,
294
+ "caps_updated": False, "new_caps": None,
295
+ }
296
+
297
+ # Alive — check if capabilities changed
298
+ caps_updated = False
299
+ new_caps = None
300
+ try:
301
+ info = response.json()
302
+ remote_caps = info.get("capabilities")
303
+ remote_desc = info.get("description", "")
304
+ if remote_caps and sorted(remote_caps) != sorted(entry["capabilities"]):
305
+ registry.add(
306
+ entry["id"], entry["url"], remote_caps,
307
+ timeout=entry["timeout"], description=remote_desc,
308
+ )
309
+ caps_updated = True
310
+ new_caps = remote_caps
311
+ except Exception:
312
+ pass
313
+
314
+ return {
315
+ "id": entry["id"], "status": "alive",
316
+ "ms": elapsed, "ok": True,
317
+ "caps_updated": caps_updated, "new_caps": new_caps,
318
+ }
235
319
  except (httpx.ConnectError, httpx.ReadTimeout, httpx.ConnectTimeout):
236
320
  elapsed = int((time.monotonic() - start) * 1000)
237
- return {"id": entry["id"], "status": "dead", "ms": elapsed, "ok": False}
321
+ return {
322
+ "id": entry["id"], "status": "dead",
323
+ "ms": elapsed, "ok": False,
324
+ "caps_updated": False, "new_caps": None,
325
+ }
238
326
 
239
- async def run_all() -> list[dict]:
327
+ async def _run_all() -> list[dict]:
240
328
  return await asyncio.gather(*[check(e) for e in entries])
241
329
 
242
- results = asyncio.run(run_all())
330
+ results = asyncio.run(_run_all())
243
331
 
244
332
  table = Table(show_header=True, header_style="bold dim")
245
333
  table.add_column("Agent ID")
@@ -250,7 +338,8 @@ def ping():
250
338
  any_dead = False
251
339
  for res, entry in zip(results, entries):
252
340
  if res["status"] == "alive":
253
- status_str = "[green]alive[/green]"
341
+ note = " [yellow](capabilities updated)[/yellow]" if res["caps_updated"] else ""
342
+ status_str = f"[green]alive[/green]{note}"
254
343
  elif res["status"] == "dead":
255
344
  status_str = "[red]dead[/red]"
256
345
  any_dead = True
@@ -284,6 +373,7 @@ def agents():
284
373
  table.add_column("URL")
285
374
  table.add_column("Capabilities")
286
375
  table.add_column("Timeout")
376
+ table.add_column("Description")
287
377
 
288
378
  for entry in entries:
289
379
  table.add_row(
@@ -291,6 +381,7 @@ def agents():
291
381
  entry["url"],
292
382
  ", ".join(entry["capabilities"]),
293
383
  f"{entry['timeout']}s",
384
+ entry.get("description", "") or "—",
294
385
  )
295
386
 
296
387
  console.print(table)
@@ -26,9 +26,15 @@ def add(
26
26
  url: str,
27
27
  capabilities: list[str],
28
28
  timeout: float = _DEFAULT_TIMEOUT,
29
+ description: str = "",
29
30
  ) -> None:
30
31
  data = _load_raw()
31
- data[agent_id] = {"url": url, "capabilities": capabilities, "timeout": timeout}
32
+ data[agent_id] = {
33
+ "url": url,
34
+ "capabilities": capabilities,
35
+ "timeout": timeout,
36
+ "description": description,
37
+ }
32
38
  _save_raw(data)
33
39
 
34
40
 
@@ -48,6 +54,7 @@ def list_all() -> list[dict]:
48
54
  "url": entry["url"],
49
55
  "capabilities": entry["capabilities"],
50
56
  "timeout": entry.get("timeout", _DEFAULT_TIMEOUT),
57
+ "description": entry.get("description", ""),
51
58
  }
52
59
  for agent_id, entry in _load_raw().items()
53
60
  ]
@@ -0,0 +1,47 @@
1
+ """
2
+ Research agent demo — implements the OAP agent interface.
3
+
4
+ Endpoints:
5
+ GET / → health + capability info (used by oap ping and oap register)
6
+ POST /invoke → receives a TaskEnvelope, returns result + optional handoff
7
+ """
8
+ from fastapi import FastAPI
9
+ from pydantic import BaseModel
10
+
11
+ app = FastAPI()
12
+
13
+ AGENT_ID = "research-agent"
14
+ CAPABILITIES = ["research", "find", "search", "analyse", "compare"]
15
+ DESCRIPTION = (
16
+ "I research topics thoroughly, find information from multiple angles, "
17
+ "analyse options and summarise findings across any domain."
18
+ )
19
+
20
+
21
+ @app.get("/")
22
+ async def info():
23
+ return {
24
+ "agent_id": AGENT_ID,
25
+ "capabilities": CAPABILITIES,
26
+ "description": DESCRIPTION,
27
+ "status": "ok",
28
+ }
29
+
30
+
31
+ class InvokeRequest(BaseModel):
32
+ goal: str
33
+ memory: dict = {}
34
+
35
+
36
+ @app.post("/invoke")
37
+ async def invoke(request: InvokeRequest):
38
+ # TODO: replace stub with real Claude Bedrock call
39
+ return {
40
+ "result": f"[{AGENT_ID}] Researched: {request.goal}",
41
+ "memory": {"researched_by": AGENT_ID},
42
+ }
43
+
44
+
45
+ if __name__ == "__main__":
46
+ import uvicorn
47
+ uvicorn.run(app, host="0.0.0.0", port=8001)
@@ -0,0 +1,47 @@
1
+ """
2
+ Translator agent demo — implements the OAP agent interface.
3
+
4
+ Endpoints:
5
+ GET / → health + capability info (used by oap ping and oap register)
6
+ POST /invoke → receives a TaskEnvelope, returns result + optional handoff
7
+ """
8
+ from fastapi import FastAPI
9
+ from pydantic import BaseModel
10
+
11
+ app = FastAPI()
12
+
13
+ AGENT_ID = "translator-agent"
14
+ CAPABILITIES = ["translate", "simplify", "explain"]
15
+ DESCRIPTION = (
16
+ "I rewrite technical content for non-technical audiences. "
17
+ "I use plain English, analogies and friendly language to make complex topics accessible to anyone."
18
+ )
19
+
20
+
21
+ @app.get("/")
22
+ async def info():
23
+ return {
24
+ "agent_id": AGENT_ID,
25
+ "capabilities": CAPABILITIES,
26
+ "description": DESCRIPTION,
27
+ "status": "ok",
28
+ }
29
+
30
+
31
+ class InvokeRequest(BaseModel):
32
+ goal: str
33
+ memory: dict = {}
34
+
35
+
36
+ @app.post("/invoke")
37
+ async def invoke(request: InvokeRequest):
38
+ # TODO: replace stub with real Claude Bedrock call
39
+ return {
40
+ "result": f"[{AGENT_ID}] Simplified for general audience: {request.goal}",
41
+ "memory": {"translated_by": AGENT_ID},
42
+ }
43
+
44
+
45
+ if __name__ == "__main__":
46
+ import uvicorn
47
+ uvicorn.run(app, host="0.0.0.0", port=8003)
@@ -0,0 +1,47 @@
1
+ """
2
+ Writer agent demo — implements the OAP agent interface.
3
+
4
+ Endpoints:
5
+ GET / → health + capability info (used by oap ping and oap register)
6
+ POST /invoke → receives a TaskEnvelope, returns result + optional handoff
7
+ """
8
+ from fastapi import FastAPI
9
+ from pydantic import BaseModel
10
+
11
+ app = FastAPI()
12
+
13
+ AGENT_ID = "writer-agent"
14
+ CAPABILITIES = ["write", "report", "summarise", "draft"]
15
+ DESCRIPTION = (
16
+ "I write clear, well-structured reports and documents based on research findings. "
17
+ "I turn raw information into polished readable content."
18
+ )
19
+
20
+
21
+ @app.get("/")
22
+ async def info():
23
+ return {
24
+ "agent_id": AGENT_ID,
25
+ "capabilities": CAPABILITIES,
26
+ "description": DESCRIPTION,
27
+ "status": "ok",
28
+ }
29
+
30
+
31
+ class InvokeRequest(BaseModel):
32
+ goal: str
33
+ memory: dict = {}
34
+
35
+
36
+ @app.post("/invoke")
37
+ async def invoke(request: InvokeRequest):
38
+ # TODO: replace stub with real Claude Bedrock call
39
+ return {
40
+ "result": f"[{AGENT_ID}] Drafted report for: {request.goal}",
41
+ "memory": {"written_by": AGENT_ID},
42
+ }
43
+
44
+
45
+ if __name__ == "__main__":
46
+ import uvicorn
47
+ uvicorn.run(app, host="0.0.0.0", port=8002)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "open-agent-protocol"
7
- version = "0.2.2"
7
+ version = "0.2.3"
8
8
  description = "Open Agent Protocol — a routing layer for inter-agent task handoff"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -0,0 +1,229 @@
1
+ """Tests for the self-describing oap register command."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import threading
6
+ from http.server import BaseHTTPRequestHandler, HTTPServer
7
+
8
+ import pytest
9
+ from typer.testing import CliRunner
10
+
11
+ from oap.cli import app
12
+ import oap.registry as reg
13
+
14
+ runner = CliRunner()
15
+
16
+ _REG_PORT = 19993
17
+ _REG_PORT_NO_HEALTH = 19992
18
+ _REG_PORT_NO_CAPS = 19991
19
+
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Fake servers
23
+ # ---------------------------------------------------------------------------
24
+
25
+ class SelfDescribingHandler(BaseHTTPRequestHandler):
26
+ """Returns full agent info on GET /, accepts POST /invoke."""
27
+
28
+ agent_info = {
29
+ "agent_id": "research-agent",
30
+ "capabilities": ["research", "find", "search", "analyse"],
31
+ "description": "Researches topics thoroughly.",
32
+ "status": "ok",
33
+ }
34
+
35
+ def do_GET(self):
36
+ body = json.dumps(self.agent_info).encode()
37
+ self.send_response(200)
38
+ self.send_header("Content-Type", "application/json")
39
+ self.end_headers()
40
+ self.wfile.write(body)
41
+
42
+ def do_POST(self):
43
+ length = int(self.headers["Content-Length"])
44
+ self.rfile.read(length)
45
+ body = json.dumps({"result": "done", "memory": {}}).encode()
46
+ self.send_response(200)
47
+ self.send_header("Content-Type", "application/json")
48
+ self.end_headers()
49
+ self.wfile.write(body)
50
+
51
+ def log_message(self, format, *args):
52
+ pass
53
+
54
+
55
+ class NoCapsHandler(BaseHTTPRequestHandler):
56
+ """GET / returns 200 but no capabilities field."""
57
+
58
+ def do_GET(self):
59
+ body = json.dumps({"agent_id": "empty-agent", "status": "ok"}).encode()
60
+ self.send_response(200)
61
+ self.send_header("Content-Type", "application/json")
62
+ self.end_headers()
63
+ self.wfile.write(body)
64
+
65
+ def log_message(self, format, *args):
66
+ pass
67
+
68
+
69
+ @pytest.fixture(scope="module")
70
+ def self_describing_server():
71
+ server = HTTPServer(("localhost", _REG_PORT), SelfDescribingHandler)
72
+ t = threading.Thread(target=server.serve_forever)
73
+ t.daemon = True
74
+ t.start()
75
+ yield f"http://localhost:{_REG_PORT}"
76
+ server.shutdown()
77
+
78
+
79
+ @pytest.fixture(scope="module")
80
+ def no_caps_server():
81
+ server = HTTPServer(("localhost", _REG_PORT_NO_CAPS), NoCapsHandler)
82
+ t = threading.Thread(target=server.serve_forever)
83
+ t.daemon = True
84
+ t.start()
85
+ yield f"http://localhost:{_REG_PORT_NO_CAPS}"
86
+ server.shutdown()
87
+
88
+
89
+ @pytest.fixture(autouse=True)
90
+ def isolated_registry(tmp_path, monkeypatch):
91
+ monkeypatch.setattr(reg, "_REGISTRY_PATH", tmp_path / "agents.json")
92
+
93
+
94
+ # ---------------------------------------------------------------------------
95
+ # Discovery tests
96
+ # ---------------------------------------------------------------------------
97
+
98
+ def test_register_discovers_capabilities(self_describing_server):
99
+ result = runner.invoke(app, ["register", "my-agent", self_describing_server])
100
+ assert result.exit_code == 0, result.output
101
+ assert "Discovered" in result.output
102
+
103
+ entries = reg.list_all()
104
+ assert len(entries) == 1
105
+ assert sorted(entries[0]["capabilities"]) == sorted(["research", "find", "search", "analyse"])
106
+
107
+
108
+ def test_register_discovers_description(self_describing_server):
109
+ runner.invoke(app, ["register", "my-agent", self_describing_server])
110
+ entries = reg.list_all()
111
+ assert entries[0]["description"] == "Researches topics thoroughly."
112
+
113
+
114
+ def test_register_uses_agent_id_from_response(self_describing_server):
115
+ """The agent_id from GET / should override the CLI positional argument."""
116
+ runner.invoke(app, ["register", "cli-given-id", self_describing_server])
117
+ entries = reg.list_all()
118
+ assert entries[0]["id"] == "research-agent"
119
+
120
+
121
+ def test_register_fallback_to_capabilities_flag_on_no_endpoint():
122
+ """GET / fails (nothing listening) → fall back to --capabilities."""
123
+ result = runner.invoke(app, [
124
+ "register", "my-agent", "http://localhost:19980",
125
+ "--capabilities", "research,find",
126
+ ])
127
+ assert result.exit_code == 0, result.output
128
+ entries = reg.list_all()
129
+ assert sorted(entries[0]["capabilities"]) == sorted(["research", "find"])
130
+
131
+
132
+ def test_register_error_when_no_endpoint_and_no_caps():
133
+ """GET / fails and no --capabilities → clear error, exit 1."""
134
+ result = runner.invoke(app, ["register", "my-agent", "http://localhost:19980"])
135
+ assert result.exit_code == 1
136
+ assert "no GET / endpoint" in result.output
137
+ assert "--capabilities" in result.output
138
+
139
+
140
+ def test_register_fallback_when_no_caps_in_response(no_caps_server):
141
+ """GET / returns 200 but no capabilities → fall back to --capabilities."""
142
+ result = runner.invoke(app, [
143
+ "register", "my-agent", no_caps_server,
144
+ "--capabilities", "research,find",
145
+ ])
146
+ assert result.exit_code == 0, result.output
147
+ entries = reg.list_all()
148
+ assert "research" in entries[0]["capabilities"]
149
+
150
+
151
+ def test_register_error_when_no_caps_in_response_and_no_flag(no_caps_server):
152
+ """GET / returns 200 but no capabilities and no --capabilities → exit 1."""
153
+ result = runner.invoke(app, ["register", "my-agent", no_caps_server])
154
+ assert result.exit_code == 1
155
+ assert "returned no capabilities" in result.output
156
+
157
+
158
+ def test_register_saves_description_as_empty_for_manual_registration():
159
+ """Manual --capabilities registration stores empty description."""
160
+ runner.invoke(app, [
161
+ "register", "my-agent", "http://localhost:19980",
162
+ "--capabilities", "research",
163
+ ])
164
+ entries = reg.list_all()
165
+ assert entries[0]["description"] == ""
166
+
167
+
168
+ # ---------------------------------------------------------------------------
169
+ # ping capability update tests
170
+ # ---------------------------------------------------------------------------
171
+
172
+ def test_ping_updates_capabilities_when_changed(self_describing_server):
173
+ """ping re-reads GET / and updates capabilities when they differ."""
174
+ # Register with stale capabilities
175
+ reg.add("research-agent", self_describing_server, ["old-cap"], description="old")
176
+
177
+ result = runner.invoke(app, ["ping"])
178
+ assert result.exit_code == 0, result.output
179
+ assert "capabilities updated" in result.output
180
+
181
+ entries = reg.list_all()
182
+ assert sorted(entries[0]["capabilities"]) == sorted(["research", "find", "search", "analyse"])
183
+
184
+
185
+ def test_ping_no_update_when_capabilities_match(self_describing_server):
186
+ """ping does not show 'capabilities updated' when nothing changed."""
187
+ reg.add(
188
+ "research-agent", self_describing_server,
189
+ ["research", "find", "search", "analyse"],
190
+ description="Researches topics thoroughly.",
191
+ )
192
+ result = runner.invoke(app, ["ping"])
193
+ assert result.exit_code == 0, result.output
194
+ assert "capabilities updated" not in result.output
195
+
196
+
197
+ # ---------------------------------------------------------------------------
198
+ # oap agents description column
199
+ # ---------------------------------------------------------------------------
200
+
201
+ def test_agents_shows_description_column(self_describing_server):
202
+ runner.invoke(app, ["register", "my-agent", self_describing_server])
203
+ result = runner.invoke(app, ["agents"])
204
+ assert result.exit_code == 0
205
+ assert "Description" in result.output
206
+ assert "Researches" in result.output
207
+
208
+
209
+ def test_agents_shows_dash_for_empty_description():
210
+ reg.add("my-agent", "http://localhost:9000", ["research"], description="")
211
+ result = runner.invoke(app, ["agents"])
212
+ assert result.exit_code == 0
213
+ assert "—" in result.output
214
+
215
+
216
+ # ---------------------------------------------------------------------------
217
+ # Backward compat: old registry entries without description
218
+ # ---------------------------------------------------------------------------
219
+
220
+ def test_list_all_defaults_description_for_old_entries(tmp_path, monkeypatch):
221
+ """Registry entries written before description field was added default to ''."""
222
+ registry_file = tmp_path / "agents.json"
223
+ registry_file.write_text(json.dumps({
224
+ "legacy-agent": {"url": "http://localhost:9000", "capabilities": ["research"], "timeout": 60.0}
225
+ }))
226
+ monkeypatch.setattr(reg, "_REGISTRY_PATH", registry_file)
227
+
228
+ entries = reg.list_all()
229
+ assert entries[0]["description"] == ""
@@ -21,6 +21,7 @@ def test_add_and_list():
21
21
  "url": "http://localhost:9000",
22
22
  "capabilities": ["research", "find"],
23
23
  "timeout": 60.0,
24
+ "description": "",
24
25
  }
25
26
 
26
27