open-agent-protocol 0.2.0__tar.gz → 0.2.2__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 (27) hide show
  1. {open_agent_protocol-0.2.0 → open_agent_protocol-0.2.2}/PKG-INFO +43 -8
  2. {open_agent_protocol-0.2.0 → open_agent_protocol-0.2.2}/README.md +42 -7
  3. {open_agent_protocol-0.2.0 → open_agent_protocol-0.2.2}/oap/cli.py +88 -8
  4. {open_agent_protocol-0.2.0 → open_agent_protocol-0.2.2}/oap/registry.py +16 -4
  5. {open_agent_protocol-0.2.0 → open_agent_protocol-0.2.2}/oap/router.py +46 -1
  6. open_agent_protocol-0.2.2/oap/transport/http.py +76 -0
  7. {open_agent_protocol-0.2.0 → open_agent_protocol-0.2.2}/pyproject.toml +1 -1
  8. {open_agent_protocol-0.2.0 → open_agent_protocol-0.2.2}/tests/test_chain.py +160 -0
  9. {open_agent_protocol-0.2.0 → open_agent_protocol-0.2.2}/tests/test_registry.py +1 -0
  10. open_agent_protocol-0.2.2/tests/test_transport.py +263 -0
  11. open_agent_protocol-0.2.0/oap/transport/http.py +0 -19
  12. {open_agent_protocol-0.2.0 → open_agent_protocol-0.2.2}/.gitignore +0 -0
  13. {open_agent_protocol-0.2.0 → open_agent_protocol-0.2.2}/LICENSE +0 -0
  14. {open_agent_protocol-0.2.0 → open_agent_protocol-0.2.2}/oap/__init__.py +0 -0
  15. {open_agent_protocol-0.2.0 → open_agent_protocol-0.2.2}/oap/adapters/__init__.py +0 -0
  16. {open_agent_protocol-0.2.0 → open_agent_protocol-0.2.2}/oap/adapters/base.py +0 -0
  17. {open_agent_protocol-0.2.0 → open_agent_protocol-0.2.2}/oap/adapters/http.py +0 -0
  18. {open_agent_protocol-0.2.0 → open_agent_protocol-0.2.2}/oap/adapters/mock.py +0 -0
  19. {open_agent_protocol-0.2.0 → open_agent_protocol-0.2.2}/oap/envelope.py +0 -0
  20. {open_agent_protocol-0.2.0 → open_agent_protocol-0.2.2}/oap/transport/__init__.py +0 -0
  21. {open_agent_protocol-0.2.0 → open_agent_protocol-0.2.2}/py.typed +0 -0
  22. {open_agent_protocol-0.2.0 → open_agent_protocol-0.2.2}/tests/__init__.py +0 -0
  23. {open_agent_protocol-0.2.0 → open_agent_protocol-0.2.2}/tests/fake_agent_server.py +0 -0
  24. {open_agent_protocol-0.2.0 → open_agent_protocol-0.2.2}/tests/test_cli.py +0 -0
  25. {open_agent_protocol-0.2.0 → open_agent_protocol-0.2.2}/tests/test_envelope.py +0 -0
  26. {open_agent_protocol-0.2.0 → open_agent_protocol-0.2.2}/tests/test_http_adapter.py +0 -0
  27. {open_agent_protocol-0.2.0 → open_agent_protocol-0.2.2}/tests/test_router.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: open-agent-protocol
3
- Version: 0.2.0
3
+ Version: 0.2.2
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,20 +64,55 @@ 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
- # Route it to an HTTP agent
68
- oap register research-agent http://localhost:9000 --capabilities "research,search" task.json --output result.json
67
+ # Register an HTTP agent in the local registry
68
+ oap register research-agent http://localhost:9000 --capabilities "research,search,find"
69
69
 
70
- # Inspect the result
70
+ # Route the envelope to the best matching agent
71
+ oap route task.json --output result.json
72
+
73
+ # Automatically follow handoffs until the task is complete
74
+ oap chain task.json --output final.json
75
+
76
+ # Inspect an envelope
71
77
  oap inspect result.json
72
78
 
73
79
  # Validate envelope structure
74
80
  oap validate result.json
75
81
 
76
- # Route using built-in demo agents
77
- oap route task.json
78
-
79
- # List demo agents
82
+ # List all registered agents
80
83
  oap agents
