builder: require durable workspace execution
Some checks failed
build / build (push) Failing after 2s
Some checks failed
build / build (push) Failing after 2s
This commit is contained in:
11
agent.py
11
agent.py
@@ -35,6 +35,7 @@ class BuilderConfig(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
_URL_RE = re.compile(r"https?://[a-zA-Z0-9._-]+\.[a-zA-Z]{2,}[\w/?#%&=.-]*")
|
_URL_RE = re.compile(r"https?://[a-zA-Z0-9._-]+\.[a-zA-Z]{2,}[\w/?#%&=.-]*")
|
||||||
|
DEEPAGENTS_RECURSION_LIMIT = 500
|
||||||
|
|
||||||
|
|
||||||
class AgentBuilder(A2AAgent[BuilderConfig, NoAuth]):
|
class AgentBuilder(A2AAgent[BuilderConfig, NoAuth]):
|
||||||
@@ -130,11 +131,11 @@ class AgentBuilder(A2AAgent[BuilderConfig, NoAuth]):
|
|||||||
async for event in graph.astream_events(
|
async for event in graph.astream_events(
|
||||||
{"messages": [{"role": "user", "content": user_msg}]},
|
{"messages": [{"role": "user", "content": user_msg}]},
|
||||||
version="v2",
|
version="v2",
|
||||||
# Default langgraph cap is 25 steps; one deepagents
|
# Agent builds can legitimately run many tool/subagent turns:
|
||||||
# build (list → write*3 → test → deploy) easily uses
|
# scaffold, write skills/files, test, fix, retest, deploy, and
|
||||||
# 12-18, and any retry pushes it past the limit. Give
|
# verify the live card. Keep this aligned with generated child
|
||||||
# the loop headroom without letting it run away.
|
# agents so complex DeepAgents workflows have the same budget.
|
||||||
config={"recursion_limit": 80},
|
config={"recursion_limit": DEEPAGENTS_RECURSION_LIMIT},
|
||||||
):
|
):
|
||||||
kind = event.get("event")
|
kind = event.get("event")
|
||||||
tname = event.get("name") or ""
|
tname = event.get("name") or ""
|
||||||
|
|||||||
@@ -93,13 +93,18 @@ runtime:
|
|||||||
max_runtime_seconds: 900
|
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
|
exception handling, and failures must return structured JSON instead of
|
||||||
crashing the worker. Use ``render: bool = False`` by default and expose every
|
crashing the worker. Use ``render: bool = False`` by default and expose every
|
||||||
public skill argument with concrete type hints so the live ``input_schema``
|
public skill argument with concrete type hints so the live ``input_schema``
|
||||||
contains the full call surface. If a render command returns nonzero after
|
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
|
emitting a usable file, preserve the file under ``/workspace/outputs/...`` and
|
||||||
failure as a warning instead of dropping the result.
|
include the command failure as a warning instead of dropping the result.
|
||||||
|
|
||||||
For Manim presentation agents specifically, generate a stable PDF fallback
|
For Manim presentation agents specifically, generate a stable PDF fallback
|
||||||
that does not require Manim rendering. If HTML/video rendering is requested,
|
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-slides render --CE -ql <source.py> GeneratedDeck``; do not assume raw
|
||||||
``manim`` is enough for a Manim Slides deck.
|
``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
|
And a ``requirements.txt`` listing any extra deps beyond a2a-pack
|
||||||
itself (pandas, httpx, etc. — the base image ships a2a-pack already
|
itself (pandas, httpx, etc. — the base image ships a2a-pack already
|
||||||
when you deploy through the control plane).
|
when you deploy through the control plane).
|
||||||
@@ -177,6 +188,9 @@ Discipline:
|
|||||||
Do not replace LLM reasoning with fake deterministic tools that return
|
Do not replace LLM reasoning with fake deterministic tools that return
|
||||||
canned answers. Tools should do exact work; skills and subagents should
|
canned answers. Tools should do exact work; skills and subagents should
|
||||||
carry reusable workflow knowledge and judgement-heavy behavior.
|
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
|
- If generated agent.py calls ``ctx.workspace_backend()``, declare
|
||||||
``workspace_access = WorkspaceAccess.dynamic(...)`` on the A2A agent class
|
``workspace_access = WorkspaceAccess.dynamic(...)`` on the A2A agent class
|
||||||
so local/dev/platform invocations actually receive a workspace grant.
|
so local/dev/platform invocations actually receive a workspace grant.
|
||||||
|
|||||||
@@ -101,7 +101,8 @@ class ResearchAgent(A2AAgent[ResearchConfig, NoAuth]):
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
|
config={"recursion_limit": 500},
|
||||||
)
|
)
|
||||||
report_text = _last_message_text(state)
|
report_text = _last_message_text(state)
|
||||||
ref = await ctx.write_artifact(
|
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
|
`workspace_access`. If it writes user-visible files, also emit artifacts with
|
||||||
`ctx.write_artifact` and `ctx.emit_artifact`.
|
`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`:
|
If an agent needs system binaries, mirror the need in `a2a.yaml`:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ Check these items:
|
|||||||
- Custom subagents that need project skills include their own `skills` field.
|
- Custom subagents that need project skills include their own `skills` field.
|
||||||
- Deterministic tools do exact work; no fake canned-response tools.
|
- Deterministic tools do exact work; no fake canned-response tools.
|
||||||
- File-producing skills call `ctx.write_artifact` and `ctx.emit_artifact`.
|
- 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
|
- External calls, secrets, workspace access, pricing, and resources are
|
||||||
declared on the class.
|
declared on the class.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
Because this uses `/outputs/...`, the write fits the normal A2A workspace
|
||||||
grant. If the agent uses `ctx.workspace_backend()`, also declare
|
grant. If the agent uses `ctx.workspace_backend()`, also declare
|
||||||
`workspace_access = WorkspaceAccess.dynamic(...)` on the A2A agent class.
|
`workspace_access = WorkspaceAccess.dynamic(...)` on the A2A agent class.
|
||||||
|
|||||||
@@ -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
|
Use `skills=skill_sources or None`, not an empty list, so agents without a
|
||||||
`skills/` directory still build cleanly.
|
`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.
|
||||||
|
|||||||
@@ -55,33 +55,33 @@ ref = await ctx.write_artifact("frames.zip", zip_bytes, "application/zip")
|
|||||||
await ctx.emit_artifact(ref)
|
await ctx.emit_artifact(ref)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Bounded Subprocesses
|
## Workspace-Mounted Execution
|
||||||
|
|
||||||
Media, rendering, archive, and data conversion agents must not run unbounded
|
Media, rendering, archive, and data conversion agents must run commands through
|
||||||
commands. Use explicit timeouts and return structured errors:
|
the workspace-mounted sandbox helpers when those commands create files:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import asyncio
|
async def run_checked(script: str, timeout_s: int = 120) -> dict[str, str | int]:
|
||||||
|
result = await ctx.workspace_shell(
|
||||||
|
script,
|
||||||
async def run_checked(cmd: list[str], timeout_s: int = 120) -> dict[str, str | int]:
|
image="python:3.11-slim",
|
||||||
try:
|
timeout_seconds=timeout_s,
|
||||||
proc = await asyncio.create_subprocess_exec(
|
memory_mib=1024,
|
||||||
*cmd,
|
cpus=1,
|
||||||
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}
|
|
||||||
return {
|
return {
|
||||||
"ok": 1 if proc.returncode == 0 else 0,
|
"ok": 1 if result.ok else 0,
|
||||||
"returncode": proc.returncode,
|
"returncode": result.exit_code,
|
||||||
"stdout": stdout.decode("utf-8", errors="replace")[-4000:],
|
"stdout": result.stdout[-4000:],
|
||||||
"stderr": stderr.decode("utf-8", errors="replace")[-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
|
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.
|
include the command failure as a warning instead of discarding the file.
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import tarfile
|
|||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from importlib import resources
|
from importlib import resources
|
||||||
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
import boto3
|
import boto3
|
||||||
@@ -927,11 +928,21 @@ def _render_a2a_init_template(
|
|||||||
|
|
||||||
|
|
||||||
def _render_sdk_template(template: str, /, **vars: str) -> str:
|
def _render_sdk_template(template: str, /, **vars: str) -> str:
|
||||||
text = (
|
local_templates = (
|
||||||
resources.files("a2a_pack.cli.templates")
|
Path(__file__).resolve().parents[2]
|
||||||
.joinpath(template)
|
/ "a2a"
|
||||||
.read_text(encoding="utf-8")
|
/ "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():
|
for key, value in vars.items():
|
||||||
text = text.replace("{{ " + key + " }}", value)
|
text = text.replace("{{ " + key + " }}", value)
|
||||||
return text
|
return text
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
a2a-pack>=0.1.4
|
a2a-pack>=0.1.8
|
||||||
httpx>=0.27
|
httpx>=0.27
|
||||||
boto3>=1.34
|
boto3>=1.34
|
||||||
deepagents>=0.5.0
|
deepagents>=0.5.0
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from agent_builder.builder import (
|
from agent_builder.builder import (
|
||||||
@@ -12,6 +13,15 @@ from deepagents.backends import StateBackend
|
|||||||
|
|
||||||
|
|
||||||
class BuilderPromptTests(unittest.TestCase):
|
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:
|
def test_prompt_requires_resource_mirror_for_heavy_agents(self) -> None:
|
||||||
self.assertIn("declare resources in BOTH places", SYSTEM_PROMPT)
|
self.assertIn("declare resources in BOTH places", SYSTEM_PROMPT)
|
||||||
self.assertIn("resources = Resources", 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("class GeneratedDeck(Slide)", SYSTEM_PROMPT)
|
||||||
self.assertIn("manim-slides render --CE -ql", SYSTEM_PROMPT)
|
self.assertIn("manim-slides render --CE -ql", SYSTEM_PROMPT)
|
||||||
self.assertIn("render: bool = False", 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:
|
def test_prompt_and_backend_load_packaged_builder_skills(self) -> None:
|
||||||
expected = {
|
expected = {
|
||||||
@@ -44,9 +61,14 @@ class BuilderPromptTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertIn("skills=[RUNTIME_SKILLS_ROOT]", files["deepagent-agent-design/SKILL.md"])
|
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("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("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.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("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())
|
backend = _with_builder_skills(StateBackend())
|
||||||
listing = backend.ls(BUILDER_SKILL_SOURCE)
|
listing = backend.ls(BUILDER_SKILL_SOURCE)
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ class TemplateInitTests(unittest.TestCase):
|
|||||||
self.assertIn("LLMProvisioning.CALLER_PROVIDED", files["agent.py"])
|
self.assertIn("LLMProvisioning.CALLER_PROVIDED", files["agent.py"])
|
||||||
self.assertIn("WorkspaceAccess.dynamic", files["agent.py"])
|
self.assertIn("WorkspaceAccess.dynamic", files["agent.py"])
|
||||||
self.assertIn("RUNTIME_SKILLS_ROOT", 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("skills=skill_sources or None", files["agent.py"])
|
||||||
self.assertIn("ctx.llm", files["agent.py"])
|
self.assertIn("ctx.llm", files["agent.py"])
|
||||||
self.assertIn("ctx.workspace_backend()", files["agent.py"])
|
self.assertIn("ctx.workspace_backend()", files["agent.py"])
|
||||||
|
|||||||
Reference in New Issue
Block a user