diff --git a/backend/app/services/agent_runtime/model_step_service.py b/backend/app/services/agent_runtime/model_step_service.py index c394022e6..3bdd816ac 100644 --- a/backend/app/services/agent_runtime/model_step_service.py +++ b/backend/app/services/agent_runtime/model_step_service.py @@ -76,6 +76,7 @@ ToolResultStoreError, ) from app.services.agent_runtime.tool_registry import ( + RUNTIME_TOOL_BINDING_KEY, resolve_registered_tool, ) from app.services.agent_tools import get_runtime_agent_tools_for_llm @@ -426,6 +427,16 @@ def _application_tools_for_model( ] +def _provider_tools(tools: Sequence[Mapping[str, object]]) -> list[dict]: + """Remove Runtime-only routing facts before sending Tool schemas to a model.""" + result: list[dict] = [] + for tool in tools: + model_tool = deepcopy(dict(tool)) + model_tool.pop(RUNTIME_TOOL_BINDING_KEY, None) + result.append(model_tool) + return result + + def _runtime_workset_entry(tool: Mapping[str, object]) -> ToolWorksetEntry: """Join one model definition to a stable, secret-free execution route.""" name = _tool_name(tool) @@ -449,7 +460,16 @@ def _runtime_workset_entry(tool: Mapping[str, object]) -> ToolWorksetEntry: dynamic_mcp_names=dynamic_mcp_names, ) if registered is not None: - return registered.to_workset_entry() + entry = registered.to_workset_entry() + raw_binding = tool.get(RUNTIME_TOOL_BINDING_KEY) + if raw_binding is None: + return entry + binding = ToolExecutionBinding.from_json(raw_binding) + if binding.kind != "mcp" or binding.handler_key != name: + raise ToolContractError( + "Runtime Tool binding does not match its model definition" + ) + return replace(entry, binding=binding) if name in GROUP_READ_TOOL_NAMES: effect, retry_policy = "read", "safe" binding_kind = "group" @@ -1456,7 +1476,7 @@ async def compact_inputs( model, requested_max_output_tokens=requested_output, static_prompt_tokens=fixed_prompt_tokens, - tool_schema_tokens=_estimate_tokens(tools), + tool_schema_tokens=_estimate_tokens(_provider_tools(tools)), reserved_runtime_tokens=256, safety_margin_tokens=256, compact_threshold_ratio=0.80, @@ -1511,7 +1531,7 @@ async def _prepare_messages( model, requested_max_output_tokens=requested_output, static_prompt_tokens=fixed_prompt_tokens, - tool_schema_tokens=_estimate_tokens(tools), + tool_schema_tokens=_estimate_tokens(_provider_tools(tools)), reserved_runtime_tokens=256, safety_margin_tokens=256, ) @@ -1661,7 +1681,7 @@ async def _call_prepared( return await self._completion( model, messages, - tools=tools, + tools=_provider_tools(tools), agent_id=agent.id, supports_vision=bool(model.supports_vision), ) diff --git a/backend/app/services/agent_runtime/node_executor.py b/backend/app/services/agent_runtime/node_executor.py index 9f9f8024f..8a0a00893 100644 --- a/backend/app/services/agent_runtime/node_executor.py +++ b/backend/app/services/agent_runtime/node_executor.py @@ -778,6 +778,8 @@ async def _model( repair_limit = ( WRITE_FILE_PROTOCOL_REPAIR_LIMIT if is_write_file_repair + else 10 + if repair_code == "invalid_tool_call" else 1 ) repair_counter_key = ( diff --git a/backend/app/services/agent_runtime/tool_execution.py b/backend/app/services/agent_runtime/tool_execution.py index 2266c5ba7..161b552a2 100644 --- a/backend/app/services/agent_runtime/tool_execution.py +++ b/backend/app/services/agent_runtime/tool_execution.py @@ -44,7 +44,7 @@ "reconcile", ] ToolSideEffectState = Literal["none", "confirmed", "possible", "unknown"] -SAFE_READ_MAX_ATTEMPTS = 3 +SAFE_READ_MAX_ATTEMPTS = 10 # These tools dispatch an external image-generation request and can therefore # leave the provider outcome uncertain after a response timeout. Direct Chat diff --git a/backend/app/services/agent_runtime/tool_registry.py b/backend/app/services/agent_runtime/tool_registry.py index 979adf55a..b23a85ee9 100644 --- a/backend/app/services/agent_runtime/tool_registry.py +++ b/backend/app/services/agent_runtime/tool_registry.py @@ -30,6 +30,8 @@ is_reserved_custom_tool_name, ) +RUNTIME_TOOL_BINDING_KEY = "_runtime_binding" + def _function_contract(model_definition: Mapping[str, object]) -> tuple[str, JsonObject]: function = model_definition.get("function") @@ -188,6 +190,7 @@ def resolve_registered_tool( __all__ = [ + "RUNTIME_TOOL_BINDING_KEY", "STATIC_REGISTERED_TOOL_NAMES", "RegisteredTool", "registered_dynamic_mcp", diff --git a/backend/app/services/agent_runtime/tool_repair_budget.py b/backend/app/services/agent_runtime/tool_repair_budget.py index 91d9abe84..9077371b7 100644 --- a/backend/app/services/agent_runtime/tool_repair_budget.py +++ b/backend/app/services/agent_runtime/tool_repair_budget.py @@ -10,7 +10,7 @@ from app.services.agent_runtime.state import JsonObject SAME_FINGERPRINT_FAILURE_LIMIT = 10 -TOOL_EPISODE_FAILURE_LIMIT = 20 +TOOL_EPISODE_FAILURE_LIMIT = 10 _REPAIRABLE_MODEL_ACTIONS = frozenset( {"repair_arguments", "choose_other_tool"} ) diff --git a/backend/app/services/agent_runtime/tool_step_service.py b/backend/app/services/agent_runtime/tool_step_service.py index 030e9ab6f..f79185001 100644 --- a/backend/app/services/agent_runtime/tool_step_service.py +++ b/backend/app/services/agent_runtime/tool_step_service.py @@ -183,6 +183,8 @@ async def __call__( user_id: uuid.UUID, session_id: str = "", on_output: object | None = None, + *, + execution_binding: Mapping[str, object] | None = None, ) -> ToolExecutionOutcome | str: ... @@ -1313,9 +1315,14 @@ async def _execute_application_with_controls( if accepted.entry.tool_name.startswith("agentbay_"): agentbay_run_token = agentbay_run_scope_id.set(context.run_id) try: + execution_kwargs = ( + {"execution_binding": accepted.entry.binding.to_json()} + if accepted.entry.binding.kind == "mcp" + else {} + ) operation_task = asyncio.create_task( self._tool_executor( - accepted.entry.tool_name, + accepted.entry.binding.handler_key, arguments, agent.id, ( @@ -1324,6 +1331,7 @@ async def _execute_application_with_controls( else agent.creator_id ), context.session_id or "", + **execution_kwargs, ) ) finally: diff --git a/backend/app/services/agent_tools.py b/backend/app/services/agent_tools.py index f26a2582b..94c97a0e8 100644 --- a/backend/app/services/agent_tools.py +++ b/backend/app/services/agent_tools.py @@ -92,9 +92,12 @@ sanitize_tool_arguments, ) from app.services.agent_runtime.tool_contracts import ( + ToolContractError, + ToolExecutionBinding, resolve_tool_deadline_seconds, ) from app.services.agent_runtime.tool_registry import ( + RUNTIME_TOOL_BINDING_KEY, STATIC_REGISTERED_TOOL_NAMES, resolve_registered_tool, ) @@ -1100,10 +1103,31 @@ async def _agent_is_designated_okr_agent(agent_id: uuid.UUID) -> bool: return False -async def _get_runtime_dynamic_mcp_tool_names( +def _mcp_route_digest( + *, + server_url: str, + server_name: str, + raw_name: str, + async_completion: object, +) -> str: + encoded = json.dumps( + { + "server_url": server_url, + "server_name": server_name, + "raw_name": raw_name, + "async_completion": async_completion, + }, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +async def _get_runtime_dynamic_mcp_bindings( agent_id: uuid.UUID, -) -> set[str]: - """Resolve locally ready dynamic MCP names without provider I/O.""" +) -> dict[str, dict]: + """Resolve ready MCP tools and freeze their secret-free route identity.""" from urllib.parse import urlparse from app.models.tool import AgentTool, Tool @@ -1111,7 +1135,7 @@ async def _get_runtime_dynamic_mcp_tool_names( try: async with async_session() as db: result = await db.execute( - select(Tool) + select(Tool, AgentTool) .join(AgentTool, AgentTool.tool_id == Tool.id) .where( AgentTool.agent_id == agent_id, @@ -1120,24 +1144,25 @@ async def _get_runtime_dynamic_mcp_tool_names( Tool.type == "mcp", ) ) - tools = result.scalars().all() + rows = result.all() except Exception as exc: logger.warning( - "[Tools] Dynamic MCP readiness lookup failed: {}", + "[Tools] Dynamic MCP binding lookup failed: {}", type(exc).__name__, ) - return set() + return {} - ready: set[str] = set() - for tool in tools: - name = str(tool.name or "") + bindings: dict[str, dict] = {} + for tool, assignment in rows: + name = str(tool.name or "").strip() server_url = str(tool.mcp_server_url or "").strip() + raw_name = str(tool.mcp_tool_name or "").strip() parsed = urlparse(server_url) if ( not name or name in BUILTIN_TOOL_NAMES or is_reserved_custom_tool_name(name) - or not str(tool.mcp_tool_name or "").strip() + or not raw_name or parsed.scheme not in {"http", "https"} or not parsed.netloc ): @@ -1146,14 +1171,29 @@ async def _get_runtime_dynamic_mcp_tool_names( name or "", ) continue - ready.add(name) - return _project_active_tool_descriptions(ready) + binding = ToolExecutionBinding( + kind="mcp", + handler_key=name, + target={ + "tool_id": str(tool.id), + "route_digest": _mcp_route_digest( + server_url=server_url, + server_name=str(tool.mcp_server_name or ""), + raw_name=raw_name, + async_completion=(tool.config or {}).get("async_completion"), + ), + }, + credential_ref=str(assignment.id), + ) + bindings[name] = binding.to_json() + return bindings async def get_runtime_agent_tools_for_llm(agent_id: uuid.UUID) -> list[dict]: """Resolve the current Durable Runtime workset with typed-outcome gating.""" tools = await get_agent_tools_for_llm(agent_id) - dynamic_mcp_names = await _get_runtime_dynamic_mcp_tool_names(agent_id) + dynamic_mcp_bindings = await _get_runtime_dynamic_mcp_bindings(agent_id) + dynamic_mcp_names = set(dynamic_mcp_bindings) resolved = _runtime_typed_tools( tools, dynamic_mcp_names=dynamic_mcp_names, @@ -1162,6 +1202,9 @@ async def get_runtime_agent_tools_for_llm(agent_id: uuid.UUID) -> list[dict]: is_designated_okr_agent: bool | None = None for tool in resolved: name = str(tool.get("function", {}).get("name") or "") + if name in dynamic_mcp_bindings: + tool = deepcopy(tool) + tool[RUNTIME_TOOL_BINDING_KEY] = dynamic_mcp_bindings[name] if name in _OKR_AGENT_ONLY_TOOL_NAMES: if is_designated_okr_agent is None: is_designated_okr_agent = ( @@ -2613,6 +2656,8 @@ async def execute_builtin_tool_outcome( user_id: uuid.UUID, session_id: str = "", on_output=None, + *, + execution_binding: Mapping[str, object] | None = None, ) -> ToolExecutionOutcome | str: """Execute only explicitly migrated builtin branches as typed outcomes. @@ -2962,7 +3007,13 @@ async def execute_builtin_tool_outcome( and tool_name not in BUILTIN_TOOL_NAMES and not is_reserved_custom_tool_name(tool_name) ): - mcp_target = await _resolve_mcp_execution_target(tool_name, agent_id) + if execution_binding is not None: + mcp_target = await _resolve_frozen_mcp_execution_target( + execution_binding, + agent_id, + ) + else: + mcp_target = await _resolve_mcp_execution_target(tool_name, agent_id) if mcp_target is not None: return await _execute_resolved_mcp_target_outcome( mcp_target, @@ -5929,6 +5980,83 @@ def _mcp_call_response_outcome( return _typed_success(summary, metadata=metadata) +async def _resolve_frozen_mcp_execution_target( + raw_binding: Mapping[str, object], + agent_id: uuid.UUID, +) -> dict: + """Resolve credentials for one frozen route and reject live route drift.""" + from app.models.tool import AgentTool, Tool + + try: + binding = ToolExecutionBinding.from_json(raw_binding) + if binding.kind != "mcp" or binding.credential_ref is None: + raise ToolContractError("MCP execution binding is incomplete") + tool_id = uuid.UUID(str(binding.target.get("tool_id") or "")) + assignment_id = uuid.UUID(binding.credential_ref) + except (ToolContractError, ValueError, TypeError): + return { + "full_name": str(raw_binding.get("handler_key") or "mcp"), + "unavailable_error_code": "mcp_binding_invalid", + } + + async with async_session() as db: + tool_result = await db.execute( + select(Tool).where(Tool.id == tool_id, Tool.type == "mcp") + ) + tool = tool_result.scalar_one_or_none() + assignment_result = await db.execute( + select(AgentTool).where( + AgentTool.id == assignment_id, + AgentTool.agent_id == agent_id, + AgentTool.tool_id == tool_id, + ) + ) + assignment = assignment_result.scalar_one_or_none() + + if tool is None or assignment is None or not tool.enabled or not assignment.enabled: + return { + "full_name": binding.handler_key, + "unavailable_error_code": "mcp_tool_not_available", + } + + server_url = str(tool.mcp_server_url or "").strip() + server_name = str(tool.mcp_server_name or "") + raw_name = str(tool.mcp_tool_name or "").strip() + current_route_digest = _mcp_route_digest( + server_url=server_url, + server_name=server_name, + raw_name=raw_name, + async_completion=(tool.config or {}).get("async_completion"), + ) + if ( + str(tool.name or "") != binding.handler_key + or binding.target.get("route_digest") != current_route_digest + ): + return { + "full_name": binding.handler_key, + "unavailable_error_code": "mcp_binding_changed", + } + + merged_config = { + **(tool.config or {}), + **(assignment.config or {}), + } + merged_config = _decrypt_sensitive_fields( + merged_config, + tool.config_schema, + ) + return { + "full_name": binding.handler_key, + "raw_name": raw_name, + "server_url": server_url, + "server_name": server_name, + "config": merged_config, + "async_completion": deepcopy( + (tool.config or {}).get("async_completion") + ), + } + + async def _resolve_mcp_execution_target( tool_name: str, agent_id, @@ -6041,8 +6169,21 @@ async def _execute_resolved_mcp_target_outcome( ) -> ToolExecutionOutcome: unavailable_error = target.get("unavailable_error_code") if unavailable_error: - return _typed_failure( + summary = { + "mcp_binding_changed": ( + "MCP tool configuration changed after this call was selected. " + "Refresh the available tools before retrying." + ), + "mcp_binding_invalid": ( + "The saved MCP execution route is invalid. Refresh the " + "available tools before retrying." + ), + }.get( + str(unavailable_error), "MCP tool is not enabled, assigned, or locally configured.", + ) + return _typed_failure( + summary, str(unavailable_error), ) diff --git a/backend/app/services/llm/caller.py b/backend/app/services/llm/caller.py index f854bc06a..fcc9809e9 100644 --- a/backend/app/services/llm/caller.py +++ b/backend/app/services/llm/caller.py @@ -66,7 +66,7 @@ async def execute_tool(*args, **kwargs): "send_message_to_agent", "send_feishu_message", "send_email" }) -WRITE_FILE_PROTOCOL_REPAIR_LIMIT = 3 +WRITE_FILE_PROTOCOL_REPAIR_LIMIT = 10 WRITE_FILE_PROTOCOL_REPAIR_COUNTER_KEY = "invalid_tool_call:write_file" WRITE_FILE_PROTOCOL_REPAIR_INSTRUCTION = ( "Your previous `write_file` call was not executed because `function.arguments` " @@ -788,7 +788,7 @@ async def _buffer_chunk(_text: str) -> None: repair_limit = ( WRITE_FILE_PROTOCOL_REPAIR_LIMIT if retry_tool_name == "write_file" - else 1 + else 10 ) repair_counter_key = ( WRITE_FILE_PROTOCOL_REPAIR_COUNTER_KEY diff --git a/backend/tests/test_agent_runtime_model_step_service.py b/backend/tests/test_agent_runtime_model_step_service.py index 16cc1c390..53d1835da 100644 --- a/backend/tests/test_agent_runtime_model_step_service.py +++ b/backend/tests/test_agent_runtime_model_step_service.py @@ -20,7 +20,9 @@ RuntimeModelStepService, _group_mention_mismatches, _message_token_counter, + _provider_tools, _prompt_messages, + _runtime_workset_entry, _tool_repair_reset_reason, _visible_mention_names, ) @@ -32,6 +34,7 @@ runtime_message_to_json, ) from app.services.agent_runtime.tool_contracts import parse_step_tool_context +from app.services.agent_runtime.tool_registry import RUNTIME_TOOL_BINDING_KEY from app.services.llm.finish import FINISH_PROTOCOL_REMINDER from app.services.llm.single_step import LLMCompletionStep from app.services.token_tracker import TokenUsage @@ -43,6 +46,39 @@ _TINY_PNG_DATA_URL = f"data:image/png;base64,{_TINY_PNG_BASE64}" +def test_runtime_binding_is_checkpointed_but_not_sent_to_provider() -> None: + tool_id = uuid.uuid4() + assignment_id = uuid.uuid4() + tool = { + "type": "function", + "function": { + "name": "tenant_search", + "description": "Search the tenant source", + "parameters": {"type": "object", "properties": {}}, + }, + RUNTIME_TOOL_BINDING_KEY: { + "kind": "mcp", + "handler_key": "tenant_search", + "target": { + "tool_id": str(tool_id), + "route_digest": "digest", + }, + "credential_ref": str(assignment_id), + }, + } + + entry = _runtime_workset_entry(tool) + + assert entry.binding.target["tool_id"] == str(tool_id) + assert entry.binding.credential_ref == str(assignment_id) + assert _provider_tools((tool,)) == [ + { + "type": "function", + "function": tool["function"], + } + ] + + class _Result: def __init__(self, values=None) -> None: self.values = list(values or []) @@ -598,7 +634,7 @@ async def complete(model_arg, _messages, **_kwargs): @pytest.mark.asyncio -async def test_invalid_write_file_arguments_request_three_protocol_repairs() -> None: +async def test_invalid_write_file_arguments_request_ten_protocol_repairs() -> None: tenant_id = uuid.uuid4() model = _model(tenant_id) agent = _agent(tenant_id) diff --git a/backend/tests/test_agent_runtime_node_executor.py b/backend/tests/test_agent_runtime_node_executor.py index 74d2634e9..19fd4aa3a 100644 --- a/backend/tests/test_agent_runtime_node_executor.py +++ b/backend/tests/test_agent_runtime_node_executor.py @@ -1351,15 +1351,16 @@ async def test_empty_output_is_repaired_once_then_fails_explicitly() -> None: @pytest.mark.asyncio @pytest.mark.parametrize( - ("repair_code", "instruction"), + ("repair_code", "instruction", "repair_limit"), [ - ("invalid_finish", "Retry finish with valid content."), - ("invalid_tool_call", "Retry with valid JSON tool arguments."), + ("invalid_finish", "Retry finish with valid content.", 1), + ("invalid_tool_call", "Retry with valid JSON tool arguments.", 10), ], ) async def test_repeated_model_tool_protocol_repair_code_fails_explicitly( repair_code: str, instruction: str, + repair_limit: int, ) -> None: run_id = uuid.uuid4() repair = ModelStepResult( @@ -1368,7 +1369,7 @@ async def test_repeated_model_tool_protocol_repair_code_fails_explicitly( repair_instruction=instruction, repair_code=repair_code, ) - model = ModelService(repair, repair) + model = ModelService(*([repair] * (repair_limit + 1))) executor = _executor(model) result = await _invoke(run_id, executor, model_turn_limit=50) @@ -1377,13 +1378,13 @@ async def test_repeated_model_tool_protocol_repair_code_fails_explicitly( assert lifecycle["status"] == "failed" assert lifecycle["reason"] == "model_tool_protocol_violation" assert lifecycle["error"]["code"] == "model_tool_protocol_violation" - assert lifecycle["model_protocol_repairs"] == {repair_code: 1} - assert lifecycle["model_step_count"] == 2 - assert model.calls == 2 + assert lifecycle["model_protocol_repairs"] == {repair_code: repair_limit} + assert lifecycle["model_step_count"] == repair_limit + 1 + assert model.calls == repair_limit + 1 @pytest.mark.asyncio -async def test_write_file_protocol_repair_uses_three_attempts_then_guides_user() -> None: +async def test_write_file_protocol_repair_uses_ten_attempts_then_guides_user() -> None: run_id = uuid.uuid4() repair = ModelStepResult( intent="text", @@ -1392,7 +1393,7 @@ async def test_write_file_protocol_repair_uses_three_attempts_then_guides_user() repair_code="invalid_tool_call", repair_tool_name="write_file", ) - model = ModelService(repair, repair, repair, repair) + model = ModelService(*([repair] * 11)) executor = _executor(model) result = await _invoke(run_id, executor, model_turn_limit=50) @@ -1408,14 +1409,14 @@ async def test_write_file_protocol_repair_uses_three_attempts_then_guides_user() ), } assert lifecycle["model_protocol_repairs"] == { - "invalid_tool_call:write_file": 3, + "invalid_tool_call:write_file": 10, } - assert lifecycle["model_step_count"] == 4 - assert model.calls == 4 + assert lifecycle["model_step_count"] == 11 + assert model.calls == 11 @pytest.mark.asyncio -async def test_write_file_protocol_can_recover_on_the_third_repair() -> None: +async def test_write_file_protocol_can_recover_on_the_tenth_repair() -> None: run_id = uuid.uuid4() repair = ModelStepResult( intent="text", @@ -1424,9 +1425,7 @@ async def test_write_file_protocol_can_recover_on_the_third_repair() -> None: repair_tool_name="write_file", ) model = ModelService( - repair, - repair, - repair, + *([repair] * 10), ModelStepResult(intent="finish", finish_content="Recovered"), ) executor = _executor(model) @@ -1435,9 +1434,9 @@ async def test_write_file_protocol_can_recover_on_the_third_repair() -> None: assert result["lifecycle"]["status"] == "completed" assert result["lifecycle"]["model_protocol_repairs"] == { - "invalid_tool_call:write_file": 3, + "invalid_tool_call:write_file": 10, } - assert model.calls == 4 + assert model.calls == 11 @pytest.mark.asyncio diff --git a/backend/tests/test_agent_runtime_tool_repair_budget.py b/backend/tests/test_agent_runtime_tool_repair_budget.py index a2779f06b..2e9916216 100644 --- a/backend/tests/test_agent_runtime_tool_repair_budget.py +++ b/backend/tests/test_agent_runtime_tool_repair_budget.py @@ -50,7 +50,7 @@ def test_tenth_consecutive_fingerprint_pauses_without_off_by_one() -> None: assert _episode(state)["total_failures"] == 10 -def test_twentieth_tool_failure_pauses_even_when_fingerprint_changes() -> None: +def test_tenth_tool_failure_pauses_even_when_fingerprint_changes() -> None: state: dict = {} transition = None for model_step in range(1, TOOL_EPISODE_FAILURE_LIMIT + 1): @@ -63,7 +63,7 @@ def test_twentieth_tool_failure_pauses_even_when_fingerprint_changes() -> None: assert transition is not None assert transition.pause_reason == "tool_repair_episode_limit_reached" - assert _episode(state)["total_failures"] == 20 + assert _episode(state)["total_failures"] == 10 assert _episode(state)["same_fingerprint_failures"] == 1 diff --git a/backend/tests/test_agent_runtime_tool_step_service.py b/backend/tests/test_agent_runtime_tool_step_service.py index d8dcfce22..5eae86030 100644 --- a/backend/tests/test_agent_runtime_tool_step_service.py +++ b/backend/tests/test_agent_runtime_tool_step_service.py @@ -656,6 +656,82 @@ async def mark(db, **kwargs): assert result.messages[0]["execution_status"] == "succeeded" +@pytest.mark.asyncio +async def test_mcp_checkpoint_dispatches_the_frozen_execution_binding( + monkeypatch, +) -> None: + tenant_id = uuid.uuid4() + agent = _agent(tenant_id) + call = _call("call-frozen-mcp", "mcp.demo.lookup") + state = _state(tenant_id, agent, (call,)) + entry = ToolWorksetEntry( + tool_name="mcp.demo.lookup", + contract_version="registered:mcp.demo.lookup:v1", + parameters_schema={"type": "object", "properties": {}}, + binding=ToolExecutionBinding( + kind="mcp", + handler_key="mcp.demo.lookup", + target={ + "tool_id": str(uuid.uuid4()), + "route_digest": "digest", + }, + credential_ref=str(uuid.uuid4()), + ), + effect="external_write", + retry_policy="never", + ) + state["lifecycle"]["step_tool_context"] = StepToolContext( + assistant_message_id="assistant-message-1", + model_step=1, + workset_version=workset_version((entry,)), + accepted_calls=( + AcceptedToolCall( + call_instance_id="call-frozen-mcp", + provider_call_id="provider-frozen-mcp", + entry=entry, + ), + ), + ).to_json() + context = _context(state) + execution = _execution( + tenant_id, + uuid.UUID(context.run_id), + "call-frozen-mcp", + "mcp.demo.lookup", + ) + dispatched: list[tuple[tuple, dict]] = [] + + async def reserve(db, **kwargs): + del db, kwargs + return _reservation(execution) + + async def execute(*args, **kwargs): + dispatched.append((args, kwargs)) + return ToolExecutionOutcome( + status="succeeded", + result_summary="frozen result", + result_ref=None, + ) + + async def mark(db, **kwargs): + del db, kwargs + execution.status = "succeeded" + execution.result_summary = "frozen result" + return execution + + monkeypatch.setattr(tool_step_service, "reserve_tool_execution", reserve) + monkeypatch.setattr(tool_step_service, "mark_tool_execution_succeeded", mark) + result = await _service(agent, _CancelSource(None), execute).execute_pending( + state, + context, + (call,), + ) + + assert result.error is None + assert dispatched[0][0][0] == "mcp.demo.lookup" + assert dispatched[0][1]["execution_binding"] == entry.binding.to_json() + + @pytest.mark.asyncio async def test_new_checkpoint_context_mismatch_fails_before_provider_or_receipt() -> None: tenant_id = uuid.uuid4() @@ -2977,7 +3053,7 @@ async def test_retryable_read_exhaustion_returns_one_non_retryable_result( "call-read-exhausted", "read_file", ) - execution.attempt_count = 3 + execution.attempt_count = 10 async def reserve(db, **kwargs): del db @@ -3017,7 +3093,7 @@ async def mark_failed(db, **kwargs): assert "Do not repeat the identical tool call unchanged" in result.messages[0][ "content" ] - assert execution.result_metadata["runtime_attempt_count"] == 3 + assert execution.result_metadata["runtime_attempt_count"] == 10 assert execution.result_metadata["runtime_retry_exhausted"] is True assert execution.result_metadata["last_error_code"] == "temporary_read_failure" diff --git a/backend/tests/test_agent_tools_agentbay_a0.py b/backend/tests/test_agent_tools_agentbay_a0.py index 880e65f0f..472bd72b8 100644 --- a/backend/tests/test_agent_tools_agentbay_a0.py +++ b/backend/tests/test_agent_tools_agentbay_a0.py @@ -119,7 +119,7 @@ def __init__(self, *args, **kwargs): monkeypatch.setattr(agent_tools, "get_agent_tools_for_llm", assigned_tools) monkeypatch.setattr( agent_tools, - "_get_runtime_dynamic_mcp_tool_names", + "_get_runtime_dynamic_mcp_bindings", no_dynamic_mcp, ) monkeypatch.setattr(agent_tools, "_get_tool_config", local_tool_config) diff --git a/backend/tests/test_agent_tools_deploy_contracts.py b/backend/tests/test_agent_tools_deploy_contracts.py index 4f210cfbc..a105e43ba 100644 --- a/backend/tests/test_agent_tools_deploy_contracts.py +++ b/backend/tests/test_agent_tools_deploy_contracts.py @@ -219,7 +219,7 @@ async def no_dynamic_mcp(_agent_id): monkeypatch.setattr(agent_tools, "_get_tool_config", config) monkeypatch.setattr( agent_tools, - "_get_runtime_dynamic_mcp_tool_names", + "_get_runtime_dynamic_mcp_bindings", no_dynamic_mcp, ) monkeypatch.setattr( diff --git a/backend/tests/test_agent_tools_email_contracts.py b/backend/tests/test_agent_tools_email_contracts.py index f57e9fac5..a718a8fb7 100644 --- a/backend/tests/test_agent_tools_email_contracts.py +++ b/backend/tests/test_agent_tools_email_contracts.py @@ -86,7 +86,7 @@ async def no_dynamic_mcp(_agent_id): monkeypatch.setattr(agent_tools, "_get_email_config", email_config) monkeypatch.setattr( agent_tools, - "_get_runtime_dynamic_mcp_tool_names", + "_get_runtime_dynamic_mcp_bindings", no_dynamic_mcp, ) if include_untyped_email_writes: diff --git a/backend/tests/test_agent_tools_legacy_contract_compatibility.py b/backend/tests/test_agent_tools_legacy_contract_compatibility.py index 1cecf14a5..62c4b9891 100644 --- a/backend/tests/test_agent_tools_legacy_contract_compatibility.py +++ b/backend/tests/test_agent_tools_legacy_contract_compatibility.py @@ -1,6 +1,8 @@ from __future__ import annotations +from contextlib import asynccontextmanager from pathlib import Path +from types import SimpleNamespace import uuid import pytest @@ -11,6 +13,30 @@ from app.services.builtin_tool_definitions import builtin_model_definition +class _ScalarResult: + def __init__(self, value) -> None: + self.value = value + + def scalar_one_or_none(self): + return self.value + + +def _mcp_binding_session(tool, assignment): + @asynccontextmanager + async def factory(): + class Session: + def __init__(self) -> None: + self.results = iter((tool, assignment)) + + async def execute(self, statement): + del statement + return _ScalarResult(next(self.results)) + + yield Session() + + return factory + + def _definition(name: str) -> dict: definition = builtin_model_definition(name) assert definition is not None @@ -237,3 +263,142 @@ async def execute(resolved_target, arguments, *, agent_id): assert isinstance(outcome, ToolExecutionOutcome) assert outcome.status == "succeeded" assert calls == [(target, {"query": "contract"}, agent_id)] + + +@pytest.mark.asyncio +async def test_registered_dynamic_mcp_uses_frozen_binding_without_name_lookup( + monkeypatch: pytest.MonkeyPatch, +) -> None: + agent_id = uuid.uuid4() + binding = { + "kind": "mcp", + "handler_key": "tenant_search", + "target": { + "tool_id": str(uuid.uuid4()), + "route_digest": "digest", + }, + "credential_ref": str(uuid.uuid4()), + } + target = { + "full_name": "tenant_search", + "raw_name": "search", + "server_url": "https://frozen.example/mcp", + "config": {}, + } + calls: list[tuple[dict, dict, uuid.UUID]] = [] + + async def live_name_lookup_forbidden(*args, **kwargs): + raise AssertionError(f"frozen binding used live name lookup: {args}, {kwargs}") + + async def resolve_frozen(raw_binding, resolved_agent_id): + assert raw_binding == binding + assert resolved_agent_id == agent_id + return target + + async def execute(resolved_target, arguments, *, agent_id): + calls.append((resolved_target, arguments, agent_id)) + return ToolExecutionOutcome( + status="succeeded", + result_summary="MCP typed receipt", + result_ref=None, + ) + + monkeypatch.setattr( + agent_tools, + "_resolve_mcp_execution_target", + live_name_lookup_forbidden, + ) + monkeypatch.setattr( + agent_tools, + "_resolve_frozen_mcp_execution_target", + resolve_frozen, + raising=False, + ) + monkeypatch.setattr( + agent_tools, + "_execute_resolved_mcp_target_outcome", + execute, + ) + + outcome = await agent_tools.execute_builtin_tool_outcome( + "tenant_search", + {"query": "contract"}, + agent_id, + uuid.uuid4(), + execution_binding=binding, + ) + + assert isinstance(outcome, ToolExecutionOutcome) + assert outcome.status == "succeeded" + assert calls == [(target, {"query": "contract"}, agent_id)] + + +@pytest.mark.asyncio +async def test_frozen_mcp_binding_resolves_assignment_and_rejects_route_drift( + monkeypatch: pytest.MonkeyPatch, +) -> None: + agent_id = uuid.uuid4() + tool_id = uuid.uuid4() + assignment_id = uuid.uuid4() + tool = SimpleNamespace( + id=tool_id, + name="tenant_search", + enabled=True, + mcp_server_url="https://frozen.example/mcp", + mcp_server_name="search", + mcp_tool_name="lookup", + config={}, + config_schema={}, + ) + assignment = SimpleNamespace( + id=assignment_id, + agent_id=agent_id, + tool_id=tool_id, + enabled=True, + config={}, + ) + monkeypatch.setattr( + agent_tools, + "async_session", + _mcp_binding_session(tool, assignment), + ) + + binding = { + "kind": "mcp", + "handler_key": "tenant_search", + "target": { + "tool_id": str(tool_id), + "route_digest": agent_tools._mcp_route_digest( + server_url="https://frozen.example/mcp", + server_name="search", + raw_name="lookup", + async_completion=None, + ), + }, + "credential_ref": str(assignment_id), + } + + target = await agent_tools._resolve_frozen_mcp_execution_target( + binding, + agent_id, + ) + + assert target == { + "full_name": "tenant_search", + "raw_name": "lookup", + "server_url": "https://frozen.example/mcp", + "server_name": "search", + "config": {}, + "async_completion": None, + } + + tool.mcp_server_url = "https://changed.example/mcp" + target = await agent_tools._resolve_frozen_mcp_execution_target( + binding, + agent_id, + ) + + assert target == { + "full_name": "tenant_search", + "unavailable_error_code": "mcp_binding_changed", + } diff --git a/backend/tests/test_agent_tools_okr_contracts.py b/backend/tests/test_agent_tools_okr_contracts.py index 7d28bf60c..ccf7db2b4 100644 --- a/backend/tests/test_agent_tools_okr_contracts.py +++ b/backend/tests/test_agent_tools_okr_contracts.py @@ -387,7 +387,7 @@ async def not_designated(_agent_id): monkeypatch.setattr(agent_tools, "get_agent_tools_for_llm", assigned) monkeypatch.setattr( agent_tools, - "_get_runtime_dynamic_mcp_tool_names", + "_get_runtime_dynamic_mcp_bindings", no_dynamic, ) monkeypatch.setattr( @@ -433,7 +433,7 @@ async def designated(_agent_id): monkeypatch.setattr(agent_tools, "get_agent_tools_for_llm", assigned) monkeypatch.setattr( agent_tools, - "_get_runtime_dynamic_mcp_tool_names", + "_get_runtime_dynamic_mcp_bindings", no_dynamic, ) monkeypatch.setattr( @@ -476,7 +476,7 @@ async def designated(_agent_id): monkeypatch.setattr(agent_tools, "get_agent_tools_for_llm", assigned) monkeypatch.setattr( agent_tools, - "_get_runtime_dynamic_mcp_tool_names", + "_get_runtime_dynamic_mcp_bindings", no_dynamic, ) monkeypatch.setattr( diff --git a/backend/tests/test_agent_tools_typed_agentbay_reads.py b/backend/tests/test_agent_tools_typed_agentbay_reads.py index c80d61e3d..813321b08 100644 --- a/backend/tests/test_agent_tools_typed_agentbay_reads.py +++ b/backend/tests/test_agent_tools_typed_agentbay_reads.py @@ -435,7 +435,7 @@ def __init__(self, *_args, **_kwargs) -> None: monkeypatch.setattr(agent_tools, "get_agent_tools_for_llm", assigned_tools) monkeypatch.setattr( agent_tools, - "_get_runtime_dynamic_mcp_tool_names", + "_get_runtime_dynamic_mcp_bindings", no_dynamic_mcp, ) monkeypatch.setattr(agent_tools, "_get_tool_config", local_config) diff --git a/backend/tests/test_agent_tools_typed_deploy_reads.py b/backend/tests/test_agent_tools_typed_deploy_reads.py index 9ef070602..bd26771e1 100644 --- a/backend/tests/test_agent_tools_typed_deploy_reads.py +++ b/backend/tests/test_agent_tools_typed_deploy_reads.py @@ -192,7 +192,7 @@ async def config(_agent_id, requested_name): monkeypatch.setattr(agent_tools, "get_agent_tools_for_llm", assigned) monkeypatch.setattr( agent_tools, - "_get_runtime_dynamic_mcp_tool_names", + "_get_runtime_dynamic_mcp_bindings", no_dynamic_mcp, ) monkeypatch.setattr(agent_tools, "_get_tool_config", config) @@ -224,7 +224,7 @@ async def no_config(_agent_id, _requested_name): monkeypatch.setattr(agent_tools, "get_agent_tools_for_llm", assigned) monkeypatch.setattr( agent_tools, - "_get_runtime_dynamic_mcp_tool_names", + "_get_runtime_dynamic_mcp_bindings", no_dynamic_mcp, ) monkeypatch.setattr(agent_tools, "_get_tool_config", no_config) diff --git a/backend/tests/test_agent_tools_typed_deploy_simple_writes.py b/backend/tests/test_agent_tools_typed_deploy_simple_writes.py index 3be8baf33..7bdc5db48 100644 --- a/backend/tests/test_agent_tools_typed_deploy_simple_writes.py +++ b/backend/tests/test_agent_tools_typed_deploy_simple_writes.py @@ -295,7 +295,7 @@ async def no_dynamic(_agent_id): monkeypatch.setattr(agent_tools, "_get_tool_config", config) monkeypatch.setattr( agent_tools, - "_get_runtime_dynamic_mcp_tool_names", + "_get_runtime_dynamic_mcp_bindings", no_dynamic, ) monkeypatch.setattr(httpx, "AsyncClient", NetworkMustNotBeUsed) diff --git a/backend/tests/test_agent_tools_typed_dynamic_mcp.py b/backend/tests/test_agent_tools_typed_dynamic_mcp.py index 468119449..5058d489b 100644 --- a/backend/tests/test_agent_tools_typed_dynamic_mcp.py +++ b/backend/tests/test_agent_tools_typed_dynamic_mcp.py @@ -24,6 +24,18 @@ def _tool(name: str) -> dict: } +def _binding(name: str) -> dict: + return { + "kind": "mcp", + "handler_key": name, + "target": { + "tool_id": str(uuid.uuid4()), + "route_digest": "digest", + }, + "credential_ref": str(uuid.uuid4()), + } + + def _async_completion_contract() -> dict: return { "version": 1, @@ -65,23 +77,14 @@ async def test_runtime_resolver_exposes_only_enabled_assigned_non_reserved_mcp( async def assigned(_agent_id): return tools - async def dynamic_names(_agent_id): - # The DB resolver returns only locally ready rows whose Tool and - # AgentTool records are both enabled. - return { - "mcp_visible_lookup", - "at", - "finish", - "wait", - "group_private_lookup", - "generate_image_openai", - } + async def dynamic_bindings(_agent_id): + return {"mcp_visible_lookup": _binding("mcp_visible_lookup")} monkeypatch.setattr(agent_tools, "get_agent_tools_for_llm", assigned) monkeypatch.setattr( agent_tools, - "_get_runtime_dynamic_mcp_tool_names", - dynamic_names, + "_get_runtime_dynamic_mcp_bindings", + dynamic_bindings, ) resolved = await agent_tools.get_runtime_agent_tools_for_llm(uuid.uuid4()) @@ -98,8 +101,8 @@ async def test_runtime_mcp_readiness_is_local_and_never_pings_provider( async def assigned(_agent_id): return [_tool("mcp_visible_lookup")] - async def dynamic_names(_agent_id): - return {"mcp_visible_lookup"} + async def dynamic_bindings(_agent_id): + return {"mcp_visible_lookup": _binding("mcp_visible_lookup")} async def network_forbidden(*_args, **_kwargs): raise AssertionError("model-step readiness must not ping MCP providers") @@ -107,8 +110,8 @@ async def network_forbidden(*_args, **_kwargs): monkeypatch.setattr(agent_tools, "get_agent_tools_for_llm", assigned) monkeypatch.setattr( agent_tools, - "_get_runtime_dynamic_mcp_tool_names", - dynamic_names, + "_get_runtime_dynamic_mcp_bindings", + dynamic_bindings, ) monkeypatch.setattr(MCPClient, "list_tools", network_forbidden) diff --git a/backend/tests/test_agent_tools_typed_feishu_remaining.py b/backend/tests/test_agent_tools_typed_feishu_remaining.py index ae4948d46..0b8f2355e 100644 --- a/backend/tests/test_agent_tools_typed_feishu_remaining.py +++ b/backend/tests/test_agent_tools_typed_feishu_remaining.py @@ -296,7 +296,7 @@ async def no_dynamic(_agent_id): monkeypatch.setattr(agent_tools, "_agent_has_feishu", not_ready) monkeypatch.setattr( agent_tools, - "_get_runtime_dynamic_mcp_tool_names", + "_get_runtime_dynamic_mcp_bindings", no_dynamic, ) monkeypatch.setattr( @@ -333,7 +333,7 @@ async def no_dynamic(_agent_id): monkeypatch.setattr(agent_tools, "_agent_has_feishu", ready) monkeypatch.setattr( agent_tools, - "_get_runtime_dynamic_mcp_tool_names", + "_get_runtime_dynamic_mcp_bindings", no_dynamic, ) monkeypatch.setattr( @@ -371,7 +371,7 @@ async def no_dynamic(_agent_id): monkeypatch.setattr(agent_tools, "_agent_has_feishu", ready) monkeypatch.setattr( agent_tools, - "_get_runtime_dynamic_mcp_tool_names", + "_get_runtime_dynamic_mcp_bindings", no_dynamic, ) diff --git a/backend/tests/test_agent_tools_typed_image_outcomes_v2.py b/backend/tests/test_agent_tools_typed_image_outcomes_v2.py index 1e36fd354..f0d5032c5 100644 --- a/backend/tests/test_agent_tools_typed_image_outcomes_v2.py +++ b/backend/tests/test_agent_tools_typed_image_outcomes_v2.py @@ -409,7 +409,7 @@ def __init__(self, *args, **kwargs): monkeypatch.setattr(agent_tools, "get_agent_tools_for_llm", assigned) monkeypatch.setattr( agent_tools, - "_get_runtime_dynamic_mcp_tool_names", + "_get_runtime_dynamic_mcp_bindings", no_dynamic_mcp, ) monkeypatch.setattr(agent_tools, "_get_tool_config", configured) @@ -439,7 +439,7 @@ async def missing_config(_agent_id, _requested_name): monkeypatch.setattr(agent_tools, "get_agent_tools_for_llm", assigned) monkeypatch.setattr( agent_tools, - "_get_runtime_dynamic_mcp_tool_names", + "_get_runtime_dynamic_mcp_bindings", no_dynamic_mcp, ) monkeypatch.setattr(agent_tools, "_get_tool_config", missing_config) diff --git a/backend/tests/test_agent_tools_typed_okr_jobs.py b/backend/tests/test_agent_tools_typed_okr_jobs.py index eb45a26bc..11221c291 100644 --- a/backend/tests/test_agent_tools_typed_okr_jobs.py +++ b/backend/tests/test_agent_tools_typed_okr_jobs.py @@ -236,7 +236,7 @@ async def designated(_agent_id): monkeypatch.setattr(agent_tools, "get_agent_tools_for_llm", assigned) monkeypatch.setattr( agent_tools, - "_get_runtime_dynamic_mcp_tool_names", + "_get_runtime_dynamic_mcp_bindings", no_dynamic, ) monkeypatch.setattr( @@ -268,7 +268,7 @@ async def not_designated(_agent_id): monkeypatch.setattr(agent_tools, "get_agent_tools_for_llm", assigned) monkeypatch.setattr( agent_tools, - "_get_runtime_dynamic_mcp_tool_names", + "_get_runtime_dynamic_mcp_bindings", no_dynamic, ) monkeypatch.setattr( diff --git a/backend/tests/test_finish_protocol.py b/backend/tests/test_finish_protocol.py index a63f0497a..e61e66227 100644 --- a/backend/tests/test_finish_protocol.py +++ b/backend/tests/test_finish_protocol.py @@ -811,7 +811,7 @@ async def test_repeated_invalid_tool_json_is_bounded_by_protocol_code(monkeypatc } ], ) - fake_client = FakeStreamClient([invalid, invalid]) + fake_client = FakeStreamClient([invalid] * 11) monkeypatch.setattr(caller, "_get_agent_config", lambda _agent_id: _async_return((50, None))) monkeypatch.setattr(caller, "_get_user_name", lambda _user_id: _async_return("Ray")) monkeypatch.setattr( @@ -841,12 +841,12 @@ async def test_repeated_invalid_tool_json_is_bounded_by_protocol_code(monkeypatc ) assert result.startswith("[Error] invalid_tool_call_protocol_violation:") - assert len(fake_client.messages_seen) == 2 + assert len(fake_client.messages_seen) == 11 assert fake_client.closed is True @pytest.mark.asyncio -async def test_invalid_write_file_json_gets_three_bounded_repairs(monkeypatch): +async def test_invalid_write_file_json_gets_ten_bounded_repairs(monkeypatch): from app.services.llm import caller from app.services.llm.client import LLMResponse @@ -863,7 +863,7 @@ async def test_invalid_write_file_json_gets_three_bounded_repairs(monkeypatch): } ], ) - fake_client = FakeStreamClient([invalid, invalid, invalid, invalid]) + fake_client = FakeStreamClient([invalid] * 11) monkeypatch.setattr(caller, "_get_agent_config", lambda _agent_id: _async_return((50, None))) monkeypatch.setattr(caller, "_get_user_name", lambda _user_id: _async_return("Ray")) monkeypatch.setattr( @@ -897,7 +897,7 @@ async def test_invalid_write_file_json_gets_three_bounded_repairs(monkeypatch): "本次文件生成未完成:write_file 工具参数无效或被截断,连续重试后仍无法执行。" "请回复「重新生成」,我会基于当前对话重新尝试。" ) - assert len(fake_client.messages_seen) == 4 + assert len(fake_client.messages_seen) == 11 assert fake_client.closed is True diff --git a/backend/tests/test_tool_execution.py b/backend/tests/test_tool_execution.py index 8932247bc..37157be5f 100644 --- a/backend/tests/test_tool_execution.py +++ b/backend/tests/test_tool_execution.py @@ -881,7 +881,10 @@ async def test_expired_final_safe_read_attempt_closes_without_provider_replay(): assert reservation.prior_failure is not None assert reservation.prior_failure.error_code == "tool_retry_exhausted" assert execution.status == "failed" - assert execution.result_metadata["runtime_attempt_count"] == 3 + assert ( + execution.result_metadata["runtime_attempt_count"] + == tool_execution.SAFE_READ_MAX_ATTEMPTS + ) assert execution.result_metadata["runtime_retry_exhausted"] is True assert db.flush_count == 1 diff --git a/specs/002-tool-runtime-contract/checklists/requirements.md b/specs/002-tool-runtime-contract/checklists/requirements.md index 8c377ca7b..9c4a731f5 100644 --- a/specs/002-tool-runtime-contract/checklists/requirements.md +++ b/specs/002-tool-runtime-contract/checklists/requirements.md @@ -33,4 +33,4 @@ - 第一次校验即通过,无 `[NEEDS CLARIFICATION]` 项。 - `Tool Call`、`Run`、`Receipt`、`checkpoint` 等词是本产品领域对象,不是具体实现方案;具体数据结构、文件和迁移步骤将在 Plan 阶段定义。 -- Spec 已覆盖用户确认的 10/20 repair budget、模型可见错误反馈、unknown write 禁止自动重放和旧 checkpoint 兼容边界。 +- Spec 已覆盖用户确认的 Tool repair/retry 上限统一为 10、模型可见错误反馈、unknown write 禁止自动重放和旧 checkpoint 兼容边界;计数结构统一重构已明确延期。 diff --git a/specs/002-tool-runtime-contract/contracts/repair-and-lifecycle.md b/specs/002-tool-runtime-contract/contracts/repair-and-lifecycle.md index 9524e391d..dc6db0791 100644 --- a/specs/002-tool-runtime-contract/contracts/repair-and-lifecycle.md +++ b/specs/002-tool-runtime-contract/contracts/repair-and-lifecycle.md @@ -3,7 +3,8 @@ ## Tool Repair Episode - `same_fingerprint_failures` reaches 10: pause immediately after recording the 10th failure; do not invoke model step 11 for that loop. -- `total_failures` reaches 20 for the same Tool episode: pause immediately; do not invoke the next model step. +- `total_failures` reaches 10 for the same Tool episode: pause immediately; do not invoke the next model step. +- Generic Tool protocol repair, `write_file` protocol repair, and safe-read replay retain their current independent counters but each uses a limit of 10; counter unification is deferred. - Changing fingerprint resets only the consecutive counter. - Success of the same Tool, new Run, or explicit user correction resets the Tool episode. - Success of another Tool does not reset it. diff --git a/specs/002-tool-runtime-contract/plan.md b/specs/002-tool-runtime-contract/plan.md index fc0227bf2..50d3fef64 100644 --- a/specs/002-tool-runtime-contract/plan.md +++ b/specs/002-tool-runtime-contract/plan.md @@ -5,7 +5,7 @@ ## Summary -在现有 Durable Runtime、`AgentToolExecution` Receipt、safe-read replay 和 unknown/reconcile 机制之上,增加一次 Model Step 固化、checkpoint 可恢复的 `StepToolContext`。新 Tool Step 只使用已接受的 Tool Contract/Execution Binding,不再调用 ToolProvider 重建 Workset;同时把 Provider Call ID、Runtime Call Instance 和 Execution Receipt 分离,统一 schema validation、authorization/approval、模型可见失败反馈及 10/20 repair budget。操作 deadline、取消传播和 Receipt lease 继续保持三个独立控制面。长期通过可渐进迁移的 RegisteredTool 收敛模型定义与执行能力,不一次性替换现有 Handler。 +在现有 Durable Runtime、`AgentToolExecution` Receipt、safe-read replay 和 unknown/reconcile 机制之上,增加一次 Model Step 固化、checkpoint 可恢复的 `StepToolContext`。新 Tool Step 只使用已接受的 Tool Contract/Execution Binding,不再调用 ToolProvider 重建 Workset;同时把 Provider Call ID、Runtime Call Instance 和 Execution Receipt 分离,统一 schema validation、authorization/approval、模型可见失败反馈,并将现有独立 Tool repair/retry 上限统一为 10。操作 deadline、取消传播和 Receipt lease 继续保持三个独立控制面。长期通过可渐进迁移的 RegisteredTool 收敛模型定义与执行能力,不一次性替换现有 Handler。 ## Technical Context @@ -50,7 +50,7 @@ ### Phase C — Repair budgets 1. checkpoint 保存 per-tool repair episode、连续 fingerprint 计数和总计数。 -2. 第 10 次连续相同失败或第 20 次同 Tool episode 失败后暂停,且不发起下一次模型调用。 +2. 第 10 次连续相同失败或第 10 次同 Tool episode 失败后暂停,且不发起下一次模型调用;普通 Tool JSON repair、`write_file` JSON repair 和 safe-read replay 也只把现有独立上限改为 10,不在本轮重构计数结构。 3. Tool 成功、新 Run、用户明确纠正按 contract 重置;Provider retry、safe internal replay、permission/confirmation、pending、cancel、unknown 不计数。 4. Verifier repair 改为当前 issue episode 计数,保留全局 `model_turn_limit` 独立语义。 @@ -122,7 +122,7 @@ backend/ 2. Runtime integration tests:Model Step → checkpoint → 新 Worker Tool Step;普通 availability 变化不影响已接受 Call;安全状态变化仍阻断。 3. Receipt tests:replay 复用同一 execution;lease renewal/loss/fence;unknown write no replay;safe read bounded retry。 4. Compatibility tests:旧 checkpoint 单次 resolver、新 checkpoint 禁止 resolver、mixed-version nullable fields。 -5. Lifecycle tests:10/20 off-by-one、reset/exclusion、operation deadline、cancel propagation。 +5. Lifecycle tests:统一上限 10 的 off-by-one、reset/exclusion、operation deadline、cancel propagation。 6. Static gates:scoped Ruff、pytest、Alembic single head + upgrade/downgrade、`scripts/arch-guard.sh`。 ## Complexity Tracking diff --git a/specs/002-tool-runtime-contract/quickstart.md b/specs/002-tool-runtime-contract/quickstart.md index dbdff9656..40c60830f 100644 --- a/specs/002-tool-runtime-contract/quickstart.md +++ b/specs/002-tool-runtime-contract/quickstart.md @@ -26,7 +26,7 @@ Expected branch: `002-tool-runtime-contract`; base contains `upstream/main@251ae 3. Remove ToolProvider access from new-format Tool Step; add legacy batch resolver. 4. Add DB columns/migration and projection metadata. 5. Add shared validation/authorization/failure envelope. -6. Add repair episode state and 10/20 gates. +6. Add repair episode state and uniform Tool repair/retry limit 10 gates. 7. Harden operation deadlines/cancel/lease tests. 8. Add RegisteredTool boundary and migrate representative tools only. @@ -66,7 +66,7 @@ cd backend - checkpoint restart on another Worker uses the same binding and execution row; - repeated Provider-local ID in another Assistant Turn does not collide; - schema failure returns exactly one sanitized Tool Result; -- failure 10 and 20 pause before the next model invocation; +- the 10th repair failure pauses before the next model invocation; - provider retry, safe replay, pending, cancel and unknown do not increment repair budget; - lease loss blocks stale settlement; uncertain write is never auto-replayed; - legacy checkpoint resolves once per pending batch, new checkpoint never uses legacy fallback. diff --git a/specs/002-tool-runtime-contract/research.md b/specs/002-tool-runtime-contract/research.md index 359f331da..d8c1591f2 100644 --- a/specs/002-tool-runtime-contract/research.md +++ b/specs/002-tool-runtime-contract/research.md @@ -56,7 +56,7 @@ ### D7. Repair budget 是 Tool episode,不是 Provider/Receipt retry -**Decision**: 连续同 fingerprint 第 10 次、同 Tool episode 第 20 次暂停;只计模型可见、可修复失败。 +**Decision**: 连续同 fingerprint 第 10 次、同 Tool episode 第 10 次暂停;只计模型可见、可修复失败。普通 Tool protocol repair、`write_file` protocol repair 和 safe-read replay 继续使用各自现有计数入口,但上限统一为 10,状态结构后续再整体重构。 **Rationale**: Provider transport retry 和 Receipt safe replay 都不代表模型做了错误决策;混计会过早停机或掩盖循环。 diff --git a/specs/002-tool-runtime-contract/spec.md b/specs/002-tool-runtime-contract/spec.md index 092302cef..807f362de 100644 --- a/specs/002-tool-runtime-contract/spec.md +++ b/specs/002-tool-runtime-contract/spec.md @@ -70,11 +70,12 @@ **Acceptance Scenarios**: 1. **Given** 同一稳定错误已经连续作为模型可见失败出现 9 次,**When** 第 10 次相同失败被记录,**Then** 系统保存该失败并暂停,不能开始第 11 次模型调用。 -2. **Given** 同一个 Tool 在当前 episode 中出现 19 次可计数失败,错误指纹可以变化,**When** 第 20 次失败被记录,**Then** 系统暂停,不能开始下一次模型调用。 +2. **Given** 同一个 Tool 在当前 episode 中出现 9 次可计数失败,错误指纹可以变化,**When** 第 10 次失败被记录,**Then** 系统暂停,不能开始下一次模型调用。 3. **Given** 失败指纹变化但 Tool 相同,**When** 记录新失败,**Then** 连续相同错误计数重新开始,但同 Tool episode 总数保留。 4. **Given** 被跟踪 Tool 成功、新 Run 开始,或用户明确纠正后恢复,**When** 后续再发生失败,**Then** 按对应规则开启新的 repair episode。 5. **Given** 事件属于 Provider transport retry、安全内部 replay、permission/confirmation wait、async pending、cancel 或 unknown external write,**When** 系统处理事件,**Then** 不增加模型修复计数。 6. **Given** 全局 Run 模型轮次已经达到上限,**When** 本地 Tool repair budget 尚未耗尽,**Then** 全局上限仍独立生效并展示不同的停止原因。 +7. **Given** 普通 Tool 或 `write_file` 的 arguments JSON 无效或截断,**When** Runtime 请求模型修复,**Then** 两类 Tool 都分别最多提供 10 次重写机会;safe-read Runtime replay 最多执行同一调用 10 次。本轮只统一上限数值,不重构这些独立计数器。 --- @@ -121,7 +122,7 @@ - 管理员关闭 Tool,但当前已接受调用仍在等待人工确认;用户随后拒绝、接受或取消。 - Safe-read 内部 retry 已耗尽,最终只应产生一次模型可见失败和一次 repair 计数。 - Tool Result 已写入 checkpoint,但节点被重新调度;结果消息和 repair counter 不能重复追加。 -- 同一 Tool 在不同错误之间交替,连续相同错误计数不断重置,但同 Tool episode 最终达到 20。 +- 同一 Tool 在不同错误之间交替,连续相同错误计数不断重置,但同 Tool episode 最终达到 10。 - Unknown external write 在重启、重连、用户输入或模型继续推理时仍不得自动重放。 - Handler 完成时 lease 已丢失;旧 owner 不能覆盖新 owner 或绕过 fence 结算。 - 底层线程调用无法真正取消;系统必须停止等待并明确记录底层取消能力限制。 @@ -148,10 +149,10 @@ - **FR-015**: Permission/confirmation、async pending、cancel、unknown external write 和协议损坏 MUST 使用各自独立状态,不得伪装成普通可修复 Tool failure。 - **FR-016**: Unknown possible write MUST 阻止自动重放,直到通过外部查询、稳定幂等结果或明确人工处理完成协调。 - **FR-017**: 系统 MUST 在第 10 次连续相同且模型可见的可修复失败后暂停,并且 MUST NOT 启动第 11 次模型调用。 -- **FR-018**: 系统 MUST 在同一个 Tool repair episode 的第 20 次可计数失败后暂停,并且 MUST NOT 启动下一次模型调用。 +- **FR-018**: 系统 MUST 在同一个 Tool repair episode 的第 10 次可计数失败后暂停,并且 MUST NOT 启动下一次模型调用。 - **FR-019**: 不同错误指纹 MUST 只重置连续相同错误计数,不得清除同 Tool episode 总数。 - **FR-020**: 对应 Tool 成功、新 Run 或用户明确纠正后恢复 MUST 按定义重置 repair episode;无关 Tool 成功不得清除其他 Tool 的失败 episode。 -- **FR-021**: Provider transport retry、安全内部 replay、permission/confirmation wait、async pending、cancel 和 unknown external write MUST NOT 增加模型修复计数。 +- **FR-021**: Provider transport retry、安全内部 replay、permission/confirmation wait、async pending、cancel 和 unknown external write MUST NOT 增加模型修复计数。普通 Tool protocol repair、`write_file` protocol repair 和 safe-read replay 保留独立计数结构,但各自上限 MUST 统一为 10;计数结构重构不属于本轮改动。 - **FR-022**: 全局模型轮次上限 MUST 与 Tool repair budget、Provider retry、Command retry 和 Verifier repair 保持独立,并报告不同停止原因。 - **FR-023**: Verifier repair MUST 按当前问题 episode 计数;历史已结束问题不得耗尽新的 verifier episode。 - **FR-024**: 外部 I/O 和长时间操作 MUST 具有与具体操作匹配的最长等待规则;系统 MUST NOT 用单一固定秒数替代所有 Tool 的时限。 @@ -183,7 +184,7 @@ - **SC-002**: 在所有受支持 Provider 的多轮 Tool 场景中,重复的 Provider-local Call ID 产生 0 次执行记录、Tool Result、Activity、Chat 或 A2A correlation 碰撞。 - **SC-003**: 同一调用实例在至少一次 checkpoint replay 后仍只产生一条有效执行记录;未知外部写的自动重放次数为 0。 - **SC-004**: 100% 带有效身份的可修复参数、binding 和明确业务失败产生恰好一个模型可见 Tool Result;敏感信息泄漏测试通过率为 100%。 -- **SC-005**: 第 10 次连续相同错误和第 20 次同 Tool episode 失败均在规定边界暂停,所有 off-by-one、reset 和 exclusion 测试通过率为 100%。 +- **SC-005**: 第 10 次连续相同错误和第 10 次同 Tool episode 失败均在规定边界暂停;普通 Tool JSON repair、`write_file` JSON repair 和 safe-read replay 的独立上限均为 10;所有 off-by-one、reset 和 exclusion 测试通过率为 100%。 - **SC-006**: Permission、confirmation、pending、cancel、unknown write、Provider retry 和全局模型轮次上限均显示独立原因,测试中不存在跨预算误计数。 - **SC-007**: 所有列入范围的 IMAP、DNS、AgentBay read 和代码执行路径在配置的最长等待内返回结果或明确状态,不产生无限等待测试用例。 - **SC-008**: 长时间 Handler 的 lease renewal、lease loss 和 cancel 测试均不会产生并发双执行或旧 owner 越权结算。 @@ -198,4 +199,4 @@ - 完整 Provider Schema capability matrix、默认 Tool 集合收窄、通用 Tool Search 和通用并行执行不属于本功能。 - 未迁移的 AgentBay Action 继续保持隐藏,后续按 Tool family 分批迁移。 - 旧 checkpoint 兼容路径只在有观测证据证明不再使用后删除。 -- 用户已经确定 repair budget 为:连续相同错误 10 次、同 Tool episode 20 次,并保留独立的全局 Run 模型轮次上限。 +- 用户已经确定所有 Tool 相关 repair/retry 上限统一为 10,并保留独立的计数结构与全局 Run 模型轮次上限;计数结构后续统一重构。 diff --git a/specs/002-tool-runtime-contract/tasks.md b/specs/002-tool-runtime-contract/tasks.md index ad234a195..965a3ab40 100644 --- a/specs/002-tool-runtime-contract/tasks.md +++ b/specs/002-tool-runtime-contract/tasks.md @@ -106,13 +106,13 @@ ## Phase 6: User Story 4 — 修复次数按问题边界计算 (Priority: P2) -**Goal**: 实现连续同错 10、同 Tool episode 20,并与其他 retry budget 分离。 +**Goal**: 实现连续同错 10、同 Tool episode 10,并将现有独立 Tool repair/retry 上限统一为 10;本轮不重构计数结构。 -**Independent Test**: 10/20 边界、fingerprint 变化、Tool success、新 Run、用户纠正、无关 Tool success 及所有 exclusion 均按 contract 转移。 +**Independent Test**: 统一上限 10 的边界、fingerprint 变化、Tool success、新 Run、用户纠正、无关 Tool success及所有 exclusion 均按 contract 转移。 ### Tests -- [x] T035 [P] [US4] 在 `backend/tests/test_agent_runtime_tool_repair_budget.py` 增加 10/20 off-by-one 与 fingerprint 测试 +- [x] T035 [P] [US4] 在 `backend/tests/test_agent_runtime_tool_repair_budget.py` 增加统一上限 10 的 off-by-one 与 fingerprint 测试 - [x] T036 [P] [US4] 在 `backend/tests/test_agent_runtime_tool_repair_budget.py` 增加 success/new Run/user correction/reset scope 测试 - [x] T037 [P] [US4] 在 `backend/tests/test_agent_runtime_tool_repair_budget.py` 增加 Provider retry/safe replay/approval/pending/cancel/unknown exclusion 测试 - [x] T038 [P] [US4] 在 `backend/tests/test_agent_runtime_node_executor.py` 增加暂停发生在下一次 Model 调用之前的集成测试 @@ -237,7 +237,7 @@ T012 live safety revocation tests 1. US1 消除已接受 Call 的 Workset 漂移。 2. US2/US3 补齐模型可修复反馈和身份兼容。 -3. US4 落地 10/20 修复次数。 +3. US4 落地统一上限 10 的修复次数,保留现有独立计数结构。 4. US5 加固长任务生命周期。 5. US6 建立长期 Registry 迁移边界。