Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .agents/README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# `.agents/` — engineering record of the python-sdk repository

- [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

[Real storage/state evidence](evidence/verify/2026-10-26-skill-storage-state-acceptance.md)
Expand Down
52 changes: 52 additions & 0 deletions .agents/evidence/verify/2026-10-29-defensive-complexity-cleanup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# Verification: Defensive complexity cleanup

- 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: 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

| 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.

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.

## 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.

## Remote CI and remaining gates

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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Decision: Reduce redundant interaction and execution branches

Status: implemented

## 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).
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,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` 特性声明。
Expand Down
45 changes: 21 additions & 24 deletions client-sdk/src/agent_sandbox/api/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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
Expand All @@ -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]
Expand All @@ -177,6 +171,9 @@ async def run(
user=user,
)

if background:
return reader

stdout_parts: list[str] = []
stderr_parts: list[str] = []
exit_code = 0
Expand Down
4 changes: 3 additions & 1 deletion client-sdk/src/agent_sandbox/api/sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 2 additions & 10 deletions client-sdk/src/agent_sandbox/api/session_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
50 changes: 14 additions & 36 deletions client-sdk/src/agent_sandbox/declarative/decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading