[项目边界]:完成全双工Agent入口和配置冻结,包含run-agent-live入口、配置校验和迁移文档

This commit is contained in:
mkbk
2026-06-18 21:40:23 +08:00
parent 696ed2c30e
commit 9fd8eaf7eb
8 changed files with 448 additions and 5 deletions
+41
View File
@@ -42,6 +42,7 @@ class CliAcceptanceTests(unittest.TestCase):
self.assertEqual(code, 0)
self.assertTrue(data["llm_api_key_present"])
self.assertEqual(data["llm_model"], "file-model")
self.assertEqual(data["assistant_mode"], "turn_based_voice_pet")
self.assertEqual(data["pipeline_mode"], "live_turn_based")
self.assertEqual(data["endpoint_mode"], "primary_speaker")
self.assertEqual(data["realtime_transcript_idle_timeout_ms"], 1500)
@@ -49,8 +50,48 @@ class CliAcceptanceTests(unittest.TestCase):
self.assertTrue(data["end_chime_enabled"])
self.assertEqual(data["end_chime_file"], "assets/sounds/codex-notification.wav")
self.assertEqual(data["end_chime_duration_ms"], 140)
self.assertEqual(data["audio_apm_provider"], "webrtc")
self.assertTrue(data["audio_aec_enabled"])
self.assertTrue(data["audio_ns_enabled"])
self.assertTrue(data["audio_agc_enabled"])
self.assertEqual(data["audio_frame_ms"], 20)
self.assertEqual(data["audio_ring_buffer_ms"], 3000)
self.assertTrue(data["interrupt_enabled"])
self.assertEqual(data["interrupt_target_latency_ms"], 200)
self.assertEqual(data["streaming_stt_provider"], "faster_whisper")
self.assertEqual(data["streaming_stt_product_candidate"], "sensevoice")
self.assertEqual(data["streaming_tts_provider"], "cosyvoice")
self.assertFalse(data["memory_enabled"])
self.assertEqual(data["memory_provider"], "faiss_sqlite")
self.assertFalse(data["tool_router_enabled"])
self.assertFalse(data["openinterpreter_enabled"])
self.assertFalse(data["browser_playwright_enabled"])
self.assertFalse(data["computer_control_enabled"])
self.assertNotIn("secret-value", str(data))
def test_help_lists_full_duplex_agent_entrypoint(self) -> None:
buffer = io.StringIO()
with redirect_stdout(buffer), self.assertRaises(SystemExit) as raised:
main(["--help"])
self.assertEqual(raised.exception.code, 0)
self.assertIn("run-agent-live", buffer.getvalue())
def test_run_agent_live_check_config_reports_reserved_entrypoint(self) -> None:
code, data = self.call("run-agent-live", "--check-config")
self.assertEqual(code, 0)
self.assertTrue(data["success"])
self.assertEqual(data["command"], "run-agent-live")
self.assertEqual(data["assistant_mode"], "full_duplex_agent")
self.assertFalse(data["full_duplex_runtime_ready"])
self.assertEqual(data["turn_based_entry"], "run-live")
def test_run_agent_live_without_runtime_returns_explicit_not_implemented(self) -> None:
code, data = self.call("run-agent-live")
self.assertEqual(code, 1)
self.assertFalse(data["success"])
self.assertEqual(data["code"], "FULL_DUPLEX_RUNTIME_NOT_IMPLEMENTED")
def test_security_check_command_has_no_leaks(self) -> None:
code, data = self.call("security-check")
self.assertEqual(code, 0)
+120
View File
@@ -74,6 +74,7 @@ class ModelsConfigTests(unittest.TestCase):
)
)
config = AppConfig.from_dotenv(path)
self.assertEqual(config.assistant_mode, "turn_based_voice_pet")
self.assertEqual(config.llm_base_url, "https://token-plan-cn.xiaomimimo.com/v1")
self.assertEqual(config.llm_api_key, "secret-value")
self.assertEqual(config.llm_model, "test-model")
@@ -122,14 +123,133 @@ class ModelsConfigTests(unittest.TestCase):
self.assertEqual(str(config.end_chime_file), "assets/sounds/codex-notification.wav")
self.assertEqual(config.end_chime_frequency_hz, 880)
self.assertEqual(config.end_chime_duration_ms, 140)
self.assertEqual(config.audio_apm_provider, "webrtc")
self.assertTrue(config.audio_aec_enabled)
self.assertTrue(config.audio_ns_enabled)
self.assertTrue(config.audio_agc_enabled)
self.assertTrue(config.audio_apm_required)
self.assertEqual(config.audio_frame_ms, 20)
self.assertEqual(config.audio_ring_buffer_ms, 3000)
self.assertTrue(config.interrupt_enabled)
self.assertEqual(config.interrupt_target_latency_ms, 200)
self.assertEqual(config.streaming_stt_provider, "faster_whisper")
self.assertEqual(config.streaming_stt_product_candidate, "sensevoice")
self.assertEqual(config.streaming_tts_provider, "cosyvoice")
self.assertFalse(config.memory_enabled)
self.assertEqual(config.memory_provider, "faiss_sqlite")
self.assertEqual(config.memory_top_k, 5)
self.assertFalse(config.memory_auto_save_sensitive)
self.assertFalse(config.tool_router_enabled)
self.assertEqual(config.tool_max_calls_per_turn, 5)
self.assertEqual(config.tool_timeout_ms, 30000)
self.assertFalse(config.openinterpreter_enabled)
self.assertEqual(config.openinterpreter_command, "openinterpreter")
self.assertFalse(config.browser_playwright_enabled)
self.assertFalse(config.computer_control_enabled)
self.assertTrue(config.llm_stream)
self.assertEqual(config.validate_basic(), [])
def test_full_duplex_agent_config_values_are_read_from_dotenv(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
path = f"{tmp}/.env"
with open(path, "w", encoding="utf-8") as handle:
handle.write(
"\n".join(
[
"OWNER_ASSISTANT_MODE=full_duplex_agent",
"OWNER_AUDIO_APM_PROVIDER=fake",
"OWNER_AUDIO_AEC_ENABLED=0",
"OWNER_AUDIO_NS_ENABLED=0",
"OWNER_AUDIO_AGC_ENABLED=0",
"OWNER_AUDIO_APM_REQUIRED=0",
"OWNER_AUDIO_FRAME_MS=10",
"OWNER_AUDIO_RING_BUFFER_MS=1200",
"OWNER_INTERRUPT_ENABLED=0",
"OWNER_INTERRUPT_TARGET_LATENCY_MS=180",
"OWNER_STREAMING_STT_PROVIDER=sherpa_onnx",
"OWNER_STREAMING_STT_PRODUCT_CANDIDATE=faster_whisper",
"OWNER_STREAMING_TTS_PROVIDER=macos_say",
"OWNER_MEMORY_ENABLED=1",
"OWNER_MEMORY_PROVIDER=faiss_sqlite",
"OWNER_MEMORY_TOP_K=3",
"OWNER_MEMORY_AUTO_SAVE_SENSITIVE=1",
"OWNER_TOOL_ROUTER_ENABLED=1",
"OWNER_TOOL_MAX_CALLS_PER_TURN=2",
"OWNER_TOOL_TIMEOUT_MS=1000",
"OWNER_OPENINTERPRETER_ENABLED=1",
"OWNER_OPENINTERPRETER_COMMAND=/tmp/openinterpreter",
"OWNER_BROWSER_PLAYWRIGHT_ENABLED=1",
"OWNER_COMPUTER_CONTROL_ENABLED=1",
]
)
)
config = AppConfig.from_dotenv(path)
self.assertEqual(config.assistant_mode, "full_duplex_agent")
self.assertEqual(config.audio_apm_provider, "fake")
self.assertFalse(config.audio_aec_enabled)
self.assertFalse(config.audio_ns_enabled)
self.assertFalse(config.audio_agc_enabled)
self.assertFalse(config.audio_apm_required)
self.assertEqual(config.audio_frame_ms, 10)
self.assertEqual(config.audio_ring_buffer_ms, 1200)
self.assertFalse(config.interrupt_enabled)
self.assertEqual(config.interrupt_target_latency_ms, 180)
self.assertEqual(config.streaming_stt_provider, "sherpa_onnx")
self.assertEqual(config.streaming_stt_product_candidate, "faster_whisper")
self.assertEqual(config.streaming_tts_provider, "macos_say")
self.assertTrue(config.memory_enabled)
self.assertEqual(config.memory_provider, "faiss_sqlite")
self.assertEqual(config.memory_top_k, 3)
self.assertTrue(config.memory_auto_save_sensitive)
self.assertTrue(config.tool_router_enabled)
self.assertEqual(config.tool_max_calls_per_turn, 2)
self.assertEqual(config.tool_timeout_ms, 1000)
self.assertTrue(config.openinterpreter_enabled)
self.assertEqual(config.openinterpreter_command, "/tmp/openinterpreter")
self.assertTrue(config.browser_playwright_enabled)
self.assertTrue(config.computer_control_enabled)
self.assertEqual(config.validate_basic(), [])
def test_speech_provider_must_be_cloud_or_local(self) -> None:
config = AppConfig(speech_provider="invalid")
errors = config.validate_basic()
self.assertTrue(any("OWNER_SPEECH_PROVIDER" in error.message for error in errors))
def test_full_duplex_agent_config_is_validated(self) -> None:
config = AppConfig(
assistant_mode="invalid",
audio_apm_provider="invalid",
audio_frame_ms=0,
audio_ring_buffer_ms=0,
interrupt_target_latency_ms=0,
streaming_stt_provider="invalid",
streaming_stt_product_candidate="invalid",
streaming_tts_provider="invalid",
memory_provider="invalid",
memory_top_k=0,
tool_max_calls_per_turn=0,
tool_timeout_ms=0,
openinterpreter_enabled=True,
openinterpreter_command="",
)
errors = config.validate_basic()
self.assertTrue(any("OWNER_ASSISTANT_MODE" in error.message for error in errors))
self.assertTrue(any("OWNER_AUDIO_APM_PROVIDER" in error.message for error in errors))
self.assertTrue(any("OWNER_AUDIO_FRAME_MS" in error.message for error in errors))
self.assertTrue(any("OWNER_AUDIO_RING_BUFFER_MS" in error.message for error in errors))
self.assertTrue(any("OWNER_INTERRUPT_TARGET_LATENCY_MS" in error.message for error in errors))
self.assertTrue(any("OWNER_STREAMING_STT_PROVIDER" in error.message for error in errors))
self.assertTrue(any("OWNER_STREAMING_STT_PRODUCT_CANDIDATE" in error.message for error in errors))
self.assertTrue(any("OWNER_STREAMING_TTS_PROVIDER" in error.message for error in errors))
self.assertTrue(any("OWNER_MEMORY_PROVIDER" in error.message for error in errors))
self.assertTrue(any("OWNER_MEMORY_TOP_K" in error.message for error in errors))
self.assertTrue(any("OWNER_TOOL_MAX_CALLS_PER_TURN" in error.message for error in errors))
self.assertTrue(any("OWNER_TOOL_TIMEOUT_MS" in error.message for error in errors))
self.assertTrue(any("OWNER_OPENINTERPRETER_COMMAND" in error.message for error in errors))
def test_realtime_transcript_idle_timeout_must_be_non_negative(self) -> None:
config = AppConfig(realtime_transcript_idle_timeout_ms=-1)
errors = config.validate_basic()