From b03b5288a436e73630888dcf6b16d2d60a12c4d7 Mon Sep 17 00:00:00 2001 From: anycodes <21079031+anycodes@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:23:14 +0000 Subject: [PATCH 1/3] fix: reuse sync runtime and preserve lifecycle failures --- .agents/README.md | 2 + ...2026-10-29-defensive-complexity-cleanup.md | 47 ++++++++++ ...2026-10-29-defensive-complexity-cleanup.md | 45 ++++++++++ CHANGELOG.md | 4 + client-sdk/src/agent_sandbox/api/commands.py | 45 +++++----- client-sdk/src/agent_sandbox/api/sandbox.py | 4 +- .../src/agent_sandbox/api/session_manager.py | 12 +-- .../agent_sandbox/declarative/decorator.py | 50 +++-------- .../providers/shared/e2b/sandbox.py | 2 +- .../src/agent_sandbox/utils/async_bridge.py | 79 +++++++++------- .../server_e2e/test_server_client_e2e.py | 3 + client-sdk/tests/test_api/test_commands.py | 17 +++- .../tests/test_declarative/test_decorator.py | 16 ++++ .../tests/test_protocol/test_sandbox.py | 20 +++++ .../test_session/test_session_manager.py | 19 ++++ .../tests/test_utils/test_async_bridge.py | 90 +++++++++++++++++++ docs/en/guide/authentication.md | 24 ++--- docs/en/guide/declarative-usage.md | 82 +++++++++-------- docs/en/guide/getting-started.md | 3 - docs/en/guide/sdk-usage.md | 28 +++--- docs/en/guide/session-persistence.md | 2 +- docs/zh/guide/authentication.md | 23 ++--- docs/zh/guide/declarative-usage.md | 81 ++++++++--------- docs/zh/guide/getting-started.md | 3 - docs/zh/guide/sdk-usage.md | 26 +++--- docs/zh/guide/session-persistence.md | 2 +- e2e/test_03_commands.py | 40 +++++++++ 27 files changed, 516 insertions(+), 253 deletions(-) create mode 100644 .agents/evidence/verify/2026-10-29-defensive-complexity-cleanup.md create mode 100644 .agents/notes/proposed/architecture/2026-10-29-defensive-complexity-cleanup.md diff --git a/.agents/README.md b/.agents/README.md index 23b85ba0..09183bcd 100644 --- a/.agents/README.md +++ b/.agents/README.md @@ -1,5 +1,7 @@ # `.agents/` — engineering record of the python-sdk repository +- [Defensive complexity cleanup](notes/proposed/architecture/2026-10-29-defensive-complexity-cleanup.md) · [verification](evidence/verify/2026-10-29-defensive-complexity-cleanup.md). + ## Skill storage/state acceptance — 2026-10-26 [Real storage/state evidence](evidence/verify/2026-10-26-skill-storage-state-acceptance.md) diff --git a/.agents/evidence/verify/2026-10-29-defensive-complexity-cleanup.md b/.agents/evidence/verify/2026-10-29-defensive-complexity-cleanup.md new file mode 100644 index 00000000..7129449f --- /dev/null +++ b/.agents/evidence/verify/2026-10-29-defensive-complexity-cleanup.md @@ -0,0 +1,47 @@ +# Verification: Defensive complexity cleanup + +- Date: 2026-10-29 +- Issue: https://github.com/Alibaba-Agent-Sandbox/python-sdk/issues/150 +- Base: 78f6d8616e3371e01faaabc3313c7328a1f5bf5b +- Result: PARTIAL — changed behavior and targeted real-cloud checks pass; full local suite has recorded failures. Merge is pending remote gates. +- Source and test changes are on an isolated branch; original workspace edits are preserved. + +## Checks + +| Check | Result | +| --- | --- | +| Focused changed behavior | 199 passed | +| Complete client suite | 2362 passed, 27 skipped | +| Complete server suite | 873 passed, 1 skipped, 2 failed | +| Final lint/format and mypy | PASS; server 56 source files, client 101 source files typechecked | +| Build, twine, fresh combined wheel install | PASS | +| Server packaging after installing hatchling | 5 passed | +| Additional engine tests, fixed provider revision | 18 passed, 8 failed | + +Server source and tests are byte-identical to the base. A raw loopback WebSocket handshake expected 400 but received external HTTP 501 with the environment's network-audit-disabled response; transfer-encoding policy expected 400 but received 502 (isolated rerun passed). Do not change product assertions to accommodate interception. + +Initial engine expansion used an old sibling provider checkout and failed 15 tests; repeat pinned CI provider revision 857a66b. Of the remaining eight failures, five encountered loopback 501/502; three non-CI expectations drifted from provider behavior (two domain choices, one advertised feature set). These are not included in passing client counts. Missing CLI fixtures and hatchling accounted for some original skips; supplements do not make the whole suite green. The pure-Python synchronous HTTP keepalive test is included in the passing client suite. + +## Adversarial decisions + +Accepted deletions have observable equivalence or a demonstrated defect: duplicate create target selection, dominated timeout validation, equivalent response defaults, full schemas in normal help, redundant background delegation, per-call event loops, and broad lifecycle catches. + +The opposing reviewer caught three regressions before delivery: requiring exec.run as well as exec.stream for background-only providers; synchronous callback self-deadlock; and submitted work surviving Ctrl-C. All three received corrections and regressions. Actual SIGINT independently ran the cancelled coroutine's finally block; sequential, notebook and 12-thread probes reused one event loop. + +Retained: ServerClient cross-loop compatibility, endpoint/audience authentication, managed-key preparation consent, ownership-aware cleanup, non-idempotent write replay rules and public configuration exports. No management API key was prepared or renewed. Qualified-ID CLI routing remains a separate unresolved design issue; register deployment and keep_alive handle design were documented, not redesigned. + +## Real components and cloud + +A source-built CLI and editable Python client used an existing authorized Express API key loaded only into child processes. One owned base sandbox with a 180-second TTL passed: three sequential synchronous literal-argument executions, background shell variable expansion, synchronous file write/read, and TypeScript lifecycle/literal/background checks against that same sandbox. Deletion succeeded. The first independent check mistakenly called instance-only standard is_running as a class method and failed locally; corrected standard get_info returned SandboxNotFoundException, independently proving absence. The verification mistake is not a product failure. + +Three maintained Python cloud regressions also passed (23 collected, 3 selected, 20 deselected): quoted_operator, background_preserves and sync_connection_reuses. Their owned base fixture performed cleanup. This is targeted Express acceptance, not every provider/template or complete cloud suite. + +English and Chinese application registration examples were executed locally: @command() registers demo, which returns hello=1. SDK application examples now use application_client and async context cleanup. Public guides omit internal acceptance statistics and audit timelines. + +## Repository records and hygiene + +Final repository health: 107 passed, 1 warning, 0 failed. git diff --check passed. Python make hygiene passed; CLI hygiene passed as part of make ci. The first CLI health run found a generated ignored parity report containing local paths; the report was moved outside the checkout and health rerun. No tracked source was exempted. + +## Remaining gates + +Overall local CI is not green. No network assertions were relaxed. Remote PR checks and required review must pass before merge; no release is part of this change. diff --git a/.agents/notes/proposed/architecture/2026-10-29-defensive-complexity-cleanup.md b/.agents/notes/proposed/architecture/2026-10-29-defensive-complexity-cleanup.md new file mode 100644 index 00000000..fb04386e --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-10-29-defensive-complexity-cleanup.md @@ -0,0 +1,45 @@ +# Decision: Reduce redundant interaction and execution branches + +Status: proposed + +## Problem + +Source review found duplicate paths, hidden failures and misleading examples that increase maintenance cost and surprise callers. Removing defensive code indiscriminately would also remove real compatibility and authorization guarantees. + +Issue: https://github.com/Alibaba-Agent-Sandbox/python-sdk/issues/150 + +## Decision + +Use one lazy background event loop for synchronous calls and reuse it from decorators. Cancel submitted work when the waiting caller is interrupted and reject synchronous callback re-entry before self-deadlock. Close borrowed/retained decorator clients. Parse command input once, preserve quoted literals and explicit background shell execution. Propagate lifecycle failures; retain the session record when remote deletion fails. + +Correct English and Chinese guides to describe supported user workflows. Keep verification history in engineering records. Independent opposing review required a concrete counterexample before deleting a safety or compatibility branch. + +## API Design + +Existing ProviderSandbox *_sync signatures remain. Calls block on a shared loop; one client must use a consistent execution style. run(..., background=True, shell=True) preserves shell expansion. is_running propagates authentication/network/service errors. SessionManager.stop propagates kill errors and preserves the local record for retry. Root standard Sandbox/AsyncSandbox interfaces are unchanged. + +## Alternatives considered + +- Delete all broad catches, capability checks or low-frequency compatibility branches: rejected without proof of the actual underlying contract. +- Replace provider routing or deploy locally registered functions implicitly: deferred; these change public architecture and ownership semantics. +- Treat full-suite environmental failures as passing: rejected; preserve baseline comparisons and keep this proposal pending gates. + +## Dependencies + +No new runtime dependencies. CLI and SDK share command semantics but remain independently installable. Companion: https://github.com/Alibaba-Agent-Sandbox/agent-sandbox/issues/215. + +## Test Strategy + +Behavior regressions cover shell quoting, background execution and lifecycle errors. Exercise actual built components, real Express instances, independent absence verification, bilingual examples, static checks and full suites. Distinguish failures, skips and targeted successes. + +## Acceptance criteria + +- Redundant branches are removed without new provider capability requirements. +- Quoted operators stay literal and background shell execution expands variables. +- Unknown lifecycle state is not reported as absence; failed deletion remains retryable. +- Bilingual guides, CHANGELOG and verification evidence match actual behavior. +- Full required CI and repository checks pass before merge. + +## Evidence + +[Verification](../../../evidence/verify/2026-10-29-defensive-complexity-cleanup.md). diff --git a/CHANGELOG.md b/CHANGELOG.md index aab19149..c6f1ca06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +### Fixed + +- Reuse a persistent loop for synchronous ProviderSandbox calls, preserve background shell execution and quoted arguments, release borrowed decorator clients, and retain session records when deletion fails. Authentication and transport failures now propagate from is_running. / 同步 ProviderSandbox 调用复用事件循环,修复后台 shell 和引号参数,释放装饰器借用连接;删除失败保留会话记录,is_running 向调用方抛出认证和传输错误。 + ### Added - Express AK/SK managed-key preparation with explicit consent, private shared storage, expiry renewal and feature-scoped RAM permission guidance. / 极速版支持经确认的 AK/SK 管理 Key 准备、私有共享存储、到期续期及按功能划分的 RAM 授权指南。 diff --git a/client-sdk/src/agent_sandbox/api/commands.py b/client-sdk/src/agent_sandbox/api/commands.py index 359ab72e..e9bd2127 100644 --- a/client-sdk/src/agent_sandbox/api/commands.py +++ b/client-sdk/src/agent_sandbox/api/commands.py @@ -35,19 +35,22 @@ def _needs_shell_wrap(cmd: str) -> bool: """Return ``True`` if *cmd* contains unquoted shell operators. - Uses :class:`shlex.shlex` with ``punctuation_chars=True`` so that - operators inside quoted sections (single or double) are *not* flagged. + Inspect the original text: shlex tokens have already lost their quoting. """ - try: - lex = shlex.shlex(cmd, posix=True, punctuation_chars=True) - lex.whitespace_split = True - for token in lex: - if token and all(c in _SHELL_PUNCT for c in token): - return True - except ValueError: - # Malformed quoting — let it through; the caller will get an error - # from shlex.split or the remote side. - pass + quote = "" + escaped = False + for char in cmd: + if escaped: + escaped = False + elif char == "\\" and quote != "'": + escaped = True + elif quote: + if char == quote: + quote = "" + elif char in ("'", '"'): + quote = char + elif char in _SHELL_PUNCT: + return True return False @@ -129,7 +132,7 @@ async def run( env: Additional environment variables. cwd: Working directory. user: OS user to run the command as. - background: If True, delegate to start() and return a + background: If True, return a StreamReader handle immediately without blocking. ``on_stdout`` / ``on_stderr`` are not called in that case. shell: When True, always run ``cmd`` via ``sh -c`` so variable @@ -146,17 +149,8 @@ async def run( handle is returned instead (not a ProcessResult); iterate over it to consume :class:`ProcessChunk` items. """ - if background: - return await self.start( - cmd, - timeout=timeout, - env=env, - cwd=cwd, - user=user, - ) - - self._require("exec.run") - if background or on_stdout or on_stderr: + self._require("exec.stream" if background else "exec.run") + if on_stdout or on_stderr: self._require("exec.stream") if shell: command, args = "sh", ["-c", cmd] @@ -177,6 +171,9 @@ async def run( user=user, ) + if background: + return reader + stdout_parts: list[str] = [] stderr_parts: list[str] = [] exit_code = 0 diff --git a/client-sdk/src/agent_sandbox/api/sandbox.py b/client-sdk/src/agent_sandbox/api/sandbox.py index b1673d08..42bc8917 100644 --- a/client-sdk/src/agent_sandbox/api/sandbox.py +++ b/client-sdk/src/agent_sandbox/api/sandbox.py @@ -1254,7 +1254,9 @@ async def run( ``timeout`` when that may take longer than the default 30s HTTP timeout. The server must be running in the sandbox (see :meth:`start_server`). A server - protected by its own token needs ``sandbox.server_at(port, token=...).commands.run(...)``. + protected by its own token needs + ``client = await sandbox.application_client(port, token=...)`` followed by + ``await client.commands.run(...)``. Args: name: Registered command name. diff --git a/client-sdk/src/agent_sandbox/api/session_manager.py b/client-sdk/src/agent_sandbox/api/session_manager.py index 627bc0e5..0277222f 100644 --- a/client-sdk/src/agent_sandbox/api/session_manager.py +++ b/client-sdk/src/agent_sandbox/api/session_manager.py @@ -133,16 +133,8 @@ async def stop(self, name: str, *, kill: bool = True) -> None: raise SessionNotFoundError(f"Session {name!r} not found") if kill and session.sandbox_id: - try: - await Sandbox.kill_by_id(session.sandbox_id) - logger.info("Sandbox %s killed for session %r", session.sandbox_id, name) - except Exception: - logger.warning( - "Failed to kill sandbox %s for session %r", - session.sandbox_id, - name, - exc_info=True, - ) + await Sandbox.kill_by_id(session.sandbox_id) + logger.info("Sandbox %s killed for session %r", session.sandbox_id, name) if kill: await self._store.delete(name) diff --git a/client-sdk/src/agent_sandbox/declarative/decorator.py b/client-sdk/src/agent_sandbox/declarative/decorator.py index 59dbcf66..46b4377c 100644 --- a/client-sdk/src/agent_sandbox/declarative/decorator.py +++ b/client-sdk/src/agent_sandbox/declarative/decorator.py @@ -16,15 +16,13 @@ def analyze(data): def demo(x: int, y: str) -> str: return f"{y}={x}" - result = sandbox.run("demo", x=1, y="hello") - + # 在模板的容器内注册并启动服务;注册不会上传本机函数。 # 启动容器内 HTTP server sandbox.server.start(port=9000) """ from __future__ import annotations -import asyncio import contextlib import functools import importlib @@ -37,6 +35,7 @@ def demo(x: int, y: str) -> str: from typing import TYPE_CHECKING, Any from uuid import uuid4 +from agent_sandbox.utils.async_bridge import run_sync from agent_sandbox.utils.coerce import coerce_kwargs as _coerce_kwargs from agent_sandbox.utils.coerce import parse_scalar as _parse_scalar # noqa: F401 re-exported @@ -272,10 +271,10 @@ class _SandboxFactory: 1. **Decorator factory** — ``@sandbox(template=..., ...)``: wraps a function so calls execute remotely in a sandbox. - 2. **Register + run** — ``@sandbox.register`` then - ``sandbox.run("name", ...)``: registers a function by name; - ``run`` creates a sandbox, invokes ``POST /commands/{name}`` - on its HTTP server, and returns the result. + 2. **Server registration** — ``@sandbox.register`` registers a function in + the current process. Put registration in the template's server program; + clients invoke it through ``ServerClient.commands.run``. + Registration does not deploy a local function to a remote sandbox. 3. **Server proxy** — ``sandbox.server.start(port=9000)``: lazy proxy to :mod:`agent_sandbox.server`. @@ -359,18 +358,7 @@ def decorator(func: Callable[..., Any]) -> Callable[..., Any]: @functools.wraps(func) def sync_wrapper(*args: Any, **kwargs: Any) -> Any: """同步调用入口。""" - try: - loop = asyncio.get_running_loop() - except RuntimeError: - loop = None - - if loop and loop.is_running(): - import concurrent.futures - - with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: - future = pool.submit(asyncio.run, _execute(func, args, kwargs)) - return future.result() - return asyncio.run(_execute(func, args, kwargs)) + return run_sync(_execute(func, args, kwargs)) @functools.wraps(func) async def async_wrapper(*args: Any, **kwargs: Any) -> Any: @@ -452,6 +440,8 @@ async def _execute( finally: if not keep_alive and not sandbox_id: await sb.kill() + else: + await sb.close() if inspect.iscoroutinefunction(func): return async_wrapper @@ -498,8 +488,10 @@ def list_registered(self) -> list[dict[str, Any]]: def run(self, name: str, **kwargs: Any) -> Any: """Execute a registered command synchronously. - Creates a sandbox with a persistent HTTP server and sends - ``POST /commands/{name}`` with coerced arguments. + Legacy convenience call: creates ``code-interpreter-v1`` and calls an + already deployed command of the same name. It does not upload the local + registered function. For template services, use an explicit sandbox and + ``application_client().commands.run``. Args: name: The registered command name. @@ -517,21 +509,7 @@ def run(self, name: str, **kwargs: Any) -> Any: cmd = self._get_registered(name) coerced = _coerce_kwargs(cmd.args, kwargs) - try: - loop = asyncio.get_running_loop() - except RuntimeError: - loop = None - - if loop and loop.is_running(): - import concurrent.futures - - with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: - future = pool.submit( - asyncio.run, - self._execute_registered(cmd, coerced), - ) - return future.result() - return asyncio.run(self._execute_registered(cmd, coerced)) + return run_sync(self._execute_registered(cmd, coerced)) async def run_async(self, name: str, **kwargs: Any) -> Any: """Execute a registered command asynchronously. diff --git a/client-sdk/src/agent_sandbox/providers/shared/e2b/sandbox.py b/client-sdk/src/agent_sandbox/providers/shared/e2b/sandbox.py index 95d40deb..fb8ef6c6 100644 --- a/client-sdk/src/agent_sandbox/providers/shared/e2b/sandbox.py +++ b/client-sdk/src/agent_sandbox/providers/shared/e2b/sandbox.py @@ -195,7 +195,7 @@ async def is_running(self, sandbox_id: str) -> bool: try: info = await self.get_info(sandbox_id) return info.status == SandboxStatus.RUNNING - except Exception: + except SandboxNotFoundError: return False async def pause(self, sandbox_id: str) -> None: diff --git a/client-sdk/src/agent_sandbox/utils/async_bridge.py b/client-sdk/src/agent_sandbox/utils/async_bridge.py index 7dce3cba..320c6b66 100644 --- a/client-sdk/src/agent_sandbox/utils/async_bridge.py +++ b/client-sdk/src/agent_sandbox/utils/async_bridge.py @@ -7,54 +7,71 @@ from __future__ import annotations import asyncio +import atexit import functools -from concurrent.futures import ThreadPoolExecutor +import threading +from concurrent.futures import Future from typing import TYPE_CHECKING, Any, TypeVar -from agent_sandbox.utils.logging import get_logger - if TYPE_CHECKING: from collections.abc import Callable, Coroutine -logger = get_logger("utils.async_bridge") - T = TypeVar("T") -# Shared thread pool for sync bridging -_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="sandbox-sync") +class _SyncRunner: + """Own the event loop shared by synchronous SDK calls and their clients.""" -def _has_running_loop() -> bool: - """Check if there's a running event loop in the current thread.""" - try: - loop = asyncio.get_running_loop() - return loop is not None - except RuntimeError: - return False + def __init__(self) -> None: + ready: Future[tuple[asyncio.AbstractEventLoop, asyncio.Event]] = Future() + async def serve() -> None: + stop = asyncio.Event() + ready.set_result((asyncio.get_running_loop(), stop)) + await stop.wait() -def run_sync(coro: Coroutine[Any, Any, T]) -> T: - """Run an async coroutine from synchronous code. + self.thread = threading.Thread( + target=lambda: asyncio.run(serve()), name="sandbox-sync", daemon=True + ) + self.thread.start() + self.loop, self.stop = ready.result() + atexit.register(self.close) - Handles the common case where an event loop may already be running - (e.g., in Jupyter notebooks) by falling back to a thread pool. + def close(self) -> None: + self.loop.call_soon_threadsafe(self.stop.set) + self.thread.join() - Args: - coro: The coroutine to execute. + def run(self, coro: Coroutine[Any, Any, T]) -> T: + if threading.current_thread() is self.thread: + coro.close() + raise RuntimeError( + "A synchronous SDK callback cannot call another synchronous SDK method; " + "use the asynchronous API for nested operations." + ) + future = asyncio.run_coroutine_threadsafe(coro, self.loop) + try: + return future.result() + except BaseException: + future.cancel() + raise - Returns: - The coroutine's return value. - Raises: - Whatever the coroutine raises. +_runner: _SyncRunner | None = None +_runner_lock = threading.Lock() + + +def run_sync(coro: Coroutine[Any, Any, T]) -> T: + """Run a coroutine on the shared synchronous SDK loop and wait for its result. + + Successive calls reuse the loop, including calls from notebook event loops. + This blocks the calling thread. Clients used here must be opened and closed + through their synchronous methods; do not move live async clients between loops. """ - if _has_running_loop(): - # Already in an async context (e.g., Jupyter) — run in a separate thread - logger.debug("Detected running event loop, using thread pool for sync execution") - future = _executor.submit(asyncio.run, coro) - return future.result() - else: - return asyncio.run(coro) + global _runner + with _runner_lock: + if _runner is None: + _runner = _SyncRunner() + return _runner.run(coro) def make_sync( diff --git a/client-sdk/tests/test_api/server_e2e/test_server_client_e2e.py b/client-sdk/tests/test_api/server_e2e/test_server_client_e2e.py index 71a8addc..f339448b 100644 --- a/client-sdk/tests/test_api/server_e2e/test_server_client_e2e.py +++ b/client-sdk/tests/test_api/server_e2e/test_server_client_e2e.py @@ -359,6 +359,7 @@ class TestSyncTwins: def test_sync_wrappers_against_real_server(self, server_factory: Any, tree: Path) -> None: _live, client = server_factory() assert client.health_sync() is True + http = client._http caps = client.discover_sync() assert caps.can("shell") target = str(tree / "out" / "s.txt") @@ -371,7 +372,9 @@ def test_sync_wrappers_against_real_server(self, server_factory: Any, tree: Path assert client.process.get_sync(proc.pid).running client.process.kill_sync(proc.pid) assert client.commands.list_sync() == [] + assert client._http is http client.aclose_sync() + assert http is not None and http.is_closed def test_every_coroutine_has_a_sync_twin(self) -> None: import inspect diff --git a/client-sdk/tests/test_api/test_commands.py b/client-sdk/tests/test_api/test_commands.py index 13021e18..7139a9b8 100644 --- a/client-sdk/tests/test_api/test_commands.py +++ b/client-sdk/tests/test_api/test_commands.py @@ -54,6 +54,10 @@ def test_parse_complex_command(self) -> None: ("python3 -c 'print(1|2)'", "python3"), # inside quotes, no wrap ('grep -r "a|b" .', "grep"), # inside quotes, no wrap ("echo hello", "echo"), # simple, no wrap + ('printf "%s%s" "$HOME" "|"', "printf"), + (r"echo \|", "echo"), + (r"echo \\| cat", "sh"), + ("echo '#' | cat", "sh"), ("ls -la /tmp", "ls"), # simple with args, no wrap ], ) @@ -334,7 +338,7 @@ async def test_start_passes_user_to_protocol( class TestCommandsBackground: - """Test that background=True delegates to start() and returns a handle.""" + """Test that background=True returns the started process handle.""" @pytest.mark.asyncio async def test_background_returns_stream_reader( @@ -356,6 +360,14 @@ async def test_background_returns_stream_reader( # Should be async-iterable (the handle from start()) assert hasattr(result, "__aiter__") + async def test_background_requires_only_stream_operation( + self, commands_module: CommandsModule, mock_process_protocol: AsyncMock + ) -> None: + commands_module._operations = frozenset({"exec.stream"}) + reader = mock_process_protocol.start.return_value + assert await commands_module.run("echo $HOME", shell=True, background=True) is reader + assert mock_process_protocol.start.call_args.kwargs["cmd"] == "sh" + @pytest.mark.asyncio async def test_background_false_returns_process_result( self, @@ -391,8 +403,11 @@ async def test_background_propagates_all_params( cwd="/tmp", user="root", background=True, + shell=True, ) call_kwargs = mock_process_protocol.start.call_args[1] + assert call_kwargs["cmd"] == "sh" + assert call_kwargs["args"] == ["-c", "ls"] assert call_kwargs["timeout"] == 30 assert call_kwargs["env"] == {"A": "1"} assert call_kwargs["cwd"] == "/tmp" diff --git a/client-sdk/tests/test_declarative/test_decorator.py b/client-sdk/tests/test_declarative/test_decorator.py index 6c291a3c..0ef58ee2 100644 --- a/client-sdk/tests/test_declarative/test_decorator.py +++ b/client-sdk/tests/test_declarative/test_decorator.py @@ -106,6 +106,20 @@ async def async_fn(data: list[int]) -> list[int]: mock_sb.kill.assert_awaited_once() +@pytest.mark.asyncio +@patch("agent_sandbox.api.sandbox.Sandbox", new_callable=lambda: MagicMock) +async def test_sync_decorator_inside_running_loop(mock_sandbox_cls: MagicMock) -> None: + mock_sb = _make_mock_sandbox(stdout=json.dumps("ok")) + mock_sandbox_cls.create = AsyncMock(return_value=mock_sb) + + @sandbox(sandbox_api_key="placeholder") + def example() -> str: + return "ok" + + assert example() == "ok" + mock_sb.kill.assert_awaited_once() + + # --------------------------------------------------------------------------- # keep_alive mode # --------------------------------------------------------------------------- @@ -125,6 +139,7 @@ def noop() -> None: noop() mock_sb.kill.assert_not_awaited() + mock_sb.close.assert_awaited_once() # --------------------------------------------------------------------------- @@ -149,6 +164,7 @@ def fn() -> str: mock_sandbox_cls.connect.assert_awaited_once() # sandbox_id mode: should NOT kill mock_sb.kill.assert_not_awaited() + mock_sb.close.assert_awaited_once() # --------------------------------------------------------------------------- diff --git a/client-sdk/tests/test_protocol/test_sandbox.py b/client-sdk/tests/test_protocol/test_sandbox.py index b6e084df..6a97f0a0 100644 --- a/client-sdk/tests/test_protocol/test_sandbox.py +++ b/client-sdk/tests/test_protocol/test_sandbox.py @@ -5,9 +5,11 @@ import json from unittest.mock import AsyncMock, MagicMock +import httpx import pytest from agent_sandbox.models.errors import ( + InvalidAPIKeyError, QuotaExceededError, SandboxCreationError, TemplateNotFoundError, @@ -324,6 +326,24 @@ async def test_is_running_on_error(self, protocol, httpx_mock): ) assert await protocol.is_running("sbx-gone") is False + @pytest.mark.parametrize("status", [401, 403]) + async def test_is_running_propagates_access_failure(self, protocol, httpx_mock, status): + httpx_mock.add_response( + url=f"{BASE}/sandboxes/sbx-abc123", + status_code=status, + json={"message": "access denied"}, + ) + error = InvalidAPIKeyError if status == 401 else httpx.HTTPStatusError + with pytest.raises(error): + await protocol.is_running("sbx-abc123") + + async def test_is_running_propagates_transport_failure(self, protocol): + failure = ConnectionError("provider unreachable") + protocol.get_info = AsyncMock(side_effect=failure) + with pytest.raises(ConnectionError) as caught: + await protocol.is_running("sbx-abc123") + assert caught.value is failure + class TestPause: """Test SandboxProtocol.pause().""" diff --git a/client-sdk/tests/test_session/test_session_manager.py b/client-sdk/tests/test_session/test_session_manager.py index f88397aa..256bee21 100644 --- a/client-sdk/tests/test_session/test_session_manager.py +++ b/client-sdk/tests/test_session/test_session_manager.py @@ -167,6 +167,25 @@ async def test_stop_kills_and_deletes( mock_kill.assert_awaited_once_with("sbx-stop-001") assert (await store.load("to-stop")) is None + async def test_failed_kill_preserves_session( + self, manager: SessionManager, store: LocalSessionStore + ) -> None: + session = SessionInfo(name="retry", sandbox_id="sbx-retry", status="running") + await store.save("retry", session) + with ( + patch( + "agent_sandbox.api.session_manager.Sandbox.kill_by_id", + new_callable=AsyncMock, + side_effect=ConnectionError("provider unreachable"), + ), + pytest.raises(ConnectionError, match="provider unreachable"), + ): + await manager.stop("retry") + saved = await store.load("retry") + assert saved is not None + assert saved.sandbox_id == "sbx-retry" + assert saved.status == "running" + async def test_stop_keep_alive(self, manager: SessionManager, store: LocalSessionStore) -> None: session = SessionInfo( name="keep-me", diff --git a/client-sdk/tests/test_utils/test_async_bridge.py b/client-sdk/tests/test_utils/test_async_bridge.py index ae560ad6..d580c794 100644 --- a/client-sdk/tests/test_utils/test_async_bridge.py +++ b/client-sdk/tests/test_utils/test_async_bridge.py @@ -2,8 +2,20 @@ from __future__ import annotations +import asyncio +import json +import threading +from concurrent.futures import ThreadPoolExecutor +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + import pytest +from agent_sandbox import ProviderSandbox +from agent_sandbox.models.sandbox import SandboxInfo +from agent_sandbox.providers.shared.e2b.sandbox import SandboxProtocol +from agent_sandbox.transport.auth_contract import ApiKeyAuth, SessionAuth +from agent_sandbox.transport.config import TransportConfig +from agent_sandbox.transport.http import HttpClient from agent_sandbox.utils.async_bridge import make_sync, run_sync @@ -62,3 +74,81 @@ async def fail(): sync_fn = make_sync(fail) with pytest.raises(RuntimeError, match="nope"): sync_fn() + + +@pytest.mark.parametrize("inside_async", [False, True]) +def test_sync_sandbox_reuses_http_connection_and_closes(inside_async): + connections = [] + + class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, *args): + pass + + def do_GET(self): + connections.append(self.connection) + data = json.dumps({"sandboxID": "sync-test", "status": "running"}).encode() + self.send_response(200) + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + url = f"http://127.0.0.1:{server.server_port}" + config = TransportConfig(sandbox_base_url=url, http2=False) + auth = ApiKeyAuth("placeholder") + http = HttpClient(config, auth) + sandbox = ProviderSandbox( + SandboxInfo(sandbox_id="sync-test", envd_url=url), + config, + http, + auth, + SessionAuth("placeholder", "sync-test"), + sandbox_protocol=SandboxProtocol(http), + process_protocol=object(), + filesystem_protocol=object(), + code_interpreter_protocol=object(), + ) + + def check(): + try: + assert sandbox.refresh_info_sync().sandbox_id == "sync-test" + assert sandbox.refresh_info_sync().sandbox_id == "sync-test" + assert len(connections) == 2 + assert connections[0] is connections[1] + finally: + sandbox.close_sync() + + async def check_inside_async(): + check() + + try: + if inside_async: + asyncio.run(check_inside_async()) + else: + check() + finally: + server.shutdown() + server.server_close() + thread.join() + + +def test_concurrent_sync_calls_share_one_loop(): + async def current_loop(): + await asyncio.sleep(0) + return asyncio.get_running_loop() + + with ThreadPoolExecutor(max_workers=4) as pool: + loops = list(pool.map(lambda _: run_sync(current_loop()), range(12))) + assert all(loop is loops[0] for loop in loops) + + +def test_nested_sync_callback_fails_without_deadlocking(): + async def nested(): + return run_sync(asyncio.sleep(0)) + + with pytest.raises(RuntimeError, match="synchronous SDK callback"): + run_sync(nested()) diff --git a/docs/en/guide/authentication.md b/docs/en/guide/authentication.md index b07470ff..fc03e288 100644 --- a/docs/en/guide/authentication.md +++ b/docs/en/guide/authentication.md @@ -10,7 +10,6 @@ Sandbox runtime requests require an **API Key**. **AK/SK (Alibaba Cloud AccessKe Credentials belong to a [provider](providers.md). The built-in provider is **Alibaba Cloud Express Edition (阿里云极速版)**, id `alicloud-express`, and it is the default. The sandbox key is `ABX_SANDBOX_API_KEY`. Alibaba Cloud access keys use the official names `ALICLOUD_ACCESS_KEY_ID` and `ALICLOUD_ACCESS_KEY_SECRET`. The region is `ALIBABA_CLOUD_REGION_ID`. Each key has one name. -> **Historical note**: the `abx auth` CLI command group (`login`/`logout`/`status`/`switch`) was removed before the first stable release (`0.1.0`). Credentials are configured with the environment variables and `abx config set sandbox_api_key` shown below; check the effective state with `abx config list`. --- @@ -102,7 +101,7 @@ The GitHub token and the region use this same chain. A blank or whitespace-only The generic sandbox key (`sandbox_api_key`, environment variable `ABX_SANDBOX_API_KEY`) and the Alibaba Cloud AK/SK belong to the built-in provider. If you switch to a [provider plugin](providers.md), the plugin does **not** receive them unless its manifest asks for them and you consented at install time. A plugin has its own scoped credential keys, for example `ABX_MY_CLOUD_API_KEY` for a provider `my-cloud`. This prevents a key for one cloud from being sent to another. -Every plugin option follows one rule: the canonical name is `ABX__` (dashes become underscores), and a manifest may add the vendor's own variable as an alias with `env = [...]`. Secret fields (`secret = true`) live only in the environment or `.env` files (mode 0600) and are refused on the command line; other fields can also live under `[providers.]` in `config.toml`. Verified with OpenSandbox on 2026-10-13: `ABX_OPENSANDBOX_API_KEY` and `ABX_OPENSANDBOX_BASE_URL` were enough to drive a real server from both the CLI and the SDK; `OPEN_SANDBOX_API_KEY` and `OPEN_SANDBOX_DOMAIN` are its declared native aliases, and the canonical name wins when both are set. A plugin that declares `[core] inherit = ["sandbox_api_key"]` receives the generic key only after you consent at install time, and `abx provider info ` shows that list under "Inherits". +Every plugin option follows one rule: the canonical name is `ABX__` (dashes become underscores), and a manifest may add the vendor's own variable as an alias with `env = [...]`. Secret fields (`secret = true`) live only in the environment or `.env` files (mode 0600) and are refused on the command line; other fields can also live under `[providers.]` in `config.toml`. OpenSandbox uses `ABX_OPENSANDBOX_API_KEY` and `ABX_OPENSANDBOX_BASE_URL`; `OPEN_SANDBOX_API_KEY` and `OPEN_SANDBOX_DOMAIN` are its declared native aliases, and the canonical name wins when both are set. A plugin that declares `[core] inherit = ["sandbox_api_key"]` receives the generic key only after you consent at install time, and `abx provider info ` shows that list under "Inherits". AK/SK-managed preparation requires explicit consent. When both are configured, runtime calls use the API key: - If both `sandbox_api_key` and `access_key_id` are provided, API Key is used @@ -110,22 +109,13 @@ AK/SK-managed preparation requires explicit consent. When both are configured, r --- -## Secret Storage (File-Based) +## Credential storage -Secrets are stored in `~/.abx/secrets.json`, a plaintext JSON file protected by `chmod 600` file permissions. This is suitable for local development only. - -> **⚠️ Warning**: `secrets.json` stores secrets in **plaintext**. It relies solely on POSIX file permissions (`chmod 600`) for access control. For production environments, use a dedicated secrets management service. - -Manage secrets via environment variables or the `.env` files, or pass `envs={"MY_TOKEN": "..."}` to `Sandbox.create()` to inject secrets into one sandbox: - -```bash -# Set API Key via environment variable (recommended) -export ABX_SANDBOX_API_KEY="your-api-key" -``` - -The CLI equivalents (`abx config set`, `abx create --env`) are in the [CLI Authentication guide](https://github.com/alibaba-agent-sandbox/agent-sandbox/blob/main/docs/en/guide/authentication.md). - -> **Historical note**: macOS Keychain integration was removed in the overdesign cleanup (ADR 2026-09-23). The `abx secret` CLI command group has also been removed. All secret management is now done through environment variables, `.env` files, or `abx config set` (a CLI command). +`abx config set sandbox_api_key` saves the key as `ABX_SANDBOX_API_KEY` in +`~/.abx/.env` with owner-only permissions. The SDK reads that file; environment +variables and the project's `.env` can override it as described above. +Non-secret defaults are stored in `~/.abx/config.toml`. Keep credential files out +of version control. For CI, supply credentials through the job's secret environment. --- diff --git a/docs/en/guide/declarative-usage.md b/docs/en/guide/declarative-usage.md index 537e1d7f..7f2ad8ba 100644 --- a/docs/en/guide/declarative-usage.md +++ b/docs/en/guide/declarative-usage.md @@ -89,76 +89,74 @@ async def async_analyze(data): result = await async_analyze({"col": [1, 2, 3]}) ``` -For synchronous functions, the decorator generates a synchronous wrapper. If the synchronous wrapper is called within an existing event loop, it uses a `ThreadPoolExecutor` to avoid blocking. +Synchronous decorated functions block until their remote result is available. In an asynchronous application, decorate an `async def` function and await it. --- ## Reusing a Sandbox +Create the instance explicitly so you retain its ID and control when it is deleted. +The decorator connects to that instance and closes its borrowed client after execution. + ```python -# Create a sandbox and keep it running -@sandbox(template="code-interpreter-v1", keep_alive=True) -def setup(): - import subprocess - subprocess.run(["pip", "install", "flask"]) - return "ready" - -sandbox_id = "sbx-xxxx" # Get the sandbox ID - -# Reuse the existing sandbox -@sandbox(sandbox_id=sandbox_id) -def use_flask(): - from flask import Flask - return "Flask imported successfully" +import asyncio +from agent_sandbox import ProviderSandbox +from agent_sandbox.declarative import sandbox + +async def main(): + instance = await ProviderSandbox.create(template="base") + try: + @sandbox(sandbox_id=instance.id) + async def calculate(): + import math + return math.factorial(6) + + print(await calculate()) + finally: + await instance.kill() + +asyncio.run(main()) ``` ---- +`keep_alive=True` on an automatically created instance retains the remote sandbox, but the decorator returns only the function result. Prefer the explicit ownership pattern above when you need to reconnect or clean up. -## Registration-Style Commands +## Registered application commands -In addition to the decorator pattern, the `sandbox` object also supports registration-style commands: +Register application commands inside the template image and start its service: ```python -from agent_sandbox.declarative import sandbox +# commands.py inside the template image +from agent_sandbox.server import command, start -@sandbox.register +@command() def demo(x: int, y: str) -> str: - """A simple demo command""" return f"{y}={x}" -# Sync call -result = sandbox.run("demo", x=1, y="hello") -print(result) # "hello=1" - -# Async call -result = await sandbox.run_async("demo", x=2, y="world") - -# List registered commands -commands = sandbox.list_registered() +if __name__ == "__main__": + start(9000) ``` -Registered function parameters only support scalar types: `str`, `int`, `float`, `bool`. - -This is the one mechanism for named commands. A command registered in a template's `commands.py` (or with `@sandbox.register`) is served by the in-sandbox server, and a regular `Sandbox` calls it with `await sandbox.run("demo", x=1, y="hello")` (`POST /commands/demo`). `template.yaml` has no `custom_commands` key; the client rejects it. - -### File Transfer - -`sandbox.register` only registers named commands. File upload and download are endpoints of the server's `file_ops` group (on by default); a client uses `sandbox.files` or `ServerClient.files`. - -### Start the HTTP Server +Launch the template using the [CLI template guide](template-workflows.md), then connect to the returned sandbox ID. Set `application_token` to the same application token used at launch: ```python -sandbox.server.start(port=9000, host="0.0.0.0") +from agent_sandbox import ProviderSandbox + +instance = await ProviderSandbox.connect(sandbox_id) +try: + async with await instance.application_client(9000, token=application_token) as client: + print(await client.commands.run("demo", x=1, y="hello")) +finally: + await instance.close() ``` ---- +Local `sandbox.register` registration does not upload a function to a remote sandbox. Template deployment and a running application service are required for named HTTP commands. Use the function decorator above for direct function execution. ## Limitations 1. **Parameter serialization**: Function arguments and return values must be JSON-serializable (non-serializable types fall back to `str`) 2. **Function source code**: The decorator obtains function source via `inspect.getsource()` — lambdas and dynamically generated functions are not supported 3. **Imports**: Libraries needed in the remote sandbox must be pre-installed via the `packages` parameter or already included in the template -4. **Registered command parameter types**: `@sandbox.register` only supports scalar types (`str`/`int`/`float`/`bool`) — complex types are not supported +4. **Registered command parameter types**: `@sandbox.register` supports `str`, `int`, `float`, `bool` and `list[str]`; arbitrary object types are not supported 5. **Closure variables**: External variables referenced in the function are not automatically serialized — use explicit parameter passing instead 6. **Execution environment isolation**: The decorated function runs in an independent remote Python process and does not share state with the local process diff --git a/docs/en/guide/getting-started.md b/docs/en/guide/getting-started.md index 260f98c8..a51bc746 100644 --- a/docs/en/guide/getting-started.md +++ b/docs/en/guide/getting-started.md @@ -101,9 +101,6 @@ Standard sandbox classes use `from agent_sandbox import Sandbox, AsyncSandbox`. Interpreter classes use `from agent_sandbox.code_interpreter import Sandbox, AsyncSandbox`; these are distinct entries. Their imports require no separate upstream SDK. -The [2026-10-25 public-a4 example record](../../../.agents/evidence/verify/2026-10-25-public-a4-examples.md) -separates 14 real cloud examples from local protocol fixtures, timeout controls -and independent cleanup checks. A passing example is not whole-catalog coverage. - [SDK usage](sdk-usage.md): provider extensions and their synchronous wrappers. - [Declarative functions](declarative-usage.md): execute a Python function remotely. diff --git a/docs/en/guide/sdk-usage.md b/docs/en/guide/sdk-usage.md index 94bbec21..38239b96 100644 --- a/docs/en/guide/sdk-usage.md +++ b/docs/en/guide/sdk-usage.md @@ -134,6 +134,8 @@ result = await sandbox.commands.run( ) ``` +Unquoted shell operators such as `|` and `>` select shell execution. Quoted or escaped operators remain literal arguments. Use `shell=True` for variable expansion and other shell syntax; this also applies with `background=True`. + ### Streaming Output ```python @@ -292,6 +294,8 @@ await sandbox.pause() await sandbox.resume() ``` +`is_running()` returns `False` when the sandbox does not exist. Authentication, connection and service errors propagate to the caller. + ### Properties ```python @@ -318,7 +322,7 @@ The SDK uses an **async-first** design — all core methods are asynchronous. Ea | `sandbox.files.read()` | `sandbox.files.read_sync()` | | `sandbox.files.write()` | `sandbox.files.write_sync()` | -Sync methods run an event loop in the background and are suitable for scripts and non-async environments. +Sync methods share a background event loop and block until a result is ready. Keep a client on one execution style: use its async methods within one event loop, or use its `_sync` methods. Call `close_sync()` when releasing a synchronous client. A synchronous method cannot be re-entered from a callback running on its background loop; keep callbacks synchronous or schedule async work instead. --- @@ -368,23 +372,23 @@ An unknown name raises `CommandNotFoundError` (the message includes the server's ## Template Server — server -Templates that ship an `agent-sandbox-server` expose a second, template-authored data plane (port 9000). `sandbox.server` -is its typed client. Use it when the template restricts what is reachable (per-endpoint policy) or when you need +Templates that ship an `agent-sandbox-server` expose a second, template-authored data plane (port 9000). `await sandbox.application_client(9000, token=application_token)` +resolves its typed client using the selected provider. Use the application token supplied at launch. Use it when the template restricts what is reachable (per-endpoint policy) or when you need endpoints envd does not have (search, archive, file watching, process output/stdin, git, browser). ```python async with await Sandbox.create(template="my-server-template") as sandbox: - server = sandbox.server # or sandbox.server_at(9000, token="...") - caps = await server.discover() # explicit; enables fail-fast pre-checks - if caps.can("files_download_stream"): - data = await server.files.download("/home/user/out/report.csv") - await server.files.upload("/home/user/in/data.json", b"{}") + async with await sandbox.application_client(9000, token=application_token) as server: + caps = await server.discover() # explicit; enables fail-fast pre-checks + if caps.can("files_download_stream"): + data = await server.files.download("/home/user/out/report.csv") + await server.files.upload("/home/user/in/data.json", b"{}") - result = await server.process.shell("ls -la", cwd="/home/user") - async for event in server.process.shell_stream("make test"): - print(event.event, event.data) + result = await server.process.shell("ls -la", cwd="/home/user") + async for event in server.process.shell_stream("make test"): + print(event.event, event.data) - print(server.files.list_sync("/home/user")) # every coroutine has a *_sync twin + print(await server.files.list("/home/user")) ``` Starting a server in a running sandbox (the image must contain `agent-sandbox-server`): diff --git a/docs/en/guide/session-persistence.md b/docs/en/guide/session-persistence.md index 34384b22..c46c9452 100644 --- a/docs/en/guide/session-persistence.md +++ b/docs/en/guide/session-persistence.md @@ -1,6 +1,6 @@ # Session Persistence -The SDK still provides `LocalSessionStore` and `SessionManager`; the removed surface is the CLI `abx session` group. Persistence stores local names, sandbox IDs and connection metadata, not remote memory or file snapshots, and cannot restore deleted or expired instances. `SessionManager.stop()` attempts deletion but logs and suppresses deletion errors; independently check the actual instance when confirmed cleanup is required. +The SDK still provides `LocalSessionStore` and `SessionManager`; the removed surface is the CLI `abx session` group. Persistence stores local names, sandbox IDs and connection metadata, not remote memory or file snapshots, and cannot restore deleted or expired instances. `SessionManager.stop()` propagates deletion errors and retains the local session so you can retry. A successful deletion removes the local record. For current SDK installation, use the [installation guide](install-python.md). diff --git a/docs/zh/guide/authentication.md b/docs/zh/guide/authentication.md index 811c2662..c815c751 100644 --- a/docs/zh/guide/authentication.md +++ b/docs/zh/guide/authentication.md @@ -10,7 +10,6 @@ 凭证属于某个[提供方](providers.md)。内置提供方是**阿里云极速版(Alibaba Cloud Express Edition)**,id 为 `alicloud-express`,也是默认提供方。沙箱密钥是 `ABX_SANDBOX_API_KEY`。阿里云 AccessKey 使用官方名字 `ALICLOUD_ACCESS_KEY_ID` 和 `ALICLOUD_ACCESS_KEY_SECRET`。地域是 `ALIBABA_CLOUD_REGION_ID`。每个键只有一个名字。 -> **历史说明**:`abx auth` CLI 命令组(`login`/`logout`/`status`/`switch`)已在首个正式版 `0.1.0` 之前移除。凭证通过下方介绍的环境变量与 `abx config set sandbox_api_key` 配置;用 `abx config list` 查看生效状态。 --- @@ -102,7 +101,7 @@ GitHub token 和区域走同一条链。空白或只含空格的值会被跳过 通用沙箱密钥(`sandbox_api_key`,环境变量 `ABX_SANDBOX_API_KEY`)和阿里云 AK/SK 属于内置提供方。切换到[提供方插件](providers.md)后,除非插件的清单申请了它们且你在安装时同意,否则插件**不会**收到。插件有自己作用域的凭证键,例如提供方 `my-cloud` 的 `ABX_MY_CLOUD_API_KEY`。这样可以避免把一个云的密钥发给另一个云。 -插件的每个选项都遵循同一条规则:规范名是 `ABX__`(连字符变下划线),清单可以用 `env = [...]` 把厂商自己的变量加为别名。密钥字段(`secret = true`)只存在于环境变量或 `.env` 文件(权限 0600),命令行上会被拒绝;其他字段也可以放在 `config.toml` 的 `[providers.]` 下。2026-10-13 用 OpenSandbox 验证:只要 `ABX_OPENSANDBOX_API_KEY` 和 `ABX_OPENSANDBOX_BASE_URL`,CLI 和 SDK 都能驱动真实服务器;`OPEN_SANDBOX_API_KEY` 与 `OPEN_SANDBOX_DOMAIN` 是它声明的原生别名,两者同时设置时规范名优先。声明了 `[core] inherit = ["sandbox_api_key"]` 的插件,只有在你于安装时同意之后才会收到通用密钥;`abx provider info ` 会在「Inherits」下显示这份清单。 +插件的每个选项都遵循同一条规则:规范名是 `ABX__`(连字符变下划线),清单可以用 `env = [...]` 把厂商自己的变量加为别名。密钥字段(`secret = true`)只存在于环境变量或 `.env` 文件(权限 0600),命令行上会被拒绝;其他字段也可以放在 `config.toml` 的 `[providers.]` 下。OpenSandbox 使用 `ABX_OPENSANDBOX_API_KEY` 和 `ABX_OPENSANDBOX_BASE_URL`;`OPEN_SANDBOX_API_KEY` 与 `OPEN_SANDBOX_DOMAIN` 是它声明的原生别名,两者同时设置时规范名优先。声明了 `[core] inherit = ["sandbox_api_key"]` 的插件,只有在你于安装时同意之后才会收到通用密钥;`abx provider info ` 会在「Inherits」下显示这份清单。 AK/SK 管理 Key 需要显式准备确认。同时配置时,运行时使用 API Key: - 若同时提供了 `sandbox_api_key` 和 `access_key_id`,使用 API Key @@ -110,22 +109,12 @@ AK/SK 管理 Key 需要显式准备确认。同时配置时,运行时使用 AP --- -## 密钥存储(基于文件) +## 凭据存储 -密钥存储在 `~/.abx/secrets.json` 中,这是一个明文 JSON 文件,通过 `chmod 600` 文件权限保护。仅适合本地开发使用。 - -> **⚠️ 警告**:`secrets.json` 以**明文**存储密钥。仅依赖 POSIX 文件权限(`chmod 600`)控制访问。生产环境请使用专用密钥管理服务。 - -通过环境变量或 `.env` 文件管理密钥,或向 `Sandbox.create()` 传入 `envs={"MY_TOKEN": "..."}` 为单个沙箱注入密钥: - -```bash -# 通过环境变量设置 API Key(推荐) -export ABX_SANDBOX_API_KEY="your-api-key" -``` - -对应的 CLI 用法(`abx config set`、`abx create --env`)见 [CLI 认证配置指南](https://github.com/alibaba-agent-sandbox/agent-sandbox/blob/main/docs/zh/guide/authentication.md)。 - -> **历史说明**:macOS Keychain 集成已在过度设计清理中移除(ADR 2026-09-23)。`abx secret` CLI 命令组也已被移除。所有密钥管理现通过环境变量、`.env` 文件或 `abx config set`(CLI 命令)完成。 +`abx config set sandbox_api_key` 将密钥以 `ABX_SANDBOX_API_KEY` 保存到 +`~/.abx/.env`,文件仅允许当前用户读写。SDK 会读取此文件;环境变量和项目 +`.env` 可按前述优先级覆盖。非敏感默认配置保存在 `~/.abx/config.toml`。 +不要将凭据文件提交到版本控制;CI 使用任务的密钥环境变量提供凭据。 --- diff --git a/docs/zh/guide/declarative-usage.md b/docs/zh/guide/declarative-usage.md index 2d944b36..aebbc7d2 100644 --- a/docs/zh/guide/declarative-usage.md +++ b/docs/zh/guide/declarative-usage.md @@ -89,76 +89,73 @@ async def async_analyze(data): result = await async_analyze({"col": [1, 2, 3]}) ``` -对于同步函数,装饰器生成同步包装器。若在已有事件循环中调用同步包装器,会通过 `ThreadPoolExecutor` 避免阻塞。 +同步装饰函数会阻塞到远程结果返回。在异步应用中,装饰 `async def` 函数并使用 `await` 调用。 --- ## 复用沙箱 +显式创建实例,保留 ID 并决定何时删除。装饰器连接该实例,执行后关闭借用的客户端。 + ```python -# 创建沙箱并保持运行 -@sandbox(template="code-interpreter-v1", keep_alive=True) -def setup(): - import subprocess - subprocess.run(["pip", "install", "flask"]) - return "ready" - -sandbox_id = "sbx-xxxx" # 获取沙箱 ID - -# 复用已有沙箱 -@sandbox(sandbox_id=sandbox_id) -def use_flask(): - from flask import Flask - return "Flask imported successfully" +import asyncio +from agent_sandbox import ProviderSandbox +from agent_sandbox.declarative import sandbox + +async def main(): + instance = await ProviderSandbox.create(template="base") + try: + @sandbox(sandbox_id=instance.id) + async def calculate(): + import math + return math.factorial(6) + + print(await calculate()) + finally: + await instance.kill() + +asyncio.run(main()) ``` ---- +自动创建时传入 `keep_alive=True` 会保留远程沙箱,但装饰器只返回函数结果。需要重连或主动清理时,优先使用上面的显式管理方式。 -## 注册式命令 +## 注册应用命令 -除了装饰器模式,`sandbox` 对象还支持注册式命令: +在模板镜像内注册应用命令并启动服务: ```python -from agent_sandbox.declarative import sandbox +# commands.py inside the template image +from agent_sandbox.server import command, start -@sandbox.register +@command() def demo(x: int, y: str) -> str: - """简单的演示命令""" return f"{y}={x}" -# 同步调用 -result = sandbox.run("demo", x=1, y="hello") -print(result) # "hello=1" - -# 异步调用 -result = await sandbox.run_async("demo", x=2, y="world") - -# 查看已注册的命令 -commands = sandbox.list_registered() +if __name__ == "__main__": + start(9000) ``` -注册函数的参数仅支持标量类型:`str`、`int`、`float`、`bool`。 - -这是命名命令的唯一机制。注册在模板 `commands.py` 里(或用 `@sandbox.register`)的命令由沙箱内 server 提供,普通 `Sandbox` 用 `await sandbox.run("demo", x=1, y="hello")` 调用(`POST /commands/demo`)。`template.yaml` 没有 `custom_commands` 键,客户端会拒绝它。 - -### 文件传输 - -`sandbox.register` 只注册命名命令。文件上传和下载是 server `file_ops` 组的端点(默认开启),客户端用 `sandbox.files` 或 `ServerClient.files`。 - -### 启动 HTTP Server +按[模板启动指南](template-workflows.md)启动模板,再连接返回的沙箱 ID。`application_token` 使用启动服务时的同一个应用令牌: ```python -sandbox.server.start(port=9000, host="0.0.0.0") +from agent_sandbox import ProviderSandbox + +instance = await ProviderSandbox.connect(sandbox_id) +try: + async with await instance.application_client(9000, token=application_token) as client: + print(await client.commands.run("demo", x=1, y="hello")) +finally: + await instance.close() ``` ---- +本机 `sandbox.register` 不会把函数上传到远程沙箱。命名 HTTP 命令需要部署模板并运行应用服务;直接执行函数使用前述函数装饰器。 ## 限制 1. **参数序列化**:函数参数和返回值必须是 JSON 可序列化的(不支持的类型会回退为 `str`) 2. **函数源码**:装饰器通过 `inspect.getsource()` 获取函数源码,Lambda 和动态生成的函数不支持 3. **导入**:远程沙箱中需要的库必须通过 `packages` 参数预安装,或已包含在模板中 -4. **注册命令参数类型**:`@sandbox.register` 仅支持标量类型(`str`/`int`/`float`/`bool`),不支持复杂类型 +4. **注册命令参数类型**:`@sandbox.register` 支持 `str`、`int`、`float`、`bool` 和 `list[str]`,不支持任意对象类型 5. **闭包变量**:函数中引用的外部变量不会被自动序列化,请使用显式参数传递 6. **执行环境隔离**:被装饰的函数在独立的远程 Python 进程中执行,不共享本地进程的状态 diff --git a/docs/zh/guide/getting-started.md b/docs/zh/guide/getting-started.md index d0dfa192..db30c284 100644 --- a/docs/zh/guide/getting-started.md +++ b/docs/zh/guide/getting-started.md @@ -93,9 +93,6 @@ assert result.exit_code == 0 and result.stdout.strip() == "2" `from agent_sandbox.code_interpreter import Sandbox, AsyncSandbox`,二者是不同入口。 这些导入不需要另装上游 SDK。 -[2026-10-25 公开 a4 示例记录](../../../.agents/evidence/verify/2026-10-25-public-a4-examples.md) -区分了 14 个真实云端示例、本地协议夹具、超时控制和独立清理检查。 -示例通过不代表整个模板目录都已验证。 - [SDK 用法](sdk-usage.md):Provider 扩展及其同步封装。 - [声明式函数](declarative-usage.md):将 Python 函数放到远程执行。 diff --git a/docs/zh/guide/sdk-usage.md b/docs/zh/guide/sdk-usage.md index 7235ff24..97dce908 100644 --- a/docs/zh/guide/sdk-usage.md +++ b/docs/zh/guide/sdk-usage.md @@ -134,6 +134,8 @@ result = await sandbox.commands.run( ) ``` +未加引号的 `|`、`>` 等操作符会启用 shell 执行;引号内或经过转义的操作符保持为普通参数。变量展开等 shell 语法请使用 `shell=True`,也适用于 `background=True`。 + ### 流式输出 ```python @@ -292,6 +294,8 @@ await sandbox.pause() await sandbox.resume() ``` +`is_running()` 在沙箱不存在时返回 `False`;认证、连接或服务错误会向调用方抛出。 + ### 属性 ```python @@ -318,7 +322,7 @@ SDK 采用 **async-first** 设计,所有核心方法都是异步的。每个 | `sandbox.files.read()` | `sandbox.files.read_sync()` | | `sandbox.files.write()` | `sandbox.files.write_sync()` | -同步方法在后台运行事件循环,适用于脚本和非异步环境。 +同步方法共用后台事件循环,并阻塞到结果返回。同一个客户端应使用一种调用方式:在同一个事件循环中调用异步方法,或使用 `_sync` 方法。同步客户端使用完后调用 `close_sync()`。在后台循环中执行的同步回调不能再次调用同步 SDK 方法;嵌套操作请使用异步 API。 --- @@ -368,22 +372,22 @@ result = await sandbox.run("dev", port=8080) # 注册函数的返回值 ## 模板服务端 — server -自带 `agent-sandbox-server` 的模板会暴露第二条由模板作者定义的数据通道(端口 9000),`sandbox.server` 是它的类型化客户端。 +自带 `agent-sandbox-server` 的模板会暴露第二条由模板作者定义的数据通道(端口 9000),`await sandbox.application_client(9000, token=application_token)` 按所选 Provider 解析其类型化客户端。应用令牌与启动服务时保持一致。 当模板按端点策略限制了可访问范围,或你需要 envd 没有的端点(搜索、打包、文件监听、进程输出/标准输入、git、浏览器)时使用它。 ```python async with await Sandbox.create(template="my-server-template") as sandbox: - server = sandbox.server # 或 sandbox.server_at(9000, token="...") - caps = await server.discover() # 显式调用;启用快速失败的预检查 - if caps.can("files_download_stream"): - data = await server.files.download("/home/user/out/report.csv") - await server.files.upload("/home/user/in/data.json", b"{}") + async with await sandbox.application_client(9000, token=application_token) as server: + caps = await server.discover() # 显式调用;启用快速失败的预检查 + if caps.can("files_download_stream"): + data = await server.files.download("/home/user/out/report.csv") + await server.files.upload("/home/user/in/data.json", b"{}") - result = await server.process.shell("ls -la", cwd="/home/user") - async for event in server.process.shell_stream("make test"): - print(event.event, event.data) + result = await server.process.shell("ls -la", cwd="/home/user") + async for event in server.process.shell_stream("make test"): + print(event.event, event.data) - print(server.files.list_sync("/home/user")) # 每个协程都有 *_sync 孪生方法 + print(await server.files.list("/home/user")) ``` 在运行中的沙箱里启动服务端(镜像中需要有 `agent-sandbox-server`): diff --git a/docs/zh/guide/session-persistence.md b/docs/zh/guide/session-persistence.md index b27be84f..e9a41964 100644 --- a/docs/zh/guide/session-persistence.md +++ b/docs/zh/guide/session-persistence.md @@ -1,6 +1,6 @@ # 会话持久化 -本页的 SDK `LocalSessionStore` 和 `SessionManager` 仍存在;已移除的是 CLI 的 `abx session` 命令组。持久化内容是本地名称与沙箱 ID 等连接信息,不是远端内存或文件快照,不能恢复已删除或到期的实例。`SessionManager.stop()` 会尝试删除实例,但会记录并吞掉删除异常;需要确认资源清理时必须额外核对实际实例状态。 +本页的 SDK `LocalSessionStore` 和 `SessionManager` 仍存在;已移除的是 CLI 的 `abx session` 命令组。持久化内容是本地名称与沙箱 ID 等连接信息,不是远端内存或文件快照,不能恢复已删除或到期的实例。`SessionManager.stop()` 会抛出实例删除异常并保留本地会话,便于重试;删除成功后才移除本地记录。 当前 SDK 安装使用[安装指南](install-python.md)。 diff --git a/e2e/test_03_commands.py b/e2e/test_03_commands.py index 0aa1c3e2..81f9b363 100644 --- a/e2e/test_03_commands.py +++ b/e2e/test_03_commands.py @@ -81,6 +81,46 @@ def test_run_shell_flag_expands_variables(run, base_sandbox): # type: ignore[no assert result.stdout.strip() == "43" +def test_quoted_operator_keeps_arguments_literal(run, base_sandbox): # type: ignore[no-untyped-def] + result = run( + base_sandbox.commands.run( + 'printf "%s%s" "$E2E_LITERAL" "|"', + env={"E2E_LITERAL": "expanded"}, + ) + ) + assert result.exit_code == 0 + assert result.stdout == "$E2E_LITERAL|" + + +def test_background_preserves_explicit_shell(run, base_sandbox): # type: ignore[no-untyped-def] + async def scenario(): # type: ignore[no-untyped-def] + reader = await base_sandbox.commands.run( + 'printf "%s" "$E2E_BACKGROUND"', + shell=True, + background=True, + env={"E2E_BACKGROUND": "expanded"}, + ) + return await reader.collect() + + chunks = run(scenario()) + assert "".join(c.data for c in chunks if c.type.value == "stdout") == "expanded" + assert chunks[-1].type.value == "exit" and chunks[-1].exit_code == 0 + + +def test_sync_connection_reuses_client_for_sequential_calls(base_sandbox): # type: ignore[no-untyped-def] + from agent_sandbox import ProviderSandbox + + client = ProviderSandbox.connect_sync(base_sandbox.id) + try: + for _ in range(3): + assert client.refresh_info_sync().sandbox_id == base_sandbox.id + result = client.commands.run_sync("printf sync-reused") + assert result.exit_code == 0 + assert result.stdout == "sync-reused" + finally: + client.close_sync() + + def test_unknown_executable_raises_envd_rpc_error(run, base_sandbox): # type: ignore[no-untyped-def] from agent_sandbox.models.errors import EnvdRpcError From 7499ddf6c6805b50a65afa6e4b9778f5c466d468 Mon Sep 17 00:00:00 2001 From: anycodes <21079031+anycodes@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:26:06 +0000 Subject: [PATCH 2/3] docs: record cloud decorator verification --- .../verify/2026-10-29-defensive-complexity-cleanup.md | 5 ++++- CHANGELOG.md | 6 ++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.agents/evidence/verify/2026-10-29-defensive-complexity-cleanup.md b/.agents/evidence/verify/2026-10-29-defensive-complexity-cleanup.md index 7129449f..4b790293 100644 --- a/.agents/evidence/verify/2026-10-29-defensive-complexity-cleanup.md +++ b/.agents/evidence/verify/2026-10-29-defensive-complexity-cleanup.md @@ -2,6 +2,7 @@ - Date: 2026-10-29 - Issue: https://github.com/Alibaba-Agent-Sandbox/python-sdk/issues/150 +- PR: https://github.com/Alibaba-Agent-Sandbox/python-sdk/pull/151 - Base: 78f6d8616e3371e01faaabc3313c7328a1f5bf5b - Result: PARTIAL — changed behavior and targeted real-cloud checks pass; full local suite has recorded failures. Merge is pending remote gates. - Source and test changes are on an isolated branch; original workspace edits are preserved. @@ -34,6 +35,8 @@ Retained: ServerClient cross-loop compatibility, endpoint/audience authenticatio A source-built CLI and editable Python client used an existing authorized Express API key loaded only into child processes. One owned base sandbox with a 180-second TTL passed: three sequential synchronous literal-argument executions, background shell variable expansion, synchronous file write/read, and TypeScript lifecycle/literal/background checks against that same sandbox. Deletion succeeded. The first independent check mistakenly called instance-only standard is_running as a class method and failed locally; corrected standard get_info returned SandboxNotFoundException, independently proving absence. The verification mistake is not a product failure. +The existing declarative cloud suite additionally passed all six tests: sync/async remote functions, remote exception propagation, borrowed-instance survival, automatic owned-instance deletion and local serializer validation. + Three maintained Python cloud regressions also passed (23 collected, 3 selected, 20 deselected): quoted_operator, background_preserves and sync_connection_reuses. Their owned base fixture performed cleanup. This is targeted Express acceptance, not every provider/template or complete cloud suite. English and Chinese application registration examples were executed locally: @command() registers demo, which returns hello=1. SDK application examples now use application_client and async context cleanup. Public guides omit internal acceptance statistics and audit timelines. @@ -44,4 +47,4 @@ Final repository health: 107 passed, 1 warning, 0 failed. git diff --check passe ## Remaining gates -Overall local CI is not green. No network assertions were relaxed. Remote PR checks and required review must pass before merge; no release is part of this change. +Overall local CI is not green. No network assertions were relaxed. Initial remote checks already passed static/links and (for Python) the real Python-to-Node provider-engine job; complete matrices are still pending. Remote PR checks and required review must pass before merge; no release is part of this change. diff --git a/CHANGELOG.md b/CHANGELOG.md index c6f1ca06..d422c95c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] -### Fixed - -- Reuse a persistent loop for synchronous ProviderSandbox calls, preserve background shell execution and quoted arguments, release borrowed decorator clients, and retain session records when deletion fails. Authentication and transport failures now propagate from is_running. / 同步 ProviderSandbox 调用复用事件循环,修复后台 shell 和引号参数,释放装饰器借用连接;删除失败保留会话记录,is_running 向调用方抛出认证和传输错误。 - ### Added - Express AK/SK managed-key preparation with explicit consent, private shared storage, expiry renewal and feature-scoped RAM permission guidance. / 极速版支持经确认的 AK/SK 管理 Key 准备、私有共享存储、到期续期及按功能划分的 RAM 授权指南。 @@ -41,6 +37,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Fixed +- Reuse a persistent loop for synchronous ProviderSandbox calls, preserve background shell execution and quoted arguments, release borrowed decorator clients, and retain session records when deletion fails. Authentication and transport failures now propagate from is_running. / 同步 ProviderSandbox 调用复用事件循环,修复后台 shell 和引号参数,释放装饰器借用连接;删除失败保留会话记录,is_running 向调用方抛出认证和传输错误。 + - Verify explicit Python MCP CPU/memory requests against actual platform metadata before success; reject unknown/mismatched resources, roll back only the new sandbox and report independently verified cleanup or a recovery handle. Direct SDK creation is unchanged. / Python MCP 对显式 CPU/内存请求独立核验平台实际配置,无法核验或不匹配时仅回滚新建沙箱,返回清理确认或恢复句柄;直接 SDK 创建行为不变。 - Track Python MCP sandbox creation ownership separately from borrowed connections. Closing a borrowing session now closes its client handle without deleting another session's sandbox; explicit kills and retained-task behavior remain supported. Correct the Provider Skill to use `template.prepare` instead of the CLI-rejected `templates.deploy` feature. / Python MCP 分开记录沙箱创建归属与借用连接;关闭借用会话不再删除其他会话的沙箱,保留显式删除及后台任务保留行为;Provider 技能改用 `template.prepare`,删除 CLI 拒绝的 `templates.deploy` 特性声明。 From 7a9f1cad42babee3387915298eb3395962f4a663 Mon Sep 17 00:00:00 2001 From: anycodes <21079031+anycodes@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:36:56 +0000 Subject: [PATCH 3/3] docs: record passing remote cleanup verification --- .agents/README.md | 2 +- .../verify/2026-10-29-defensive-complexity-cleanup.md | 8 +++++--- .../2026-10-29-defensive-complexity-cleanup.md | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) rename .agents/notes/{proposed => implemented}/architecture/2026-10-29-defensive-complexity-cleanup.md (99%) diff --git a/.agents/README.md b/.agents/README.md index 09183bcd..b62d4624 100644 --- a/.agents/README.md +++ b/.agents/README.md @@ -1,6 +1,6 @@ # `.agents/` — engineering record of the python-sdk repository -- [Defensive complexity cleanup](notes/proposed/architecture/2026-10-29-defensive-complexity-cleanup.md) · [verification](evidence/verify/2026-10-29-defensive-complexity-cleanup.md). +- [Defensive complexity cleanup](notes/implemented/architecture/2026-10-29-defensive-complexity-cleanup.md) · [verification](evidence/verify/2026-10-29-defensive-complexity-cleanup.md). ## Skill storage/state acceptance — 2026-10-26 diff --git a/.agents/evidence/verify/2026-10-29-defensive-complexity-cleanup.md b/.agents/evidence/verify/2026-10-29-defensive-complexity-cleanup.md index 4b790293..e368e2ee 100644 --- a/.agents/evidence/verify/2026-10-29-defensive-complexity-cleanup.md +++ b/.agents/evidence/verify/2026-10-29-defensive-complexity-cleanup.md @@ -4,7 +4,7 @@ - Issue: https://github.com/Alibaba-Agent-Sandbox/python-sdk/issues/150 - PR: https://github.com/Alibaba-Agent-Sandbox/python-sdk/pull/151 - Base: 78f6d8616e3371e01faaabc3313c7328a1f5bf5b -- Result: PARTIAL — changed behavior and targeted real-cloud checks pass; full local suite has recorded failures. Merge is pending remote gates. +- Result: Source behavior, targeted real-cloud acceptance and the complete remote CI matrix pass. Local environment failures remain recorded below. Final documentation commit and merge still require current-head checks. - Source and test changes are on an isolated branch; original workspace edits are preserved. ## Checks @@ -45,6 +45,8 @@ English and Chinese application registration examples were executed locally: @co Final repository health: 107 passed, 1 warning, 0 failed. git diff --check passed. Python make hygiene passed; CLI hygiene passed as part of make ci. The first CLI health run found a generated ignored parity report containing local paths; the report was moved outside the checkout and health rerun. No tracked source was exempted. -## Remaining gates +## Remote CI and remaining gates -Overall local CI is not green. No network assertions were relaxed. Initial remote checks already passed static/links and (for Python) the real Python-to-Node provider-engine job; complete matrices are still pending. Remote PR checks and required review must pass before merge; no release is part of this change. +Complete remote checks passed for source revision `7499ddf6c6805b50a65afa6e4b9778f5c466d468`: Build packages, server-sdk (Python 3.11), Public runtime fixture (python-hello), client-sdk (Python 3.12), Python to Node provider engine, Pinned E2B public contract, server-sdk (Python 3.12), No local paths or secrets, Public runtime fixture (agents-cli), client-sdk (Python 3.11), server-sdk (Python 3.10), client-sdk (Python 3.10), Markdown links. This independently distinguishes local environment failures from the successful remote matrices. Local failures and prior cancellations remain in history; no network assertions were relaxed. + +The final records-only commit must pass its own current-head checks before merge. The ADR describes the implemented change; the PR carries pending merge state. No release is part of this change. diff --git a/.agents/notes/proposed/architecture/2026-10-29-defensive-complexity-cleanup.md b/.agents/notes/implemented/architecture/2026-10-29-defensive-complexity-cleanup.md similarity index 99% rename from .agents/notes/proposed/architecture/2026-10-29-defensive-complexity-cleanup.md rename to .agents/notes/implemented/architecture/2026-10-29-defensive-complexity-cleanup.md index fb04386e..07a6a7da 100644 --- a/.agents/notes/proposed/architecture/2026-10-29-defensive-complexity-cleanup.md +++ b/.agents/notes/implemented/architecture/2026-10-29-defensive-complexity-cleanup.md @@ -1,6 +1,6 @@ # Decision: Reduce redundant interaction and execution branches -Status: proposed +Status: implemented ## Problem