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:
1 parent
1672ab9ff2
commit
fd37505c48
6 files changed
+383
-7
No files matched your search
+53
-3
@@ -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):
|
||||
|
||||
@@ -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
@@ -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 秒仍在);
|
||||
|
||||
@@ -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": []}), [])
|
||||
@@ -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 时,优先真换模型
|
||||
|
||||
Reference in new issue
Block a user