Teach builder to author DeepAgents skills
All checks were successful
build / build (push) Successful in 26s

This commit is contained in:
robert
2026-05-19 09:39:29 -03:00
parent e1d9eab879
commit f6c612a071
10 changed files with 857 additions and 13 deletions

View File

@@ -92,7 +92,9 @@ def _ensure_bucket(s3: Any, bucket: str) -> None:
_AGENT_NAME_RE = re.compile(r"^[a-z][a-z0-9-]{1,62}$")
_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_INTERNAL_PREFIX = ".agent-builder/"
def _validate_name(name: str) -> None:
@@ -102,6 +104,14 @@ def _validate_name(name: str) -> None:
)
def _validate_skill_name(name: str) -> None:
if not _SKILL_NAME_RE.match(name) or "--" in name:
raise ValueError(
"invalid skill name "
f"{name!r}: use lowercase alphanumeric kebab-case, max 64 chars"
)
def _agent_prefix(name: str) -> str:
_validate_name(name)
return f"agents/{name}/"
@@ -162,7 +172,7 @@ def build_tools(ctx: ToolContext) -> list[Any]:
for page in paginator.paginate(Bucket=bucket, Prefix=prefix):
for obj in page.get("Contents") or ():
rel = obj["Key"][len(prefix):]
if rel == _BUILDER_STATE_FILE:
if rel == _BUILDER_STATE_FILE or rel.startswith(_BUILDER_INTERNAL_PREFIX):
continue
out.append({
"path": rel,
@@ -182,8 +192,8 @@ def build_tools(ctx: ToolContext) -> list[Any]:
except ValueError as exc:
return json.dumps({"error": str(exc)})
rel = path.lstrip("/")
if rel == _BUILDER_STATE_FILE:
return json.dumps({"error": f"{_BUILDER_STATE_FILE} is managed by deploys"})
if rel == _BUILDER_STATE_FILE or rel.startswith(_BUILDER_INTERNAL_PREFIX):
return json.dumps({"error": f"{rel} is managed by agent-builder"})
key = prefix + rel
s3.put_object(
Bucket=bucket, Key=key, Body=content.encode("utf-8"),
@@ -199,8 +209,8 @@ def build_tools(ctx: ToolContext) -> list[Any]:
except ValueError as exc:
return json.dumps({"error": str(exc)})
rel = path.lstrip("/")
if rel == _BUILDER_STATE_FILE:
return json.dumps({"error": f"{_BUILDER_STATE_FILE} is managed by deploys"})
if rel == _BUILDER_STATE_FILE or rel.startswith(_BUILDER_INTERNAL_PREFIX):
return json.dumps({"error": f"{rel} is managed by agent-builder"})
key = prefix + rel
try:
obj = s3.get_object(Bucket=bucket, Key=key)
@@ -209,6 +219,59 @@ def build_tools(ctx: ToolContext) -> list[Any]:
body = obj["Body"].read().decode("utf-8", errors="replace")
return json.dumps({"path": key, "content": body[:200_000]})
@tool
def write_agent_skill(
name: str,
skill_name: str,
description: str,
instructions: str,
supporting_files_json: str = "{}",
) -> str:
"""Create a DeepAgents skill bundle in ``agents/<name>/skills/``.
Use this for generated agents that should rely on DeepAgents'
progressive-disclosure skills instead of fake one-off tools.
``supporting_files_json`` is a JSON object mapping relative paths
inside the skill directory to text content, for example
``{"references/schema.md": "..."}``.
After writing skills, update ``agent.py`` so it seeds packaged
``skills/`` files into ``ctx.workspace_backend()`` and passes
``skills=[...]`` to ``create_deep_agent``.
"""
try:
prefix = _agent_prefix(name)
_validate_skill_name(skill_name)
files = _parse_supporting_skill_files(supporting_files_json)
skill_md = _render_skill_md(skill_name, description, instructions)
except ValueError as exc:
return json.dumps({"error": str(exc)})
base = prefix + f"skills/{skill_name}/"
written: list[dict[str, Any]] = []
payloads = {"SKILL.md": skill_md, **files}
for rel, content in payloads.items():
key = base + rel
encoded = content.encode("utf-8")
s3.put_object(
Bucket=bucket,
Key=key,
Body=encoded,
ContentType="text/plain; charset=utf-8",
)
written.append({"path": key, "size": len(encoded)})
return json.dumps({
"ok": True,
"agent": name,
"skill": skill_name,
"source_path": f"skills/{skill_name}/",
"files": written,
"wire_agent_py": (
"Seed project skills into ctx.workspace_backend() and pass "
"skills=[RUNTIME_SKILLS_ROOT] to create_deep_agent."
),
})
@tool
async def test_agent_in_sandbox(name: str) -> str:
"""Spin a microVM, ``pip install a2a-pack`` + the agent's own
@@ -583,7 +646,7 @@ def build_tools(ctx: ToolContext) -> list[Any]:
return [
init_agent_template, list_agent_files, write_agent_file, read_agent_file,
test_agent_in_sandbox, cp_deploy_tarball, cp_refresh_agent,
write_agent_skill, test_agent_in_sandbox, cp_deploy_tarball, cp_refresh_agent,
sync_agent_workspace_from_repo,
list_a2a_pack, read_a2a_pack,
]
@@ -605,7 +668,7 @@ def _tarball_workspace_dir(s3: Any, bucket: str, prefix: str) -> bytes:
rel = key[len(prefix):]
if not rel:
continue
if rel == _BUILDER_STATE_FILE:
if rel == _BUILDER_STATE_FILE or rel.startswith(_BUILDER_INTERNAL_PREFIX):
continue
body = s3.get_object(Bucket=bucket, Key=key)["Body"].read()
info = tarfile.TarInfo(name=rel)
@@ -646,6 +709,59 @@ def _write_builder_state(
)
def _render_skill_md(skill_name: str, description: str, instructions: str) -> str:
desc = " ".join(description.strip().split())
body = instructions.strip()
if not desc:
raise ValueError("skill description is required")
if len(desc) > 1024:
raise ValueError("skill description must be 1024 characters or less")
if not body:
raise ValueError("skill instructions are required")
if "\n---" in body or body.startswith("---"):
raise ValueError("skill instructions must not contain frontmatter delimiters")
return (
"---\n"
f"name: {skill_name}\n"
"description: "
+ json.dumps(desc)
+ "\n"
"---\n"
f"# {skill_name}\n\n"
+ body
+ "\n"
)
def _parse_supporting_skill_files(raw: str) -> dict[str, str]:
try:
parsed = json.loads(raw or "{}")
except (TypeError, ValueError) as exc:
raise ValueError("supporting_files_json must be a JSON object") from exc
if not isinstance(parsed, dict):
raise ValueError("supporting_files_json must be a JSON object")
out: dict[str, str] = {}
for path, content in parsed.items():
if not isinstance(path, str) or not isinstance(content, str):
raise ValueError("supporting skill file paths and contents must be strings")
rel = _safe_supporting_skill_path(path)
if rel == "SKILL.md":
raise ValueError("supporting_files_json cannot override SKILL.md")
out[rel] = content
return out
def _safe_supporting_skill_path(path: str) -> str:
rel = path.replace("\\", "/").lstrip("/")
while rel.startswith("./"):
rel = rel[2:]
if not rel or ".." in rel.split("/") or rel.startswith("."):
raise ValueError(f"unsafe supporting skill file path: {path!r}")
if rel.endswith("/") or "//" in rel:
raise ValueError(f"unsafe supporting skill file path: {path!r}")
return rel
def _delete_workspace_objects(s3: Any, bucket: str, prefix: str) -> None:
paginator = s3.get_paginator("list_objects_v2")
batch: list[dict[str, str]] = []