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.
@@ -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,11 @@
1
+ from oap.envelope import Constraints, Handoff, Step, TaskEnvelope
2
+ from oap.router import OAPRouter, RoutingError
3
+
4
+ __all__ = [
5
+ "TaskEnvelope",
6
+ "Step",
7
+ "Constraints",
8
+ "Handoff",
9
+ "OAPRouter",
10
+ "RoutingError",
11
+ ]
@@ -0,0 +1,4 @@
1
+ from oap.adapters.base import AgentAdapter
2
+ from oap.adapters.http import HTTPAdapter
3
+
4
+ __all__ = ["AgentAdapter", "HTTPAdapter"]
@@ -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,3 @@
1
+ from oap.transport.http import HTTPTransport
2
+
3
+ __all__ = ["HTTPTransport"]
@@ -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