✨ feat: WP-06 工具域——注册表、四维裁决、审批与 RunBudget

- 迁移 10:tool(注册表:风险级/审批声明/输入 Schema)、tool_call(六态含 pending_approval、幂等唯一约束)、run.max_tool_calls(RunBudget 硬上限)
- 内置工具三件:get-current-time/calculate(AST 白名单四则,只读循律)、record-note(真实副作用:写入记忆候选,必须请命)
- 四维裁决(能力/数据/后果/情境→autonomy_level):read_only 循律直接执行;side_effect/high_impact 进入 pending_approval 请命;禁用工具 forbidden
- 审批流(朱批):approve → 执行并落审计;deny → 拒绝且不可逆(重复审批 409);副作用工具产物走既有记忆审核通道
- 工具入参按不可信模型输出处理:JSON Schema 子集校验(required/type/未知字段拒绝);RunBudget 超限 422 不静默放行
- 幂等:同 (run, tool, Idempotency-Key) 重放返回 200 原调用
- 测试 67 项全绿(+7):循律即执行、请命→朱批→真实候选产物、拒绝不可逆与不执行、Schema 三类拒绝、未注册 404、幂等重放、预算硬上限
This commit is contained in:
chenxin committed 2026-09-11 11:29:43 +08:00
1 parent e333dc5ea5
commit dfce60f7db
7 files changed
+669

No files matched your search

