cloud-dog-api-kit 0.13.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 (98) hide show
  1. cloud_dog_api_kit/__init__.py +170 -0
  2. cloud_dog_api_kit/a2a/__init__.py +53 -0
  3. cloud_dog_api_kit/a2a/card.py +138 -0
  4. cloud_dog_api_kit/a2a/events.py +1123 -0
  5. cloud_dog_api_kit/a2a/gateway.py +105 -0
  6. cloud_dog_api_kit/a2a/skill_audit.py +107 -0
  7. cloud_dog_api_kit/auth/__init__.py +35 -0
  8. cloud_dog_api_kit/auth/dependency.py +121 -0
  9. cloud_dog_api_kit/auth/rbac.py +107 -0
  10. cloud_dog_api_kit/auth/service_auth.py +54 -0
  11. cloud_dog_api_kit/clients/__init__.py +29 -0
  12. cloud_dog_api_kit/clients/circuit_breaker.py +39 -0
  13. cloud_dog_api_kit/clients/http_client.py +127 -0
  14. cloud_dog_api_kit/clients/retry.py +83 -0
  15. cloud_dog_api_kit/compat/__init__.py +37 -0
  16. cloud_dog_api_kit/compat/envelope.py +120 -0
  17. cloud_dog_api_kit/compat/profile.py +102 -0
  18. cloud_dog_api_kit/compat/routes.py +90 -0
  19. cloud_dog_api_kit/config.py +54 -0
  20. cloud_dog_api_kit/correlation/__init__.py +50 -0
  21. cloud_dog_api_kit/correlation/context.py +118 -0
  22. cloud_dog_api_kit/correlation/middleware.py +133 -0
  23. cloud_dog_api_kit/envelopes/__init__.py +37 -0
  24. cloud_dog_api_kit/envelopes/error.py +87 -0
  25. cloud_dog_api_kit/envelopes/success.py +84 -0
  26. cloud_dog_api_kit/errors/__init__.py +51 -0
  27. cloud_dog_api_kit/errors/exceptions.py +184 -0
  28. cloud_dog_api_kit/errors/handler.py +102 -0
  29. cloud_dog_api_kit/errors/taxonomy.py +62 -0
  30. cloud_dog_api_kit/factory.py +157 -0
  31. cloud_dog_api_kit/idempotency/__init__.py +28 -0
  32. cloud_dog_api_kit/idempotency/middleware.py +118 -0
  33. cloud_dog_api_kit/idempotency/store.py +100 -0
  34. cloud_dog_api_kit/lifecycle/__init__.py +39 -0
  35. cloud_dog_api_kit/lifecycle/hooks.py +75 -0
  36. cloud_dog_api_kit/lifecycle/shutdown.py +178 -0
  37. cloud_dog_api_kit/mcp/__init__.py +122 -0
  38. cloud_dog_api_kit/mcp/async_jobs.py +126 -0
  39. cloud_dog_api_kit/mcp/client_sdk.py +235 -0
  40. cloud_dog_api_kit/mcp/client_transport/__init__.py +47 -0
  41. cloud_dog_api_kit/mcp/client_transport/base.py +98 -0
  42. cloud_dog_api_kit/mcp/client_transport/exceptions.py +37 -0
  43. cloud_dog_api_kit/mcp/client_transport/http_jsonrpc.py +405 -0
  44. cloud_dog_api_kit/mcp/client_transport/legacy_sse.py +320 -0
  45. cloud_dog_api_kit/mcp/client_transport/stdio.py +322 -0
  46. cloud_dog_api_kit/mcp/client_transport/streamable_http.py +748 -0
  47. cloud_dog_api_kit/mcp/contract.py +113 -0
  48. cloud_dog_api_kit/mcp/error_mapper.py +84 -0
  49. cloud_dog_api_kit/mcp/gateway.py +117 -0
  50. cloud_dog_api_kit/mcp/legacy_sse.py +129 -0
  51. cloud_dog_api_kit/mcp/session.py +96 -0
  52. cloud_dog_api_kit/mcp/sync_handler.py +269 -0
  53. cloud_dog_api_kit/mcp/tool_audit.py +136 -0
  54. cloud_dog_api_kit/mcp/tool_router.py +180 -0
  55. cloud_dog_api_kit/mcp/transport.py +1041 -0
  56. cloud_dog_api_kit/middleware/__init__.py +39 -0
  57. cloud_dog_api_kit/middleware/cors.py +74 -0
  58. cloud_dog_api_kit/middleware/logging.py +98 -0
  59. cloud_dog_api_kit/middleware/request_size_limit.py +86 -0
  60. cloud_dog_api_kit/middleware/timeout.py +78 -0
  61. cloud_dog_api_kit/middleware/timing.py +52 -0
  62. cloud_dog_api_kit/openapi/__init__.py +30 -0
  63. cloud_dog_api_kit/openapi/customise.py +69 -0
  64. cloud_dog_api_kit/openapi/route.py +46 -0
  65. cloud_dog_api_kit/routers/__init__.py +41 -0
  66. cloud_dog_api_kit/routers/crud.py +173 -0
  67. cloud_dog_api_kit/routers/health.py +160 -0
  68. cloud_dog_api_kit/routers/jobs.py +69 -0
  69. cloud_dog_api_kit/routers/version.py +46 -0
  70. cloud_dog_api_kit/schemas/__init__.py +36 -0
  71. cloud_dog_api_kit/schemas/envelopes.py +37 -0
  72. cloud_dog_api_kit/schemas/filters.py +103 -0
  73. cloud_dog_api_kit/schemas/pagination.py +148 -0
  74. cloud_dog_api_kit/streaming/__init__.py +28 -0
  75. cloud_dog_api_kit/streaming/events.py +47 -0
  76. cloud_dog_api_kit/streaming/jsonl.py +68 -0
  77. cloud_dog_api_kit/streaming/sse.py +102 -0
  78. cloud_dog_api_kit/testing/__init__.py +46 -0
  79. cloud_dog_api_kit/testing/conformance.py +156 -0
  80. cloud_dog_api_kit/testing/fixtures.py +90 -0
  81. cloud_dog_api_kit/testing/flows/__init__.py +32 -0
  82. cloud_dog_api_kit/testing/flows/auth_flow.py +41 -0
  83. cloud_dog_api_kit/testing/flows/crud_flow.py +50 -0
  84. cloud_dog_api_kit/testing/flows/job_flow.py +42 -0
  85. cloud_dog_api_kit/testing/flows/streaming_flow.py +42 -0
  86. cloud_dog_api_kit/traceability_ids.py +84 -0
  87. cloud_dog_api_kit/versioning/__init__.py +30 -0
  88. cloud_dog_api_kit/versioning/header.py +52 -0
  89. cloud_dog_api_kit/web/__init__.py +7 -0
  90. cloud_dog_api_kit/web/proxy.py +222 -0
  91. cloud_dog_api_kit/webhook/__init__.py +29 -0
  92. cloud_dog_api_kit/webhook/signature.py +149 -0
  93. cloud_dog_api_kit-0.13.0.dist-info/METADATA +27 -0
  94. cloud_dog_api_kit-0.13.0.dist-info/RECORD +98 -0
  95. cloud_dog_api_kit-0.13.0.dist-info/WHEEL +4 -0
  96. cloud_dog_api_kit-0.13.0.dist-info/licenses/LICENCE +190 -0
  97. cloud_dog_api_kit-0.13.0.dist-info/licenses/LICENSE +176 -0
  98. cloud_dog_api_kit-0.13.0.dist-info/licenses/NOTICE +7 -0
