Teach builder to author DeepAgents skills
All checks were successful
build / build (push) Successful in 26s
All checks were successful
build / build (push) Successful in 26s
This commit is contained in:
@@ -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]] = []
|
||||
|
||||
Reference in New Issue
Block a user