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.
- {open_agent_protocol-0.2.2 → open_agent_protocol-0.2.3}/PKG-INFO +28 -3
- {open_agent_protocol-0.2.2 → open_agent_protocol-0.2.3}/README.md +27 -2
- {open_agent_protocol-0.2.2 → open_agent_protocol-0.2.3}/oap/cli.py +107 -16
- {open_agent_protocol-0.2.2 → open_agent_protocol-0.2.3}/oap/registry.py +8 -1
- open_agent_protocol-0.2.3/oap-demo/research_agent.py +47 -0
- open_agent_protocol-0.2.3/oap-demo/translator_agent.py +47 -0
- open_agent_protocol-0.2.3/oap-demo/writer_agent.py +47 -0
- {open_agent_protocol-0.2.2 → open_agent_protocol-0.2.3}/pyproject.toml +1 -1
- open_agent_protocol-0.2.3/tests/test_register.py +229 -0
- {open_agent_protocol-0.2.2 → open_agent_protocol-0.2.3}/tests/test_registry.py +1 -0
- {open_agent_protocol-0.2.2 → open_agent_protocol-0.2.3}/.gitignore +0 -0
- {open_agent_protocol-0.2.2 → open_agent_protocol-0.2.3}/LICENSE +0 -0
- {open_agent_protocol-0.2.2 → open_agent_protocol-0.2.3}/oap/__init__.py +0 -0
- {open_agent_protocol-0.2.2 → open_agent_protocol-0.2.3}/oap/adapters/__init__.py +0 -0
- {open_agent_protocol-0.2.2 → open_agent_protocol-0.2.3}/oap/adapters/base.py +0 -0
- {open_agent_protocol-0.2.2 → open_agent_protocol-0.2.3}/oap/adapters/http.py +0 -0
- {open_agent_protocol-0.2.2 → open_agent_protocol-0.2.3}/oap/adapters/mock.py +0 -0
- {open_agent_protocol-0.2.2 → open_agent_protocol-0.2.3}/oap/envelope.py +0 -0
- {open_agent_protocol-0.2.2 → open_agent_protocol-0.2.3}/oap/router.py +0 -0
- {open_agent_protocol-0.2.2 → open_agent_protocol-0.2.3}/oap/transport/__init__.py +0 -0
- {open_agent_protocol-0.2.2 → open_agent_protocol-0.2.3}/oap/transport/http.py +0 -0
- {open_agent_protocol-0.2.2 → open_agent_protocol-0.2.3}/py.typed +0 -0
- {open_agent_protocol-0.2.2 → open_agent_protocol-0.2.3}/tests/__init__.py +0 -0
- {open_agent_protocol-0.2.2 → open_agent_protocol-0.2.3}/tests/fake_agent_server.py +0 -0
- {open_agent_protocol-0.2.2 → open_agent_protocol-0.2.3}/tests/test_chain.py +0 -0
- {open_agent_protocol-0.2.2 → open_agent_protocol-0.2.3}/tests/test_cli.py +0 -0
- {open_agent_protocol-0.2.2 → open_agent_protocol-0.2.3}/tests/test_envelope.py +0 -0
- {open_agent_protocol-0.2.2 → open_agent_protocol-0.2.3}/tests/test_http_adapter.py +0 -0
- {open_agent_protocol-0.2.2 → open_agent_protocol-0.2.3}/tests/test_router.py +0 -0
- {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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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 {
|
|
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
|
|
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(
|
|
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
|
-
|
|
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] = {
|
|
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)
|
|
@@ -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"] == ""
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|