feat: agent tools as required tools with tenant tool gate

Agent definitions gain tools (websearch/imagegen/playwright,
unknown tool is a 400), stored as agent_tools JSON and returned
by GET /tasks. This is deliberately required-tools, not an
allowlist: create_thread has no tool allowlist and tenant homes
are per-project shared, so blocking other tools cannot be
claimed. Instead the worker fails fast before any model call
when a declared tool has no [mcp_servers.<tool>] section in
the tenant codex-home config.toml (checked after _tenant_dirs
seeding, so a section seeded earlier by another worker counts
as present). The gate runs only for tenant-isolated Docker
workers; local/cloud workers skip it, documented, not verified.

Tests: test_agent_tools.py (8 tests).
This commit is contained in:
host committed 2026-11-04 18:01:44 +00:00
1 parent 1672ab9ff2
commit fd37505c48
6 files changed
+383 -7

No files matched your search

+53 -3
View File
@@ -66,6 +66,27 @@ three v1 boundaries that were explicitly left open:
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.
v3 (tools, this file plus server/worker changes) adds a ``tools``
field, but deliberately as REQUIRED tools, not an allowlist:
* A definition may declare ``tools`` from the MCP servers this
codebase actually seeds: ``websearch``, ``imagegen``,
``playwright``. An unknown tool is a 400, never a silent drop.
* The worker does NOT claim to block other tools. Blocking would
need a tool allowlist in ``create_thread`` (which does not exist)
or per-task codex-homes (tenant homes are per-project and shared,
and MCP seeding is worker-global), so an allowlist claim would be
false. What v3 does instead is fail fast on the opposite, silent
failure: a task that declares a required tool but whose tenant
codex-home config.toml has no ``[mcp_servers.<tool>]`` section
at dispatch time is marked failed before any model call, with the
missing tools named. The check runs only for tenant-isolated
Docker workers, where the tenant config.toml is the actual
availability record (worker flags alone would be wrong: a section
seeded earlier by another worker persists in the shared tenant
home). Local/cloud workers skip the check and are documented as
such, not as "tools verified".
"""
import json
@@ -81,7 +102,8 @@ EFFORTS = ("low", "medium", "high", "xhigh", "max")
APPROVALS = ("ask", "auto", "full")
EXECUTORS = ("local", "docker", "cloud")
_TOP_LEVEL_KEYS = ("name", "version", "description", "instructions",
"skills", "defaults")
"skills", "tools", "defaults")
KNOWN_TOOLS = ("websearch", "imagegen", "playwright")
_DEFAULTS_KEYS = ("model", "effort", "approval", "executor", "plan")
SKILLS_DIR = Path(__file__).resolve().parent / "skills"
AGENTS_DIR = Path(__file__).resolve().parent.parent / "agents"
@@ -116,12 +138,14 @@ class AgentDefinition:
"""A parsed, validated agent definition."""
def __init__(self, name, version=None, description=None,
instructions=None, skills=None, defaults=None):
instructions=None, skills=None, tools=None,
defaults=None):
self.name = name
self.version = version
self.description = description
self.instructions = instructions
self.skills = list(skills or [])
self.tools = list(tools or [])
self.defaults = dict(defaults or {})
def to_summary(self):
@@ -130,9 +154,22 @@ class AgentDefinition:
"name": self.name,
"version": self.version,
"skills": list(self.skills),
"tools": list(self.tools),
}
def missing_required_tools(tools, config_text):
"""Tools declared but with no [mcp_servers.<tool>] section.
Pure function over the tenant config.toml text. An empty or
missing config means every declared tool is missing -- a task
that needs a tool must not run on a home that never seeded it.
"""
text = config_text or ""
return [t for t in (tools or [])
if ("[mcp_servers.%s]" % t) not in text]
def parse_agent_definition(data, known_skills=None):
"""Parse and validate one definition, or raise ValueError.
@@ -190,6 +227,19 @@ def parse_agent_definition(data, known_skills=None):
if missing:
raise ValueError("agent.skills unknown skill(s): %s"
% ", ".join(missing))
tools = []
if data.get("tools") is not None:
raw_tools = data.get("tools")
if not isinstance(raw_tools, list):
raise ValueError("agent.tools must be a list")
for tool in raw_tools:
tool = _clean_str(tool, "agent.tools entry")
if tool not in tools:
tools.append(tool)
unknown_tools = [t for t in tools if t not in KNOWN_TOOLS]
if unknown_tools:
raise ValueError("agent.tools unknown tool(s): %s"
% ", ".join(unknown_tools))
defaults = {}
if data.get("defaults") is not None:
raw_defaults = data.get("defaults")
@@ -230,7 +280,7 @@ def parse_agent_definition(data, known_skills=None):
return AgentDefinition(name=name, version=version,
description=description,
instructions=instructions, skills=skills,
defaults=defaults)
tools=tools, defaults=defaults)
def load_agent_definition(path, agents_dir=None):
+1
View File
@@ -883,6 +883,7 @@ POSTGRES_SCHEMA = (
"agent_name TEXT, "
"agent_version TEXT, "
"agent_instructions TEXT, "
"agent_tools TEXT, "
"UNIQUE(project_id, idempotency_key))",
"CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status)",
# 附件包与任务附件:与 server._ensure_uploads_tables 同款,
+26 -3
View File
@@ -1243,6 +1243,8 @@ def migrate_tasks_table(conn):
"agent_name": "TEXT",
"agent_version": "TEXT",
"agent_instructions": "TEXT",
# agent 定义 v3:必需工具 JSON 数组(NULL = 未声明)。
"agent_tools": "TEXT",
}
for name, coltype in new_cols.items():
if name not in cols:
@@ -1787,6 +1789,20 @@ def _row_get(row, name):
return None
def _agent_tools_from_row(row):
"""agent_tools 列的容错解析:JSON 数组,非数组/脏数据为 None。"""
raw = _row_get(row, "agent_tools")
if raw is None:
return None
if isinstance(raw, list):
return list(raw)
try:
parsed = json.loads(raw)
except (TypeError, ValueError):
return None
return list(parsed) if isinstance(parsed, list) else None
def _task_to_dict(row, attachments=None, total_tokens=None):
depends_on = row["depends_on"]
try:
@@ -1845,6 +1861,9 @@ def _task_to_dict(row, attachments=None, total_tokens=None):
"agent_name": _row_get(row, "agent_name"),
"agent_version": _row_get(row, "agent_version"),
"agent_instructions": _row_get(row, "agent_instructions"),
# v3:agent_tools 存 JSON 数组;脏数据/老行缺列时
# 按 None 计,不炸列表接口。
"agent_tools": _agent_tools_from_row(row),
"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,
@@ -4360,6 +4379,7 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
"description": definition.description,
"instructions": definition.instructions,
"skills": definition.skills,
"tools": definition.tools,
"defaults": definition.defaults,
},
known_skills=_agent_known_skills())
@@ -4679,6 +4699,8 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
agent_version_val = definition.version if definition else None
agent_instructions_val = (
definition.instructions if definition else None)
agent_tools_val = (
json.dumps(definition.tools) if definition else None)
now = time.time()
conn = self._db()
try:
@@ -4704,9 +4726,10 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
" environment_id, coordinator_parts,"
" parent_task_id, coordinator_part,"
" child_model, child_effort,"
" agent_name, agent_version, agent_instructions)"
" agent_name, agent_version, agent_instructions,"
" agent_tools)"
" 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,
@@ -4715,7 +4738,7 @@ class CloudControlPlaneHandler(BaseHTTPRequestHandler):
json.dumps(coord_parts) if coord_parts else None,
None, None, child_model, child_effort,
agent_name_val, agent_version_val,
agent_instructions_val),
agent_instructions_val, agent_tools_val),
)
# 长任务不限时:API 字段 no_time_limit=1 时写标记,
# worker 认领后按不限时跑(单轮 host 600 秒仍在);
+202
View File
@@ -0,0 +1,202 @@
"""agent tools v3 单测:解析、落库、租户必需工具门。
运行(在仓库根目录下):
python3 -m unittest discover -s cloud/tests -p "test_agent_tools.py" -v
说明:tools 是必需工具,不是白名单(见 agent_def.py 的 v3
边界说明:本代码库没有 tool allowlist 通道,worker 也不能
阻止 agent 用别的工具)。单测覆盖:未知工具 400、必需工具
缺失判定(纯函数 + 真实租户 config.toml)、建任务落库与
回读、agent_name 示例的 tools。run_one 的门接线是单条
if 调用 _missing_required_tools,完整 Docker 派发需真实
Docker 主机,不在单测里假装跑通。
"""
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 KNOWN_TOOLS, missing_required_tools
from cloud.agent_def import parse_agent_definition
from cloud.auth import AuthStore
from cloud.sandbox import DockerExecutor
from cloud.server import create_server, init_db
from cloud.worker import Worker
except ImportError:
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from cloud.agent_def import KNOWN_TOOLS, missing_required_tools
from cloud.agent_def import parse_agent_definition
from cloud.auth import AuthStore
from cloud.sandbox import DockerExecutor
from cloud.server import create_server, init_db
from cloud.worker import Worker
def _agent(**overrides):
agent = {
"name": "research-brief",
"version": "v1",
"instructions": "Cite sources.",
"skills": ["live-info"],
"tools": ["websearch"],
"defaults": {"effort": "medium", "approval": "ask",
"executor": "docker"},
}
agent.update(overrides)
return agent
class ParseToolsTestCase(unittest.TestCase):
def test_known_tools_and_dedup(self):
definition = parse_agent_definition(
_agent(tools=["websearch", "websearch", "imagegen"]))
self.assertEqual(definition.tools, ["websearch", "imagegen"])
self.assertEqual(set(KNOWN_TOOLS),
{"websearch", "imagegen", "playwright"})
def test_unknown_tool_rejected(self):
with self.assertRaises(ValueError):
parse_agent_definition(_agent(tools=["shell"]))
def test_missing_required_tools_pure(self):
config = "[mcp_servers.websearch]\ncommand = \"x\"\n"
self.assertEqual(
missing_required_tools(["websearch", "imagegen"], config),
["imagegen"])
self.assertEqual(missing_required_tools(["websearch"], config), [])
self.assertEqual(
missing_required_tools(["playwright"], ""), ["playwright"])
class ServerToolsTestCase(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_inline_agent_tools_stored(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["agent_tools"], ["websearch"])
self.assertEqual(task["agent"]["tools"], ["websearch"])
self.assertEqual(self._list_tasks()[0]["agent_tools"],
["websearch"])
def test_unknown_tool_rejected_no_task(self):
status, _data = self._req("POST", "/tasks", {
"project_id": self.project["id"], "prompt": "do research",
"agent": _agent(tools=["shell"])}, self.owner_key)
self.assertEqual(status, 400)
self.assertEqual(self._list_tasks(), [])
def test_agent_name_example_tools(self):
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_tools"], ["websearch"])
class WorkerToolGateTestCase(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.db_path = os.path.join(self.tmp.name, "control.db")
init_db(self.db_path)
self.tenants_root = os.path.join(self.tmp.name, "tenants")
self.template_home = os.path.join(self.tmp.name, "template")
os.makedirs(self.template_home, exist_ok=True)
with open(os.path.join(self.template_home, "config.toml"),
"w", encoding="utf-8") as fh:
fh.write("")
self.template_repo = os.path.join(self.tmp.name, "test-repo")
os.makedirs(self.template_repo, exist_ok=True)
with open(os.path.join(self.template_repo, "sample.py"),
"w", encoding="utf-8") as fh:
fh.write("")
executor = DockerExecutor.__new__(DockerExecutor)
self.worker = Worker.__new__(Worker)
self.worker.tenant_isolation = True
self.worker.executor = executor
self.worker.tenants_root = self.tenants_root
self.worker.tenant_template_home = self.template_home
self.worker.tenant_template_repo = self.template_repo
self.worker.websearch_mcp = False
self.worker.imagegen_mcp = False
self.worker.playwright_mcp = False
self.worker.seed_skills = False
def tearDown(self):
self.tmp.cleanup()
def test_gate_missing_and_present(self):
# 租户 home 初始只有模板 config.toml,无任何 MCP 段。
task = {"project_id": "p1", "agent_tools": ["websearch"]}
self.assertEqual(
self.worker._missing_required_tools(task), ["websearch"])
# 模拟之前别的 worker 已播过 websearch:段保留在共享
# 租户 home 里,本 worker 旗关也不误判。
home = self.worker._tenant_dirs("p1")["codex_home"]
with open(os.path.join(home, "config.toml"),
"a", encoding="utf-8") as fh:
fh.write("\n[mcp_servers.websearch]\ncommand = \"x\"\n")
self.assertEqual(
self.worker._missing_required_tools(task), [])
def test_gate_skipped_when_not_applicable(self):
self.worker.tenant_isolation = False
task = {"project_id": "p1", "agent_tools": ["websearch"]}
self.assertEqual(self.worker._missing_required_tools(task), [])
self.worker.tenant_isolation = True
self.assertEqual(
self.worker._missing_required_tools(
{"project_id": "p1", "agent_tools": []}), [])
+84
View File
@@ -136,6 +136,7 @@ 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.agent_def import missing_required_tools as _missing_tools
from cloud.auth import AuthStore
from cloud.coordinator import build_report as _coord_build_report
from cloud.coordinator import DEFAULT_POLL_INTERVAL as _COORD_POLL_INTERVAL
@@ -350,6 +351,19 @@ def _resolve_model(model=None, config_path=None):
return DEFAULT_MODEL
def _agent_tools_from_col(raw):
"""agent_tools 列的容错解析:JSON 数组,非数组/脏数据为 []。"""
if raw is None:
return []
if isinstance(raw, list):
return list(raw)
try:
parsed = json.loads(raw)
except (TypeError, ValueError):
return []
return list(parsed) if isinstance(parsed, list) else []
def _task_from_row(row):
depends_on = row["depends_on"]
try:
@@ -418,6 +432,9 @@ def _task_from_row(row):
"agent_name": _col("agent_name"),
"agent_version": _col("agent_version"),
"agent_instructions": _col("agent_instructions"),
# v3:agent_tools 存 JSON 数组;脏数据/老行缺列时
# 按 [] 计(= 未声明必需工具),不炸认领。
"agent_tools": _agent_tools_from_col(_col("agent_tools")),
}
@@ -5535,6 +5552,44 @@ class Worker:
self.usage_cost_usd += micros / 1000000.0
return cost_id
def _missing_required_tools(self, task):
"""agent 必需工具门(v3):返回缺失工具名列表。
只在租户隔离 Docker worker 上查:租户 codex-home 的
config.toml 是实际可用性的记录(_tenant_dirs 已按
worker 旗播种过,之前别的 worker 播过的段会保留,
所以只看旗会误判)。返回 [] = 不缺或本 worker 不
适用(非租户/非 Docker/未声明工具);返回非空 =
声明了但租户 home 里没有对应 [mcp_servers.<tool>]
段,run_one 应在模型调用前直接 failed,不静默跑
一个注定缺工具的任务。租户目录准备失败时不由本
方法定罪(返回 [],让 _docker_task_context 的
租户准备失败路径处理),避免同一失败被两处各说一遍。
"""
tools = task.get("agent_tools") or []
if not tools:
return []
if not self.tenant_isolation:
return []
if not isinstance(self.executor, DockerExecutor):
return []
try:
home = self._tenant_dirs(
task.get("project_id"))["codex_home"]
with open(os.path.join(home, "config.toml"),
encoding="utf-8") as fh:
config_text = fh.read()
except Exception as exc:
# 不由本方法定罪,但绝不静默吞掉:记日志,真正
# 的租户准备失败由 _docker_task_context 报错。
try:
self.logger.warning(
"required tools check skipped: %s", exc)
except Exception:
pass
return []
return _missing_tools(tools, config_text)
def run_one(self, task=None):
"""认领并执行一个任务,返回结果 dict(见模块 docstring)。
@@ -5885,6 +5940,35 @@ class Worker:
version=task.get("agent_version"),
instructions=task["agent_instructions"]),
task.get("prompt") or "")
# agent 必需工具门(v3):协调员父任务已在前面返回,
# 这里只剩真正要跑模型的任务(普通/goal/fork 首轮都
# 走后面的尝试循环,门放在循环前,一次定罪不重试)。
# 非租户 Docker 任务不适用本门(见方法注释),不拦。
_missing = self._missing_required_tools(task)
if _missing:
_tool_error = (
"agent 声明必需工具缺失:%s(租户 codex-home 里"
"没有对应 [mcp_servers.<tool>] 段;请在 worker"
" 开启对应 MCP 后新建任务)" % ", ".join(_missing))
self._set_status(task_id, "failed")
self._write_execution_result(
task_id, error_text=_tool_error,
actual_model=self.model,
started_at=started_at, finished_at=time.time(),
only_if_status="failed")
self._audit(task["project_id"], "worker_tool_gate_rejected",
"%s: %s" % (task_id, _tool_error))
log_event(self.logger, "worker_tool_gate_rejected",
task_id=task_id, project_id=task["project_id"],
status="failed")
self._running_clear(task_id)
return {"dispatched": True, "task_id": task_id,
"host_name": self.executor._host_name(task)
if hasattr(self.executor, "_host_name") else task_id,
"status": "failed",
"executor": self._executor_name(),
"stdout": "", "error": _tool_error, "events": [],
"cost_record_id": None}
actual_model = task["model"]
# 有限重试与真实 fallback 的优先级:取消不重试;一次尝试失败
# 且可 fallback、且显式配了 fallback_models 时,优先真换模型