From d0fa3f3f5f2ff2d8dd9a3b2c98ad570915730e9f Mon Sep 17 00:00:00 2001 From: robert Date: Tue, 19 May 2026 11:28:37 -0300 Subject: [PATCH] builder: require durable workspace execution --- agent.py | 11 ++--- agent_builder/builder.py | 20 ++++++++-- .../skills/a2apack-agent-authoring/SKILL.md | 14 ++++++- .../skills/agent-quality-review/SKILL.md | 3 ++ .../skills/deepagent-agent-design/SKILL.md | 9 +++++ .../SKILL.md | 15 +++++++ .../skills/workspace-artifact-safety/SKILL.md | 40 +++++++++---------- agent_builder/tools.py | 19 +++++++-- requirements.txt | 2 +- tests/test_builder_prompt.py | 22 ++++++++++ tests/test_template_init.py | 5 +++ 11 files changed, 126 insertions(+), 34 deletions(-) diff --git a/agent.py b/agent.py index 3f208ff..892f631 100644 --- a/agent.py +++ b/agent.py @@ -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 "" diff --git a/agent_builder/builder.py b/agent_builder/builder.py index b6556bf..137dfb0 100644 --- a/agent_builder/builder.py +++ b/agent_builder/builder.py @@ -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 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. diff --git a/agent_builder/skills/a2apack-agent-authoring/SKILL.md b/agent_builder/skills/a2apack-agent-authoring/SKILL.md index ce25763..4d5b56f 100644 --- a/agent_builder/skills/a2apack-agent-authoring/SKILL.md +++ b/agent_builder/skills/a2apack-agent-authoring/SKILL.md @@ -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 diff --git a/agent_builder/skills/agent-quality-review/SKILL.md b/agent_builder/skills/agent-quality-review/SKILL.md index 4c645c2..779da3f 100644 --- a/agent_builder/skills/agent-quality-review/SKILL.md +++ b/agent_builder/skills/agent-quality-review/SKILL.md @@ -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. diff --git a/agent_builder/skills/deepagent-agent-design/SKILL.md b/agent_builder/skills/deepagent-agent-design/SKILL.md index 03f8f6e..c036f05 100644 --- a/agent_builder/skills/deepagent-agent-design/SKILL.md +++ b/agent_builder/skills/deepagent-agent-design/SKILL.md @@ -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. diff --git a/agent_builder/skills/deepagents-implementation-patterns/SKILL.md b/agent_builder/skills/deepagents-implementation-patterns/SKILL.md index fc6c2ad..e964291 100644 --- a/agent_builder/skills/deepagents-implementation-patterns/SKILL.md +++ b/agent_builder/skills/deepagents-implementation-patterns/SKILL.md @@ -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. diff --git a/agent_builder/skills/workspace-artifact-safety/SKILL.md b/agent_builder/skills/workspace-artifact-safety/SKILL.md index 70a34cb..41b0633 100644 --- a/agent_builder/skills/workspace-artifact-safety/SKILL.md +++ b/agent_builder/skills/workspace-artifact-safety/SKILL.md @@ -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. diff --git a/agent_builder/tools.py b/agent_builder/tools.py index d1bdc98..880c2d8 100644 --- a/agent_builder/tools.py +++ b/agent_builder/tools.py @@ -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 diff --git a/requirements.txt b/requirements.txt index b37ca6f..5a0c646 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -a2a-pack>=0.1.4 +a2a-pack>=0.1.8 httpx>=0.27 boto3>=1.34 deepagents>=0.5.0 diff --git a/tests/test_builder_prompt.py b/tests/test_builder_prompt.py index 2e53649..d03a47f 100644 --- a/tests/test_builder_prompt.py +++ b/tests/test_builder_prompt.py @@ -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) diff --git a/tests/test_template_init.py b/tests/test_template_init.py index 4f26ce0..c58923e 100644 --- a/tests/test_template_init.py +++ b/tests/test_template_init.py @@ -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"])