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.
230 lines
8.8 KiB
Python
230 lines
8.8 KiB
Python
"""声明式 agent 定义 v1/v2 单测:解析、校验、解析到任务字段、建任务入口。
|
||
|
||
v2 改动:prompt 不再拼头,指令落库(见 test_agent_def_v2.py)。
|
||
|
||
运行(在仓库根目录下):
|
||
python3 -m unittest discover -s cloud/tests -p "test_agent_def.py" -v
|
||
|
||
说明:服务端部分用 create_server 本地 HTTP(test_task_options
|
||
同款),不跑真实模型、不联网、不登录任何服务;skill 名单
|
||
以仓库 cloud/skills/ 实际包为准。
|
||
"""
|
||
|
||
import http.client
|
||
import json
|
||
import os
|
||
import sys
|
||
import tempfile
|
||
import threading
|
||
import unittest
|
||
from pathlib import Path
|
||
|
||
try:
|
||
from cloud.agent_def import (
|
||
format_instructions_header,
|
||
list_known_skills,
|
||
load_agent_definition,
|
||
parse_agent_definition,
|
||
resolve_task_fields,
|
||
)
|
||
from cloud.auth import AuthStore
|
||
from cloud.server import create_server
|
||
except ImportError: # discover -s cloud/tests 时把项目根目录补进 sys.path
|
||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||
from cloud.agent_def import (
|
||
format_instructions_header,
|
||
list_known_skills,
|
||
load_agent_definition,
|
||
parse_agent_definition,
|
||
resolve_task_fields,
|
||
)
|
||
from cloud.auth import AuthStore
|
||
from cloud.server import create_server
|
||
|
||
|
||
AGENT = {
|
||
"name": "research-brief",
|
||
"version": "v1",
|
||
"description": "Evidence-backed brief.",
|
||
"instructions": "Cite sources and dates.",
|
||
"skills": ["live-info"],
|
||
"defaults": {
|
||
"model": "gpt-6.1-sol",
|
||
"effort": "medium",
|
||
"approval": "ask",
|
||
"executor": "docker",
|
||
},
|
||
}
|
||
|
||
|
||
def _agent(**overrides):
|
||
agent = json.loads(json.dumps(AGENT))
|
||
agent.update(overrides)
|
||
return agent
|
||
|
||
|
||
class ParseTestCase(unittest.TestCase):
|
||
def test_valid_and_known_skills(self):
|
||
definition = parse_agent_definition(
|
||
_agent(), known_skills=list_known_skills())
|
||
self.assertEqual(definition.name, "research-brief")
|
||
self.assertEqual(definition.defaults["effort"], "medium")
|
||
self.assertEqual(definition.to_summary()["skills"], ["live-info"])
|
||
|
||
def test_name_and_unknown_fields_rejected(self):
|
||
for bad in (
|
||
_agent(name="Bad Name"),
|
||
_agent(name=""),
|
||
_agent(routing={"mode": "active"}),
|
||
_agent(defaults={"efforts": "high"}),
|
||
_agent(defaults={"effort": "ultra"}),
|
||
_agent(defaults={"plan": True, "approval": "full"}),
|
||
_agent(skills=["no-such-skill"]),
|
||
_agent(instructions="x" * 8001),
|
||
):
|
||
with self.assertRaises(ValueError, msg=bad):
|
||
parse_agent_definition(
|
||
bad, known_skills=list_known_skills())
|
||
|
||
def test_unknown_skill_skipped_when_no_known_list(self):
|
||
# known_skills=None 只跳过存在性检查,绝不发明技能。
|
||
definition = parse_agent_definition(_agent(skills=["x"]))
|
||
self.assertEqual(definition.skills, ["x"])
|
||
|
||
def test_resolve_explicit_body_wins_per_field(self):
|
||
definition = parse_agent_definition(_agent())
|
||
resolved = resolve_task_fields(
|
||
definition, {"effort": "low", "plan": True})
|
||
self.assertEqual(resolved["model"], "gpt-6.1-sol")
|
||
self.assertEqual(resolved["effort"], "low")
|
||
self.assertEqual(resolved["approval"], "ask")
|
||
self.assertEqual(resolved["executor"], "docker")
|
||
self.assertEqual(resolved["plan"], 1)
|
||
# 不改输入。
|
||
self.assertEqual(definition.defaults["effort"], "medium")
|
||
|
||
def test_header_and_no_header(self):
|
||
definition = parse_agent_definition(_agent())
|
||
headed = format_instructions_header(definition, "do research")
|
||
self.assertTrue(headed.startswith(
|
||
"[Agent: research-brief v1]\nCite sources and dates.\n\n"))
|
||
self.assertTrue(headed.endswith("do research"))
|
||
self.assertEqual(
|
||
format_instructions_header(None, "do research"), "do research")
|
||
|
||
def test_load_example_and_traversal_guard(self):
|
||
repo_root = Path(__file__).resolve().parents[2]
|
||
definition = load_agent_definition(
|
||
"research-brief.json", agents_dir=repo_root / "agents")
|
||
self.assertEqual(definition.name, "research-brief")
|
||
with self.assertRaises(ValueError):
|
||
load_agent_definition(
|
||
"../cloud/agent_def.py", agents_dir=repo_root / "agents")
|
||
|
||
|
||
class ServerAgentTestCase(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_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)
|
||
self.assertEqual(status, 201, task)
|
||
self.assertEqual(task["model"], "gpt-6.1-sol")
|
||
self.assertEqual(task["effort"], "medium")
|
||
self.assertEqual(task["approval"], "ask")
|
||
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.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", {
|
||
"project_id": self.project["id"], "prompt": "do research",
|
||
"effort": "low", "agent": _agent()}, self.owner_key)
|
||
self.assertEqual(status, 201, task)
|
||
self.assertEqual(task["effort"], "low")
|
||
self.assertEqual(task["model"], "gpt-6.1-sol")
|
||
|
||
def test_invalid_agent_rejected_and_no_task_created(self):
|
||
for bad_agent in (
|
||
_agent(skills=["no-such-skill"]),
|
||
_agent(defaults={"effort": "ultra"}),
|
||
"not-an-object",
|
||
):
|
||
status, data = self._req("POST", "/tasks", {
|
||
"project_id": self.project["id"], "prompt": "do research",
|
||
"agent": bad_agent}, self.owner_key)
|
||
self.assertEqual(status, 400, bad_agent)
|
||
self.assertEqual(self._list_tasks(), [])
|
||
|
||
def test_agent_instructions_rejected_with_goal(self):
|
||
status, data = self._req("POST", "/tasks", {
|
||
"project_id": self.project["id"], "prompt": "do research",
|
||
"goal_mode": 1, "agent": _agent()}, self.owner_key)
|
||
self.assertEqual(status, 400, data)
|
||
self.assertEqual(self._list_tasks(), [])
|
||
|
||
def test_no_agent_unchanged(self):
|
||
status, task = self._req("POST", "/tasks", {
|
||
"project_id": self.project["id"], "prompt": "hi"},
|
||
self.owner_key)
|
||
self.assertEqual(status, 201, task)
|
||
self.assertIsNone(task["model"])
|
||
self.assertEqual(task["prompt"], "hi")
|
||
self.assertNotIn("agent", task)
|