Registry: POST /tasks accepts agent_name, loading <name>.json from repo-root agents/ (traversal-guarded, skill-pack re-validated); agent and agent_name together is a 400. Persistence: tasks gains agent_name/agent_version/agent_instructions (migrate + PG schema), returned by GET /tasks; stored prompt stays clean. Channel: local and docker dispatch pass --developer-instructions to host.py dispatch -> create_thread(developerInstructions=...); run/fork do not re-send. Cloud executor has no such channel, so the worker falls back to the v1 marked header in the in-memory dispatch prompt only (DB prompt stays clean). Goal/coordinator tasks still reject instructions. Tests: test_agent_def_v2.py (7 tests) + updated v1 suite.
185 lines
7.0 KiB
Python
185 lines
7.0 KiB
Python
"""声明式 agent 定义 v2 单测:注册表、落库、developerInstructions 通道。
|
|
|
|
运行(在仓库根目录下):
|
|
python3 -m unittest discover -s cloud/tests -p "test_agent_def_v2.py" -v
|
|
|
|
覆盖:agent_name 查注册表、agent 与 agent_name 同给 400、
|
|
未知 agent_name 400、本地/docker 派发命令含
|
|
--developer-instructions、无指令时命令不变、host dispatch 把
|
|
指令传给 create_thread 的 instructions(即 developerInstructions)。
|
|
不跑真实模型、不联网;host 部分用假 server,只断言传参。
|
|
"""
|
|
|
|
import http.client
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
try:
|
|
from cloud.agent_def import default_agents_dir
|
|
from cloud.auth import AuthStore
|
|
from cloud.sandbox import DockerExecutor, LocalExecutor
|
|
from cloud.server import create_server
|
|
except ImportError:
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
|
from cloud.agent_def import default_agents_dir
|
|
from cloud.auth import AuthStore
|
|
from cloud.sandbox import DockerExecutor, LocalExecutor
|
|
from cloud.server import create_server
|
|
|
|
|
|
def _load_host():
|
|
spec = importlib.util.spec_from_file_location(
|
|
"hostmod_v2", str(Path(__file__).resolve().parents[2] / "host.py"))
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
class ServerAgentNameTestCase(unittest.TestCase):
|
|
def setUp(self):
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|
self.db_path = os.path.join(self.tmp.name, "control.db")
|
|
self.auth = AuthStore(self.db_path)
|
|
self.owner_id, self.owner_key = self.auth.create_user("owner")
|
|
self.server = create_server(self.db_path, "127.0.0.1", 0)
|
|
self.port = self.server.server_address[1]
|
|
self.thread = threading.Thread(
|
|
target=self.server.serve_forever, daemon=True)
|
|
self.thread.start()
|
|
status, self.project = self._req(
|
|
"POST", "/projects", {"name": "demo"}, self.owner_key)
|
|
self.assertEqual(status, 201)
|
|
|
|
def tearDown(self):
|
|
self.server.shutdown()
|
|
self.server.server_close()
|
|
self.thread.join(timeout=5)
|
|
try:
|
|
self.auth.conn.close()
|
|
except Exception:
|
|
pass
|
|
self.tmp.cleanup()
|
|
|
|
def _req(self, method, path, body=None, key=None):
|
|
conn = http.client.HTTPConnection("127.0.0.1", self.port, timeout=10)
|
|
headers = {"Content-Type": "application/json"}
|
|
if key:
|
|
headers["X-API-Key"] = key
|
|
conn.request(method, path,
|
|
json.dumps(body) if body is not None else None, headers)
|
|
resp = conn.getresponse()
|
|
raw = resp.read()
|
|
conn.close()
|
|
try:
|
|
data = json.loads(raw)
|
|
except ValueError:
|
|
data = None
|
|
return resp.status, data
|
|
|
|
def _list_tasks(self):
|
|
status, data = self._req(
|
|
"GET", "/tasks?project_id=" + self.project["id"],
|
|
key=self.owner_key)
|
|
self.assertEqual(status, 200)
|
|
return data["tasks"]
|
|
|
|
def test_agent_name_from_registry(self):
|
|
# 注册表里有 agents/research-brief.json(仓库自带示例)。
|
|
self.assertTrue((default_agents_dir()
|
|
/ "research-brief.json").is_file())
|
|
status, task = self._req("POST", "/tasks", {
|
|
"project_id": self.project["id"], "prompt": "do research",
|
|
"agent_name": "research-brief"}, self.owner_key)
|
|
self.assertEqual(status, 201, task)
|
|
self.assertEqual(task["agent_name"], "research-brief")
|
|
self.assertEqual(task["agent_version"], "v1")
|
|
self.assertEqual(task["effort"], "medium")
|
|
self.assertEqual(task["executor"], "docker")
|
|
self.assertEqual(task["prompt"], "do research")
|
|
stored = self._list_tasks()[0]
|
|
self.assertEqual(stored["agent_instructions"][:20],
|
|
"Confirm the question")
|
|
|
|
def test_agent_and_agent_name_conflict(self):
|
|
status, _data = self._req("POST", "/tasks", {
|
|
"project_id": self.project["id"], "prompt": "do research",
|
|
"agent": {"name": "research-brief"},
|
|
"agent_name": "research-brief"}, self.owner_key)
|
|
self.assertEqual(status, 400)
|
|
self.assertEqual(self._list_tasks(), [])
|
|
|
|
def test_unknown_agent_name(self):
|
|
status, _data = self._req("POST", "/tasks", {
|
|
"project_id": self.project["id"], "prompt": "do research",
|
|
"agent_name": "no-such-agent"}, self.owner_key)
|
|
self.assertEqual(status, 400)
|
|
self.assertEqual(self._list_tasks(), [])
|
|
|
|
|
|
class InstructionsChannelTestCase(unittest.TestCase):
|
|
def test_local_dispatch_includes_instructions(self):
|
|
captured = []
|
|
|
|
def runner(cmd, env=None):
|
|
captured.append(cmd)
|
|
return {"returncode": 0, "stdout": "ok", "stderr": ""}
|
|
|
|
ex = LocalExecutor(host_py_path="host.py", runner=runner,
|
|
repo="/tmp/repo")
|
|
ex.dispatch_task({"id": "t1", "prompt": "hi",
|
|
"agent_instructions": "be careful"})
|
|
self.assertIn("--developer-instructions", captured[0])
|
|
idx = captured[0].index("--developer-instructions")
|
|
self.assertEqual(captured[0][idx + 1], "be careful")
|
|
self.assertLess(idx, captured[0].index("dispatch"))
|
|
|
|
def test_local_dispatch_without_instructions_unchanged(self):
|
|
captured = []
|
|
|
|
def runner(cmd, env=None):
|
|
captured.append(cmd)
|
|
return {"returncode": 0, "stdout": "ok", "stderr": ""}
|
|
|
|
ex = LocalExecutor(host_py_path="host.py", runner=runner,
|
|
repo="/tmp/repo")
|
|
ex.dispatch_task({"id": "t1", "prompt": "hi"})
|
|
self.assertNotIn("--developer-instructions", captured[0])
|
|
|
|
def test_docker_inner_command_includes_instructions(self):
|
|
ex = DockerExecutor.__new__(DockerExecutor)
|
|
ex.config_path = None
|
|
ex.model = None
|
|
ex.ignore_host_budget = False
|
|
cmd = ex._inner_host_command(
|
|
"dispatch", model="m",
|
|
developer_instructions="be careful")
|
|
self.assertIn("--developer-instructions", cmd)
|
|
self.assertEqual(cmd[cmd.index("--developer-instructions") + 1],
|
|
"be careful")
|
|
|
|
def test_host_dispatch_passes_instructions(self):
|
|
host = _load_host()
|
|
|
|
class FakeServer:
|
|
def __init__(self):
|
|
self.calls = []
|
|
|
|
def create_thread(self, cwd, model=None, sandbox=None,
|
|
approval_policy=None, instructions=None):
|
|
self.calls.append(instructions)
|
|
return "thr1"
|
|
|
|
srv = FakeServer()
|
|
state = {"tasks": {}}
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
host.dispatch(srv, state, "t1", "hi", tmpdir, "m", "low",
|
|
None, None, use_worktree=False,
|
|
developer_instructions="be careful")
|
|
self.assertEqual(srv.calls, ["be careful"])
|