feat: declarative agent definition v1 (inline agent on task creation)
Agent definition is a versioned JSON document bundling the task fields this codebase already honours (model/effort/approval/ executor/plan), plus instructions and required skills. At task creation, explicit body fields win per field over the definition; unresolved fields stay NULL and follow the worker-default chain (and rule routing v0 when model is left empty). Instructions reach the model only as a marked header prepended to the stored prompt, because the dispatch path has no developerInstructions channel; goal and coordinator tasks reject instructions. Skills are validated against the cloud/skills pack at parse time; seeding itself stays worker-global, documented as the v1 boundary. Routing policy is not part of a definition in v1 (worker-global), and unknown fields are rejected so typos cannot be silently dropped. No new tasks-table column in v1; definition identity is echoed in the creation response and via the prompt header. Tests: test_agent_def.py (11 tests).
This commit is contained in:
1 parent
e364970b46
commit
7bb3c37a10
4 files changed
+552
-1
No files matched your search
@@ -0,0 +1,221 @@
|
||||
"""声明式 agent 定义 v1 单测:解析、校验、解析到任务字段、建任务入口。
|
||||
|
||||
运行(在仓库根目录下):
|
||||
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_header(self):
|
||||
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"])
|
||||
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"))
|
||||
|
||||
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)
|
||||
Reference in new issue
Block a user