feat: tool approval declarations (v9)

Agent definitions gain tool_approvals: known tool -> ask/auto/deny.
Unknown tool/policy is a 400, and required+deny is a 400
contradiction. The declaration is persisted (tasks.agent_tool_
approvals, PG + migrate), echoed in GET and the creation response
(via to_summary), and resolved for registry definitions -- fixing
a latent drop where resolve_registry_agent re-parsed without new
fields. At dispatch the worker audits the declaration and fails
fast on a defensive required+deny re-check (v3 tools-gate shape,
runner never called); goal/coordinator tasks reject the field at
creation like instructions. effective_tool_approvals maps a
declaration + host global approval to the actual host answer,
with ask_honoured always False: host answers elicitation with
one global switch and carries no tool identity, and the approvals
queue is record-only with no runtime ask channel, so per-tool
interception and credential scope are explicitly NOT claimed.
This commit is contained in:
host committed 2026-11-04 18:02:20 +00:00
1 parent cd33a929ca
commit a51919f24b
6 files changed
+441 -6

No files matched your search

+63
View File
@@ -370,6 +370,20 @@ def _agent_tools_from_col(raw):
return list(parsed) if isinstance(parsed, list) else []
def _agent_tool_approvals_from_col(raw):
"""agent_tool_approvals 列的容错解析:JSON 对象,非对象/
脏数据为 {}(= 未声明),不炸认领。"""
if raw is None:
return {}
if isinstance(raw, dict):
return dict(raw)
try:
parsed = json.loads(raw)
except (TypeError, ValueError):
return {}
return dict(parsed) if isinstance(parsed, dict) else {}
def _task_from_row(row):
depends_on = row["depends_on"]
try:
@@ -441,6 +455,10 @@ def _task_from_row(row):
# v3:agent_tools 存 JSON 数组;脏数据/老行缺列时
# 按 [] 计(= 未声明必需工具),不炸认领。
"agent_tools": _agent_tools_from_col(_col("agent_tools")),
# v9:agent_tool_approvals 存 JSON 对象;脏数据/老行
# 缺列时按 {} 计(= 未声明),不炸认领。
"agent_tool_approvals": _agent_tool_approvals_from_col(
_col("agent_tool_approvals")),
}
@@ -6101,6 +6119,51 @@ class Worker:
version=task.get("agent_version"),
instructions=task["agent_instructions"]),
task.get("prompt") or "")
# agent 工具审批声明(v9):派发前审计声明本身,并
# 防御性重查 required+deny 矛盾(创建时已拒绝,防
# v9 之前或 API 外写入的行漏网)。审计只记声明,不
# 宣称运行时按工具拦截:host 的 elicitation 回答是
# 全局开关(auto 接受、其余拒绝),没有按工具分流,
# ask 在今天没有询问通道,见 agent_def.py 的 v9 边界。
_tool_approvals = task.get("agent_tool_approvals") or {}
if _tool_approvals:
_denied_required = [
t for t in (task.get("agent_tools") or [])
if _tool_approvals.get(t) == "deny"]
if _denied_required:
_appr_error = (
"agent 工具审批声明矛盾:必需工具 %s 被声明为"
" deny,任务无法使用其必需工具"
% ", ".join(_denied_required))
self._set_status(task_id, "failed")
self._write_execution_result(
task_id, error_text=_appr_error,
actual_model=self.model,
started_at=started_at, finished_at=time.time(),
only_if_status="failed")
self._audit(task["project_id"],
"worker_tool_approval_gate_rejected",
"%s: %s" % (task_id, _appr_error))
log_event(self.logger,
"worker_tool_approval_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": _appr_error, "events": [],
"cost_record_id": None}
self._audit(task["project_id"],
"worker_tool_approvals_declared",
"%s: %s (per-tool interception is not enforced"
" at runtime; host answers elicitation globally)"
% (task_id, json.dumps(_tool_approvals,
ensure_ascii=False,
sort_keys=True)))
# agent 必需工具门(v3):协调员父任务已在前面返回,
# 这里只剩真正要跑模型的任务(普通/goal/fork 首轮都
# 走后面的尝试循环,门放在循环前,一次定罪不重试)。