feat: agent definition v2 (registry, persistence, developerInstructions channel)
Registry: POST /tasks accepts agent_name, loading <name>.json from repo-root agents/ (traversal-guarded, skill-pack re-validated); agent and agent_name together is a 400. Persistence: tasks gains agent_name/agent_version/agent_instructions (migrate + PG schema), returned by GET /tasks; stored prompt stays clean. Channel: local and docker dispatch pass --developer-instructions to host.py dispatch -> create_thread(developerInstructions=...); run/fork do not re-send. Cloud executor has no such channel, so the worker falls back to the v1 marked header in the in-memory dispatch prompt only (DB prompt stays clean). Goal/coordinator tasks still reject instructions. Tests: test_agent_def_v2.py (7 tests) + updated v1 suite.
This commit is contained in:
1 parent
7bb3c37a10
commit
1672ab9ff2
8 files changed
+361
-35
No files matched your search
+33
-5
@@ -39,11 +39,33 @@ today, with every boundary written down instead of implied:
|
|||||||
is rejected, so a typo or a v2 idea cannot masquerade as v1 behaviour.
|
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
|
* Unknown top-level or defaults keys are rejected for the same
|
||||||
reason: a declarative file must fail on typos, not silently ignore
|
reason: a declarative file must fail on typos, not silently ignore
|
||||||
them. Definition identity (name/version/skills) is echoed in the
|
them.
|
||||||
creation response and, via the prompt header, in the stored prompt;
|
|
||||||
v1 adds NO new tasks-table column, so ``GET /tasks`` shows the
|
v2 (this file, plus server/worker/sandbox/host changes) closes the
|
||||||
resolved fields and the header, not a separate ``agent`` column.
|
three v1 boundaries that were explicitly left open:
|
||||||
That column is v2, not something v1 pretends to have.
|
|
||||||
|
* Registry: definitions can live as files under repo-root ``agents/``
|
||||||
|
(``default_agents_dir()``) and be referenced by name
|
||||||
|
(``agent_name``) instead of only inline. The server never loads a
|
||||||
|
definition from a caller-supplied path; it only loads
|
||||||
|
``<name>.json`` under the registry dir, with the traversal guard
|
||||||
|
in ``load_agent_definition``.
|
||||||
|
* Persistence: ``tasks`` gains ``agent_name`` / ``agent_version`` /
|
||||||
|
``agent_instructions`` columns (see ``migrate_tasks_table``), so
|
||||||
|
``GET /tasks`` shows the definition identity and instructions, and
|
||||||
|
the worker no longer has to reconstruct them from a prompt header.
|
||||||
|
* Real channel: for local/docker executors, instructions travel as
|
||||||
|
``--developer-instructions`` on the host.py top-level command,
|
||||||
|
through ``sandbox`` into ``host.py dispatch`` ->
|
||||||
|
``create_thread(developerInstructions=...)``. The stored prompt
|
||||||
|
stays clean. For the generic cloud executor, which has no such
|
||||||
|
channel, the worker falls back to the v1 marked header in the
|
||||||
|
in-memory dispatch prompt only (DB prompt stays clean) -- that
|
||||||
|
fallback is the documented cloud boundary, not a claim that the
|
||||||
|
cloud runner honours developerInstructions. Goal and coordinator
|
||||||
|
tasks still reject instructions in v2: the goal loop and the
|
||||||
|
coordinator parent do not pass instructions through, and silently
|
||||||
|
dropping them there would be worse than rejecting them.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -62,6 +84,12 @@ _TOP_LEVEL_KEYS = ("name", "version", "description", "instructions",
|
|||||||
"skills", "defaults")
|
"skills", "defaults")
|
||||||
_DEFAULTS_KEYS = ("model", "effort", "approval", "executor", "plan")
|
_DEFAULTS_KEYS = ("model", "effort", "approval", "executor", "plan")
|
||||||
SKILLS_DIR = Path(__file__).resolve().parent / "skills"
|
SKILLS_DIR = Path(__file__).resolve().parent / "skills"
|
||||||
|
AGENTS_DIR = Path(__file__).resolve().parent.parent / "agents"
|
||||||
|
|
||||||
|
|
||||||
|
def default_agents_dir():
|
||||||
|
"""Repo-root ``agents/`` directory (definitions live with code)."""
|
||||||
|
return AGENTS_DIR
|
||||||
|
|
||||||
|
|
||||||
def list_known_skills(skills_dir=None):
|
def list_known_skills(skills_dir=None):
|
||||||
|
|||||||
@@ -880,6 +880,9 @@ POSTGRES_SCHEMA = (
|
|||||||
"parent_task_id TEXT, "
|
"parent_task_id TEXT, "
|
||||||
"coordinator_part TEXT, "
|
"coordinator_part TEXT, "
|
||||||
"goal_resume_pending INTEGER DEFAULT 0, "
|
"goal_resume_pending INTEGER DEFAULT 0, "
|
||||||
|
"agent_name TEXT, "
|
||||||
|
"agent_version TEXT, "
|
||||||
|
"agent_instructions TEXT, "
|
||||||
"UNIQUE(project_id, idempotency_key))",
|
"UNIQUE(project_id, idempotency_key))",
|
||||||
"CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status)",
|
"CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status)",
|
||||||
# 附件包与任务附件:与 server._ensure_uploads_tables 同款,
|
# 附件包与任务附件:与 server._ensure_uploads_tables 同款,
|
||||||
|
|||||||
+20
-4
@@ -351,7 +351,8 @@ class LocalExecutor(Executor):
|
|||||||
self.model = model
|
self.model = model
|
||||||
|
|
||||||
def _base_command(self, model=None, effort=None, approval=None,
|
def _base_command(self, model=None, effort=None, approval=None,
|
||||||
sandbox=None, model_provider=None):
|
sandbox=None, model_provider=None,
|
||||||
|
developer_instructions=None):
|
||||||
cmd = [sys.executable, self.host_py_path]
|
cmd = [sys.executable, self.host_py_path]
|
||||||
if self.config_path is not None:
|
if self.config_path is not None:
|
||||||
cmd += ["--config", self.config_path]
|
cmd += ["--config", self.config_path]
|
||||||
@@ -367,6 +368,13 @@ class LocalExecutor(Executor):
|
|||||||
if effort:
|
if effort:
|
||||||
cmd += ["--effort", effort]
|
cmd += ["--effort", effort]
|
||||||
cmd += _approval_cli_args(approval)
|
cmd += _approval_cli_args(approval)
|
||||||
|
# agent 定义 v2:仅 dispatch_task 传,run/fork/goal
|
||||||
|
# 不传(线程已建好指令,续跑不重复发;fork 靠线程
|
||||||
|
# 分叉继承,goal 不接受指令)。空值不传参,旧命令
|
||||||
|
# 逐字节不变。
|
||||||
|
if developer_instructions:
|
||||||
|
cmd += ["--developer-instructions",
|
||||||
|
developer_instructions]
|
||||||
# 显式沙箱放最后:argparse 里后出现的 --sandbox 覆盖前面
|
# 显式沙箱放最后:argparse 里后出现的 --sandbox 覆盖前面
|
||||||
# 的(approval=full 自带 danger-full-access);server 侧
|
# 的(approval=full 自带 danger-full-access);server 侧
|
||||||
# 已拒绝 plan+full 同存,这里是第二道防线。
|
# 已拒绝 plan+full 同存,这里是第二道防线。
|
||||||
@@ -545,7 +553,9 @@ class LocalExecutor(Executor):
|
|||||||
approval=task.get("approval"),
|
approval=task.get("approval"),
|
||||||
sandbox=("read-only"
|
sandbox=("read-only"
|
||||||
if task.get("plan") else None),
|
if task.get("plan") else None),
|
||||||
model_provider=model_provider)
|
model_provider=model_provider,
|
||||||
|
developer_instructions=task.get(
|
||||||
|
"agent_instructions"))
|
||||||
cmd += ["dispatch", "--name", self._host_name(task),
|
cmd += ["dispatch", "--name", self._host_name(task),
|
||||||
"--task", task["prompt"]]
|
"--task", task["prompt"]]
|
||||||
repo = task.get("repo") or self.repo
|
repo = task.get("repo") or self.repo
|
||||||
@@ -1162,7 +1172,8 @@ class DockerExecutor(Executor):
|
|||||||
|
|
||||||
def _inner_host_command(self, *args, model=None, effort=None,
|
def _inner_host_command(self, *args, model=None, effort=None,
|
||||||
approval=None, sandbox=None,
|
approval=None, sandbox=None,
|
||||||
model_provider=None):
|
model_provider=None,
|
||||||
|
developer_instructions=None):
|
||||||
"""容器内 host.py 命令:python3 /app/host.py [args...]。
|
"""容器内 host.py 命令:python3 /app/host.py [args...]。
|
||||||
|
|
||||||
config_path 若已给,容器内用 /app/<basename> 指代它——
|
config_path 若已给,容器内用 /app/<basename> 指代它——
|
||||||
@@ -1191,6 +1202,10 @@ class DockerExecutor(Executor):
|
|||||||
cmd += _approval_cli_args(approval)
|
cmd += _approval_cli_args(approval)
|
||||||
if sandbox:
|
if sandbox:
|
||||||
cmd += ["--sandbox", sandbox]
|
cmd += ["--sandbox", sandbox]
|
||||||
|
# agent 定义 v2:仅 dispatch_task 传,语义同本地版。
|
||||||
|
if developer_instructions:
|
||||||
|
cmd += ["--developer-instructions",
|
||||||
|
developer_instructions]
|
||||||
cmd += list(args)
|
cmd += list(args)
|
||||||
return cmd
|
return cmd
|
||||||
|
|
||||||
@@ -1421,7 +1436,8 @@ class DockerExecutor(Executor):
|
|||||||
"--task", task["prompt"], model=task.get("model"),
|
"--task", task["prompt"], model=task.get("model"),
|
||||||
effort=task.get("effort"), approval=task.get("approval"),
|
effort=task.get("effort"), approval=task.get("approval"),
|
||||||
sandbox=("read-only" if task.get("plan") else None),
|
sandbox=("read-only" if task.get("plan") else None),
|
||||||
model_provider=model_provider)
|
model_provider=model_provider,
|
||||||
|
developer_instructions=task.get("agent_instructions"))
|
||||||
repo = task.get("repo")
|
repo = task.get("repo")
|
||||||
if repo:
|
if repo:
|
||||||
inner += ["--repo", repo]
|
inner += ["--repo", repo]
|
||||||
|
|||||||
+74
-17
@@ -57,8 +57,9 @@ from decimal import Decimal
|
|||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from urllib.parse import parse_qs, quote, urlparse
|
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 default_agents_dir as _agent_default_dir
|
||||||
from cloud.agent_def import list_known_skills as _agent_known_skills
|
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 parse_agent_definition as _parse_agent_definition
|
||||||
from cloud.auth import ROLES as _MEMBER_ROLES
|
from cloud.auth import ROLES as _MEMBER_ROLES
|
||||||
from cloud.auth import AuthStore
|
from cloud.auth import AuthStore
|
||||||
@@ -1236,6 +1237,12 @@ def migrate_tasks_table(conn):
|
|||||||
"auto_resume_stall_count": "INTEGER DEFAULT 0",
|
"auto_resume_stall_count": "INTEGER DEFAULT 0",
|
||||||
"checkpoint_round_at_last_resume": "INTEGER",
|
"checkpoint_round_at_last_resume": "INTEGER",
|
||||||
"last_error_signature": "TEXT",
|
"last_error_signature": "TEXT",
|
||||||
|
# agent 定义 v2:定义身份与指令落库(NULL = 未用定义)。
|
||||||
|
# 老库行缺列时按 NULL 计,worker/server 读时用 _col /
|
||||||
|
# _row_get 容错,不炸。
|
||||||
|
"agent_name": "TEXT",
|
||||||
|
"agent_version": "TEXT",
|
||||||
|
"agent_instructions": "TEXT",
|
||||||
}
|
}
|
||||||
for name, coltype in new_cols.items():
|
for name, coltype in new_cols.items():
|
||||||
if name not in cols:
|
if name not in cols:
|
||||||
@@ -1833,6 +1840,11 @@ def _task_to_dict(row, attachments=None, total_tokens=None):
|
|||||||
"parent_task_id": _row_get(row, "parent_task_id"),
|
"parent_task_id": _row_get(row, "parent_task_id"),
|
||||||
"coordinator_part": _row_get(row, "coordinator_part"),
|
"coordinator_part": _row_get(row, "coordinator_part"),
|
||||||
"goal_resume_pending": _row_get(row, "goal_resume_pending") or 0,
|
"goal_resume_pending": _row_get(row, "goal_resume_pending") or 0,
|
||||||
|
# agent 定义 v2:身份与指令落库后直接回读(老行缺列
|
||||||
|
# 时为 None;指令是定义文本、非密钥,任务详情里可见)。
|
||||||
|
"agent_name": _row_get(row, "agent_name"),
|
||||||
|
"agent_version": _row_get(row, "agent_version"),
|
||||||
|
"agent_instructions": _row_get(row, "agent_instructions"),
|
||||||
"paused_by_user": _row_get(row, "paused_by_user") or 0,
|
"paused_by_user": _row_get(row, "paused_by_user") or 0,
|
||||||
"interrupted_reason": _row_get(row, "interrupted_reason"),
|
"interrupted_reason": _row_get(row, "interrupted_reason"),
|
||||||
"auto_resume_count": _row_get(row, "auto_resume_count") or 0,
|
"auto_resume_count": _row_get(row, "auto_resume_count") or 0,
|
||||||
@@ -4301,12 +4313,18 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
|||||||
return
|
return
|
||||||
if prompt_goal_target:
|
if prompt_goal_target:
|
||||||
prompt = prompt_goal_target
|
prompt = prompt_goal_target
|
||||||
# 声明式 agent 定义 v1(见 cloud/agent_def.py):inline
|
# 声明式 agent 定义 v1/v2(见 cloud/agent_def.py):
|
||||||
# agent 对象,逐字段填充任务选项;显式 body 字段优先
|
# inline agent 对象,或 agent_name(注册表 agents/ 下
|
||||||
# 于定义,定义本身不新开任务表列,解析出的字段落进
|
# 的 <name>.json);两者不能同时给,歧义不猜。显式
|
||||||
# 已有列,instructions 以带标记的头拼进 prompt(见后),
|
# body 字段逐项优先于定义;解析出的字段落进已有列,
|
||||||
# 身份在创建响应里回显。
|
# 定义身份与指令落进 agent_name/agent_version/
|
||||||
|
# agent_instructions 列(v2),prompt 不再带头。
|
||||||
definition = None
|
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:
|
if body.get("agent") is not None:
|
||||||
if not isinstance(body.get("agent"), dict):
|
if not isinstance(body.get("agent"), dict):
|
||||||
self._send_json(400, {"error": "agent must be an object"})
|
self._send_json(400, {"error": "agent must be an object"})
|
||||||
@@ -4318,6 +4336,36 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
|||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
self._send_json(400, {"error": str(exc)})
|
self._send_json(400, {"error": str(exc)})
|
||||||
return
|
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,
|
||||||
|
"defaults": definition.defaults,
|
||||||
|
},
|
||||||
|
known_skills=_agent_known_skills())
|
||||||
|
except ValueError as exc:
|
||||||
|
self._send_json(400, {"error": str(exc)})
|
||||||
|
return
|
||||||
depends_on = body.get("depends_on") or []
|
depends_on = body.get("depends_on") or []
|
||||||
if isinstance(depends_on, str):
|
if isinstance(depends_on, str):
|
||||||
depends_on = [depends_on]
|
depends_on = [depends_on]
|
||||||
@@ -4615,18 +4663,22 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
|||||||
if not bundle_files:
|
if not bundle_files:
|
||||||
self._send_json(400, {"error": "附件包为空"})
|
self._send_json(400, {"error": "附件包为空"})
|
||||||
return
|
return
|
||||||
# agent instructions:goal 与协调员任务不接受(见
|
# agent instructions v2:goal 与协调员任务仍不接受
|
||||||
# agent_def.py 的边界说明)。非这两种时以带标记的头
|
# (goal 循环与协调员父任务不传 instructions,见
|
||||||
# 拼进 prompt,存库的 prompt 即带头的 prompt,worker
|
# agent_def.py 的 v2 边界)。非这两种时指令不拼进
|
||||||
# 派发、回读看到的都是同一份;子任务派生默认值解析
|
# prompt,存进 agent_instructions 列,由 worker 按
|
||||||
# 在前面已用原始 prompt 跑过,不受头影响。
|
# executor 选通道派发(local/docker 走
|
||||||
|
# --developer-instructions,cloud 走内存里的头回落)。
|
||||||
if definition is not None and definition.instructions:
|
if definition is not None and definition.instructions:
|
||||||
if goal_mode or verify_command or coord_parts:
|
if goal_mode or verify_command or coord_parts:
|
||||||
self._send_json(400, {
|
self._send_json(400, {
|
||||||
"error": "agent instructions cannot be used with"
|
"error": "agent instructions cannot be used with"
|
||||||
" goal/coordinator tasks"})
|
" goal/coordinator tasks"})
|
||||||
return
|
return
|
||||||
prompt = _agent_format_header(definition, prompt)
|
agent_name_val = definition.name if definition else None
|
||||||
|
agent_version_val = definition.version if definition else None
|
||||||
|
agent_instructions_val = (
|
||||||
|
definition.instructions if definition else None)
|
||||||
now = time.time()
|
now = time.time()
|
||||||
conn = self._db()
|
conn = self._db()
|
||||||
try:
|
try:
|
||||||
@@ -4651,16 +4703,19 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
|||||||
" model, effort, approval, plan, goal_mode,"
|
" model, effort, approval, plan, goal_mode,"
|
||||||
" environment_id, coordinator_parts,"
|
" environment_id, coordinator_parts,"
|
||||||
" parent_task_id, coordinator_part,"
|
" parent_task_id, coordinator_part,"
|
||||||
" child_model, child_effort)"
|
" child_model, child_effort,"
|
||||||
|
" agent_name, agent_version, agent_instructions)"
|
||||||
" VALUES(?, ?, ?, 'queued', ?, ?, ?, ?, ?, ?, NULL, ?,"
|
" VALUES(?, ?, ?, 'queued', ?, ?, ?, ?, ?, ?, NULL, ?,"
|
||||||
" ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
" ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
(task_id, project_id, prompt, idempotency_key,
|
(task_id, project_id, prompt, idempotency_key,
|
||||||
json.dumps(depends_on), priority, user_id, now, now,
|
json.dumps(depends_on), priority, user_id, now, now,
|
||||||
executor, goal, verify_command, goal_max_rounds,
|
executor, goal, verify_command, goal_max_rounds,
|
||||||
model, effort, approval, plan, goal_mode,
|
model, effort, approval, plan, goal_mode,
|
||||||
environment_id,
|
environment_id,
|
||||||
json.dumps(coord_parts) if coord_parts else None,
|
json.dumps(coord_parts) if coord_parts else None,
|
||||||
None, None, child_model, child_effort),
|
None, None, child_model, child_effort,
|
||||||
|
agent_name_val, agent_version_val,
|
||||||
|
agent_instructions_val),
|
||||||
)
|
)
|
||||||
# 长任务不限时:API 字段 no_time_limit=1 时写标记,
|
# 长任务不限时:API 字段 no_time_limit=1 时写标记,
|
||||||
# worker 认领后按不限时跑(单轮 host 600 秒仍在);
|
# worker 认领后按不限时跑(单轮 host 600 秒仍在);
|
||||||
@@ -4714,8 +4769,10 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
|
|||||||
len(coord_parts)
|
len(coord_parts)
|
||||||
* COORDINATOR_RESERVE_PER_PART_USD_MICROS)
|
* COORDINATOR_RESERVE_PER_PART_USD_MICROS)
|
||||||
if definition is not None:
|
if definition is not None:
|
||||||
# 仅新建任务的 201 响应回显定义身份;幂等返回的
|
# 201 响应回显定义身份与技能(v1 兼容);幂等返回
|
||||||
# 已有任务不补这个字段(那是另一条创建记录)。
|
# 的已有任务靠 _task_to_dict 里的 agent_name /
|
||||||
|
# agent_version / agent_instructions(v2 落库后
|
||||||
|
# 回读即有),不补 agent 汇总字段。
|
||||||
task["agent"] = definition.to_summary()
|
task["agent"] = definition.to_summary()
|
||||||
self._send_json(201, task)
|
self._send_json(201, task)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
"""声明式 agent 定义 v1 单测:解析、校验、解析到任务字段、建任务入口。
|
"""声明式 agent 定义 v1/v2 单测:解析、校验、解析到任务字段、建任务入口。
|
||||||
|
|
||||||
|
v2 改动:prompt 不再拼头,指令落库(见 test_agent_def_v2.py)。
|
||||||
|
|
||||||
运行(在仓库根目录下):
|
运行(在仓库根目录下):
|
||||||
python3 -m unittest discover -s cloud/tests -p "test_agent_def.py" -v
|
python3 -m unittest discover -s cloud/tests -p "test_agent_def.py" -v
|
||||||
@@ -168,7 +170,9 @@ class ServerAgentTestCase(unittest.TestCase):
|
|||||||
self.assertEqual(status, 200)
|
self.assertEqual(status, 200)
|
||||||
return data["tasks"]
|
return data["tasks"]
|
||||||
|
|
||||||
def test_agent_fills_fields_and_header(self):
|
def test_agent_fills_fields_and_stores_instructions(self):
|
||||||
|
# v2:prompt 保持干净,指令落库,不再拼头(v1 行为已
|
||||||
|
# 被 v2 替换,见 agent_def.py 的 v2 边界说明)。
|
||||||
status, task = self._req("POST", "/tasks", {
|
status, task = self._req("POST", "/tasks", {
|
||||||
"project_id": self.project["id"], "prompt": "do research",
|
"project_id": self.project["id"], "prompt": "do research",
|
||||||
"agent": _agent()}, self.owner_key)
|
"agent": _agent()}, self.owner_key)
|
||||||
@@ -179,10 +183,14 @@ class ServerAgentTestCase(unittest.TestCase):
|
|||||||
self.assertEqual(task["executor"], "docker")
|
self.assertEqual(task["executor"], "docker")
|
||||||
self.assertEqual(task["agent"]["name"], "research-brief")
|
self.assertEqual(task["agent"]["name"], "research-brief")
|
||||||
self.assertEqual(task["agent"]["skills"], ["live-info"])
|
self.assertEqual(task["agent"]["skills"], ["live-info"])
|
||||||
|
self.assertEqual(task["agent_name"], "research-brief")
|
||||||
|
self.assertEqual(task["agent_version"], "v1")
|
||||||
|
self.assertEqual(task["agent_instructions"],
|
||||||
|
"Cite sources and dates.")
|
||||||
stored = self._list_tasks()[0]
|
stored = self._list_tasks()[0]
|
||||||
self.assertTrue(stored["prompt"].startswith(
|
self.assertEqual(stored["prompt"], "do research")
|
||||||
"[Agent: research-brief v1]\nCite sources and dates.\n\n"))
|
self.assertEqual(stored["agent_instructions"],
|
||||||
self.assertTrue(stored["prompt"].endswith("do research"))
|
"Cite sources and dates.")
|
||||||
|
|
||||||
def test_explicit_body_wins_over_agent(self):
|
def test_explicit_body_wins_over_agent(self):
|
||||||
status, task = self._req("POST", "/tasks", {
|
status, task = self._req("POST", "/tasks", {
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
"""声明式 agent 定义 v2 单测:注册表、落库、developerInstructions 通道。
|
||||||
|
|
||||||
|
运行(在仓库根目录下):
|
||||||
|
python3 -m unittest discover -s cloud/tests -p "test_agent_def_v2.py" -v
|
||||||
|
|
||||||
|
覆盖:agent_name 查注册表、agent 与 agent_name 同给 400、
|
||||||
|
未知 agent_name 400、本地/docker 派发命令含
|
||||||
|
--developer-instructions、无指令时命令不变、host dispatch 把
|
||||||
|
指令传给 create_thread 的 instructions(即 developerInstructions)。
|
||||||
|
不跑真实模型、不联网;host 部分用假 server,只断言传参。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import http.client
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
from cloud.agent_def import default_agents_dir
|
||||||
|
from cloud.auth import AuthStore
|
||||||
|
from cloud.sandbox import DockerExecutor, LocalExecutor
|
||||||
|
from cloud.server import create_server
|
||||||
|
except ImportError:
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||||
|
from cloud.agent_def import default_agents_dir
|
||||||
|
from cloud.auth import AuthStore
|
||||||
|
from cloud.sandbox import DockerExecutor, LocalExecutor
|
||||||
|
from cloud.server import create_server
|
||||||
|
|
||||||
|
|
||||||
|
def _load_host():
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
"hostmod_v2", str(Path(__file__).resolve().parents[2] / "host.py"))
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
class ServerAgentNameTestCase(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_name_from_registry(self):
|
||||||
|
# 注册表里有 agents/research-brief.json(仓库自带示例)。
|
||||||
|
self.assertTrue((default_agents_dir()
|
||||||
|
/ "research-brief.json").is_file())
|
||||||
|
status, task = self._req("POST", "/tasks", {
|
||||||
|
"project_id": self.project["id"], "prompt": "do research",
|
||||||
|
"agent_name": "research-brief"}, self.owner_key)
|
||||||
|
self.assertEqual(status, 201, task)
|
||||||
|
self.assertEqual(task["agent_name"], "research-brief")
|
||||||
|
self.assertEqual(task["agent_version"], "v1")
|
||||||
|
self.assertEqual(task["effort"], "medium")
|
||||||
|
self.assertEqual(task["executor"], "docker")
|
||||||
|
self.assertEqual(task["prompt"], "do research")
|
||||||
|
stored = self._list_tasks()[0]
|
||||||
|
self.assertEqual(stored["agent_instructions"][:20],
|
||||||
|
"Confirm the question")
|
||||||
|
|
||||||
|
def test_agent_and_agent_name_conflict(self):
|
||||||
|
status, _data = self._req("POST", "/tasks", {
|
||||||
|
"project_id": self.project["id"], "prompt": "do research",
|
||||||
|
"agent": {"name": "research-brief"},
|
||||||
|
"agent_name": "research-brief"}, self.owner_key)
|
||||||
|
self.assertEqual(status, 400)
|
||||||
|
self.assertEqual(self._list_tasks(), [])
|
||||||
|
|
||||||
|
def test_unknown_agent_name(self):
|
||||||
|
status, _data = self._req("POST", "/tasks", {
|
||||||
|
"project_id": self.project["id"], "prompt": "do research",
|
||||||
|
"agent_name": "no-such-agent"}, self.owner_key)
|
||||||
|
self.assertEqual(status, 400)
|
||||||
|
self.assertEqual(self._list_tasks(), [])
|
||||||
|
|
||||||
|
|
||||||
|
class InstructionsChannelTestCase(unittest.TestCase):
|
||||||
|
def test_local_dispatch_includes_instructions(self):
|
||||||
|
captured = []
|
||||||
|
|
||||||
|
def runner(cmd, env=None):
|
||||||
|
captured.append(cmd)
|
||||||
|
return {"returncode": 0, "stdout": "ok", "stderr": ""}
|
||||||
|
|
||||||
|
ex = LocalExecutor(host_py_path="host.py", runner=runner,
|
||||||
|
repo="/tmp/repo")
|
||||||
|
ex.dispatch_task({"id": "t1", "prompt": "hi",
|
||||||
|
"agent_instructions": "be careful"})
|
||||||
|
self.assertIn("--developer-instructions", captured[0])
|
||||||
|
idx = captured[0].index("--developer-instructions")
|
||||||
|
self.assertEqual(captured[0][idx + 1], "be careful")
|
||||||
|
self.assertLess(idx, captured[0].index("dispatch"))
|
||||||
|
|
||||||
|
def test_local_dispatch_without_instructions_unchanged(self):
|
||||||
|
captured = []
|
||||||
|
|
||||||
|
def runner(cmd, env=None):
|
||||||
|
captured.append(cmd)
|
||||||
|
return {"returncode": 0, "stdout": "ok", "stderr": ""}
|
||||||
|
|
||||||
|
ex = LocalExecutor(host_py_path="host.py", runner=runner,
|
||||||
|
repo="/tmp/repo")
|
||||||
|
ex.dispatch_task({"id": "t1", "prompt": "hi"})
|
||||||
|
self.assertNotIn("--developer-instructions", captured[0])
|
||||||
|
|
||||||
|
def test_docker_inner_command_includes_instructions(self):
|
||||||
|
ex = DockerExecutor.__new__(DockerExecutor)
|
||||||
|
ex.config_path = None
|
||||||
|
ex.model = None
|
||||||
|
ex.ignore_host_budget = False
|
||||||
|
cmd = ex._inner_host_command(
|
||||||
|
"dispatch", model="m",
|
||||||
|
developer_instructions="be careful")
|
||||||
|
self.assertIn("--developer-instructions", cmd)
|
||||||
|
self.assertEqual(cmd[cmd.index("--developer-instructions") + 1],
|
||||||
|
"be careful")
|
||||||
|
|
||||||
|
def test_host_dispatch_passes_instructions(self):
|
||||||
|
host = _load_host()
|
||||||
|
|
||||||
|
class FakeServer:
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def create_thread(self, cwd, model=None, sandbox=None,
|
||||||
|
approval_policy=None, instructions=None):
|
||||||
|
self.calls.append(instructions)
|
||||||
|
return "thr1"
|
||||||
|
|
||||||
|
srv = FakeServer()
|
||||||
|
state = {"tasks": {}}
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
host.dispatch(srv, state, "t1", "hi", tmpdir, "m", "low",
|
||||||
|
None, None, use_worktree=False,
|
||||||
|
developer_instructions="be careful")
|
||||||
|
self.assertEqual(srv.calls, ["be careful"])
|
||||||
@@ -134,6 +134,8 @@ import time
|
|||||||
import uuid
|
import uuid
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from cloud.agent_def import AgentDefinition as _AgentDefinition
|
||||||
|
from cloud.agent_def import format_instructions_header as _agent_header
|
||||||
from cloud.auth import AuthStore
|
from cloud.auth import AuthStore
|
||||||
from cloud.coordinator import build_report as _coord_build_report
|
from cloud.coordinator import build_report as _coord_build_report
|
||||||
from cloud.coordinator import DEFAULT_POLL_INTERVAL as _COORD_POLL_INTERVAL
|
from cloud.coordinator import DEFAULT_POLL_INTERVAL as _COORD_POLL_INTERVAL
|
||||||
@@ -412,6 +414,10 @@ def _task_from_row(row):
|
|||||||
"coordinator_parts": _coord_parse_parts(_col("coordinator_parts")),
|
"coordinator_parts": _coord_parse_parts(_col("coordinator_parts")),
|
||||||
"parent_task_id": _col("parent_task_id"),
|
"parent_task_id": _col("parent_task_id"),
|
||||||
"coordinator_part": _col("coordinator_part"),
|
"coordinator_part": _col("coordinator_part"),
|
||||||
|
# agent 定义 v2:老行缺列时 None = 未用定义,不炸。
|
||||||
|
"agent_name": _col("agent_name"),
|
||||||
|
"agent_version": _col("agent_version"),
|
||||||
|
"agent_instructions": _col("agent_instructions"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -5864,6 +5870,21 @@ class Worker:
|
|||||||
task_id, decision.tier, decision.source,
|
task_id, decision.tier, decision.source,
|
||||||
decision.applied))
|
decision.applied))
|
||||||
task["model"] = decision.chosen_model or self.model
|
task["model"] = decision.chosen_model or self.model
|
||||||
|
# agent 指令通道(v2):local/docker 由 sandbox 的
|
||||||
|
# --developer-instructions 派发,prompt 保持干净;通用
|
||||||
|
# cloud 执行器没有这个通道,只能在这里把 v1 的带标记
|
||||||
|
# 头拼进内存里的派发 prompt(只改本轮 task 副本,不写
|
||||||
|
# 库,库 prompt 保持干净),否则 cloud 任务会比 v1
|
||||||
|
# 倒退成指令全丢。已带头的 prompt 不重复拼。
|
||||||
|
if task.get("executor") == "cloud" \
|
||||||
|
and task.get("agent_instructions") \
|
||||||
|
and not (task.get("prompt") or "").startswith("[Agent:"):
|
||||||
|
task["prompt"] = _agent_header(
|
||||||
|
_AgentDefinition(
|
||||||
|
name=task.get("agent_name") or "agent",
|
||||||
|
version=task.get("agent_version"),
|
||||||
|
instructions=task["agent_instructions"]),
|
||||||
|
task.get("prompt") or "")
|
||||||
actual_model = task["model"]
|
actual_model = task["model"]
|
||||||
# 有限重试与真实 fallback 的优先级:取消不重试;一次尝试失败
|
# 有限重试与真实 fallback 的优先级:取消不重试;一次尝试失败
|
||||||
# 且可 fallback、且显式配了 fallback_models 时,优先真换模型
|
# 且可 fallback、且显式配了 fallback_models 时,优先真换模型
|
||||||
|
|||||||
@@ -1803,7 +1803,8 @@ def _seed_worktree(cwd, seed_dir):
|
|||||||
|
|
||||||
def dispatch(server, state, name, prompt, repo, model, effort, sandbox,
|
def dispatch(server, state, name, prompt, repo, model, effort, sandbox,
|
||||||
approval_policy, use_worktree=True, parent_task=None,
|
approval_policy, use_worktree=True, parent_task=None,
|
||||||
depends_on=None, priority=0, max_retries=0, seed_dir=None):
|
depends_on=None, priority=0, max_retries=0, seed_dir=None,
|
||||||
|
developer_instructions=None):
|
||||||
if name in state["tasks"]:
|
if name in state["tasks"]:
|
||||||
raise RuntimeError(f"task 已存在: {name},先换名或 discard 它")
|
raise RuntimeError(f"task 已存在: {name},先换名或 discard 它")
|
||||||
if repo and not os.path.isabs(repo) and not os.path.exists(repo):
|
if repo and not os.path.isabs(repo) and not os.path.exists(repo):
|
||||||
@@ -1823,8 +1824,10 @@ def dispatch(server, state, name, prompt, repo, model, effort, sandbox,
|
|||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"seed-dir 需要 worktree,当前派发没有建 worktree")
|
"seed-dir 需要 worktree,当前派发没有建 worktree")
|
||||||
_seed_worktree(cwd, seed_dir)
|
_seed_worktree(cwd, seed_dir)
|
||||||
thread_id = server.create_thread(cwd, model=model, sandbox=sandbox,
|
thread_id = server.create_thread(
|
||||||
approval_policy=approval_policy)
|
cwd, model=model, sandbox=sandbox,
|
||||||
|
approval_policy=approval_policy,
|
||||||
|
instructions=developer_instructions)
|
||||||
task = {"name": name, "thread_id": thread_id, "cwd": cwd,
|
task = {"name": name, "thread_id": thread_id, "cwd": cwd,
|
||||||
"repo": os.path.abspath(repo) if repo else None,
|
"repo": os.path.abspath(repo) if repo else None,
|
||||||
"branch": branch, "sandbox": sandbox, "status": "running",
|
"branch": branch, "sandbox": sandbox, "status": "running",
|
||||||
@@ -2247,6 +2250,10 @@ def main():
|
|||||||
ap.add_argument("--sandbox", default=None)
|
ap.add_argument("--sandbox", default=None)
|
||||||
ap.add_argument("--approval-policy", default=None)
|
ap.add_argument("--approval-policy", default=None)
|
||||||
ap.add_argument("--approval", default=None, choices=["auto", "deny"])
|
ap.add_argument("--approval", default=None, choices=["auto", "deny"])
|
||||||
|
ap.add_argument("--developer-instructions", default=None,
|
||||||
|
help="agent 定义 v2:派发时传给 codex 的"
|
||||||
|
" developerInstructions(只在 dispatch 建"
|
||||||
|
" 线程时用,run/goal/fork 不用它)")
|
||||||
ap.add_argument("--turn-timeout", type=int, default=None,
|
ap.add_argument("--turn-timeout", type=int, default=None,
|
||||||
help="单轮等待上限(秒);0 表示不限时、对齐 Codex App "
|
help="单轮等待上限(秒);0 表示不限时、对齐 Codex App "
|
||||||
"等 turn/completed 的语义;不传时用配置 "
|
"等 turn/completed 的语义;不传时用配置 "
|
||||||
@@ -2709,7 +2716,9 @@ def main():
|
|||||||
use_worktree=not args.no_worktree,
|
use_worktree=not args.no_worktree,
|
||||||
depends_on=deps, priority=args.priority,
|
depends_on=deps, priority=args.priority,
|
||||||
max_retries=args.max_retries,
|
max_retries=args.max_retries,
|
||||||
seed_dir=args.seed_dir)
|
seed_dir=args.seed_dir,
|
||||||
|
developer_instructions=(
|
||||||
|
args.developer_instructions))
|
||||||
# 生产教训:先落盘任务登记再跑 turn,中途被杀/超时后
|
# 生产教训:先落盘任务登记再跑 turn,中途被杀/超时后
|
||||||
# 任务还在 host-state 里,可用 run 续跑;否则 turn 中断
|
# 任务还在 host-state 里,可用 run 续跑;否则 turn 中断
|
||||||
# 就等于任务丢了(2026-10-25 委派评审 300 秒超时实测)。
|
# 就等于任务丢了(2026-10-25 委派评审 300 秒超时实测)。
|
||||||
|
|||||||
Reference in new issue
Block a user