Exclude cache artifacts from builder source packages
Some checks failed
build / build (push) Failing after 2s

This commit is contained in:
robert
2026-06-07 19:54:03 -03:00
parent d7cb4b3f8f
commit aba68aaf2e
5 changed files with 158 additions and 26 deletions

View File

@@ -49,7 +49,7 @@ class AgentBuilder(A2AAgent[BuilderConfig, NoAuth]):
"Writes the project into the user's workspace, validates it in a " "Writes the project into the user's workspace, validates it in a "
"sandbox, then ships it via the control plane." "sandbox, then ships it via the control plane."
) )
version = "0.1.2" version = "0.1.3"
config_model = BuilderConfig config_model = BuilderConfig
auth_model = NoAuth auth_model = NoAuth

View File

@@ -12,7 +12,7 @@ from deepagents.backends.utils import create_file_data
from langgraph.store.memory import InMemoryStore from langgraph.store.memory import InMemoryStore
from .config import Settings, load_settings from .config import Settings, load_settings
from .tools import ToolContext, build_tools from .tools import ToolContext, _should_include_source_path, build_tools
BUILDER_SKILL_SOURCE = "/.agent-builder/skills/" BUILDER_SKILL_SOURCE = "/.agent-builder/skills/"
@@ -430,6 +430,13 @@ def _workspace_backend_with_grep_compat(
""" """
class BuilderWorkspaceBackend(workspace_backend_cls): # type: ignore[misc, valid-type] class BuilderWorkspaceBackend(workspace_backend_cls): # type: ignore[misc, valid-type]
def _all_paths(self) -> list[str]:
return [
path
for path in super()._all_paths()
if _should_include_source_path(path)
]
def grep( def grep(
self, self,
pattern: str, pattern: str,

View File

@@ -12,6 +12,8 @@ import ast
import base64 import base64
import io import io
import json import json
import logging
import os
import re import re
import tarfile import tarfile
import tempfile import tempfile
@@ -31,7 +33,8 @@ if TYPE_CHECKING:
from .config import Settings from .config import Settings
A2A_PACK_MIN_VERSION = "0.1.79" A2A_PACK_MIN_VERSION = "0.1.80"
logger = logging.getLogger(__name__)
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -213,6 +216,17 @@ _SKILL_NAME_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$")
_BUILDER_STATE_FILE = ".a2a-builder-state.json" _BUILDER_STATE_FILE = ".a2a-builder-state.json"
_BUILDER_INTERNAL_PREFIX = ".agent-builder/" _BUILDER_INTERNAL_PREFIX = ".agent-builder/"
_DERIVED_PRICING_FIELDS = frozenset({"compute", "total_usd"}) _DERIVED_PRICING_FIELDS = frozenset({"compute", "total_usd"})
_EXCLUDED_SOURCE_PARTS = frozenset({
"__pycache__",
".git",
".pytest_cache",
".mypy_cache",
".ruff_cache",
".venv",
"node_modules",
})
_EXCLUDED_SOURCE_SUFFIXES = (".pyc", ".pyo", ".pyd")
_EXCLUDED_SOURCE_PART_SUFFIXES = (".egg-info",)
def _validate_name(name: str) -> None: def _validate_name(name: str) -> None:
@@ -239,6 +253,24 @@ def _builder_state_key(name: str) -> str:
return _agent_prefix(name) + _BUILDER_STATE_FILE return _agent_prefix(name) + _BUILDER_STATE_FILE
def _should_include_source_path(path: str) -> bool:
normalized = path.replace("\\", "/").strip("/")
if not normalized:
return False
parts = normalized.split("/")
if any(part in _EXCLUDED_SOURCE_PARTS for part in parts):
return False
if any(part.endswith(_EXCLUDED_SOURCE_PART_SUFFIXES) for part in parts):
return False
return not normalized.endswith(_EXCLUDED_SOURCE_SUFFIXES)
def _should_include_agent_rel_path(rel: str) -> bool:
if rel == _BUILDER_STATE_FILE or rel.startswith(_BUILDER_INTERNAL_PREFIX):
return False
return _should_include_source_path(rel)
def build_tools(ctx: ToolContext) -> list[Any]: def build_tools(ctx: ToolContext) -> list[Any]:
bucket = ctx.bucket bucket = ctx.bucket
settings = ctx.settings settings = ctx.settings
@@ -306,9 +338,14 @@ def build_tools(ctx: ToolContext) -> list[Any]:
out: list[dict[str, Any]] = [] out: list[dict[str, Any]] = []
for key in store.iter_keys(prefix): for key in store.iter_keys(prefix):
rel = key[len(prefix):] rel = key[len(prefix):]
if rel == _BUILDER_STATE_FILE or rel.startswith(_BUILDER_INTERNAL_PREFIX): if not _should_include_agent_rel_path(rel):
continue continue
out.append({"path": rel, "size": len(store.get(key))}) try:
size = len(store.get(key))
except FileNotFoundError:
logger.warning("Skipping disappeared source file during list: %s", key)
continue
out.append({"path": rel, "size": size})
return json.dumps({"agent": name, "files": out}) return json.dumps({"agent": name, "files": out})
@tool @tool
@@ -323,7 +360,7 @@ def build_tools(ctx: ToolContext) -> list[Any]:
except ValueError as exc: except ValueError as exc:
return json.dumps({"error": str(exc)}) return json.dumps({"error": str(exc)})
rel = path.lstrip("/") rel = path.lstrip("/")
if rel == _BUILDER_STATE_FILE or rel.startswith(_BUILDER_INTERNAL_PREFIX): if not _should_include_agent_rel_path(rel):
return json.dumps({"error": f"{rel} is managed by agent-builder"}) return json.dumps({"error": f"{rel} is managed by agent-builder"})
sanitized_fields: tuple[str, ...] = () sanitized_fields: tuple[str, ...] = ()
if rel == "agent.py": if rel == "agent.py":
@@ -349,7 +386,7 @@ def build_tools(ctx: ToolContext) -> list[Any]:
except ValueError as exc: except ValueError as exc:
return json.dumps({"error": str(exc)}) return json.dumps({"error": str(exc)})
rel = path.lstrip("/") rel = path.lstrip("/")
if rel == _BUILDER_STATE_FILE or rel.startswith(_BUILDER_INTERNAL_PREFIX): if not _should_include_agent_rel_path(rel):
return json.dumps({"error": f"{rel} is managed by agent-builder"}) return json.dumps({"error": f"{rel} is managed by agent-builder"})
key = prefix + rel key = prefix + rel
try: try:
@@ -430,6 +467,7 @@ def build_tools(ctx: ToolContext) -> list[Any]:
b64 = base64.b64encode(bundle_bytes).decode("ascii") b64 = base64.b64encode(bundle_bytes).decode("ascii")
script = ( script = (
"set -e\n" "set -e\n"
"export PYTHONDONTWRITEBYTECODE=1\n"
f"pip install --quiet 'a2a-pack>={A2A_PACK_MIN_VERSION}' >/dev/null\n" f"pip install --quiet 'a2a-pack>={A2A_PACK_MIN_VERSION}' >/dev/null\n"
"mkdir -p /tmp/agent\n" "mkdir -p /tmp/agent\n"
f"echo '{b64}' | base64 -d | tar -xzf - -C /tmp/agent\n" f"echo '{b64}' | base64 -d | tar -xzf - -C /tmp/agent\n"
@@ -949,11 +987,13 @@ def _tarball_workspace_dir(
with tarfile.open(fileobj=buf, mode="w:gz") as tf: with tarfile.open(fileobj=buf, mode="w:gz") as tf:
for key in store.iter_keys(prefix): for key in store.iter_keys(prefix):
rel = key[len(prefix):] rel = key[len(prefix):]
if not rel: if not _should_include_agent_rel_path(rel):
continue
if rel == _BUILDER_STATE_FILE or rel.startswith(_BUILDER_INTERNAL_PREFIX):
continue continue
try:
body = store.get(key) body = store.get(key)
except FileNotFoundError:
logger.warning("Skipping disappeared source file during tarball: %s", key)
continue
info = tarfile.TarInfo(name=rel) info = tarfile.TarInfo(name=rel)
info.size = len(body) info.size = len(body)
info.mode = 0o644 info.mode = 0o644
@@ -1165,7 +1205,7 @@ def _files_from_tarball(bundle: bytes) -> dict[str, bytes]:
rel = rel[2:] rel = rel[2:]
if not rel or rel.startswith("/") or ".." in rel.split("/"): if not rel or rel.startswith("/") or ".." in rel.split("/"):
raise ValueError(f"unsafe member path: {member.name}") raise ValueError(f"unsafe member path: {member.name}")
if rel == _BUILDER_STATE_FILE: if not _should_include_agent_rel_path(rel):
continue continue
extracted = tf.extractfile(member) extracted = tf.extractfile(member)
if extracted is None: if extracted is None:
@@ -1177,6 +1217,7 @@ def _files_from_tarball(bundle: bytes) -> dict[str, bytes]:
def _compile_agent_dsl_json(bundle: bytes) -> str: def _compile_agent_dsl_json(bundle: bytes) -> str:
import sys
import yaml import yaml
from a2a_pack import apply_project_manifest, compile_agent_to_dsl from a2a_pack import apply_project_manifest, compile_agent_to_dsl
from a2a_pack.cli.loader import load_agent_class from a2a_pack.cli.loader import load_agent_class
@@ -1197,6 +1238,11 @@ def _compile_agent_dsl_json(bundle: bytes) -> str:
entrypoint = str(cfg.get("entrypoint") or "").strip() entrypoint = str(cfg.get("entrypoint") or "").strip()
if not entrypoint: if not entrypoint:
raise ValueError("a2a.yaml entrypoint is required") raise ValueError("a2a.yaml entrypoint is required")
old_dont_write_bytecode = os.environ.get("PYTHONDONTWRITEBYTECODE")
old_sys_dont_write_bytecode = sys.dont_write_bytecode
os.environ["PYTHONDONTWRITEBYTECODE"] = "1"
sys.dont_write_bytecode = True
try:
cls = load_agent_class(entrypoint, project_dir=root) cls = load_agent_class(entrypoint, project_dir=root)
apply_project_manifest(cls, cfg) apply_project_manifest(cls, cfg)
dsl = compile_agent_to_dsl( dsl = compile_agent_to_dsl(
@@ -1212,6 +1258,12 @@ def _compile_agent_dsl_json(bundle: bytes) -> str:
}, },
}, },
) )
finally:
if old_dont_write_bytecode is None:
os.environ.pop("PYTHONDONTWRITEBYTECODE", None)
else:
os.environ["PYTHONDONTWRITEBYTECODE"] = old_dont_write_bytecode
sys.dont_write_bytecode = old_sys_dont_write_bytecode
return dsl.model_dump_json() return dsl.model_dump_json()

View File

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

View File

@@ -8,6 +8,7 @@ import unittest
from unittest.mock import patch from unittest.mock import patch
from agent_builder.config import Settings from agent_builder.config import Settings
from agent_builder.builder import _workspace_backend_with_grep_compat
from agent_builder.tools import ( from agent_builder.tools import (
_BUILDER_STATE_FILE, _BUILDER_STATE_FILE,
_BUILDER_INTERNAL_PREFIX, _BUILDER_INTERNAL_PREFIX,
@@ -160,6 +161,44 @@ class TemplateInitTests(unittest.TestCase):
with tarfile.open(fileobj=io.BytesIO(bundle), mode="r:gz") as tf: with tarfile.open(fileobj=io.BytesIO(bundle), mode="r:gz") as tf:
self.assertEqual(tf.getnames(), ["agent.py"]) self.assertEqual(tf.getnames(), ["agent.py"])
def test_tarball_workspace_excludes_python_cache_artifacts(self) -> None:
prefix = "agents/demo-agent/"
s3 = _FakeS3({
prefix + "agent.py": b"print('ok')\n",
prefix + "__pycache__/agent.cpython-311.pyc": b"bytecode",
prefix + "helper.pyc": b"bytecode",
prefix + ".pytest_cache/v/cache/nodeids": b"[]",
prefix + "demo_agent.egg-info/PKG-INFO": b"metadata",
})
bundle = _tarball_workspace_dir(s3, "bucket", prefix)
with tarfile.open(fileobj=io.BytesIO(bundle), mode="r:gz") as tf:
self.assertEqual(tf.getnames(), ["agent.py"])
def test_tarball_workspace_skips_disappeared_files(self) -> None:
prefix = "agents/demo-agent/"
store = _FakeStaleStore(
objects={prefix + "agent.py": b"print('ok')\n"},
stale_keys={prefix + "generated.txt"},
)
bundle = _tarball_workspace_dir(store, prefix)
with tarfile.open(fileobj=io.BytesIO(bundle), mode="r:gz") as tf:
self.assertEqual(tf.getnames(), ["agent.py"])
def test_builder_workspace_backend_filters_cache_artifacts(self) -> None:
backend_cls = _workspace_backend_with_grep_compat(
_FakeDeepAgentsBackend,
object(),
)
self.assertEqual(
backend_cls._all_paths(),
["agents/demo-agent/agent.py"],
)
def test_render_skill_md_creates_valid_frontmatter(self) -> None: def test_render_skill_md_creates_valid_frontmatter(self) -> None:
text = _render_skill_md( text = _render_skill_md(
"market-research", "market-research",
@@ -299,6 +338,10 @@ class TemplateInitTests(unittest.TestCase):
self.assertEqual(result["exit_code"], 0) self.assertEqual(result["exit_code"], 0)
self.assertEqual(len(sandbox.calls), 1) self.assertEqual(len(sandbox.calls), 1)
self.assertEqual(sandbox.calls[0]["workspace"], "bucket") self.assertEqual(sandbox.calls[0]["workspace"], "bucket")
self.assertIn(
"export PYTHONDONTWRITEBYTECODE=1",
str(sandbox.calls[0]["script"]),
)
self.assertEqual(_FakeAsyncClient.posts, []) self.assertEqual(_FakeAsyncClient.posts, [])
def test_cp_deploy_tarball_posts_agent_dsl(self) -> None: def test_cp_deploy_tarball_posts_agent_dsl(self) -> None:
@@ -525,6 +568,36 @@ class _FakeS3:
raise AssertionError("batch DeleteObjects should not be used") raise AssertionError("batch DeleteObjects should not be used")
class _FakeStaleStore:
def __init__(self, objects: dict[str, bytes], stale_keys: set[str]) -> None:
self.objects = objects
self.stale_keys = stale_keys
def iter_keys(self, prefix: str) -> list[str]:
return sorted(
key
for key in [*self.objects, *self.stale_keys]
if key.startswith(prefix)
)
def get(self, key: str) -> bytes:
if key in self.stale_keys:
raise FileNotFoundError(key)
return self.objects[key]
class _FakeDeepAgentsBackend:
def __init__(self, workspace: object, **kwargs: object) -> None:
pass
def _all_paths(self) -> list[str]:
return [
"agents/demo-agent/agent.py",
"agents/demo-agent/__pycache__/agent.cpython-311.pyc",
"agents/demo-agent/.pytest_cache/v/cache/nodeids",
]
class _FakePaginator: class _FakePaginator:
def __init__(self, objects: dict[str, bytes]) -> None: def __init__(self, objects: dict[str, bytes]) -> None:
self.objects = objects self.objects = objects