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

+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 时,优先真换模型