asap-protocol 0.3.0__py3-none-any.whl → 1.0.0__py3-none-any.whl
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.
- asap/__init__.py +1 -1
- asap/cli.py +137 -2
- asap/errors.py +167 -0
- asap/examples/README.md +81 -10
- asap/examples/auth_patterns.py +212 -0
- asap/examples/error_recovery.py +248 -0
- asap/examples/long_running.py +287 -0
- asap/examples/mcp_integration.py +240 -0
- asap/examples/multi_step_workflow.py +134 -0
- asap/examples/orchestration.py +293 -0
- asap/examples/rate_limiting.py +137 -0
- asap/examples/run_demo.py +9 -4
- asap/examples/secure_handler.py +84 -0
- asap/examples/state_migration.py +240 -0
- asap/examples/streaming_response.py +108 -0
- asap/examples/websocket_concept.py +129 -0
- asap/mcp/__init__.py +43 -0
- asap/mcp/client.py +224 -0
- asap/mcp/protocol.py +179 -0
- asap/mcp/server.py +333 -0
- asap/mcp/server_runner.py +40 -0
- asap/models/__init__.py +4 -0
- asap/models/base.py +0 -3
- asap/models/constants.py +76 -1
- asap/models/entities.py +58 -7
- asap/models/envelope.py +14 -1
- asap/models/ids.py +8 -4
- asap/models/parts.py +33 -3
- asap/models/validators.py +16 -0
- asap/observability/__init__.py +6 -0
- asap/observability/dashboards/README.md +24 -0
- asap/observability/dashboards/asap-detailed.json +131 -0
- asap/observability/dashboards/asap-red.json +129 -0
- asap/observability/logging.py +81 -1
- asap/observability/metrics.py +15 -1
- asap/observability/trace_parser.py +238 -0
- asap/observability/trace_ui.py +218 -0
- asap/observability/tracing.py +293 -0
- asap/state/machine.py +15 -2
- asap/state/snapshot.py +0 -9
- asap/testing/__init__.py +31 -0
- asap/testing/assertions.py +108 -0
- asap/testing/fixtures.py +113 -0
- asap/testing/mocks.py +152 -0
- asap/transport/__init__.py +31 -0
- asap/transport/cache.py +180 -0
- asap/transport/circuit_breaker.py +194 -0
- asap/transport/client.py +989 -72
- asap/transport/compression.py +389 -0
- asap/transport/handlers.py +106 -53
- asap/transport/middleware.py +64 -39
- asap/transport/server.py +461 -94
- asap/transport/validators.py +320 -0
- asap/utils/__init__.py +7 -0
- asap/utils/sanitization.py +134 -0
- asap_protocol-1.0.0.dist-info/METADATA +264 -0
- asap_protocol-1.0.0.dist-info/RECORD +70 -0
- asap_protocol-0.3.0.dist-info/METADATA +0 -227
- asap_protocol-0.3.0.dist-info/RECORD +0 -37
- {asap_protocol-0.3.0.dist-info → asap_protocol-1.0.0.dist-info}/WHEEL +0 -0
- {asap_protocol-0.3.0.dist-info → asap_protocol-1.0.0.dist-info}/entry_points.txt +0 -0
- {asap_protocol-0.3.0.dist-info → asap_protocol-1.0.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""MCP tool integration example for ASAP protocol.
|
|
2
|
+
|
|
3
|
+
This module shows how to call MCP (Model Context Protocol) tools via ASAP
|
|
4
|
+
envelopes: build McpToolCall and McpToolResult payloads, wrap them in
|
|
5
|
+
Envelopes, and send/receive using ASAPClient.
|
|
6
|
+
|
|
7
|
+
Payload types:
|
|
8
|
+
- mcp_tool_call: Request to invoke an MCP tool (tool_name, arguments).
|
|
9
|
+
- mcp_tool_result: Response with success/result or error.
|
|
10
|
+
|
|
11
|
+
Run:
|
|
12
|
+
uv run python -m asap.examples.mcp_integration
|
|
13
|
+
uv run python -m asap.examples.mcp_integration --agent-url http://127.0.0.1:8000
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import asyncio
|
|
20
|
+
from typing import Any, Sequence
|
|
21
|
+
|
|
22
|
+
from asap.models.envelope import Envelope
|
|
23
|
+
from asap.models.ids import generate_id
|
|
24
|
+
from asap.models.payloads import McpToolCall, McpToolResult
|
|
25
|
+
from asap.observability import get_logger
|
|
26
|
+
from asap.transport.client import ASAPClient
|
|
27
|
+
|
|
28
|
+
logger = get_logger(__name__)
|
|
29
|
+
|
|
30
|
+
# Default agent that might expose MCP tools (e.g. echo or a custom MCP gateway)
|
|
31
|
+
DEFAULT_AGENT_ID = "urn:asap:agent:mcp-gateway"
|
|
32
|
+
DEFAULT_SENDER_ID = "urn:asap:agent:caller"
|
|
33
|
+
DEFAULT_AGENT_URL = "http://127.0.0.1:8000"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def build_mcp_tool_call_envelope(
|
|
37
|
+
tool_name: str,
|
|
38
|
+
arguments: dict[str, Any],
|
|
39
|
+
recipient_id: str = DEFAULT_AGENT_ID,
|
|
40
|
+
sender_id: str = DEFAULT_SENDER_ID,
|
|
41
|
+
request_id: str | None = None,
|
|
42
|
+
mcp_context: dict[str, Any] | None = None,
|
|
43
|
+
) -> Envelope:
|
|
44
|
+
"""Build an ASAP envelope containing an MCP tool call request.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
tool_name: Name of the MCP tool to invoke (e.g. "web_search", "read_file").
|
|
48
|
+
arguments: JSON-serializable arguments for the tool.
|
|
49
|
+
recipient_id: URN of the agent that will execute the MCP tool.
|
|
50
|
+
sender_id: URN of the calling agent.
|
|
51
|
+
request_id: Optional request ID for correlation; generated if not provided.
|
|
52
|
+
mcp_context: Optional MCP context (server URL, session, etc.).
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
Envelope with payload_type "mcp_tool_call" and McpToolCall payload.
|
|
56
|
+
"""
|
|
57
|
+
req_id = request_id or generate_id()
|
|
58
|
+
payload = McpToolCall(
|
|
59
|
+
request_id=req_id,
|
|
60
|
+
tool_name=tool_name,
|
|
61
|
+
arguments=arguments,
|
|
62
|
+
mcp_context=mcp_context,
|
|
63
|
+
)
|
|
64
|
+
return Envelope(
|
|
65
|
+
asap_version="0.1",
|
|
66
|
+
sender=sender_id,
|
|
67
|
+
recipient=recipient_id,
|
|
68
|
+
payload_type="mcp_tool_call",
|
|
69
|
+
payload=payload.model_dump(),
|
|
70
|
+
trace_id=generate_id(),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def build_mcp_tool_result_envelope(
|
|
75
|
+
request_id: str,
|
|
76
|
+
success: bool,
|
|
77
|
+
result: dict[str, Any] | None = None,
|
|
78
|
+
error: str | None = None,
|
|
79
|
+
recipient_id: str = DEFAULT_SENDER_ID,
|
|
80
|
+
sender_id: str = DEFAULT_AGENT_ID,
|
|
81
|
+
correlation_id: str | None = None,
|
|
82
|
+
) -> Envelope:
|
|
83
|
+
"""Build an ASAP envelope containing an MCP tool result (response).
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
request_id: ID of the original McpToolCall request.
|
|
87
|
+
success: Whether the tool call succeeded.
|
|
88
|
+
result: Result data when success=True; must be None when success=False.
|
|
89
|
+
error: Error message when success=False; must be None when success=True.
|
|
90
|
+
recipient_id: URN of the agent that sent the tool call (caller).
|
|
91
|
+
sender_id: URN of the agent that executed the tool (gateway).
|
|
92
|
+
correlation_id: Envelope ID of the request envelope for tracking.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
Envelope with payload_type "mcp_tool_result" and McpToolResult payload.
|
|
96
|
+
"""
|
|
97
|
+
payload = McpToolResult(
|
|
98
|
+
request_id=request_id,
|
|
99
|
+
success=success,
|
|
100
|
+
result=result,
|
|
101
|
+
error=error,
|
|
102
|
+
)
|
|
103
|
+
return Envelope(
|
|
104
|
+
asap_version="0.1",
|
|
105
|
+
sender=sender_id,
|
|
106
|
+
recipient=recipient_id,
|
|
107
|
+
payload_type="mcp_tool_result",
|
|
108
|
+
payload=payload.model_dump(),
|
|
109
|
+
trace_id=generate_id(),
|
|
110
|
+
correlation_id=correlation_id,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
async def send_mcp_tool_call(
|
|
115
|
+
base_url: str,
|
|
116
|
+
tool_name: str,
|
|
117
|
+
arguments: dict[str, Any],
|
|
118
|
+
sender_id: str = DEFAULT_SENDER_ID,
|
|
119
|
+
recipient_id: str = DEFAULT_AGENT_ID,
|
|
120
|
+
) -> Envelope:
|
|
121
|
+
"""Send an MCP tool call envelope to an agent and return the response envelope.
|
|
122
|
+
|
|
123
|
+
The agent at base_url must handle "mcp_tool_call" and return an envelope
|
|
124
|
+
with payload_type "mcp_tool_result". If the agent does not implement MCP,
|
|
125
|
+
the request may fail with a connection or handler error.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
base_url: Base URL of the agent (no trailing /asap).
|
|
129
|
+
tool_name: MCP tool name to invoke.
|
|
130
|
+
arguments: Tool arguments.
|
|
131
|
+
sender_id: Sender URN.
|
|
132
|
+
recipient_id: Recipient URN.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
Response envelope (e.g. mcp_tool_result) from the agent.
|
|
136
|
+
"""
|
|
137
|
+
envelope = build_mcp_tool_call_envelope(
|
|
138
|
+
tool_name=tool_name,
|
|
139
|
+
arguments=arguments,
|
|
140
|
+
recipient_id=recipient_id,
|
|
141
|
+
sender_id=sender_id,
|
|
142
|
+
)
|
|
143
|
+
logger.info(
|
|
144
|
+
"asap.mcp_integration.sending",
|
|
145
|
+
tool_name=tool_name,
|
|
146
|
+
envelope_id=envelope.id,
|
|
147
|
+
)
|
|
148
|
+
async with ASAPClient(base_url) as client:
|
|
149
|
+
response = await client.send(envelope)
|
|
150
|
+
logger.info(
|
|
151
|
+
"asap.mcp_integration.received",
|
|
152
|
+
payload_type=response.payload_type,
|
|
153
|
+
response_id=response.id,
|
|
154
|
+
)
|
|
155
|
+
return response
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def run_demo_local() -> None:
|
|
159
|
+
"""Demonstrate building MCP envelopes without sending (no server required).
|
|
160
|
+
|
|
161
|
+
Builds a tool call envelope and a corresponding tool result envelope
|
|
162
|
+
to show the expected shape for MCP integration.
|
|
163
|
+
"""
|
|
164
|
+
request_id = generate_id()
|
|
165
|
+
call_envelope = build_mcp_tool_call_envelope(
|
|
166
|
+
tool_name="web_search",
|
|
167
|
+
arguments={"query": "ASAP protocol", "max_results": 5},
|
|
168
|
+
request_id=request_id,
|
|
169
|
+
mcp_context={"server": "mcp://tools.example.com"},
|
|
170
|
+
)
|
|
171
|
+
logger.info(
|
|
172
|
+
"asap.mcp_integration.tool_call_envelope",
|
|
173
|
+
envelope_id=call_envelope.id,
|
|
174
|
+
payload_type=call_envelope.payload_type,
|
|
175
|
+
tool_name=call_envelope.payload.get("tool_name"),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
result_envelope = build_mcp_tool_result_envelope(
|
|
179
|
+
request_id=request_id,
|
|
180
|
+
success=True,
|
|
181
|
+
result={"findings": ["result1", "result2"], "count": 2},
|
|
182
|
+
correlation_id=call_envelope.id,
|
|
183
|
+
)
|
|
184
|
+
logger.info(
|
|
185
|
+
"asap.mcp_integration.tool_result_envelope",
|
|
186
|
+
envelope_id=result_envelope.id,
|
|
187
|
+
payload_type=result_envelope.payload_type,
|
|
188
|
+
success=result_envelope.payload.get("success"),
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
async def run_demo_remote(agent_url: str) -> None:
|
|
193
|
+
"""Send a real MCP tool call to an agent (agent must support mcp_tool_call).
|
|
194
|
+
|
|
195
|
+
If the agent does not register a handler for mcp_tool_call, the request
|
|
196
|
+
will fail; this demo is for use with an MCP-capable agent.
|
|
197
|
+
"""
|
|
198
|
+
try:
|
|
199
|
+
response = await send_mcp_tool_call(
|
|
200
|
+
base_url=agent_url,
|
|
201
|
+
tool_name="echo",
|
|
202
|
+
arguments={"message": "hello from MCP example"},
|
|
203
|
+
)
|
|
204
|
+
logger.info(
|
|
205
|
+
"asap.mcp_integration.remote_complete",
|
|
206
|
+
payload_type=response.payload_type,
|
|
207
|
+
payload=response.payload,
|
|
208
|
+
)
|
|
209
|
+
except Exception as e:
|
|
210
|
+
logger.warning(
|
|
211
|
+
"asap.mcp_integration.remote_failed",
|
|
212
|
+
error=str(e),
|
|
213
|
+
message="Ensure the agent supports mcp_tool_call or run without --agent-url",
|
|
214
|
+
)
|
|
215
|
+
raise
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
|
219
|
+
"""Parse command-line arguments for the MCP integration demo."""
|
|
220
|
+
parser = argparse.ArgumentParser(
|
|
221
|
+
description="Call MCP tools via ASAP envelopes (build and optionally send)."
|
|
222
|
+
)
|
|
223
|
+
parser.add_argument(
|
|
224
|
+
"--agent-url",
|
|
225
|
+
default=None,
|
|
226
|
+
help="Base URL of an agent that handles mcp_tool_call (optional; if omitted, only build envelopes).",
|
|
227
|
+
)
|
|
228
|
+
return parser.parse_args(argv)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def main(argv: Sequence[str] | None = None) -> None:
|
|
232
|
+
"""Build MCP envelopes and optionally send to an agent."""
|
|
233
|
+
args = parse_args(argv)
|
|
234
|
+
run_demo_local()
|
|
235
|
+
if args.agent_url:
|
|
236
|
+
asyncio.run(run_demo_remote(args.agent_url))
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
if __name__ == "__main__":
|
|
240
|
+
main()
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Multi-step workflow example for ASAP protocol.
|
|
2
|
+
|
|
3
|
+
This module demonstrates a pipeline of steps (fetch -> transform -> summarize)
|
|
4
|
+
where each step consumes input and produces output; state flows between steps.
|
|
5
|
+
Use this pattern for workflows that could be split across agents (each step
|
|
6
|
+
as a TaskRequest to a different agent) or run in-process.
|
|
7
|
+
|
|
8
|
+
Use case: Multi-agent pipelines, ETL-style workflows, or sequential task chains.
|
|
9
|
+
|
|
10
|
+
Run:
|
|
11
|
+
uv run python -m asap.examples.multi_step_workflow
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Any, Callable, Sequence
|
|
19
|
+
|
|
20
|
+
from asap.models.ids import generate_id
|
|
21
|
+
from asap.observability import get_logger
|
|
22
|
+
|
|
23
|
+
logger = get_logger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class WorkflowState:
|
|
28
|
+
"""State passed between workflow steps.
|
|
29
|
+
|
|
30
|
+
In a distributed setup, this could be a TaskRequest input or
|
|
31
|
+
TaskResponse result; here we keep it simple for the example.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
step_name: str
|
|
35
|
+
data: dict[str, Any]
|
|
36
|
+
task_id: str | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def make_step(
|
|
40
|
+
name: str, fn: Callable[[dict[str, Any]], dict[str, Any]]
|
|
41
|
+
) -> Callable[[WorkflowState], WorkflowState]:
|
|
42
|
+
"""Wrap a function as a workflow step that takes and returns WorkflowState.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
name: Step name for logging and state.
|
|
46
|
+
fn: Function that takes input dict and returns output dict.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Callable(WorkflowState) -> WorkflowState.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def step(state: WorkflowState) -> WorkflowState:
|
|
53
|
+
out = fn(state.data)
|
|
54
|
+
logger.info(
|
|
55
|
+
"asap.multi_step_workflow.step",
|
|
56
|
+
step_name=name,
|
|
57
|
+
input_keys=list(state.data.keys()),
|
|
58
|
+
output_keys=list(out.keys()),
|
|
59
|
+
)
|
|
60
|
+
return WorkflowState(step_name=name, data=out, task_id=state.task_id)
|
|
61
|
+
|
|
62
|
+
return step
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def run_workflow(
|
|
66
|
+
initial_data: dict[str, Any],
|
|
67
|
+
steps: Sequence[Callable[[WorkflowState], WorkflowState]],
|
|
68
|
+
task_id: str | None = None,
|
|
69
|
+
) -> WorkflowState:
|
|
70
|
+
"""Run a linear workflow: initial_data -> step1 -> step2 -> ... -> final state.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
initial_data: Input for the first step.
|
|
74
|
+
steps: List of step functions (each takes WorkflowState, returns WorkflowState).
|
|
75
|
+
task_id: Optional task ID for correlation.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Final WorkflowState after all steps.
|
|
79
|
+
"""
|
|
80
|
+
task_id = task_id or generate_id()
|
|
81
|
+
state = WorkflowState(step_name="init", data=initial_data, task_id=task_id)
|
|
82
|
+
for step in steps:
|
|
83
|
+
state = step(state)
|
|
84
|
+
logger.info(
|
|
85
|
+
"asap.multi_step_workflow.complete",
|
|
86
|
+
task_id=task_id,
|
|
87
|
+
final_step=state.step_name,
|
|
88
|
+
)
|
|
89
|
+
return state
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def run_demo() -> WorkflowState:
|
|
93
|
+
"""Run a demo workflow: fetch -> transform -> summarize."""
|
|
94
|
+
|
|
95
|
+
def fetch(data: dict[str, Any]) -> dict[str, Any]:
|
|
96
|
+
return {"raw": ["item1", "item2", "item3"], "count": 3}
|
|
97
|
+
|
|
98
|
+
def transform(data: dict[str, Any]) -> dict[str, Any]:
|
|
99
|
+
raw = data.get("raw", [])
|
|
100
|
+
return {"transformed": [x.upper() for x in raw], "count": len(raw)}
|
|
101
|
+
|
|
102
|
+
def summarize(data: dict[str, Any]) -> dict[str, Any]:
|
|
103
|
+
transformed = data.get("transformed", [])
|
|
104
|
+
return {"summary": f"Processed {len(transformed)} items", "items": transformed}
|
|
105
|
+
|
|
106
|
+
steps = [
|
|
107
|
+
make_step("fetch", fetch),
|
|
108
|
+
make_step("transform", transform),
|
|
109
|
+
make_step("summarize", summarize),
|
|
110
|
+
]
|
|
111
|
+
return run_workflow(initial_data={}, steps=steps)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
|
115
|
+
"""Parse command-line arguments for the workflow demo."""
|
|
116
|
+
parser = argparse.ArgumentParser(
|
|
117
|
+
description="Multi-step workflow example (fetch -> transform -> summarize)."
|
|
118
|
+
)
|
|
119
|
+
return parser.parse_args(argv)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def main(argv: Sequence[str] | None = None) -> None:
|
|
123
|
+
"""Run the multi-step workflow demo."""
|
|
124
|
+
parse_args(argv)
|
|
125
|
+
final = run_demo()
|
|
126
|
+
logger.info(
|
|
127
|
+
"asap.multi_step_workflow.demo_complete",
|
|
128
|
+
final_data_keys=list(final.data.keys()),
|
|
129
|
+
summary=final.data.get("summary"),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
if __name__ == "__main__":
|
|
134
|
+
main()
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""Multi-agent orchestration example for ASAP protocol.
|
|
2
|
+
|
|
3
|
+
This module demonstrates a main (orchestrator) agent delegating work to two
|
|
4
|
+
sub-agents, with explicit task coordination and state tracking. Use it as
|
|
5
|
+
a reference for building multi-agent workflows (3+ agents).
|
|
6
|
+
|
|
7
|
+
Scenario:
|
|
8
|
+
- Orchestrator receives a high-level task, splits it into two sub-tasks.
|
|
9
|
+
- Sub-agent A and Sub-agent B each handle one sub-task (e.g. "fetch" and "summarize").
|
|
10
|
+
- Orchestrator coordinates order, collects results, and tracks state.
|
|
11
|
+
|
|
12
|
+
Run:
|
|
13
|
+
Start two echo agents (or your own agents) on ports 8001 and 8002, then:
|
|
14
|
+
uv run python -m asap.examples.orchestration --worker-a-url http://127.0.0.1:8001 --worker-b-url http://127.0.0.1:8002
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import asyncio
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from typing import Any, Sequence
|
|
23
|
+
|
|
24
|
+
from asap.models.entities import Capability, Endpoint, Manifest, Skill
|
|
25
|
+
from asap.models.envelope import Envelope
|
|
26
|
+
from asap.models.ids import generate_id
|
|
27
|
+
from asap.models.payloads import TaskRequest
|
|
28
|
+
from asap.observability import get_logger
|
|
29
|
+
from asap.observability.logging import bind_context, clear_context
|
|
30
|
+
from asap.transport.client import ASAPClient
|
|
31
|
+
|
|
32
|
+
logger = get_logger(__name__)
|
|
33
|
+
|
|
34
|
+
# Orchestrator (main agent)
|
|
35
|
+
ORCHESTRATOR_ID = "urn:asap:agent:orchestrator"
|
|
36
|
+
ORCHESTRATOR_NAME = "Orchestrator Agent"
|
|
37
|
+
ORCHESTRATOR_VERSION = "0.1.0"
|
|
38
|
+
ORCHESTRATOR_DESCRIPTION = "Coordinates tasks across two sub-agents"
|
|
39
|
+
|
|
40
|
+
# Sub-agents (default: two echo agents on different ports)
|
|
41
|
+
SUB_AGENT_A_ID = "urn:asap:agent:worker-a"
|
|
42
|
+
SUB_AGENT_B_ID = "urn:asap:agent:worker-b"
|
|
43
|
+
DEFAULT_WORKER_A_URL = "http://127.0.0.1:8001"
|
|
44
|
+
DEFAULT_WORKER_B_URL = "http://127.0.0.1:8002"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class OrchestrationState:
|
|
49
|
+
"""Tracks progress and results across the orchestration workflow.
|
|
50
|
+
|
|
51
|
+
Used for task coordination and observability: which step we are in,
|
|
52
|
+
what each sub-agent returned, and final status.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
conversation_id: str
|
|
56
|
+
trace_id: str
|
|
57
|
+
step: str = "init"
|
|
58
|
+
result_a: dict[str, Any] | None = None
|
|
59
|
+
result_b: dict[str, Any] | None = None
|
|
60
|
+
error: str | None = None
|
|
61
|
+
completed: bool = False
|
|
62
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
63
|
+
|
|
64
|
+
def to_dict(self) -> dict[str, Any]:
|
|
65
|
+
"""Serialize state for logging or persistence."""
|
|
66
|
+
return {
|
|
67
|
+
"conversation_id": self.conversation_id,
|
|
68
|
+
"trace_id": self.trace_id,
|
|
69
|
+
"step": self.step,
|
|
70
|
+
"result_a": self.result_a,
|
|
71
|
+
"result_b": self.result_b,
|
|
72
|
+
"error": self.error,
|
|
73
|
+
"completed": self.completed,
|
|
74
|
+
"metadata": self.metadata,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def build_orchestrator_manifest(asap_endpoint: str = "http://localhost:8000/asap") -> Manifest:
|
|
79
|
+
"""Build the manifest for the orchestrator agent.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
asap_endpoint: URL where the orchestrator receives ASAP messages.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
Manifest describing the orchestrator's capabilities and endpoints.
|
|
86
|
+
"""
|
|
87
|
+
return Manifest(
|
|
88
|
+
id=ORCHESTRATOR_ID,
|
|
89
|
+
name=ORCHESTRATOR_NAME,
|
|
90
|
+
version=ORCHESTRATOR_VERSION,
|
|
91
|
+
description=ORCHESTRATOR_DESCRIPTION,
|
|
92
|
+
capabilities=Capability(
|
|
93
|
+
asap_version="0.1",
|
|
94
|
+
skills=[
|
|
95
|
+
Skill(
|
|
96
|
+
id="orchestrate", description="Delegate and coordinate work across sub-agents"
|
|
97
|
+
),
|
|
98
|
+
],
|
|
99
|
+
state_persistence=False,
|
|
100
|
+
),
|
|
101
|
+
endpoints=Endpoint(asap=asap_endpoint),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def build_task_envelope(
|
|
106
|
+
recipient_id: str,
|
|
107
|
+
skill_id: str,
|
|
108
|
+
input_payload: dict[str, Any],
|
|
109
|
+
conversation_id: str,
|
|
110
|
+
trace_id: str,
|
|
111
|
+
parent_task_id: str | None = None,
|
|
112
|
+
) -> Envelope:
|
|
113
|
+
"""Build a TaskRequest envelope for a sub-agent.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
recipient_id: URN of the sub-agent.
|
|
117
|
+
skill_id: Skill to invoke (e.g. "echo" for echo agents).
|
|
118
|
+
input_payload: Input dict for the skill.
|
|
119
|
+
conversation_id: Shared conversation ID for the workflow.
|
|
120
|
+
trace_id: Shared trace ID for distributed tracing.
|
|
121
|
+
parent_task_id: Optional parent task ID for hierarchy.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
Envelope ready to send via ASAPClient.
|
|
125
|
+
"""
|
|
126
|
+
task_request = TaskRequest(
|
|
127
|
+
conversation_id=conversation_id,
|
|
128
|
+
parent_task_id=parent_task_id,
|
|
129
|
+
skill_id=skill_id,
|
|
130
|
+
input=input_payload,
|
|
131
|
+
)
|
|
132
|
+
return Envelope(
|
|
133
|
+
asap_version="0.1",
|
|
134
|
+
sender=ORCHESTRATOR_ID,
|
|
135
|
+
recipient=recipient_id,
|
|
136
|
+
payload_type="task.request",
|
|
137
|
+
payload=task_request.model_dump(),
|
|
138
|
+
trace_id=trace_id,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
async def send_to_sub_agent(
|
|
143
|
+
client: ASAPClient,
|
|
144
|
+
envelope: Envelope,
|
|
145
|
+
) -> Envelope:
|
|
146
|
+
"""Send an envelope to a sub-agent and return the response envelope.
|
|
147
|
+
|
|
148
|
+
Uses the provided client so the caller can reuse a single ASAPClient
|
|
149
|
+
instance for multiple requests (e.g. one client per worker in run_orchestration).
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
client: Open ASAPClient connected to the sub-agent (caller owns lifecycle).
|
|
153
|
+
envelope: TaskRequest envelope to send.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
Response envelope from the sub-agent (e.g. TaskResponse).
|
|
157
|
+
"""
|
|
158
|
+
return await client.send(envelope)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
async def run_orchestration(
|
|
162
|
+
worker_a_url: str = DEFAULT_WORKER_A_URL,
|
|
163
|
+
worker_b_url: str = DEFAULT_WORKER_B_URL,
|
|
164
|
+
input_a: dict[str, Any] | None = None,
|
|
165
|
+
input_b: dict[str, Any] | None = None,
|
|
166
|
+
) -> OrchestrationState:
|
|
167
|
+
"""Run the orchestration: delegate to sub-agent A, then sub-agent B, and track state.
|
|
168
|
+
|
|
169
|
+
Task coordination: steps run sequentially (A then B). State is updated after
|
|
170
|
+
each response so you can inspect or persist progress.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
worker_a_url: Base URL for sub-agent A.
|
|
174
|
+
worker_b_url: Base URL for sub-agent B.
|
|
175
|
+
input_a: Input payload for sub-agent A (default: step "a" message).
|
|
176
|
+
input_b: Input payload for sub-agent B (default: step "b" message).
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
Final orchestration state with both results and completion status.
|
|
180
|
+
"""
|
|
181
|
+
conversation_id = generate_id()
|
|
182
|
+
trace_id = generate_id()
|
|
183
|
+
state = OrchestrationState(conversation_id=conversation_id, trace_id=trace_id)
|
|
184
|
+
state.step = "start"
|
|
185
|
+
state.metadata["worker_a_url"] = worker_a_url
|
|
186
|
+
state.metadata["worker_b_url"] = worker_b_url
|
|
187
|
+
|
|
188
|
+
payload_a = input_a if input_a is not None else {"step": "a", "message": "task for worker A"}
|
|
189
|
+
payload_b = input_b if input_b is not None else {"step": "b", "message": "task for worker B"}
|
|
190
|
+
|
|
191
|
+
bind_context(trace_id=trace_id, correlation_id=conversation_id)
|
|
192
|
+
try:
|
|
193
|
+
async with ASAPClient(worker_a_url) as client_a, ASAPClient(worker_b_url) as client_b:
|
|
194
|
+
state.step = "sent_to_a"
|
|
195
|
+
envelope_a = build_task_envelope(
|
|
196
|
+
recipient_id=SUB_AGENT_A_ID,
|
|
197
|
+
skill_id="echo",
|
|
198
|
+
input_payload=payload_a,
|
|
199
|
+
conversation_id=conversation_id,
|
|
200
|
+
trace_id=trace_id,
|
|
201
|
+
)
|
|
202
|
+
logger.info(
|
|
203
|
+
"asap.orchestration.sent_to_a",
|
|
204
|
+
envelope_id=envelope_a.id,
|
|
205
|
+
recipient=SUB_AGENT_A_ID,
|
|
206
|
+
)
|
|
207
|
+
try:
|
|
208
|
+
response_a = await send_to_sub_agent(client_a, envelope_a)
|
|
209
|
+
state.result_a = response_a.payload
|
|
210
|
+
state.step = "received_from_a"
|
|
211
|
+
except Exception as e: # noqa: BLE001
|
|
212
|
+
state.error = f"worker_a: {e!s}"
|
|
213
|
+
state.step = "failed_at_a"
|
|
214
|
+
clear_context()
|
|
215
|
+
return state
|
|
216
|
+
|
|
217
|
+
state.step = "sent_to_b"
|
|
218
|
+
envelope_b = build_task_envelope(
|
|
219
|
+
recipient_id=SUB_AGENT_B_ID,
|
|
220
|
+
skill_id="echo",
|
|
221
|
+
input_payload=payload_b,
|
|
222
|
+
conversation_id=conversation_id,
|
|
223
|
+
trace_id=trace_id,
|
|
224
|
+
)
|
|
225
|
+
logger.info(
|
|
226
|
+
"asap.orchestration.sent_to_b",
|
|
227
|
+
envelope_id=envelope_b.id,
|
|
228
|
+
recipient=SUB_AGENT_B_ID,
|
|
229
|
+
)
|
|
230
|
+
try:
|
|
231
|
+
response_b = await send_to_sub_agent(client_b, envelope_b)
|
|
232
|
+
state.result_b = response_b.payload
|
|
233
|
+
state.step = "received_from_b"
|
|
234
|
+
except Exception as e: # noqa: BLE001
|
|
235
|
+
state.error = f"worker_b: {e!s}"
|
|
236
|
+
state.step = "failed_at_b"
|
|
237
|
+
clear_context()
|
|
238
|
+
return state
|
|
239
|
+
|
|
240
|
+
state.step = "completed"
|
|
241
|
+
state.completed = True
|
|
242
|
+
logger.info(
|
|
243
|
+
"asap.orchestration.complete",
|
|
244
|
+
conversation_id=conversation_id,
|
|
245
|
+
state=state.to_dict(),
|
|
246
|
+
)
|
|
247
|
+
finally:
|
|
248
|
+
clear_context()
|
|
249
|
+
|
|
250
|
+
return state
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
|
254
|
+
"""Parse command-line arguments for the orchestration demo.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
argv: Optional list of CLI arguments for testing.
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
Parsed argparse namespace.
|
|
261
|
+
"""
|
|
262
|
+
parser = argparse.ArgumentParser(
|
|
263
|
+
description="Run multi-agent orchestration (orchestrator + 2 sub-agents)."
|
|
264
|
+
)
|
|
265
|
+
parser.add_argument(
|
|
266
|
+
"--worker-a-url",
|
|
267
|
+
default=DEFAULT_WORKER_A_URL,
|
|
268
|
+
help="Base URL for sub-agent A (e.g. echo agent on 8001).",
|
|
269
|
+
)
|
|
270
|
+
parser.add_argument(
|
|
271
|
+
"--worker-b-url",
|
|
272
|
+
default=DEFAULT_WORKER_B_URL,
|
|
273
|
+
help="Base URL for sub-agent B (e.g. echo agent on 8002).",
|
|
274
|
+
)
|
|
275
|
+
return parser.parse_args(argv)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def main(argv: Sequence[str] | None = None) -> None:
|
|
279
|
+
"""Run a single orchestration round: delegate to worker A then B and print state."""
|
|
280
|
+
args = parse_args(argv)
|
|
281
|
+
state = asyncio.run(
|
|
282
|
+
run_orchestration(
|
|
283
|
+
worker_a_url=args.worker_a_url,
|
|
284
|
+
worker_b_url=args.worker_b_url,
|
|
285
|
+
)
|
|
286
|
+
)
|
|
287
|
+
logger.info("asap.orchestration.demo_complete", state=state.to_dict())
|
|
288
|
+
if state.error:
|
|
289
|
+
raise SystemExit(1)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
if __name__ == "__main__":
|
|
293
|
+
main()
|