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.
@@ -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"]
@@ -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
+