opencode-a2a 0.3.1__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.
- opencode_a2a/__init__.py +15 -0
- opencode_a2a/cli.py +52 -0
- opencode_a2a/config.py +160 -0
- opencode_a2a/contracts/__init__.py +1 -0
- opencode_a2a/contracts/extensions.py +948 -0
- opencode_a2a/execution/__init__.py +1 -0
- opencode_a2a/execution/executor.py +1582 -0
- opencode_a2a/execution/request_context.py +91 -0
- opencode_a2a/execution/stream_events.py +578 -0
- opencode_a2a/execution/stream_state.py +279 -0
- opencode_a2a/execution/upstream_errors.py +264 -0
- opencode_a2a/jsonrpc/__init__.py +1 -0
- opencode_a2a/jsonrpc/application.py +1036 -0
- opencode_a2a/jsonrpc/methods.py +537 -0
- opencode_a2a/jsonrpc/params.py +123 -0
- opencode_a2a/opencode_upstream_client.py +544 -0
- opencode_a2a/parts/__init__.py +1 -0
- opencode_a2a/parts/mapping.py +151 -0
- opencode_a2a/parts/text.py +24 -0
- opencode_a2a/profile/__init__.py +1 -0
- opencode_a2a/profile/runtime.py +254 -0
- opencode_a2a/server/__init__.py +1 -0
- opencode_a2a/server/agent_card.py +288 -0
- opencode_a2a/server/application.py +634 -0
- opencode_a2a/server/openapi.py +432 -0
- opencode_a2a/server/request_parsing.py +109 -0
- opencode_a2a-0.3.1.dist-info/METADATA +173 -0
- opencode_a2a-0.3.1.dist-info/RECORD +32 -0
- opencode_a2a-0.3.1.dist-info/WHEEL +5 -0
- opencode_a2a-0.3.1.dist-info/entry_points.txt +2 -0
- opencode_a2a-0.3.1.dist-info/licenses/LICENSE +176 -0
- opencode_a2a-0.3.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1036 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import Awaitable, Callable
|
|
5
|
+
from typing import Any, cast
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from a2a.server.apps.jsonrpc.fastapi_app import A2AFastAPIApplication
|
|
9
|
+
from a2a.types import (
|
|
10
|
+
A2AError,
|
|
11
|
+
InternalError,
|
|
12
|
+
InvalidParamsError,
|
|
13
|
+
InvalidRequestError,
|
|
14
|
+
JSONRPCError,
|
|
15
|
+
JSONRPCRequest,
|
|
16
|
+
)
|
|
17
|
+
from fastapi.responses import JSONResponse
|
|
18
|
+
from starlette.requests import Request
|
|
19
|
+
from starlette.responses import Response
|
|
20
|
+
|
|
21
|
+
from ..contracts.extensions import (
|
|
22
|
+
INTERRUPT_ERROR_BUSINESS_CODES,
|
|
23
|
+
PROVIDER_DISCOVERY_ERROR_BUSINESS_CODES,
|
|
24
|
+
SESSION_QUERY_ERROR_BUSINESS_CODES,
|
|
25
|
+
)
|
|
26
|
+
from ..opencode_upstream_client import OpencodeUpstreamClient, UpstreamContractError
|
|
27
|
+
from .methods import (
|
|
28
|
+
SESSION_CONTEXT_PREFIX,
|
|
29
|
+
_apply_session_query_limit,
|
|
30
|
+
_as_a2a_message,
|
|
31
|
+
_as_a2a_session_task,
|
|
32
|
+
_extract_provider_catalog,
|
|
33
|
+
_extract_raw_items,
|
|
34
|
+
_normalize_model_summaries,
|
|
35
|
+
_normalize_permission_reply,
|
|
36
|
+
_normalize_provider_summaries,
|
|
37
|
+
_parse_question_answers,
|
|
38
|
+
_PromptAsyncValidationError,
|
|
39
|
+
_validate_command_request_payload,
|
|
40
|
+
_validate_prompt_async_format,
|
|
41
|
+
_validate_prompt_async_part,
|
|
42
|
+
_validate_prompt_async_request_payload,
|
|
43
|
+
_validate_shell_request_payload,
|
|
44
|
+
)
|
|
45
|
+
from .params import (
|
|
46
|
+
JsonRpcParamsValidationError,
|
|
47
|
+
parse_get_session_messages_params,
|
|
48
|
+
parse_list_sessions_params,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
logger = logging.getLogger(__name__)
|
|
52
|
+
|
|
53
|
+
__all__ = [
|
|
54
|
+
"SESSION_CONTEXT_PREFIX",
|
|
55
|
+
"_PromptAsyncValidationError",
|
|
56
|
+
"_extract_provider_catalog",
|
|
57
|
+
"_normalize_model_summaries",
|
|
58
|
+
"_normalize_permission_reply",
|
|
59
|
+
"_normalize_provider_summaries",
|
|
60
|
+
"_parse_question_answers",
|
|
61
|
+
"_validate_command_request_payload",
|
|
62
|
+
"_validate_prompt_async_format",
|
|
63
|
+
"_validate_prompt_async_part",
|
|
64
|
+
"_validate_shell_request_payload",
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
ERR_SESSION_NOT_FOUND = SESSION_QUERY_ERROR_BUSINESS_CODES["SESSION_NOT_FOUND"]
|
|
68
|
+
ERR_SESSION_FORBIDDEN = SESSION_QUERY_ERROR_BUSINESS_CODES["SESSION_FORBIDDEN"]
|
|
69
|
+
ERR_METHOD_NOT_SUPPORTED = -32601
|
|
70
|
+
ERR_UPSTREAM_UNREACHABLE = SESSION_QUERY_ERROR_BUSINESS_CODES["UPSTREAM_UNREACHABLE"]
|
|
71
|
+
ERR_UPSTREAM_HTTP_ERROR = SESSION_QUERY_ERROR_BUSINESS_CODES["UPSTREAM_HTTP_ERROR"]
|
|
72
|
+
ERR_INTERRUPT_NOT_FOUND = INTERRUPT_ERROR_BUSINESS_CODES["INTERRUPT_REQUEST_NOT_FOUND"]
|
|
73
|
+
ERR_UPSTREAM_PAYLOAD_ERROR = SESSION_QUERY_ERROR_BUSINESS_CODES["UPSTREAM_PAYLOAD_ERROR"]
|
|
74
|
+
ERR_DISCOVERY_UPSTREAM_UNREACHABLE = PROVIDER_DISCOVERY_ERROR_BUSINESS_CODES["UPSTREAM_UNREACHABLE"]
|
|
75
|
+
ERR_DISCOVERY_UPSTREAM_HTTP_ERROR = PROVIDER_DISCOVERY_ERROR_BUSINESS_CODES["UPSTREAM_HTTP_ERROR"]
|
|
76
|
+
ERR_DISCOVERY_UPSTREAM_PAYLOAD_ERROR = PROVIDER_DISCOVERY_ERROR_BUSINESS_CODES[
|
|
77
|
+
"UPSTREAM_PAYLOAD_ERROR"
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class OpencodeSessionQueryJSONRPCApplication(A2AFastAPIApplication):
|
|
82
|
+
"""Extend A2A JSON-RPC endpoint with OpenCode session methods.
|
|
83
|
+
|
|
84
|
+
These methods are optional (declared via AgentCard.capabilities.extensions) and do
|
|
85
|
+
not require additional private REST endpoints.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
90
|
+
*args: Any,
|
|
91
|
+
upstream_client: OpencodeUpstreamClient,
|
|
92
|
+
methods: dict[str, str],
|
|
93
|
+
protocol_version: str,
|
|
94
|
+
supported_methods: list[str],
|
|
95
|
+
directory_resolver: Callable[[str | None], str | None] | None = None,
|
|
96
|
+
session_claim: Callable[..., Awaitable[bool]] | None = None,
|
|
97
|
+
session_claim_finalize: Callable[..., Awaitable[None]] | None = None,
|
|
98
|
+
session_claim_release: Callable[..., Awaitable[None]] | None = None,
|
|
99
|
+
**kwargs: Any,
|
|
100
|
+
):
|
|
101
|
+
super().__init__(*args, **kwargs)
|
|
102
|
+
self._upstream_client = upstream_client
|
|
103
|
+
self._method_list_sessions = methods["list_sessions"]
|
|
104
|
+
self._method_get_session_messages = methods["get_session_messages"]
|
|
105
|
+
self._method_prompt_async = methods["prompt_async"]
|
|
106
|
+
self._method_command = methods["command"]
|
|
107
|
+
self._method_shell = methods.get("shell")
|
|
108
|
+
self._method_list_providers = methods["list_providers"]
|
|
109
|
+
self._method_list_models = methods["list_models"]
|
|
110
|
+
self._method_reply_permission = methods["reply_permission"]
|
|
111
|
+
self._method_reply_question = methods["reply_question"]
|
|
112
|
+
self._method_reject_question = methods["reject_question"]
|
|
113
|
+
self._protocol_version = protocol_version
|
|
114
|
+
self._supported_methods = list(supported_methods)
|
|
115
|
+
missing_control_hooks = [
|
|
116
|
+
name
|
|
117
|
+
for name, hook in (
|
|
118
|
+
("directory_resolver", directory_resolver),
|
|
119
|
+
("session_claim", session_claim),
|
|
120
|
+
("session_claim_finalize", session_claim_finalize),
|
|
121
|
+
("session_claim_release", session_claim_release),
|
|
122
|
+
)
|
|
123
|
+
if hook is None
|
|
124
|
+
]
|
|
125
|
+
if missing_control_hooks:
|
|
126
|
+
raise ValueError(
|
|
127
|
+
"Control methods require guard hooks: " + ", ".join(sorted(missing_control_hooks))
|
|
128
|
+
)
|
|
129
|
+
self._directory_resolver = cast(Callable[[str | None], str | None], directory_resolver)
|
|
130
|
+
self._session_claim = cast(Callable[..., Awaitable[bool]], session_claim)
|
|
131
|
+
self._session_claim_finalize = cast(Callable[..., Awaitable[None]], session_claim_finalize)
|
|
132
|
+
self._session_claim_release = cast(Callable[..., Awaitable[None]], session_claim_release)
|
|
133
|
+
|
|
134
|
+
def _session_forbidden_response(
|
|
135
|
+
self,
|
|
136
|
+
request_id: str | int | None,
|
|
137
|
+
*,
|
|
138
|
+
session_id: str,
|
|
139
|
+
) -> Response:
|
|
140
|
+
return self._generate_error_response(
|
|
141
|
+
request_id,
|
|
142
|
+
JSONRPCError(
|
|
143
|
+
code=ERR_SESSION_FORBIDDEN,
|
|
144
|
+
message="Session forbidden",
|
|
145
|
+
data={"type": "SESSION_FORBIDDEN", "session_id": session_id},
|
|
146
|
+
),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def _extract_directory_from_metadata(
|
|
150
|
+
self,
|
|
151
|
+
*,
|
|
152
|
+
request_id: str | int | None,
|
|
153
|
+
params: dict[str, Any],
|
|
154
|
+
) -> tuple[str | None, Response | None]:
|
|
155
|
+
metadata = params.get("metadata")
|
|
156
|
+
if metadata is not None and not isinstance(metadata, dict):
|
|
157
|
+
return None, self._generate_error_response(
|
|
158
|
+
request_id,
|
|
159
|
+
A2AError(
|
|
160
|
+
root=InvalidParamsError(
|
|
161
|
+
message="metadata must be an object",
|
|
162
|
+
data={"type": "INVALID_FIELD", "field": "metadata"},
|
|
163
|
+
)
|
|
164
|
+
),
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
opencode_metadata: dict[str, Any] | None = None
|
|
168
|
+
if isinstance(metadata, dict):
|
|
169
|
+
unknown_metadata_fields = sorted(set(metadata) - {"opencode", "shared"})
|
|
170
|
+
if unknown_metadata_fields:
|
|
171
|
+
prefixed_fields = [f"metadata.{field}" for field in unknown_metadata_fields]
|
|
172
|
+
return None, self._generate_error_response(
|
|
173
|
+
request_id,
|
|
174
|
+
A2AError(
|
|
175
|
+
root=InvalidParamsError(
|
|
176
|
+
message=f"Unsupported metadata fields: {', '.join(prefixed_fields)}",
|
|
177
|
+
data={"type": "INVALID_FIELD", "fields": prefixed_fields},
|
|
178
|
+
)
|
|
179
|
+
),
|
|
180
|
+
)
|
|
181
|
+
raw_opencode_metadata = metadata.get("opencode")
|
|
182
|
+
if raw_opencode_metadata is not None and not isinstance(raw_opencode_metadata, dict):
|
|
183
|
+
return None, self._generate_error_response(
|
|
184
|
+
request_id,
|
|
185
|
+
A2AError(
|
|
186
|
+
root=InvalidParamsError(
|
|
187
|
+
message="metadata.opencode must be an object",
|
|
188
|
+
data={"type": "INVALID_FIELD", "field": "metadata.opencode"},
|
|
189
|
+
)
|
|
190
|
+
),
|
|
191
|
+
)
|
|
192
|
+
if isinstance(raw_opencode_metadata, dict):
|
|
193
|
+
opencode_metadata = raw_opencode_metadata
|
|
194
|
+
raw_shared_metadata = metadata.get("shared")
|
|
195
|
+
if raw_shared_metadata is not None and not isinstance(raw_shared_metadata, dict):
|
|
196
|
+
return None, self._generate_error_response(
|
|
197
|
+
request_id,
|
|
198
|
+
A2AError(
|
|
199
|
+
root=InvalidParamsError(
|
|
200
|
+
message="metadata.shared must be an object",
|
|
201
|
+
data={"type": "INVALID_FIELD", "field": "metadata.shared"},
|
|
202
|
+
)
|
|
203
|
+
),
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
directory = None
|
|
207
|
+
if opencode_metadata is not None:
|
|
208
|
+
directory = opencode_metadata.get("directory")
|
|
209
|
+
if directory is not None and not isinstance(directory, str):
|
|
210
|
+
return None, self._generate_error_response(
|
|
211
|
+
request_id,
|
|
212
|
+
A2AError(
|
|
213
|
+
root=InvalidParamsError(
|
|
214
|
+
message="metadata.opencode.directory must be a string",
|
|
215
|
+
data={"type": "INVALID_FIELD", "field": "metadata.opencode.directory"},
|
|
216
|
+
)
|
|
217
|
+
),
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
return directory, None
|
|
221
|
+
|
|
222
|
+
async def _handle_requests(self, request: Request) -> Response:
|
|
223
|
+
# Fast path: sniff method first then either handle here or delegate.
|
|
224
|
+
request_id: str | int | None = None
|
|
225
|
+
try:
|
|
226
|
+
body = await request.json()
|
|
227
|
+
if isinstance(body, dict):
|
|
228
|
+
request_id = body.get("id")
|
|
229
|
+
if request_id is not None and not isinstance(request_id, str | int):
|
|
230
|
+
request_id = None
|
|
231
|
+
|
|
232
|
+
if not self._allowed_content_length(request):
|
|
233
|
+
return self._generate_error_response(
|
|
234
|
+
request_id,
|
|
235
|
+
A2AError(root=InvalidRequestError(message="Payload too large")),
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
base_request = JSONRPCRequest.model_validate(body)
|
|
239
|
+
except Exception:
|
|
240
|
+
# Delegate to base implementation for consistent error handling.
|
|
241
|
+
return await super()._handle_requests(request)
|
|
242
|
+
|
|
243
|
+
session_query_methods = {
|
|
244
|
+
self._method_list_sessions,
|
|
245
|
+
self._method_get_session_messages,
|
|
246
|
+
}
|
|
247
|
+
provider_discovery_methods = {
|
|
248
|
+
self._method_list_providers,
|
|
249
|
+
self._method_list_models,
|
|
250
|
+
}
|
|
251
|
+
session_control_methods = {
|
|
252
|
+
self._method_prompt_async,
|
|
253
|
+
self._method_command,
|
|
254
|
+
}
|
|
255
|
+
if self._method_shell is not None:
|
|
256
|
+
session_control_methods.add(self._method_shell)
|
|
257
|
+
interrupt_callback_methods = {
|
|
258
|
+
self._method_reply_permission,
|
|
259
|
+
self._method_reply_question,
|
|
260
|
+
self._method_reject_question,
|
|
261
|
+
}
|
|
262
|
+
if (
|
|
263
|
+
base_request.method
|
|
264
|
+
not in session_query_methods
|
|
265
|
+
| provider_discovery_methods
|
|
266
|
+
| session_control_methods
|
|
267
|
+
| interrupt_callback_methods
|
|
268
|
+
):
|
|
269
|
+
core_methods = {
|
|
270
|
+
"message/send",
|
|
271
|
+
"message/stream",
|
|
272
|
+
"tasks/get",
|
|
273
|
+
"tasks/cancel",
|
|
274
|
+
"tasks/resubscribe",
|
|
275
|
+
}
|
|
276
|
+
if base_request.method in core_methods:
|
|
277
|
+
return await super()._handle_requests(request)
|
|
278
|
+
|
|
279
|
+
if base_request.id is None:
|
|
280
|
+
return Response(status_code=204)
|
|
281
|
+
|
|
282
|
+
return self._generate_error_response(
|
|
283
|
+
base_request.id,
|
|
284
|
+
JSONRPCError(
|
|
285
|
+
code=ERR_METHOD_NOT_SUPPORTED,
|
|
286
|
+
message=f"Unsupported method: {base_request.method}",
|
|
287
|
+
data={
|
|
288
|
+
"type": "METHOD_NOT_SUPPORTED",
|
|
289
|
+
"method": base_request.method,
|
|
290
|
+
"supported_methods": self._supported_methods,
|
|
291
|
+
"protocol_version": self._protocol_version,
|
|
292
|
+
},
|
|
293
|
+
),
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
params = base_request.params or {}
|
|
297
|
+
if not isinstance(params, dict):
|
|
298
|
+
return self._generate_error_response(
|
|
299
|
+
base_request.id,
|
|
300
|
+
A2AError(root=InvalidParamsError(message="params must be an object")),
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
if base_request.method in session_query_methods:
|
|
304
|
+
return await self._handle_session_query_request(base_request, params)
|
|
305
|
+
if base_request.method in provider_discovery_methods:
|
|
306
|
+
return await self._handle_provider_discovery_request(base_request, params)
|
|
307
|
+
if base_request.method in session_control_methods:
|
|
308
|
+
return await self._handle_session_control_request(
|
|
309
|
+
base_request,
|
|
310
|
+
params,
|
|
311
|
+
request=request,
|
|
312
|
+
)
|
|
313
|
+
return await self._handle_interrupt_callback_request(base_request, params, request=request)
|
|
314
|
+
|
|
315
|
+
async def _handle_session_query_request(
|
|
316
|
+
self,
|
|
317
|
+
base_request: JSONRPCRequest,
|
|
318
|
+
params: dict[str, Any],
|
|
319
|
+
) -> Response:
|
|
320
|
+
try:
|
|
321
|
+
if base_request.method == self._method_list_sessions:
|
|
322
|
+
query = parse_list_sessions_params(params)
|
|
323
|
+
session_id: str | None = None
|
|
324
|
+
else:
|
|
325
|
+
session_id, query = parse_get_session_messages_params(params)
|
|
326
|
+
except JsonRpcParamsValidationError as exc:
|
|
327
|
+
return self._generate_error_response(
|
|
328
|
+
base_request.id,
|
|
329
|
+
A2AError(
|
|
330
|
+
root=InvalidParamsError(
|
|
331
|
+
message=str(exc),
|
|
332
|
+
data=exc.data,
|
|
333
|
+
)
|
|
334
|
+
),
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
limit = int(query["limit"])
|
|
338
|
+
try:
|
|
339
|
+
if base_request.method == self._method_list_sessions:
|
|
340
|
+
raw_result = await self._upstream_client.list_sessions(params=query)
|
|
341
|
+
else:
|
|
342
|
+
assert session_id is not None
|
|
343
|
+
raw_result = await self._upstream_client.list_messages(session_id, params=query)
|
|
344
|
+
except httpx.HTTPStatusError as exc:
|
|
345
|
+
upstream_status = exc.response.status_code
|
|
346
|
+
if upstream_status == 404 and base_request.method == self._method_get_session_messages:
|
|
347
|
+
return self._generate_error_response(
|
|
348
|
+
base_request.id,
|
|
349
|
+
JSONRPCError(
|
|
350
|
+
code=ERR_SESSION_NOT_FOUND,
|
|
351
|
+
message="Session not found",
|
|
352
|
+
data={"type": "SESSION_NOT_FOUND", "session_id": session_id},
|
|
353
|
+
),
|
|
354
|
+
)
|
|
355
|
+
return self._generate_error_response(
|
|
356
|
+
base_request.id,
|
|
357
|
+
JSONRPCError(
|
|
358
|
+
code=ERR_UPSTREAM_HTTP_ERROR,
|
|
359
|
+
message="Upstream OpenCode error",
|
|
360
|
+
data={
|
|
361
|
+
"type": "UPSTREAM_HTTP_ERROR",
|
|
362
|
+
"upstream_status": upstream_status,
|
|
363
|
+
},
|
|
364
|
+
),
|
|
365
|
+
)
|
|
366
|
+
except httpx.HTTPError:
|
|
367
|
+
return self._generate_error_response(
|
|
368
|
+
base_request.id,
|
|
369
|
+
JSONRPCError(
|
|
370
|
+
code=ERR_UPSTREAM_UNREACHABLE,
|
|
371
|
+
message="Upstream OpenCode unreachable",
|
|
372
|
+
data={"type": "UPSTREAM_UNREACHABLE"},
|
|
373
|
+
),
|
|
374
|
+
)
|
|
375
|
+
except Exception as exc:
|
|
376
|
+
logger.exception("OpenCode session query JSON-RPC method failed")
|
|
377
|
+
return self._generate_error_response(
|
|
378
|
+
base_request.id,
|
|
379
|
+
A2AError(root=InternalError(message=str(exc))),
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
try:
|
|
383
|
+
if base_request.method == self._method_list_sessions:
|
|
384
|
+
raw_items = _extract_raw_items(raw_result, kind="sessions")
|
|
385
|
+
else:
|
|
386
|
+
raw_items = _extract_raw_items(raw_result, kind="messages")
|
|
387
|
+
except ValueError as exc:
|
|
388
|
+
logger.warning("Upstream OpenCode payload mismatch: %s", exc)
|
|
389
|
+
return self._generate_error_response(
|
|
390
|
+
base_request.id,
|
|
391
|
+
JSONRPCError(
|
|
392
|
+
code=ERR_UPSTREAM_PAYLOAD_ERROR,
|
|
393
|
+
message="Upstream OpenCode payload mismatch",
|
|
394
|
+
data={"type": "UPSTREAM_PAYLOAD_ERROR", "detail": str(exc)},
|
|
395
|
+
),
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
# Protocol: items are always arrays of A2A objects.
|
|
399
|
+
# Task for sessions; Message for messages.
|
|
400
|
+
if base_request.method == self._method_list_sessions:
|
|
401
|
+
mapped: list[dict[str, Any]] = []
|
|
402
|
+
for item in raw_items:
|
|
403
|
+
task = _as_a2a_session_task(item)
|
|
404
|
+
if task is not None:
|
|
405
|
+
mapped.append(task)
|
|
406
|
+
# OpenCode documents `limit` for message history, not for session list.
|
|
407
|
+
# Enforce the adapter contract locally so the declared pagination stays true.
|
|
408
|
+
items: list[dict[str, Any]] = _apply_session_query_limit(mapped, limit=limit)
|
|
409
|
+
else:
|
|
410
|
+
assert session_id is not None
|
|
411
|
+
mapped = []
|
|
412
|
+
for item in raw_items:
|
|
413
|
+
message = _as_a2a_message(session_id, item)
|
|
414
|
+
if message is not None:
|
|
415
|
+
mapped.append(message)
|
|
416
|
+
items = mapped
|
|
417
|
+
|
|
418
|
+
result = {
|
|
419
|
+
"items": items,
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
# Notifications (id omitted) should not yield a response.
|
|
423
|
+
if base_request.id is None:
|
|
424
|
+
return Response(status_code=204)
|
|
425
|
+
|
|
426
|
+
return self._jsonrpc_success_response(
|
|
427
|
+
base_request.id,
|
|
428
|
+
result,
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
async def _handle_provider_discovery_request(
|
|
432
|
+
self,
|
|
433
|
+
base_request: JSONRPCRequest,
|
|
434
|
+
params: dict[str, Any],
|
|
435
|
+
) -> Response:
|
|
436
|
+
allowed_fields = {"metadata"}
|
|
437
|
+
if base_request.method == self._method_list_models:
|
|
438
|
+
allowed_fields.add("provider_id")
|
|
439
|
+
unknown_fields = sorted(set(params) - allowed_fields)
|
|
440
|
+
if unknown_fields:
|
|
441
|
+
prefixed_fields = [f"params.{field}" for field in unknown_fields]
|
|
442
|
+
return self._generate_error_response(
|
|
443
|
+
base_request.id,
|
|
444
|
+
A2AError(
|
|
445
|
+
root=InvalidParamsError(
|
|
446
|
+
message=f"Unsupported params fields: {', '.join(prefixed_fields)}",
|
|
447
|
+
data={"type": "INVALID_FIELD", "fields": prefixed_fields},
|
|
448
|
+
)
|
|
449
|
+
),
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
provider_id: str | None = None
|
|
453
|
+
if base_request.method == self._method_list_models:
|
|
454
|
+
raw_provider_id = params.get("provider_id")
|
|
455
|
+
if raw_provider_id is not None:
|
|
456
|
+
if not isinstance(raw_provider_id, str) or not raw_provider_id.strip():
|
|
457
|
+
return self._generate_error_response(
|
|
458
|
+
base_request.id,
|
|
459
|
+
A2AError(
|
|
460
|
+
root=InvalidParamsError(
|
|
461
|
+
message="provider_id must be a non-empty string",
|
|
462
|
+
data={"type": "INVALID_FIELD", "field": "provider_id"},
|
|
463
|
+
)
|
|
464
|
+
),
|
|
465
|
+
)
|
|
466
|
+
provider_id = raw_provider_id.strip()
|
|
467
|
+
|
|
468
|
+
directory, metadata_error = self._extract_directory_from_metadata(
|
|
469
|
+
request_id=base_request.id,
|
|
470
|
+
params=params,
|
|
471
|
+
)
|
|
472
|
+
if metadata_error is not None:
|
|
473
|
+
return metadata_error
|
|
474
|
+
|
|
475
|
+
try:
|
|
476
|
+
directory = self._directory_resolver(directory)
|
|
477
|
+
except ValueError as exc:
|
|
478
|
+
return self._generate_error_response(
|
|
479
|
+
base_request.id,
|
|
480
|
+
A2AError(
|
|
481
|
+
root=InvalidParamsError(
|
|
482
|
+
message=str(exc),
|
|
483
|
+
data={"type": "INVALID_FIELD", "field": "metadata.opencode.directory"},
|
|
484
|
+
)
|
|
485
|
+
),
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
try:
|
|
489
|
+
raw_result = await self._upstream_client.list_provider_catalog(directory=directory)
|
|
490
|
+
except httpx.HTTPStatusError as exc:
|
|
491
|
+
upstream_status = exc.response.status_code
|
|
492
|
+
return self._generate_error_response(
|
|
493
|
+
base_request.id,
|
|
494
|
+
JSONRPCError(
|
|
495
|
+
code=ERR_DISCOVERY_UPSTREAM_HTTP_ERROR,
|
|
496
|
+
message="Upstream OpenCode error",
|
|
497
|
+
data={
|
|
498
|
+
"type": "UPSTREAM_HTTP_ERROR",
|
|
499
|
+
"method": base_request.method,
|
|
500
|
+
"upstream_status": upstream_status,
|
|
501
|
+
},
|
|
502
|
+
),
|
|
503
|
+
)
|
|
504
|
+
except httpx.HTTPError:
|
|
505
|
+
return self._generate_error_response(
|
|
506
|
+
base_request.id,
|
|
507
|
+
JSONRPCError(
|
|
508
|
+
code=ERR_DISCOVERY_UPSTREAM_UNREACHABLE,
|
|
509
|
+
message="Upstream OpenCode unreachable",
|
|
510
|
+
data={"type": "UPSTREAM_UNREACHABLE", "method": base_request.method},
|
|
511
|
+
),
|
|
512
|
+
)
|
|
513
|
+
except Exception as exc:
|
|
514
|
+
logger.exception("OpenCode provider discovery JSON-RPC method failed")
|
|
515
|
+
return self._generate_error_response(
|
|
516
|
+
base_request.id,
|
|
517
|
+
A2AError(root=InternalError(message=str(exc))),
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
try:
|
|
521
|
+
raw_providers, default_by_provider, connected = _extract_provider_catalog(raw_result)
|
|
522
|
+
if base_request.method == self._method_list_providers:
|
|
523
|
+
items = _normalize_provider_summaries(
|
|
524
|
+
raw_providers,
|
|
525
|
+
default_by_provider=default_by_provider,
|
|
526
|
+
connected=connected,
|
|
527
|
+
)
|
|
528
|
+
else:
|
|
529
|
+
items = _normalize_model_summaries(
|
|
530
|
+
raw_providers,
|
|
531
|
+
default_by_provider=default_by_provider,
|
|
532
|
+
connected=connected,
|
|
533
|
+
provider_id=provider_id,
|
|
534
|
+
)
|
|
535
|
+
except ValueError as exc:
|
|
536
|
+
logger.warning("Upstream OpenCode provider payload mismatch: %s", exc)
|
|
537
|
+
return self._generate_error_response(
|
|
538
|
+
base_request.id,
|
|
539
|
+
JSONRPCError(
|
|
540
|
+
code=ERR_DISCOVERY_UPSTREAM_PAYLOAD_ERROR,
|
|
541
|
+
message="Upstream OpenCode payload mismatch",
|
|
542
|
+
data={
|
|
543
|
+
"type": "UPSTREAM_PAYLOAD_ERROR",
|
|
544
|
+
"method": base_request.method,
|
|
545
|
+
"detail": str(exc),
|
|
546
|
+
},
|
|
547
|
+
),
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
result = {
|
|
551
|
+
"items": items,
|
|
552
|
+
"default_by_provider": default_by_provider,
|
|
553
|
+
"connected": connected,
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if base_request.id is None:
|
|
557
|
+
return Response(status_code=204)
|
|
558
|
+
|
|
559
|
+
return self._jsonrpc_success_response(base_request.id, result)
|
|
560
|
+
|
|
561
|
+
async def _handle_session_control_request(
|
|
562
|
+
self,
|
|
563
|
+
base_request: JSONRPCRequest,
|
|
564
|
+
params: dict[str, Any],
|
|
565
|
+
*,
|
|
566
|
+
request: Request,
|
|
567
|
+
) -> Response:
|
|
568
|
+
allowed_fields = {"session_id", "request", "metadata"}
|
|
569
|
+
unknown_fields = sorted(set(params) - allowed_fields)
|
|
570
|
+
if unknown_fields:
|
|
571
|
+
return self._generate_error_response(
|
|
572
|
+
base_request.id,
|
|
573
|
+
A2AError(
|
|
574
|
+
root=InvalidParamsError(
|
|
575
|
+
message=f"Unsupported fields: {', '.join(unknown_fields)}",
|
|
576
|
+
data={"type": "INVALID_FIELD", "fields": unknown_fields},
|
|
577
|
+
)
|
|
578
|
+
),
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
session_id = params.get("session_id")
|
|
582
|
+
if not isinstance(session_id, str) or not session_id.strip():
|
|
583
|
+
return self._generate_error_response(
|
|
584
|
+
base_request.id,
|
|
585
|
+
A2AError(
|
|
586
|
+
root=InvalidParamsError(
|
|
587
|
+
message="Missing required params.session_id",
|
|
588
|
+
data={"type": "MISSING_FIELD", "field": "session_id"},
|
|
589
|
+
)
|
|
590
|
+
),
|
|
591
|
+
)
|
|
592
|
+
session_id = session_id.strip()
|
|
593
|
+
|
|
594
|
+
raw_request = params.get("request")
|
|
595
|
+
if raw_request is None:
|
|
596
|
+
return self._generate_error_response(
|
|
597
|
+
base_request.id,
|
|
598
|
+
A2AError(
|
|
599
|
+
root=InvalidParamsError(
|
|
600
|
+
message="Missing required params.request",
|
|
601
|
+
data={"type": "MISSING_FIELD", "field": "request"},
|
|
602
|
+
)
|
|
603
|
+
),
|
|
604
|
+
)
|
|
605
|
+
if not isinstance(raw_request, dict):
|
|
606
|
+
return self._generate_error_response(
|
|
607
|
+
base_request.id,
|
|
608
|
+
A2AError(
|
|
609
|
+
root=InvalidParamsError(
|
|
610
|
+
message="params.request must be an object",
|
|
611
|
+
data={"type": "INVALID_FIELD", "field": "request"},
|
|
612
|
+
)
|
|
613
|
+
),
|
|
614
|
+
)
|
|
615
|
+
|
|
616
|
+
request_identity = getattr(request.state, "user_identity", None)
|
|
617
|
+
identity = request_identity if isinstance(request_identity, str) else None
|
|
618
|
+
task_id = getattr(request.state, "task_id", None)
|
|
619
|
+
context_id = getattr(request.state, "context_id", None)
|
|
620
|
+
|
|
621
|
+
def _log_shell_audit(outcome: str) -> None:
|
|
622
|
+
if base_request.method != self._method_shell:
|
|
623
|
+
return
|
|
624
|
+
logger.info(
|
|
625
|
+
"session_shell_audit method=%s identity=%s task_id=%s context_id=%s "
|
|
626
|
+
"session_id=%s outcome=%s",
|
|
627
|
+
base_request.method,
|
|
628
|
+
identity if identity else "-",
|
|
629
|
+
task_id if isinstance(task_id, str) and task_id.strip() else "-",
|
|
630
|
+
context_id if isinstance(context_id, str) and context_id.strip() else "-",
|
|
631
|
+
session_id,
|
|
632
|
+
outcome,
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
try:
|
|
636
|
+
if base_request.method == self._method_prompt_async:
|
|
637
|
+
_validate_prompt_async_request_payload(raw_request)
|
|
638
|
+
elif base_request.method == self._method_command:
|
|
639
|
+
_validate_command_request_payload(raw_request)
|
|
640
|
+
elif base_request.method == self._method_shell:
|
|
641
|
+
_validate_shell_request_payload(raw_request)
|
|
642
|
+
else:
|
|
643
|
+
raise _PromptAsyncValidationError(
|
|
644
|
+
field="method",
|
|
645
|
+
message=f"Unsupported method: {base_request.method}",
|
|
646
|
+
)
|
|
647
|
+
except _PromptAsyncValidationError as exc:
|
|
648
|
+
return self._generate_error_response(
|
|
649
|
+
base_request.id,
|
|
650
|
+
A2AError(
|
|
651
|
+
root=InvalidParamsError(
|
|
652
|
+
message=str(exc),
|
|
653
|
+
data={"type": "INVALID_FIELD", "field": exc.field},
|
|
654
|
+
)
|
|
655
|
+
),
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
directory, metadata_error = self._extract_directory_from_metadata(
|
|
659
|
+
request_id=base_request.id,
|
|
660
|
+
params=params,
|
|
661
|
+
)
|
|
662
|
+
if metadata_error is not None:
|
|
663
|
+
return metadata_error
|
|
664
|
+
|
|
665
|
+
try:
|
|
666
|
+
directory = self._directory_resolver(directory)
|
|
667
|
+
except ValueError as exc:
|
|
668
|
+
return self._generate_error_response(
|
|
669
|
+
base_request.id,
|
|
670
|
+
A2AError(
|
|
671
|
+
root=InvalidParamsError(
|
|
672
|
+
message=str(exc),
|
|
673
|
+
data={"type": "INVALID_FIELD", "field": "metadata.opencode.directory"},
|
|
674
|
+
)
|
|
675
|
+
),
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
pending_claim = False
|
|
679
|
+
claim_finalized = False
|
|
680
|
+
if identity:
|
|
681
|
+
try:
|
|
682
|
+
pending_claim = await self._session_claim(
|
|
683
|
+
identity=identity,
|
|
684
|
+
session_id=session_id,
|
|
685
|
+
)
|
|
686
|
+
except PermissionError:
|
|
687
|
+
_log_shell_audit("forbidden")
|
|
688
|
+
return self._session_forbidden_response(
|
|
689
|
+
base_request.id,
|
|
690
|
+
session_id=session_id,
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
try:
|
|
694
|
+
result: dict[str, Any]
|
|
695
|
+
if base_request.method == self._method_prompt_async:
|
|
696
|
+
await self._upstream_client.session_prompt_async(
|
|
697
|
+
session_id,
|
|
698
|
+
request=dict(raw_request),
|
|
699
|
+
directory=directory,
|
|
700
|
+
)
|
|
701
|
+
result = {"ok": True, "session_id": session_id}
|
|
702
|
+
elif base_request.method == self._method_command:
|
|
703
|
+
raw_result = await self._upstream_client.session_command(
|
|
704
|
+
session_id,
|
|
705
|
+
request=dict(raw_request),
|
|
706
|
+
directory=directory,
|
|
707
|
+
)
|
|
708
|
+
item = _as_a2a_message(session_id, raw_result)
|
|
709
|
+
if item is None:
|
|
710
|
+
raise UpstreamContractError(
|
|
711
|
+
"OpenCode /session/{sessionID}/command response could not be mapped "
|
|
712
|
+
"to A2A Message"
|
|
713
|
+
)
|
|
714
|
+
result = {"item": item}
|
|
715
|
+
else:
|
|
716
|
+
raw_result = await self._upstream_client.session_shell(
|
|
717
|
+
session_id,
|
|
718
|
+
request=dict(raw_request),
|
|
719
|
+
directory=directory,
|
|
720
|
+
)
|
|
721
|
+
item = _as_a2a_message(session_id, raw_result)
|
|
722
|
+
if item is None:
|
|
723
|
+
raise UpstreamContractError(
|
|
724
|
+
"OpenCode /session/{sessionID}/shell response could not be mapped "
|
|
725
|
+
"to A2A Message"
|
|
726
|
+
)
|
|
727
|
+
result = {"item": item}
|
|
728
|
+
|
|
729
|
+
if pending_claim and identity:
|
|
730
|
+
await self._session_claim_finalize(
|
|
731
|
+
identity=identity,
|
|
732
|
+
session_id=session_id,
|
|
733
|
+
)
|
|
734
|
+
claim_finalized = True
|
|
735
|
+
_log_shell_audit("success")
|
|
736
|
+
except httpx.HTTPStatusError as exc:
|
|
737
|
+
upstream_status = exc.response.status_code
|
|
738
|
+
if upstream_status == 404:
|
|
739
|
+
_log_shell_audit("upstream_404")
|
|
740
|
+
return self._generate_error_response(
|
|
741
|
+
base_request.id,
|
|
742
|
+
JSONRPCError(
|
|
743
|
+
code=ERR_SESSION_NOT_FOUND,
|
|
744
|
+
message="Session not found",
|
|
745
|
+
data={"type": "SESSION_NOT_FOUND", "session_id": session_id},
|
|
746
|
+
),
|
|
747
|
+
)
|
|
748
|
+
_log_shell_audit("upstream_http_error")
|
|
749
|
+
return self._generate_error_response(
|
|
750
|
+
base_request.id,
|
|
751
|
+
JSONRPCError(
|
|
752
|
+
code=ERR_UPSTREAM_HTTP_ERROR,
|
|
753
|
+
message="Upstream OpenCode error",
|
|
754
|
+
data={
|
|
755
|
+
"type": "UPSTREAM_HTTP_ERROR",
|
|
756
|
+
"method": base_request.method,
|
|
757
|
+
"upstream_status": upstream_status,
|
|
758
|
+
"session_id": session_id,
|
|
759
|
+
},
|
|
760
|
+
),
|
|
761
|
+
)
|
|
762
|
+
except httpx.HTTPError:
|
|
763
|
+
_log_shell_audit("upstream_unreachable")
|
|
764
|
+
return self._generate_error_response(
|
|
765
|
+
base_request.id,
|
|
766
|
+
JSONRPCError(
|
|
767
|
+
code=ERR_UPSTREAM_UNREACHABLE,
|
|
768
|
+
message="Upstream OpenCode unreachable",
|
|
769
|
+
data={
|
|
770
|
+
"type": "UPSTREAM_UNREACHABLE",
|
|
771
|
+
"method": base_request.method,
|
|
772
|
+
"session_id": session_id,
|
|
773
|
+
},
|
|
774
|
+
),
|
|
775
|
+
)
|
|
776
|
+
except UpstreamContractError as exc:
|
|
777
|
+
_log_shell_audit("upstream_payload_error")
|
|
778
|
+
return self._generate_error_response(
|
|
779
|
+
base_request.id,
|
|
780
|
+
JSONRPCError(
|
|
781
|
+
code=ERR_UPSTREAM_PAYLOAD_ERROR,
|
|
782
|
+
message="Upstream OpenCode payload mismatch",
|
|
783
|
+
data={
|
|
784
|
+
"type": "UPSTREAM_PAYLOAD_ERROR",
|
|
785
|
+
"method": base_request.method,
|
|
786
|
+
"detail": str(exc),
|
|
787
|
+
"session_id": session_id,
|
|
788
|
+
},
|
|
789
|
+
),
|
|
790
|
+
)
|
|
791
|
+
except PermissionError:
|
|
792
|
+
_log_shell_audit("forbidden")
|
|
793
|
+
return self._session_forbidden_response(
|
|
794
|
+
base_request.id,
|
|
795
|
+
session_id=session_id,
|
|
796
|
+
)
|
|
797
|
+
except Exception as exc:
|
|
798
|
+
_log_shell_audit("internal_error")
|
|
799
|
+
logger.exception("OpenCode session control JSON-RPC method failed")
|
|
800
|
+
return self._generate_error_response(
|
|
801
|
+
base_request.id,
|
|
802
|
+
A2AError(root=InternalError(message=str(exc))),
|
|
803
|
+
)
|
|
804
|
+
finally:
|
|
805
|
+
if pending_claim and not claim_finalized and identity:
|
|
806
|
+
try:
|
|
807
|
+
await self._session_claim_release(
|
|
808
|
+
identity=identity,
|
|
809
|
+
session_id=session_id,
|
|
810
|
+
)
|
|
811
|
+
except Exception:
|
|
812
|
+
logger.exception(
|
|
813
|
+
"Failed to release pending session claim for session_id=%s",
|
|
814
|
+
session_id,
|
|
815
|
+
)
|
|
816
|
+
|
|
817
|
+
if base_request.id is None:
|
|
818
|
+
return Response(status_code=204)
|
|
819
|
+
return self._jsonrpc_success_response(
|
|
820
|
+
base_request.id,
|
|
821
|
+
result,
|
|
822
|
+
)
|
|
823
|
+
|
|
824
|
+
async def _handle_interrupt_callback_request(
|
|
825
|
+
self,
|
|
826
|
+
base_request: JSONRPCRequest,
|
|
827
|
+
params: dict[str, Any],
|
|
828
|
+
*,
|
|
829
|
+
request: Request,
|
|
830
|
+
) -> Response:
|
|
831
|
+
request_id = params.get("request_id")
|
|
832
|
+
if not isinstance(request_id, str) or not request_id.strip():
|
|
833
|
+
return self._generate_error_response(
|
|
834
|
+
base_request.id,
|
|
835
|
+
A2AError(
|
|
836
|
+
root=InvalidParamsError(
|
|
837
|
+
message="Missing required params.request_id",
|
|
838
|
+
data={"type": "MISSING_FIELD", "field": "request_id"},
|
|
839
|
+
)
|
|
840
|
+
),
|
|
841
|
+
)
|
|
842
|
+
request_id = request_id.strip()
|
|
843
|
+
request_identity = getattr(request.state, "user_identity", None)
|
|
844
|
+
directory, metadata_error = self._extract_directory_from_metadata(
|
|
845
|
+
request_id=base_request.id,
|
|
846
|
+
params=params,
|
|
847
|
+
)
|
|
848
|
+
if metadata_error is not None:
|
|
849
|
+
return metadata_error
|
|
850
|
+
expected_interrupt_type = (
|
|
851
|
+
"permission" if base_request.method == self._method_reply_permission else "question"
|
|
852
|
+
)
|
|
853
|
+
resolve_request = getattr(self._upstream_client, "resolve_interrupt_request", None)
|
|
854
|
+
if callable(resolve_request):
|
|
855
|
+
status, binding = resolve_request(request_id)
|
|
856
|
+
if status != "active" or binding is None:
|
|
857
|
+
error_type = (
|
|
858
|
+
"INTERRUPT_REQUEST_EXPIRED"
|
|
859
|
+
if status == "expired"
|
|
860
|
+
else "INTERRUPT_REQUEST_NOT_FOUND"
|
|
861
|
+
)
|
|
862
|
+
return self._generate_error_response(
|
|
863
|
+
base_request.id,
|
|
864
|
+
JSONRPCError(
|
|
865
|
+
code=ERR_INTERRUPT_NOT_FOUND,
|
|
866
|
+
message=(
|
|
867
|
+
"Interrupt request expired"
|
|
868
|
+
if status == "expired"
|
|
869
|
+
else "Interrupt request not found"
|
|
870
|
+
),
|
|
871
|
+
data={"type": error_type, "request_id": request_id},
|
|
872
|
+
),
|
|
873
|
+
)
|
|
874
|
+
if binding.interrupt_type != expected_interrupt_type:
|
|
875
|
+
return self._generate_error_response(
|
|
876
|
+
base_request.id,
|
|
877
|
+
A2AError(
|
|
878
|
+
root=InvalidParamsError(
|
|
879
|
+
message=(
|
|
880
|
+
"Interrupt type mismatch: "
|
|
881
|
+
f"expected {expected_interrupt_type}, got {binding.interrupt_type}"
|
|
882
|
+
),
|
|
883
|
+
data={
|
|
884
|
+
"type": "INTERRUPT_TYPE_MISMATCH",
|
|
885
|
+
"request_id": request_id,
|
|
886
|
+
"expected": expected_interrupt_type,
|
|
887
|
+
"actual": binding.interrupt_type,
|
|
888
|
+
},
|
|
889
|
+
)
|
|
890
|
+
),
|
|
891
|
+
)
|
|
892
|
+
if (
|
|
893
|
+
isinstance(request_identity, str)
|
|
894
|
+
and request_identity
|
|
895
|
+
and binding.identity
|
|
896
|
+
and binding.identity != request_identity
|
|
897
|
+
):
|
|
898
|
+
return self._generate_error_response(
|
|
899
|
+
base_request.id,
|
|
900
|
+
JSONRPCError(
|
|
901
|
+
code=ERR_INTERRUPT_NOT_FOUND,
|
|
902
|
+
message="Interrupt request not found",
|
|
903
|
+
data={
|
|
904
|
+
"type": "INTERRUPT_REQUEST_NOT_FOUND",
|
|
905
|
+
"request_id": request_id,
|
|
906
|
+
},
|
|
907
|
+
),
|
|
908
|
+
)
|
|
909
|
+
else:
|
|
910
|
+
resolve_session = getattr(self._upstream_client, "resolve_interrupt_session", None)
|
|
911
|
+
if callable(resolve_session):
|
|
912
|
+
if not resolve_session(request_id):
|
|
913
|
+
return self._generate_error_response(
|
|
914
|
+
base_request.id,
|
|
915
|
+
JSONRPCError(
|
|
916
|
+
code=ERR_INTERRUPT_NOT_FOUND,
|
|
917
|
+
message="Interrupt request not found",
|
|
918
|
+
data={
|
|
919
|
+
"type": "INTERRUPT_REQUEST_NOT_FOUND",
|
|
920
|
+
"request_id": request_id,
|
|
921
|
+
},
|
|
922
|
+
),
|
|
923
|
+
)
|
|
924
|
+
if base_request.method == self._method_reply_permission:
|
|
925
|
+
allowed_fields = {"request_id", "reply", "message", "metadata"}
|
|
926
|
+
elif base_request.method == self._method_reply_question:
|
|
927
|
+
allowed_fields = {"request_id", "answers", "metadata"}
|
|
928
|
+
else:
|
|
929
|
+
allowed_fields = {"request_id", "metadata"}
|
|
930
|
+
unknown_fields = sorted(set(params) - allowed_fields)
|
|
931
|
+
if unknown_fields:
|
|
932
|
+
return self._generate_error_response(
|
|
933
|
+
base_request.id,
|
|
934
|
+
A2AError(
|
|
935
|
+
root=InvalidParamsError(
|
|
936
|
+
message=f"Unsupported fields: {', '.join(unknown_fields)}",
|
|
937
|
+
data={"type": "INVALID_FIELD", "fields": unknown_fields},
|
|
938
|
+
)
|
|
939
|
+
),
|
|
940
|
+
)
|
|
941
|
+
|
|
942
|
+
try:
|
|
943
|
+
result: dict[str, Any] = {
|
|
944
|
+
"ok": True,
|
|
945
|
+
"request_id": request_id,
|
|
946
|
+
}
|
|
947
|
+
if base_request.method == self._method_reply_permission:
|
|
948
|
+
reply = _normalize_permission_reply(params.get("reply"))
|
|
949
|
+
message = params.get("message")
|
|
950
|
+
if message is not None and not isinstance(message, str):
|
|
951
|
+
raise ValueError("message must be a string")
|
|
952
|
+
await self._upstream_client.permission_reply(
|
|
953
|
+
request_id,
|
|
954
|
+
reply=reply,
|
|
955
|
+
message=message,
|
|
956
|
+
directory=directory,
|
|
957
|
+
)
|
|
958
|
+
elif base_request.method == self._method_reply_question:
|
|
959
|
+
answers = _parse_question_answers(params.get("answers"))
|
|
960
|
+
await self._upstream_client.question_reply(
|
|
961
|
+
request_id,
|
|
962
|
+
answers=answers,
|
|
963
|
+
directory=directory,
|
|
964
|
+
)
|
|
965
|
+
else:
|
|
966
|
+
await self._upstream_client.question_reject(request_id, directory=directory)
|
|
967
|
+
discard_request = getattr(self._upstream_client, "discard_interrupt_request", None)
|
|
968
|
+
if callable(discard_request):
|
|
969
|
+
discard_request(request_id)
|
|
970
|
+
except ValueError as exc:
|
|
971
|
+
return self._generate_error_response(
|
|
972
|
+
base_request.id,
|
|
973
|
+
A2AError(
|
|
974
|
+
root=InvalidParamsError(
|
|
975
|
+
message=str(exc),
|
|
976
|
+
data={"type": "INVALID_FIELD"},
|
|
977
|
+
)
|
|
978
|
+
),
|
|
979
|
+
)
|
|
980
|
+
except httpx.HTTPStatusError as exc:
|
|
981
|
+
upstream_status = exc.response.status_code
|
|
982
|
+
if upstream_status == 404:
|
|
983
|
+
discard_request = getattr(self._upstream_client, "discard_interrupt_request", None)
|
|
984
|
+
if callable(discard_request):
|
|
985
|
+
discard_request(request_id)
|
|
986
|
+
return self._generate_error_response(
|
|
987
|
+
base_request.id,
|
|
988
|
+
JSONRPCError(
|
|
989
|
+
code=ERR_INTERRUPT_NOT_FOUND,
|
|
990
|
+
message="Interrupt request not found",
|
|
991
|
+
data={
|
|
992
|
+
"type": "INTERRUPT_REQUEST_NOT_FOUND",
|
|
993
|
+
"request_id": request_id,
|
|
994
|
+
},
|
|
995
|
+
),
|
|
996
|
+
)
|
|
997
|
+
return self._generate_error_response(
|
|
998
|
+
base_request.id,
|
|
999
|
+
JSONRPCError(
|
|
1000
|
+
code=ERR_UPSTREAM_HTTP_ERROR,
|
|
1001
|
+
message="Upstream OpenCode error",
|
|
1002
|
+
data={
|
|
1003
|
+
"type": "UPSTREAM_HTTP_ERROR",
|
|
1004
|
+
"upstream_status": upstream_status,
|
|
1005
|
+
"request_id": request_id,
|
|
1006
|
+
},
|
|
1007
|
+
),
|
|
1008
|
+
)
|
|
1009
|
+
except httpx.HTTPError:
|
|
1010
|
+
return self._generate_error_response(
|
|
1011
|
+
base_request.id,
|
|
1012
|
+
JSONRPCError(
|
|
1013
|
+
code=ERR_UPSTREAM_UNREACHABLE,
|
|
1014
|
+
message="Upstream OpenCode unreachable",
|
|
1015
|
+
data={"type": "UPSTREAM_UNREACHABLE", "request_id": request_id},
|
|
1016
|
+
),
|
|
1017
|
+
)
|
|
1018
|
+
except Exception as exc:
|
|
1019
|
+
logger.exception("OpenCode interrupt callback JSON-RPC method failed")
|
|
1020
|
+
return self._generate_error_response(
|
|
1021
|
+
base_request.id,
|
|
1022
|
+
A2AError(root=InternalError(message=str(exc))),
|
|
1023
|
+
)
|
|
1024
|
+
|
|
1025
|
+
if base_request.id is None:
|
|
1026
|
+
return Response(status_code=204)
|
|
1027
|
+
return self._jsonrpc_success_response(base_request.id, result)
|
|
1028
|
+
|
|
1029
|
+
def _jsonrpc_success_response(self, request_id: str | int, result: Any) -> JSONResponse:
|
|
1030
|
+
return JSONResponse(
|
|
1031
|
+
{
|
|
1032
|
+
"jsonrpc": "2.0",
|
|
1033
|
+
"id": request_id,
|
|
1034
|
+
"result": result,
|
|
1035
|
+
}
|
|
1036
|
+
)
|