builder: require durable workspace execution
Some checks failed
build / build (push) Failing after 2s

This commit is contained in:
robert
2026-05-19 11:28:37 -03:00
parent 960c3558c9
commit d0fa3f3f5f
11 changed files with 126 additions and 34 deletions

View File

@@ -35,6 +35,7 @@ class BuilderConfig(BaseModel):
_URL_RE = re.compile(r"https?://[a-zA-Z0-9._-]+\.[a-zA-Z]{2,}[\w/?#%&=.-]*")
DEEPAGENTS_RECURSION_LIMIT = 500
class AgentBuilder(A2AAgent[BuilderConfig, NoAuth]):
@@ -130,11 +131,11 @@ class AgentBuilder(A2AAgent[BuilderConfig, NoAuth]):
async for event in graph.astream_events(
{"messages": [{"role": "user", "content": user_msg}]},
version="v2",
# Default langgraph cap is 25 steps; one deepagents
# build (list → write*3 → test → deploy) easily uses
# 12-18, and any retry pushes it past the limit. Give
# the loop headroom without letting it run away.
config={"recursion_limit": 80},
# Agent builds can legitimately run many tool/subagent turns:
# scaffold, write skills/files, test, fix, retest, deploy, and
# verify the live card. Keep this aligned with generated child
# agents so complex DeepAgents workflows have the same budget.
config={"recursion_limit": DEEPAGENTS_RECURSION_LIMIT},
):
kind = event.get("event")
tname = event.get("name") or ""

View File

@@ -93,13 +93,18 @@ runtime:
max_runtime_seconds: 900
```
For render/media agents, subprocesses must be bounded with timeouts and broad
For render/media/data agents, commands that create user-visible files MUST run
through ``await ctx.workspace_shell(...)`` or ``await ctx.workspace_python(...)``
and write under ``/workspace/outputs/...``. Do not use in-process
``asyncio.create_subprocess_exec`` for durable outputs; the agent container is
not workspace-mounted, so files created in ``/tmp`` or the image filesystem can
vanish after the request. Commands must still be bounded with timeouts and broad
exception handling, and failures must return structured JSON instead of
crashing the worker. Use ``render: bool = False`` by default and expose every
public skill argument with concrete type hints so the live ``input_schema``
contains the full call surface. If a render command returns nonzero after
emitting a usable file, persist or wrap that artifact and include the command
failure as a warning instead of dropping the result.
emitting a usable file, preserve the file under ``/workspace/outputs/...`` and
include the command failure as a warning instead of dropping the result.
For Manim presentation agents specifically, generate a stable PDF fallback
that does not require Manim rendering. If HTML/video rendering is requested,
@@ -107,6 +112,12 @@ generate ``class GeneratedDeck(Slide)`` and call
``manim-slides render --CE -ql <source.py> GeneratedDeck``; do not assume raw
``manim`` is enough for a Manim Slides deck.
Generated agents that invoke an inner DeepAgents graph MUST pass
``config={"recursion_limit": 500}`` to ``graph.ainvoke(...)`` or
``graph.astream_events(...)``. This matches agent-builder's own runtime budget
and prevents complex skill/subagent workflows from failing at the default
LangGraph recursion cap.
And a ``requirements.txt`` listing any extra deps beyond a2a-pack
itself (pandas, httpx, etc. — the base image ships a2a-pack already
when you deploy through the control plane).
@@ -177,6 +188,9 @@ Discipline:
Do not replace LLM reasoning with fake deterministic tools that return
canned answers. Tools should do exact work; skills and subagents should
carry reusable workflow knowledge and judgement-heavy behavior.
- When invoking the generated DeepAgents graph, use
``config={"recursion_limit": 500}`` so child agents have the same graph
budget as agent-builder.
- If generated agent.py calls ``ctx.workspace_backend()``, declare
``workspace_access = WorkspaceAccess.dynamic(...)`` on the A2A agent class
so local/dev/platform invocations actually receive a workspace grant.

View File

@@ -101,7 +101,8 @@ class ResearchAgent(A2AAgent[ResearchConfig, NoAuth]):
),
}
]
}
},
config={"recursion_limit": 500},
)
report_text = _last_message_text(state)
ref = await ctx.write_artifact(
@@ -127,6 +128,17 @@ If a skill calls `ctx.workspace_backend()` or `ctx.workspace`, declare
`workspace_access`. If it writes user-visible files, also emit artifacts with
`ctx.write_artifact` and `ctx.emit_artifact`.
If a skill runs code, renderers, converters, or data tools that create
downloadable files, run them through `await ctx.workspace_shell(...)` or
`await ctx.workspace_python(...)` and make the command write under
`/workspace/outputs/...`. In-process subprocesses write inside the agent pod,
not the caller's mounted workspace.
When invoking an inner DeepAgents graph, pass
`config={"recursion_limit": 500}` to `graph.ainvoke(...)` or
`graph.astream_events(...)`. Complex skill/subagent workflows can exceed
LangGraph defaults during normal operation.
If an agent needs system binaries, mirror the need in `a2a.yaml`:
```yaml

