feat: agent definition v2 (registry, persistence, developerInstructions channel)
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.
This commit is contained in:
1 parent
7bb3c37a10
commit
1672ab9ff2
8 files changed
+361
-35
No files matched your search
@@ -1,4 +1,6 @@
|
||||
"""声明式 agent 定义 v1 单测:解析、校验、解析到任务字段、建任务入口。
|
||||
"""声明式 agent 定义 v1/v2 单测:解析、校验、解析到任务字段、建任务入口。
|
||||
|
||||
v2 改动:prompt 不再拼头,指令落库(见 test_agent_def_v2.py)。
|
||||
|
||||
运行(在仓库根目录下):
|
||||
python3 -m unittest discover -s cloud/tests -p "test_agent_def.py" -v
|
||||
@@ -168,7 +170,9 @@ class ServerAgentTestCase(unittest.TestCase):
|
||||
self.assertEqual(status, 200)
|
||||
return data["tasks"]
|
||||
|
||||
def test_agent_fills_fields_and_header(self):
|
||||
def test_agent_fills_fields_and_stores_instructions(self):
|
||||
# v2:prompt 保持干净,指令落库,不再拼头(v1 行为已
|
||||
# 被 v2 替换,见 agent_def.py 的 v2 边界说明)。
|
||||
status, task = self._req("POST", "/tasks", {
|
||||
"project_id": self.project["id"], "prompt": "do research",
|
||||
"agent": _agent()}, self.owner_key)
|
||||
@@ -179,10 +183,14 @@ class ServerAgentTestCase(unittest.TestCase):
|
||||
self.assertEqual(task["executor"], "docker")
|
||||
self.assertEqual(task["agent"]["name"], "research-brief")
|
||||
self.assertEqual(task["agent"]["skills"], ["live-info"])
|
||||
self.assertEqual(task["agent_name"], "research-brief")
|
||||
self.assertEqual(task["agent_version"], "v1")
|
||||
self.assertEqual(task["agent_instructions"],
|
||||
"Cite sources and dates.")
|
||||
stored = self._list_tasks()[0]
|
||||
self.assertTrue(stored["prompt"].startswith(
|
||||
"[Agent: research-brief v1]\nCite sources and dates.\n\n"))
|
||||
self.assertTrue(stored["prompt"].endswith("do research"))
|
||||
self.assertEqual(stored["prompt"], "do research")
|
||||
self.assertEqual(stored["agent_instructions"],
|
||||
"Cite sources and dates.")
|
||||
|
||||
def test_explicit_body_wins_over_agent(self):
|
||||
status, task = self._req("POST", "/tasks", {
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
"""声明式 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"])
|
||||
Reference in new issue
Block a user