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,137 @@
1
+ """Rate limiting strategies example for ASAP protocol.
2
+
3
+ This module shows how rate limiting is configured and how per-sender
4
+ and per-endpoint patterns work with the server.
5
+
6
+ Patterns:
7
+ 1. Global limit: create_app(..., rate_limit="10/second;100/minute") or ASAP_RATE_LIMIT env.
8
+ 2. Per-sender: key_func returns sender URN when available (envelope.sender), else IP.
9
+ 3. Per-endpoint: apply different limit strings to different routes (e.g. /asap vs /metrics).
10
+
11
+ Run:
12
+ uv run python -m asap.examples.rate_limiting
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import os
19
+ from typing import Sequence
20
+
21
+ from asap.observability import get_logger
22
+ from asap.transport.middleware import (
23
+ DEFAULT_RATE_LIMIT,
24
+ create_limiter,
25
+ )
26
+
27
+ logger = get_logger(__name__)
28
+
29
+
30
+ def get_server_rate_limit_config() -> str:
31
+ """Return the effective rate limit string used by create_app.
32
+
33
+ create_app uses rate_limit parameter or ASAP_RATE_LIMIT env.
34
+ Default is "10/second;100/minute" (burst + sustained).
35
+
36
+ Returns:
37
+ Rate limit string (e.g. "10/second;100/minute").
38
+ """
39
+ return os.environ.get("ASAP_RATE_LIMIT", DEFAULT_RATE_LIMIT)
40
+
41
+
42
+ def per_sender_key_concept(sender_urn: str | None, client_ip: str) -> str:
43
+ """Build a rate limit key for per-sender strategy (concept).
44
+
45
+ When the envelope sender is known (e.g. after auth or body parse), key by sender
46
+ so each agent has its own quota. Otherwise fall back to client IP.
47
+ The server's _get_sender_from_envelope(request) does this: it tries envelope.sender
48
+ then falls back to get_remote_address(request).
49
+
50
+ Args:
51
+ sender_urn: Sender URN from envelope, or None if not yet available.
52
+ client_ip: Client IP address (fallback key).
53
+
54
+ Returns:
55
+ Key string for the rate limiter (e.g. "urn:asap:agent:client-a" or "192.168.1.1").
56
+ """
57
+ if sender_urn:
58
+ return sender_urn
59
+ return client_ip
60
+
61
+
62
+ def per_endpoint_limits_concept() -> dict[str, str]:
63
+ """Return example per-endpoint limit strings (concept).
64
+
65
+ Different routes can have different limits, e.g. strict for /asap and
66
+ looser for read-only /.well-known/asap/manifest.json.
67
+ The server currently applies one limit to the /asap route; this shows
68
+ the pattern for multiple routes.
69
+
70
+ Returns:
71
+ Map from route/path description to limit string.
72
+ """
73
+ return {
74
+ "asap": "10/second;100/minute", # Burst + sustained for main endpoint
75
+ "metrics": "10/minute", # Lower limit for metrics scraping
76
+ "manifest": "200/minute", # Higher limit for discovery
77
+ }
78
+
79
+
80
+ def run_demo() -> None:
81
+ """Demonstrate rate limiting config: global, per-sender key, per-endpoint pattern."""
82
+ # Global: server config
83
+ effective_limit = get_server_rate_limit_config()
84
+ logger.info(
85
+ "asap.rate_limiting.server_config",
86
+ rate_limit=effective_limit,
87
+ env_var="ASAP_RATE_LIMIT",
88
+ )
89
+
90
+ # Per-sender: key concept (sender URN vs IP fallback)
91
+ key_sender = per_sender_key_concept("urn:asap:agent:client-a", "192.168.1.1")
92
+ key_ip = per_sender_key_concept(None, "192.168.1.1")
93
+ logger.info(
94
+ "asap.rate_limiting.per_sender_key",
95
+ when_sender_known=key_sender,
96
+ when_fallback=key_ip,
97
+ )
98
+
99
+ # Per-endpoint: different limits per route
100
+ limits = per_endpoint_limits_concept()
101
+ logger.info(
102
+ "asap.rate_limiting.per_endpoint",
103
+ limits=limits,
104
+ message="Apply @limiter.limit(limit)(handler) per route",
105
+ )
106
+
107
+ # create_limiter uses _get_sender_from_envelope as key_func
108
+ _limiter = create_limiter(["50/minute"])
109
+ logger.info(
110
+ "asap.rate_limiting.limiter_created",
111
+ limits=["50/minute"],
112
+ key_func="get_sender_from_envelope (sender or IP)",
113
+ )
114
+
115
+ # rate_limit_handler is used by the server for 429 responses
116
+ logger.info(
117
+ "asap.rate_limiting.handler",
118
+ message="rate_limit_handler returns JSON-RPC error with Retry-After header",
119
+ )
120
+
121
+
122
+ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
123
+ """Parse command-line arguments for the rate limiting demo."""
124
+ parser = argparse.ArgumentParser(
125
+ description="Rate limiting strategies: per-sender, per-endpoint patterns."
126
+ )
127
+ return parser.parse_args(argv)
128
+
129
+
130
+ def main(argv: Sequence[str] | None = None) -> None:
131
+ """Run the rate limiting demo."""
132
+ parse_args(argv)
133
+ run_demo()
134
+
135
+
136
+ if __name__ == "__main__":
137
+ main()
asap/examples/run_demo.py CHANGED
@@ -6,7 +6,7 @@ communication by sending a task request from the coordinator logic.
6
6
 
7
7
  import asyncio
8
8
  import signal
9
- import subprocess
9
+ import subprocess # nosec B404
10
10
  import sys
11
11
  import time
12
12
  from typing import Sequence
@@ -34,8 +34,15 @@ def start_process(command: Sequence[str]) -> subprocess.Popen[str]:
34
34
 
35
35
  Returns:
36
36
  Started subprocess handle.
37
+
38
+ Note:
39
+ This is example/demo code that only executes trusted commands
40
+ (sys.executable with known modules). The command is controlled
41
+ and not user input.
37
42
  """
