agentkit-sdk-python 0.7.2__py3-none-any.whl → 0.7.3__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.
- agentkit/auth/model_login.py +87 -105
- agentkit/toolkit/cli/sandbox/a2a_client.py +407 -0
- agentkit/toolkit/cli/sandbox/agentkit_client.py +248 -0
- agentkit/toolkit/cli/sandbox/cli.py +5 -0
- agentkit/toolkit/cli/sandbox/cli_create.py +219 -165
- agentkit/toolkit/cli/sandbox/cli_exec.py +2 -70
- agentkit/toolkit/cli/sandbox/cli_file.py +1 -1
- agentkit/toolkit/cli/sandbox/cli_get.py +2 -6
- agentkit/toolkit/cli/sandbox/cli_invoke.py +375 -0
- agentkit/toolkit/cli/sandbox/cli_model_login.py +57 -70
- agentkit/toolkit/cli/sandbox/cli_mount.py +1 -1
- agentkit/toolkit/cli/sandbox/env_config.py +798 -0
- agentkit/toolkit/cli/sandbox/model_config.py +90 -29
- agentkit/toolkit/cli/sandbox/session_create.py +94 -168
- agentkit/toolkit/cli/sandbox/session_sync.py +1 -1
- agentkit/toolkit/cli/sandbox/tool_resolve.py +37 -73
- agentkit/version.py +1 -1
- {agentkit_sdk_python-0.7.2.dist-info → agentkit_sdk_python-0.7.3.dist-info}/METADATA +1 -1
- {agentkit_sdk_python-0.7.2.dist-info → agentkit_sdk_python-0.7.3.dist-info}/RECORD +23 -19
- {agentkit_sdk_python-0.7.2.dist-info → agentkit_sdk_python-0.7.3.dist-info}/WHEEL +0 -0
- {agentkit_sdk_python-0.7.2.dist-info → agentkit_sdk_python-0.7.3.dist-info}/entry_points.txt +0 -0
- {agentkit_sdk_python-0.7.2.dist-info → agentkit_sdk_python-0.7.3.dist-info}/licenses/LICENSE +0 -0
- {agentkit_sdk_python-0.7.2.dist-info → agentkit_sdk_python-0.7.3.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
|
|
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
|
+
"""A2A JSON-RPC helpers for sandbox CLI commands."""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
import json
|
|
21
|
+
import sys
|
|
22
|
+
import time
|
|
23
|
+
from typing import Any
|
|
24
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
25
|
+
import uuid
|
|
26
|
+
|
|
27
|
+
import requests
|
|
28
|
+
|
|
29
|
+
DEFAULT_A2A_PATH = "/a2a"
|
|
30
|
+
DEFAULT_A2A_TIMEOUT_SECONDS = 1200
|
|
31
|
+
DEFAULT_A2A_HISTORY_LENGTH = 20
|
|
32
|
+
DEFAULT_A2A_POLL_INTERVAL_SECONDS = 2.0
|
|
33
|
+
DEFAULT_READY_RETRIES = 12
|
|
34
|
+
DEFAULT_READY_RETRY_DELAY = 5.0
|
|
35
|
+
RETRYABLE_A2A_STATUS_CODES = {502, 503, 504}
|
|
36
|
+
TERMINAL_STATES = {
|
|
37
|
+
"completed",
|
|
38
|
+
"failed",
|
|
39
|
+
"canceled",
|
|
40
|
+
"rejected",
|
|
41
|
+
"input-required",
|
|
42
|
+
"auth-required",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class A2AApiError(RuntimeError):
|
|
47
|
+
"""Raised when a sandbox A2A request fails."""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
operation: str,
|
|
52
|
+
message: str,
|
|
53
|
+
*,
|
|
54
|
+
status_code: int | None = None,
|
|
55
|
+
response_text: str | None = None,
|
|
56
|
+
response_json: Any = None,
|
|
57
|
+
) -> None:
|
|
58
|
+
super().__init__(message)
|
|
59
|
+
self.operation = operation
|
|
60
|
+
self.status_code = status_code
|
|
61
|
+
self.response_text = response_text
|
|
62
|
+
self.response_json = response_json
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class A2ATaskStart:
|
|
67
|
+
task: dict[str, Any]
|
|
68
|
+
task_id: str
|
|
69
|
+
context_id: str | None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def send_message_nonblocking(
|
|
73
|
+
*,
|
|
74
|
+
endpoint: object,
|
|
75
|
+
prompt: str,
|
|
76
|
+
a2a_path: str = DEFAULT_A2A_PATH,
|
|
77
|
+
context_id: str | None = None,
|
|
78
|
+
request_metadata: dict[str, str] | None = None,
|
|
79
|
+
history_length: int | None = DEFAULT_A2A_HISTORY_LENGTH,
|
|
80
|
+
timeout: int = 60,
|
|
81
|
+
readiness_retries: int = DEFAULT_READY_RETRIES,
|
|
82
|
+
readiness_retry_delay: float = DEFAULT_READY_RETRY_DELAY,
|
|
83
|
+
) -> A2ATaskStart:
|
|
84
|
+
message: dict[str, Any] = {
|
|
85
|
+
"kind": "message",
|
|
86
|
+
"messageId": str(uuid.uuid4()),
|
|
87
|
+
"role": "user",
|
|
88
|
+
"parts": [{"kind": "text", "text": prompt}],
|
|
89
|
+
}
|
|
90
|
+
if context_id:
|
|
91
|
+
message["contextId"] = context_id
|
|
92
|
+
|
|
93
|
+
configuration: dict[str, Any] = {"blocking": False}
|
|
94
|
+
if history_length is not None:
|
|
95
|
+
configuration["historyLength"] = history_length
|
|
96
|
+
|
|
97
|
+
params: dict[str, Any] = {
|
|
98
|
+
"message": message,
|
|
99
|
+
"configuration": configuration,
|
|
100
|
+
}
|
|
101
|
+
if request_metadata:
|
|
102
|
+
params["metadata"] = request_metadata
|
|
103
|
+
|
|
104
|
+
response = _post_jsonrpc(
|
|
105
|
+
endpoint=endpoint,
|
|
106
|
+
a2a_path=a2a_path,
|
|
107
|
+
payload={
|
|
108
|
+
"jsonrpc": "2.0",
|
|
109
|
+
"id": str(uuid.uuid4()),
|
|
110
|
+
"method": "message/send",
|
|
111
|
+
"params": params,
|
|
112
|
+
},
|
|
113
|
+
timeout=timeout,
|
|
114
|
+
operation="A2ASendMessage",
|
|
115
|
+
readiness_retries=readiness_retries,
|
|
116
|
+
readiness_retry_delay=readiness_retry_delay,
|
|
117
|
+
)
|
|
118
|
+
task = _jsonrpc_result_task("A2ASendMessage", response)
|
|
119
|
+
task_id = _task_id(task)
|
|
120
|
+
if not task_id:
|
|
121
|
+
raise A2AApiError(
|
|
122
|
+
"A2ASendMessage",
|
|
123
|
+
"response task does not contain id",
|
|
124
|
+
response_json=response,
|
|
125
|
+
)
|
|
126
|
+
return A2ATaskStart(
|
|
127
|
+
task=task,
|
|
128
|
+
task_id=task_id,
|
|
129
|
+
context_id=task_context_id(task),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def get_task(
|
|
134
|
+
*,
|
|
135
|
+
endpoint: object,
|
|
136
|
+
task_id: str,
|
|
137
|
+
a2a_path: str = DEFAULT_A2A_PATH,
|
|
138
|
+
history_length: int | None = DEFAULT_A2A_HISTORY_LENGTH,
|
|
139
|
+
timeout: int = 60,
|
|
140
|
+
readiness_retries: int = DEFAULT_READY_RETRIES,
|
|
141
|
+
readiness_retry_delay: float = DEFAULT_READY_RETRY_DELAY,
|
|
142
|
+
) -> dict[str, Any]:
|
|
143
|
+
params: dict[str, Any] = {"id": task_id}
|
|
144
|
+
if history_length is not None:
|
|
145
|
+
params["historyLength"] = history_length
|
|
146
|
+
|
|
147
|
+
response = _post_jsonrpc(
|
|
148
|
+
endpoint=endpoint,
|
|
149
|
+
a2a_path=a2a_path,
|
|
150
|
+
payload={
|
|
151
|
+
"jsonrpc": "2.0",
|
|
152
|
+
"id": str(uuid.uuid4()),
|
|
153
|
+
"method": "tasks/get",
|
|
154
|
+
"params": params,
|
|
155
|
+
},
|
|
156
|
+
timeout=timeout,
|
|
157
|
+
operation="A2AGetTask",
|
|
158
|
+
readiness_retries=readiness_retries,
|
|
159
|
+
readiness_retry_delay=readiness_retry_delay,
|
|
160
|
+
)
|
|
161
|
+
return _jsonrpc_result_task("A2AGetTask", response)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def poll_task_until_terminal(
|
|
165
|
+
*,
|
|
166
|
+
endpoint: object,
|
|
167
|
+
task_id: str,
|
|
168
|
+
a2a_path: str = DEFAULT_A2A_PATH,
|
|
169
|
+
history_length: int | None = DEFAULT_A2A_HISTORY_LENGTH,
|
|
170
|
+
timeout: int = DEFAULT_A2A_TIMEOUT_SECONDS,
|
|
171
|
+
interval: float = DEFAULT_A2A_POLL_INTERVAL_SECONDS,
|
|
172
|
+
print_events: bool = False,
|
|
173
|
+
) -> dict[str, Any]:
|
|
174
|
+
deadline = time.monotonic() + timeout
|
|
175
|
+
latest_task = get_task(
|
|
176
|
+
endpoint=endpoint,
|
|
177
|
+
task_id=task_id,
|
|
178
|
+
a2a_path=a2a_path,
|
|
179
|
+
history_length=history_length,
|
|
180
|
+
timeout=min(60, timeout),
|
|
181
|
+
)
|
|
182
|
+
while task_state(latest_task) not in TERMINAL_STATES:
|
|
183
|
+
if print_events:
|
|
184
|
+
print(json.dumps(latest_task, ensure_ascii=False), file=sys.stderr)
|
|
185
|
+
if time.monotonic() >= deadline:
|
|
186
|
+
raise TimeoutError(f"Timed out while waiting for A2A task {task_id}")
|
|
187
|
+
time.sleep(interval)
|
|
188
|
+
latest_task = get_task(
|
|
189
|
+
endpoint=endpoint,
|
|
190
|
+
task_id=task_id,
|
|
191
|
+
a2a_path=a2a_path,
|
|
192
|
+
history_length=history_length,
|
|
193
|
+
timeout=min(60, timeout),
|
|
194
|
+
)
|
|
195
|
+
if print_events:
|
|
196
|
+
print(json.dumps(latest_task, ensure_ascii=False), file=sys.stderr)
|
|
197
|
+
return latest_task
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def task_result_parts(task: dict[str, Any] | None) -> list[dict[str, str]]:
|
|
201
|
+
if not task:
|
|
202
|
+
return []
|
|
203
|
+
for source in (
|
|
204
|
+
_artifact_parts(task),
|
|
205
|
+
_message_parts(_get_nested(task, ("status", "message"))),
|
|
206
|
+
_history_agent_parts(task),
|
|
207
|
+
):
|
|
208
|
+
parts = _text_parts(source)
|
|
209
|
+
if parts:
|
|
210
|
+
return parts
|
|
211
|
+
return []
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def task_result_text(task: dict[str, Any] | None) -> str:
|
|
215
|
+
return "\n".join(part["text"] for part in task_result_parts(task))
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def task_state(task: dict[str, Any] | None) -> str | None:
|
|
219
|
+
value = _get_nested(task, ("status", "state"))
|
|
220
|
+
return value if isinstance(value, str) else None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def task_context_id(task: dict[str, Any] | None) -> str | None:
|
|
224
|
+
if not isinstance(task, dict):
|
|
225
|
+
return None
|
|
226
|
+
value = task.get("contextId") or task.get("context_id")
|
|
227
|
+
return value if isinstance(value, str) and value else None
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def build_a2a_url(endpoint: object, a2a_path: str = DEFAULT_A2A_PATH) -> str:
|
|
231
|
+
if not isinstance(endpoint, str) or not endpoint.strip():
|
|
232
|
+
raise A2AApiError("A2ARequest", "Sandbox session endpoint is missing")
|
|
233
|
+
|
|
234
|
+
parts = urlsplit(endpoint.strip())
|
|
235
|
+
base_path = parts.path.rstrip("/")
|
|
236
|
+
if not a2a_path or a2a_path == "/":
|
|
237
|
+
resolved_path = f"{base_path}/" if base_path else "/"
|
|
238
|
+
else:
|
|
239
|
+
suffix = "/" + a2a_path.strip("/")
|
|
240
|
+
resolved_path = f"{base_path}{suffix}" if base_path else suffix
|
|
241
|
+
return urlunsplit(
|
|
242
|
+
(parts.scheme, parts.netloc, resolved_path, parts.query, parts.fragment)
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _post_jsonrpc(
|
|
247
|
+
*,
|
|
248
|
+
endpoint: object,
|
|
249
|
+
a2a_path: str,
|
|
250
|
+
payload: dict[str, Any],
|
|
251
|
+
timeout: int,
|
|
252
|
+
operation: str,
|
|
253
|
+
readiness_retries: int,
|
|
254
|
+
readiness_retry_delay: float,
|
|
255
|
+
) -> dict[str, Any]:
|
|
256
|
+
url = build_a2a_url(endpoint, a2a_path)
|
|
257
|
+
response: requests.Response | None = None
|
|
258
|
+
for attempt in range(readiness_retries + 1):
|
|
259
|
+
try:
|
|
260
|
+
response = requests.post(url, json=payload, timeout=timeout)
|
|
261
|
+
except requests.RequestException as exc:
|
|
262
|
+
if attempt >= readiness_retries:
|
|
263
|
+
raise A2AApiError(operation, str(exc)) from exc
|
|
264
|
+
time.sleep(readiness_retry_delay)
|
|
265
|
+
continue
|
|
266
|
+
|
|
267
|
+
if not _is_retryable_a2a_response(response) or attempt >= readiness_retries:
|
|
268
|
+
break
|
|
269
|
+
time.sleep(readiness_retry_delay)
|
|
270
|
+
|
|
271
|
+
if response is None:
|
|
272
|
+
raise A2AApiError(operation, "request was not sent")
|
|
273
|
+
|
|
274
|
+
body = response.text
|
|
275
|
+
if response.status_code < 200 or response.status_code >= 300:
|
|
276
|
+
raise A2AApiError(
|
|
277
|
+
operation,
|
|
278
|
+
_failure_hint(response.status_code, body),
|
|
279
|
+
status_code=response.status_code,
|
|
280
|
+
response_text=body,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
try:
|
|
284
|
+
parsed = response.json()
|
|
285
|
+
except ValueError as exc:
|
|
286
|
+
raise A2AApiError(
|
|
287
|
+
operation,
|
|
288
|
+
"response is not valid JSON",
|
|
289
|
+
status_code=response.status_code,
|
|
290
|
+
response_text=body,
|
|
291
|
+
) from exc
|
|
292
|
+
if not isinstance(parsed, dict):
|
|
293
|
+
raise A2AApiError(
|
|
294
|
+
operation,
|
|
295
|
+
"response JSON is not an object",
|
|
296
|
+
status_code=response.status_code,
|
|
297
|
+
response_json={"response": parsed},
|
|
298
|
+
)
|
|
299
|
+
if parsed.get("error") is not None:
|
|
300
|
+
raise A2AApiError(
|
|
301
|
+
operation,
|
|
302
|
+
"A2A JSON-RPC returned error",
|
|
303
|
+
status_code=response.status_code,
|
|
304
|
+
response_json=parsed,
|
|
305
|
+
)
|
|
306
|
+
return parsed
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _is_retryable_a2a_response(response: requests.Response) -> bool:
|
|
310
|
+
if response.status_code in RETRYABLE_A2A_STATUS_CODES:
|
|
311
|
+
return True
|
|
312
|
+
if response.status_code != 500:
|
|
313
|
+
return False
|
|
314
|
+
|
|
315
|
+
body = response.text.lower()
|
|
316
|
+
return "function_proxy_error" in body and "connection refused" in body
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _jsonrpc_result_task(operation: str, response: dict[str, Any]) -> dict[str, Any]:
|
|
320
|
+
result = response.get("result")
|
|
321
|
+
if not isinstance(result, dict):
|
|
322
|
+
raise A2AApiError(
|
|
323
|
+
operation,
|
|
324
|
+
"response does not contain result task",
|
|
325
|
+
response_json=response,
|
|
326
|
+
)
|
|
327
|
+
if result.get("kind") != "task" and "status" not in result:
|
|
328
|
+
raise A2AApiError(
|
|
329
|
+
operation,
|
|
330
|
+
"response result is not an A2A task",
|
|
331
|
+
response_json=response,
|
|
332
|
+
)
|
|
333
|
+
return result
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _task_id(task: dict[str, Any] | None) -> str | None:
|
|
337
|
+
if not isinstance(task, dict):
|
|
338
|
+
return None
|
|
339
|
+
value = task.get("id")
|
|
340
|
+
return value if isinstance(value, str) and value else None
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _artifact_parts(task: dict[str, Any]) -> list[Any]:
|
|
344
|
+
artifacts = task.get("artifacts")
|
|
345
|
+
if not isinstance(artifacts, list):
|
|
346
|
+
return []
|
|
347
|
+
parts: list[Any] = []
|
|
348
|
+
for artifact in artifacts:
|
|
349
|
+
if not isinstance(artifact, dict):
|
|
350
|
+
continue
|
|
351
|
+
artifact_parts = artifact.get("parts")
|
|
352
|
+
if isinstance(artifact_parts, list):
|
|
353
|
+
parts.extend(artifact_parts)
|
|
354
|
+
return parts
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _message_parts(message: Any) -> list[Any]:
|
|
358
|
+
if not isinstance(message, dict):
|
|
359
|
+
return []
|
|
360
|
+
parts = message.get("parts")
|
|
361
|
+
return parts if isinstance(parts, list) else []
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _history_agent_parts(task: dict[str, Any]) -> list[Any]:
|
|
365
|
+
history = task.get("history")
|
|
366
|
+
if not isinstance(history, list):
|
|
367
|
+
return []
|
|
368
|
+
parts: list[Any] = []
|
|
369
|
+
for message in reversed(history):
|
|
370
|
+
if not isinstance(message, dict) or message.get("role") != "agent":
|
|
371
|
+
continue
|
|
372
|
+
message_parts = message.get("parts")
|
|
373
|
+
if isinstance(message_parts, list):
|
|
374
|
+
text_parts = _text_parts(message_parts)
|
|
375
|
+
if text_parts:
|
|
376
|
+
parts.extend(message_parts)
|
|
377
|
+
break
|
|
378
|
+
return parts
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _text_parts(parts: list[Any]) -> list[dict[str, str]]:
|
|
382
|
+
return [
|
|
383
|
+
{"text": part["text"]}
|
|
384
|
+
for part in parts
|
|
385
|
+
if isinstance(part, dict)
|
|
386
|
+
and part.get("kind") == "text"
|
|
387
|
+
and isinstance(part.get("text"), str)
|
|
388
|
+
and part["text"]
|
|
389
|
+
]
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _get_nested(value: Any, path: tuple[str, ...]) -> Any:
|
|
393
|
+
current = value
|
|
394
|
+
for key in path:
|
|
395
|
+
if not isinstance(current, dict):
|
|
396
|
+
return None
|
|
397
|
+
current = current.get(key)
|
|
398
|
+
return current
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _failure_hint(status_code: int, body: str) -> str:
|
|
402
|
+
lower_body = body.lower()
|
|
403
|
+
if status_code in (404, 410) or "expired" in lower_body or "deleted" in lower_body:
|
|
404
|
+
return "Sandbox session or A2A task may have expired or been deleted"
|
|
405
|
+
if status_code in (401, 403):
|
|
406
|
+
return "Sandbox access credentials may have expired or become invalid"
|
|
407
|
+
return "A2A request returned non-2xx status"
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# Copyright (c) 2026 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
|
|
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
|
+
"""Sandbox-specific AgentKit tools client helpers."""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
from typing import Any, Type, TypeVar
|
|
22
|
+
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
23
|
+
|
|
24
|
+
import requests
|
|
25
|
+
|
|
26
|
+
from agentkit.platform.constants import SERVICE_METADATA
|
|
27
|
+
from agentkit.sdk.tools.client import AgentkitToolsClient as _OpenapiAgentkitToolsClient
|
|
28
|
+
from agentkit.sdk.tools.types import (
|
|
29
|
+
CreateSessionRequest,
|
|
30
|
+
CreateSessionResponse,
|
|
31
|
+
GetSessionRequest,
|
|
32
|
+
GetSessionResponse,
|
|
33
|
+
ListSessionsRequest,
|
|
34
|
+
ListSessionsResponse,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
SANDBOX_APIG_ENDPOINT_ENV = "SANDBOX_APIG_ENDPOINT"
|
|
38
|
+
TIP_TOKEN_ENV = "TIP_TOKEN"
|
|
39
|
+
_AGENTKIT_API_VERSION = SERVICE_METADATA["agentkit"].default_version
|
|
40
|
+
|
|
41
|
+
T = TypeVar("T")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _env_value(name: str) -> str:
|
|
45
|
+
return (os.getenv(name) or "").strip()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def tip_auth_env_enabled() -> bool:
|
|
49
|
+
return bool(_env_value(SANDBOX_APIG_ENDPOINT_ENV) and _env_value(TIP_TOKEN_ENV))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _with_action_query(endpoint: str, action: str) -> str:
|
|
53
|
+
split = urlsplit(endpoint)
|
|
54
|
+
query = dict(parse_qsl(split.query, keep_blank_values=True))
|
|
55
|
+
query.setdefault("Action", action)
|
|
56
|
+
query.setdefault("Version", _AGENTKIT_API_VERSION)
|
|
57
|
+
return urlunsplit(
|
|
58
|
+
(
|
|
59
|
+
split.scheme,
|
|
60
|
+
split.netloc,
|
|
61
|
+
split.path,
|
|
62
|
+
urlencode(query),
|
|
63
|
+
split.fragment,
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _tip_endpoint_url(endpoint: str, action: str) -> str:
|
|
69
|
+
if "{Action}" in endpoint or "{action}" in endpoint:
|
|
70
|
+
return endpoint.replace("{Action}", action).replace("{action}", action)
|
|
71
|
+
return _with_action_query(endpoint, action)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _extract_error_message(payload: object, default: str) -> str:
|
|
75
|
+
if not isinstance(payload, dict):
|
|
76
|
+
return default
|
|
77
|
+
|
|
78
|
+
metadata = payload.get("ResponseMetadata")
|
|
79
|
+
if isinstance(metadata, dict):
|
|
80
|
+
api_error = metadata.get("Error")
|
|
81
|
+
if isinstance(api_error, dict):
|
|
82
|
+
message = api_error.get("Message")
|
|
83
|
+
if isinstance(message, str) and message:
|
|
84
|
+
return message
|
|
85
|
+
|
|
86
|
+
for key in ("message", "Message", "error", "Error"):
|
|
87
|
+
value = payload.get(key)
|
|
88
|
+
if isinstance(value, str) and value:
|
|
89
|
+
return value
|
|
90
|
+
if isinstance(value, dict):
|
|
91
|
+
message = value.get("message") or value.get("Message")
|
|
92
|
+
if isinstance(message, str) and message:
|
|
93
|
+
return message
|
|
94
|
+
return default
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _tip_result_payload(payload: object) -> object:
|
|
98
|
+
if not isinstance(payload, dict):
|
|
99
|
+
return payload
|
|
100
|
+
if "Result" in payload:
|
|
101
|
+
return payload.get("Result") or {}
|
|
102
|
+
data = payload.get("data")
|
|
103
|
+
if isinstance(data, dict):
|
|
104
|
+
return data
|
|
105
|
+
return payload
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class TipAgentkitToolsClient(_OpenapiAgentkitToolsClient):
|
|
109
|
+
"""AgentKit tools client with optional TIP bearer-token session routing.
|
|
110
|
+
|
|
111
|
+
When both SANDBOX_APIG_ENDPOINT and TIP_TOKEN are present, session APIs are
|
|
112
|
+
sent directly to the APIG endpoint. Otherwise this behaves exactly like the
|
|
113
|
+
generated AgentkitToolsClient.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
def __init__(
|
|
117
|
+
self,
|
|
118
|
+
access_key: str = "",
|
|
119
|
+
secret_key: str = "",
|
|
120
|
+
region: str = "",
|
|
121
|
+
session_token: str = "",
|
|
122
|
+
timeout: int = 30,
|
|
123
|
+
) -> None:
|
|
124
|
+
self._tip_endpoint = _env_value(SANDBOX_APIG_ENDPOINT_ENV).rstrip("/")
|
|
125
|
+
self._tip_token = _env_value(TIP_TOKEN_ENV)
|
|
126
|
+
self._tip_timeout = timeout
|
|
127
|
+
self._tip_session: requests.Session | None = None
|
|
128
|
+
if self.uses_tip_auth:
|
|
129
|
+
self._tip_session = requests.Session()
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
super().__init__(
|
|
133
|
+
access_key=access_key,
|
|
134
|
+
secret_key=secret_key,
|
|
135
|
+
region=region,
|
|
136
|
+
session_token=session_token,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def uses_tip_auth(self) -> bool:
|
|
141
|
+
return bool(self._tip_endpoint and self._tip_token)
|
|
142
|
+
|
|
143
|
+
def _invoke_tip_api(
|
|
144
|
+
self,
|
|
145
|
+
api_action: str,
|
|
146
|
+
request: Any,
|
|
147
|
+
response_type: Type[T],
|
|
148
|
+
) -> T:
|
|
149
|
+
if not self.uses_tip_auth or self._tip_session is None:
|
|
150
|
+
return self._invoke_api(
|
|
151
|
+
api_action=api_action,
|
|
152
|
+
request=request,
|
|
153
|
+
response_type=response_type,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
url = _tip_endpoint_url(self._tip_endpoint, api_action)
|
|
157
|
+
body = request.model_dump(by_alias=True, exclude_none=True)
|
|
158
|
+
try:
|
|
159
|
+
response = self._tip_session.post(
|
|
160
|
+
url,
|
|
161
|
+
json=body,
|
|
162
|
+
headers={
|
|
163
|
+
"Accept": "application/json",
|
|
164
|
+
"Content-Type": "application/json",
|
|
165
|
+
"Authorization": f"Bearer {self._tip_token}",
|
|
166
|
+
},
|
|
167
|
+
timeout=self._tip_timeout,
|
|
168
|
+
)
|
|
169
|
+
except requests.RequestException as exc:
|
|
170
|
+
raise Exception(f"Failed to {api_action}: {exc}") from exc
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
payload = response.json()
|
|
174
|
+
except ValueError as exc:
|
|
175
|
+
raise Exception(
|
|
176
|
+
f"Failed to {api_action}: invalid JSON response: {response.text}"
|
|
177
|
+
) from exc
|
|
178
|
+
|
|
179
|
+
if response.status_code >= 400:
|
|
180
|
+
raise Exception(
|
|
181
|
+
f"Failed to {api_action}: "
|
|
182
|
+
f"{_extract_error_message(payload, response.text)}"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
if isinstance(payload, dict):
|
|
186
|
+
metadata = payload.get("ResponseMetadata")
|
|
187
|
+
if isinstance(metadata, dict) and metadata.get("Error"):
|
|
188
|
+
raise Exception(
|
|
189
|
+
f"Failed to {api_action}: "
|
|
190
|
+
f"{_extract_error_message(payload, json.dumps(payload))}"
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
result = _tip_result_payload(payload)
|
|
194
|
+
if not isinstance(result, dict):
|
|
195
|
+
raise Exception(f"Failed to {api_action}: invalid response payload")
|
|
196
|
+
return response_type(**result)
|
|
197
|
+
|
|
198
|
+
def create_session(self, request: CreateSessionRequest) -> CreateSessionResponse:
|
|
199
|
+
return self._invoke_tip_api(
|
|
200
|
+
api_action="CreateSession",
|
|
201
|
+
request=request,
|
|
202
|
+
response_type=CreateSessionResponse,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
def get_session(self, request: GetSessionRequest) -> GetSessionResponse:
|
|
206
|
+
return self._invoke_tip_api(
|
|
207
|
+
api_action="GetSession",
|
|
208
|
+
request=request,
|
|
209
|
+
response_type=GetSessionResponse,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
def list_sessions(self, request: ListSessionsRequest) -> ListSessionsResponse:
|
|
213
|
+
return self._invoke_tip_api(
|
|
214
|
+
api_action="ListSessions",
|
|
215
|
+
request=request,
|
|
216
|
+
response_type=ListSessionsResponse,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
def _raise_tip_unsupported(self, api_action: str) -> None:
|
|
220
|
+
raise Exception(
|
|
221
|
+
f"{api_action} is not available with TIP sandbox auth. "
|
|
222
|
+
f"Unset {SANDBOX_APIG_ENDPOINT_ENV}/{TIP_TOKEN_ENV} to use the "
|
|
223
|
+
"standard AgentKit OpenAPI client."
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
def create_tool(self, request: Any) -> Any:
|
|
227
|
+
if self.uses_tip_auth:
|
|
228
|
+
self._raise_tip_unsupported("CreateTool")
|
|
229
|
+
return super().create_tool(request)
|
|
230
|
+
|
|
231
|
+
def get_tool(self, request: Any) -> Any:
|
|
232
|
+
if self.uses_tip_auth:
|
|
233
|
+
self._raise_tip_unsupported("GetTool")
|
|
234
|
+
return super().get_tool(request)
|
|
235
|
+
|
|
236
|
+
def list_tools(self, request: Any) -> Any:
|
|
237
|
+
if self.uses_tip_auth:
|
|
238
|
+
self._raise_tip_unsupported("ListTools")
|
|
239
|
+
return super().list_tools(request)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def is_tip_agentkit_client(client: object) -> bool:
|
|
243
|
+
return bool(getattr(client, "uses_tip_auth", False))
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# Keep sandbox modules/tests able to patch a local AgentkitToolsClient symbol,
|
|
247
|
+
# while routing construction through the sandbox-aware implementation.
|
|
248
|
+
AgentkitToolsClient = TipAgentkitToolsClient
|
|
@@ -22,6 +22,7 @@ from agentkit.toolkit.cli.sandbox.cli_create import create_command
|
|
|
22
22
|
from agentkit.toolkit.cli.sandbox.cli_exec import exec_command
|
|
23
23
|
from agentkit.toolkit.cli.sandbox.cli_file import file_command
|
|
24
24
|
from agentkit.toolkit.cli.sandbox.cli_get import get_command
|
|
25
|
+
from agentkit.toolkit.cli.sandbox.cli_invoke import invoke_command
|
|
25
26
|
from agentkit.toolkit.cli.sandbox.cli_model_login import codex_login_command
|
|
26
27
|
from agentkit.toolkit.cli.sandbox.cli_mount import mount_command
|
|
27
28
|
from agentkit.toolkit.cli.sandbox.cli_run import run_command
|
|
@@ -44,6 +45,10 @@ sandbox_app.command(
|
|
|
44
45
|
name="exec",
|
|
45
46
|
context_settings={"allow_extra_args": True},
|
|
46
47
|
)(exec_command)
|
|
48
|
+
sandbox_app.command(
|
|
49
|
+
name="invoke",
|
|
50
|
+
context_settings={"allow_extra_args": True},
|
|
51
|
+
)(invoke_command)
|
|
47
52
|
sandbox_app.command(name="run")(run_command)
|
|
48
53
|
sandbox_app.command(
|
|
49
54
|
name="shell",
|