ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
"""Shared pytest fixtures for the web search agent test suite."""
|
||||
|
||||
import json
|
||||
import socket
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
PROVIDER_ENV_VARS = (
|
||||
"MOONSHOT_API_KEY",
|
||||
"KIMI_API_KEY",
|
||||
"OPENROUTER_API_KEY",
|
||||
"OPENROUTER_BASE_URL",
|
||||
"OPENROUTER_MODEL",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolate_provider_environment(monkeypatch):
|
||||
"""Keep developer credentials and provider overrides out of every test."""
|
||||
for variable in PROVIDER_ENV_VARS:
|
||||
monkeypatch.delenv(variable, raising=False)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def block_external_network(monkeypatch):
|
||||
"""Fail fast if a unit test accidentally attempts a network connection."""
|
||||
|
||||
def deny_network(*args, **kwargs):
|
||||
raise AssertionError("Unit tests must not access the external network")
|
||||
|
||||
monkeypatch.setattr(socket, "create_connection", deny_network)
|
||||
monkeypatch.setattr(socket, "getaddrinfo", deny_network)
|
||||
monkeypatch.setattr(socket.socket, "connect", deny_network)
|
||||
monkeypatch.setattr(socket.socket, "connect_ex", deny_network)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_tool_call():
|
||||
"""Build a minimal SDK-shaped tool-call object for mocked model replies."""
|
||||
|
||||
def factory(
|
||||
*,
|
||||
name="web_search",
|
||||
arguments=None,
|
||||
call_id="call-1",
|
||||
):
|
||||
payload = arguments if arguments is not None else {"query": "example"}
|
||||
return SimpleNamespace(
|
||||
id=call_id,
|
||||
function=SimpleNamespace(
|
||||
name=name,
|
||||
arguments=json.dumps(payload, ensure_ascii=False),
|
||||
),
|
||||
)
|
||||
|
||||
return factory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_choice():
|
||||
"""Build a minimal SDK-shaped chat choice for deterministic Agent tests."""
|
||||
|
||||
def factory(
|
||||
*,
|
||||
finish_reason="stop",
|
||||
content="",
|
||||
reasoning_content=None,
|
||||
tool_calls=None,
|
||||
):
|
||||
return SimpleNamespace(
|
||||
finish_reason=finish_reason,
|
||||
message=SimpleNamespace(
|
||||
content=content,
|
||||
reasoning_content=reasoning_content,
|
||||
tool_calls=list(tool_calls or []),
|
||||
),
|
||||
)
|
||||
|
||||
return factory
|
||||
@@ -0,0 +1,284 @@
|
||||
"""Unit tests for ReAct formatting, tools, and the agent loop."""
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
from agent import WebSearchAgent, _reasoning_safe_temperature, format_trace_step
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, payload, status_code=200):
|
||||
self._payload = payload
|
||||
self.status_code = status_code
|
||||
self.text = ""
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
def raise_for_status(self):
|
||||
if self.status_code >= 400:
|
||||
raise RuntimeError(f"HTTP {self.status_code}")
|
||||
|
||||
|
||||
def build_agent(*choices):
|
||||
"""Create an Agent without constructing a real OpenAI client."""
|
||||
instance = WebSearchAgent.__new__(WebSearchAgent)
|
||||
instance.verbose = False
|
||||
instance.using_openrouter = False
|
||||
instance.trace = []
|
||||
instance.conversation_history = []
|
||||
instance.api_turns = []
|
||||
instance._formula_tools = None
|
||||
instance._chat = Mock(side_effect=choices)
|
||||
instance._execute_formula = Mock(return_value="encrypted formula output")
|
||||
return instance
|
||||
|
||||
|
||||
def test_format_trace_step_formats_action_with_unicode_arguments():
|
||||
rendered = format_trace_step(
|
||||
{
|
||||
"iteration": 2,
|
||||
"type": "action",
|
||||
"tool": "web_search",
|
||||
"args": {"query": "서울 날씨"},
|
||||
}
|
||||
)
|
||||
|
||||
assert rendered == ('🔧 [2] 行动: 调用工具 web_search 参数={"query": "서울 날씨"}')
|
||||
|
||||
|
||||
def test_format_trace_step_truncates_long_content():
|
||||
rendered = format_trace_step(
|
||||
{"iteration": 1, "type": "thought", "content": "abcdef"},
|
||||
max_len=3,
|
||||
)
|
||||
|
||||
assert rendered == "💭 [1] 思考: abc…(省略 3 字)"
|
||||
|
||||
|
||||
def test_reasoning_models_force_supported_temperature():
|
||||
assert _reasoning_safe_temperature("kimi-k3", 0.2) == 1
|
||||
assert _reasoning_safe_temperature("openai/gpt-5.6-luna", 0.2) == 1
|
||||
assert _reasoning_safe_temperature("deepseek-chat", 0.2) == 0.2
|
||||
|
||||
|
||||
def test_tool_definition_is_available_for_moonshot_only():
|
||||
instance = WebSearchAgent.__new__(WebSearchAgent)
|
||||
instance.using_openrouter = False
|
||||
instance._formula_tools = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "web_search",
|
||||
"parameters": {"type": "object"},
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
assert instance._get_tools() == instance._formula_tools
|
||||
|
||||
instance.using_openrouter = True
|
||||
assert instance._get_tools() == []
|
||||
|
||||
|
||||
def test_formula_declaration_is_fetched_and_recorded(monkeypatch):
|
||||
instance = WebSearchAgent.__new__(WebSearchAgent)
|
||||
instance.using_openrouter = False
|
||||
instance._formula_tools = None
|
||||
instance.base_url = "https://api.moonshot.cn/v1"
|
||||
instance.formula_uri = "moonshot/web-search:latest"
|
||||
instance._api_key = "not-recorded"
|
||||
instance._request_timeout = 12
|
||||
instance.api_turns = []
|
||||
tool = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "web_search",
|
||||
"parameters": {"type": "object"},
|
||||
},
|
||||
}
|
||||
get = Mock(return_value=FakeResponse({"object": "list", "tools": [tool]}))
|
||||
monkeypatch.setattr("agent.requests.get", get)
|
||||
|
||||
assert instance._get_tools() == [tool]
|
||||
assert instance._get_tools() == [tool]
|
||||
assert get.call_count == 1
|
||||
assert instance.api_turns[0]["kind"] == "formula_tools"
|
||||
assert "Authorization" not in instance.api_turns[0]["request"]
|
||||
|
||||
|
||||
def test_formula_fiber_forwards_raw_arguments_and_records_receipt(monkeypatch):
|
||||
instance = WebSearchAgent.__new__(WebSearchAgent)
|
||||
instance.using_openrouter = False
|
||||
instance.base_url = "https://api.moonshot.cn/v1"
|
||||
instance.formula_uri = "moonshot/web-search:latest"
|
||||
instance._api_key = "not-recorded"
|
||||
instance._request_timeout = 12
|
||||
instance.api_turns = []
|
||||
raw = '{"query":"Moonshot K3"}'
|
||||
post = Mock(
|
||||
return_value=FakeResponse(
|
||||
{
|
||||
"id": "fiber-real",
|
||||
"status": "succeeded",
|
||||
"context": {"encrypted_output": "encrypted provider output"},
|
||||
}
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr("agent.requests.post", post)
|
||||
|
||||
assert instance._execute_formula("web_search", raw) == "encrypted provider output"
|
||||
assert post.call_args.kwargs["json"] == {
|
||||
"name": "web_search",
|
||||
"arguments": raw,
|
||||
}
|
||||
assert instance.api_turns[0]["response"]["id"] == "fiber-real"
|
||||
|
||||
|
||||
def test_agent_loop_records_tool_flow_and_final_answer(make_choice, make_tool_call):
|
||||
tool_call = make_tool_call(arguments={"query": "Moonshot caching"})
|
||||
tool_choice = make_choice(
|
||||
finish_reason="tool_calls",
|
||||
reasoning_content="공식 설명을 검색해야 한다.",
|
||||
tool_calls=[tool_call],
|
||||
)
|
||||
answer_choice = make_choice(content="Context Caching 설명입니다.")
|
||||
instance = build_agent(tool_choice, answer_choice)
|
||||
answer = instance.search_and_answer("Context Caching이 뭐야?")
|
||||
|
||||
assert answer == "Context Caching 설명입니다."
|
||||
assert [step["type"] for step in instance.get_trace()] == [
|
||||
"thought",
|
||||
"action",
|
||||
"observation",
|
||||
"answer",
|
||||
]
|
||||
instance._execute_formula.assert_called_once_with(
|
||||
"web_search", '{"query": "Moonshot caching"}'
|
||||
)
|
||||
assert instance._chat.call_count == 2
|
||||
assert instance.conversation_history[2] == {
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call-1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "web_search",
|
||||
"arguments": '{"query": "Moonshot caching"}',
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
assert instance.conversation_history[3] == {
|
||||
"role": "tool",
|
||||
"tool_call_id": "call-1",
|
||||
"content": "encrypted formula output",
|
||||
}
|
||||
assert instance.conversation_history[-1] == {
|
||||
"role": "assistant",
|
||||
"content": answer,
|
||||
}
|
||||
|
||||
|
||||
def test_agent_loop_handles_multiple_tool_calls(make_choice, make_tool_call):
|
||||
first = make_tool_call(arguments={"query": "first"}, call_id="call-1")
|
||||
second = make_tool_call(arguments={"query": "second"}, call_id="call-2")
|
||||
instance = build_agent(
|
||||
make_choice(finish_reason="tool_calls", tool_calls=[first, second]),
|
||||
make_choice(content="combined answer"),
|
||||
)
|
||||
|
||||
assert instance.search_and_answer("compare") == "combined answer"
|
||||
assert [step["type"] for step in instance.get_trace()] == [
|
||||
"action",
|
||||
"observation",
|
||||
"action",
|
||||
"observation",
|
||||
"answer",
|
||||
]
|
||||
tool_messages = [
|
||||
message
|
||||
for message in instance.conversation_history
|
||||
if message["role"] == "tool"
|
||||
]
|
||||
assert [message["tool_call_id"] for message in tool_messages] == [
|
||||
"call-1",
|
||||
"call-2",
|
||||
]
|
||||
|
||||
|
||||
def test_agent_loop_stops_at_iteration_limit(make_choice, make_tool_call):
|
||||
instance = build_agent(
|
||||
make_choice(
|
||||
finish_reason="tool_calls",
|
||||
tool_calls=[make_tool_call()],
|
||||
)
|
||||
)
|
||||
|
||||
answer = instance.search_and_answer("keep searching", max_iterations=1)
|
||||
|
||||
assert answer == "抱歉,搜索过程超过了最大迭代次数,请稍后重试。"
|
||||
assert instance._chat.call_count == 1
|
||||
|
||||
|
||||
def test_agent_loop_returns_a_readable_error():
|
||||
instance = build_agent()
|
||||
instance._chat = Mock(side_effect=RuntimeError("provider unavailable"))
|
||||
|
||||
answer = instance.search_and_answer("question")
|
||||
|
||||
assert answer == "搜索过程中出现错误: provider unavailable"
|
||||
assert instance.get_trace() == []
|
||||
|
||||
|
||||
def test_agent_loop_marks_truncated_empty_answer(make_choice):
|
||||
"""finish_reason=length with empty content must not masquerade as
|
||||
the misleading 'couldn't get enough info' response."""
|
||||
instance = build_agent(make_choice(finish_reason="length", content=""))
|
||||
|
||||
answer = instance.search_and_answer("question")
|
||||
|
||||
assert "无法获取足够" not in answer
|
||||
assert "截断" in answer
|
||||
assert instance.get_trace()[-1]["type"] == "answer"
|
||||
|
||||
|
||||
def test_agent_loop_marks_truncated_partial_answer(make_choice):
|
||||
"""A partial answer cut off by max_tokens is returned WITH a truncation
|
||||
marker, never presented as a complete answer."""
|
||||
instance = build_agent(
|
||||
make_choice(finish_reason="length", content="部分答案,被截")
|
||||
)
|
||||
|
||||
answer = instance.search_and_answer("question")
|
||||
|
||||
assert answer.startswith("部分答案,被截")
|
||||
assert "截断" in answer
|
||||
# conversation_history retains the truncation marker (stores final, not the
|
||||
# bare partial), so get_conversation_history() doesn't lose the semantics.
|
||||
assert instance.conversation_history[-1]["role"] == "assistant"
|
||||
assert "截断" in instance.conversation_history[-1]["content"]
|
||||
|
||||
|
||||
def test_agent_loop_survives_malformed_tool_arguments_json(make_choice):
|
||||
"""Slightly invalid tool JSON must not abort the ReAct loop."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
bad_call = SimpleNamespace(
|
||||
id="call-bad",
|
||||
function=SimpleNamespace(
|
||||
name="web_search",
|
||||
arguments='{"query": "moonshot",}', # trailing comma
|
||||
),
|
||||
)
|
||||
tool_choice = make_choice(finish_reason="tool_calls", tool_calls=[bad_call])
|
||||
answer_choice = make_choice(content="recovered answer")
|
||||
instance = build_agent(tool_choice, answer_choice)
|
||||
answer = instance.search_and_answer("what is caching?")
|
||||
|
||||
assert answer == "recovered answer"
|
||||
instance._execute_formula.assert_called_once_with(
|
||||
"web_search", '{"query": "moonshot",}'
|
||||
)
|
||||
assert any(step["type"] == "action" for step in instance.get_trace())
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Unit tests for model mapping and provider selection."""
|
||||
|
||||
import pytest
|
||||
from config import map_model_to_openrouter, resolve_llm_backend
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("model", "expected"),
|
||||
[
|
||||
("openai/gpt-5.6-luna", "openai/gpt-5.6-luna"),
|
||||
("gpt-5.6-luna", "openai/gpt-5.6-luna"),
|
||||
("o3-mini", "openai/o3-mini"),
|
||||
("claude-sonnet-4.6", "anthropic/claude-sonnet-4.6"),
|
||||
("claude-haiku-4.5", "anthropic/claude-haiku-4.5"),
|
||||
("claude-opus-4.8", "anthropic/claude-opus-4.8"),
|
||||
("kimi-k3", "moonshotai/kimi-k2.6"),
|
||||
],
|
||||
)
|
||||
def test_map_model_to_openrouter(model, expected):
|
||||
assert map_model_to_openrouter(model) == expected
|
||||
|
||||
|
||||
def test_unknown_model_uses_configured_openrouter_default(monkeypatch):
|
||||
"""Substitution is opt-in, for callers that cannot send an unmapped id."""
|
||||
monkeypatch.setenv("OPENROUTER_MODEL", "vendor/fallback-model")
|
||||
|
||||
mapped = map_model_to_openrouter("unknown-model", substitute_unknown=True)
|
||||
assert mapped == "vendor/fallback-model"
|
||||
|
||||
|
||||
def test_unknown_model_is_kept_when_not_substituting(monkeypatch):
|
||||
"""Rerouting for credential reasons keeps the model the reader asked for."""
|
||||
monkeypatch.setenv("OPENROUTER_MODEL", "vendor/fallback-model")
|
||||
|
||||
assert map_model_to_openrouter("unknown-model") == "unknown-model"
|
||||
|
||||
|
||||
def test_primary_provider_is_preserved_when_its_key_exists():
|
||||
assert resolve_llm_backend(
|
||||
"moonshot-key", "https://moonshot.test/v1", "kimi-k3"
|
||||
) == (
|
||||
"moonshot-key",
|
||||
"https://moonshot.test/v1",
|
||||
"kimi-k3",
|
||||
False,
|
||||
)
|
||||
|
||||
|
||||
def test_openrouter_is_used_when_primary_key_is_missing(monkeypatch):
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "openrouter-key")
|
||||
monkeypatch.setenv("OPENROUTER_BASE_URL", "https://openrouter.test/v1")
|
||||
|
||||
assert resolve_llm_backend(None, "https://moonshot.test/v1", "kimi-k3") == (
|
||||
"openrouter-key",
|
||||
"https://openrouter.test/v1",
|
||||
"moonshotai/kimi-k2.6",
|
||||
True,
|
||||
)
|
||||
|
||||
|
||||
def test_gpt5_prefers_openrouter_when_both_keys_exist(monkeypatch):
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "openrouter-key")
|
||||
|
||||
resolved = resolve_llm_backend(
|
||||
"primary-key", "https://primary.test/v1", "gpt-5.6-luna"
|
||||
)
|
||||
|
||||
assert resolved == (
|
||||
"openrouter-key",
|
||||
"https://openrouter.ai/api/v1",
|
||||
"openai/gpt-5.6-luna",
|
||||
True,
|
||||
)
|
||||
|
||||
|
||||
def test_provider_resolution_requires_a_key():
|
||||
with pytest.raises(ValueError, match="No API key found"):
|
||||
resolve_llm_backend(None, "https://moonshot.test/v1", "kimi-k3")
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Unit tests for AdvancedWebSearchAgent helpers and failure classification."""
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
from agent import (
|
||||
MAX_ITERATIONS_MESSAGE,
|
||||
NO_INFO_MESSAGE,
|
||||
SEARCH_ERROR_PREFIX,
|
||||
is_failure_answer,
|
||||
)
|
||||
from examples import AdvancedWebSearchAgent
|
||||
|
||||
|
||||
def build_advanced(answers):
|
||||
"""AdvancedWebSearchAgent without a real OpenAI client; search_and_answer mocked."""
|
||||
instance = AdvancedWebSearchAgent.__new__(AdvancedWebSearchAgent)
|
||||
instance.search_and_answer = Mock(side_effect=answers)
|
||||
instance.clear_history = Mock()
|
||||
return instance
|
||||
|
||||
|
||||
def test_is_failure_answer_covers_every_failure_fallback():
|
||||
assert is_failure_answer(f"{SEARCH_ERROR_PREFIX}: boom") is True
|
||||
assert is_failure_answer(MAX_ITERATIONS_MESSAGE) is True
|
||||
assert is_failure_answer(NO_INFO_MESSAGE) is True
|
||||
assert is_failure_answer("北京今天多云。") is False
|
||||
|
||||
|
||||
def test_batch_search_marks_all_failure_fallbacks_as_error():
|
||||
"""search_and_answer never raises; every failure fallback prefix must map to
|
||||
status='error', not just the '搜索过程中出现错误' one."""
|
||||
answers = [
|
||||
"正常答案",
|
||||
f"{SEARCH_ERROR_PREFIX}: network down",
|
||||
MAX_ITERATIONS_MESSAGE,
|
||||
NO_INFO_MESSAGE,
|
||||
]
|
||||
instance = build_advanced(answers)
|
||||
|
||||
results = instance.batch_search(["q1", "q2", "q3", "q4"])
|
||||
|
||||
assert [r["status"] for r in results] == ["success", "error", "error", "error"]
|
||||
assert instance.clear_history.call_count == 4
|
||||
@@ -0,0 +1,185 @@
|
||||
from run_experiment_1_2 import fiber_ids, validate
|
||||
|
||||
|
||||
def formula_tool():
|
||||
return {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "web_search",
|
||||
"description": "Search the web",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"query": {"type": "string"}},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_fiber_ids_only_accept_real_succeeded_receipts():
|
||||
turns = [
|
||||
{
|
||||
"kind": "formula_fiber",
|
||||
"http_status": 200,
|
||||
"response": {"id": "fiber-one", "status": "succeeded"},
|
||||
},
|
||||
{
|
||||
"kind": "formula_fiber",
|
||||
"http_status": 200,
|
||||
"response": {"id": "fiber-failed", "status": "failed"},
|
||||
},
|
||||
{
|
||||
"kind": "chat_completion",
|
||||
"http_status": 200,
|
||||
"response": {"id": "chat-one"},
|
||||
},
|
||||
]
|
||||
assert fiber_ids(turns) == ["fiber-one"]
|
||||
|
||||
|
||||
def test_acceptance_requires_distinct_sequential_formula_fibers_and_links():
|
||||
tool = formula_tool()
|
||||
turns = [
|
||||
{
|
||||
"kind": "formula_tools",
|
||||
"formula_uri": "moonshot/web-search:latest",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "https://api.moonshot.cn/v1/formulas/moonshot/web-search:latest/tools",
|
||||
},
|
||||
"http_status": 200,
|
||||
"response": {"tools": [tool]},
|
||||
},
|
||||
{
|
||||
"kind": "chat_completion",
|
||||
"request": {"tools": [tool]},
|
||||
"response": {"id": "chat-1", "usage": {}},
|
||||
},
|
||||
{
|
||||
"kind": "formula_fiber",
|
||||
"formula_uri": "moonshot/web-search:latest",
|
||||
"request": {
|
||||
"body": {
|
||||
"name": "web_search",
|
||||
"arguments": '{"query":"ASEAN capitals"}',
|
||||
}
|
||||
},
|
||||
"http_status": 200,
|
||||
"response": {"id": "fiber-one", "status": "succeeded"},
|
||||
},
|
||||
{
|
||||
"kind": "chat_completion",
|
||||
"request": {"tools": [tool]},
|
||||
"response": {"id": "chat-2", "usage": {}},
|
||||
},
|
||||
{
|
||||
"kind": "formula_fiber",
|
||||
"formula_uri": "moonshot/web-search:latest",
|
||||
"request": {
|
||||
"body": {
|
||||
"name": "web_search",
|
||||
"arguments": '{"query":"ASEAN capital coordinates"}',
|
||||
}
|
||||
},
|
||||
"http_status": 200,
|
||||
"response": {"id": "fiber-two", "status": "succeeded"},
|
||||
},
|
||||
{
|
||||
"kind": "chat_completion",
|
||||
"request": {"tools": [tool]},
|
||||
"response": {"id": "chat-3", "usage": {}},
|
||||
},
|
||||
]
|
||||
payload = {
|
||||
"provider": "moonshot",
|
||||
"base_url": "https://api.moonshot.cn/v1",
|
||||
"model": "kimi-k3",
|
||||
"answer": (
|
||||
"检索日期 2026-07-30:东盟现有 11(十一)个成员,"
|
||||
"Timor-Leste(东帝汶)于 2025-10-26 加入。"
|
||||
"雅加达 Jakarta 在总统令生效前仍是首都,Nusantara 为迁都目标。"
|
||||
"https://asean.org/example https://inp.polri.go.id/example"
|
||||
),
|
||||
"trace": [
|
||||
{
|
||||
"iteration": 1,
|
||||
"type": "action",
|
||||
"tool": "web_search",
|
||||
"args": {"query": "ASEAN capitals"},
|
||||
},
|
||||
{"iteration": 1, "type": "thought"},
|
||||
{
|
||||
"iteration": 2,
|
||||
"type": "action",
|
||||
"tool": "web_search",
|
||||
"args": {"query": "ASEAN capital coordinates"},
|
||||
},
|
||||
{"iteration": 3, "type": "answer"},
|
||||
],
|
||||
"api_turns": turns,
|
||||
}
|
||||
assert validate(payload)["passed"] is True
|
||||
|
||||
|
||||
def test_acceptance_rejects_two_fibers_from_one_search_round():
|
||||
tool = formula_tool()
|
||||
payload = {
|
||||
"provider": "moonshot",
|
||||
"base_url": "https://api.moonshot.cn/v1",
|
||||
"model": "kimi-k3",
|
||||
"answer": (
|
||||
"2026-07-30:11 个成员,东帝汶 Timor-Leste 于 2025-10-26 加入。"
|
||||
"雅加达 Jakarta 在总统令前仍是首都,Nusantara 努山塔拉待迁都。"
|
||||
"https://asean.org/a https://inp.polri.go.id/b"
|
||||
),
|
||||
"trace": [
|
||||
{"iteration": 1, "type": "thought"},
|
||||
{
|
||||
"iteration": 1,
|
||||
"type": "action",
|
||||
"tool": "web_search",
|
||||
"args": {"query": "one"},
|
||||
},
|
||||
{
|
||||
"iteration": 1,
|
||||
"type": "action",
|
||||
"tool": "web_search",
|
||||
"args": {"query": "two"},
|
||||
},
|
||||
{"iteration": 2, "type": "answer"},
|
||||
],
|
||||
"api_turns": [
|
||||
{
|
||||
"kind": "formula_tools",
|
||||
"formula_uri": "moonshot/web-search:latest",
|
||||
"http_status": 200,
|
||||
"response": {"tools": [tool]},
|
||||
},
|
||||
*[
|
||||
{
|
||||
"kind": "chat_completion",
|
||||
"request": {"tools": [tool]},
|
||||
"response": {"id": f"chat-{i}", "usage": {}},
|
||||
}
|
||||
for i in range(3)
|
||||
],
|
||||
*[
|
||||
{
|
||||
"kind": "formula_fiber",
|
||||
"formula_uri": "moonshot/web-search:latest",
|
||||
"request": {
|
||||
"body": {
|
||||
"name": "web_search",
|
||||
"arguments": f'{{"query":"{i}"}}',
|
||||
}
|
||||
},
|
||||
"http_status": 200,
|
||||
"response": {"id": f"fiber-{i}", "status": "succeeded"},
|
||||
}
|
||||
for i in range(2)
|
||||
],
|
||||
],
|
||||
}
|
||||
result = validate(payload)
|
||||
assert result["checks"]["sequential_search_rounds_observed"] is False
|
||||
assert result["passed"] is False
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Tests for CLI parsing, offline dispatch, and JSON output."""
|
||||
|
||||
import json
|
||||
|
||||
import main as cli
|
||||
from config import Config
|
||||
|
||||
|
||||
def test_parser_defaults_to_interactive_kimi_mode():
|
||||
args = cli.build_parser().parse_args([])
|
||||
|
||||
assert args.query == []
|
||||
assert args.provider == "kimi"
|
||||
assert args.model == Config.DEFAULT_MODEL
|
||||
assert args.max_steps == Config.MAX_SEARCH_ITERATIONS
|
||||
assert args.base_url == Config.KIMI_BASE_URL
|
||||
assert args.output is None
|
||||
assert args.quiet is False
|
||||
|
||||
|
||||
def test_parser_accepts_cli_overrides():
|
||||
args = cli.build_parser().parse_args(
|
||||
[
|
||||
"first",
|
||||
"question",
|
||||
"--provider",
|
||||
"offline-demo",
|
||||
"--model",
|
||||
"custom-model",
|
||||
"--max-steps",
|
||||
"3",
|
||||
"--base-url",
|
||||
"https://provider.test/v1",
|
||||
"--api-key",
|
||||
"explicit-key",
|
||||
"--output",
|
||||
"result.json",
|
||||
"--quiet",
|
||||
]
|
||||
)
|
||||
|
||||
assert args.query == ["first", "question"]
|
||||
assert args.provider == "offline-demo"
|
||||
assert args.model == "custom-model"
|
||||
assert args.max_steps == 3
|
||||
assert args.base_url == "https://provider.test/v1"
|
||||
assert args.api_key == "explicit-key"
|
||||
assert args.output == "result.json"
|
||||
assert args.quiet is True
|
||||
|
||||
|
||||
def test_offline_cli_writes_utf8_json_without_api_credentials(tmp_path, capsys):
|
||||
output_path = tmp_path / "offline-result.json"
|
||||
|
||||
cli.main(
|
||||
[
|
||||
"한국어",
|
||||
"질문",
|
||||
"--provider",
|
||||
"offline-demo",
|
||||
"--quiet",
|
||||
"--output",
|
||||
str(output_path),
|
||||
]
|
||||
)
|
||||
|
||||
payload = json.loads(output_path.read_text(encoding="utf-8"))
|
||||
output = capsys.readouterr().out
|
||||
assert payload["question"] == "한국어 질문"
|
||||
assert set(payload) == {"question", "trace", "answer"}
|
||||
assert payload["trace"][-1]["type"] == "answer"
|
||||
assert payload["answer"] == payload["trace"][-1]["content"]
|
||||
assert "💭" not in output
|
||||
assert "结果已保存到" in output
|
||||
|
||||
|
||||
def test_offline_cli_uses_default_question_when_query_is_omitted(capsys):
|
||||
cli.main(["--provider", "offline-demo", "--quiet"])
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert "Moonshot AI 的 Context Caching 是什么技术?" in output
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Tests for the deterministic offline ReAct demonstration."""
|
||||
|
||||
from agent import run_offline_demo
|
||||
|
||||
|
||||
def test_offline_demo_returns_a_complete_deterministic_trace():
|
||||
result = run_offline_demo("캐싱이 뭐야?", verbose=False)
|
||||
|
||||
assert result["question"] == "캐싱이 뭐야?"
|
||||
assert [step["type"] for step in result["trace"]] == [
|
||||
"thought",
|
||||
"action",
|
||||
"observation",
|
||||
"thought",
|
||||
"action",
|
||||
"observation",
|
||||
"answer",
|
||||
]
|
||||
assert result["answer"] == result["trace"][-1]["content"]
|
||||
assert "离线示例轨迹" in result["answer"]
|
||||
|
||||
|
||||
def test_offline_demo_verbose_mode_prints_each_trace_step(capsys):
|
||||
result = run_offline_demo("question", verbose=True)
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert output.count("💭") == 2
|
||||
assert output.count("🔧") == 2
|
||||
assert output.count("👀") == 2
|
||||
assert output.count("✅") == 1
|
||||
assert result["answer"] in output
|
||||
|
||||
|
||||
def test_offline_demo_quiet_mode_prints_nothing(capsys):
|
||||
run_offline_demo("question", verbose=False)
|
||||
|
||||
assert capsys.readouterr().out == ""
|
||||
Reference in New Issue
Block a user