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,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
@@ -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):
|
||||
|
||||
@@ -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