Add conversational meta-agent compose tool
All checks were successful
build / build (push) Successful in 15s

This commit is contained in:
robert
2026-06-01 22:47:40 -03:00
parent c2ad8eca4c
commit d665563953
5 changed files with 327 additions and 7 deletions

View File

@@ -136,9 +136,37 @@ runtime:
resources:
cpu: "2"
memory: 2Gi
max_runtime_seconds: 900
max_runtime_seconds: 900
```
When the user asks to compose existing agents, build a declarative meta-agent
manifest instead of hand-writing orchestration code. The source of truth is a
JSON object with ``composition`` plus optional ``goal`` and ``memory``:
```json
{
"composition": {
"sub_agents": [
{"name": "writer-agent", "skills": ["draft"]},
{"tag": "charting", "skills": ["render_chart"], "required": false}
],
"max_nodes": 6,
"max_parallel": 2,
"max_replans": 1
},
"goal": {
"objective": "Ship a launch report",
"success_criteria": ["draft complete", "chart complete"]
},
"memory": {"tiers": ["files", "kv"], "namespace": "launch-report"}
}
```
Deploy that manifest with ``cp_compose_meta_agent``. That endpoint validates
children against the registry, generates editable ``MetaAgent`` source, commits
it, and deploys it through the same GitOps path. Use this path for
meta-agents unless the user explicitly asks for custom source.
For render/media/data agents, commands that create user-visible files MUST run
through ``await ctx.workspace_shell(...)`` or ``await ctx.workspace_python(...)``.
The platform sandbox persists ``/workspace`` writes directly and captures changed
@@ -202,6 +230,13 @@ Your tools:
elsewhere; use force=True only after
the user explicitly accepts replacing
the current repo source.
- cp_compose_meta_agent(name, manifest_json, description="", version="0.1.0",
public=True, refresh_existing=False)
— create/deploy a manifest-backed
meta-agent that composes existing agents.
Use for "compose these agents toward this
goal" requests instead of hand-writing
orchestration source.
- sync_agent_workspace_from_repo(name)
— replace agents/<name>/ in MinIO with
the current managed repo source and
@@ -234,6 +269,9 @@ Discipline:
``csv-sanitizer``). Class name is PascalCase from the slug.
- For a new project, call init_agent_template first. Then read/edit the
generated files instead of inventing boilerplate from memory.
- For a new meta-agent that only composes existing agents, call
cp_compose_meta_agent with a manifest. Do not scaffold a normal project
unless the user needs custom code beyond composition.
- Ensure all three core files (agent.py, a2a.yaml, requirements.txt)
exist before testing — partial scaffolds break ``a2a card``.
- When the user asks for a usable app, dashboard, charting UI, workflow UI,

View File

@@ -3,6 +3,7 @@
- workspace file CRUD through the caller's scoped workspace grant
- sandbox python (for `a2a card` / `a2a validate` round-trips)
- cp_deploy_tarball (call /v1/agents/from-tarball on the user's behalf)
- cp_compose_meta_agent (call /v1/agents/compose with a manifest)
"""
from __future__ import annotations
@@ -28,7 +29,7 @@ if TYPE_CHECKING:
from .config import Settings
A2A_PACK_MIN_VERSION = "0.1.41"
A2A_PACK_MIN_VERSION = "0.1.47"
@dataclass(frozen=True)
@@ -579,6 +580,108 @@ def build_tools(ctx: ToolContext) -> list[Any]:
out["last_seen_version"] = live_card.get("version")
return json.dumps(out)
@tool
async def cp_compose_meta_agent(
name: str,
manifest_json: str,
description: str = "",
version: str = "0.1.0",
public: bool = True,
refresh_existing: bool = False,
) -> str:
"""Deploy a declarative meta-agent composition through the control plane.
Use this when the user asks to compose existing agents toward a goal.
``manifest_json`` must be a JSON object with ``composition`` and
optional ``goal`` / ``memory`` blocks. The control plane validates
referenced sub-agents and skills, generates editable MetaAgent source,
commits it to the user's managed source repo, and stamps the runtime
repo. This is the preferred path for meta-agents; do not hand-write
orchestration source when a manifest is enough.
"""
try:
prefix = _agent_prefix(name)
manifest = _parse_manifest_json(manifest_json)
except ValueError as exc:
return json.dumps({"error": str(exc)})
if not ctx.cp_jwt:
return json.dumps({
"error": "no CP JWT forwarded — agent declaration missing wants_cp_jwt=True",
})
body = {
"name": name,
"description": description,
"version": version,
"public": public,
"manifest": manifest,
"refresh_existing": refresh_existing,
}
try:
async with httpx.AsyncClient(timeout=60.0) as c:
r = await c.post(
f"{settings.cp_url}/v1/agents/compose",
headers={"authorization": f"bearer {ctx.cp_jwt}"},
json=body,
)
except httpx.HTTPError as exc:
return json.dumps({"error": f"cp unreachable: {exc}"})
if r.status_code >= 400:
return json.dumps({"error": f"cp {r.status_code}", "detail": r.text[:1000]})
payload = r.json() or {}
head_sha = payload.get("head_sha")
store.put(
prefix + "meta_agent_manifest.json",
(json.dumps(manifest, indent=2, sort_keys=True) + "\n").encode("utf-8"),
"application/json",
)
if isinstance(head_sha, str) and head_sha:
_write_builder_state(
store,
name,
{
"agent": name,
"repo_head_sha": head_sha,
"deployment_id": payload.get("deployment_id"),
"version": payload.get("version"),
"updated_at": int(time.time()),
"source": "agent-builder-compose",
},
)
url = payload.get("expected_url") or payload.get("url")
live, live_card = await _wait_for_live_card(
url,
payload.get("version"),
timeout_s=settings.deploy_wait_timeout_s,
)
out: dict[str, Any] = {
"ok": live,
"name": payload.get("name"),
"version": payload.get("version"),
"status": payload.get("status"),
"url": url,
"head_sha": head_sha,
"deployment_id": payload.get("deployment_id"),
"preview": payload.get("preview") or {},
"live": live,
}
if live:
out["live_version"] = live_card.get("version")
out["live_skills"] = [
{
"name": s.get("name"),
"input_schema": s.get("input_schema") or {},
}
for s in (live_card.get("skills") or [])
]
else:
out["error"] = (
"compose deploy did not become live within timeout — build may "
"still be rolling. Inspect deployment_id or call cp_refresh_agent later."
)
if live_card:
out["last_seen_version"] = live_card.get("version")
return json.dumps(out)
@tool
async def cp_refresh_agent(name: str) -> str:
"""Force the control plane to re-fetch the agent's live
@@ -759,7 +862,8 @@ def build_tools(ctx: ToolContext) -> list[Any]:
return [
init_agent_template, list_agent_files, write_agent_file, read_agent_file,
write_agent_skill, test_agent_in_sandbox, cp_deploy_tarball, cp_refresh_agent,
write_agent_skill, test_agent_in_sandbox, cp_deploy_tarball,
cp_compose_meta_agent, cp_refresh_agent,
sync_agent_workspace_from_repo,
list_a2a_pack, read_a2a_pack,
]
@@ -810,6 +914,19 @@ def _write_builder_state(
store.put(_builder_state_key(name), body, "application/json")
def _parse_manifest_json(raw: str) -> dict[str, Any]:
try:
parsed = json.loads(raw or "{}")
except (TypeError, ValueError) as exc:
raise ValueError("manifest_json must be a JSON object") from exc
if not isinstance(parsed, dict):
raise ValueError("manifest_json must be a JSON object")
composition = parsed.get("composition")
if not isinstance(composition, (dict, list)):
raise ValueError("manifest_json requires a composition object or list")
return parsed
def _render_skill_md(skill_name: str, description: str, instructions: str) -> str:
desc = " ".join(description.strip().split())
body = instructions.strip()