Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/app/services/agent_runtime/node_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down
16 changes: 15 additions & 1 deletion backend/app/services/agent_runtime/tool_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,13 @@ def __post_init__(self) -> None:
"runtime_default", 60.0, 300.0, "stop_waiting_only"
),
"network_read": ToolDeadlinePolicy(
"network_read", 30.0, 60.0, "stop_waiting_only"
"network_read", 60.0, 60.0, "stop_waiting_only"
),
"image_generation": ToolDeadlinePolicy(
"image_generation", 120.0, 120.0, "stop_waiting_only"
),
"custom_image_generation": ToolDeadlinePolicy(
"custom_image_generation", 600.0, 600.0, "stop_waiting_only"
),
"local_code": ToolDeadlinePolicy(
"local_code", 30.0, 3600.0, "cooperative"
Expand All @@ -93,6 +99,14 @@ def deadline_policy_for_tool(tool_name: str) -> ToolDeadlinePolicy:
return _DEADLINE_POLICIES["agentbay_read"]
if tool_name in {"read_emails", "read_webpage", "jina_read"}:
return _DEADLINE_POLICIES["network_read"]
if tool_name == "generate_image_custom":
return _DEADLINE_POLICIES["custom_image_generation"]
if tool_name in {
"generate_image_siliconflow",
"generate_image_openai",
"generate_image_google",
}:
return _DEADLINE_POLICIES["image_generation"]
return _DEADLINE_POLICIES["runtime_default"]


Expand Down
2 changes: 1 addition & 1 deletion backend/app/services/agent_runtime/tool_execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion backend/app/services/agent_runtime/tool_repair_budget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
)
Expand Down
2 changes: 1 addition & 1 deletion backend/app/services/builtin_tool_definitions.py
Original file line number Diff line number Diff line change
Expand Up @@ -3837,7 +3837,7 @@
"generate_image_siliconflow": 120,
"generate_image_openai": 120,
"generate_image_google": 120,
"generate_image_custom": 120,
"generate_image_custom": 600,
}


Expand Down
4 changes: 2 additions & 2 deletions backend/app/services/llm/caller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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` "
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion backend/tests/test_agent_runtime_model_step_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -598,7 +598,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)
Expand Down
35 changes: 17 additions & 18 deletions backend/tests/test_agent_runtime_node_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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)
Expand All @@ -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",
Expand All @@ -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)
Expand All @@ -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",
Expand All @@ -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)
Expand All @@ -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
Expand Down
25 changes: 25 additions & 0 deletions backend/tests/test_agent_runtime_tool_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,34 @@
ToolContractError,
ToolExecutionBinding,
ToolWorksetEntry,
deadline_policy_for_tool,
resolve_tool_deadline_seconds,
parse_step_tool_context,
workset_version,
)
from app.services.builtin_tool_definitions import BUILTIN_TOOL_DEFINITIONS


def test_runtime_deadlines_cover_declared_network_and_image_provider_budgets() -> None:
expected = {
"read_webpage": 60.0,
"jina_read": 60.0,
"generate_image_siliconflow": 120.0,
"generate_image_openai": 120.0,
"generate_image_google": 120.0,
"generate_image_custom": 600.0,
}

assert {
name: resolve_tool_deadline_seconds(deadline_policy_for_tool(name).name)
for name in expected
} == expected
declared = {
item["name"]: float(item["timeout_seconds"])
for item in BUILTIN_TOOL_DEFINITIONS
if item["name"] in expected
}
assert declared == expected


def _entry() -> ToolWorksetEntry:
Expand Down
4 changes: 2 additions & 2 deletions backend/tests/test_agent_runtime_tool_repair_budget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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


Expand Down
4 changes: 2 additions & 2 deletions backend/tests/test_agent_runtime_tool_step_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -2977,7 +2977,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
Expand Down Expand Up @@ -3017,7 +3017,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"

Expand Down
10 changes: 5 additions & 5 deletions backend/tests/test_finish_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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

Expand All @@ -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(
Expand Down Expand Up @@ -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


Expand Down
5 changes: 4 additions & 1 deletion backend/tests/test_tool_execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion specs/002-tool-runtime-contract/checklists/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 兼容边界;计数结构统一重构已明确延期
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions specs/002-tool-runtime-contract/plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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` 独立语义。

Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions specs/002-tool-runtime-contract/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion specs/002-tool-runtime-contract/research.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 都不代表模型做了错误决策;混计会过早停机或掩盖循环。

Expand Down
Loading