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,335 @@
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import demo
|
||||
from browser import RecoverableFillError
|
||||
from bus import MessageBus
|
||||
from models import DecisionRecord, FieldSpec
|
||||
from orchestration import (
|
||||
ComputerAgent,
|
||||
initiate_phone_call_agent,
|
||||
run_parallel,
|
||||
timing_evidence,
|
||||
)
|
||||
from voice import ScriptedPhoneChannel
|
||||
|
||||
|
||||
class FakeBrowser:
|
||||
def __init__(self):
|
||||
self.values = {}
|
||||
self.submit_enabled = False
|
||||
|
||||
async def fill(self, field, value):
|
||||
await asyncio.sleep(0.05)
|
||||
self.values[field.name] = value
|
||||
|
||||
async def submit(self):
|
||||
return False
|
||||
|
||||
|
||||
class SubmittingFakeBrowser(FakeBrowser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.submit_enabled = True
|
||||
self.submit_calls = 0
|
||||
|
||||
async def submit(self):
|
||||
self.submit_calls += 1
|
||||
return True
|
||||
|
||||
|
||||
class FailingFillBrowser(FakeBrowser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.submit_calls = 0
|
||||
|
||||
async def fill(self, field, value):
|
||||
raise RecoverableFillError(f"cannot fill {field.name}")
|
||||
|
||||
async def submit(self):
|
||||
self.submit_calls += 1
|
||||
return True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ask_one_fill_one_runs_concurrently_and_reasks_invalid_format():
|
||||
fields = [
|
||||
FieldSpec("email", "邮箱", "email", format_hint="name@example.com"),
|
||||
FieldSpec("birth", "出生日期", "date", format_hint="YYYY-MM-DD"),
|
||||
]
|
||||
decision = DecisionRecord(
|
||||
page_url="https://example.test/register",
|
||||
page_title="Register",
|
||||
known_fields=[],
|
||||
discovered_fields=fields,
|
||||
tool_called="initiate_phone_call_agent",
|
||||
purpose="协助填写注册表单",
|
||||
required_info=fields,
|
||||
rationale_summary="tool call",
|
||||
model="test",
|
||||
monotonic_seconds=0,
|
||||
)
|
||||
# First email answer is invalid, forcing format feedback and a real re-ask.
|
||||
channel = ScriptedPhoneChannel(["bad", "me@example.com", "2020-01-02"])
|
||||
bus = MessageBus()
|
||||
browser = FakeBrowser()
|
||||
extracted = AsyncMock(side_effect=["bad", "me@example.com", "2020-01-02"])
|
||||
agents = initiate_phone_call_agent(
|
||||
decision=decision,
|
||||
bus=bus,
|
||||
channel=channel,
|
||||
browser=browser,
|
||||
known_values={},
|
||||
)
|
||||
with patch("orchestration._extract_value", extracted):
|
||||
result = await run_parallel(agents, bus)
|
||||
|
||||
assert result["errors"] == []
|
||||
assert browser.values == {"email": "me@example.com", "birth": "2020-01-02"}
|
||||
assert any(m.type == "format_invalid" for m in bus.history)
|
||||
evidence = timing_evidence(bus)
|
||||
assert any(c["next_question_before_fill_completed"] for c in evidence["overlap_checks"])
|
||||
|
||||
|
||||
def test_field_validation_handles_email_phone_date_and_page_pattern():
|
||||
assert not FieldSpec("e", "email", "email").validate("bad")[0]
|
||||
assert FieldSpec("e", "email", "email").validate("a@b.com")[0]
|
||||
assert not FieldSpec("d", "date", "date").validate("01/02/2020")[0]
|
||||
assert FieldSpec("p", "code", pattern=r"[A-Z]{2}\d{4}").validate("AB1234")[0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_live_transport_without_consent_refuses_before_browser_or_audio_creation():
|
||||
args = demo.parser().parse_args(["--headless", "--phone-transport", "local"])
|
||||
with patch("demo.RegistrationBrowser") as browser_type, patch(
|
||||
"demo.LiveMicrophoneChannel"
|
||||
) as audio_type:
|
||||
with pytest.raises(SystemExit, match="confirm-consent"):
|
||||
await demo.main(args)
|
||||
browser_type.assert_not_called()
|
||||
audio_type.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unexpected_phone_failure_cancels_peer_and_closes_channel():
|
||||
class ExplodingChannel:
|
||||
def __init__(self):
|
||||
self.closed = False
|
||||
|
||||
async def say(self, _text):
|
||||
raise RuntimeError("synthetic transport failure")
|
||||
|
||||
async def listen(self, *, timeout=30): # pragma: no cover - say fails first
|
||||
raise AssertionError(timeout)
|
||||
|
||||
async def close(self):
|
||||
self.closed = True
|
||||
|
||||
fields = [FieldSpec("email", "邮箱", "email")]
|
||||
decision = DecisionRecord(
|
||||
page_url="https://example.test/register",
|
||||
page_title="Register",
|
||||
known_fields=[],
|
||||
discovered_fields=fields,
|
||||
tool_called="initiate_phone_call_agent",
|
||||
purpose="协助填写注册表单",
|
||||
required_info=fields,
|
||||
rationale_summary="tool call",
|
||||
model="test",
|
||||
monotonic_seconds=0,
|
||||
)
|
||||
bus = MessageBus()
|
||||
channel = ExplodingChannel()
|
||||
agents = initiate_phone_call_agent(
|
||||
decision=decision,
|
||||
bus=bus,
|
||||
channel=channel,
|
||||
browser=FakeBrowser(),
|
||||
known_values={},
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="synthetic transport failure"):
|
||||
await asyncio.wait_for(run_parallel(agents, bus), timeout=0.5)
|
||||
assert channel.closed is True
|
||||
assert not any(
|
||||
task.get_name() in {"phone-agent-react-loop", "computer-agent-react-loop"}
|
||||
and not task.done()
|
||||
for task in asyncio.all_tasks()
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_task_completed_triggers_submission_when_browser_is_opted_in():
|
||||
fields = [FieldSpec("email", "邮箱", "email")]
|
||||
decision = DecisionRecord(
|
||||
page_url="https://example.test/register",
|
||||
page_title="Register",
|
||||
known_fields=[],
|
||||
discovered_fields=fields,
|
||||
tool_called="initiate_phone_call_agent",
|
||||
purpose="协助填写注册表单",
|
||||
required_info=fields,
|
||||
rationale_summary="tool call",
|
||||
model="test",
|
||||
monotonic_seconds=0,
|
||||
)
|
||||
browser = SubmittingFakeBrowser()
|
||||
bus = MessageBus()
|
||||
agents = initiate_phone_call_agent(
|
||||
decision=decision,
|
||||
bus=bus,
|
||||
channel=ScriptedPhoneChannel(["me@example.com"]),
|
||||
browser=browser,
|
||||
known_values={},
|
||||
)
|
||||
with patch("orchestration._extract_value", AsyncMock(return_value="me@example.com")):
|
||||
result = await run_parallel(agents, bus)
|
||||
assert result["submitted"] is True
|
||||
assert browser.submit_calls == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_computer_fill_error_flows_back_to_phone_and_blocks_submission():
|
||||
fields = [FieldSpec("email", "邮箱", "email")]
|
||||
decision = DecisionRecord(
|
||||
page_url="https://example.test/register",
|
||||
page_title="Register",
|
||||
known_fields=[],
|
||||
discovered_fields=fields,
|
||||
tool_called="initiate_phone_call_agent",
|
||||
purpose="协助填写注册表单",
|
||||
required_info=fields,
|
||||
rationale_summary="tool call",
|
||||
model="test",
|
||||
monotonic_seconds=0,
|
||||
)
|
||||
browser = FailingFillBrowser()
|
||||
channel = ScriptedPhoneChannel(["me@example.com"])
|
||||
bus = MessageBus()
|
||||
agents = initiate_phone_call_agent(
|
||||
decision=decision,
|
||||
bus=bus,
|
||||
channel=channel,
|
||||
browser=browser,
|
||||
known_values={},
|
||||
)
|
||||
with patch("orchestration._extract_value", AsyncMock(return_value="me@example.com")):
|
||||
result = await run_parallel(agents, bus)
|
||||
|
||||
assert result["submitted"] is False
|
||||
assert browser.submit_calls == 0
|
||||
assert agents.phone.browser_feedback == [
|
||||
{"field": "email", "error": "cannot fill email"}
|
||||
]
|
||||
fill_error = next(message for message in bus.history if message.type == "fill_error")
|
||||
assert getattr(fill_error, "_sensitive_keys") == ("error",)
|
||||
assert "填写遇到问题" in channel.prompts[-1]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_malformed_info_collected_fails_before_fill_error_handling():
|
||||
bus = MessageBus()
|
||||
computer = ComputerAgent(bus, FakeBrowser(), [], {})
|
||||
task = asyncio.create_task(computer.run())
|
||||
await bus.send(
|
||||
"phone_agent",
|
||||
"computer_agent",
|
||||
"info_collected",
|
||||
sensitive_keys=("value",),
|
||||
value="secret",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="non-empty field"):
|
||||
await task
|
||||
assert not any(message.type == "fill_error" for message in bus.history)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_optional_blank_is_audited_skip_and_never_written_to_browser():
|
||||
fields = [
|
||||
FieldSpec("email", "邮箱", "email"),
|
||||
FieldSpec("birthday", "生日", "text", required=False),
|
||||
FieldSpec("address", "地址", "textarea", required=False),
|
||||
]
|
||||
decision = DecisionRecord(
|
||||
page_url="https://example.test/register",
|
||||
page_title="Register",
|
||||
known_fields=[],
|
||||
discovered_fields=fields,
|
||||
tool_called="initiate_phone_call_agent",
|
||||
purpose="协助填写注册表单",
|
||||
required_info=fields,
|
||||
rationale_summary="tool call",
|
||||
model="test",
|
||||
monotonic_seconds=0,
|
||||
)
|
||||
browser = FakeBrowser()
|
||||
bus = MessageBus()
|
||||
agents = initiate_phone_call_agent(
|
||||
decision=decision,
|
||||
bus=bus,
|
||||
channel=ScriptedPhoneChannel(["me@example.com", "", ""]),
|
||||
browser=browser,
|
||||
known_values={},
|
||||
)
|
||||
with patch("orchestration._extract_value", AsyncMock(return_value="me@example.com")) as extract:
|
||||
result = await run_parallel(agents, bus)
|
||||
|
||||
assert result["errors"] == []
|
||||
assert browser.values == {"email": "me@example.com"}
|
||||
assert extract.await_count == 1
|
||||
skipped = [message for message in bus.history if message.type == "info_skipped"]
|
||||
assert [message.payload["field"] for message in skipped] == ["birthday", "address"]
|
||||
evidence = timing_evidence(bus)
|
||||
assert evidence["expected_overlap_count"] == 1
|
||||
assert len(evidence["overlap_checks"]) == 1
|
||||
|
||||
|
||||
class CountryFailBrowser(SubmittingFakeBrowser):
|
||||
"""Fails only the pre-filled known field, succeeds on the phone-collected one."""
|
||||
|
||||
async def fill(self, field, value):
|
||||
if field.name == "country":
|
||||
raise RecoverableFillError(f"cannot fill {field.name}")
|
||||
await asyncio.sleep(0.05)
|
||||
self.values[field.name] = value
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_known_value_fill_error_flows_back_to_phone_and_blocks_submission():
|
||||
# A known_values field that fails to pre-fill must surface a fill_error (like
|
||||
# the in-dialogue path), so the phone agent reports the failure instead of
|
||||
# telling the user registration completed.
|
||||
email = FieldSpec("email", "邮箱", "email")
|
||||
country = FieldSpec("country", "国家", "text")
|
||||
decision = DecisionRecord(
|
||||
page_url="https://example.test/register",
|
||||
page_title="Register",
|
||||
known_fields=[country.name],
|
||||
discovered_fields=[email, country],
|
||||
tool_called="initiate_phone_call_agent",
|
||||
purpose="协助填写注册表单",
|
||||
required_info=[email],
|
||||
rationale_summary="tool call",
|
||||
model="test",
|
||||
monotonic_seconds=0,
|
||||
)
|
||||
browser = CountryFailBrowser()
|
||||
channel = ScriptedPhoneChannel(["me@example.com"])
|
||||
bus = MessageBus()
|
||||
agents = initiate_phone_call_agent(
|
||||
decision=decision,
|
||||
bus=bus,
|
||||
channel=channel,
|
||||
browser=browser,
|
||||
known_values={"country": "US"},
|
||||
)
|
||||
with patch("orchestration._extract_value", AsyncMock(return_value="me@example.com")):
|
||||
result = await run_parallel(agents, bus)
|
||||
|
||||
assert result["submitted"] is False
|
||||
assert browser.submit_calls == 0
|
||||
assert {"field": "country", "error": "cannot fill country"} in agents.phone.browser_feedback
|
||||
assert any(message.type == "fill_error" for message in bus.history)
|
||||
assert "填写遇到问题" in channel.prompts[-1]
|
||||
Reference in New Issue
Block a user