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:
host committed 2026-11-04 18:01:44 +00:00
1 parent 8fe4ad2f38
commit 7f60f12639
4 files changed
+277 -31

No files matched your search

+75
View File
@@ -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
View File
@@ -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)})
+163
View File
@@ -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(), [])