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.
Files changed (62) hide show
  1. asap/__init__.py +1 -1
  2. asap/cli.py +137 -2
  3. asap/errors.py +167 -0
  4. asap/examples/README.md +81 -10
  5. asap/examples/auth_patterns.py +212 -0
  6. asap/examples/error_recovery.py +248 -0
  7. asap/examples/long_running.py +287 -0
  8. asap/examples/mcp_integration.py +240 -0
  9. asap/examples/multi_step_workflow.py +134 -0
  10. asap/examples/orchestration.py +293 -0
  11. asap/examples/rate_limiting.py +137 -0
  12. asap/examples/run_demo.py +9 -4
  13. asap/examples/secure_handler.py +84 -0
  14. asap/examples/state_migration.py +240 -0
  15. asap/examples/streaming_response.py +108 -0
  16. asap/examples/websocket_concept.py +129 -0
  17. asap/mcp/__init__.py +43 -0
  18. asap/mcp/client.py +224 -0
  19. asap/mcp/protocol.py +179 -0
  20. asap/mcp/server.py +333 -0
  21. asap/mcp/server_runner.py +40 -0
  22. asap/models/__init__.py +4 -0
  23. asap/models/base.py +0 -3
  24. asap/models/constants.py +76 -1
  25. asap/models/entities.py +58 -7
  26. asap/models/envelope.py +14 -1
  27. asap/models/ids.py +8 -4
  28. asap/models/parts.py +33 -3
  29. asap/models/validators.py +16 -0
  30. asap/observability/__init__.py +6 -0
  31. asap/observability/dashboards/README.md +24 -0
  32. asap/observability/dashboards/asap-detailed.json +131 -0
  33. asap/observability/dashboards/asap-red.json +129 -0
  34. asap/observability/logging.py +81 -1
  35. asap/observability/metrics.py +15 -1
  36. asap/observability/trace_parser.py +238 -0
  37. asap/observability/trace_ui.py +218 -0
  38. asap/observability/tracing.py +293 -0
  39. asap/state/machine.py +15 -2
  40. asap/state/snapshot.py +0 -9
  41. asap/testing/__init__.py +31 -0
  42. asap/testing/assertions.py +108 -0
  43. asap/testing/fixtures.py +113 -0
  44. asap/testing/mocks.py +152 -0
  45. asap/transport/__init__.py +31 -0
  46. asap/transport/cache.py +180 -0
  47. asap/transport/circuit_breaker.py +194 -0
  48. asap/transport/client.py +989 -72
  49. asap/transport/compression.py +389 -0
  50. asap/transport/handlers.py +106 -53
  51. asap/transport/middleware.py +64 -39
  52. asap/transport/server.py +461 -94
  53. asap/transport/validators.py +320 -0
  54. asap/utils/__init__.py +7 -0
  55. asap/utils/sanitization.py +134 -0
  56. asap_protocol-1.0.0.dist-info/METADATA +264 -0
  57. asap_protocol-1.0.0.dist-info/RECORD +70 -0
  58. asap_protocol-0.3.0.dist-info/METADATA +0 -227
  59. asap_protocol-0.3.0.dist-info/RECORD +0 -37
  60. {asap_protocol-0.3.0.dist-info → asap_protocol-1.0.0.dist-info}/WHEEL +0 -0
  61. {asap_protocol-0.3.0.dist-info → asap_protocol-1.0.0.dist-info}/entry_points.txt +0 -0
  62. {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()