Exclude cache artifacts from builder source packages
Some checks failed
build / build (push) Failing after 2s
Some checks failed
build / build (push) Failing after 2s
This commit is contained in:
2
agent.py
2
agent.py
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user