@@ -0,0 +1,67 @@
"""工具注册表、调用与 RunBudget
Revision ID: b3a26cb61923
Revises: a994e5b4337b
Create Date: 2026-09-11 11:19:50.562943
"""
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
# revision identifiers, used by Alembic.
revision: str = 'b3a26cb61923'
down_revision: str | Sequence[str] | None = 'a994e5b4337b'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('tool',
sa.Column('name', sa.String(), nullable=False),
sa.Column('description', sa.String(), nullable=False),
sa.Column('input_schema', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('risk_level', sa.String(), nullable=False),
sa.Column('requires_approval', sa.Boolean(), nullable=False),
sa.Column('enabled', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.CheckConstraint("risk_level IN ('read_only', 'side_effect', 'high_impact')", name=op.f('ck_tool_risk_level_values')),
sa.PrimaryKeyConstraint('name', name=op.f('pk_tool'))
)
op.create_table('tool_call',
sa.Column('id', sa.String(), nullable=False),
sa.Column('run_id', sa.String(), nullable=False),
sa.Column('tool_name', sa.String(), nullable=False),
sa.Column('input', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('output', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('status', sa.String(), nullable=False),
sa.Column('error', sa.String(), nullable=True),
sa.Column('idempotency_key', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True),
sa.CheckConstraint("status IN ('pending_approval', 'approved', 'denied', 'executing', 'succeeded', 'failed')", name=op.f('ck_tool_call_status_values')),
sa.ForeignKeyConstraint(['run_id'], ['run.id'], name=op.f('fk_tool_call_run'), ondelete='cascade'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_tool_call'))
)
op.create_index(op.f('ix_tool_call_run_id'), 'tool_call', ['run_id'], unique=False)
op.create_index('uq_tool_call_idempotency', 'tool_call', ['run_id', 'tool_name', 'idempotency_key'], unique=True)
op.create_foreign_key(op.f('fk_memory_memory_version'), 'memory', 'memory_version', ['current_version_id'], ['id'], ondelete='set null', use_alter=True)
op.add_column('run', sa.Column('max_tool_calls', sa.Integer(), nullable=False))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('run', 'max_tool_calls')
op.drop_constraint(op.f('fk_memory_memory_version'), 'memory', type_='foreignkey')
op.drop_index('uq_tool_call_idempotency', table_name='tool_call')
op.drop_index(op.f('ix_tool_call_run_id'), table_name='tool_call')
op.drop_table('tool_call')
op.drop_table('tool')
# ### end Alembic commands ###
+3
View File
@@ -12,6 +12,7 @@ from app.core.middleware import RequestContextMiddleware, configure_logging
from app.db.session import init_engine
from app.memories.routes import cycle_router
from app.memories.routes import router as memories_router
from app.tools.routes import calls_router, tools_router
def create_app() -> FastAPI:
@@ -34,6 +35,8 @@ def create_app() -> FastAPI:
app.include_router(cycle_router) # 先于 characters:避免通配生命周期路由拦截
app.include_router(characters_router)
app.include_router(memories_router)
app.include_router(tools_router)
app.include_router(calls_router)
@app.get("/healthz", tags=["ops"])
async def healthz() -> dict[str, str]:
+59
View File
@@ -240,6 +240,7 @@ class Run(Base):
ForeignKey("conversation.id", ondelete="cascade"), nullable=True, index=True
)
status: Mapped[str] = mapped_column(String, nullable=False, default="queued")
max_tool_calls: Mapped[int] = mapped_column(nullable=False, default=10) # RunBudget 硬上限
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
@@ -614,3 +615,61 @@ class BigCycle(Base):
"status IN ('pending', 'running', 'completed', 'failed')", name="status_values"
),
)
class Tool(Base):
"""工具注册表:内置工具与后续插件贡献统一登记(WP-06)。"""
__tablename__ = "tool"
name: Mapped[str] = mapped_column(String, primary_key=True) # kebab-case
description: Mapped[str] = mapped_column(String, nullable=False, default="")
input_schema: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
risk_level: Mapped[str] = mapped_column(String, nullable=False, default="read_only")
requires_approval: Mapped[bool] = mapped_column(nullable=False, default=False)
enabled: Mapped[bool] = mapped_column(nullable=False, default=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
__table_args__ = (
CheckConstraint(
"risk_level IN ('read_only', 'side_effect', 'high_impact')",
name="risk_level_values",
),
)
class ToolCall(Base):
"""工具调用:Schema 校验与 Policy 判定后的执行记录(含审批态)。"""
__tablename__ = "tool_call"
id: Mapped[str] = mapped_column(
String, primary_key=True, default=lambda: new_id("tool_call_")
)
run_id: Mapped[str] = mapped_column(
ForeignKey("run.id", ondelete="cascade"), nullable=False, index=True
)
tool_name: Mapped[str] = mapped_column(String, nullable=False)
input: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
output: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
status: Mapped[str] = mapped_column(String, nullable=False, default="pending_approval")
error: Mapped[str | None] = mapped_column(String, nullable=True)
idempotency_key: Mapped[str | None] = mapped_column(String, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
__table_args__ = (
CheckConstraint(
"status IN ('pending_approval', 'approved', 'denied', "
"'executing', 'succeeded', 'failed')",
name="status_values",
),
Index(
"uq_tool_call_idempotency", "run_id", "tool_name", "idempotency_key",
unique=True,
),
)
+1
View File
@@ -0,0 +1 @@
"""工具域:注册表、裁决与审批。"""
+112
View File
@@ -0,0 +1,112 @@
"""工具域路由(WP-06)。"""
from typing import Annotated, Any
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
from fastapi.responses import JSONResponse
from sqlalchemy import select
from app.auth.dependencies import get_current_user, require_state_change
from app.conversations.routes import _domain_of
from app.db.session import DbSession
from app.models import Tool, ToolCall, User
from app.tools import service
tools_router = APIRouter(prefix="/v1/tools", tags=["tools"])
calls_router = APIRouter(prefix="/v1", tags=["tool-calls"])
def _tool_out(t: Tool) -> dict[str, Any]:
return {
"name": t.name,
"description": t.description,
"input_schema": t.input_schema,
"risk_level": t.risk_level,
"requires_approval": t.requires_approval,
"enabled": t.enabled,
}
def _call_out(c: ToolCall) -> dict[str, Any]:
return {
"id": c.id,
"run_id": c.run_id,
"tool_name": c.tool_name,
"input": c.input,
"output": c.output,
"status": c.status,
"error": c.error,
"created_at": c.created_at.isoformat(),
"completed_at": c.completed_at.isoformat() if c.completed_at else None,
}
@tools_router.get("")
async def list_tools(
db: DbSession,
user: Annotated[User, Depends(get_current_user)],
) -> dict[str, Any]:
await service.seed_builtin_tools(db) # 幂等登记(后续由 Registry 管理取代)
rows = list((await db.scalars(select(Tool).order_by(Tool.name.asc()))).all())
return {"data": [_tool_out(t) for t in rows]}
@calls_router.post("/runs/{run_id}/tool-calls", status_code=201, response_model=None)
async def create_tool_call(
run_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, Any],
idempotency_key: Annotated[str | None, Header(alias="Idempotency-Key")] = None,
) -> dict[str, Any] | JSONResponse:
domain = await _domain_of(db, user)
if idempotency_key:
body = {**body, "idempotency_key": idempotency_key}
call, replayed = await service.create_tool_call(
db, domain.id, run_id, body, getattr(request.state, "trace_id", None)
)
if replayed:
from fastapi.responses import JSONResponse
return JSONResponse(status_code=200, content=_call_out(call))
return _call_out(call)
@calls_router.post("/tool-calls/{tool_call_id}/approvals", status_code=201)
async def decide_approval(
tool_call_id: str,
db: DbSession,
user: Annotated[User, Depends(require_state_change)],
request: Request,
body: dict[str, bool],
) -> dict[str, Any]:
domain = await _domain_of(db, user)
call = await service.decide_approval(
db, domain.id, tool_call_id, bool(body.get("approve")),
getattr(request.state, "trace_id", None),
)
return _call_out(call)
@calls_router.get("/tool-calls")
async def list_tool_calls(
db: DbSession,
user: Annotated[User, Depends(get_current_user)],
status_filter: str = "pending_approval",
) -> dict[str, Any]:
if status_filter not in {
"pending_approval", "approved", "denied", "executing", "succeeded", "failed",
}:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "status 取值不合法")
domain = await _domain_of(db, user)
from app.models import Run
stmt = (
select(ToolCall)
.join(Run, ToolCall.run_id == Run.id)
.where(Run.personal_domain_id == domain.id, ToolCall.status == status_filter)
.order_by(ToolCall.created_at.asc())
)
rows = list((await db.scalars(stmt)).all())
return {"data": [_call_out(c) for c in rows]}
+292
View File
@@ -0,0 +1,292 @@
"""工具域:注册表、内置工具、Schema 校验与四维策略裁决(WP-06)。"""
import ast
import operator
from collections.abc import Callable
from datetime import UTC, datetime
from typing import Any
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import AuditEvent, Run, Tool, ToolCall
# ===== 内置工具实现(纯函数;副作用工具由服务层落库) =====
_UNARY: dict[type[ast.AST], Callable[[float], float]] = {
ast.USub: operator.neg, ast.UAdd: operator.pos,
}
_BINARY: dict[type[ast.AST], Callable[[float, float], float]] = {
ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
ast.Div: operator.truediv, ast.Mod: operator.mod, ast.Pow: operator.pow,
}
def _safe_eval(node: ast.AST) -> float:
"""AST 白名单四则运算:仅数字、一元/二元算子;任何其它节点抛 ValueError。"""
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
return float(node.value)
if isinstance(node, ast.UnaryOp):
fn = _UNARY.get(type(node.op))
if fn is not None:
return fn(_safe_eval(node.operand))
if isinstance(node, ast.BinOp):
fn2 = _BINARY.get(type(node.op))
if fn2 is not None:
return fn2(_safe_eval(node.left), _safe_eval(node.right))
raise ValueError("表达式包含不允许的节点")
def tool_get_current_time(_input: dict[str, Any]) -> dict[str, Any]:
now = datetime.now(UTC)
return {"utc_now": now.isoformat(), "epoch_ms": int(now.timestamp() * 1000)}
def tool_calculate(input_: dict[str, Any]) -> dict[str, Any]:
tree = ast.parse(str(input_.get("expression", "")), mode="eval")
return {"result": _safe_eval(tree.body)}
BUILTIN_TOOLS: dict[str, dict[str, Any]] = {
"get-current-time": {
"description": "读取当前 UTC 时间(只读,无副作用)",
"input_schema": {"type": "object", "properties": {}, "required": []},
"risk_level": "read_only",
"requires_approval": False,
"handler": tool_get_current_time,
},
"calculate": {
"description": "四则运算(AST 白名单求值,只读)",
"input_schema": {
"type": "object",
"properties": {"expression": {"type": "string"}},
"required": ["expression"],
},
"risk_level": "read_only",
"requires_approval": False,
"handler": tool_calculate,
},
"record-note": {
"description": "把一条笔记写入记忆候选(副作用,需审批)",
"input_schema": {
"type": "object",
"properties": {"text": {"type": "string"}},
"required": ["text"],
},
"risk_level": "side_effect",
"requires_approval": True,
"handler": None,
},
}
# ===== Schema 校验(JSON Schema 子集:type/required) =====
_TYPES: dict[str, tuple[type, ...]] = {
"string": (str,), "number": (int, float), "boolean": (bool,), "object": (dict,),
}
def validate_input(schema: dict[str, Any], input_: dict[str, Any]) -> None:
props: dict[str, Any] = schema.get("properties", {})
for name in schema.get("required", []):
if name not in input_:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY, f"缺少必需字段:{name}"
)
for name, value in input_.items():
spec = props.get(name)
if spec is None:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, f"未知字段:{name}")
expected = spec.get("type")
if expected in _TYPES and not isinstance(value, _TYPES[expected]):
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY, f"字段 {name} 类型应为 {expected}"
)
# ===== 四维裁决(能力/数据/后果/情境 → autonomy_level) =====
def decide(tool: Tool) -> str:
"""返回 autonomy_level:rule_bound(循律直接执行)或 approval_required(请命)。
四维映射(agent-runtime 第 6 节):能力=risk_level 与审批声明;数据=内置工具
不涉外部数据;后果=side_effect/high_impact 必须请命;情境=工具启用状态。
"""
if not tool.enabled:
return "forbidden"
if tool.requires_approval or tool.risk_level in {"side_effect", "high_impact"}:
return "approval_required"
return "rule_bound"
async def seed_builtin_tools(db: AsyncSession) -> None:
"""幂等登记内置工具(已存在则更新声明,不动 enabled)。"""
for name, spec in BUILTIN_TOOLS.items():
existing = await db.get(Tool, name)
if existing is None:
db.add(
Tool(
name=name,
description=spec["description"],
input_schema=spec["input_schema"],
risk_level=spec["risk_level"],
requires_approval=spec["requires_approval"],
)
)
else:
existing.description = spec["description"]
existing.input_schema = spec["input_schema"]
existing.risk_level = spec["risk_level"]
existing.requires_approval = spec["requires_approval"]
await db.flush()
async def create_tool_call(
db: AsyncSession,
domain_id: str,
run_id: str,
body: dict[str, Any],
trace_id: str | None,
) -> tuple[ToolCall, bool]:
"""创建工具调用:幂等重放 → Schema 校验 → RunBudget → 四维裁决。"""
run = await db.get(Run, run_id)
if run is None or run.personal_domain_id != domain_id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Run 不存在")
key = body.get("idempotency_key")
if key:
existing = await db.execute(
select(ToolCall).where(
ToolCall.run_id == run_id,
ToolCall.tool_name == body.get("tool_name"),
ToolCall.idempotency_key == key,
)
)
replay = existing.scalar_one_or_none()
if replay is not None:
return replay, True
tool = await db.get(Tool, body.get("tool_name", ""))
if tool is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "工具未注册")
used = len(
list(
(
await db.execute(
select(ToolCall.id).where(
ToolCall.run_id == run_id,
ToolCall.status.in_(
["succeeded", "executing", "approved", "pending_approval"]
),
)
)
).scalars().all()
)
)
if used >= run.max_tool_calls:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
f"RunBudget 工具调用上限已达({run.max_tool_calls})",
)
input_ = body.get("input", {})
if not isinstance(input_, dict):
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "input 必须是对象")
validate_input(tool.input_schema, input_) # 工具入参是不可信模型输出
level = decide(tool)
if level == "forbidden":
raise HTTPException(status.HTTP_403_FORBIDDEN, "工具已被禁用")
call = ToolCall(
run_id=run_id,
tool_name=tool.name,
input=input_,
idempotency_key=key,
status="pending_approval" if level == "approval_required" else "approved",
)
db.add(call)
await db.flush()
db.add(
AuditEvent(
actor_type="user", actor_id=domain_id, action="tool.call.created",
aggregate_type="tool_call", aggregate_id=call.id,
payload={"tool": tool.name, "autonomy": level}, trace_id=trace_id,
)
)
if level == "rule_bound":
await _execute(db, domain_id, call, trace_id)
await db.flush()
return call, False
async def decide_approval(
db: AsyncSession,
domain_id: str,
tool_call_id: str,
approve: bool,
trace_id: str | None,
) -> ToolCall:
"""朱批:approve → 执行;deny → 拒绝不执行且不可逆(拒绝/超时均不执行)。"""
call = await db.get(ToolCall, tool_call_id)
if call is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "调用不存在")
run = await db.get(Run, call.run_id)
assert run is not None
if run.personal_domain_id != domain_id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "调用不存在")
if call.status != "pending_approval":
raise HTTPException(status.HTTP_409_CONFLICT, "调用不在待审批状态")
if not approve:
call.status = "denied"
call.completed_at = datetime.now(UTC)
db.add(
AuditEvent(
actor_type="user", actor_id=domain_id, action="tool.call.denied",
aggregate_type="tool_call", aggregate_id=call.id, trace_id=trace_id,
)
)
await db.flush()
return call
call.status = "approved"
await _execute(db, domain_id, call, trace_id)
await db.flush()
return call
async def _execute(
db: AsyncSession, domain_id: str, call: ToolCall, trace_id: str | None
) -> None:
"""执行工具:内置纯函数或副作用落库;结果与错误如实记录。"""
spec = BUILTIN_TOOLS.get(call.tool_name)
call.status = "executing"
try:
if call.tool_name == "record-note":
from app.memories import service as memory_service
candidate, _memory = await memory_service.submit_candidate(
db,
domain_id,
{
"kind": "fact",
"sensitivity": "low",
"source": "auto",
"payload": {"text": str(call.input.get("text", ""))},
"source_ref": {"tool_call_id": call.id},
},
trace_id,
)
call.output = {"memory_candidate_id": candidate.id, "status": candidate.status}
else:
assert spec is not None and spec["handler"] is not None
call.output = spec["handler"](call.input)
call.status = "succeeded"
except HTTPException:
raise
except Exception as exc: # 工具失败如实记录,不丢弃不伪装
call.status = "failed"
call.error = str(exc)[:500]
call.completed_at = datetime.now(UTC)
db.add(
AuditEvent(
actor_type="system", actor_id="tool-runtime", action="tool.call.executed",
aggregate_type="tool_call", aggregate_id=call.id,
payload={"status": call.status}, trace_id=trace_id,
)
)
+135
View File
@@ -881,3 +881,138 @@ class TestBigCycle:
assert version.status == "pending" # 候选先于写入
assert version.payload["source"] == "big_cycle"
assert version.payload["proposal"] # 模型真实输出
@pytest.mark.usefixtures("client")
class TestTools:
async def _make_run(self, client, headers) -> str:
cid = await _create_conversation(client, headers)
resp = await _post_message(client, headers, cid, f"idem-tool-{_uuid4().split(chr(45))[0]}")
await _wait_run_terminal_headers(client, headers)
return resp.json()["run"]["id"]
async def test_rule_bound_tool_executes_immediately(self, client: AsyncClient) -> None:
headers = await login(client, "tool1@example.com")
await client.get("/v1/tools", headers=headers) # 触发内置登记
run_id = await self._make_run(client, headers)
resp = await client.post(
f"/v1/runs/{run_id}/tool-calls",
json={"tool_name": "calculate", "input": {"expression": "(1+2)*3"}},
headers={**headers, "Idempotency-Key": "tc-1"},
)
assert resp.status_code == 201, resp.text
body = resp.json()
assert body["status"] == "succeeded"
assert body["output"]["result"] == 9.0
async def test_side_effect_tool_requires_approval(self, client: AsyncClient) -> None:
headers = await login(client, "tool2@example.com")
await client.get("/v1/tools", headers=headers)
run_id = await self._make_run(client, headers)
created = await client.post(
f"/v1/runs/{run_id}/tool-calls",
json={"tool_name": "record-note", "input": {"text": "重要笔记"}},
headers=headers,
)
assert created.json()["status"] == "pending_approval"
call_id = created.json()["id"]
# 待审批列表可见
pending = await client.get("/v1/tool-calls", headers=headers)
assert any(c["id"] == call_id for c in pending.json()["data"])
# 朱批 → 执行 → 真实副作用(记忆候选创建)
approved = await client.post(
f"/v1/tool-calls/{call_id}/approvals", json={"approve": True}, headers=headers
)
assert approved.json()["status"] == "succeeded"
assert approved.json()["output"]["memory_candidate_id"]
async def test_denied_tool_never_executes(self, client: AsyncClient) -> None:
headers = await login(client, "tool3@example.com")
await client.get("/v1/tools", headers=headers)
run_id = await self._make_run(client, headers)
created = await client.post(
f"/v1/runs/{run_id}/tool-calls",
json={"tool_name": "record-note", "input": {"text": "被拒绝的笔记"}},
headers=headers,
)
call_id = created.json()["id"]
denied = await client.post(
f"/v1/tool-calls/{call_id}/approvals", json={"approve": False}, headers=headers
)
assert denied.json()["status"] == "denied"
assert denied.json()["output"] is None # 未执行
again = await client.post(
f"/v1/tool-calls/{call_id}/approvals", json={"approve": True}, headers=headers
)
assert again.status_code == 409 # 拒绝不可逆
async def test_schema_validation_rejects_bad_input(self, client) -> None:
headers = await login(client, "tool4@example.com")
await client.get("/v1/tools", headers=headers)
run_id = await self._make_run(client, headers)
missing = await client.post(
f"/v1/runs/{run_id}/tool-calls",
json={"tool_name": "calculate", "input": {}}, headers=headers,
)
assert missing.status_code == 422
wrong_type = await client.post(
f"/v1/runs/{run_id}/tool-calls",
json={"tool_name": "calculate", "input": {"expression": 42}}, headers=headers,
)
assert wrong_type.status_code == 422
async def test_unknown_tool_404(self, client) -> None:
headers = await login(client, "tool5@example.com")
run_id = await self._make_run(client, headers)
resp = await client.post(
f"/v1/runs/{run_id}/tool-calls",
json={"tool_name": "rm-rf", "input": {}}, headers=headers,
)
assert resp.status_code == 404
async def test_idempotent_replay_returns_original(self, client) -> None:
from fastapi.responses import JSONResponse # noqa: F401
headers = await login(client, "tool6@example.com")
await client.get("/v1/tools", headers=headers)
run_id = await self._make_run(client, headers)
first = await client.post(
f"/v1/runs/{run_id}/tool-calls",
json={"tool_name": "get-current-time", "input": {}},
headers={**headers, "Idempotency-Key": "tc-replay"},
)
assert first.status_code == 201
second = await client.post(
f"/v1/runs/{run_id}/tool-calls",
json={"tool_name": "get-current-time", "input": {}},
headers={**headers, "Idempotency-Key": "tc-replay"},
)
assert second.status_code == 200
assert second.json()["id"] == first.json()["id"]
async def test_run_budget_hard_limit(self, client) -> None:
headers = await login(client, "tool7@example.com")
await client.get("/v1/tools", headers=headers)
run_id = await self._make_run(client, headers)
# max_tool_calls 默认 10:连续调用到上限后第 11 次被拒
last_status = None
for i in range(11):
resp = await client.post(
f"/v1/runs/{run_id}/tool-calls",
json={"tool_name": "get-current-time", "input": {}},
headers={**headers, "Idempotency-Key": f"tc-budget-{i}"},
)
last_status = resp.status_code
assert last_status == 422
def _uuid4() -> str:
from uuid import uuid4
return str(uuid4())
async def _wait_run_terminal_headers(client, headers, limit_s: float = 10.0) -> None:
import asyncio
await asyncio.sleep(0)