Files
openai--openai-agents-python/examples/sandbox/docs/coding_task.py
T
2026-07-13 12:39:17 +08:00

261 lines
9.2 KiB
Python

"""Runnable sandbox coding example used by docs/sandbox_agents.md.
This example gives the model a tiny repo plus one lazy-loaded skill, then
verifies that the agent edited the repo and ran the targeted test command.
"""
from __future__ import annotations
import argparse
import asyncio
import json
import sys
from collections.abc import Sequence
from pathlib import Path
from agents import ModelSettings, Runner
from agents.items import ToolCallItem, ToolCallOutputItem
from agents.run import RunConfig
from agents.sandbox import Manifest, SandboxAgent, SandboxRunConfig
from agents.sandbox.capabilities import LocalDirLazySkillSource, Skills
from agents.sandbox.capabilities.capabilities import Capabilities
from agents.sandbox.entries import LocalDir
from agents.sandbox.sandboxes.unix_local import UnixLocalSandboxClient
DEFAULT_MODEL = "gpt-5.6-sol"
TARGET_TEST_CMD = "sh tests/test_credit_note.sh"
DEFAULT_PROMPT = (
"Open `repo/task.md`, use the `$credit-note-fixer` skill, fix the bug, run "
f"`{TARGET_TEST_CMD}`, and summarize the change."
)
EXAMPLE_DIR = Path(__file__).resolve().parent
if __package__ is None or __package__ == "":
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
def build_agent(model: str) -> SandboxAgent[None]:
return SandboxAgent(
name="Sandbox engineer",
model=model,
instructions=(
"Inspect the repo, make the smallest correct change, run the most relevant checks, "
"and summarize the file changes and risks. "
"Read `repo/task.md` before editing files. Stay grounded in the repository, preserve "
"existing behavior, and use the `$credit-note-fixer` skill before editing files. "
"When using `apply_patch`, remember that paths are relative to the sandbox workspace "
"root, not the shell working directory, so edit files as `repo/credit_note.sh` and "
"`repo/tests/test_credit_note.sh`. "
f"Run the exact verification command `{TARGET_TEST_CMD}` from `repo/`, then mention "
"that command in the final answer."
),
default_manifest=Manifest(
entries={
"repo": LocalDir(src=EXAMPLE_DIR / "repo"),
}
),
capabilities=Capabilities.default()
+ [
Skills(
lazy_from=LocalDirLazySkillSource(
# This is a host path read by the SDK process.
# Requested skills are copied into `skills_path` in the sandbox.
source=LocalDir(src=EXAMPLE_DIR / "skills"),
)
),
],
model_settings=ModelSettings(tool_choice="required"),
)
async def _read_workspace_text(session, path: Path) -> str:
handle = await session.read(path)
try:
payload = handle.read()
finally:
handle.close()
if isinstance(payload, str):
return payload
return bytes(payload).decode("utf-8", errors="replace")
def _tool_call_name(item: ToolCallItem) -> str:
raw_item = item.raw_item
if isinstance(raw_item, dict):
raw_type = raw_item.get("type")
name = raw_item.get("name")
else:
raw_type = getattr(raw_item, "type", None)
name = getattr(raw_item, "name", None)
if raw_type == "apply_patch_call":
return "apply_patch"
if isinstance(name, str) and name:
return name
if isinstance(raw_type, str) and raw_type:
return raw_type
return ""
def _tool_call_arguments(item: ToolCallItem) -> dict[str, object]:
raw_item = item.raw_item
if isinstance(raw_item, dict):
arguments = raw_item.get("arguments")
else:
arguments = getattr(raw_item, "arguments", None)
if not isinstance(arguments, str) or arguments == "":
return {}
try:
parsed = json.loads(arguments)
except json.JSONDecodeError:
return {"_raw": arguments}
if isinstance(parsed, dict):
return parsed
return {"_value": parsed}
def _saw_target_test_command(tool_calls: list[ToolCallItem]) -> bool:
for item in tool_calls:
if _tool_call_name(item) != "exec_command":
continue
arguments = _tool_call_arguments(item)
cmd = arguments.get("cmd")
workdir = arguments.get("workdir")
if cmd == TARGET_TEST_CMD and workdir == "repo":
return True
if isinstance(cmd, str) and TARGET_TEST_CMD in cmd:
return True
if isinstance(cmd, str) and workdir == "repo" and TARGET_TEST_CMD in cmd:
return True
return False
def _tool_call_debug_lines(tool_calls: list[ToolCallItem]) -> list[str]:
lines: list[str] = []
for item in tool_calls:
lines.append(
f"{_tool_call_name(item)}: {json.dumps(_tool_call_arguments(item), sort_keys=True)}"
)
return lines
def _tool_output_debug_lines(new_items: Sequence[object]) -> list[str]:
lines: list[str] = []
for item in new_items:
if not isinstance(item, ToolCallOutputItem):
continue
output = item.output
if isinstance(output, str):
rendered = output
else:
rendered = str(output)
lines.append(rendered[:400] if len(rendered) > 400 else rendered)
return lines
def _saw_target_test_success(new_items: Sequence[object]) -> bool:
awaiting_target_output = False
for item in new_items:
if isinstance(item, ToolCallItem):
if _tool_call_name(item) != "exec_command":
awaiting_target_output = False
continue
arguments = _tool_call_arguments(item)
cmd = arguments.get("cmd")
if isinstance(cmd, str) and TARGET_TEST_CMD in cmd:
awaiting_target_output = True
continue
awaiting_target_output = False
continue
if awaiting_target_output and isinstance(item, ToolCallOutputItem):
output = item.output
if isinstance(output, str) and "2 passed" in output:
return True
awaiting_target_output = False
return False
async def main(model: str, prompt: str) -> None:
agent = build_agent(model)
client = UnixLocalSandboxClient()
sandbox = await client.create(manifest=agent.default_manifest)
try:
async with sandbox:
result = await Runner.run(
agent,
prompt,
max_turns=12,
run_config=RunConfig(
sandbox=SandboxRunConfig(session=sandbox),
tracing_disabled=True,
workflow_name="Sandbox docs coding example",
),
)
tool_calls = [item for item in result.new_items if isinstance(item, ToolCallItem)]
tool_names = [_tool_call_name(item) for item in tool_calls]
if "load_skill" not in tool_names:
raise RuntimeError(f"Expected load_skill call, saw: {tool_names}")
if "apply_patch" not in tool_names:
raise RuntimeError(f"Expected apply_patch call, saw: {tool_names}")
if not _saw_target_test_command(tool_calls):
raise RuntimeError(
"Expected the agent to run the targeted test command.\n"
+ "\n".join(_tool_call_debug_lines(tool_calls))
)
if not _saw_target_test_success(result.new_items):
raise RuntimeError(
"Expected the targeted test command to report `2 passed`.\n"
"Tool calls:\n"
+ "\n".join(_tool_call_debug_lines(tool_calls))
+ "\nTool outputs:\n"
+ "\n".join(_tool_output_debug_lines(result.new_items))
)
verification = await sandbox.exec(
f"cd repo && {TARGET_TEST_CMD}",
shell=True,
)
verification_text = verification.stdout.decode(
"utf-8", errors="replace"
) + verification.stderr.decode("utf-8", errors="replace")
if verification.exit_code != 0 or "2 passed" not in verification_text:
raise RuntimeError(f"Post-run verification failed:\n{verification_text}")
updated_module = await _read_workspace_text(sandbox, Path("repo/credit_note.sh"))
print("=== Final summary ===")
print("final_output:", result.final_output)
print("tool_calls:", ", ".join(tool_names))
print("verification_command:", TARGET_TEST_CMD)
print("verification_result: observed target test output with `2 passed`")
print("updated_credit_note.sh:")
print(updated_module, end="" if updated_module.endswith("\n") else "\n")
finally:
await client.delete(sandbox)
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Run a self-validating sandbox coding example used by the docs."
)
parser.add_argument("--model", default=DEFAULT_MODEL, help="Model name to use.")
parser.add_argument("--prompt", default=DEFAULT_PROMPT, help="Prompt to send to the agent.")
args = parser.parse_args()
asyncio.run(main(args.model, args.prompt))