View File

@@ -37,6 +37,9 @@ Check these items:
- Custom subagents that need project skills include their own `skills` field.
- Deterministic tools do exact work; no fake canned-response tools.
- File-producing skills call `ctx.write_artifact` and `ctx.emit_artifact`.
- Commands that create downloadable outputs run through `ctx.workspace_shell`
or `ctx.workspace_python` and write under `/workspace/outputs/...`. Plain
subprocesses in the agent container are not durable workspace writes.
- External calls, secrets, workspace access, pricing, and resources are
declared on the class.

View File

@@ -79,6 +79,15 @@ return create_deep_agent(
)
```
Invoke the graph with the same recursion budget agent-builder uses:
```python
state = await graph.ainvoke(
{"messages": [{"role": "user", "content": prompt}]},
config={"recursion_limit": 500},
)
```
Because this uses `/outputs/...`, the write fits the normal A2A workspace
grant. If the agent uses `ctx.workspace_backend()`, also declare
`workspace_access = WorkspaceAccess.dynamic(...)` on the A2A agent class.

View File

@@ -127,3 +127,18 @@ for generated agents that read or write files.
Use `skills=skill_sources or None`, not an empty list, so agents without a
`skills/` directory still build cleanly.
## Invocation Budget
Generated agents should use the same recursion budget as agent-builder:
```python
state = await graph.ainvoke(
{"messages": [{"role": "user", "content": prompt}]},
config={"recursion_limit": 500},
)
```
For streaming/event loops, pass the same config to `graph.astream_events(...)`.
This prevents normal multi-step skill/subagent workflows from failing at the
default LangGraph recursion cap.

View File

@@ -55,33 +55,33 @@ ref = await ctx.write_artifact("frames.zip", zip_bytes, "application/zip")
await ctx.emit_artifact(ref)
```
## Bounded Subprocesses
## Workspace-Mounted Execution
Media, rendering, archive, and data conversion agents must not run unbounded
commands. Use explicit timeouts and return structured errors:
Media, rendering, archive, and data conversion agents must run commands through
the workspace-mounted sandbox helpers when those commands create files:
```python
import asyncio
async def run_checked(cmd: list[str], timeout_s: int = 120) -> dict[str, str | int]:
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout_s)
except asyncio.TimeoutError:
return {"ok": 0, "error": "command timed out", "timeout_s": timeout_s}
async def run_checked(script: str, timeout_s: int = 120) -> dict[str, str | int]:
result = await ctx.workspace_shell(
script,
image="python:3.11-slim",
timeout_seconds=timeout_s,
memory_mib=1024,
cpus=1,
)
return {
"ok": 1 if proc.returncode == 0 else 0,
"returncode": proc.returncode,
"stdout": stdout.decode("utf-8", errors="replace")[-4000:],
"stderr": stderr.decode("utf-8", errors="replace")[-4000:],
"ok": 1 if result.ok else 0,
"returncode": result.exit_code,
"stdout": result.stdout[-4000:],
"stderr": result.stderr[-4000:],
}
```
Commands should write user-visible files under `/workspace/outputs/...`; that
path is backed by the caller's workspace bucket. Do not use
`asyncio.create_subprocess_exec(...)` for durable outputs. Plain subprocesses
run inside the agent container, which is not mounted to the caller workspace.
If a nonzero command still produced a usable output, preserve the output and
include the command failure as a warning instead of discarding the file.

View File