@@ -0,0 +1,178 @@
1
+ # Copyright 2026 Cloud-Dog, Viewdeck Engineering Limited
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ # cloud_dog_api_kit — Graceful shutdown management
16
+ #
17
+ # Licence: Proprietary — Cloud-Dog AI Platform
18
+ # Owner: Cloud-Dog AI
19
+ # Description: In-flight request draining and signal-handler integration for
20
+ # deterministic graceful shutdown.
21
+ # Related requirements: FR18.9
22
+ # Related architecture: SA1
23
+
24
+ """Graceful shutdown support for cloud_dog_api_kit."""
25
+
26
+ from __future__ import annotations
27
+
28
+ import asyncio
29
+ import signal
30
+ import threading
31
+ from typing import Any, Callable
32
+
33
+ from starlette.middleware.base import BaseHTTPMiddleware
34
+ from starlette.requests import Request
35
+ from starlette.responses import JSONResponse, Response
36
+
37
+ from cloud_dog_api_kit.envelopes.error import error_envelope
38
+
39
+
40
+ class GracefulShutdownManager:
41
+ """Track in-flight requests and coordinate graceful shutdown.
42
+
43
+ Args:
44
+ drain_timeout_seconds: Maximum wait time for in-flight requests.
45
+
46
+ Related tests: UT1.45_GracefulShutdown, ST1.13_StartupLifecycleIntegration
47
+ """
48
+
49
+ def __init__(self, drain_timeout_seconds: float = 5.0) -> None:
50
+ if drain_timeout_seconds < 0:
51
+ raise ValueError("drain_timeout_seconds must be >= 0")
52
+ self._drain_timeout_seconds = drain_timeout_seconds
53
+ self._lock = threading.Lock()
54
+ self._active_requests = 0
55
+ self._shutting_down = False
56
+ self._all_requests_drained = asyncio.Event()
57
+ self._all_requests_drained.set()
58
+
59
+ @property
60
+ def active_requests(self) -> int:
61
+ """Current number of active requests."""
62
+ with self._lock:
63
+ return self._active_requests
64
+
65
+ @property
66
+ def shutting_down(self) -> bool:
67
+ """Whether shutdown has started."""
68
+ with self._lock:
69
+ return self._shutting_down
70
+
71
+ def mark_request_started(self) -> bool:
72
+ """Mark a request start.
73
+
74
+ Returns:
75
+ True when request is accepted, False when shutdown is active.
76
+ """
77
+ with self._lock:
78
+ if self._shutting_down:
79
+ return False
80
+ self._active_requests += 1
81
+ self._all_requests_drained.clear()
82
+ return True
83
+
84
+ def mark_request_finished(self) -> None:
85
+ """Mark request completion."""
86
+ with self._lock:
87
+ if self._active_requests > 0:
88
+ self._active_requests -= 1
89
+ if self._active_requests == 0:
90
+ self._all_requests_drained.set()
91
+
92
+ def set_shutting_down(self) -> None:
93
+ """Set shutdown flag to reject new requests."""
94
+ with self._lock:
95
+ self._shutting_down = True
96
+ if self._active_requests == 0:
97
+ self._all_requests_drained.set()
98
+
99
+ async def drain(self) -> bool:
100
+ """Wait for in-flight requests to drain.
101
+
102
+ Returns:
103
+ True if drained before timeout, otherwise False.
104
+ """
105
+ if self.active_requests == 0:
106
+ return True
107
+ try:
108
+ await asyncio.wait_for(self._all_requests_drained.wait(), timeout=self._drain_timeout_seconds)
109
+ return True
110
+ except asyncio.TimeoutError:
111
+ return False
112
+
113
+ async def initiate_shutdown(self) -> bool:
114
+ """Start shutdown and drain requests."""
115
+ self.set_shutting_down()
116
+ return await self.drain()
117
+
118
+
119
+ class ShutdownDrainMiddleware(BaseHTTPMiddleware):
120
+ """Reject new requests during shutdown and track in-flight requests.
121
+
122
+ Args:
123
+ app: The ASGI application.
124
+ manager: Shared graceful shutdown manager.
125
+ """
126
+
127
+ def __init__(self, app: Any, manager: GracefulShutdownManager) -> None:
128
+ super().__init__(app)
129
+ self._manager = manager
130
+
131
+ async def dispatch(self, request: Request, call_next: Callable) -> Response:
132
+ """Track active request and reject when shutting down."""
133
+ if not self._manager.mark_request_started():
134
+ request_id = getattr(request.state, "request_id", "")
135
+ correlation_id = getattr(request.state, "correlation_id", None)
136
+ return JSONResponse(
137
+ status_code=503,
138
+ content=error_envelope(
139
+ code="INTERNAL_ERROR",
140
+ message="Service is shutting down",
141
+ details=None,
142
+ retryable=True,
143
+ request_id=request_id,
144
+ correlation_id=correlation_id,
145
+ ),
146
+ )
147
+ try:
148
+ return await call_next(request)
149
+ finally:
150
+ self._manager.mark_request_finished()
151
+
152
+
153
+ def install_shutdown_signal_handlers(
154
+ manager: GracefulShutdownManager,
155
+ loop: asyncio.AbstractEventLoop | None = None,
156
+ ) -> dict[int, bool]:
157
+ """Attempt to install SIGTERM/SIGINT handlers for graceful shutdown.
158
+
159
+ Returns:
160
+ Mapping of signal number to whether installation succeeded.
161
+ """
162
+ try:
163
+ active_loop = loop or asyncio.get_running_loop()
164
+ except RuntimeError:
165
+ return {signal.SIGTERM: False, signal.SIGINT: False}
166
+
167
+ results: dict[int, bool] = {}
168
+
169
+ def _schedule_shutdown() -> None:
170
+ active_loop.create_task(manager.initiate_shutdown())
171
+
172
+ for sig in (signal.SIGTERM, signal.SIGINT):
173
+ try:
174
+ active_loop.add_signal_handler(sig, _schedule_shutdown)
175
+ results[sig] = True
176
+ except (NotImplementedError, RuntimeError, ValueError):
177
+ results[sig] = False
178
+ return results
@@ -0,0 +1,122 @@
1
+ # Copyright 2026 Cloud-Dog, Viewdeck Engineering Limited
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ # cloud_dog_api_kit — MCP gateway
16
+ #
17
+ # Licence: Proprietary — Cloud-Dog AI Platform
18
+ # Owner: Cloud-Dog AI
19
+ # Description: REST-to-MCP gateway helpers.
20
+ # Related requirements: FR14.1
21
+ # Related architecture: SA1
22
+
23
+ """MCP gateway helpers for cloud_dog_api_kit."""
24
+
25
+ from __future__ import annotations
26
+
27
+ from cloud_dog_api_kit.mcp.client_transport import (
28
+ HTTPJSONRPCConfig,
29
+ HTTPJSONRPCTransport,
30
+ LegacySSETransport,
31
+ MCPProtocolError,
32
+ MCPSessionError,
33
+ MCPTransport,
34
+ MCPTransportError,
35
+ StdioConfig,
36
+ StdioTransport,
37
+ StreamableHTTPConfig,
38
+ StreamableHTTPTransport,
39
+ )
40
+ from cloud_dog_api_kit.mcp.async_jobs import AsyncJobStore, InMemoryAsyncJobStore
41
+ from cloud_dog_api_kit.mcp.gateway import MCPToolDefinition, create_mcp_tool_from_endpoint
42
+ from cloud_dog_api_kit.mcp.contract import MCPContractRegistration, register_mcp_contract
43
+ from cloud_dog_api_kit.mcp.error_mapper import map_legacy_mcp_payload
44
+ from cloud_dog_api_kit.mcp.legacy_sse import LegacySSEConfig
45
+ from cloud_dog_api_kit.mcp.session import SESSION_HEADER, McpSession, McpSessionManager
46
+ from cloud_dog_api_kit.mcp.sync_handler import (
47
+ BUDGET_EXCEEDED_CODE,
48
+ dispatch_with_sync_class,
49
+ format_a2a_task_response,
50
+ format_async_rest_response,
51
+ format_budget_exceeded_mcp_error,
52
+ validate_blocking_siblings,
53
+ )
54
+ from cloud_dog_api_kit.mcp.client_sdk import (
55
+ AsyncJobClient,
56
+ JobFailedError,
57
+ JobTimeoutError,
58
+ )
59
+ from cloud_dog_api_kit.mcp.tool_router import (
60
+ DEFAULT_SYNC_BUDGET_SECONDS,
61
+ MAX_SYNC_BUDGET_SECONDS,
62
+ SYNC_CLASS_ASYNC,
63
+ SYNC_CLASS_DEFAULT,
64
+ SYNC_CLASS_PROGRESS,
65
+ SYNC_CLASSES,
66
+ ToolContract,
67
+ register_tool_router,
68
+ )
69
+ from cloud_dog_api_kit.mcp.transport import (
70
+ AsyncJobResultShape,
71
+ MCPResource,
72
+ MCPResourceContent,
73
+ ResourcesHandler,
74
+ register_mcp_routes,
75
+ )
76
+
77
+ __all__ = [
78
+ "AsyncJobStore",
79
+ "InMemoryAsyncJobStore",
80
+ "MCPContractRegistration",
81
+ "MCPToolDefinition",
82
+ "MCPProtocolError",
83
+ "MCPSessionError",
84
+ "MCPTransport",
85
+ "MCPTransportError",
86
+ "McpSession",
87
+ "McpSessionManager",
88
+ "SESSION_HEADER",
89
+ "HTTPJSONRPCConfig",
90
+ "HTTPJSONRPCTransport",
91
+ "LegacySSEConfig",
92
+ "LegacySSETransport",
93
+ "AsyncJobResultShape",
94
+ "MCPResource",
95
+ "MCPResourceContent",
96
+ "ResourcesHandler",
97
+ "StdioConfig",
98
+ "StdioTransport",
99
+ "StreamableHTTPConfig",
100
+ "StreamableHTTPTransport",
101
+ "BUDGET_EXCEEDED_CODE",
102
+ "DEFAULT_SYNC_BUDGET_SECONDS",
103
+ "MAX_SYNC_BUDGET_SECONDS",
104
+ "SYNC_CLASS_ASYNC",
105
+ "SYNC_CLASS_DEFAULT",
106
+ "SYNC_CLASS_PROGRESS",
107
+ "SYNC_CLASSES",
108
+ "ToolContract",
109
+ "dispatch_with_sync_class",
110
+ "format_a2a_task_response",
111
+ "format_async_rest_response",
112
+ "format_budget_exceeded_mcp_error",
113
+ "validate_blocking_siblings",
114
+ "create_mcp_tool_from_endpoint",
115
+ "map_legacy_mcp_payload",
116
+ "register_mcp_contract",
117
+ "register_mcp_routes",
118
+ "register_tool_router",
119
+ "AsyncJobClient",
120
+ "JobFailedError",
121
+ "JobTimeoutError",
122
+ ]
@@ -0,0 +1,126 @@
1
+ # Copyright 2026 Cloud-Dog, Viewdeck Engineering Limited
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ # cloud_dog_api_kit — MCP async job helpers
16
+ #
17
+ # Licence: Proprietary — Cloud-Dog AI Platform
18
+ # Owner: Cloud-Dog AI
19
+ # Description: Shared async job-store protocol and in-memory reference
20
+ # implementation for wait=false MCP tool calls.
21
+ # Related requirements: FR18.1
22
+ # Related architecture: SA1
23
+
24
+ """Async MCP tool-call job helpers."""
25
+
26
+ from __future__ import annotations
27
+
28
+ import asyncio
29
+ import inspect
30
+ import threading
31
+ import uuid
32
+ from collections.abc import Awaitable, Callable
33
+ from typing import Any, Protocol
34
+
35
+ AsyncJobContext = dict[str, Any]
36
+ AsyncJobStatus = dict[str, Any]
37
+
38
+
39
+ class AsyncJobStore(Protocol):
40
+ """Protocol for async MCP tool-call job stores."""
41
+
42
+ def submit(
43
+ self,
44
+ tool_name: str,
45
+ arguments: dict[str, Any],
46
+ context: AsyncJobContext,
47
+ ) -> str | Awaitable[str]:
48
+ """Submit a tool call for async execution and return a job id."""
49
+
50
+ def get_status(self, job_id: str) -> AsyncJobStatus | Awaitable[AsyncJobStatus]:
51
+ """Return the current status payload for a submitted job."""
52
+
53
+
54
+ class InMemoryAsyncJobStore:
55
+ """In-memory async job store for local tests and simple service deployments."""
56
+
57
+ def __init__(self) -> None:
58
+ self._lock = threading.Lock()
59
+ self._jobs: dict[str, AsyncJobStatus] = {}
60
+ self._tasks: dict[str, asyncio.Task[Any]] = {}
61
+
62
+ def submit(
63
+ self,
64
+ tool_name: str,
65
+ arguments: dict[str, Any],
66
+ context: AsyncJobContext,
67
+ ) -> str:
68
+ """Submit a tool call for background execution."""
69
+ runner = context.get("runner")
70
+ if not callable(runner):
71
+ raise TypeError("async job context requires a callable 'runner'")
72
+
73
+ result_formatter = context.get("result_formatter")
74
+ if result_formatter is not None and not callable(result_formatter):
75
+ raise TypeError("async job context 'result_formatter' must be callable when provided")
76
+
77
+ job_id = f"job-{uuid.uuid4().hex}"
78
+ with self._lock:
79
+ self._jobs[job_id] = {"status": "pending", "tool_name": tool_name, "arguments": dict(arguments)}
80
+
81
+ task = asyncio.create_task(self._run_job(job_id, runner, result_formatter))
82
+ with self._lock:
83
+ self._tasks[job_id] = task
84
+ return job_id
85
+
86
+ async def _run_job(
87
+ self,
88
+ job_id: str,
89
+ runner: Callable[[], Any],
90
+ result_formatter: Callable[[Any], Any] | None,
91
+ ) -> None:
92
+ """Execute a submitted async job and persist its lifecycle state."""
93
+ with self._lock:
94
+ job = dict(self._jobs.get(job_id) or {})
95
+ job["status"] = "running"
96
+ self._jobs[job_id] = job
97
+
98
+ try:
99
+ result = runner()
100
+ if inspect.isawaitable(result):
101
+ result = await result
102
+ if result_formatter is not None:
103
+ result = result_formatter(result)
104
+ with self._lock:
105
+ job = dict(self._jobs.get(job_id) or {})
106
+ job["status"] = "completed"
107
+ job["result"] = result
108
+ job.pop("error", None)
109
+ self._jobs[job_id] = job
110
+ except Exception as exc:
111
+ with self._lock:
112
+ job = dict(self._jobs.get(job_id) or {})
113
+ job["status"] = "failed"
114
+ job["error"] = str(exc)
115
+ self._jobs[job_id] = job
116
+ finally:
117
+ with self._lock:
118
+ self._tasks.pop(job_id, None)
119
+
120
+ def get_status(self, job_id: str) -> AsyncJobStatus:
121
+ """Return the current status payload for a job id."""
122
+ with self._lock:
123
+ status = self._jobs.get(job_id)
124
+ if status is None:
125
+ return {"status": "not_found", "error": "Job not found"}
126
+ return dict(status)
@@ -0,0 +1,235 @@
1
+ # Copyright 2026 Cloud-Dog, Viewdeck Engineering Limited
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """PS-95 client SDK helpers for three-mode tool invocation (W28D-309).
4
+
5
+ Provides:
6
+ - submit(): fire-and-forget async job submission (Mode 3)
7
+ - wait(): poll until job completes or timeout
8
+ - call_with_auto_wait(): smart caller that handles all three modes
9
+ - progress_token_handler(): handles Mode 2 progress notifications
10
+ - retry_idempotent(): idempotent retry with automatic dedup
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import time
17
+ from typing import Any, Callable, Awaitable
18
+
19
+ # PS-95 constants (consistent with sync_handler.py)
20
+ DEFAULT_POLL_INTERVAL_SECONDS = 1.0
21
+ DEFAULT_CLIENT_TIMEOUT_SECONDS = 300.0 # 5 minutes (PS-95 §9 worker_timeout)
22
+ BUDGET_EXCEEDED_CODE = -32000
23
+
24
+
25
+ class AsyncJobClient:
26
+ """Client-side helper for PS-95 three-mode tool calls.
27
+
28
+ Wraps a transport (MCP/REST/A2A) and provides high-level helpers
29
+ that handle the three sync_class modes transparently.
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ *,
35
+ call_tool: Callable[[str, dict[str, Any]], Awaitable[dict[str, Any]]],
36
+ poll_job: Callable[[str], Awaitable[dict[str, Any]]],
37
+ poll_interval: float = DEFAULT_POLL_INTERVAL_SECONDS,
38
+ timeout: float = DEFAULT_CLIENT_TIMEOUT_SECONDS,
39
+ ) -> None:
40
+ """Initialize.
41
+
42
+ Args:
43
+ call_tool: async callable(tool_name, arguments) -> server response dict
44
+ poll_job: async callable(job_id) -> job status dict
45
+ poll_interval: seconds between poll requests
46
+ timeout: max seconds to wait for job completion
47
+ """
48
+ self._call_tool = call_tool
49
+ self._poll_job = poll_job
50
+ self._poll_interval = poll_interval
51
+ self._timeout = timeout
52
+
53
+ async def submit(self, tool_name: str, arguments: dict[str, Any]) -> str:
54
+ """Submit a tool call and return the job_id immediately (Mode 3 client).
55
+
56
+ If the server returns a sync result, wraps it as a completed job.
57
+ """
58
+ response = await self._call_tool(tool_name, arguments)
59
+ job_id = _extract_job_id(response)
60
+ if job_id is None:
61
+ raise ValueError(
62
+ f"Server did not return a job_id for tool {tool_name!r}. "
63
+ "Use call_with_auto_wait() for tools that may return sync results."
64
+ )
65
+ return job_id
66
+
67
+ async def wait(self, job_id: str, *, timeout: float | None = None) -> dict[str, Any]:
68
+ """Poll a job until completion or timeout.
69
+
70
+ Returns the final job status dict with 'result' on success.
71
+ Raises TimeoutError with the job_id if timeout expires.
72
+ """
73
+ deadline = time.monotonic() + (timeout or self._timeout)
74
+ while True:
75
+ status = await self._poll_job(job_id)
76
+ state = str(status.get("status", "")).lower()
77
+ if state in ("completed", "succeeded"):
78
+ return status
79
+ if state in ("failed", "cancelled", "timeout", "dead_lettered"):
80
+ raise JobFailedError(job_id=job_id, status=status)
81
+ if time.monotonic() >= deadline:
82
+ raise JobTimeoutError(job_id=job_id, last_status=status)
83
+ await asyncio.sleep(self._poll_interval)
84
+
85
+ async def call_with_auto_wait(
86
+ self,
87
+ tool_name: str,
88
+ arguments: dict[str, Any],
89
+ *,
90
+ timeout: float | None = None,
91
+ on_progress: Callable[[float, float | None, str | None], Awaitable[None]] | None = None,
92
+ ) -> dict[str, Any]:
93
+ """Call a tool with automatic mode detection and waiting.
94
+
95
+ - If server returns sync result (Mode 1 completed): returns immediately.
96
+ - If server returns job_id (Mode 3 or budget-exceeded): polls until done.
97
+ - If server sends progress (Mode 2): calls on_progress, then returns result.
98
+
99
+ Returns dict with 'result' key on success.
100
+ Raises JobTimeoutError with job_id on timeout (client can resume polling).
101
+ """
102
+ response = await self._call_tool(tool_name, arguments)
103
+
104
+ # Check for sync result (Mode 1 completed within budget)
105
+ if _is_sync_result(response):
106
+ return {"status": "completed", "result": response.get("result", response)}
107
+
108
+ # Check for budget-exceeded error (Mode 1 timeout)
109
+ if _is_budget_exceeded(response):
110
+ job_id = _extract_job_id_from_error(response)
111
+ if job_id:
112
+ return await self.wait(job_id, timeout=timeout)
113
+ raise ValueError("Budget exceeded but no job_id in error response")
114
+
115
+ # Async/job_id response (Mode 3)
116
+ job_id = _extract_job_id(response)
117
+ if job_id:
118
+ return await self.wait(job_id, timeout=timeout)
119
+
120
+ # Fallback: treat entire response as sync result
121
+ return {"status": "completed", "result": response}
122
+
123
+ async def retry_idempotent(
124
+ self,
125
+ tool_name: str,
126
+ arguments: dict[str, Any],
127
+ *,
128
+ idempotency_key: str | None = None,
129
+ max_retries: int = 3,
130
+ timeout: float | None = None,
131
+ ) -> dict[str, Any]:
132
+ """Retry a tool call with idempotency guarantees.
133
+
134
+ If the call returns a job_id, subsequent retries poll the existing job
135
+ instead of resubmitting (deduplication).
136
+ """
137
+ last_error: Exception | None = None
138
+ known_job_id: str | None = None
139
+
140
+ for attempt in range(max_retries):
141
+ try:
142
+ if known_job_id:
143
+ return await self.wait(known_job_id, timeout=timeout)
144
+
145
+ args = dict(arguments)
146
+ if idempotency_key:
147
+ args["_idempotency_key"] = idempotency_key
148
+
149
+ response = await self._call_tool(tool_name, args)
150
+ job_id = _extract_job_id(response)
151
+ if job_id:
152
+ known_job_id = job_id
153
+ return await self.wait(job_id, timeout=timeout)
154
+
155
+ if _is_sync_result(response):
156
+ return {"status": "completed", "result": response.get("result", response)}
157
+
158
+ return {"status": "completed", "result": response}
159
+
160
+ except JobTimeoutError as e:
161
+ known_job_id = e.job_id
162
+ last_error = e
163
+ except Exception as e:
164
+ last_error = e
165
+
166
+ if attempt < max_retries - 1:
167
+ await asyncio.sleep(self._poll_interval * (attempt + 1))
168
+
169
+ raise last_error or RuntimeError("Retry exhausted with no result or error")
170
+
171
+
172
+ # ── Response parsing helpers ──
173
+
174
+
175
+ def _extract_job_id(response: dict[str, Any]) -> str | None:
176
+ """Extract job_id from various server response formats."""
177
+ if "job_id" in response:
178
+ return str(response["job_id"])
179
+ if "content" in response and isinstance(response["content"], list):
180
+ for item in response["content"]:
181
+ if isinstance(item, dict) and "job_id" in item:
182
+ return str(item["job_id"])
183
+ return None
184
+
185
+
186
+ def _extract_job_id_from_error(response: dict[str, Any]) -> str | None:
187
+ """Extract job_id from budget-exceeded error response."""
188
+ error = response.get("error", {})
189
+ if isinstance(error, dict):
190
+ data = error.get("data", {})
191
+ if isinstance(data, dict) and "job_id" in data:
192
+ return str(data["job_id"])
193
+ return None
194
+
195
+
196
+ def _is_sync_result(response: dict[str, Any]) -> bool:
197
+ """True if the response is a completed sync result (not a job envelope)."""
198
+ if "job_id" in response:
199
+ return False
200
+ if "error" in response:
201
+ return False
202
+ if response.get("mode") == "async":
203
+ return False
204
+ if response.get("status") == "submitted":
205
+ return False
206
+ return "result" in response or "content" in response
207
+
208
+
209
+ def _is_budget_exceeded(response: dict[str, Any]) -> bool:
210
+ """True if the response is a PS-95 budget-exceeded error."""
211
+ error = response.get("error", {})
212
+ if isinstance(error, dict):
213
+ return error.get("code") == BUDGET_EXCEEDED_CODE
214
+ return False
215
+
216
+
217
+ # ── Exceptions ──
218
+
219
+
220
+ class JobTimeoutError(Exception):
221
+ """Raised when polling exceeds timeout. Carries job_id for resumption."""
222
+
223
+ def __init__(self, job_id: str, last_status: dict[str, Any] | None = None) -> None:
224
+ self.job_id = job_id
225
+ self.last_status = last_status
226
+ super().__init__(f"Job {job_id} timed out waiting for completion")
227
+
228
+
229
+ class JobFailedError(Exception):
230
+ """Raised when a polled job reaches a terminal failure state."""
231
+
232
+ def __init__(self, job_id: str, status: dict[str, Any]) -> None:
233
+ self.job_id = job_id
234
+ self.status = status
235
+ super().__init__(f"Job {job_id} failed: {status.get('error', 'unknown')}")