84
+
85
+ # Remove an agent from the registry
86
+ oap unregister research-agent
87
+ ```
88
+
89
+ ## Chaining agents
90
+
91
+ When an agent sets a `handoff.next_agent` on its response, `oap chain` automatically routes to the next agent and keeps going until the task is complete or a hop limit is reached.
92
+
93
+ ```python
94
+ # Python API
95
+ result, visited = await router.chain(envelope, max_hops=10)
96
+ print(" → ".join(visited)) # e.g. research-agent → summarise-agent
97
+ ```
98
+
99
+ ```bash
100
+ # CLI — follows handoffs automatically, prints each hop
101
+ oap chain task.json --output final.json --max-hops 5
102
+ ```
103
+
104
+ The chain stops when:
105
+ - An agent returns a response with no `handoff` set, or
106
+ - `max_hops` is reached (default: 10)
107
+
108
+ ## Registry
109
+
110
+ Agents are stored in `~/.oap/agents.json` and persist across commands.
111
+
112
+ ```bash
113
+ oap register my-agent http://localhost:9000 --capabilities "research,find"
114
+ oap agents # list all registered agents
115
+ oap unregister my-agent
81
116
  ```
82
117
 
83
118
  ## Concepts
@@ -34,20 +34,55 @@ 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
- # Route it to an HTTP agent
38
- oap register research-agent http://localhost:9000 --capabilities "research,search" task.json --output result.json
37
+ # Register an HTTP agent in the local registry
38
+ oap register research-agent http://localhost:9000 --capabilities "research,search,find"
39
39
 
40
- # Inspect the result
40
+ # Route the envelope to the best matching agent
41
+ oap route task.json --output result.json
42
+
43
+ # Automatically follow handoffs until the task is complete
44
+ oap chain task.json --output final.json
45
+
46
+ # Inspect an envelope
41
47
  oap inspect result.json
42
48
 
43
49
  # Validate envelope structure
44
50
  oap validate result.json
45
51
 
46
- # Route using built-in demo agents
47
- oap route task.json
48
-
49
- # List demo agents
52
+ # List all registered agents
50
53
  oap agents
54
+
55
+ # Remove an agent from the registry
56
+ oap unregister research-agent
57
+ ```
58
+
59
+ ## Chaining agents
60
+
61
+ When an agent sets a `handoff.next_agent` on its response, `oap chain` automatically routes to the next agent and keeps going until the task is complete or a hop limit is reached.
62
+
63
+ ```python
64
+ # Python API
65
+ result, visited = await router.chain(envelope, max_hops=10)
66
+ print(" → ".join(visited)) # e.g. research-agent → summarise-agent
67
+ ```
68
+
69
+ ```bash
70
+ # CLI — follows handoffs automatically, prints each hop
71
+ oap chain task.json --output final.json --max-hops 5
72
+ ```
73
+
74
+ The chain stops when:
75
+ - An agent returns a response with no `handoff` set, or
76
+ - `max_hops` is reached (default: 10)
77
+
78
+ ## Registry
79
+
80
+ Agents are stored in `~/.oap/agents.json` and persist across commands.
81
+
82
+ ```bash
83
+ oap register my-agent http://localhost:9000 --capabilities "research,find"
84
+ oap agents # list all registered agents
85
+ oap unregister my-agent
51
86
  ```
52
87
 
53
88
  ## Concepts
@@ -1,8 +1,9 @@
1
1
  import asyncio
2
- import json
2
+ import time
3
3
  from pathlib import Path
4
4
  from typing import Optional
5
5
 
6
+ import httpx
6
7
  import typer
7
8
  from rich.console import Console
8
9
  from rich.syntax import Syntax
@@ -11,6 +12,7 @@ from rich.table import Table
11
12
  from oap.envelope import TaskEnvelope
12
13
  from oap.router import OAPRouter, RoutingError
13
14
  from oap import registry
15
+ from oap.transport.http import HTTPTransport
14
16
 