@@ -15,6 +15,7 @@ import tarfile
import time
from dataclasses import dataclass
from importlib import resources
from pathlib import Path
from typing import TYPE_CHECKING, Any
import boto3
@@ -927,11 +928,21 @@ def _render_a2a_init_template(
def _render_sdk_template(template: str, /, **vars: str) -> str:
text = (
resources.files("a2a_pack.cli.templates")
.joinpath(template)
.read_text(encoding="utf-8")
local_templates = (
Path(__file__).resolve().parents[2]
/ "a2a"
/ "a2a_pack"
/ "cli"
/ "templates"
)
if local_templates.exists():
text = local_templates.joinpath(template).read_text(encoding="utf-8")
else:
text = (
resources.files("a2a_pack.cli.templates")
.joinpath(template)
.read_text(encoding="utf-8")
)
for key, value in vars.items():
text = text.replace("{{ " + key + " }}", value)
return text

View File

@@ -1,4 +1,4 @@
a2a-pack>=0.1.4
a2a-pack>=0.1.8
httpx>=0.27
boto3>=1.34
deepagents>=0.5.0

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
from pathlib import Path
import unittest
from agent_builder.builder import (
@@ -12,6 +13,15 @@ from deepagents.backends import StateBackend
class BuilderPromptTests(unittest.TestCase):
def test_outer_agent_uses_500_recursion_limit(self) -> None:
agent_source = Path(__file__).resolve().parents[1].joinpath("agent.py").read_text()
self.assertIn("DEEPAGENTS_RECURSION_LIMIT = 500", agent_source)
self.assertIn(
"config={\"recursion_limit\": DEEPAGENTS_RECURSION_LIMIT}",
agent_source,
)
def test_prompt_requires_resource_mirror_for_heavy_agents(self) -> None:
self.assertIn("declare resources in BOTH places", SYSTEM_PROMPT)
self.assertIn("resources = Resources", SYSTEM_PROMPT)
@@ -23,6 +33,13 @@ class BuilderPromptTests(unittest.TestCase):
self.assertIn("class GeneratedDeck(Slide)", SYSTEM_PROMPT)
self.assertIn("manim-slides render --CE -ql", SYSTEM_PROMPT)
self.assertIn("render: bool = False", SYSTEM_PROMPT)
self.assertIn('config={"recursion_limit": 500}', SYSTEM_PROMPT)
def test_prompt_requires_workspace_mounted_execution_for_outputs(self) -> None:
self.assertIn("ctx.workspace_shell", SYSTEM_PROMPT)
self.assertIn("ctx.workspace_python", SYSTEM_PROMPT)
self.assertIn("/workspace/outputs/...", SYSTEM_PROMPT)
self.assertIn("not workspace-mounted", SYSTEM_PROMPT)
def test_prompt_and_backend_load_packaged_builder_skills(self) -> None:
expected = {
@@ -44,9 +61,14 @@ class BuilderPromptTests(unittest.TestCase):
)
self.assertIn("skills=[RUNTIME_SKILLS_ROOT]", files["deepagent-agent-design/SKILL.md"])
self.assertIn("from a2a_pack import", files["a2apack-agent-authoring/SKILL.md"])
self.assertIn("ctx.workspace_shell", files["a2apack-agent-authoring/SKILL.md"])
self.assertIn("create_deep_agent", files["deepagents-implementation-patterns/SKILL.md"])
self.assertIn('config={"recursion_limit": 500}', files["deepagents-implementation-patterns/SKILL.md"])
self.assertIn("ctx.write_artifact", files["workspace-artifact-safety/SKILL.md"])
self.assertIn("ctx.workspace_shell", files["workspace-artifact-safety/SKILL.md"])
self.assertIn("/workspace/outputs/...", files["workspace-artifact-safety/SKILL.md"])
self.assertIn("live_skills[].input_schema", files["agent-quality-review/SKILL.md"])
self.assertIn("ctx.workspace_python", files["agent-quality-review/SKILL.md"])
backend = _with_builder_skills(StateBackend())
listing = backend.ls(BUILDER_SKILL_SOURCE)

View File

@@ -30,6 +30,11 @@ class TemplateInitTests(unittest.TestCase):
self.assertIn("LLMProvisioning.CALLER_PROVIDED", files["agent.py"])
self.assertIn("WorkspaceAccess.dynamic", files["agent.py"])
self.assertIn("RUNTIME_SKILLS_ROOT", files["agent.py"])
self.assertIn("DEEPAGENTS_RECURSION_LIMIT = 500", files["agent.py"])
self.assertIn(
'config={"recursion_limit": DEEPAGENTS_RECURSION_LIMIT}',
files["agent.py"],
)
self.assertIn("skills=skill_sources or None", files["agent.py"])
self.assertIn("ctx.llm", files["agent.py"])
self.assertIn("ctx.workspace_backend()", files["agent.py"])