feat: agent version pinning (v4)
resolve_registry_agent in cloud/agent_def.py: agent_name may be <name> or <name>@<version>, agent_version may also be given separately; both given must agree or 400. Unpinned loads <name>.json (default); pinned loads <name>.<version>.json and requires the file's own version field to match, with no silent fallback to the default file. Version strings are restricted to alphanumerics/._- (no path separators, no ..); the agents-dir traversal guard is a second line of defence. Server: agent_version is only for agent_name (inline agent with agent_version is 400); stored agent_name is the base name, not the @ form. Snapshot fixture: agents/research-brief.v1.json (same content as the default v1 file). Tests: test_agent_version.py (7 tests).
This commit is contained in:
1 parent
8fe4ad2f38
commit
7f60f12639
4 files changed
+277
-31
No files matched your search
@@ -309,6 +309,81 @@ def load_agent_definition(path, agents_dir=None):
|
||||
return parse_agent_definition(data)
|
||||
|
||||
|
||||
VERSION_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
||||
|
||||
|
||||
def resolve_registry_agent(name, version=None, agents_dir=None,
|
||||
known_skills=None):
|
||||
"""Resolve a registry definition, with optional version pinning.
|
||||
|
||||
``name`` may be ``<name>`` or ``<name>@<version>``; ``version``
|
||||
may also be given separately. If both are given they must agree,
|
||||
otherwise this is a 400 (ambiguity is never guessed). Without a
|
||||
version, this loads ``<name>.json`` (the default/latest file).
|
||||
With a version, it loads ``<name>.<version>.json`` and requires
|
||||
the file's own ``version`` field to equal the requested version:
|
||||
a misnamed snapshot (file says v1, filename says v2) is rejected
|
||||
instead of being used as the wrong version, and a missing snapshot
|
||||
is an error, never a silent fallback to the default file (that
|
||||
fallback would defeat pinning entirely). A version string is
|
||||
restricted to alphanumerics, ``.``, ``_``, ``-`` so it cannot
|
||||
carry a path separator or ``..``; the agents-dir traversal guard
|
||||
in load_agent_definition is a second line of defence, not the only
|
||||
one. Skill existence is checked only when ``known_skills`` is
|
||||
given (the server passes the pack; pure-function callers may omit
|
||||
it, exactly as with parse_agent_definition).
|
||||
"""
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
raise ValueError("agent_name must be a non-empty string")
|
||||
name = name.strip()
|
||||
pinned_from_name = None
|
||||
if "@" in name:
|
||||
parts = name.split("@")
|
||||
if len(parts) != 2 or not parts[0] or not parts[1]:
|
||||
raise ValueError(
|
||||
"agent_name with @ must be <name>@<version>")
|
||||
name, pinned_from_name = parts
|
||||
if version is not None:
|
||||
if not isinstance(version, str) or not version.strip():
|
||||
raise ValueError("agent_version must be a non-empty string")
|
||||
version = version.strip()
|
||||
if pinned_from_name is not None:
|
||||
if version is not None and version != pinned_from_name:
|
||||
raise ValueError(
|
||||
"agent_version %r does not match agent_name version "
|
||||
"%r" % (version, pinned_from_name))
|
||||
version = pinned_from_name
|
||||
if not NAME_RE.match(name):
|
||||
raise ValueError(
|
||||
"agent_name must match [a-z0-9][a-z0-9_-]{0,63}")
|
||||
if version is not None and not VERSION_RE.match(version):
|
||||
raise ValueError(
|
||||
"agent_version must match [A-Za-z0-9][A-Za-z0-9._-]{0,63}")
|
||||
filename = "%s.json" % name if version is None else \
|
||||
"%s.%s.json" % (name, version)
|
||||
definition = load_agent_definition(filename, agents_dir=agents_dir)
|
||||
if version is not None and definition.version != version:
|
||||
raise ValueError(
|
||||
"registry file %s says version %r, not requested %r" %
|
||||
(filename, definition.version, version))
|
||||
if known_skills is not None:
|
||||
# Re-parse for skill existence only (load already validated
|
||||
# format); the re-parsed definition is the one returned, so
|
||||
# callers never see a definition that failed skill checks.
|
||||
definition = parse_agent_definition(
|
||||
{
|
||||
"name": definition.name,
|
||||
"version": definition.version,
|
||||
"description": definition.description,
|
||||
"instructions": definition.instructions,
|
||||
"skills": definition.skills,
|
||||
"tools": definition.tools,
|
||||
"defaults": definition.defaults,
|
||||
},
|
||||
known_skills=known_skills)
|
||||
return definition
|
||||
|
||||
|
||||
def resolve_task_fields(definition, body):
|
||||
"""Resolve task fields: explicit body field wins, else definition.
|
||||
|
||||
|
||||
+22
-31
@@ -59,8 +59,8 @@ from urllib.parse import parse_qs, quote, urlparse
|
||||
|
||||
from cloud.agent_def import default_agents_dir as _agent_default_dir
|
||||
from cloud.agent_def import list_known_skills as _agent_known_skills
|
||||
from cloud.agent_def import load_agent_definition as _load_agent_definition
|
||||
from cloud.agent_def import parse_agent_definition as _parse_agent_definition
|
||||
from cloud.agent_def import resolve_registry_agent as _resolve_agent
|
||||
from cloud.auth import ROLES as _MEMBER_ROLES
|
||||
from cloud.auth import AuthStore
|
||||
from cloud.coordinator import parse_parts as _coord_parse_parts
|
||||
@@ -4409,18 +4409,30 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
return
|
||||
if prompt_goal_target:
|
||||
prompt = prompt_goal_target
|
||||
# 声明式 agent 定义 v1/v2(见 cloud/agent_def.py):
|
||||
# 声明式 agent 定义 v1/v2/v3(见 cloud/agent_def.py):
|
||||
# inline agent 对象,或 agent_name(注册表 agents/ 下
|
||||
# 的 <name>.json);两者不能同时给,歧义不猜。显式
|
||||
# body 字段逐项优先于定义;解析出的字段落进已有列,
|
||||
# 定义身份与指令落进 agent_name/agent_version/
|
||||
# agent_instructions 列(v2),prompt 不再带头。
|
||||
# 的 <name>.json,未钉选时即默认文件);两者不能同时
|
||||
# 给,歧义不猜。版本钉选(v4):agent_version 字段,
|
||||
# 或 agent_name 里的 <name>@<version> 写法,两者同给
|
||||
# 时必须一致;钉选载 <name>.<version>.json 且文件内
|
||||
# version 必须与请求一致,缺文件绝不回落默认文件。
|
||||
# inline agent 自带 version,agent_version 只配
|
||||
# agent_name用,两者同给即 400。显式 body 字段逐项
|
||||
# 优先于定义;解析出的字段落进已有列,定义身份与
|
||||
# 指令落进 agent_name/agent_version/agent_instructions
|
||||
# 列(v2),prompt 不再带头。
|
||||
definition = None
|
||||
if body.get("agent") is not None \
|
||||
and body.get("agent_name") is not None:
|
||||
self._send_json(400, {
|
||||
"error": "agent and agent_name cannot both be used"})
|
||||
return
|
||||
if body.get("agent") is not None \
|
||||
and body.get("agent_version") is not None:
|
||||
self._send_json(400, {
|
||||
"error": "agent_version is only for agent_name;"
|
||||
" an inline agent carries its own version"})
|
||||
return
|
||||
if body.get("agent") is not None:
|
||||
if not isinstance(body.get("agent"), dict):
|
||||
self._send_json(400, {"error": "agent must be an object"})
|
||||
@@ -4433,32 +4445,11 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
||||
self._send_json(400, {"error": str(exc)})
|
||||
return
|
||||
if body.get("agent_name") is not None:
|
||||
agent_name = body.get("agent_name")
|
||||
if not isinstance(agent_name, str) or not agent_name.strip():
|
||||
self._send_json(400, {
|
||||
"error": "agent_name must be a non-empty string"})
|
||||
return
|
||||
try:
|
||||
definition = _load_agent_definition(
|
||||
agent_name.strip() + ".json",
|
||||
agents_dir=_agent_default_dir())
|
||||
except ValueError as exc:
|
||||
self._send_json(400, {"error": str(exc)})
|
||||
return
|
||||
# 注册表定义同样须过技能包校验:load 只校验了
|
||||
# 格式(known_skills=None 时不查存在性),这里
|
||||
# 用同一解析器补存在性校验,校验不过即 400。
|
||||
try:
|
||||
definition = _parse_agent_definition(
|
||||
{
|
||||
"name": definition.name,
|
||||
"version": definition.version,
|
||||
"description": definition.description,
|
||||
"instructions": definition.instructions,
|
||||
"skills": definition.skills,
|
||||
"tools": definition.tools,
|
||||
"defaults": definition.defaults,
|
||||
},
|
||||
definition = _resolve_agent(
|
||||
body.get("agent_name"),
|
||||
version=body.get("agent_version"),
|
||||
agents_dir=_agent_default_dir(),
|
||||
known_skills=_agent_known_skills())
|
||||
except ValueError as exc:
|
||||
self._send_json(400, {"error": str(exc)})
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"""agent 版本钉选 v4 单测:文件选择、版本一致、@ 语法、歧义拒绝。
|
||||
|
||||
运行(在仓库根目录下):
|
||||
python3 -m unittest discover -s cloud/tests -p "test_agent_version.py" -v
|
||||
|
||||
说明:helper 部分用临时 agents 目录(不碰仓库注册表),
|
||||
服务端点部分用 create_server 本地 HTTP(test_task_options
|
||||
同款),仓库自带快照 agents/research-brief.v1.json 作
|
||||
钉选夹具。关键断言:缺快照绝不回落默认文件;文件内
|
||||
version 与请求不一致即 400;@ 与 agent_version 不一致
|
||||
即 400;inline agent 配 agent_version 即 400;钉选存库
|
||||
的 agent_name 是基本名、不是 @ 写法。
|
||||
"""
|
||||
|
||||
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 list_known_skills
|
||||
from cloud.agent_def import resolve_registry_agent
|
||||
from cloud.auth import AuthStore
|
||||
from cloud.server import create_server
|
||||
except ImportError:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
from cloud.agent_def import list_known_skills
|
||||
from cloud.agent_def import resolve_registry_agent
|
||||
from cloud.auth import AuthStore
|
||||
from cloud.server import create_server
|
||||
|
||||
|
||||
def _write_definition(directory, filename, name="demo", version="v1"):
|
||||
with open(os.path.join(directory, filename),
|
||||
"w", encoding="utf-8") as fh:
|
||||
json.dump({"name": name, "version": version,
|
||||
"skills": [], "tools": [],
|
||||
"defaults": {"effort": "low"}}, fh)
|
||||
|
||||
|
||||
class ResolveTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.dir = self.tmp.name
|
||||
_write_definition(self.dir, "demo.json", version="v1")
|
||||
_write_definition(self.dir, "demo.v2.json", version="v2")
|
||||
_write_definition(self.dir, "demo.v9.json", version="v1")
|
||||
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def test_unpinned_and_pinned(self):
|
||||
self.assertEqual(
|
||||
resolve_registry_agent("demo", agents_dir=self.dir).version,
|
||||
"v1")
|
||||
self.assertEqual(
|
||||
resolve_registry_agent("demo", version="v2",
|
||||
agents_dir=self.dir).version, "v2")
|
||||
self.assertEqual(
|
||||
resolve_registry_agent("demo@v2",
|
||||
agents_dir=self.dir).version, "v2")
|
||||
|
||||
def test_missing_snapshot_never_falls_back(self):
|
||||
with self.assertRaises(ValueError):
|
||||
resolve_registry_agent("demo", version="v3",
|
||||
agents_dir=self.dir)
|
||||
|
||||
def test_file_version_mismatch_rejected(self):
|
||||
with self.assertRaises(ValueError):
|
||||
resolve_registry_agent("demo", version="v9",
|
||||
agents_dir=self.dir)
|
||||
|
||||
def test_at_and_version_conflict_and_bad_version(self):
|
||||
with self.assertRaises(ValueError):
|
||||
resolve_registry_agent("demo@v2", version="v1",
|
||||
agents_dir=self.dir)
|
||||
with self.assertRaises(ValueError):
|
||||
resolve_registry_agent("demo", version="../x",
|
||||
agents_dir=self.dir)
|
||||
with self.assertRaises(ValueError):
|
||||
resolve_registry_agent("demo@", agents_dir=self.dir)
|
||||
with self.assertRaises(ValueError):
|
||||
resolve_registry_agent("demo@v2@v3",
|
||||
agents_dir=self.dir)
|
||||
|
||||
|
||||
class ServerVersionTestCase(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_pinned_by_field_and_at_syntax(self):
|
||||
for body in (
|
||||
{"agent_name": "research-brief", "agent_version": "v1"},
|
||||
{"agent_name": "research-brief@v1"},
|
||||
):
|
||||
status, task = self._req("POST", "/tasks", {
|
||||
"project_id": self.project["id"], "prompt": "hi",
|
||||
**body}, self.owner_key)
|
||||
self.assertEqual(status, 201, task)
|
||||
self.assertEqual(task["agent_name"], "research-brief")
|
||||
self.assertEqual(task["agent_version"], "v1")
|
||||
|
||||
def test_pinned_missing_and_inline_version_rejected(self):
|
||||
for body in (
|
||||
{"agent_name": "research-brief", "agent_version": "v999"},
|
||||
{"agent_name": "research-brief@v2",
|
||||
"agent_version": "v1"},
|
||||
{"agent": {"name": "research-brief", "version": "v1"},
|
||||
"agent_version": "v1"},
|
||||
):
|
||||
status, _data = self._req("POST", "/tasks", {
|
||||
"project_id": self.project["id"], "prompt": "hi",
|
||||
**body}, self.owner_key)
|
||||
self.assertEqual(status, 400, body)
|
||||
self.assertEqual(self._list_tasks(), [])
|
||||
Reference in new issue
Block a user