feat(v1.8.0): 稳定性修复 + AI Agent 体验增强

稳定性修复:

- 修复 cache.py SQLite 连接泄漏(contextlib.closing 包装)

- 修复 fetch.py requests stream=True 连接泄漏(try/finally resp.close())

- RETRYABLE_STATUS 新增 403,激活 UA fallback 切换逻辑

- --cache-stats 移至实例解析前,无需实例即可查询

- classify_error 从错误消息提取 HTTP 状态码,正确分类 E_AUTH/E_RATE_LIMIT

- --stream 与 --queries-file 互斥检查,违规报 E_INPUT

- batch 退出码语义统一(0=有结果 / 1=全部错误 / 2=全部空结果)

AI Agent 体验增强:

- 错误码体系完善:E_CONFIG/E_AUTH/E_NETWORK/E_RATE_LIMIT/E_PARSE/E_EMPTY/E_INPUT/E_INTERNAL

- recovery_hint 恢复提示字段,AI Agent 可程序化决策恢复策略

- stream 模式新增 error 事件类型(含 error_code + recovery_hint)

- 进度事件扩展:instance_try/instance_ok/instance_fail

- batch 模式统一 schema(status 字段区分 success/failed)

- JSON 输出含 schema_version 字段确保版本兼容

测试与文档:

- 测试覆盖:330 -> 352

- SKILL.md / README.md 同步更新
This commit is contained in:
2026-08-01 19:02:44 +08:00
parent dea899143d
commit fb9b2af45f
12 changed files with 871 additions and 131 deletions
+67
View File
@@ -231,3 +231,70 @@ def test_run_single_query_propagates_rate_limit():
_, _, err_code = _run_single_query(
"test", args, ["https://x.example.com"], {}, 0)
assert err_code == E_RATE_LIMIT
# ----- classify_error: HTTP status extraction from RuntimeError messages -----
#
# search_multi wraps the last error into its RuntimeError message, e.g.
# "All 3 instances failed. Last error: HTTP Error 403: Forbidden"
# classify_error must extract the status code from that message rather than
# always falling back to E_NETWORK.
def test_classify_runtime_all_instances_failed_with_http_403_is_auth():
"""'...Last error: HTTP Error 403' -> E_AUTH (not E_NETWORK)."""
err = RuntimeError("All 3 instances failed. Last error: HTTP Error 403: Forbidden")
assert classify_error(err) == E_AUTH
def test_classify_runtime_all_instances_failed_with_http_500_is_network():
"""'...Last error: HTTP Error 500' -> E_NETWORK (5xx server error)."""
err = RuntimeError("All 3 instances failed. Last error: HTTP Error 500: Internal Server Error")
assert classify_error(err) == E_NETWORK
def test_classify_runtime_parallel_failed_with_http_429_is_rate_limit():
"""'...(parallel). Last error: HTTP Error 429' -> E_RATE_LIMIT."""
err = RuntimeError("All 3 instances failed (parallel). Last error: HTTP Error 429: Too Many Requests")
assert classify_error(err) == E_RATE_LIMIT
# ----- _emit_error: recovery_hint in JSON output -----
#
# recovery_hint gives AI agents an actionable suggestion per error_code.
# Only present when error_code is known; omitted otherwise (backwards compat).
def test_emit_error_json_includes_recovery_hint_for_config(capsys):
"""JSON error with E_CONFIG includes the config recovery_hint."""
args = SimpleNamespace(format="json")
with pytest.raises(SystemExit):
_emit_error("no instance resolved", args, error_code=E_CONFIG)
out, _ = capsys.readouterr()
data = json.loads(out)
assert data["error_code"] == E_CONFIG
assert "recovery_hint" in data
hint = data["recovery_hint"].lower()
assert "instance" in hint or "config" in hint
def test_emit_error_json_includes_recovery_hint_for_auth(capsys):
"""JSON error with E_AUTH includes the auth recovery_hint."""
args = SimpleNamespace(format="json")
with pytest.raises(SystemExit):
_emit_error("forbidden", args, error_code=E_AUTH)
out, _ = capsys.readouterr()
data = json.loads(out)
assert data["error_code"] == E_AUTH
assert "recovery_hint" in data
hint = data["recovery_hint"].lower()
assert "credential" in hint or "token" in hint
def test_emit_error_json_no_recovery_hint_without_error_code(capsys):
"""No error_code -> no recovery_hint field (backwards compat)."""
args = SimpleNamespace(format="json")
with pytest.raises(SystemExit):
_emit_error("something broke", args)
out, _ = capsys.readouterr()
data = json.loads(out)
assert "error_code" not in data
assert "recovery_hint" not in data