15
17
  app = typer.Typer(
16
18
  name="oap",
@@ -93,12 +95,14 @@ def register(
93
95
  agent_id: str = typer.Argument(..., help="Unique name for this agent"),
94
96
  url: str = typer.Argument(..., help="Base URL of the agent's HTTP server"),
95
97
  capabilities: str = typer.Option(..., "--capabilities", "-c", help="Comma-separated capability keywords"),
98
+ timeout: float = typer.Option(60.0, "--timeout", "-t", help="Request timeout in seconds"),
96
99
  ):
97
100
  """Register an HTTP agent in the local registry (~/.oap/agents.json)."""
98
101
  caps = [c.strip() for c in capabilities.split(",")]
99
- registry.add(agent_id, url, caps)
102
+ registry.add(agent_id, url, caps, timeout=timeout)
100
103
  console.print(f"[green]Registered[/green] [cyan]{agent_id}[/cyan] → {url}")
101
104
  console.print(f"[dim]Capabilities:[/dim] {', '.join(caps)}")
105
+ console.print(f"[dim]Timeout:[/dim] {timeout}s")
102
106
 
103
107
 
104
108
  @app.command()
@@ -158,8 +162,12 @@ def chain(
158
162
  file: Path = typer.Argument(..., help="Path to a TaskEnvelope JSON file"),
159
163
  output: Optional[Path] = typer.Option(None, "--output", "-o", help="Save final envelope to file"),
160
164
  max_hops: int = typer.Option(10, "--max-hops", help="Maximum number of agent hops before stopping"),
165
+ pipeline: Optional[str] = typer.Option(None, "--pipeline", help="Comma-separated list of agent IDs to invoke in order, bypassing capability matching"),
161
166
  ):
162
- """Route a TaskEnvelope, automatically following handoffs until the task is complete."""
167
+ """Route a TaskEnvelope, automatically following handoffs until the task is complete.
168
+
169
+ Use --pipeline to force a fixed sequence of agents regardless of handoffs.
170
+ """
163
171
  if not file.exists():
164
172
  console.print(f"[red]File not found: {file}[/red]")
165
173
  raise typer.Exit(1)
@@ -172,12 +180,26 @@ def chain(
172
180
 
173
181
  router = registry.load_router()
174
182
 
175
- def on_hop(hop: int, agent_id: str) -> None:
176
- console.print(f" [dim]hop {hop}:[/dim] [cyan]{agent_id}[/cyan]")
177
-
178
183
  try:
179
- console.print(f"[bold]Chaining[/bold] (max {max_hops} hops)...")
180
- result, visited = asyncio.run(router.chain(envelope, max_hops=max_hops, on_hop=on_hop))
184
+ if pipeline:
185
+ agent_ids = [a.strip() for a in pipeline.split(",")]
186
+ total = len(agent_ids)
187
+
188
+ def on_pipeline_hop(hop: int, tot: int, agent_id: str) -> None:
189
+ console.print(f" [dim]hop {hop}/{tot}[/dim] → [cyan]{agent_id}[/cyan]")
190
+
191
+ console.print(f"[bold]Pipeline[/bold] ({total} agent(s))...")
192
+ result, visited = asyncio.run(
193
+ router.run_pipeline(envelope, agent_ids, on_hop=on_pipeline_hop)
194
+ )
195
+ else:
196
+ def on_hop(hop: int, agent_id: str) -> None:
197
+ console.print(f" [dim]hop {hop}:[/dim] [cyan]{agent_id}[/cyan]")
198
+
199
+ console.print(f"[bold]Chaining[/bold] (max {max_hops} hops)...")
200
+ result, visited = asyncio.run(
201
+ router.chain(envelope, max_hops=max_hops, on_hop=on_hop)
202
+ )
181
203
  except RoutingError as e:
182
204
  console.print(f"[red]Routing failed:[/red] {e}")
183
205
  raise typer.Exit(1)
@@ -192,6 +214,62 @@ def chain(
192
214
  console.print(f"\n[green]Saved to {output}[/green]")
193
215
 
194
216
 
217
+ @app.command()
218
+ def ping():
219
+ """Check reachability of all registered agents."""
220
+ entries = registry.list_all()
221
+
222
+ if not entries:
223
+ console.print("[dim]No agents registered.[/dim]")
224
+ return
225
+
226
+ async def check(entry: dict) -> dict:
227
+ transport = HTTPTransport(base_url=entry["url"], timeout=5.0)
228
+ start = time.monotonic()
229
+ try:
230
+ response = await transport.get("/")
231
+ 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}
235
+ except (httpx.ConnectError, httpx.ReadTimeout, httpx.ConnectTimeout):
236
+ elapsed = int((time.monotonic() - start) * 1000)
237
+ return {"id": entry["id"], "status": "dead", "ms": elapsed, "ok": False}
238
+
239
+ async def run_all() -> list[dict]:
240
+ return await asyncio.gather(*[check(e) for e in entries])
241
+
242
+ results = asyncio.run(run_all())
243
+
244
+ table = Table(show_header=True, header_style="bold dim")
245
+ table.add_column("Agent ID")
246
+ table.add_column("URL")
247
+ table.add_column("Status")
248
+ table.add_column("ms", justify="right")
249
+
250
+ any_dead = False
251
+ for res, entry in zip(results, entries):
252
+ if res["status"] == "alive":
253
+ status_str = "[green]alive[/green]"
254
+ elif res["status"] == "dead":
255
+ status_str = "[red]dead[/red]"
256
+ any_dead = True
257
+ else:
258
+ status_str = "[dim]no health endpoint[/dim]"
259
+
260
+ table.add_row(
261
+ f"[cyan]{res['id']}[/cyan]",
262
+ entry["url"],
263
+ status_str,
264
+ str(res["ms"]),
265
+ )
266
+
267
+ console.print(table)
268
+
269
+ if any_dead:
270
+ raise typer.Exit(1)
271
+
272
+
195
273
  @app.command()
196
274
  def agents():
197
275
  """List all agents in the local registry."""
@@ -205,12 +283,14 @@ def agents():
205
283
  table.add_column("Agent ID")
206
284
  table.add_column("URL")
207
285
  table.add_column("Capabilities")
286
+ table.add_column("Timeout")
208
287
 
209
288
  for entry in entries:
210
289
  table.add_row(
211
290
  f"[cyan]{entry['id']}[/cyan]",
212
291
  entry["url"],
213
292
  ", ".join(entry["capabilities"]),
293
+ f"{entry['timeout']}s",
214
294
  )
215
295
 
216
296
  console.print(table)
@@ -7,6 +7,7 @@ from oap.adapters.http import HTTPAdapter
7
7
  from oap.router import OAPRouter
8
8
 
9
9
  _REGISTRY_PATH = Path.home() / ".oap" / "agents.json"
10
+ _DEFAULT_TIMEOUT = 60.0
10
11
 
11
12
 
12
13
  def _load_raw() -> dict[str, dict]:
@@ -20,9 +21,14 @@ def _save_raw(data: dict[str, dict]) -> None:
20
21
  _REGISTRY_PATH.write_text(json.dumps(data, indent=2))
21
22
 
22
23
 
23
- def add(agent_id: str, url: str, capabilities: list[str]) -> None:
24
+ def add(
25
+ agent_id: str,
26
+ url: str,
27
+ capabilities: list[str],
28
+ timeout: float = _DEFAULT_TIMEOUT,
29
+ ) -> None:
24
30
  data = _load_raw()
25
- data[agent_id] = {"url": url, "capabilities": capabilities}
31
+ data[agent_id] = {"url": url, "capabilities": capabilities, "timeout": timeout}
26
32
  _save_raw(data)
27
33
 
28
34
 
@@ -37,7 +43,12 @@ def remove(agent_id: str) -> bool:
37
43
 
38
44
  def list_all() -> list[dict]:
39
45
  return [
40
- {"id": agent_id, "url": entry["url"], "capabilities": entry["capabilities"]}
46
+ {
47
+ "id": agent_id,
48
+ "url": entry["url"],
49
+ "capabilities": entry["capabilities"],
50
+ "timeout": entry.get("timeout", _DEFAULT_TIMEOUT),
51
+ }
41
52
  for agent_id, entry in _load_raw().items()
42
53
  ]
43
54
 
@@ -45,9 +56,10 @@ def list_all() -> list[dict]:
45
56
  def load_router() -> OAPRouter:
46
57
  router = OAPRouter()
47
58
  for agent_id, entry in _load_raw().items():
59
+ timeout = entry.get("timeout", _DEFAULT_TIMEOUT)
48
60
  router.register(
49
61
  agent_id,
50
- HTTPAdapter(agent_id=agent_id, base_url=entry["url"]),
62
+ HTTPAdapter(agent_id=agent_id, base_url=entry["url"], timeout=timeout),
51
63
  entry["capabilities"],
52
64
  )
53
65
  return router
@@ -124,4 +124,49 @@ class OAPRouter:
124
124
  # Exited loop because max_hops was reached without handoff clearing
125
125
  pass
126
126
 
127
- return current, visited
127
+ return current, visited
128
+
129
+ async def run_pipeline(
130
+ self,
131
+ envelope: TaskEnvelope,
132
+ agent_ids: list[str],
133
+ on_hop: object = None,
134
+ ) -> tuple[TaskEnvelope, list[str]]:
135
+ """Route through a fixed ordered list of agents, ignoring capability matching and handoffs.
136
+
137
+ Args:
138
+ envelope: The starting TaskEnvelope.
139
+ agent_ids: Ordered list of agent IDs to invoke in sequence.
140
+ on_hop: Optional callable(hop_number, total, agent_id) invoked after each hop.
141
+
142
+ Raises:
143
+ RoutingError: If any agent_id is not registered.
144
+
145
+ Returns:
146
+ A tuple of (final_envelope, agent_ids).
147
+ """
148
+ missing = [aid for aid in agent_ids if aid not in self._agents]
149
+ if missing:
150
+ raise RoutingError(
151
+ f"Pipeline agent(s) not found in registry: {', '.join(missing)}"
152
+ )
153
+
154
+ current = envelope
155
+ total = len(agent_ids)
156
+
157
+ for hop, agent_id in enumerate(agent_ids, 1):
158
+ adapter = self._agents[agent_id]
159
+ agent_input = adapter.to_agent_format(current)
160
+ agent_output = await adapter.invoke(agent_input)
161
+ current = adapter.to_envelope(agent_output, current)
162
+ current.handoff = None # pipeline ignores handoffs
163
+ current.add_step(
164
+ agent_id=agent_id,
165
+ action=f"Pipeline hop {hop}/{total}: {agent_id}",
166
+ result=agent_output,
167
+ )
168
+
169
+ if on_hop:
170
+ on_hop(hop, total, agent_id) # type: ignore[operator]
171
+
172
+ return current, agent_ids
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+
5
+ import httpx
6
+
7
+ from oap.envelope import TaskEnvelope
8
+
9
+ _RETRY_DELAYS = [1.0, 2.0, 4.0] # seconds between attempts 1→2, 2→3, 3→4
10
+
11
+
12
+ class HTTPTransport:
13
+ def __init__(self, base_url: str, timeout: float = 60.0):
14
+ self.base_url = base_url.rstrip("/")
15
+ self.timeout = timeout
16
+
17
+ async def get(self, path: str = "/") -> httpx.Response:
18
+ """Issue a GET request. Raises httpx exceptions on failure."""
19
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
20
+ return await client.get(f"{self.base_url}{path}")
21
+
22
+ async def invoke(self, envelope: TaskEnvelope) -> dict:
23
+ payload = envelope.model_dump(mode="json")
24
+ last_exc: Exception | None = None
25
+ max_attempts = len(_RETRY_DELAYS) + 1 # 4 total
26
+
27
+ for attempt in range(1, max_attempts + 1):
28
+ if attempt > 1:
29
+ await asyncio.sleep(_RETRY_DELAYS[attempt - 2])
30
+
31
+ try:
32
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
33
+ response = await client.post(
34
+ f"{self.base_url}/invoke",
35
+ json=payload,
36
+ )
37
+
38
+ if 400 <= response.status_code < 500:
39
+ # 4xx — fail immediately, do not retry
40
+ raise _make_error(
41
+ self.base_url, attempt,
42
+ f"HTTP {response.status_code} (not retried)",
43
+ retried=False,
44
+ )
45
+
46
+ if response.status_code >= 500:
47
+ last_exc = _make_error(
48
+ self.base_url, attempt,
49
+ f"HTTP {response.status_code}",
50
+ )
51
+ continue
52
+
53
+ return response.json()
54
+
55
+ except (httpx.ConnectError, httpx.ReadTimeout) as exc:
56
+ last_exc = exc
57
+ continue
58
+
59
+ raise _make_error(
60
+ self.base_url,
61
+ max_attempts,
62
+ str(last_exc),
63
+ retried=True,
64
+ )
65
+
66
+
67
+ def _make_error(
68
+ base_url: str,
69
+ attempts: int,
70
+ detail: str,
71
+ retried: bool = True,
72
+ ) -> Exception:
73
+ from oap.router import RoutingError
74
+
75
+ suffix = f" after {attempts} attempt(s)" if retried else ""
76
+ return RoutingError(f"Transport error for {base_url}{suffix}: {detail}")
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "open-agent-protocol"
7
- version = "0.2.0"
7
+ version = "0.2.2"
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"
@@ -239,3 +239,163 @@ def test_chain_cli_max_hops_flag(tmp_path, handoff_server):
239
239
 
240
240
  assert result.exit_code == 0, result.output
241
241
  assert "1 hop" in result.output
242
+
243
+
244
+ # ---------------------------------------------------------------------------
245
+ # Router-level run_pipeline() tests
246
+ # ---------------------------------------------------------------------------
247
+
248
+ async def test_pipeline_single_agent():
249
+ router = make_router(
250
+ ("agent-a", MockAgentAdapter("agent-a", response="done"), ["research"]),
251
+ )
252
+ envelope = TaskEnvelope(goal="research something")
253
+ result, visited = await router.run_pipeline(envelope, ["agent-a"])
254
+
255
+ assert visited == ["agent-a"]
256
+ assert len(result.steps_taken) == 1
257
+ assert result.memory["last_result"] == "done"
258
+
259
+
260
+ async def test_pipeline_three_hops_in_order():
261
+ router = make_router(
262
+ ("agent-a", MockAgentAdapter("agent-a", response="step 1"), ["research"]),
263
+ ("agent-b", MockAgentAdapter("agent-b", response="step 2"), ["write"]),
264
+ ("agent-c", MockAgentAdapter("agent-c", response="step 3"), ["translate"]),
265
+ )
266
+ envelope = TaskEnvelope(goal="do a thing")
267
+ result, visited = await router.run_pipeline(envelope, ["agent-a", "agent-b", "agent-c"])
268
+
269
+ assert visited == ["agent-a", "agent-b", "agent-c"]
270
+ assert len(result.steps_taken) == 3
271
+ assert result.memory["last_result"] == "step 3"
272
+
273
+
274
+ async def test_pipeline_ignores_handoff():
275
+ # agent-a wants to hand off to agent-c, but pipeline forces agent-b next
276
+ router = make_router(
277
+ ("agent-a", MockAgentAdapter("agent-a", response="step 1", next_agent="agent-c"), ["research"]),
278
+ ("agent-b", MockAgentAdapter("agent-b", response="step 2"), ["write"]),
279
+ )
280
+ envelope = TaskEnvelope(goal="research something")
281
+ result, visited = await router.run_pipeline(envelope, ["agent-a", "agent-b"])
282
+
283
+ assert visited == ["agent-a", "agent-b"]
284
+ assert result.handoff is None
285
+
286
+
287
+ async def test_pipeline_unknown_agent_raises():
288
+ router = make_router(
289
+ ("agent-a", MockAgentAdapter("agent-a"), ["research"]),
290
+ )
291
+ envelope = TaskEnvelope(goal="do something")
292
+ with pytest.raises(RoutingError, match="not found in registry"):
293
+ await router.run_pipeline(envelope, ["agent-a", "ghost-agent"])
294
+
295
+
296
+ async def test_pipeline_preserves_goal_and_id():
297
+ router = make_router(
298
+ ("agent-a", MockAgentAdapter("agent-a"), ["research"]),
299
+ ("agent-b", MockAgentAdapter("agent-b"), ["write"]),
300
+ )
301
+ envelope = TaskEnvelope(goal="research something")
302
+ result, _ = await router.run_pipeline(envelope, ["agent-a", "agent-b"])
303
+
304
+ assert result.goal == envelope.goal
305
+ assert result.id == envelope.id
306
+
307
+
308
+ async def test_pipeline_step_labels():
309
+ router = make_router(
310
+ ("agent-a", MockAgentAdapter("agent-a"), ["research"]),
311
+ ("agent-b", MockAgentAdapter("agent-b"), ["write"]),
312
+ )
313
+ envelope = TaskEnvelope(goal="research something")
314
+ result, _ = await router.run_pipeline(envelope, ["agent-a", "agent-b"])
315
+
316
+ assert "1/2" in result.steps_taken[0].action
317
+ assert "2/2" in result.steps_taken[1].action
318
+
319
+
320
+ # ---------------------------------------------------------------------------
321
+ # CLI --pipeline tests
322
+ # ---------------------------------------------------------------------------
323
+
324
+ def test_pipeline_cli_three_hops(tmp_path, handoff_server):
325
+ HandoffAgentHandler.call_count = 99 # force "done" response for all calls
326
+
327
+ task = tmp_path / "task.json"
328
+ out = tmp_path / "final.json"
329
+ runner.invoke(app, ["init", "do a thing", "--output", str(task)])
330
+ runner.invoke(app, ["register", "agent-a", handoff_server, "--capabilities", "research"])
331
+ runner.invoke(app, ["register", "agent-b", handoff_server, "--capabilities", "write"])
332
+ runner.invoke(app, ["register", "agent-c", handoff_server, "--capabilities", "translate"])
333
+
334
+ result = runner.invoke(app, [
335
+ "chain", str(task),
336
+ "--pipeline", "agent-a,agent-b,agent-c",
337
+ "--output", str(out),
338
+ ])
339
+
340
+ assert result.exit_code == 0, result.output
341
+ assert "hop 1/3" in result.output
342
+ assert "hop 2/3" in result.output
343
+ assert "hop 3/3" in result.output
344
+ assert "3 hop(s)" in result.output
345
+ assert out.exists()
346
+ data = json.loads(out.read_text())
347
+ assert len(data["steps_taken"]) == 3
348
+
349
+
350
+ def test_pipeline_cli_single_agent(tmp_path, handoff_server):
351
+ HandoffAgentHandler.call_count = 99
352
+
353
+ task = tmp_path / "task.json"
354
+ out = tmp_path / "final.json"
355
+ runner.invoke(app, ["init", "do a thing", "--output", str(task)])
356
+ runner.invoke(app, ["register", "agent-a", handoff_server, "--capabilities", "research"])
357
+
358
+ result = runner.invoke(app, [
359
+ "chain", str(task),
360
+ "--pipeline", "agent-a",
361
+ "--output", str(out),
362
+ ])
363
+
364
+ assert result.exit_code == 0, result.output
365
+ assert "1 hop(s)" in result.output
366
+ assert out.exists()
367
+
368
+
369
+ def test_pipeline_cli_unknown_agent(tmp_path, handoff_server):
370
+ task = tmp_path / "task.json"
371
+ runner.invoke(app, ["init", "do a thing", "--output", str(task)])
372
+ runner.invoke(app, ["register", "agent-a", handoff_server, "--capabilities", "research"])
373
+
374
+ result = runner.invoke(app, [
375
+ "chain", str(task),
376
+ "--pipeline", "agent-a,ghost-agent",
377
+ ])
378
+
379
+ assert result.exit_code == 1
380
+ assert "Routing failed" in result.output
381
+ assert "ghost-agent" in result.output
382
+
383
+
384
+ def test_pipeline_cli_output_saved(tmp_path, handoff_server):
385
+ HandoffAgentHandler.call_count = 99
386
+
387
+ task = tmp_path / "task.json"
388
+ out = tmp_path / "final.json"
389
+ runner.invoke(app, ["init", "do a thing", "--output", str(task)])
390
+ runner.invoke(app, ["register", "agent-a", handoff_server, "--capabilities", "research"])
391
+
392
+ runner.invoke(app, [
393
+ "chain", str(task),
394
+ "--pipeline", "agent-a",
395
+ "--output", str(out),
396
+ ])
397
+
398
+ assert out.exists()
399
+ data = json.loads(out.read_text())
400
+ assert data["goal"] == "do a thing"
401
+ assert len(data["steps_taken"]) == 1
@@ -20,6 +20,7 @@ def test_add_and_list():
20
20
  "id": "agent-a",
21
21
  "url": "http://localhost:9000",
22
22
  "capabilities": ["research", "find"],
23
+ "timeout": 60.0,
23
24
  }
24
25
 
25
26
 
@@ -0,0 +1,263 @@
1
+ """Tests for HTTPTransport retry/backoff logic and oap ping CLI command."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import threading
6
+ from http.server import BaseHTTPRequestHandler, HTTPServer
7
+
8
+ import httpx
9
+ import pytest
10
+ import respx
11
+ from typer.testing import CliRunner
12
+
13
+ from oap.cli import app
14
+ from oap.envelope import TaskEnvelope
15
+ from oap.router import RoutingError
16
+ from oap.transport.http import HTTPTransport
17
+ import oap.registry as reg
18
+
19
+ runner = CliRunner()
20
+ _PING_PORT = 19996
21
+
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Fake server for ping tests
25
+ # ---------------------------------------------------------------------------
26
+
27
+ class PingHandler(BaseHTTPRequestHandler):
28
+ def do_GET(self):
29
+ self.send_response(200)
30
+ self.end_headers()
31
+ self.wfile.write(b"ok")
32
+
33
+ def do_POST(self):
34
+ length = int(self.headers["Content-Length"])
35
+ self.rfile.read(length)
36
+ body = json.dumps({"result": "ok", "memory": {}})
37
+ self.send_response(200)
38
+ self.send_header("Content-Type", "application/json")
39
+ self.end_headers()
40
+ self.wfile.write(body.encode())
41
+
42
+ def log_message(self, format, *args):
43
+ pass
44
+
45
+
46
+ @pytest.fixture(scope="module")
47
+ def ping_server():
48
+ server = HTTPServer(("localhost", _PING_PORT), PingHandler)
49
+ thread = threading.Thread(target=server.serve_forever)
50
+ thread.daemon = True
51
+ thread.start()
52
+ yield f"http://localhost:{_PING_PORT}"
53
+ server.shutdown()
54
+
55
+
56
+ @pytest.fixture(autouse=True)
57
+ def isolated_registry(tmp_path, monkeypatch):
58
+ monkeypatch.setattr(reg, "_REGISTRY_PATH", tmp_path / "agents.json")
59
+
60
+
61
+ # ---------------------------------------------------------------------------
62
+ # HTTPTransport retry tests (using respx to mock httpx)
63
+ # ---------------------------------------------------------------------------
64
+
65
+ @respx.mock
66
+ async def test_retry_succeeds_on_second_attempt():
67
+ """ReadTimeout on attempt 1, success on attempt 2."""
68
+ envelope = TaskEnvelope(goal="test")
69
+ transport = HTTPTransport(base_url="http://fake-agent", timeout=1.0)
70
+
71
+ route = respx.post("http://fake-agent/invoke")
72
+ route.side_effect = [
73
+ httpx.ReadTimeout("timed out"),
74
+ httpx.Response(200, json={"result": "ok", "memory": {}}),
75
+ ]
76
+
77
+ # Patch sleep so the test doesn't actually wait
78
+ import oap.transport.http as http_mod
79
+ original_sleep = http_mod.asyncio.sleep
80
+ http_mod.asyncio.sleep = lambda _: original_sleep(0)
81
+
82
+ result = await transport.invoke(envelope)
83
+ assert result["result"] == "ok"
84
+ assert route.call_count == 2
85
+
86
+ http_mod.asyncio.sleep = original_sleep
87
+
88
+
89
+ @respx.mock
90
+ async def test_retry_exhausted_raises_routing_error():
91
+ """All 4 attempts fail — raises RoutingError mentioning attempt count."""
92
+ envelope = TaskEnvelope(goal="test")
93
+ transport = HTTPTransport(base_url="http://fake-agent", timeout=1.0)
94
+
95
+ respx.post("http://fake-agent/invoke").mock(
96
+ side_effect=httpx.ReadTimeout("timed out")
97
+ )
98
+
99
+ import oap.transport.http as http_mod
100
+ original_sleep = http_mod.asyncio.sleep
101
+ http_mod.asyncio.sleep = lambda _: original_sleep(0)
102
+
103
+ with pytest.raises(RoutingError) as exc_info:
104
+ await transport.invoke(envelope)
105
+
106
+ http_mod.asyncio.sleep = original_sleep
107
+
108
+ assert "4 attempt(s)" in str(exc_info.value)
109
+ assert "http://fake-agent" in str(exc_info.value)
110
+
111
+
112
+ @respx.mock
113
+ async def test_connect_error_triggers_retry():
114
+ """ConnectError on attempt 1, success on attempt 2."""
115
+ envelope = TaskEnvelope(goal="test")
116
+ transport = HTTPTransport(base_url="http://fake-agent", timeout=1.0)
117
+
118
+ route = respx.post("http://fake-agent/invoke")
119
+ route.side_effect = [
120
+ httpx.ConnectError("connection refused"),
121
+ httpx.Response(200, json={"result": "ok", "memory": {}}),
122
+ ]
123
+
124
+ import oap.transport.http as http_mod
125
+ original_sleep = http_mod.asyncio.sleep
126
+ http_mod.asyncio.sleep = lambda _: original_sleep(0)
127
+
128
+ result = await transport.invoke(envelope)
129
+ assert result["result"] == "ok"
130
+ assert route.call_count == 2
131
+
132
+ http_mod.asyncio.sleep = original_sleep
133
+
134
+
135
+ @respx.mock
136
+ async def test_5xx_triggers_retry():
137
+ """500 on attempt 1, success on attempt 2."""
138
+ envelope = TaskEnvelope(goal="test")
139
+ transport = HTTPTransport(base_url="http://fake-agent", timeout=1.0)
140
+
141
+ route = respx.post("http://fake-agent/invoke")
142
+ route.side_effect = [
143
+ httpx.Response(500, text="internal error"),
144
+ httpx.Response(200, json={"result": "ok", "memory": {}}),
145
+ ]
146
+
147
+ import oap.transport.http as http_mod
148
+ original_sleep = http_mod.asyncio.sleep
149
+ http_mod.asyncio.sleep = lambda _: original_sleep(0)
150
+
151
+ result = await transport.invoke(envelope)
152
+ assert result["result"] == "ok"
153
+ assert route.call_count == 2
154
+
155
+ http_mod.asyncio.sleep = original_sleep
156
+
157
+
158
+ @respx.mock
159
+ async def test_4xx_does_not_retry():
160
+ """404 fails immediately without retrying."""
161
+ envelope = TaskEnvelope(goal="test")
162
+ transport = HTTPTransport(base_url="http://fake-agent", timeout=1.0)
163
+
164
+ route = respx.post("http://fake-agent/invoke").mock(
165
+ return_value=httpx.Response(404, text="not found")
166
+ )
167
+
168
+ with pytest.raises(RoutingError) as exc_info:
169
+ await transport.invoke(envelope)
170
+
171
+ assert route.call_count == 1
172
+ assert "HTTP 404" in str(exc_info.value)
173
+ assert "not retried" in str(exc_info.value)
174
+
175
+
176
+ # ---------------------------------------------------------------------------
177
+ # Timeout wired through registry → HTTPAdapter → HTTPTransport
178
+ # ---------------------------------------------------------------------------
179
+
180
+ def test_timeout_saved_in_registry():
181
+ reg.add("agent-a", "http://localhost:9000", ["research"], timeout=120.0)
182
+ entries = reg.list_all()
183
+ assert entries[0]["timeout"] == 120.0
184
+
185
+
186
+ def test_timeout_default_in_registry():
187
+ reg.add("agent-a", "http://localhost:9000", ["research"])
188
+ entries = reg.list_all()
189
+ assert entries[0]["timeout"] == 60.0
190
+
191
+
192
+ def test_timeout_passed_to_http_adapter():
193
+ reg.add("agent-a", "http://localhost:9000", ["research"], timeout=45.0)
194
+ router = reg.load_router()
195
+ # Reach into the adapter to verify timeout was wired through
196
+ adapter = router._agents["agent-a"]
197
+ assert adapter.transport.timeout == 45.0
198
+
199
+
200
+ def test_register_cli_saves_timeout(ping_server):
201
+ result = runner.invoke(app, [
202
+ "register", "agent-a", ping_server,
203
+ "--capabilities", "research",
204
+ "--timeout", "90",
205
+ ])
206
+ assert result.exit_code == 0
207
+ assert "90" in result.output
208
+ entries = reg.list_all()
209
+ assert entries[0]["timeout"] == 90.0
210
+
211
+
212
+ # ---------------------------------------------------------------------------
213
+ # oap ping tests
214
+ # ---------------------------------------------------------------------------
215
+
216
+ def test_ping_alive_agent(ping_server):
217
+ runner.invoke(app, ["register", "agent-a", ping_server, "--capabilities", "research"])
218
+ result = runner.invoke(app, ["ping"])
219
+ assert result.exit_code == 0
220
+ assert "alive" in result.output
221
+ assert "agent-a" in result.output
222
+
223
+
224
+ def test_ping_dead_agent():
225
+ runner.invoke(app, ["register", "dead-agent", "http://localhost:19990", "--capabilities", "research"])
226
+ result = runner.invoke(app, ["ping"])
227
+ assert result.exit_code == 1
228
+ assert "dead" in result.output
229
+ assert "dead-agent" in result.output
230
+
231
+
232
+ def test_ping_mixed_exits_1(ping_server):
233
+ """One alive, one dead → exit code 1."""
234
+ runner.invoke(app, ["register", "alive-agent", ping_server, "--capabilities", "research"])
235
+ runner.invoke(app, ["register", "dead-agent", "http://localhost:19990", "--capabilities", "code"])
236
+ result = runner.invoke(app, ["ping"])
237
+ assert result.exit_code == 1
238
+ assert "alive" in result.output
239
+ assert "dead" in result.output
240
+
241
+
242
+ def test_ping_no_health_endpoint(ping_server):
243
+ """A 404 on GET / is 'no health endpoint', not dead — exit 0."""
244
+ # The fake_agent_server returns 404 for GET /
245
+ from tests.fake_agent_server import FakeAgentHandler
246
+ _port = 19995
247
+ server = HTTPServer(("localhost", _port), FakeAgentHandler)
248
+ t = threading.Thread(target=server.serve_forever)
249
+ t.daemon = True
250
+ t.start()
251
+
252
+ runner.invoke(app, ["register", "no-health", f"http://localhost:{_port}", "--capabilities", "research"])
253
+ result = runner.invoke(app, ["ping"])
254
+ assert result.exit_code == 0
255
+ assert "no health endpoint" in result.output
256
+
257
+ server.shutdown()
258
+
259
+
260
+ def test_ping_no_agents_registered():
261
+ result = runner.invoke(app, ["ping"])
262
+ assert result.exit_code == 0
263
+ assert "No agents registered" in result.output
@@ -1,19 +0,0 @@
1
- from __future__ import annotations
2
- import httpx
3
- from oap.envelope import TaskEnvelope
4
-
5
-
6
- class HTTPTransport:
7
- def __init__(self, base_url: str, timeout: float = 30.0):
8
- self.base_url = base_url.rstrip("/")
9
- self.timeout = timeout
10
-
11
- async def invoke(self, envelope: TaskEnvelope) -> dict:
12
- payload = envelope.model_dump(mode="json")
13
- async with httpx.AsyncClient(timeout=self.timeout) as client:
14
- response = await client.post(
15
- f"{self.base_url}/invoke",
16
- json=payload,
17
- )
18
- response.raise_for_status()
19
- return response.json()