38
- return subprocess.Popen(command, text=True)
43
+ # nosec B404, B603: This is example code executing trusted commands only
44
+ # (sys.executable with known Python modules, not user input)
45
+ return subprocess.Popen(command, text=True) # nosec B404, B603
39
46
 
40
47
 
41
48
  def wait_for_ready(url: str, timeout_seconds: float) -> None:
@@ -86,12 +93,10 @@ def main() -> None:
86
93
  signal.signal(signal.SIGTERM, handle_signal)
87
94
 
88
95
  try:
89
- # Start echo agent server
90
96
  echo_process = start_process(echo_command)
91
97
  wait_for_ready(ECHO_MANIFEST_URL, READY_TIMEOUT_SECONDS)
92
98
  logger.info("asap.demo.echo_ready", url=ECHO_MANIFEST_URL)
93
99
 
94
- # Demonstrate communication using coordinator dispatch logic
95
100
  logger.info("asap.demo.starting_communication")
96
101
  try:
97
102
  response = asyncio.run(
@@ -0,0 +1,84 @@
1
+ """Secure handler example for ASAP protocol.
2
+
3
+ This module demonstrates proper input validation and security practices
4
+ when implementing ASAP handlers: validated payloads, FilePart URI validation,
5
+ and sanitized logging. See docs/security.md (Handler Security) for the full guide.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pydantic import ValidationError
11
+
12
+ from asap.errors import MalformedEnvelopeError
13
+ from asap.models.entities import Manifest
14
+ from asap.models.envelope import Envelope
15
+ from asap.models.ids import generate_id
16
+ from asap.models.parts import FilePart
17
+ from asap.models.payloads import TaskRequest, TaskResponse
18
+ from asap.models.enums import TaskStatus
19
+ from asap.observability import get_logger, sanitize_for_logging
20
+ from asap.transport.handlers import Handler
21
+
22
+ logger = get_logger(__name__)
23
+
24
+
25
+ def create_secure_handler() -> Handler:
26
+ """Create a handler that validates input and sanitizes logs.
27
+
28
+ The handler:
29
+ - Parses payload with TaskRequest (raises MalformedEnvelopeError on invalid input)
30
+ - Validates file parts with FilePart so URIs are checked (no path traversal, no file://)
31
+ - Logs payload only after sanitize_for_logging() to avoid leaking secrets
32
+
33
+ Returns:
34
+ A handler callable (envelope, manifest) -> Envelope suitable for
35
+ registry.register("task.request", ...).
36
+ """
37
+
38
+ def secure_handler(envelope: Envelope, manifest: Manifest) -> Envelope:
39
+ try:
40
+ task_request = TaskRequest(**envelope.payload)
41
+ except ValidationError as e:
42
+ raise MalformedEnvelopeError(
43
+ reason="Invalid TaskRequest payload",
44
+ details={"validation_errors": e.errors()},
45
+ ) from e
46
+
47
+ logger.debug(
48
+ "secure_handler.request",
49
+ payload_type=envelope.payload_type,
50
+ payload=sanitize_for_logging(envelope.payload),
51
+ )
52
+
53
+ validated_uris: list[str] = []
54
+ input_data = task_request.input or {}
55
+ parts = input_data.get("parts") or []
56
+ for part in parts:
57
+ if isinstance(part, dict) and part.get("type") == "file":
58
+ try:
59
+ file_part = FilePart.model_validate(part)
60
+ validated_uris.append(file_part.uri)
61
+ except ValidationError as e:
62
+ raise MalformedEnvelopeError(
63
+ reason="Invalid file part (e.g. path traversal or file:// not allowed)",
64
+ details={"validation_errors": e.errors()},
65
+ ) from e
66
+
67
+ result = {"echoed": task_request.input, "validated_file_uris": validated_uris}
68
+ response_payload = TaskResponse(
69
+ task_id=f"task_{generate_id()}",
70
+ status=TaskStatus.COMPLETED,
71
+ result=result,
72
+ )
73
+
74
+ return Envelope(
75
+ asap_version=envelope.asap_version,
76
+ sender=manifest.id,
77
+ recipient=envelope.sender,
78
+ payload_type="task.response",
79
+ payload=response_payload.model_dump(),
80
+ correlation_id=envelope.id,
81
+ trace_id=envelope.trace_id,
82
+ )
83
+
84
+ return secure_handler
@@ -0,0 +1,240 @@
1
+ """State migration example for ASAP protocol.
2
+
3
+ This module demonstrates moving task state between agents: export state
4
+ from one agent's SnapshotStore and import it into another, using
5
+ StateSnapshot, StateQuery, and StateRestore.
6
+
7
+ Scenario:
8
+ - Agent A holds task state in its SnapshotStore.
9
+ - We query/export the snapshot (get from store A).
10
+ - We save it to Agent B's store (or send StateRestore to B if B has
11
+ the snapshot by ID).
12
+
13
+ Run:
14
+ uv run python -m asap.examples.state_migration
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ from datetime import datetime, timezone
21
+ from typing import Any, Protocol, Sequence, runtime_checkable
22
+
23
+ from asap.models.entities import StateSnapshot
24
+ from asap.models.envelope import Envelope
25
+ from asap.models.ids import generate_id
26
+ from asap.models.payloads import StateQuery, StateRestore
27
+ from asap.models.types import TaskID
28
+ from asap.observability import get_logger
29
+ from asap.state.snapshot import InMemorySnapshotStore
30
+
31
+ logger = get_logger(__name__)
32
+
33
+ # URNs for source and target agents in the example
34
+ AGENT_A_ID = "urn:asap:agent:source"
35
+ AGENT_B_ID = "urn:asap:agent:target"
36
+
37
+
38
+ @runtime_checkable
39
+ class SnapshotStoreLike(Protocol):
40
+ """Minimal protocol for get/save (for type hints in this example)."""
41
+
42
+ def get(self, task_id: TaskID, version: int | None = None) -> StateSnapshot | None: ...
43
+ def save(self, snapshot: StateSnapshot) -> None: ...
44
+
45
+
46
+ def build_state_query_envelope(
47
+ task_id: str,
48
+ version: int | None = None,
49
+ sender_id: str = AGENT_B_ID,
50
+ recipient_id: str = AGENT_A_ID,
51
+ ) -> Envelope:
52
+ """Build an ASAP envelope containing a StateQuery (request state from an agent).
53
+
54
+ Args:
55
+ task_id: Task whose state to query.
56
+ version: Optional snapshot version; None = latest.
57
+ sender_id: URN of the agent requesting state (e.g. target).
58
+ recipient_id: URN of the agent that holds the state (e.g. source).
59
+
60
+ Returns:
61
+ Envelope with payload_type "state_query" and StateQuery payload.
62
+ """
63
+ payload = StateQuery(task_id=task_id, version=version)
64
+ return Envelope(
65
+ asap_version="0.1",
66
+ sender=sender_id,
67
+ recipient=recipient_id,
68
+ payload_type="state_query",
69
+ payload=payload.model_dump(),
70
+ trace_id=generate_id(),
71
+ )
72
+
73
+
74
+ def build_state_restore_envelope(
75
+ task_id: str,
76
+ snapshot_id: str,
77
+ sender_id: str = AGENT_B_ID,
78
+ recipient_id: str = AGENT_B_ID,
79
+ ) -> Envelope:
80
+ """Build an ASAP envelope containing a StateRestore (restore task from snapshot).
81
+
82
+ Args:
83
+ task_id: Task to restore.
84
+ snapshot_id: Snapshot ID to restore from (must exist in recipient's store).
85
+ sender_id: URN of the agent requesting restore.
86
+ recipient_id: URN of the agent that will restore (e.g. target agent).
87
+
88
+ Returns:
89
+ Envelope with payload_type "state_restore" and StateRestore payload.
90
+ """
91
+ payload = StateRestore(task_id=task_id, snapshot_id=snapshot_id)
92
+ return Envelope(
93
+ asap_version="0.1",
94
+ sender=sender_id,
95
+ recipient=recipient_id,
96
+ payload_type="state_restore",
97
+ payload=payload.model_dump(),
98
+ trace_id=generate_id(),
99
+ )
100
+
101
+
102
+ def create_snapshot(
103
+ task_id: str,
104
+ version: int,
105
+ data: dict[str, Any],
106
+ checkpoint: bool = True,
107
+ ) -> StateSnapshot:
108
+ """Create a StateSnapshot for the example."""
109
+ return StateSnapshot(
110
+ id=generate_id(),
111
+ task_id=task_id,
112
+ version=version,
113
+ data=data,
114
+ checkpoint=checkpoint,
115
+ created_at=datetime.now(timezone.utc),
116
+ )
117
+
118
+
119
+ def move_state_between_agents(
120
+ source_store: SnapshotStoreLike,
121
+ target_store: SnapshotStoreLike,
122
+ task_id: str,
123
+ version: int | None = None,
124
+ new_task_id: str | None = None,
125
+ ) -> StateSnapshot | None:
126
+ """Move task state from source agent's store to target agent's store.
127
+
128
+ Exports the snapshot from source (get) and imports it into target (save).
129
+ If new_task_id is set, the snapshot is saved under that task_id on the target
130
+ (e.g. for "migrated" task identity); otherwise the same task_id is used.
131
+
132
+ Args:
133
+ source_store: Snapshot store of the source agent (Agent A).
134
+ target_store: Snapshot store of the target agent (Agent B).
135
+ task_id: Task ID to export from source.
136
+ version: Snapshot version to export; None = latest.
137
+ new_task_id: If set, save on target under this task_id; else use task_id.
138
+
139
+ Returns:
140
+ The snapshot that was saved to the target store, or None if not found on source.
141
+ """
142
+ snapshot = source_store.get(task_id, version=version)
143
+ if snapshot is None:
144
+ logger.warning(
145
+ "asap.state_migration.no_snapshot",
146
+ task_id=task_id,
147
+ version=version,
148
+ )
149
+ return None
150
+
151
+ target_task_id = new_task_id if new_task_id is not None else task_id
152
+ # New snapshot instance for target (new id, same data) so target has its own record
153
+ migrated = StateSnapshot(
154
+ id=generate_id(),
155
+ task_id=target_task_id,
156
+ version=snapshot.version,
157
+ data=dict(snapshot.data),
158
+ checkpoint=snapshot.checkpoint,
159
+ created_at=snapshot.created_at,
160
+ )
161
+ target_store.save(migrated)
162
+ logger.info(
163
+ "asap.state_migration.moved",
164
+ source_task_id=task_id,
165
+ target_task_id=target_task_id,
166
+ snapshot_version=snapshot.version,
167
+ )
168
+ return migrated
169
+
170
+
171
+ def run_demo() -> None:
172
+ """Run state migration demo: save state on agent A, move to agent B, verify."""
173
+ source_store: InMemorySnapshotStore = InMemorySnapshotStore()
174
+ target_store: InMemorySnapshotStore = InMemorySnapshotStore()
175
+
176
+ task_id = generate_id()
177
+ snapshot = create_snapshot(
178
+ task_id=task_id,
179
+ version=1,
180
+ data={"step": "processing", "items_processed": 42, "agent": "A"},
181
+ )
182
+ source_store.save(snapshot)
183
+ logger.info(
184
+ "asap.state_migration.saved_on_source",
185
+ task_id=task_id,
186
+ snapshot_id=snapshot.id,
187
+ )
188
+
189
+ # Build StateQuery envelope (protocol-level: request state from Agent A)
190
+ query_envelope = build_state_query_envelope(task_id=task_id)
191
+ logger.info(
192
+ "asap.state_migration.state_query_envelope",
193
+ payload_type=query_envelope.payload_type,
194
+ task_id=query_envelope.payload.get("task_id"),
195
+ )
196
+
197
+ # Move state from A to B (in-process: get from source, save to target)
198
+ migrated = move_state_between_agents(source_store, target_store, task_id)
199
+ if migrated is None:
200
+ raise RuntimeError("State migration failed: no snapshot on source")
201
+
202
+ # Build StateRestore envelope (protocol-level: tell Agent B to restore from snapshot)
203
+ restore_envelope = build_state_restore_envelope(
204
+ task_id=task_id,
205
+ snapshot_id=migrated.id,
206
+ recipient_id=AGENT_B_ID,
207
+ )
208
+ logger.info(
209
+ "asap.state_migration.state_restore_envelope",
210
+ payload_type=restore_envelope.payload_type,
211
+ task_id=restore_envelope.payload.get("task_id"),
212
+ snapshot_id=restore_envelope.payload.get("snapshot_id"),
213
+ )
214
+
215
+ restored = target_store.get(migrated.task_id, version=migrated.version)
216
+ assert restored is not None # nosec B101
217
+ assert restored.data == snapshot.data # nosec B101
218
+ logger.info(
219
+ "asap.state_migration.demo_complete",
220
+ task_id=task_id,
221
+ target_has_state=restored is not None,
222
+ )
223
+
224
+
225
+ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
226
+ """Parse command-line arguments for the state migration demo."""
227
+ parser = argparse.ArgumentParser(
228
+ description="Move task state between agents (StateSnapshot, StateQuery, StateRestore)."
229
+ )
230
+ return parser.parse_args(argv)
231
+
232
+
233
+ def main(argv: Sequence[str] | None = None) -> None:
234
+ """Run the state migration demo."""
235
+ parse_args(argv)
236
+ run_demo()
237
+
238
+
239
+ if __name__ == "__main__":
240
+ main()
@@ -0,0 +1,108 @@
1
+ """Streaming response example for ASAP protocol.
2
+
3
+ This module demonstrates how to simulate streaming task updates using
4
+ TaskUpdate payloads: yield progress updates (and optionally a final
5
+ TaskResponse) so clients can show real-time progress.
6
+
7
+ Use case: Long-running tasks that emit TaskUpdate (progress, status_change)
8
+ before sending a final TaskResponse.
9
+
10
+ Run:
11
+ uv run python -m asap.examples.streaming_response
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ from typing import Any, Iterator, Sequence
18
+
19
+ from asap.models.enums import TaskStatus, UpdateType
20
+ from asap.models.ids import generate_id
21
+ from asap.models.payloads import TaskUpdate
22
+ from asap.observability import get_logger
23
+
24
+ logger = get_logger(__name__)
25
+
26
+
27
+ def stream_task_updates(
28
+ task_id: str,
29
+ num_chunks: int = 5,
30
+ chunk_message_prefix: str = "Processing chunk",
31
+ ) -> Iterator[TaskUpdate]:
32
+ """Yield TaskUpdate payloads to simulate streaming progress.
33
+
34
+ In a real implementation, each update would be sent over HTTP chunked
35
+ response, WebSocket, or Server-Sent Events. Here we just yield in-process.
36
+
37
+ Args:
38
+ task_id: Task identifier.
39
+ num_chunks: Number of progress updates to yield (1..num_chunks).
40
+ chunk_message_prefix: Message prefix for progress updates.
41
+
42
+ Yields:
43
+ TaskUpdate with progress percent and message.
44
+ """
45
+ for i in range(1, num_chunks + 1):
46
+ percent = (i * 100) // num_chunks
47
+ update = TaskUpdate(
48
+ task_id=task_id,
49
+ update_type=UpdateType.PROGRESS,
50
+ status=TaskStatus.WORKING,
51
+ progress={
52
+ "percent": percent,
53
+ "message": f"{chunk_message_prefix} {i}/{num_chunks}",
54
+ },
55
+ )
56
+ yield update
57
+ message = update.progress.get("message") if update.progress else None
58
+ logger.info(
59
+ "asap.streaming_response.update",
60
+ task_id=task_id,
61
+ percent=percent,
62
+ message=message,
63
+ )
64
+
65
+
66
+ def run_demo(num_chunks: int = 5) -> list[dict[str, Any]]:
67
+ """Run streaming demo: yield TaskUpdate payloads and collect them.
68
+
69
+ Args:
70
+ num_chunks: Number of progress chunks to stream.
71
+
72
+ Returns:
73
+ List of update payload dicts (for tests or inspection).
74
+ """
75
+ task_id = generate_id()
76
+ updates: list[dict[str, Any]] = []
77
+ for update in stream_task_updates(task_id, num_chunks=num_chunks):
78
+ updates.append(update.model_dump())
79
+ logger.info(
80
+ "asap.streaming_response.demo_complete",
81
+ task_id=task_id,
82
+ num_updates=len(updates),
83
+ )
84
+ return updates
85
+
86
+
87
+ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
88
+ """Parse command-line arguments for the streaming demo."""
89
+ parser = argparse.ArgumentParser(
90
+ description="Streaming response example (TaskUpdate progress chunks)."
91
+ )
92
+ parser.add_argument(
93
+ "--chunks",
94
+ type=int,
95
+ default=5,
96
+ help="Number of progress chunks to stream.",
97
+ )
98
+ return parser.parse_args(argv)
99
+
100
+
101
+ def main(argv: Sequence[str] | None = None) -> None:
102
+ """Run the streaming response demo."""
103
+ args = parse_args(argv)
104
+ run_demo(num_chunks=args.chunks)
105
+
106
+
107
+ if __name__ == "__main__":
108
+ main()