Add conversational meta-agent compose tool
All checks were successful
build / build (push) Successful in 15s
All checks were successful
build / build (push) Successful in 15s
This commit is contained in:
@@ -1,14 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import tarfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from agent_builder.config import Settings
|
||||
from agent_builder.tools import (
|
||||
_BUILDER_STATE_FILE,
|
||||
_BUILDER_INTERNAL_PREFIX,
|
||||
_parse_manifest_json,
|
||||
_deployment_drift_error,
|
||||
_files_from_tarball,
|
||||
_parse_supporting_skill_files,
|
||||
@@ -235,6 +238,124 @@ class TemplateInitTests(unittest.TestCase):
|
||||
self.assertIn("agents/website-scraper/a2a.yaml", workspace.objects)
|
||||
self.assertIn("agents/website-scraper/requirements.txt", workspace.objects)
|
||||
|
||||
def test_parse_manifest_json_requires_object_with_composition(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
_parse_manifest_json("[]")
|
||||
with self.assertRaises(ValueError):
|
||||
_parse_manifest_json('{"goal":"ship"}')
|
||||
|
||||
parsed = _parse_manifest_json('{"composition":[{"name":"writer"}]}')
|
||||
self.assertEqual(parsed["composition"][0]["name"], "writer")
|
||||
|
||||
def test_cp_compose_meta_agent_requires_manifest_object(self) -> None:
|
||||
tools = build_tools(
|
||||
ToolContext(
|
||||
bucket="bucket",
|
||||
settings=_settings(),
|
||||
workspace=_FakeWorkspace(),
|
||||
cp_jwt="jwt-user",
|
||||
)
|
||||
)
|
||||
compose = _tool_by_name(tools, "cp_compose_meta_agent")
|
||||
|
||||
result = json.loads(
|
||||
asyncio.run(
|
||||
compose.ainvoke(
|
||||
{
|
||||
"name": "report-meta",
|
||||
"manifest_json": "[]",
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
self.assertIn("manifest_json must be a JSON object", result["error"])
|
||||
|
||||
def test_cp_compose_meta_agent_posts_manifest_and_records_state(self) -> None:
|
||||
workspace = _FakeWorkspace()
|
||||
tools = build_tools(
|
||||
ToolContext(
|
||||
bucket="bucket",
|
||||
settings=_settings(deploy_wait_timeout_s=0),
|
||||
workspace=workspace,
|
||||
cp_jwt="jwt-user",
|
||||
)
|
||||
)
|
||||
compose = _tool_by_name(tools, "cp_compose_meta_agent")
|
||||
manifest = {
|
||||
"composition": {
|
||||
"sub_agents": [{"name": "writer", "skills": ["draft"]}],
|
||||
"max_nodes": 3,
|
||||
},
|
||||
"goal": {
|
||||
"objective": "Ship a report.",
|
||||
"success_criteria": ["draft complete"],
|
||||
},
|
||||
"memory": {"tiers": ["files", "kv"], "namespace": "report"},
|
||||
}
|
||||
|
||||
_FakeAsyncClient.posts = []
|
||||
with patch("agent_builder.tools.httpx.AsyncClient", _FakeAsyncClient):
|
||||
result = json.loads(
|
||||
asyncio.run(
|
||||
compose.ainvoke(
|
||||
{
|
||||
"name": "report-meta",
|
||||
"manifest_json": json.dumps(manifest),
|
||||
"description": "Report coordinator",
|
||||
"version": "1.2.3",
|
||||
"public": True,
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
self.assertFalse(result["ok"])
|
||||
self.assertEqual(result["name"], "report-meta")
|
||||
self.assertEqual(result["head_sha"], "source-sha")
|
||||
self.assertEqual(len(_FakeAsyncClient.posts), 1)
|
||||
post = _FakeAsyncClient.posts[0]
|
||||
self.assertEqual(post["url"], "http://cp.test/v1/agents/compose")
|
||||
self.assertEqual(post["headers"]["authorization"], "bearer jwt-user")
|
||||
self.assertEqual(post["json"]["name"], "report-meta")
|
||||
self.assertEqual(post["json"]["manifest"], manifest)
|
||||
self.assertTrue(post["json"]["public"])
|
||||
|
||||
manifest_key = "agents/report-meta/meta_agent_manifest.json"
|
||||
self.assertIn(manifest_key, workspace.objects)
|
||||
saved_manifest = json.loads(workspace.objects[manifest_key].decode("utf-8"))
|
||||
self.assertEqual(saved_manifest["goal"]["objective"], "Ship a report.")
|
||||
state = json.loads(
|
||||
workspace.objects[
|
||||
"agents/report-meta/" + _BUILDER_STATE_FILE
|
||||
].decode("utf-8")
|
||||
)
|
||||
self.assertEqual(state["repo_head_sha"], "source-sha")
|
||||
self.assertEqual(state["source"], "agent-builder-compose")
|
||||
|
||||
def test_cp_compose_meta_agent_requires_cp_jwt(self) -> None:
|
||||
tools = build_tools(
|
||||
ToolContext(
|
||||
bucket="bucket",
|
||||
settings=_settings(),
|
||||
workspace=_FakeWorkspace(),
|
||||
)
|
||||
)
|
||||
compose = _tool_by_name(tools, "cp_compose_meta_agent")
|
||||
|
||||
result = json.loads(
|
||||
asyncio.run(
|
||||
compose.ainvoke(
|
||||
{
|
||||
"name": "report-meta",
|
||||
"manifest_json": '{"composition":[{"name":"writer"}]}',
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
self.assertIn("no CP JWT", result["error"])
|
||||
|
||||
|
||||
def _tarball(files: dict[str, str]) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
@@ -315,12 +436,12 @@ def _tool_by_name(tools: list[object], name: str) -> object:
|
||||
raise AssertionError(f"tool not found: {name}")
|
||||
|
||||
|
||||
def _settings() -> Settings:
|
||||
def _settings(*, deploy_wait_timeout_s: int = 1) -> Settings:
|
||||
return Settings(
|
||||
sandbox_url="http://sandbox.test",
|
||||
sandbox_timeout_s=1,
|
||||
sandbox_token=None,
|
||||
deploy_wait_timeout_s=1,
|
||||
deploy_wait_timeout_s=deploy_wait_timeout_s,
|
||||
litellm_url="http://litellm.test",
|
||||
litellm_key="",
|
||||
litellm_model="gpt-test",
|
||||
@@ -330,3 +451,47 @@ def _settings() -> Settings:
|
||||
minio_secret_key="secret",
|
||||
image="python:3.11-slim",
|
||||
)
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, status_code: int, body: dict[str, object]) -> None:
|
||||
self.status_code = status_code
|
||||
self._body = body
|
||||
self.text = json.dumps(body)
|
||||
|
||||
def json(self) -> dict[str, object]:
|
||||
return self._body
|
||||
|
||||
|
||||
class _FakeAsyncClient:
|
||||
posts: list[dict[str, object]] = []
|
||||
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
pass
|
||||
|
||||
async def __aenter__(self) -> "_FakeAsyncClient":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args: object) -> None:
|
||||
return None
|
||||
|
||||
async def post(
|
||||
self,
|
||||
url: str,
|
||||
*,
|
||||
headers: dict[str, str],
|
||||
json: dict[str, object],
|
||||
) -> _FakeResponse:
|
||||
self.posts.append({"url": url, "headers": headers, "json": json})
|
||||
return _FakeResponse(
|
||||
201,
|
||||
{
|
||||
"name": json["name"],
|
||||
"version": json["version"],
|
||||
"status": "building",
|
||||
"expected_url": "https://report-meta.a2acloud.io",
|
||||
"deployment_id": "dpl_123",
|
||||
"head_sha": "source-sha",
|
||||
"preview": {"skills": ["pursue"]},
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user