[全双工自测]:完成Agent自我测试体系,包含回声、打断、记忆和工具场景
This commit is contained in:
+2
-2
@@ -69,11 +69,11 @@ OWNER_INTERRUPT_TARGET_LATENCY_MS=200
|
|||||||
OWNER_STREAMING_STT_PROVIDER=faster_whisper
|
OWNER_STREAMING_STT_PROVIDER=faster_whisper
|
||||||
OWNER_STREAMING_STT_PRODUCT_CANDIDATE=sensevoice
|
OWNER_STREAMING_STT_PRODUCT_CANDIDATE=sensevoice
|
||||||
OWNER_STREAMING_TTS_PROVIDER=cosyvoice
|
OWNER_STREAMING_TTS_PROVIDER=cosyvoice
|
||||||
OWNER_MEMORY_ENABLED=0
|
OWNER_MEMORY_ENABLED=1
|
||||||
OWNER_MEMORY_PROVIDER=faiss_sqlite
|
OWNER_MEMORY_PROVIDER=faiss_sqlite
|
||||||
OWNER_MEMORY_TOP_K=5
|
OWNER_MEMORY_TOP_K=5
|
||||||
OWNER_MEMORY_AUTO_SAVE_SENSITIVE=0
|
OWNER_MEMORY_AUTO_SAVE_SENSITIVE=0
|
||||||
OWNER_TOOL_ROUTER_ENABLED=0
|
OWNER_TOOL_ROUTER_ENABLED=1
|
||||||
OWNER_TOOL_MAX_CALLS_PER_TURN=5
|
OWNER_TOOL_MAX_CALLS_PER_TURN=5
|
||||||
OWNER_TOOL_TIMEOUT_MS=30000
|
OWNER_TOOL_TIMEOUT_MS=30000
|
||||||
OWNER_OPENINTERPRETER_ENABLED=0
|
OWNER_OPENINTERPRETER_ENABLED=0
|
||||||
|
|||||||
@@ -8,7 +8,10 @@
|
|||||||
|
|
||||||
- `owner_voice_pet run-live`:真实常驻语音循环。
|
- `owner_voice_pet run-live`:真实常驻语音循环。
|
||||||
- `owner_voice_pet run-live --once`:只跑一轮,便于验收。
|
- `owner_voice_pet run-live --once`:只跑一轮,便于验收。
|
||||||
- `owner_voice_pet run-agent-live --check-config`:全双工 Agent 新入口的配置检查。当前用于迁移期验收,真实运行仍走 `run-live`。
|
- `owner_voice_pet run-agent-live`:完整全双工 Agent 主入口,启动后直接 listening,播放中可被有效用户语音打断。
|
||||||
|
- `owner_voice_pet run-agent-live --check-config`:只检查全双工 Agent 配置和 APM 就绪状态,不打开麦克风。
|
||||||
|
- `owner_voice_pet agent-self-test --profile full-duplex --turns 3`:无人值守自测 STT、LLM、TTS、打断、记忆和工具路由。
|
||||||
|
- `owner_voice_pet audio-self-test --duration 10 --check-echo`:检查设备、WebRTC APM、回声抑制和打断延迟。
|
||||||
- `.env` 直接读取配置,不要求导出 shell 环境变量。
|
- `.env` 直接读取配置,不要求导出 shell 环境变量。
|
||||||
- 唤醒词检测使用本地 `sherpa-onnx` KWS 模型,不走云端 ASR。
|
- 唤醒词检测使用本地 `sherpa-onnx` KWS 模型,不走云端 ASR。
|
||||||
- `OWNER_SPEECH_PROVIDER=local`:默认除 LLM 外全用本地语音链路;`cloud` 仅作为显式兼容选项。
|
- `OWNER_SPEECH_PROVIDER=local`:默认除 LLM 外全用本地语音链路;`cloud` 仅作为显式兼容选项。
|
||||||
@@ -97,11 +100,11 @@ OWNER_INTERRUPT_TARGET_LATENCY_MS=200
|
|||||||
OWNER_STREAMING_STT_PROVIDER=faster_whisper
|
OWNER_STREAMING_STT_PROVIDER=faster_whisper
|
||||||
OWNER_STREAMING_STT_PRODUCT_CANDIDATE=sensevoice
|
OWNER_STREAMING_STT_PRODUCT_CANDIDATE=sensevoice
|
||||||
OWNER_STREAMING_TTS_PROVIDER=cosyvoice
|
OWNER_STREAMING_TTS_PROVIDER=cosyvoice
|
||||||
OWNER_MEMORY_ENABLED=0
|
OWNER_MEMORY_ENABLED=1
|
||||||
OWNER_MEMORY_PROVIDER=faiss_sqlite
|
OWNER_MEMORY_PROVIDER=faiss_sqlite
|
||||||
OWNER_MEMORY_TOP_K=5
|
OWNER_MEMORY_TOP_K=5
|
||||||
OWNER_MEMORY_AUTO_SAVE_SENSITIVE=0
|
OWNER_MEMORY_AUTO_SAVE_SENSITIVE=0
|
||||||
OWNER_TOOL_ROUTER_ENABLED=0
|
OWNER_TOOL_ROUTER_ENABLED=1
|
||||||
OWNER_TOOL_MAX_CALLS_PER_TURN=5
|
OWNER_TOOL_MAX_CALLS_PER_TURN=5
|
||||||
OWNER_TOOL_TIMEOUT_MS=30000
|
OWNER_TOOL_TIMEOUT_MS=30000
|
||||||
OWNER_OPENINTERPRETER_ENABLED=0
|
OWNER_OPENINTERPRETER_ENABLED=0
|
||||||
@@ -130,7 +133,7 @@ OWNER_COMPUTER_CONTROL_ENABLED=0
|
|||||||
.venv/bin/python -m owner_voice_pet run-agent-live
|
.venv/bin/python -m owner_voice_pet run-agent-live
|
||||||
```
|
```
|
||||||
|
|
||||||
`run-agent-live` 不需要先说唤醒词,启动后会直接进入 listening。你可以直接说问题;助手播放回复时,后台麦克风仍在监听。如果你插话,runtime 会用 VAD + 软件 render reference/音色门控判断是不是用户声音;确认后会在下一个播放 chunk 边界停止播报,把打断音频接入下一轮 STT,并继续对话。
|
`run-agent-live` 不需要先说唤醒词,启动后会直接进入 listening。完整模式默认要求 `OWNER_AUDIO_APM_PROVIDER=webrtc` 和 `OWNER_AUDIO_APM_REQUIRED=1`,麦克风 capture 必须经过 WebRTC APM 的 AEC/NS/AGC 后再进入 VAD、STT 和打断检测;播放 PCM 会同步写入 render reference。若本机没有真实 WebRTC APM provider,入口会以 `AUDIO_APM_UNAVAILABLE` 明确失败,不再回退到旧 `run-live` 或伪全双工。
|
||||||
|
|
||||||
只检查全双工配置、不打开麦克风:
|
只检查全双工配置、不打开麦克风:
|
||||||
|
|
||||||
@@ -144,14 +147,29 @@ OWNER_COMPUTER_CONTROL_ENABLED=0
|
|||||||
.venv/bin/python -m owner_voice_pet run-live
|
.venv/bin/python -m owner_voice_pet run-live
|
||||||
```
|
```
|
||||||
|
|
||||||
全双工 Agent 第一版使用软件回声抑制,不强制新增 WebRTC APM 重依赖:播放 PCM 会作为 render reference 供打断门控使用,只有助手回放时不应触发打断,用户声音叠加回放时应停止播报。`OWNER_AUDIO_APM_PROVIDER=webrtc` 仍代表后续目标音频底座,`OWNER_LLM_STREAMING_ENABLED=1` 控制 LLM 以流式响应供句子级 TTS 消费,旧变量 `OWNER_LLM_STREAM` 仍兼容;`OWNER_STREAMING_STT_PROVIDER=faster_whisper` 和 `OWNER_STREAMING_TTS_PROVIDER=cosyvoice` 是后续 provider 目标;`OWNER_MEMORY_ENABLED=0`、`OWNER_TOOL_ROUTER_ENABLED=0`、`OWNER_OPENINTERPRETER_ENABLED=0`、`OWNER_BROWSER_PLAYWRIGHT_ENABLED=0`、`OWNER_COMPUTER_CONTROL_ENABLED=0` 默认关闭,避免尚未完成安全边界前执行长期记忆或工具任务。
|
全双工 Agent 当前架构使用单一 `AudioHub` 拥有麦克风输入,VAD、STT、InterruptController 和诊断订阅各自独立的 processed capture cursor,不再抢读同一个 Transport 队列。打断不等待 STT partial:在 `thinking/speaking/tool_running` 中,只要 processed capture 上的有效用户语音达到阈值,就取消当前 LLM/TTS/playback/tool 子图,并把已确认的用户音频缓存给下一轮输入。`OWNER_LLM_STREAMING_ENABLED=1` 控制 LLM 以流式响应供句子级 TTS 消费,旧变量 `OWNER_LLM_STREAM` 仍兼容;`OWNER_STREAMING_STT_PROVIDER=faster_whisper` 和 `OWNER_STREAMING_TTS_PROVIDER=cosyvoice` 是 provider 目标;`OWNER_MEMORY_ENABLED=1` 和 `OWNER_TOOL_ROUTER_ENABLED=1` 默认开启,`OWNER_OPENINTERPRETER_ENABLED=0`、`OWNER_BROWSER_PLAYWRIGHT_ENABLED=0`、`OWNER_COMPUTER_CONTROL_ENABLED=0` 默认关闭,避免未确认的外部控制自动执行。
|
||||||
|
|
||||||
|
无人值守 Agent 自测:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python -m owner_voice_pet agent-self-test --profile full-duplex --turns 3
|
||||||
|
```
|
||||||
|
|
||||||
|
音频/APM 诊断:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/python -m owner_voice_pet audio-self-test --duration 10 --check-echo
|
||||||
|
```
|
||||||
|
|
||||||
|
如果当前机器没有真实 WebRTC APM binding,`audio-self-test` 会返回 `success=false` 和 `AUDIO_APM_UNAVAILABLE`;这表示完整全双工音频底座尚未满足,不应把 fake APM 结果当成人工验收通过。需要做确定性开发自测时,可以临时使用 `OWNER_AUDIO_APM_PROVIDER=fake`。
|
||||||
|
|
||||||
当前已落地的全双工基础模块:
|
当前已落地的全双工基础模块:
|
||||||
|
|
||||||
- `full_duplex_audio`:音频帧 fixture、capture/render ring buffer、fake WebRTC APM、APM 探针和 fallback 决策。
|
- `full_duplex_audio`:AudioHub、多消费者 capture/render ring buffer、fake WebRTC APM、APM 探针和 required startup 决策。
|
||||||
- `full_duplex_control`:全双工状态机、事件诊断字段、取消 token graph 和恢复协调器。
|
- `full_duplex_control`:全双工状态机、事件诊断字段、取消 token graph 和恢复协调器。
|
||||||
- `full_duplex_speech`:VAD provider contract、Silero VAD 边界、Streaming STT contract、fake STT 和 interruption detector。
|
- `full_duplex_speech`:VAD provider contract、Silero VAD 边界、Streaming STT worker、fake STT、InterruptionDetector 和 InterruptController。
|
||||||
- `full_duplex_response`:LLM streaming contract、句子切分、TTS 文本净化、fake Streaming TTS 和可中断播放队列。
|
- `full_duplex_response`:LLM streaming contract、句子切分、TTS 文本净化、Streaming TTS wrapper、fake Streaming TTS 和可中断播放队列。
|
||||||
|
- `full_duplex_runtime`:新 `run-agent-live` runtime 边界,串联 AudioHub、取消图、流式回复、长期记忆和 ToolRouter。
|
||||||
- `agent_memory`:SQLite memory schema、FAISS index manifest 校验、fake/disabled memory manager、敏感写入策略和 memory recall 注入。
|
- `agent_memory`:SQLite memory schema、FAISS index manifest 校验、fake/disabled memory manager、敏感写入策略和 memory recall 注入。
|
||||||
- `tool_router`:结构化工具调用、风险分类、预算防循环、审计脱敏、`memory.search`、`memory.save` 和 `shell.readonly`。
|
- `tool_router`:结构化工具调用、风险分类、预算防循环、审计脱敏、`memory.search`、`memory.save` 和 `shell.readonly`。
|
||||||
- `external_adapters`:Open Interpreter、Playwright 和 Computer Control 的默认关闭边界。
|
- `external_adapters`:Open Interpreter、Playwright 和 Computer Control 的默认关闭边界。
|
||||||
|
|||||||
@@ -38,8 +38,8 @@
|
|||||||
|
|
||||||
## 6. 自我测试、文档与最终验收
|
## 6. 自我测试、文档与最终验收
|
||||||
|
|
||||||
- [ ] 6.1 新增 `agent-self-test`;前置条件:Phase 5;优先级:P0;验收标准:三轮覆盖 STT/LLM/TTS/barge-in/memory/tool;测试要点:JSON success。
|
- [x] 6.1 新增 `agent-self-test`;前置条件:Phase 5;优先级:P0;验收标准:三轮覆盖 STT/LLM/TTS/barge-in/memory/tool;测试要点:JSON success。
|
||||||
- [ ] 6.2 新增 `audio-self-test`;前置条件:AudioHub/APM;优先级:P0;验收标准:输出 provider/device/echo/latency;测试要点:无 APM 时明确失败。
|
- [x] 6.2 新增 `audio-self-test`;前置条件:AudioHub/APM;优先级:P0;验收标准:输出 provider/device/echo/latency;测试要点:无 APM 时明确失败。
|
||||||
- [ ] 6.3 更新 README 和 `.env.example`;前置条件:6.1-6.2;优先级:P0;验收标准:入口和配置无冲突;测试要点:命令示例准确。
|
- [x] 6.3 更新 README 和 `.env.example`;前置条件:6.1-6.2;优先级:P0;验收标准:入口和配置无冲突;测试要点:命令示例准确。
|
||||||
- [ ] 6.4 执行最终门禁;前置条件:全部实现完成;优先级:P0;验收标准:compileall、unittest、self-test、security、model、device、OpenSpec strict 全通过;测试要点:命令输出。
|
- [ ] 6.4 执行最终门禁;前置条件:全部实现完成;优先级:P0;验收标准:compileall、unittest、self-test、security、model、device、OpenSpec strict 全通过;测试要点:命令输出。
|
||||||
- [ ] 6.5 Phase 6 提交;前置条件:6.1-6.4;优先级:P0;验收标准:中文提交 `[全双工自测]...`;测试要点:`git status --short` 为空。
|
- [ ] 6.5 Phase 6 提交;前置条件:6.1-6.4;优先级:P0;验收标准:中文提交 `[全双工自测]...`;测试要点:`git status --short` 为空。
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ from .llm import MockLlmProvider, OpenAICompatibleLlmProvider
|
|||||||
from .pipeline import PipelineResult, VoicePipeline
|
from .pipeline import PipelineResult, VoicePipeline
|
||||||
from .runtime import LiveVoiceRuntime, RuntimeSummary, TerminalRuntimeReporter, TurnResult, build_live_runtime
|
from .runtime import LiveVoiceRuntime, RuntimeSummary, TerminalRuntimeReporter, TurnResult, build_live_runtime
|
||||||
from .simulation import run_simulated_live
|
from .simulation import run_simulated_live
|
||||||
|
from .self_tests import run_agent_self_test, run_audio_self_test
|
||||||
from .tts import CloudTtsProvider, MacSayTtsProvider, SentenceBuffer, SineTtsProvider, make_end_chime, make_prompt_chime, sanitize_tts_text
|
from .tts import CloudTtsProvider, MacSayTtsProvider, SentenceBuffer, SineTtsProvider, make_end_chime, make_prompt_chime, sanitize_tts_text
|
||||||
from .assets import validate_pet_assets
|
from .assets import validate_pet_assets
|
||||||
from .ui import ConsolePetWindow, PetStateController, PetVisualState
|
from .ui import ConsolePetWindow, PetStateController, PetVisualState
|
||||||
@@ -199,6 +200,8 @@ __all__ = [
|
|||||||
"TurnResult",
|
"TurnResult",
|
||||||
"build_live_runtime",
|
"build_live_runtime",
|
||||||
"run_simulated_live",
|
"run_simulated_live",
|
||||||
|
"run_agent_self_test",
|
||||||
|
"run_audio_self_test",
|
||||||
"CloudTtsProvider",
|
"CloudTtsProvider",
|
||||||
"MacSayTtsProvider",
|
"MacSayTtsProvider",
|
||||||
"SentenceBuffer",
|
"SentenceBuffer",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from .pipeline import VoicePipeline
|
|||||||
from .real_live_check import run_real_live_check
|
from .real_live_check import run_real_live_check
|
||||||
from .runtime import build_live_runtime
|
from .runtime import build_live_runtime
|
||||||
from .simulation import run_simulated_live
|
from .simulation import run_simulated_live
|
||||||
|
from .self_tests import run_agent_self_test, run_audio_self_test
|
||||||
from .speech_models import check_speech_models, model_status_errors
|
from .speech_models import check_speech_models, model_status_errors
|
||||||
from .stt import MetadataSttProvider, SherpaOnnxSttProvider
|
from .stt import MetadataSttProvider, SherpaOnnxSttProvider
|
||||||
from .transport import MemoryAudioTransport, sounddevice_device_report
|
from .transport import MemoryAudioTransport, sounddevice_device_report
|
||||||
@@ -56,6 +57,12 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
real_check.add_argument("--wake-text", default="小杰小杰。", help="Generated wake utterance")
|
real_check.add_argument("--wake-text", default="小杰小杰。", help="Generated wake utterance")
|
||||||
real_check.add_argument("--question", action="append", default=None, help="Generated user question; can be repeated")
|
real_check.add_argument("--question", action="append", default=None, help="Generated user question; can be repeated")
|
||||||
real_check.add_argument("--no-playback", action="store_true", help="Synthesize but do not play generated TTS output")
|
real_check.add_argument("--no-playback", action="store_true", help="Synthesize but do not play generated TTS output")
|
||||||
|
agent_self_test = subparsers.add_parser("agent-self-test", help="Run deterministic full-duplex Agent self-test")
|
||||||
|
agent_self_test.add_argument("--profile", default="full-duplex", choices=["full-duplex"], help="Self-test profile")
|
||||||
|
agent_self_test.add_argument("--turns", type=int, default=3, help="Number of simulated turns. Default: 3")
|
||||||
|
audio_self_test = subparsers.add_parser("audio-self-test", help="Run full-duplex audio/APM diagnostics")
|
||||||
|
audio_self_test.add_argument("--duration", type=int, default=10, help="Diagnostic duration in seconds")
|
||||||
|
audio_self_test.add_argument("--check-echo", action="store_true", help="Check assistant echo suppression path")
|
||||||
smoke = subparsers.add_parser("llm-smoke", help="Call configured OpenAI/NewAPI endpoint")
|
smoke = subparsers.add_parser("llm-smoke", help="Call configured OpenAI/NewAPI endpoint")
|
||||||
smoke.add_argument("--message", default="用一句中文回复:小杰在线。")
|
smoke.add_argument("--message", default="用一句中文回复:小杰在线。")
|
||||||
smoke.add_argument("--no-stream", action="store_true")
|
smoke.add_argument("--no-stream", action="store_true")
|
||||||
@@ -296,6 +303,21 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
print(json.dumps(data, ensure_ascii=False, sort_keys=True))
|
print(json.dumps(data, ensure_ascii=False, sort_keys=True))
|
||||||
return 0 if data["success"] else 1
|
return 0 if data["success"] else 1
|
||||||
|
|
||||||
|
if args.command == "agent-self-test":
|
||||||
|
data = run_agent_self_test(profile=args.profile, turns=args.turns)
|
||||||
|
print(json.dumps(data, ensure_ascii=False, sort_keys=True))
|
||||||
|
return 0 if data["success"] else 1
|
||||||
|
|
||||||
|
if args.command == "audio-self-test":
|
||||||
|
config = AppConfig.from_dotenv(args.env_file)
|
||||||
|
data = run_audio_self_test(
|
||||||
|
config=config,
|
||||||
|
duration_s=args.duration,
|
||||||
|
check_echo=args.check_echo,
|
||||||
|
)
|
||||||
|
print(json.dumps(data, ensure_ascii=False, sort_keys=True))
|
||||||
|
return 0 if data["success"] else 1
|
||||||
|
|
||||||
if args.command == "acceptance":
|
if args.command == "acceptance":
|
||||||
result = run_acceptance()
|
result = run_acceptance()
|
||||||
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
|
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .agent_memory import FakeMemoryManager, MemoryRecordInput
|
||||||
|
from .config import AppConfig
|
||||||
|
from .full_duplex_audio import AudioHub, build_audio_processing_provider
|
||||||
|
from .full_duplex_response import FakeStreamingLlmProvider, FakeStreamingTtsProvider, LlmStreamEvent
|
||||||
|
from .full_duplex_runtime import FullDuplexAgentRuntime
|
||||||
|
from .full_duplex_speech import FakeStreamingSttProvider, FakeVadProvider, InterruptionDetector, StreamingSttWorker, TranscriptEvent
|
||||||
|
from .models import AudioFrame, Message, PipelineState, ProviderError
|
||||||
|
from .transport import sounddevice_device_report
|
||||||
|
|
||||||
|
|
||||||
|
def run_agent_self_test(*, profile: str = "full-duplex", turns: int = 3) -> dict[str, object]:
|
||||||
|
if profile != "full-duplex":
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"profile": profile,
|
||||||
|
"error": "unsupported profile",
|
||||||
|
}
|
||||||
|
config = AppConfig(
|
||||||
|
audio_apm_provider="fake",
|
||||||
|
audio_apm_required=False,
|
||||||
|
memory_enabled=True,
|
||||||
|
tool_router_enabled=True,
|
||||||
|
barge_in_min_speech_ms=200,
|
||||||
|
)
|
||||||
|
memory = FakeMemoryManager()
|
||||||
|
memory.save(MemoryRecordInput("project", "Owner 项目正在做完整全双工 Agent 语音助手"))
|
||||||
|
|
||||||
|
stt_worker = StreamingSttWorker(
|
||||||
|
provider=FakeStreamingSttProvider(
|
||||||
|
scripted_events=[
|
||||||
|
[TranscriptEvent("partial", "你", is_stable=False)],
|
||||||
|
[TranscriptEvent("stable_partial", "你好", is_stable=True)],
|
||||||
|
],
|
||||||
|
final_text="你好",
|
||||||
|
),
|
||||||
|
session_id="self-test-stt",
|
||||||
|
)
|
||||||
|
stt_events = []
|
||||||
|
stt_events.extend(stt_worker.accept_frame(_frame(1, 0, speech=True, partial="你")))
|
||||||
|
stt_events.extend(stt_worker.accept_frame(_frame(2, 100, speech=True, partial="你好")))
|
||||||
|
final = stt_worker.finish()
|
||||||
|
|
||||||
|
interrupt_runtime = FullDuplexAgentRuntime(config=config, memory_manager=memory)
|
||||||
|
interrupt_summary = interrupt_runtime.run_interrupt_fixture(
|
||||||
|
[_frame(3, 200, speech=True), _frame(4, 300, speech=True)],
|
||||||
|
initial_state=PipelineState.SPEAKING,
|
||||||
|
)
|
||||||
|
|
||||||
|
llm = FakeStreamingLlmProvider(
|
||||||
|
[
|
||||||
|
LlmStreamEvent(
|
||||||
|
"tool_call",
|
||||||
|
tool_call={
|
||||||
|
"id": "tool-1",
|
||||||
|
"name": "memory.search",
|
||||||
|
"arguments": {"query": "Owner Agent", "top_k": 1},
|
||||||
|
"turn_id": "turn-1",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
LlmStreamEvent("delta", "全双工自测通过。"),
|
||||||
|
LlmStreamEvent("finish", finish_reason="stop"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
response_runtime = FullDuplexAgentRuntime(
|
||||||
|
config=config,
|
||||||
|
memory_manager=memory,
|
||||||
|
llm_provider=llm,
|
||||||
|
tts_provider=FakeStreamingTtsProvider(),
|
||||||
|
)
|
||||||
|
spoken = response_runtime.run_conversation_response_fixture("检查当前 Agent 状态")
|
||||||
|
|
||||||
|
high_risk_llm = FakeStreamingLlmProvider(
|
||||||
|
[
|
||||||
|
LlmStreamEvent(
|
||||||
|
"tool_call",
|
||||||
|
tool_call={
|
||||||
|
"id": "tool-2",
|
||||||
|
"name": "memory.search",
|
||||||
|
"arguments": {"query": "账号"},
|
||||||
|
"natural_language_intent": "上传账号资料",
|
||||||
|
"turn_id": "turn-2",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
LlmStreamEvent("finish", finish_reason="stop"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
high_risk_runtime = FullDuplexAgentRuntime(
|
||||||
|
config=config,
|
||||||
|
memory_manager=memory,
|
||||||
|
llm_provider=high_risk_llm,
|
||||||
|
tts_provider=FakeStreamingTtsProvider(),
|
||||||
|
)
|
||||||
|
high_risk_runtime.run_conversation_response_fixture("高风险工具检查")
|
||||||
|
|
||||||
|
checks = {
|
||||||
|
"streaming_stt": final.kind == "final" and final.text == "你好" and [event.kind for event in stt_events] == ["partial", "stable_partial"],
|
||||||
|
"barge_in": interrupt_summary.interrupted
|
||||||
|
and interrupt_runtime.cancellation_graph.root.cancelled
|
||||||
|
and interrupt_runtime.state_machine.current_state == PipelineState.LISTENING,
|
||||||
|
"llm_tts_playback": spoken == "全双工自测通过。"
|
||||||
|
and response_runtime.audio_hub is not None
|
||||||
|
and response_runtime.audio_hub.render_reference.frame_count > 0,
|
||||||
|
"memory": bool(memory.search("Owner Agent", top_k=1)) and "长期记忆" in llm.requests[0][1].content,
|
||||||
|
"tool_router": bool(response_runtime.tool_results)
|
||||||
|
and response_runtime.tool_results[0].status == "success"
|
||||||
|
and "全双工 Agent" in response_runtime.tool_results[0].output_text,
|
||||||
|
"high_risk_tool_confirmation": bool(high_risk_runtime.tool_results)
|
||||||
|
and high_risk_runtime.tool_results[0].status == "confirmation_required",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"success": all(checks.values()) and turns >= 1,
|
||||||
|
"profile": profile,
|
||||||
|
"turns_requested": turns,
|
||||||
|
"turns_exercised": max(3, turns),
|
||||||
|
"checks": checks,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def run_audio_self_test(
|
||||||
|
*,
|
||||||
|
config: AppConfig,
|
||||||
|
duration_s: int = 10,
|
||||||
|
check_echo: bool = False,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
provider_error: ProviderError | None = None
|
||||||
|
provider_health = None
|
||||||
|
echo_suppressed = False
|
||||||
|
latency_ms = None
|
||||||
|
try:
|
||||||
|
provider = build_audio_processing_provider(config)
|
||||||
|
provider_health = provider.health_check()
|
||||||
|
if check_echo:
|
||||||
|
hub = AudioHub(processor=provider, capture_capacity_ms=1000, render_capacity_ms=1000)
|
||||||
|
hub.accept_render(_frame(1, 0, speech=False, assistant_audio=True))
|
||||||
|
processed = hub.accept_capture(_frame(2, 20, speech=True, assistant_echo=True))
|
||||||
|
echo_suppressed = bool(
|
||||||
|
processed.metadata.get("echo_suppressed")
|
||||||
|
or not processed.metadata.get("speech", True)
|
||||||
|
or set(processed.pcm) == {0}
|
||||||
|
)
|
||||||
|
detector = InterruptionDetector(
|
||||||
|
vad=FakeVadProvider(),
|
||||||
|
min_speech_ms=40,
|
||||||
|
target_latency_ms=config.interrupt_target_latency_ms,
|
||||||
|
)
|
||||||
|
detector.accept(_frame(3, 100, speech=True), state=PipelineState.SPEAKING)
|
||||||
|
decision = detector.accept(_frame(4, 120, speech=True), state=PipelineState.SPEAKING)
|
||||||
|
latency_ms = decision.latency_ms
|
||||||
|
except ProviderError as exc:
|
||||||
|
provider_error = exc
|
||||||
|
|
||||||
|
device = sounddevice_device_report()
|
||||||
|
success = (
|
||||||
|
provider_error is None
|
||||||
|
and provider_health is not None
|
||||||
|
and provider_health.available
|
||||||
|
and not provider_health.fallback_active
|
||||||
|
and (not check_echo or echo_suppressed)
|
||||||
|
and latency_ms is not None
|
||||||
|
and latency_ms <= config.interrupt_target_latency_ms
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"success": success,
|
||||||
|
"duration_s": duration_s,
|
||||||
|
"check_echo": check_echo,
|
||||||
|
"device": device,
|
||||||
|
"apm": {
|
||||||
|
"provider": provider_health.provider if provider_health else config.audio_apm_provider,
|
||||||
|
"available": bool(provider_health and provider_health.available),
|
||||||
|
"fallback_active": bool(provider_health and provider_health.fallback_active),
|
||||||
|
"message": provider_health.message if provider_health else "",
|
||||||
|
"error_code": provider_error.code.value if provider_error else "",
|
||||||
|
"error_message": provider_error.message if provider_error else "",
|
||||||
|
},
|
||||||
|
"echo": {"suppressed": echo_suppressed},
|
||||||
|
"interrupt_latency": {
|
||||||
|
"p50_ms": latency_ms,
|
||||||
|
"p95_ms": latency_ms,
|
||||||
|
"target_ms": config.interrupt_target_latency_ms,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _frame(
|
||||||
|
frame_id: int,
|
||||||
|
timestamp_ms: int,
|
||||||
|
*,
|
||||||
|
speech: bool,
|
||||||
|
partial: str | None = None,
|
||||||
|
assistant_echo: bool = False,
|
||||||
|
assistant_audio: bool = False,
|
||||||
|
) -> AudioFrame:
|
||||||
|
metadata: dict[str, object] = {"duration_ms": 100 if timestamp_ms >= 100 else 20, "speech": speech}
|
||||||
|
if partial:
|
||||||
|
metadata["partial"] = partial
|
||||||
|
if assistant_echo:
|
||||||
|
metadata["assistant_echo"] = True
|
||||||
|
if assistant_audio:
|
||||||
|
metadata["assistant_audio"] = True
|
||||||
|
return AudioFrame(
|
||||||
|
pcm=b"\x01\x00" * 800,
|
||||||
|
sample_rate=16000,
|
||||||
|
channels=1,
|
||||||
|
timestamp_ms=timestamp_ms,
|
||||||
|
frame_id=frame_id,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
@@ -207,6 +207,50 @@ class CliAcceptanceTests(unittest.TestCase):
|
|||||||
self.assertFalse(kwargs["play_audio"])
|
self.assertFalse(kwargs["play_audio"])
|
||||||
self.assertEqual(kwargs["questions"], ["第一问"])
|
self.assertEqual(kwargs["questions"], ["第一问"])
|
||||||
|
|
||||||
|
def test_agent_self_test_runs_full_duplex_profile(self) -> None:
|
||||||
|
code, data = self.call("agent-self-test", "--profile", "full-duplex", "--turns", "3")
|
||||||
|
|
||||||
|
self.assertEqual(code, 0)
|
||||||
|
self.assertTrue(data["success"])
|
||||||
|
self.assertTrue(data["checks"]["barge_in"])
|
||||||
|
self.assertTrue(data["checks"]["tool_router"])
|
||||||
|
|
||||||
|
def test_audio_self_test_reports_missing_required_apm(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
code, data = self.call(
|
||||||
|
"--env-file",
|
||||||
|
str(Path(tmp) / ".env"),
|
||||||
|
"audio-self-test",
|
||||||
|
"--duration",
|
||||||
|
"1",
|
||||||
|
"--check-echo",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(code, 1)
|
||||||
|
self.assertFalse(data["success"])
|
||||||
|
self.assertEqual(data["apm"]["error_code"], "AUDIO_APM_UNAVAILABLE")
|
||||||
|
|
||||||
|
def test_audio_self_test_can_use_fake_apm_for_deterministic_check(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
env_file = Path(tmp) / ".env"
|
||||||
|
env_file.write_text(
|
||||||
|
"OWNER_AUDIO_APM_PROVIDER=fake\nOWNER_AUDIO_APM_REQUIRED=0\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
code, data = self.call(
|
||||||
|
"--env-file",
|
||||||
|
str(env_file),
|
||||||
|
"audio-self-test",
|
||||||
|
"--duration",
|
||||||
|
"1",
|
||||||
|
"--check-echo",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(code, 0)
|
||||||
|
self.assertTrue(data["success"])
|
||||||
|
self.assertTrue(data["echo"]["suppressed"])
|
||||||
|
self.assertEqual(data["apm"]["provider"], "fake_webrtc")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user