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.
- {open_agent_protocol-0.2.0 → open_agent_protocol-0.2.2}/PKG-INFO +43 -8
- {open_agent_protocol-0.2.0 → open_agent_protocol-0.2.2}/README.md +42 -7
- {open_agent_protocol-0.2.0 → open_agent_protocol-0.2.2}/oap/cli.py +88 -8
- {open_agent_protocol-0.2.0 → open_agent_protocol-0.2.2}/oap/registry.py +16 -4
- {open_agent_protocol-0.2.0 → open_agent_protocol-0.2.2}/oap/router.py +46 -1
- open_agent_protocol-0.2.2/oap/transport/http.py +76 -0
- {open_agent_protocol-0.2.0 → open_agent_protocol-0.2.2}/pyproject.toml +1 -1
- {open_agent_protocol-0.2.0 → open_agent_protocol-0.2.2}/tests/test_chain.py +160 -0
- {open_agent_protocol-0.2.0 → open_agent_protocol-0.2.2}/tests/test_registry.py +1 -0
- open_agent_protocol-0.2.2/tests/test_transport.py +263 -0
- open_agent_protocol-0.2.0/oap/transport/http.py +0 -19
- {open_agent_protocol-0.2.0 → open_agent_protocol-0.2.2}/.gitignore +0 -0
- {open_agent_protocol-0.2.0 → open_agent_protocol-0.2.2}/LICENSE +0 -0
- {open_agent_protocol-0.2.0 → open_agent_protocol-0.2.2}/oap/__init__.py +0 -0
- {open_agent_protocol-0.2.0 → open_agent_protocol-0.2.2}/oap/adapters/__init__.py +0 -0
- {open_agent_protocol-0.2.0 → open_agent_protocol-0.2.2}/oap/adapters/base.py +0 -0
- {open_agent_protocol-0.2.0 → open_agent_protocol-0.2.2}/oap/adapters/http.py +0 -0
- {open_agent_protocol-0.2.0 → open_agent_protocol-0.2.2}/oap/adapters/mock.py +0 -0
- {open_agent_protocol-0.2.0 → open_agent_protocol-0.2.2}/oap/envelope.py +0 -0
- {open_agent_protocol-0.2.0 → open_agent_protocol-0.2.2}/oap/transport/__init__.py +0 -0
- {open_agent_protocol-0.2.0 → open_agent_protocol-0.2.2}/py.typed +0 -0
- {open_agent_protocol-0.2.0 → open_agent_protocol-0.2.2}/tests/__init__.py +0 -0
- {open_agent_protocol-0.2.0 → open_agent_protocol-0.2.2}/tests/fake_agent_server.py +0 -0
- {open_agent_protocol-0.2.0 → open_agent_protocol-0.2.2}/tests/test_cli.py +0 -0
- {open_agent_protocol-0.2.0 → open_agent_protocol-0.2.2}/tests/test_envelope.py +0 -0
- {open_agent_protocol-0.2.0 → open_agent_protocol-0.2.2}/tests/test_http_adapter.py +0 -0
- {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.
|
|
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
|
-
#
|
|
68
|
-
oap register research-agent http://localhost:9000 --capabilities "research,search"
|
|
67
|
+
# Register an HTTP agent in the local registry
|
|
68
|
+
oap register research-agent http://localhost:9000 --capabilities "research,search,find"
|
|
69
69
|
|
|
70
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
38
|
-
oap register research-agent http://localhost:9000 --capabilities "research,search"
|
|
37
|
+
# Register an HTTP agent in the local registry
|
|
38
|
+
oap register research-agent http://localhost:9000 --capabilities "research,search,find"
|
|
39
39
|
|
|
40
|
-
#
|
|
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
|
-
#
|
|
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
|
|
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
|
-
|
|
180
|
-
|
|
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(
|
|
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
|
-
{
|
|
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}")
|
|
@@ -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
|
|
@@ -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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|