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:
host committed 2026-11-04 18:01:44 +00:00
1 parent e364970b46
commit 7bb3c37a10
4 files changed
+552 -1

No files matched your search

+275
View File
@@ -0,0 +1,275 @@
"""Declarative agent definitions (harness alignment, v1).
Zero third-party dependencies; standard library only.
A definition is a versioned JSON document that bundles the fields a
task already understands -- model, effort, approval, executor, plan --
plus a human-written instruction block and a list of required skills.
It is the Foundry-style "agent defined declaratively, harness runs
the loop" idea, cut down to what this codebase can actually honour
today, with every boundary written down instead of implied:
* Resolution happens at task creation (``POST /tasks`` with an inline
``agent`` object). Per field, an explicit body field wins over the
definition; a field absent from both stays NULL and follows the
existing worker-default chain. A definition that sets ``model``
therefore makes the task explicit for rule routing v0, and a
definition that leaves ``model`` empty leaves routing to the worker.
* Instructions reach the model only as a marked header prepended to
the stored prompt (``[Agent: <name>]``), because the dispatch path
(``cloud/sandbox.py`` -> ``host.py dispatch``) has no
developerInstructions channel today. This is NOT claimed to be
``developerInstructions``: it is visible in the stored prompt, in
the creation response, and in every later prompt read. Instructions
are rejected with goal tasks and coordinator tasks, where the
prompt is a goal or is never run as a model turn, so the header
cannot silently pollute a goal text or a coordinator parent.
* Skills are validated against the ``cloud/skills/`` pack at parse
time (an unknown skill is a 400, never a silent drop). Seeding
itself stays worker-global (``--seed-skills`` seeds the whole pack
into the tenant codex-home); v1 does NOT claim per-agent seeding.
When seeding is off, a listed skill is recorded in the creation
response but is not guaranteed present at run time -- that is the
documented v1 boundary, not a bug to be discovered later.
* Routing policy is deliberately NOT part of a definition in v1:
routing stays a worker-global policy (see ``cloud/models.py``).
Putting a routing object inside a definition would either be
silently dropped (a lie) or need a per-task policy column that does
not exist. A definition that contains ``routing`` / ``routing_policy``
is rejected, so a typo or a v2 idea cannot masquerade as v1 behaviour.
* Unknown top-level or defaults keys are rejected for the same
reason: a declarative file must fail on typos, not silently ignore
them. Definition identity (name/version/skills) is echoed in the
creation response and, via the prompt header, in the stored prompt;
v1 adds NO new tasks-table column, so ``GET /tasks`` shows the
resolved fields and the header, not a separate ``agent`` column.
That column is v2, not something v1 pretends to have.
"""
import json
import os
import re
from pathlib import Path
MAX_INSTRUCTIONS_CHARS = 8000
MAX_DESCRIPTION_CHARS = 1000
MAX_SKILLS = 20
NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
EFFORTS = ("low", "medium", "high", "xhigh", "max")
APPROVALS = ("ask", "auto", "full")
EXECUTORS = ("local", "docker", "cloud")
_TOP_LEVEL_KEYS = ("name", "version", "description", "instructions",
"skills", "defaults")
_DEFAULTS_KEYS = ("model", "effort", "approval", "executor", "plan")
SKILLS_DIR = Path(__file__).resolve().parent / "skills"
def list_known_skills(skills_dir=None):
"""Sorted skill names present in the pack (dirs with SKILL.md)."""
base = Path(skills_dir) if skills_dir else SKILLS_DIR
names = []
try:
entries = sorted(base.iterdir())
except OSError:
return names
for entry in entries:
if entry.is_dir() and (entry / "SKILL.md").is_file():
names.append(entry.name)
return names
def _clean_str(value, field):
if not isinstance(value, str) or not value.strip():
raise ValueError("%s must be a non-empty string" % field)
return value.strip()
class AgentDefinition:
"""A parsed, validated agent definition."""
def __init__(self, name, version=None, description=None,
instructions=None, skills=None, defaults=None):
self.name = name
self.version = version
self.description = description
self.instructions = instructions
self.skills = list(skills or [])
self.defaults = dict(defaults or {})
def to_summary(self):
"""The identity echoed in the task-creation response."""
return {
"name": self.name,
"version": self.version,
"skills": list(self.skills),
}
def parse_agent_definition(data, known_skills=None):
"""Parse and validate one definition, or raise ValueError.
``known_skills`` is a list of pack skill names; when it is not
None, every listed skill must be in it. Passing None skips the
existence check (pure-function tests), it never invents a skill.
"""
if not isinstance(data, dict):
raise ValueError("agent must be an object")
unknown = sorted(set(data) - set(_TOP_LEVEL_KEYS))
if unknown:
raise ValueError("agent has unknown field(s): %s"
% ", ".join(unknown))
name = data.get("name")
if not isinstance(name, str) or not NAME_RE.match(name):
raise ValueError(
"agent.name must match [a-z0-9][a-z0-9_-]{0,63}")
version = None
if data.get("version") is not None:
version = _clean_str(data.get("version"), "agent.version")
if len(version) > 64:
raise ValueError("agent.version is too long")
description = None
if data.get("description") is not None:
if not isinstance(data.get("description"), str):
raise ValueError("agent.description must be a string")
description = data["description"]
if len(description) > MAX_DESCRIPTION_CHARS:
raise ValueError("agent.description is too long")
instructions = None
if data.get("instructions") is not None:
if not isinstance(data.get("instructions"), str):
raise ValueError("agent.instructions must be a string")
instructions = data["instructions"].strip()
if not instructions:
instructions = None
elif len(instructions) > MAX_INSTRUCTIONS_CHARS:
raise ValueError(
"agent.instructions exceeds %d characters"
% MAX_INSTRUCTIONS_CHARS)
skills = []
if data.get("skills") is not None:
raw_skills = data.get("skills")
if not isinstance(raw_skills, list):
raise ValueError("agent.skills must be a list")
if len(raw_skills) > MAX_SKILLS:
raise ValueError("agent.skills has too many entries")
for skill in raw_skills:
skill = _clean_str(skill, "agent.skills entry")
if skill not in skills:
skills.append(skill)
if known_skills is not None:
known = set(known_skills)
missing = [s for s in skills if s not in known]
if missing:
raise ValueError("agent.skills unknown skill(s): %s"
% ", ".join(missing))
defaults = {}
if data.get("defaults") is not None:
raw_defaults = data.get("defaults")
if not isinstance(raw_defaults, dict):
raise ValueError("agent.defaults must be an object")
unknown_defaults = sorted(set(raw_defaults) - set(_DEFAULTS_KEYS))
if unknown_defaults:
raise ValueError("agent.defaults has unknown field(s): %s"
% ", ".join(unknown_defaults))
if raw_defaults.get("model") is not None:
defaults["model"] = _clean_str(raw_defaults["model"],
"agent.defaults.model")
if raw_defaults.get("effort") is not None:
if raw_defaults["effort"] not in EFFORTS:
raise ValueError(
"agent.defaults.effort must be low, medium, high,"
" xhigh or max")
defaults["effort"] = raw_defaults["effort"]
if raw_defaults.get("approval") is not None:
if raw_defaults["approval"] not in APPROVALS:
raise ValueError(
"agent.defaults.approval must be ask, auto or full")
defaults["approval"] = raw_defaults["approval"]
if raw_defaults.get("executor") is not None:
if raw_defaults["executor"] not in EXECUTORS:
raise ValueError(
"agent.defaults.executor must be local, docker"
" or cloud")
defaults["executor"] = raw_defaults["executor"]
if raw_defaults.get("plan") is not None:
plan = raw_defaults["plan"]
if plan not in (True, False, 0, 1):
raise ValueError("agent.defaults.plan must be true/false")
defaults["plan"] = 1 if plan in (True, 1) else 0
if defaults.get("plan") and defaults.get("approval") == "full":
raise ValueError(
"plan 模式与 approval=full 冲突(只读 vs 完全访问)")
return AgentDefinition(name=name, version=version,
description=description,
instructions=instructions, skills=skills,
defaults=defaults)
def load_agent_definition(path, agents_dir=None):
"""Load one definition from a JSON file (operator/local use only).
This is NOT an API path: the server never loads a definition from
a caller-supplied path (that would be a file-read primitive). When
``agents_dir`` is given, the resolved path must stay under it, so
``../`` cannot escape the definitions directory.
"""
candidate = Path(path)
if agents_dir is not None and not candidate.is_absolute():
candidate = Path(agents_dir) / candidate
resolved = candidate.resolve()
if agents_dir is not None:
base = Path(agents_dir).resolve()
if base not in resolved.parents and resolved != base:
raise ValueError("agent definition path escapes agents dir")
try:
with open(resolved, encoding="utf-8") as f:
data = json.load(f)
except OSError as exc:
raise ValueError("cannot read agent definition: %s" % exc)
except ValueError as exc:
raise ValueError("agent definition is not valid JSON: %s" % exc)
return parse_agent_definition(data)
def resolve_task_fields(definition, body):
"""Resolve task fields: explicit body field wins, else definition.
``body`` is the task-creation body dict. Returns a dict with
``model``, ``effort``, ``approval``, ``executor`` (None when
absent from both) and ``plan`` (int 0/1). It never mutates either
input, and it never validates the resolved values -- the caller
(server task creation) runs its existing validation on them, so
a bad definition value is caught by the same 400 path as a bad
body value.
"""
body = body if isinstance(body, dict) else {}
defaults = definition.defaults if definition else {}
resolved = {}
for field in ("model", "effort", "approval", "executor"):
value = body.get(field)
if value is None:
value = defaults.get(field)
resolved[field] = value
if "plan" in body:
resolved["plan"] = 1 if body.get("plan") in (True, 1) else 0
elif defaults.get("plan") is not None:
resolved["plan"] = defaults["plan"]
else:
resolved["plan"] = 0
return resolved
def format_instructions_header(definition, prompt):
"""Prepend a definition's instructions to a prompt, marked.
Returns the original prompt unchanged when there is no definition
or no instructions, so callers that do not use definitions pay
nothing and see no header.
"""
if definition is None or not definition.instructions:
return prompt
header = "[Agent: %s" % definition.name
if definition.version:
header += " %s" % definition.version
header += "]"
return "%s\n%s\n\n%s" % (header, definition.instructions, prompt)
+55 -1
View File
@@ -57,6 +57,9 @@ from decimal import Decimal
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, quote, urlparse
from cloud.agent_def import format_instructions_header as _agent_format_header
from cloud.agent_def import list_known_skills as _agent_known_skills
from cloud.agent_def import parse_agent_definition as _parse_agent_definition
from cloud.auth import ROLES as _MEMBER_ROLES
from cloud.auth import AuthStore
from cloud.coordinator import parse_parts as _coord_parse_parts
@@ -4298,6 +4301,23 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
return
if prompt_goal_target:
prompt = prompt_goal_target
# 声明式 agent 定义 v1(见 cloud/agent_def.py):inline
# agent 对象,逐字段填充任务选项;显式 body 字段优先
# 于定义,定义本身不新开任务表列,解析出的字段落进
# 已有列,instructions 以带标记的头拼进 prompt(见后),
# 身份在创建响应里回显。
definition = None
if body.get("agent") is not None:
if not isinstance(body.get("agent"), dict):
self._send_json(400, {"error": "agent must be an object"})
return
try:
definition = _parse_agent_definition(
body.get("agent"),
known_skills=_agent_known_skills())
except ValueError as exc:
self._send_json(400, {"error": str(exc)})
return
depends_on = body.get("depends_on") or []
if isinstance(depends_on, str):
depends_on = [depends_on]
@@ -4311,7 +4331,11 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
return
# executor 可选:None 表示任意 worker 可认领;指定时只能是
# local/docker/cloud,非法值 400 且不建任务。
# 未指定时用 agent 定义的 executor(若有),再由下面
# 的校验与环境继承处理。
executor = body.get("executor")
if executor is None and definition is not None:
executor = definition.defaults.get("executor")
if executor is not None and executor not in (
"local", "docker", "cloud"):
self._send_json(400, {
@@ -4322,6 +4346,8 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
# 是 console 三档(ask/auto/full),非法值 400 且不建任务。
# 三者都缺省(NULL)时 worker 走配置默认值,行为与过去一致。
model = body.get("model")
if model is None and definition is not None:
model = definition.defaults.get("model")
if model is not None:
if not isinstance(model, str) or not model.strip():
self._send_json(400, {
@@ -4329,12 +4355,16 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
return
model = model.strip()
effort = body.get("effort")
if effort is None and definition is not None:
effort = definition.defaults.get("effort")
if effort is not None and effort not in (
"low", "medium", "high", "xhigh", "max"):
self._send_json(400, {
"error": "effort must be low, medium, high, xhigh or max"})
return
approval = body.get("approval")
if approval is None and definition is not None:
approval = definition.defaults.get("approval")
if approval is not None and approval not in ("ask", "auto", "full"):
self._send_json(400, {
"error": "approval must be ask, auto or full"})
@@ -4369,7 +4399,15 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
child_effort = parsed.get("effort")
# 计划模式:只读沙箱,不改任何文件;与“完全访问”
# (danger-full-access)语义直接冲突,拒绝建任务。
plan = 1 if body.get("plan") in (True, 1) else 0
# plan 未在 body 里出现时用 agent 定义的 plan(若有),
# 否则为 0;body 里显式给了 plan(含 False)时以 body 为准。
if "plan" in body:
plan = 1 if body.get("plan") in (True, 1) else 0
elif definition is not None \
and definition.defaults.get("plan") is not None:
plan = definition.defaults["plan"]
else:
plan = 0
if plan and approval == "full":
self._send_json(400, {
"error": "plan 模式与 approval=full 冲突(只读 vs 完全访问)"})
@@ -4577,6 +4615,18 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
if not bundle_files:
self._send_json(400, {"error": "附件包为空"})
return
# agent instructions:goal 与协调员任务不接受(见
# agent_def.py 的边界说明)。非这两种时以带标记的头
# 拼进 prompt,存库的 prompt 即带头的 prompt,worker
# 派发、回读看到的都是同一份;子任务派生默认值解析
# 在前面已用原始 prompt 跑过,不受头影响。
if definition is not None and definition.instructions:
if goal_mode or verify_command or coord_parts:
self._send_json(400, {
"error": "agent instructions cannot be used with"
" goal/coordinator tasks"})
return
prompt = _agent_format_header(definition, prompt)
now = time.time()
conn = self._db()
try:
@@ -4663,6 +4713,10 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
task["quota_reserved_usd_micros"] = (
len(coord_parts)
* COORDINATOR_RESERVE_PER_PART_USD_MICROS)
if definition is not None:
# 仅新建任务的 201 响应回显定义身份;幂等返回的
# 已有任务不补这个字段(那是另一条创建记录)。
task["agent"] = definition.to_summary()
self._send_json(201, task)
def _list_tasks(self, user_id, query):
+221
View File
@@ -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)