adk-code-mode 0.1.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.
- adk_code_mode/__about__.py +4 -0
- adk_code_mode/__init__.py +22 -0
- adk_code_mode/_artifact_tools.py +152 -0
- adk_code_mode/callback.py +81 -0
- adk_code_mode/executor.py +607 -0
- adk_code_mode/output.py +97 -0
- adk_code_mode/py.typed +1 -0
- adk_code_mode/runtime/__init__.py +9 -0
- adk_code_mode/runtime/base.py +88 -0
- adk_code_mode/runtime/docker.py +331 -0
- adk_code_mode/runtime/protocol.py +193 -0
- adk_code_mode/tools/__init__.py +4 -0
- adk_code_mode/tools/catalog.py +95 -0
- adk_code_mode/tools/dispatcher.py +290 -0
- adk_code_mode/tools/namespacing.py +200 -0
- adk_code_mode/tools/normaliser.py +69 -0
- adk_code_mode/tools/stubs.py +446 -0
- adk_code_mode/workspace/__init__.py +8 -0
- adk_code_mode/workspace/files.py +44 -0
- adk_code_mode-0.1.0.dist-info/METADATA +351 -0
- adk_code_mode-0.1.0.dist-info/RECORD +23 -0
- adk_code_mode-0.1.0.dist-info/WHEEL +4 -0
- adk_code_mode-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2025-present A2A Net <hello@a2anet.com>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
"""``CodeModeCodeExecutor`` — the user-facing ``BaseCodeExecutor`` implementation.
|
|
5
|
+
|
|
6
|
+
Wires together the normaliser, namespacer, stub renderer, progressive-disclosure
|
|
7
|
+
selector, dispatcher, workspace bridge, runtime, and output truncator.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import atexit
|
|
14
|
+
import base64
|
|
15
|
+
import dataclasses
|
|
16
|
+
import datetime as dt
|
|
17
|
+
import logging
|
|
18
|
+
import mimetypes
|
|
19
|
+
import os
|
|
20
|
+
import shutil
|
|
21
|
+
import tempfile
|
|
22
|
+
import threading
|
|
23
|
+
from collections.abc import Awaitable, Sequence
|
|
24
|
+
from concurrent.futures import Future
|
|
25
|
+
from decimal import Decimal
|
|
26
|
+
from pathlib import Path, PurePosixPath
|
|
27
|
+
from typing import Any, Callable, ClassVar
|
|
28
|
+
|
|
29
|
+
from google.adk.agents.invocation_context import InvocationContext
|
|
30
|
+
from google.adk.agents.readonly_context import ReadonlyContext
|
|
31
|
+
from google.adk.code_executors.base_code_executor import BaseCodeExecutor
|
|
32
|
+
from google.adk.code_executors.code_execution_utils import (
|
|
33
|
+
CodeExecutionInput,
|
|
34
|
+
CodeExecutionResult,
|
|
35
|
+
File,
|
|
36
|
+
)
|
|
37
|
+
from google.adk.tools.base_tool import BaseTool
|
|
38
|
+
from google.adk.tools.base_toolset import BaseToolset
|
|
39
|
+
from pydantic import ConfigDict, Field, PrivateAttr
|
|
40
|
+
|
|
41
|
+
from adk_code_mode._artifact_tools import ARTIFACT_TOOLS
|
|
42
|
+
from adk_code_mode.output import truncate
|
|
43
|
+
from adk_code_mode.runtime.base import SandboxHandle, SandboxResult, SandboxRuntime
|
|
44
|
+
from adk_code_mode.runtime.protocol import (
|
|
45
|
+
PROTOCOL_VERSION,
|
|
46
|
+
DoneFrame,
|
|
47
|
+
Frame,
|
|
48
|
+
LogFrame,
|
|
49
|
+
ReadyFrame,
|
|
50
|
+
RunFrame,
|
|
51
|
+
ShutdownFrame,
|
|
52
|
+
ToolCallFrame,
|
|
53
|
+
ToolErrorPayload,
|
|
54
|
+
ToolResultFrame,
|
|
55
|
+
)
|
|
56
|
+
from adk_code_mode.tools import namespacing, normaliser, stubs
|
|
57
|
+
from adk_code_mode.tools.dispatcher import Dispatcher
|
|
58
|
+
from adk_code_mode.workspace.files import hash_bytes, hash_file, walk_workspace
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ProtocolVersionMismatchError(RuntimeError):
|
|
62
|
+
"""Sandbox image's wire protocol does not match the host's."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
logger = logging.getLogger("adk_code_mode.executor")
|
|
66
|
+
|
|
67
|
+
CODE_MODE_SYSTEM_INSTRUCTION = """\
|
|
68
|
+
Code you write in a fenced Python block (i.e. ```python) will be executed in a sandbox, and the stdout and stderr will be returned to you.
|
|
69
|
+
|
|
70
|
+
All of the standard Python libraries and a custom set of tools are available to you.
|
|
71
|
+
|
|
72
|
+
Code is executed in a new environment each time. To save files, use the `save_artifact` tool. To list available artifacts, use the `list_artifacts` tool. And to load an artifact, use the `load_artifact` tool.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
ArtifactsSavedCallback = Callable[[InvocationContext, dict[str, int]], Awaitable[None]]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class CodeModeCodeExecutor(BaseCodeExecutor):
|
|
80
|
+
"""Execute model-written Python in a sandbox with access to ADK tools."""
|
|
81
|
+
|
|
82
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
83
|
+
|
|
84
|
+
tools: list[BaseTool | BaseToolset] = Field(default_factory=list)
|
|
85
|
+
runtime: SandboxRuntime = Field(...)
|
|
86
|
+
max_output_chars: int = 50_000
|
|
87
|
+
max_catalog_chars: int = 50_000
|
|
88
|
+
"""Soft cap on the rendered catalog string. Above this the callback drops
|
|
89
|
+
the per-tool sections and tells the model to navigate ``/tools/`` from
|
|
90
|
+
Python instead."""
|
|
91
|
+
per_tool_timeout_seconds: float | None = None
|
|
92
|
+
include_artifact_tools: bool = True
|
|
93
|
+
"""If true (default), ``save_artifact`` / ``load_artifact`` /
|
|
94
|
+
``list_artifacts`` are injected at the front of ``tools`` as top-level
|
|
95
|
+
tools. Set ``False`` for a strict tool surface."""
|
|
96
|
+
on_artifacts_saved: ArtifactsSavedCallback | None = None
|
|
97
|
+
"""Optional async callback fired after each ``execute_code`` whose
|
|
98
|
+
sandbox-side ``save_artifact`` calls produced new artifact versions.
|
|
99
|
+
Receives the live ``InvocationContext`` and a ``{filename: version}``
|
|
100
|
+
dict. The empty case is skipped (no call). Hook errors are logged and
|
|
101
|
+
swallowed — they must not break code execution."""
|
|
102
|
+
|
|
103
|
+
stateful: bool = Field(default=True, frozen=True, exclude=True)
|
|
104
|
+
optimize_data_file: bool = Field(default=False, frozen=True, exclude=True)
|
|
105
|
+
|
|
106
|
+
_bg_loop: "_BackgroundLoop | None" = PrivateAttr(default=None)
|
|
107
|
+
_loop_lock: threading.Lock = PrivateAttr(default_factory=threading.Lock)
|
|
108
|
+
_resolution_cache: dict[str, list[normaliser.ResolvedTool]] = PrivateAttr(default_factory=dict)
|
|
109
|
+
_resolution_cache_lock: threading.Lock = PrivateAttr(default_factory=threading.Lock)
|
|
110
|
+
|
|
111
|
+
_ROOT_LOOP_REGISTRY: ClassVar[list["_BackgroundLoop"]] = []
|
|
112
|
+
_RESOLUTION_CACHE_LIMIT: ClassVar[int] = 64
|
|
113
|
+
|
|
114
|
+
def __init__(
|
|
115
|
+
self,
|
|
116
|
+
*,
|
|
117
|
+
tools: Sequence[BaseTool | BaseToolset | Callable[..., Any]] = (),
|
|
118
|
+
runtime: SandboxRuntime,
|
|
119
|
+
include_artifact_tools: bool = True,
|
|
120
|
+
**kwargs: Any,
|
|
121
|
+
) -> None:
|
|
122
|
+
adapted: list[BaseTool | BaseToolset] = []
|
|
123
|
+
if include_artifact_tools:
|
|
124
|
+
adapted.extend(ARTIFACT_TOOLS)
|
|
125
|
+
for item in tools:
|
|
126
|
+
if isinstance(item, (BaseTool, BaseToolset)):
|
|
127
|
+
adapted.append(item)
|
|
128
|
+
elif callable(item):
|
|
129
|
+
from google.adk.tools.function_tool import FunctionTool
|
|
130
|
+
|
|
131
|
+
adapted.append(FunctionTool(item))
|
|
132
|
+
else:
|
|
133
|
+
raise TypeError(
|
|
134
|
+
f"Unsupported tool input {item!r}: expected BaseTool, BaseToolset, or callable"
|
|
135
|
+
)
|
|
136
|
+
super().__init__(
|
|
137
|
+
tools=adapted,
|
|
138
|
+
runtime=runtime,
|
|
139
|
+
include_artifact_tools=include_artifact_tools,
|
|
140
|
+
**kwargs,
|
|
141
|
+
)
|
|
142
|
+
# Validate the eagerly-known tool surface (bare BaseTools and FunctionTool-
|
|
143
|
+
# wrapped callables). Toolset-derived tools are async to resolve, so any
|
|
144
|
+
# collisions involving them surface on first ``execute_code`` instead.
|
|
145
|
+
eager_resolved = [
|
|
146
|
+
normaliser.ResolvedTool(tool=t, toolset=None)
|
|
147
|
+
for t in adapted
|
|
148
|
+
if isinstance(t, BaseTool)
|
|
149
|
+
]
|
|
150
|
+
if eager_resolved:
|
|
151
|
+
namespacing.build(eager_resolved)
|
|
152
|
+
|
|
153
|
+
def execute_code(
|
|
154
|
+
self,
|
|
155
|
+
invocation_context: InvocationContext,
|
|
156
|
+
code_execution_input: CodeExecutionInput,
|
|
157
|
+
) -> CodeExecutionResult:
|
|
158
|
+
loop = self._ensure_background_loop()
|
|
159
|
+
fut: Future[CodeExecutionResult] = asyncio.run_coroutine_threadsafe(
|
|
160
|
+
self._aexecute(invocation_context, code_execution_input), loop
|
|
161
|
+
)
|
|
162
|
+
return fut.result()
|
|
163
|
+
|
|
164
|
+
def _ensure_background_loop(self) -> asyncio.AbstractEventLoop:
|
|
165
|
+
with self._loop_lock:
|
|
166
|
+
if self._bg_loop is None or not self._bg_loop.is_running:
|
|
167
|
+
self._bg_loop = _BackgroundLoop()
|
|
168
|
+
self._bg_loop.start()
|
|
169
|
+
CodeModeCodeExecutor._ROOT_LOOP_REGISTRY.append(self._bg_loop)
|
|
170
|
+
return self._bg_loop.loop
|
|
171
|
+
|
|
172
|
+
async def _aexecute(
|
|
173
|
+
self,
|
|
174
|
+
invocation_context: InvocationContext,
|
|
175
|
+
code_execution_input: CodeExecutionInput,
|
|
176
|
+
) -> CodeExecutionResult:
|
|
177
|
+
session_id = invocation_context.session.id
|
|
178
|
+
execution_id = code_execution_input.execution_id or session_id
|
|
179
|
+
|
|
180
|
+
prepared = await self._prepare_tool_surface(invocation_context)
|
|
181
|
+
|
|
182
|
+
run_workspace = _prepare_run_workspace(code_execution_input.input_files)
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
dispatcher = Dispatcher(
|
|
186
|
+
invocation_context=invocation_context,
|
|
187
|
+
registry=prepared.registry,
|
|
188
|
+
execution_id=execution_id,
|
|
189
|
+
per_tool_timeout_seconds=self.per_tool_timeout_seconds,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
handle = await self.runtime.start(
|
|
193
|
+
tools_files=prepared.tools_map,
|
|
194
|
+
workdir_path=run_workspace.root,
|
|
195
|
+
timeout_seconds=self.timeout_seconds,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
timed_out = False
|
|
199
|
+
try:
|
|
200
|
+
run_task = asyncio.create_task(
|
|
201
|
+
_run_sandbox(
|
|
202
|
+
handle=handle,
|
|
203
|
+
dispatcher=dispatcher,
|
|
204
|
+
code=code_execution_input.code,
|
|
205
|
+
)
|
|
206
|
+
)
|
|
207
|
+
result = await asyncio.wait_for(
|
|
208
|
+
run_task,
|
|
209
|
+
timeout=self.timeout_seconds if self.timeout_seconds else None,
|
|
210
|
+
)
|
|
211
|
+
except asyncio.TimeoutError:
|
|
212
|
+
timed_out = True
|
|
213
|
+
result = None
|
|
214
|
+
finally:
|
|
215
|
+
await handle.close()
|
|
216
|
+
|
|
217
|
+
output_files = run_workspace.collect_output_files()
|
|
218
|
+
await self._fire_artifacts_saved(invocation_context, dispatcher.artifact_delta)
|
|
219
|
+
|
|
220
|
+
if timed_out or result is None:
|
|
221
|
+
return CodeExecutionResult(
|
|
222
|
+
stdout="",
|
|
223
|
+
stderr=(
|
|
224
|
+
f"Execution exceeded timeout of {self.timeout_seconds}s and was terminated."
|
|
225
|
+
if self.timeout_seconds is not None
|
|
226
|
+
else "Execution timed out and was terminated."
|
|
227
|
+
),
|
|
228
|
+
output_files=[],
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
stderr = _stderr_with_exit_code(result.stderr, result.exit_code)
|
|
232
|
+
|
|
233
|
+
stdout_res, stderr_res = await asyncio.gather(
|
|
234
|
+
truncate(
|
|
235
|
+
result.stdout,
|
|
236
|
+
limit=self.max_output_chars,
|
|
237
|
+
stream_name="stdout",
|
|
238
|
+
execution_id=execution_id,
|
|
239
|
+
invocation_context=invocation_context,
|
|
240
|
+
),
|
|
241
|
+
truncate(
|
|
242
|
+
stderr,
|
|
243
|
+
limit=self.max_output_chars,
|
|
244
|
+
stream_name="stderr",
|
|
245
|
+
execution_id=execution_id,
|
|
246
|
+
invocation_context=invocation_context,
|
|
247
|
+
),
|
|
248
|
+
)
|
|
249
|
+
return CodeExecutionResult(
|
|
250
|
+
stdout=stdout_res.text,
|
|
251
|
+
stderr=stderr_res.text,
|
|
252
|
+
output_files=output_files,
|
|
253
|
+
)
|
|
254
|
+
finally:
|
|
255
|
+
run_workspace.cleanup()
|
|
256
|
+
|
|
257
|
+
async def _fire_artifacts_saved(
|
|
258
|
+
self, invocation_context: InvocationContext, delta: dict[str, int]
|
|
259
|
+
) -> None:
|
|
260
|
+
if self.on_artifacts_saved is None or not delta:
|
|
261
|
+
return
|
|
262
|
+
try:
|
|
263
|
+
await self.on_artifacts_saved(invocation_context, delta)
|
|
264
|
+
except Exception:
|
|
265
|
+
logger.exception("on_artifacts_saved hook raised; ignoring")
|
|
266
|
+
|
|
267
|
+
async def _prepare_tool_surface(
|
|
268
|
+
self, invocation_context: InvocationContext
|
|
269
|
+
) -> "_PreparedToolSurface":
|
|
270
|
+
cached = self._consume_resolved_tools(invocation_context.invocation_id)
|
|
271
|
+
if cached is not None:
|
|
272
|
+
resolved = cached
|
|
273
|
+
else:
|
|
274
|
+
resolved = await normaliser.resolve(
|
|
275
|
+
list(self.tools), ReadonlyContext(invocation_context)
|
|
276
|
+
)
|
|
277
|
+
ns_tools = namespacing.build(resolved)
|
|
278
|
+
registry = namespacing.Registry(ns_tools)
|
|
279
|
+
|
|
280
|
+
tree_files = stubs.render_tree(ns_tools)
|
|
281
|
+
tools_map = {f.path: f.source for f in tree_files}
|
|
282
|
+
return _PreparedToolSurface(
|
|
283
|
+
registry=registry,
|
|
284
|
+
tools_map=tools_map,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
def _record_resolved_tools(
|
|
288
|
+
self, invocation_id: str, resolved: list[normaliser.ResolvedTool]
|
|
289
|
+
) -> None:
|
|
290
|
+
"""Store a resolved tool list for ``_prepare_tool_surface`` to consume.
|
|
291
|
+
|
|
292
|
+
Used by ``code_mode_before_model_callback`` to avoid re-resolving
|
|
293
|
+
toolsets in the follow-up ``execute_code`` call. FIFO eviction kicks
|
|
294
|
+
in once the cache exceeds ``_RESOLUTION_CACHE_LIMIT`` so an
|
|
295
|
+
invocation that never reaches the executor (model rejected, error
|
|
296
|
+
before code ran, etc.) doesn't leak forever.
|
|
297
|
+
"""
|
|
298
|
+
with self._resolution_cache_lock:
|
|
299
|
+
cache = self._resolution_cache
|
|
300
|
+
cache[invocation_id] = resolved
|
|
301
|
+
while len(cache) > self._RESOLUTION_CACHE_LIMIT:
|
|
302
|
+
cache.pop(next(iter(cache)))
|
|
303
|
+
|
|
304
|
+
def _consume_resolved_tools(self, invocation_id: str) -> list[normaliser.ResolvedTool] | None:
|
|
305
|
+
"""Pop and return cached resolved tools for ``invocation_id``.
|
|
306
|
+
|
|
307
|
+
Returns ``None`` when no entry is cached for this id (e.g. the
|
|
308
|
+
callback wasn't wired or the cache evicted it); the executor then
|
|
309
|
+
resolves fresh. The argument itself must always be a real id.
|
|
310
|
+
"""
|
|
311
|
+
with self._resolution_cache_lock:
|
|
312
|
+
return self._resolution_cache.pop(invocation_id, None)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@dataclasses.dataclass(frozen=True)
|
|
316
|
+
class _PreparedToolSurface:
|
|
317
|
+
registry: namespacing.Registry
|
|
318
|
+
tools_map: dict[str, str]
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
@dataclasses.dataclass
|
|
322
|
+
class _RunWorkspace:
|
|
323
|
+
root: str
|
|
324
|
+
initial_hashes: dict[str, str]
|
|
325
|
+
|
|
326
|
+
def cleanup(self) -> None:
|
|
327
|
+
shutil.rmtree(self.root, ignore_errors=True)
|
|
328
|
+
|
|
329
|
+
def collect_output_files(self) -> list[File]:
|
|
330
|
+
files: list[File] = []
|
|
331
|
+
for rel_path in walk_workspace(self.root):
|
|
332
|
+
abs_path = os.path.join(self.root, rel_path)
|
|
333
|
+
try:
|
|
334
|
+
digest, _size = hash_file(abs_path)
|
|
335
|
+
except OSError:
|
|
336
|
+
continue
|
|
337
|
+
if self.initial_hashes.get(rel_path) == digest:
|
|
338
|
+
continue
|
|
339
|
+
with open(abs_path, "rb") as fh:
|
|
340
|
+
data = fh.read()
|
|
341
|
+
mime_type, _ = mimetypes.guess_type(rel_path)
|
|
342
|
+
files.append(
|
|
343
|
+
File(
|
|
344
|
+
name=rel_path,
|
|
345
|
+
content=data,
|
|
346
|
+
mime_type=mime_type or "application/octet-stream",
|
|
347
|
+
)
|
|
348
|
+
)
|
|
349
|
+
return files
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _prepare_run_workspace(input_files: Sequence[File]) -> _RunWorkspace:
|
|
353
|
+
root = tempfile.mkdtemp(prefix="adk-code-mode-run-")
|
|
354
|
+
seen_paths: set[str] = set()
|
|
355
|
+
initial_hashes: dict[str, str] = {}
|
|
356
|
+
for file in input_files:
|
|
357
|
+
rel_path = _normalise_workspace_rel_path(file.name)
|
|
358
|
+
if rel_path in seen_paths:
|
|
359
|
+
raise ValueError(f"duplicate input file name {file.name!r}")
|
|
360
|
+
seen_paths.add(rel_path)
|
|
361
|
+
abs_path = os.path.join(root, rel_path)
|
|
362
|
+
os.makedirs(os.path.dirname(abs_path), exist_ok=True)
|
|
363
|
+
data = _input_file_bytes(file)
|
|
364
|
+
with open(abs_path, "wb") as fh:
|
|
365
|
+
fh.write(data)
|
|
366
|
+
initial_hashes[rel_path] = hash_bytes(data)
|
|
367
|
+
return _RunWorkspace(root=root, initial_hashes=initial_hashes)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _normalise_workspace_rel_path(raw_path: str) -> str:
|
|
371
|
+
candidate = raw_path.replace("\\", "/")
|
|
372
|
+
path = PurePosixPath(candidate)
|
|
373
|
+
if path.is_absolute():
|
|
374
|
+
raise ValueError(f"input file path must be relative: {raw_path!r}")
|
|
375
|
+
parts = [part for part in path.parts if part not in ("", ".")]
|
|
376
|
+
if any(part == ".." for part in parts) or not parts:
|
|
377
|
+
raise ValueError(f"invalid input file path: {raw_path!r}")
|
|
378
|
+
return "/".join(parts)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _input_file_bytes(file: File) -> bytes:
|
|
382
|
+
if isinstance(file.content, bytes):
|
|
383
|
+
return file.content
|
|
384
|
+
return base64.b64decode(file.content)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _stderr_with_exit_code(stderr: str, exit_code: int) -> str:
|
|
388
|
+
if exit_code == 0:
|
|
389
|
+
return stderr
|
|
390
|
+
marker = f"Process exited with code {exit_code}."
|
|
391
|
+
if not stderr:
|
|
392
|
+
return marker
|
|
393
|
+
return f"{stderr.rstrip()}\n{marker}"
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
async def _run_sandbox(
|
|
397
|
+
*,
|
|
398
|
+
handle: SandboxHandle,
|
|
399
|
+
dispatcher: Dispatcher,
|
|
400
|
+
code: str,
|
|
401
|
+
) -> SandboxResult:
|
|
402
|
+
host_loop = asyncio.create_task(_host_loop(handle=handle, dispatcher=dispatcher))
|
|
403
|
+
done_exit_code: int | None = None
|
|
404
|
+
try:
|
|
405
|
+
await handle.send(RunFrame(code=code))
|
|
406
|
+
done_exit_code = await host_loop
|
|
407
|
+
finally:
|
|
408
|
+
if not host_loop.done():
|
|
409
|
+
host_loop.cancel()
|
|
410
|
+
await asyncio.gather(host_loop, return_exceptions=True)
|
|
411
|
+
try:
|
|
412
|
+
await handle.send(ShutdownFrame())
|
|
413
|
+
except Exception:
|
|
414
|
+
pass
|
|
415
|
+
result = await handle.wait()
|
|
416
|
+
if done_exit_code is None:
|
|
417
|
+
return result
|
|
418
|
+
return SandboxResult(
|
|
419
|
+
stdout=result.stdout,
|
|
420
|
+
stderr=result.stderr,
|
|
421
|
+
exit_code=done_exit_code,
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
async def _host_loop(
|
|
426
|
+
*,
|
|
427
|
+
handle: SandboxHandle,
|
|
428
|
+
dispatcher: Dispatcher,
|
|
429
|
+
) -> int | None:
|
|
430
|
+
"""Consume frames from the sandbox until a ``DoneFrame`` arrives.
|
|
431
|
+
|
|
432
|
+
``tool_call`` frames are dispatched concurrently as background tasks so a
|
|
433
|
+
slow tool doesn't block other calls.
|
|
434
|
+
"""
|
|
435
|
+
pending: list[asyncio.Task[Any]] = []
|
|
436
|
+
frames = handle.frames()
|
|
437
|
+
try:
|
|
438
|
+
async for frame in frames:
|
|
439
|
+
if isinstance(frame, ReadyFrame):
|
|
440
|
+
if frame.protocol_version != PROTOCOL_VERSION:
|
|
441
|
+
raise ProtocolVersionMismatchError(
|
|
442
|
+
f"sandbox uses protocol v{frame.protocol_version}, "
|
|
443
|
+
f"host uses v{PROTOCOL_VERSION}; rebuild the sandbox image"
|
|
444
|
+
)
|
|
445
|
+
continue
|
|
446
|
+
if isinstance(frame, DoneFrame):
|
|
447
|
+
return frame.exit_code
|
|
448
|
+
if isinstance(frame, ToolCallFrame):
|
|
449
|
+
pending.append(asyncio.create_task(_handle_tool_call(handle, dispatcher, frame)))
|
|
450
|
+
continue
|
|
451
|
+
if isinstance(frame, LogFrame):
|
|
452
|
+
logger.log(
|
|
453
|
+
_level_name_to_int(frame.level),
|
|
454
|
+
"[code-mode sandbox] %s",
|
|
455
|
+
frame.msg,
|
|
456
|
+
)
|
|
457
|
+
continue
|
|
458
|
+
finally:
|
|
459
|
+
if pending:
|
|
460
|
+
for task in pending:
|
|
461
|
+
if not task.done():
|
|
462
|
+
task.cancel()
|
|
463
|
+
await asyncio.gather(*pending, return_exceptions=True)
|
|
464
|
+
return None
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
async def _handle_tool_call(
|
|
468
|
+
handle: SandboxHandle,
|
|
469
|
+
dispatcher: Dispatcher,
|
|
470
|
+
frame: ToolCallFrame,
|
|
471
|
+
) -> None:
|
|
472
|
+
result = await dispatcher.dispatch(frame.name, frame.args, timeout=frame.timeout)
|
|
473
|
+
try:
|
|
474
|
+
if result.ok:
|
|
475
|
+
reply: Frame = ToolResultFrame(id=frame.id, ok=True, value=_json_safe(result.value))
|
|
476
|
+
else:
|
|
477
|
+
reply = ToolResultFrame(
|
|
478
|
+
id=frame.id,
|
|
479
|
+
ok=False,
|
|
480
|
+
error=ToolErrorPayload(
|
|
481
|
+
type=result.error_type or "Error",
|
|
482
|
+
message=result.error_message or "",
|
|
483
|
+
),
|
|
484
|
+
)
|
|
485
|
+
await handle.send(reply)
|
|
486
|
+
except asyncio.CancelledError:
|
|
487
|
+
raise
|
|
488
|
+
except Exception as exc:
|
|
489
|
+
logger.exception("failed to send tool_result frame for id=%s", frame.id)
|
|
490
|
+
fallback = ToolResultFrame(
|
|
491
|
+
id=frame.id,
|
|
492
|
+
ok=False,
|
|
493
|
+
error=ToolErrorPayload(
|
|
494
|
+
type=type(exc).__name__,
|
|
495
|
+
message=f"failed to serialise or send tool result: {exc}",
|
|
496
|
+
),
|
|
497
|
+
)
|
|
498
|
+
try:
|
|
499
|
+
await handle.send(fallback)
|
|
500
|
+
except Exception:
|
|
501
|
+
logger.exception("failed to send fallback tool_result frame for id=%s", frame.id)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _json_safe(value: Any) -> Any:
|
|
505
|
+
"""Best-effort conversion of a tool result to a JSON-safe value."""
|
|
506
|
+
if value is None or isinstance(value, (str, int, float, bool)):
|
|
507
|
+
return value
|
|
508
|
+
if isinstance(value, (bytes, bytearray)):
|
|
509
|
+
return {
|
|
510
|
+
"kind": "bytes",
|
|
511
|
+
"data": base64.b64encode(bytes(value)).decode("ascii"),
|
|
512
|
+
"encoding": "base64",
|
|
513
|
+
}
|
|
514
|
+
if isinstance(value, (dt.datetime, dt.date)):
|
|
515
|
+
return value.isoformat()
|
|
516
|
+
if isinstance(value, Decimal):
|
|
517
|
+
return str(value)
|
|
518
|
+
if isinstance(value, Path):
|
|
519
|
+
return str(value)
|
|
520
|
+
if isinstance(value, (list, tuple, set, frozenset)):
|
|
521
|
+
return [_json_safe(v) for v in value]
|
|
522
|
+
if isinstance(value, dict):
|
|
523
|
+
return {str(k): _json_safe(v) for k, v in value.items()}
|
|
524
|
+
if hasattr(value, "model_dump"):
|
|
525
|
+
try:
|
|
526
|
+
return _json_safe(value.model_dump(mode="json"))
|
|
527
|
+
except Exception:
|
|
528
|
+
pass
|
|
529
|
+
if dataclasses.is_dataclass(value) and not isinstance(value, type):
|
|
530
|
+
return _json_safe(dataclasses.asdict(value))
|
|
531
|
+
return repr(value)
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _level_name_to_int(name: str) -> int:
|
|
535
|
+
levels = {
|
|
536
|
+
"debug": logging.DEBUG,
|
|
537
|
+
"info": logging.INFO,
|
|
538
|
+
"warning": logging.WARNING,
|
|
539
|
+
"error": logging.ERROR,
|
|
540
|
+
"critical": logging.CRITICAL,
|
|
541
|
+
}
|
|
542
|
+
return levels.get((name or "info").lower(), logging.INFO)
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
class _BackgroundLoop:
|
|
546
|
+
"""A dedicated asyncio loop running on a daemon thread.
|
|
547
|
+
|
|
548
|
+
Used so ``execute_code`` (sync) can drive async work even when the caller
|
|
549
|
+
already has a running event loop (Jupyter, ``adk web``, etc.).
|
|
550
|
+
"""
|
|
551
|
+
|
|
552
|
+
def __init__(self) -> None:
|
|
553
|
+
self.loop: asyncio.AbstractEventLoop = asyncio.new_event_loop()
|
|
554
|
+
self._thread = threading.Thread(target=self._run, name="adk-code-mode-loop", daemon=True)
|
|
555
|
+
self._running = False
|
|
556
|
+
|
|
557
|
+
@property
|
|
558
|
+
def is_running(self) -> bool:
|
|
559
|
+
return self._running and self._thread.is_alive()
|
|
560
|
+
|
|
561
|
+
def start(self) -> None:
|
|
562
|
+
self._running = True
|
|
563
|
+
self._thread.start()
|
|
564
|
+
|
|
565
|
+
def _run(self) -> None:
|
|
566
|
+
asyncio.set_event_loop(self.loop)
|
|
567
|
+
try:
|
|
568
|
+
self.loop.run_forever()
|
|
569
|
+
finally:
|
|
570
|
+
self._running = False
|
|
571
|
+
|
|
572
|
+
def stop(self) -> None:
|
|
573
|
+
if not self._running:
|
|
574
|
+
return
|
|
575
|
+
|
|
576
|
+
# Cancel and await any in-flight tasks so subprocesses / sockets they own
|
|
577
|
+
# get a chance to clean up before we tear the loop down.
|
|
578
|
+
async def _drain() -> None:
|
|
579
|
+
current = asyncio.current_task(self.loop)
|
|
580
|
+
tasks = [t for t in asyncio.all_tasks(self.loop) if t is not current and not t.done()]
|
|
581
|
+
for task in tasks:
|
|
582
|
+
task.cancel()
|
|
583
|
+
if tasks:
|
|
584
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
585
|
+
|
|
586
|
+
try:
|
|
587
|
+
asyncio.run_coroutine_threadsafe(_drain(), self.loop).result(timeout=5.0)
|
|
588
|
+
except Exception:
|
|
589
|
+
pass
|
|
590
|
+
self.loop.call_soon_threadsafe(self.loop.stop)
|
|
591
|
+
self._thread.join(timeout=5.0)
|
|
592
|
+
try:
|
|
593
|
+
self.loop.close()
|
|
594
|
+
except Exception:
|
|
595
|
+
pass
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
@atexit.register
|
|
599
|
+
def _stop_background_loops() -> None:
|
|
600
|
+
for loop in CodeModeCodeExecutor._ROOT_LOOP_REGISTRY:
|
|
601
|
+
try:
|
|
602
|
+
loop.stop()
|
|
603
|
+
except Exception:
|
|
604
|
+
pass
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
__all__ = ["ArtifactsSavedCallback", "CODE_MODE_SYSTEM_INSTRUCTION", "CodeModeCodeExecutor"]
|
adk_code_mode/output.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2025-present A2A Net <hello@a2anet.com>
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
"""Bounded output surfacing with artifact spillover.
|
|
5
|
+
|
|
6
|
+
The model only ever sees a truncated view of the execution output; the full
|
|
7
|
+
stream is persisted as a session-scoped artifact so the user can still recover
|
|
8
|
+
it. When truncation kicks in, the message the model reads cites the artifact
|
|
9
|
+
name so it can ``load_artifact()`` the overflow if needed.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Literal
|
|
16
|
+
|
|
17
|
+
from google.adk.agents.invocation_context import InvocationContext
|
|
18
|
+
from google.genai import types as genai_types
|
|
19
|
+
|
|
20
|
+
StreamName = Literal["stdout", "stderr"]
|
|
21
|
+
|
|
22
|
+
STDOUT_PREFIX = "code_mode/stdout"
|
|
23
|
+
STDERR_PREFIX = "code_mode/stderr"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _overflow_filename(stream_name: StreamName, execution_id: str) -> str:
|
|
27
|
+
prefix = STDOUT_PREFIX if stream_name == "stdout" else STDERR_PREFIX
|
|
28
|
+
return f"{prefix}/{execution_id}.txt"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class TruncationResult:
|
|
33
|
+
text: str
|
|
34
|
+
"""The model-visible view (possibly truncated)."""
|
|
35
|
+
artifact_filename: str | None
|
|
36
|
+
"""The artifact that stores the full content, if spillover happened."""
|
|
37
|
+
artifact_version: int | None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def truncate(
|
|
41
|
+
text: str,
|
|
42
|
+
*,
|
|
43
|
+
limit: int,
|
|
44
|
+
stream_name: StreamName,
|
|
45
|
+
execution_id: str,
|
|
46
|
+
invocation_context: InvocationContext,
|
|
47
|
+
) -> TruncationResult:
|
|
48
|
+
"""Cap ``text`` at ``limit`` characters; spill the full content to an artifact."""
|
|
49
|
+
if len(text) <= limit:
|
|
50
|
+
return TruncationResult(text=text, artifact_filename=None, artifact_version=None)
|
|
51
|
+
|
|
52
|
+
if invocation_context.artifact_service is None:
|
|
53
|
+
marker = f"\n---\nOutput exceeded {limit:,} characters. Try again with a smaller output."
|
|
54
|
+
return TruncationResult(
|
|
55
|
+
text=_head_tail(text, limit) + marker, artifact_filename=None, artifact_version=None
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
filename = _overflow_filename(stream_name, execution_id)
|
|
59
|
+
part = genai_types.Part(
|
|
60
|
+
inline_data=genai_types.Blob(
|
|
61
|
+
data=text.encode("utf-8"),
|
|
62
|
+
mime_type="text/plain",
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
version = await invocation_context.artifact_service.save_artifact(
|
|
66
|
+
app_name=invocation_context.app_name,
|
|
67
|
+
user_id=invocation_context.user_id,
|
|
68
|
+
filename=filename,
|
|
69
|
+
artifact=part,
|
|
70
|
+
session_id=invocation_context.session.id,
|
|
71
|
+
)
|
|
72
|
+
marker = (
|
|
73
|
+
f"\n---\n"
|
|
74
|
+
f"Output exceeded {limit:,} characters. Try again with a smaller output.\n"
|
|
75
|
+
f"Full {stream_name} saved as artifact: {filename}"
|
|
76
|
+
)
|
|
77
|
+
return TruncationResult(
|
|
78
|
+
text=_head_tail(text, limit) + marker,
|
|
79
|
+
artifact_filename=filename,
|
|
80
|
+
artifact_version=version,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _head_tail(text: str, limit: int) -> str:
|
|
85
|
+
"""Keep a head+tail window of roughly ``limit`` characters."""
|
|
86
|
+
if limit <= 40:
|
|
87
|
+
return text[:limit]
|
|
88
|
+
head_len = int(limit * 0.7)
|
|
89
|
+
tail_len = limit - head_len - 20 # leave room for the elision marker
|
|
90
|
+
if tail_len < 0:
|
|
91
|
+
tail_len = 0
|
|
92
|
+
head = text[:head_len]
|
|
93
|
+
tail = text[-tail_len:] if tail_len > 0 else ""
|
|
94
|
+
return f"{head}\n... [truncated] ...\n{tail}"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
__all__ = ["STDERR_PREFIX", "STDOUT_PREFIX", "TruncationResult", "truncate"]
|
adk_code_mode/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|