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:
host committed 2026-11-04 18:01:44 +00:00
1 parent 7bb3c37a10
commit 1672ab9ff2
8 files changed
+361 -35

No files matched your search

+74 -17
View File
@@ -57,8 +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 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.auth import ROLES as _MEMBER_ROLES
from cloud.auth import AuthStore
@@ -1236,6 +1237,12 @@ def migrate_tasks_table(conn):
"auto_resume_stall_count": "INTEGER DEFAULT 0",
"checkpoint_round_at_last_resume": "INTEGER",
"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():
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"),
"coordinator_part": _row_get(row, "coordinator_part"),
"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,
"interrupted_reason": _row_get(row, "interrupted_reason"),
"auto_resume_count": _row_get(row, "auto_resume_count") or 0,
@@ -4301,12 +4313,18 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
return
if prompt_goal_target:
prompt = prompt_goal_target
# 声明式 agent 定义 v1(见 cloud/agent_def.py):inline
# agent 对象,逐字段填充任务选项;显式 body 字段优先
# 于定义,定义本身不新开任务表列,解析出的字段落进
# 已有列,instructions 以带标记的头拼进 prompt(见后),
# 身份在创建响应里回显。
# 声明式 agent 定义 v1/v2(见 cloud/agent_def.py):
# inline agent 对象,或 agent_name(注册表 agents/ 下
# 的 <name>.json);两者不能同时给,歧义不猜。显式
# 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:
if not isinstance(body.get("agent"), dict):
self._send_json(400, {"error": "agent must be an object"})
@@ -4318,6 +4336,36 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
except ValueError as exc:
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,
"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 []
if isinstance(depends_on, str):
depends_on = [depends_on]
@@ -4615,18 +4663,22 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
if not bundle_files:
self._send_json(400, {"error": "附件包为空"})
return
# agent instructions:goal 与协调员任务不接受(见
# agent_def.py 的边界说明)。非这两种时以带标记的头
# 拼进 prompt,存库的 prompt 即带头的 prompt,worker
# 派发、回读看到的都是同一份;子任务派生默认值解析
# 在前面已用原始 prompt 跑过,不受头影响。
# agent instructions v2:goal 与协调员任务仍不接受
# (goal 循环与协调员父任务不传 instructions,见
# agent_def.py 的 v2 边界)。非这两种时指令不拼进
# prompt,存进 agent_instructions 列,由 worker 按
# executor 选通道派发(local/docker 走
# --developer-instructions,cloud 走内存里的头回落)。
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)
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()
conn = self._db()
try:
@@ -4651,16 +4703,19 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
" model, effort, approval, plan, goal_mode,"
" environment_id, coordinator_parts,"
" parent_task_id, coordinator_part,"
" child_model, child_effort)"
" child_model, child_effort,"
" agent_name, agent_version, agent_instructions)"
" VALUES(?, ?, ?, 'queued', ?, ?, ?, ?, ?, ?, NULL, ?,"
" ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
" ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(task_id, project_id, prompt, idempotency_key,
json.dumps(depends_on), priority, user_id, now, now,
executor, goal, verify_command, goal_max_rounds,
model, effort, approval, plan, goal_mode,
environment_id,
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 时写标记,
# worker 认领后按不限时跑(单轮 host 600 秒仍在);
@@ -4714,8 +4769,10 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
len(coord_parts)
* COORDINATOR_RESERVE_PER_PART_USD_MICROS)
if definition is not None:
# 仅新建任务的 201 响应回显定义身份;幂等返回的
# 已有任务不补这个字段(那是另一条创建记录)。
# 201 响应回显定义身份与技能(v1 兼容);幂等返回
# 的已有任务靠 _task_to_dict 里的 agent_name /
# agent_version / agent_instructions(v2 落库后
# 回读即有),不补 agent 汇总字段。
task["agent"] = definition.to_summary()
self._send_json(201, task)