open-agent-protocol 0.1.0__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.1.0/.gitignore +25 -0
- open_agent_protocol-0.1.0/LICENSE +21 -0
- open_agent_protocol-0.1.0/PKG-INFO +92 -0
- open_agent_protocol-0.1.0/README.md +62 -0
- open_agent_protocol-0.1.0/oap/__init__.py +11 -0
- open_agent_protocol-0.1.0/oap/adapters/__init__.py +4 -0
- open_agent_protocol-0.1.0/oap/adapters/base.py +22 -0
- open_agent_protocol-0.1.0/oap/adapters/http.py +38 -0
- open_agent_protocol-0.1.0/oap/adapters/mock.py +23 -0
- open_agent_protocol-0.1.0/oap/cli.py +195 -0
- open_agent_protocol-0.1.0/oap/envelope.py +41 -0
- open_agent_protocol-0.1.0/oap/router.py +92 -0
- open_agent_protocol-0.1.0/oap/transport/__init__.py +3 -0
- open_agent_protocol-0.1.0/oap/transport/http.py +19 -0
- open_agent_protocol-0.1.0/py.typed +0 -0
- open_agent_protocol-0.1.0/pyproject.toml +50 -0
- open_agent_protocol-0.1.0/tests/__init__.py +0 -0
- open_agent_protocol-0.1.0/tests/fake_agent_server.py +45 -0
- open_agent_protocol-0.1.0/tests/test_cli.py +131 -0
- open_agent_protocol-0.1.0/tests/test_envelope.py +63 -0
- open_agent_protocol-0.1.0/tests/test_http_adapter.py +71 -0
- open_agent_protocol-0.1.0/tests/test_router.py +92 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
dist/
|
|
6
|
+
build/
|
|
7
|
+
.eggs/
|
|
8
|
+
|
|
9
|
+
# Virtual environment
|
|
10
|
+
venv/
|
|
11
|
+
.venv/
|
|
12
|
+
|
|
13
|
+
# Pytest
|
|
14
|
+
.pytest_cache/
|
|
15
|
+
|
|
16
|
+
# Typing
|
|
17
|
+
.mypy_cache/
|
|
18
|
+
.pyright/
|
|
19
|
+
|
|
20
|
+
# Editor
|
|
21
|
+
.vscode/
|
|
22
|
+
.idea/
|
|
23
|
+
|
|
24
|
+
# OS
|
|
25
|
+
.DS_Store
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 OAP Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: open-agent-protocol
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Open Agent Protocol — a routing layer for inter-agent task handoff
|
|
5
|
+
Project-URL: Repository, https://github.com/Adam-Abinsha-vahab-Baker/OAP
|
|
6
|
+
Project-URL: Issues, https://github.com/Adam-Abinsha-vahab-Baker/OAP/issues
|
|
7
|
+
Author: OAP Contributors
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: agents,llm,multi-agent,routing,task-handoff
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Requires-Dist: httpx<1.0,>=0.27
|
|
22
|
+
Requires-Dist: pydantic<3.0,>=2.0
|
|
23
|
+
Requires-Dist: rich<14.0,>=13.0
|
|
24
|
+
Requires-Dist: typer<1.0,>=0.12
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# OAP — Open Agent Protocol
|
|
32
|
+
|
|
33
|
+
A lightweight routing layer for passing tasks between AI agents.
|
|
34
|
+
|
|
35
|
+
OAP defines a standard envelope format (`TaskEnvelope`) and a router that dispatches tasks to the right agent based on capabilities or explicit handoff instructions.
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install open-agent-protocol
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Quick start
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from oap import TaskEnvelope, OAPRouter
|
|
47
|
+
from oap.adapters.http import HTTPAdapter
|
|
48
|
+
|
|
49
|
+
router = OAPRouter()
|
|
50
|
+
router.register(
|
|
51
|
+
"research-agent",
|
|
52
|
+
HTTPAdapter(agent_id="research-agent", base_url="http://localhost:9000"),
|
|
53
|
+
capabilities=["research", "search", "find"],
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
envelope = TaskEnvelope(goal="research the best vector databases")
|
|
57
|
+
result = await router.route(envelope)
|
|
58
|
+
print(result.memory["last_result"])
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## CLI
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
# Create a new task envelope
|
|
65
|
+
oap init "research the best vector databases" --output task.json
|
|
66
|
+
|
|
67
|
+
# Route it to an HTTP agent
|
|
68
|
+
oap register research-agent http://localhost:9000 --capabilities "research,search" task.json --output result.json
|
|
69
|
+
|
|
70
|
+
# Inspect the result
|
|
71
|
+
oap inspect result.json
|
|
72
|
+
|
|
73
|
+
# Validate envelope structure
|
|
74
|
+
oap validate result.json
|
|
75
|
+
|
|
76
|
+
# Route using built-in demo agents
|
|
77
|
+
oap route task.json
|
|
78
|
+
|
|
79
|
+
# List demo agents
|
|
80
|
+
oap agents
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Concepts
|
|
84
|
+
|
|
85
|
+
- **TaskEnvelope** — the standard task object passed between agents. Contains the goal, memory, steps taken, and optional constraints.
|
|
86
|
+
- **OAPRouter** — selects the best registered agent for a given envelope and invokes it.
|
|
87
|
+
- **AgentAdapter** — translates between the envelope format and an agent's native interface.
|
|
88
|
+
- **HTTPAdapter** — built-in adapter for agents that expose a `POST /invoke` endpoint.
|
|
89
|
+
|
|
90
|
+
## License
|
|
91
|
+
|
|
92
|
+
MIT
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# OAP — Open Agent Protocol
|
|
2
|
+
|
|
3
|
+
A lightweight routing layer for passing tasks between AI agents.
|
|
4
|
+
|
|
5
|
+
OAP defines a standard envelope format (`TaskEnvelope`) and a router that dispatches tasks to the right agent based on capabilities or explicit handoff instructions.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install open-agent-protocol
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from oap import TaskEnvelope, OAPRouter
|
|
17
|
+
from oap.adapters.http import HTTPAdapter
|
|
18
|
+
|
|
19
|
+
router = OAPRouter()
|
|
20
|
+
router.register(
|
|
21
|
+
"research-agent",
|
|
22
|
+
HTTPAdapter(agent_id="research-agent", base_url="http://localhost:9000"),
|
|
23
|
+
capabilities=["research", "search", "find"],
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
envelope = TaskEnvelope(goal="research the best vector databases")
|
|
27
|
+
result = await router.route(envelope)
|
|
28
|
+
print(result.memory["last_result"])
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## CLI
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
# Create a new task envelope
|
|
35
|
+
oap init "research the best vector databases" --output task.json
|
|
36
|
+
|
|
37
|
+
# Route it to an HTTP agent
|
|
38
|
+
oap register research-agent http://localhost:9000 --capabilities "research,search" task.json --output result.json
|
|
39
|
+
|
|
40
|
+
# Inspect the result
|
|
41
|
+
oap inspect result.json
|
|
42
|
+
|
|
43
|
+
# Validate envelope structure
|
|
44
|
+
oap validate result.json
|
|
45
|
+
|
|
46
|
+
# Route using built-in demo agents
|
|
47
|
+
oap route task.json
|
|
48
|
+
|
|
49
|
+
# List demo agents
|
|
50
|
+
oap agents
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Concepts
|
|
54
|
+
|
|
55
|
+
- **TaskEnvelope** — the standard task object passed between agents. Contains the goal, memory, steps taken, and optional constraints.
|
|
56
|
+
- **OAPRouter** — selects the best registered agent for a given envelope and invokes it.
|
|
57
|
+
- **AgentAdapter** — translates between the envelope format and an agent's native interface.
|
|
58
|
+
- **HTTPAdapter** — built-in adapter for agents that expose a `POST /invoke` endpoint.
|
|
59
|
+
|
|
60
|
+
## License
|
|
61
|
+
|
|
62
|
+
MIT
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from typing import Any
|
|
4
|
+
from oap.envelope import TaskEnvelope
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AgentAdapter(ABC):
|
|
8
|
+
|
|
9
|
+
@abstractmethod
|
|
10
|
+
def to_agent_format(self, envelope: TaskEnvelope) -> Any:
|
|
11
|
+
"""Translate a TaskEnvelope into whatever format this agent expects."""
|
|
12
|
+
...
|
|
13
|
+
|
|
14
|
+
@abstractmethod
|
|
15
|
+
async def invoke(self, agent_input: Any) -> Any:
|
|
16
|
+
"""Call the agent and return its raw output."""
|
|
17
|
+
...
|
|
18
|
+
|
|
19
|
+
@abstractmethod
|
|
20
|
+
def to_envelope(self, agent_output: Any, previous: TaskEnvelope) -> TaskEnvelope:
|
|
21
|
+
"""Translate the agent's output back into a TaskEnvelope."""
|
|
22
|
+
...
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Any
|
|
3
|
+
from oap.adapters.base import AgentAdapter
|
|
4
|
+
from oap.envelope import TaskEnvelope
|
|
5
|
+
from oap.transport.http import HTTPTransport
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class HTTPAdapter(AgentAdapter):
|
|
9
|
+
"""Adapter for any agent that exposes a POST /invoke endpoint."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, agent_id: str, base_url: str, timeout: float = 30.0):
|
|
12
|
+
self.agent_id = agent_id
|
|
13
|
+
self.transport = HTTPTransport(base_url=base_url, timeout=timeout)
|
|
14
|
+
|
|
15
|
+
def to_agent_format(self, envelope: TaskEnvelope) -> TaskEnvelope:
|
|
16
|
+
return envelope
|
|
17
|
+
|
|
18
|
+
async def invoke(self, agent_input: Any) -> dict:
|
|
19
|
+
return await self.transport.invoke(agent_input)
|
|
20
|
+
|
|
21
|
+
def to_envelope(self, agent_output: dict, previous: TaskEnvelope) -> TaskEnvelope:
|
|
22
|
+
updated = previous.model_copy(deep=True)
|
|
23
|
+
|
|
24
|
+
if "memory" in agent_output:
|
|
25
|
+
updated.memory.update(agent_output["memory"])
|
|
26
|
+
|
|
27
|
+
if "result" in agent_output:
|
|
28
|
+
updated.memory["last_result"] = agent_output["result"]
|
|
29
|
+
|
|
30
|
+
if "handoff" in agent_output and agent_output["handoff"]:
|
|
31
|
+
h = agent_output["handoff"]
|
|
32
|
+
updated.with_handoff(
|
|
33
|
+
next_agent=h["next_agent"],
|
|
34
|
+
reason=h.get("reason", ""),
|
|
35
|
+
partial_result=h.get("partial_result"),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
return updated
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Any
|
|
3
|
+
from oap.adapters.base import AgentAdapter
|
|
4
|
+
from oap.envelope import TaskEnvelope
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class MockAgentAdapter(AgentAdapter):
|
|
8
|
+
"""A fake agent that returns a canned response. Used for testing."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, agent_id: str, response: str = "Mock result"):
|
|
11
|
+
self.agent_id = agent_id
|
|
12
|
+
self.response = response
|
|
13
|
+
|
|
14
|
+
def to_agent_format(self, envelope: TaskEnvelope) -> dict:
|
|
15
|
+
return {"goal": envelope.goal, "memory": envelope.memory}
|
|
16
|
+
|
|
17
|
+
async def invoke(self, agent_input: Any) -> dict:
|
|
18
|
+
return {"result": self.response, "goal": agent_input["goal"]}
|
|
19
|
+
|
|
20
|
+
def to_envelope(self, agent_output: Any, previous: TaskEnvelope) -> TaskEnvelope:
|
|
21
|
+
updated = previous.model_copy(deep=True)
|
|
22
|
+
updated.memory["last_result"] = agent_output.get("result")
|
|
23
|
+
return updated
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.syntax import Syntax
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
from oap.envelope import TaskEnvelope
|
|
12
|
+
from oap.router import OAPRouter, RoutingError
|
|
13
|
+
from oap.adapters.mock import MockAgentAdapter
|
|
14
|
+
from oap.adapters.http import HTTPAdapter
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(
|
|
17
|
+
name="oap",
|
|
18
|
+
help="Open Agent Protocol — route tasks between agents.",
|
|
19
|
+
add_completion=False,
|
|
20
|
+
)
|
|
21
|
+
console = Console()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _build_demo_router() -> OAPRouter:
|
|
25
|
+
"""A router pre-loaded with mock agents for local testing."""
|
|
26
|
+
router = OAPRouter()
|
|
27
|
+
router.register(
|
|
28
|
+
"research-agent",
|
|
29
|
+
MockAgentAdapter("research-agent", response="Found 5 relevant sources."),
|
|
30
|
+
capabilities=["research", "find", "search", "summarise"],
|
|
31
|
+
)
|
|
32
|
+
router.register(
|
|
33
|
+
"coding-agent",
|
|
34
|
+
MockAgentAdapter("coding-agent", response="Code written and tested."),
|
|
35
|
+
capabilities=["code", "implement", "debug", "refactor"],
|
|
36
|
+
)
|
|
37
|
+
return router
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@app.command()
|
|
41
|
+
def init(
|
|
42
|
+
goal: str = typer.Argument(..., help="The task goal for this envelope"),
|
|
43
|
+
output: Optional[Path] = typer.Option(None, "--output", "-o", help="Save envelope to file"),
|
|
44
|
+
):
|
|
45
|
+
"""Create a new TaskEnvelope and print it."""
|
|
46
|
+
envelope = TaskEnvelope(goal=goal)
|
|
47
|
+
json_str = envelope.model_dump_json(indent=2)
|
|
48
|
+
|
|
49
|
+
console.print(Syntax(json_str, "json", theme="monokai"))
|
|
50
|
+
|
|
51
|
+
if output:
|
|
52
|
+
output.write_text(json_str)
|
|
53
|
+
console.print(f"\n[green]Saved to {output}[/green]")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@app.command()
|
|
57
|
+
def inspect(
|
|
58
|
+
file: Path = typer.Argument(..., help="Path to a TaskEnvelope JSON file"),
|
|
59
|
+
):
|
|
60
|
+
"""Inspect a TaskEnvelope file and summarise its contents."""
|
|
61
|
+
if not file.exists():
|
|
62
|
+
console.print(f"[red]File not found: {file}[/red]")
|
|
63
|
+
raise typer.Exit(1)
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
envelope = TaskEnvelope.model_validate_json(file.read_text())
|
|
67
|
+
except Exception as e:
|
|
68
|
+
console.print(f"[red]Invalid envelope: {e}[/red]")
|
|
69
|
+
raise typer.Exit(1)
|
|
70
|
+
|
|
71
|
+
console.print(f"\n[bold]Envelope[/bold] [dim]{envelope.id}[/dim]")
|
|
72
|
+
console.print(f"[bold]Goal:[/bold] {envelope.goal}")
|
|
73
|
+
console.print(f"[bold]Version:[/bold] {envelope.version}")
|
|
74
|
+
|
|
75
|
+
table = Table(show_header=True, header_style="bold dim")
|
|
76
|
+
table.add_column("Field")
|
|
77
|
+
table.add_column("Value")
|
|
78
|
+
table.add_row("Steps taken", str(len(envelope.steps_taken)))
|
|
79
|
+
table.add_row("Memory keys", ", ".join(envelope.memory.keys()) or "—")
|
|
80
|
+
table.add_row("Max cost", str(envelope.constraints.max_cost_usd or "—"))
|
|
81
|
+
table.add_row("Allowed tools", ", ".join(envelope.constraints.allowed_tools or []) or "—")
|
|
82
|
+
table.add_row("Handoff", envelope.handoff.next_agent if envelope.handoff else "—")
|
|
83
|
+
console.print(table)
|
|
84
|
+
|
|
85
|
+
if envelope.steps_taken:
|
|
86
|
+
console.print("\n[bold]Steps:[/bold]")
|
|
87
|
+
for i, step in enumerate(envelope.steps_taken, 1):
|
|
88
|
+
console.print(f" {i}. [cyan]{step.agent_id}[/cyan] → {step.action}")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@app.command()
|
|
92
|
+
def validate(
|
|
93
|
+
file: Path = typer.Argument(..., help="Path to a TaskEnvelope JSON file"),
|
|
94
|
+
):
|
|
95
|
+
"""Validate a TaskEnvelope file against the schema."""
|
|
96
|
+
if not file.exists():
|
|
97
|
+
console.print(f"[red]File not found: {file}[/red]")
|
|
98
|
+
raise typer.Exit(1)
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
TaskEnvelope.model_validate_json(file.read_text())
|
|
102
|
+
console.print("[green]Valid envelope.[/green]")
|
|
103
|
+
except Exception as e:
|
|
104
|
+
console.print(f"[red]Invalid:[/red] {e}")
|
|
105
|
+
raise typer.Exit(1)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@app.command()
|
|
109
|
+
def route(
|
|
110
|
+
file: Path = typer.Argument(..., help="Path to a TaskEnvelope JSON file"),
|
|
111
|
+
output: Optional[Path] = typer.Option(None, "--output", "-o", help="Save result envelope to file"),
|
|
112
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Show which agent would handle it, without invoking"),
|
|
113
|
+
):
|
|
114
|
+
"""Route a TaskEnvelope to the appropriate agent."""
|
|
115
|
+
if not file.exists():
|
|
116
|
+
console.print(f"[red]File not found: {file}[/red]")
|
|
117
|
+
raise typer.Exit(1)
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
envelope = TaskEnvelope.model_validate_json(file.read_text())
|
|
121
|
+
except Exception as e:
|
|
122
|
+
console.print(f"[red]Invalid envelope: {e}[/red]")
|
|
123
|
+
raise typer.Exit(1)
|
|
124
|
+
|
|
125
|
+
router = _build_demo_router()
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
agent_id = router.select_agent(envelope)
|
|
129
|
+
except RoutingError as e:
|
|
130
|
+
console.print(f"[red]Routing failed:[/red] {e}")
|
|
131
|
+
raise typer.Exit(1)
|
|
132
|
+
|
|
133
|
+
if dry_run:
|
|
134
|
+
console.print(f"[yellow]Dry run:[/yellow] would route to [cyan]{agent_id}[/cyan]")
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
console.print(f"[dim]Routing to[/dim] [cyan]{agent_id}[/cyan]...")
|
|
138
|
+
result = asyncio.run(router.route(envelope))
|
|
139
|
+
|
|
140
|
+
json_str = result.model_dump_json(indent=2)
|
|
141
|
+
console.print(Syntax(json_str, "json", theme="monokai"))
|
|
142
|
+
|
|
143
|
+
if output:
|
|
144
|
+
output.write_text(json_str)
|
|
145
|
+
console.print(f"\n[green]Saved to {output}[/green]")
|
|
146
|
+
|
|
147
|
+
@app.command()
|
|
148
|
+
def register(
|
|
149
|
+
agent_id: str = typer.Argument(..., help="Unique name for this agent"),
|
|
150
|
+
url: str = typer.Argument(..., help="Base URL of the agent's HTTP server"),
|
|
151
|
+
capabilities: str = typer.Option(..., "--capabilities", "-c", help="Comma-separated capability keywords"),
|
|
152
|
+
file: Path = typer.Argument(..., help="Envelope file to route"),
|
|
153
|
+
output: Optional[Path] = typer.Option(None, "--output", "-o", help="Save result envelope to file"),
|
|
154
|
+
):
|
|
155
|
+
"""Route an envelope to a specific HTTP agent by URL."""
|
|
156
|
+
if not file.exists():
|
|
157
|
+
console.print(f"[red]File not found: {file}[/red]")
|
|
158
|
+
raise typer.Exit(1)
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
envelope = TaskEnvelope.model_validate_json(file.read_text())
|
|
162
|
+
except Exception as e:
|
|
163
|
+
console.print(f"[red]Invalid envelope: {e}[/red]")
|
|
164
|
+
raise typer.Exit(1)
|
|
165
|
+
|
|
166
|
+
caps = [c.strip() for c in capabilities.split(",")]
|
|
167
|
+
router = OAPRouter()
|
|
168
|
+
router.register(agent_id, HTTPAdapter(agent_id=agent_id, base_url=url), caps)
|
|
169
|
+
|
|
170
|
+
console.print(f"[dim]Routing to[/dim] [cyan]{agent_id}[/cyan] at [dim]{url}[/dim]...")
|
|
171
|
+
result = asyncio.run(router.route(envelope))
|
|
172
|
+
|
|
173
|
+
json_str = result.model_dump_json(indent=2)
|
|
174
|
+
console.print(Syntax(json_str, "json", theme="monokai"))
|
|
175
|
+
|
|
176
|
+
if output:
|
|
177
|
+
output.write_text(json_str)
|
|
178
|
+
console.print(f"\n[green]Saved to {output}[/green]")
|
|
179
|
+
|
|
180
|
+
@app.command()
|
|
181
|
+
def agents():
|
|
182
|
+
"""List all registered agents and their capabilities."""
|
|
183
|
+
router = _build_demo_router()
|
|
184
|
+
|
|
185
|
+
table = Table(show_header=True, header_style="bold dim")
|
|
186
|
+
table.add_column("Agent ID")
|
|
187
|
+
table.add_column("Capabilities")
|
|
188
|
+
|
|
189
|
+
for entry in router.list_agents():
|
|
190
|
+
table.add_row(
|
|
191
|
+
f"[cyan]{entry['id']}[/cyan]",
|
|
192
|
+
", ".join(entry["capabilities"]),
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
console.print(table)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
from typing import Any
|
|
4
|
+
from uuid import uuid4
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Step(BaseModel):
|
|
9
|
+
agent_id: str
|
|
10
|
+
action: str
|
|
11
|
+
result: Any = None
|
|
12
|
+
timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Constraints(BaseModel):
|
|
16
|
+
max_cost_usd: float | None = None
|
|
17
|
+
allowed_tools: list[str] | None = None
|
|
18
|
+
deadline_ms: int | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Handoff(BaseModel):
|
|
22
|
+
next_agent: str
|
|
23
|
+
reason: str
|
|
24
|
+
partial_result: Any = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TaskEnvelope(BaseModel):
|
|
28
|
+
id: str = Field(default_factory=lambda: str(uuid4()))
|
|
29
|
+
version: str = "0.1"
|
|
30
|
+
goal: str
|
|
31
|
+
memory: dict[str, Any] = Field(default_factory=dict)
|
|
32
|
+
steps_taken: list[Step] = Field(default_factory=list)
|
|
33
|
+
constraints: Constraints = Field(default_factory=Constraints)
|
|
34
|
+
handoff: Handoff | None = None
|
|
35
|
+
|
|
36
|
+
def add_step(self, agent_id: str, action: str, result: Any = None) -> None:
|
|
37
|
+
self.steps_taken.append(Step(agent_id=agent_id, action=action, result=result))
|
|
38
|
+
|
|
39
|
+
def with_handoff(self, next_agent: str, reason: str, partial_result: Any = None) -> "TaskEnvelope":
|
|
40
|
+
self.handoff = Handoff(next_agent=next_agent, reason=reason, partial_result=partial_result)
|
|
41
|
+
return self
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import re
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from typing import Any
|
|
5
|
+
from oap.envelope import TaskEnvelope
|
|
6
|
+
from oap.adapters.base import AgentAdapter
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RoutingError(Exception):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class OAPRouter:
|
|
14
|
+
def __init__(self):
|
|
15
|
+
self._agents: dict[str, AgentAdapter] = {}
|
|
16
|
+
self._capabilities: dict[str, list[str]] = {}
|
|
17
|
+
|
|
18
|
+
def register(self, agent_id: str, adapter: AgentAdapter, capabilities: list[str]) -> None:
|
|
19
|
+
"""Register an agent with its capabilities."""
|
|
20
|
+
self._agents[agent_id] = adapter
|
|
21
|
+
self._capabilities[agent_id] = [c.lower() for c in capabilities]
|
|
22
|
+
|
|
23
|
+
def list_agents(self) -> list[dict[str, Any]]:
|
|
24
|
+
"""Return all registered agents and their capabilities."""
|
|
25
|
+
return [
|
|
26
|
+
{"id": agent_id, "capabilities": caps}
|
|
27
|
+
for agent_id, caps in self._capabilities.items()
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
def select_agent(self, envelope: TaskEnvelope) -> str:
|
|
31
|
+
"""Pick the best agent for this envelope.
|
|
32
|
+
|
|
33
|
+
Priority:
|
|
34
|
+
1. Explicit handoff.next_agent in the envelope
|
|
35
|
+
2. Capability match against goal keywords (most matches wins)
|
|
36
|
+
3. Raise RoutingError if nothing matches or there is a tie
|
|
37
|
+
"""
|
|
38
|
+
if envelope.handoff and envelope.handoff.next_agent:
|
|
39
|
+
agent_id = envelope.handoff.next_agent
|
|
40
|
+
if agent_id not in self._agents:
|
|
41
|
+
raise RoutingError(f"Requested agent '{agent_id}' is not registered.")
|
|
42
|
+
return agent_id
|
|
43
|
+
|
|
44
|
+
return self._match_by_capability(envelope.goal)
|
|
45
|
+
|
|
46
|
+
def _match_by_capability(self, goal: str) -> str:
|
|
47
|
+
"""Score each agent by how many capability keywords appear in the goal."""
|
|
48
|
+
goal_lower = goal.lower()
|
|
49
|
+
scores: dict[str, int] = {}
|
|
50
|
+
|
|
51
|
+
for agent_id, caps in self._capabilities.items():
|
|
52
|
+
score = sum(
|
|
53
|
+
1 for cap in caps
|
|
54
|
+
if re.search(rf"\b{re.escape(cap)}\b", goal_lower)
|
|
55
|
+
)
|
|
56
|
+
scores[agent_id] = score
|
|
57
|
+
|
|
58
|
+
if not scores:
|
|
59
|
+
raise RoutingError("No agents are registered.")
|
|
60
|
+
|
|
61
|
+
best_score = max(scores.values())
|
|
62
|
+
|
|
63
|
+
if best_score == 0:
|
|
64
|
+
raise RoutingError(
|
|
65
|
+
f"No agent matched the goal: '{goal}'\n"
|
|
66
|
+
f"Registered capabilities: {self._capabilities}"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
winners = [aid for aid, score in scores.items() if score == best_score]
|
|
70
|
+
if len(winners) > 1:
|
|
71
|
+
raise RoutingError(
|
|
72
|
+
f"Ambiguous routing for goal: '{goal}'\n"
|
|
73
|
+
f"Tied agents: {winners} — add a handoff.next_agent to disambiguate."
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
return winners[0]
|
|
77
|
+
|
|
78
|
+
async def route(self, envelope: TaskEnvelope) -> TaskEnvelope:
|
|
79
|
+
"""Route an envelope to the correct agent and return the updated envelope."""
|
|
80
|
+
agent_id = self.select_agent(envelope)
|
|
81
|
+
adapter = self._agents[agent_id]
|
|
82
|
+
|
|
83
|
+
agent_input = adapter.to_agent_format(envelope)
|
|
84
|
+
agent_output = await adapter.invoke(agent_input)
|
|
85
|
+
result = adapter.to_envelope(agent_output, envelope)
|
|
86
|
+
|
|
87
|
+
result.add_step(
|
|
88
|
+
agent_id=agent_id,
|
|
89
|
+
action=f"Routed to {agent_id}",
|
|
90
|
+
result=agent_output,
|
|
91
|
+
)
|
|
92
|
+
return result
|
|
@@ -0,0 +1,19 @@
|
|
|
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
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "open-agent-protocol"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Open Agent Protocol — a routing layer for inter-agent task handoff"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "OAP Contributors" }]
|
|
13
|
+
keywords = ["agents", "routing", "llm", "multi-agent", "task-handoff"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
23
|
+
"Typing :: Typed",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"typer>=0.12,<1.0",
|
|
27
|
+
"pydantic>=2.0,<3.0",
|
|
28
|
+
"httpx>=0.27,<1.0",
|
|
29
|
+
"rich>=13.0,<14.0",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Repository = "https://github.com/Adam-Abinsha-vahab-Baker/OAP"
|
|
34
|
+
Issues = "https://github.com/Adam-Abinsha-vahab-Baker/OAP/issues"
|
|
35
|
+
|
|
36
|
+
[project.scripts]
|
|
37
|
+
oap = "oap.cli:app"
|
|
38
|
+
|
|
39
|
+
[project.optional-dependencies]
|
|
40
|
+
dev = [
|
|
41
|
+
"pytest>=8.0",
|
|
42
|
+
"pytest-asyncio>=0.23",
|
|
43
|
+
"respx>=0.21",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[tool.pytest.ini_options]
|
|
47
|
+
asyncio_mode = "auto"
|
|
48
|
+
|
|
49
|
+
[tool.hatch.build.targets.wheel]
|
|
50
|
+
packages = ["oap"]
|
|
File without changes
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A minimal fake agent server for integration testing.
|
|
3
|
+
Run with: python tests/fake_agent_server.py
|
|
4
|
+
It listens on localhost:9999 and responds to POST /invoke
|
|
5
|
+
"""
|
|
6
|
+
import json
|
|
7
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FakeAgentHandler(BaseHTTPRequestHandler):
|
|
11
|
+
def do_POST(self):
|
|
12
|
+
if self.path != "/invoke":
|
|
13
|
+
self.send_response(404)
|
|
14
|
+
self.end_headers()
|
|
15
|
+
return
|
|
16
|
+
|
|
17
|
+
length = int(self.headers["Content-Length"])
|
|
18
|
+
body = json.loads(self.rfile.read(length))
|
|
19
|
+
|
|
20
|
+
goal = body.get("goal", "")
|
|
21
|
+
response = {
|
|
22
|
+
"result": f"Fake agent processed: {goal}",
|
|
23
|
+
"memory": {
|
|
24
|
+
"processed_by": "fake-agent",
|
|
25
|
+
"goal_length": len(goal),
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
self.send_response(200)
|
|
30
|
+
self.send_header("Content-Type", "application/json")
|
|
31
|
+
self.end_headers()
|
|
32
|
+
self.wfile.write(json.dumps(response).encode())
|
|
33
|
+
|
|
34
|
+
def log_message(self, format, *args):
|
|
35
|
+
pass # silence request logs during tests
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def run(port: int = 9999):
|
|
39
|
+
server = HTTPServer(("localhost", port), FakeAgentHandler)
|
|
40
|
+
print(f"Fake agent running on http://localhost:{port}")
|
|
41
|
+
server.serve_forever()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
if __name__ == "__main__":
|
|
45
|
+
run()
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import threading
|
|
3
|
+
import pytest
|
|
4
|
+
from typer.testing import CliRunner
|
|
5
|
+
from tests.fake_agent_server import FakeAgentHandler, HTTPServer
|
|
6
|
+
from oap.cli import app
|
|
7
|
+
|
|
8
|
+
runner = CliRunner()
|
|
9
|
+
|
|
10
|
+
_CLI_TEST_PORT = 19998
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture(scope="module")
|
|
14
|
+
def fake_server():
|
|
15
|
+
server = HTTPServer(("localhost", _CLI_TEST_PORT), FakeAgentHandler)
|
|
16
|
+
thread = threading.Thread(target=server.serve_forever)
|
|
17
|
+
thread.daemon = True
|
|
18
|
+
thread.start()
|
|
19
|
+
yield f"http://localhost:{_CLI_TEST_PORT}"
|
|
20
|
+
server.shutdown()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_init_prints_envelope():
|
|
24
|
+
result = runner.invoke(app, ["init", "test goal"])
|
|
25
|
+
assert result.exit_code == 0
|
|
26
|
+
assert "test goal" in result.output
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_init_saves_to_file(tmp_path):
|
|
30
|
+
out = tmp_path / "envelope.json"
|
|
31
|
+
result = runner.invoke(app, ["init", "save this goal", "--output", str(out)])
|
|
32
|
+
assert result.exit_code == 0
|
|
33
|
+
assert out.exists()
|
|
34
|
+
data = json.loads(out.read_text())
|
|
35
|
+
assert data["goal"] == "save this goal"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_inspect_valid_file(tmp_path):
|
|
39
|
+
out = tmp_path / "envelope.json"
|
|
40
|
+
runner.invoke(app, ["init", "inspect me", "--output", str(out)])
|
|
41
|
+
result = runner.invoke(app, ["inspect", str(out)])
|
|
42
|
+
assert result.exit_code == 0
|
|
43
|
+
assert "inspect me" in result.output
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_inspect_missing_file():
|
|
47
|
+
result = runner.invoke(app, ["inspect", "/nonexistent/path.json"])
|
|
48
|
+
assert result.exit_code == 1
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_validate_valid_file(tmp_path):
|
|
52
|
+
out = tmp_path / "envelope.json"
|
|
53
|
+
runner.invoke(app, ["init", "valid goal", "--output", str(out)])
|
|
54
|
+
result = runner.invoke(app, ["validate", str(out)])
|
|
55
|
+
assert result.exit_code == 0
|
|
56
|
+
assert "Valid" in result.output
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_validate_invalid_file(tmp_path):
|
|
60
|
+
bad = tmp_path / "bad.json"
|
|
61
|
+
bad.write_text('{"not": "an envelope"}')
|
|
62
|
+
result = runner.invoke(app, ["validate", str(bad)])
|
|
63
|
+
assert result.exit_code == 1
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_route_dry_run(tmp_path):
|
|
67
|
+
out = tmp_path / "task.json"
|
|
68
|
+
runner.invoke(app, ["init", "research neural networks", "--output", str(out)])
|
|
69
|
+
result = runner.invoke(app, ["route", str(out), "--dry-run"])
|
|
70
|
+
assert result.exit_code == 0
|
|
71
|
+
assert "research-agent" in result.output
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_route_produces_result(tmp_path):
|
|
75
|
+
task = tmp_path / "task.json"
|
|
76
|
+
result_file = tmp_path / "result.json"
|
|
77
|
+
runner.invoke(app, ["init", "debug my python code", "--output", str(task)])
|
|
78
|
+
result = runner.invoke(app, ["route", str(task), "--output", str(result_file)])
|
|
79
|
+
assert result.exit_code == 0
|
|
80
|
+
assert result_file.exists()
|
|
81
|
+
data = json.loads(result_file.read_text())
|
|
82
|
+
assert len(data["steps_taken"]) == 1
|
|
83
|
+
assert data["steps_taken"][0]["agent_id"] == "coding-agent"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_agents_lists_registered():
|
|
87
|
+
result = runner.invoke(app, ["agents"])
|
|
88
|
+
assert result.exit_code == 0
|
|
89
|
+
assert "research-agent" in result.output
|
|
90
|
+
assert "coding-agent" in result.output
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# --- register command ---
|
|
94
|
+
|
|
95
|
+
def test_register_routes_to_http_agent(tmp_path, fake_server):
|
|
96
|
+
task = tmp_path / "task.json"
|
|
97
|
+
result_file = tmp_path / "result.json"
|
|
98
|
+
runner.invoke(app, ["init", "research the best vector databases", "--output", str(task)])
|
|
99
|
+
|
|
100
|
+
result = runner.invoke(app, [
|
|
101
|
+
"register", "research-agent", fake_server,
|
|
102
|
+
"--capabilities", "research,find,search",
|
|
103
|
+
str(task),
|
|
104
|
+
"--output", str(result_file),
|
|
105
|
+
])
|
|
106
|
+
|
|
107
|
+
assert result.exit_code == 0, result.output
|
|
108
|
+
assert result_file.exists()
|
|
109
|
+
data = json.loads(result_file.read_text())
|
|
110
|
+
assert len(data["steps_taken"]) == 1
|
|
111
|
+
assert data["steps_taken"][0]["agent_id"] == "research-agent"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_register_missing_file(fake_server):
|
|
115
|
+
result = runner.invoke(app, [
|
|
116
|
+
"register", "research-agent", fake_server,
|
|
117
|
+
"--capabilities", "research",
|
|
118
|
+
"/nonexistent/task.json",
|
|
119
|
+
])
|
|
120
|
+
assert result.exit_code == 1
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_register_invalid_envelope(tmp_path, fake_server):
|
|
124
|
+
bad = tmp_path / "bad.json"
|
|
125
|
+
bad.write_text('{"not": "valid"}')
|
|
126
|
+
result = runner.invoke(app, [
|
|
127
|
+
"register", "research-agent", fake_server,
|
|
128
|
+
"--capabilities", "research",
|
|
129
|
+
str(bad),
|
|
130
|
+
])
|
|
131
|
+
assert result.exit_code == 1
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from oap.envelope import TaskEnvelope, Step, Constraints, Handoff
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def test_default_envelope():
|
|
6
|
+
e = TaskEnvelope(goal="test goal")
|
|
7
|
+
assert e.goal == "test goal"
|
|
8
|
+
assert e.version == "0.1"
|
|
9
|
+
assert e.memory == {}
|
|
10
|
+
assert e.steps_taken == []
|
|
11
|
+
assert e.handoff is None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_envelope_id_is_unique():
|
|
15
|
+
a = TaskEnvelope(goal="task a")
|
|
16
|
+
b = TaskEnvelope(goal="task b")
|
|
17
|
+
assert a.id != b.id
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_add_step():
|
|
21
|
+
e = TaskEnvelope(goal="test")
|
|
22
|
+
e.add_step(agent_id="research-agent", action="searched web", result="5 results")
|
|
23
|
+
assert len(e.steps_taken) == 1
|
|
24
|
+
assert e.steps_taken[0].agent_id == "research-agent"
|
|
25
|
+
assert e.steps_taken[0].result == "5 results"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_add_multiple_steps():
|
|
29
|
+
e = TaskEnvelope(goal="test")
|
|
30
|
+
e.add_step("agent-a", "step 1")
|
|
31
|
+
e.add_step("agent-b", "step 2")
|
|
32
|
+
assert len(e.steps_taken) == 2
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_with_handoff():
|
|
36
|
+
e = TaskEnvelope(goal="test")
|
|
37
|
+
e.with_handoff(next_agent="coding-agent", reason="needs code", partial_result={"found": True})
|
|
38
|
+
assert e.handoff is not None
|
|
39
|
+
assert e.handoff.next_agent == "coding-agent"
|
|
40
|
+
assert e.handoff.reason == "needs code"
|
|
41
|
+
assert e.handoff.partial_result == {"found": True}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_serialise_roundtrip():
|
|
45
|
+
e = TaskEnvelope(goal="roundtrip test")
|
|
46
|
+
e.add_step("agent-x", "did something", result="ok")
|
|
47
|
+
json_str = e.model_dump_json()
|
|
48
|
+
restored = TaskEnvelope.model_validate_json(json_str)
|
|
49
|
+
assert restored.id == e.id
|
|
50
|
+
assert restored.goal == e.goal
|
|
51
|
+
assert len(restored.steps_taken) == 1
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_constraints_defaults():
|
|
55
|
+
e = TaskEnvelope(goal="test")
|
|
56
|
+
assert e.constraints.max_cost_usd is None
|
|
57
|
+
assert e.constraints.allowed_tools is None
|
|
58
|
+
assert e.constraints.deadline_ms is None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_invalid_envelope_missing_goal():
|
|
62
|
+
with pytest.raises(Exception):
|
|
63
|
+
TaskEnvelope.model_validate({"version": "0.1"})
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
import threading
|
|
3
|
+
from tests.fake_agent_server import FakeAgentHandler, HTTPServer
|
|
4
|
+
from oap.envelope import TaskEnvelope
|
|
5
|
+
from oap.adapters.http import HTTPAdapter
|
|
6
|
+
from oap.router import OAPRouter
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
PORT = 19999 # high port, avoids permission issues
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture(scope="module")
|
|
13
|
+
def fake_server():
|
|
14
|
+
"""Spin up the fake agent server in a background thread for the test module."""
|
|
15
|
+
server = HTTPServer(("localhost", PORT), FakeAgentHandler)
|
|
16
|
+
thread = threading.Thread(target=server.serve_forever)
|
|
17
|
+
thread.daemon = True
|
|
18
|
+
thread.start()
|
|
19
|
+
yield f"http://localhost:{PORT}"
|
|
20
|
+
server.shutdown()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def test_http_adapter_invoke(fake_server):
|
|
24
|
+
adapter = HTTPAdapter(agent_id="fake-agent", base_url=fake_server)
|
|
25
|
+
envelope = TaskEnvelope(goal="find the best open source LLMs")
|
|
26
|
+
|
|
27
|
+
agent_input = adapter.to_agent_format(envelope)
|
|
28
|
+
raw_output = await adapter.invoke(agent_input)
|
|
29
|
+
|
|
30
|
+
assert "result" in raw_output
|
|
31
|
+
assert "find the best open source LLMs" in raw_output["result"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def test_http_adapter_updates_memory(fake_server):
|
|
35
|
+
adapter = HTTPAdapter(agent_id="fake-agent", base_url=fake_server)
|
|
36
|
+
envelope = TaskEnvelope(goal="research vector databases")
|
|
37
|
+
|
|
38
|
+
agent_input = adapter.to_agent_format(envelope)
|
|
39
|
+
raw_output = await adapter.invoke(agent_input)
|
|
40
|
+
result = adapter.to_envelope(raw_output, envelope)
|
|
41
|
+
|
|
42
|
+
assert result.memory["last_result"] is not None
|
|
43
|
+
assert result.memory["processed_by"] == "fake-agent"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def test_http_adapter_preserves_goal(fake_server):
|
|
47
|
+
adapter = HTTPAdapter(agent_id="fake-agent", base_url=fake_server)
|
|
48
|
+
envelope = TaskEnvelope(goal="debug my python script")
|
|
49
|
+
|
|
50
|
+
agent_input = adapter.to_agent_format(envelope)
|
|
51
|
+
raw_output = await adapter.invoke(agent_input)
|
|
52
|
+
result = adapter.to_envelope(raw_output, envelope)
|
|
53
|
+
|
|
54
|
+
assert result.goal == envelope.goal
|
|
55
|
+
assert result.id == envelope.id
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def test_router_with_http_adapter(fake_server):
|
|
59
|
+
router = OAPRouter()
|
|
60
|
+
router.register(
|
|
61
|
+
"fake-agent",
|
|
62
|
+
HTTPAdapter(agent_id="fake-agent", base_url=fake_server),
|
|
63
|
+
capabilities=["research", "find", "search"],
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
envelope = TaskEnvelope(goal="research the best vector databases")
|
|
67
|
+
result = await router.route(envelope)
|
|
68
|
+
|
|
69
|
+
assert len(result.steps_taken) == 1
|
|
70
|
+
assert result.steps_taken[0].agent_id == "fake-agent"
|
|
71
|
+
assert "last_result" in result.memory
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from oap.envelope import TaskEnvelope
|
|
3
|
+
from oap.router import OAPRouter, RoutingError
|
|
4
|
+
from oap.adapters.mock import MockAgentAdapter
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def make_router() -> OAPRouter:
|
|
8
|
+
router = OAPRouter()
|
|
9
|
+
router.register(
|
|
10
|
+
"research-agent",
|
|
11
|
+
MockAgentAdapter("research-agent", response="research done"),
|
|
12
|
+
capabilities=["research", "find", "search"],
|
|
13
|
+
)
|
|
14
|
+
router.register(
|
|
15
|
+
"coding-agent",
|
|
16
|
+
MockAgentAdapter("coding-agent", response="code written"),
|
|
17
|
+
capabilities=["code", "implement", "debug"],
|
|
18
|
+
)
|
|
19
|
+
return router
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_list_agents():
|
|
23
|
+
router = make_router()
|
|
24
|
+
agents = router.list_agents()
|
|
25
|
+
ids = [a["id"] for a in agents]
|
|
26
|
+
assert "research-agent" in ids
|
|
27
|
+
assert "coding-agent" in ids
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_capability_match_research():
|
|
31
|
+
router = make_router()
|
|
32
|
+
e = TaskEnvelope(goal="research the best vector databases")
|
|
33
|
+
assert router.select_agent(e) == "research-agent"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_capability_match_coding():
|
|
37
|
+
router = make_router()
|
|
38
|
+
e = TaskEnvelope(goal="implement a quick sort algorithm")
|
|
39
|
+
assert router.select_agent(e) == "coding-agent"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_explicit_handoff_overrides_capability():
|
|
43
|
+
router = make_router()
|
|
44
|
+
e = TaskEnvelope(goal="research something")
|
|
45
|
+
e.with_handoff(next_agent="coding-agent", reason="user override")
|
|
46
|
+
assert router.select_agent(e) == "coding-agent"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_no_match_raises_routing_error():
|
|
50
|
+
router = make_router()
|
|
51
|
+
e = TaskEnvelope(goal="make me a sandwich")
|
|
52
|
+
with pytest.raises(RoutingError):
|
|
53
|
+
router.select_agent(e)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_empty_router_raises_routing_error():
|
|
57
|
+
router = OAPRouter()
|
|
58
|
+
e = TaskEnvelope(goal="research something")
|
|
59
|
+
with pytest.raises(RoutingError, match="No agents are registered"):
|
|
60
|
+
router.select_agent(e)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_unknown_handoff_agent_raises_routing_error():
|
|
64
|
+
router = make_router()
|
|
65
|
+
e = TaskEnvelope(goal="do something")
|
|
66
|
+
e.with_handoff(next_agent="nonexistent-agent", reason="test")
|
|
67
|
+
with pytest.raises(RoutingError):
|
|
68
|
+
router.select_agent(e)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def test_route_updates_memory():
|
|
72
|
+
router = make_router()
|
|
73
|
+
e = TaskEnvelope(goal="research neural networks")
|
|
74
|
+
result = await router.route(e)
|
|
75
|
+
assert "last_result" in result.memory
|
|
76
|
+
assert result.memory["last_result"] == "research done"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
async def test_route_appends_step():
|
|
80
|
+
router = make_router()
|
|
81
|
+
e = TaskEnvelope(goal="debug my code")
|
|
82
|
+
result = await router.route(e)
|
|
83
|
+
assert len(result.steps_taken) == 1
|
|
84
|
+
assert result.steps_taken[0].agent_id == "coding-agent"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def test_route_preserves_goal():
|
|
88
|
+
router = make_router()
|
|
89
|
+
e = TaskEnvelope(goal="find open source llm frameworks")
|
|
90
|
+
result = await router.route(e)
|
|
91
|
+
assert result.goal == e.goal
|
|
92
|
+
assert result.id == e.id
|