[项目边界]:完成全双工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
+70
View File
@@ -38,6 +38,11 @@ def main(argv: list[str] | None = None) -> int:
subparsers.add_parser("device-check", help="Validate local microphone and speaker availability")
live = subparsers.add_parser("run-live", help="Run real repeated live voice conversation")
live.add_argument("--once", action="store_true", help="Run one completed live turn and exit")
agent_live = subparsers.add_parser(
"run-agent-live",
help="Run planned full-duplex Agent voice assistant entry point",
)
agent_live.add_argument("--check-config", action="store_true", help="Validate and print full-duplex Agent config")
simulate = subparsers.add_parser("simulate-live", help="Run live pipeline with simulated microphone frames")
simulate.add_argument("--turns", type=int, default=2, help="Number of simulated turns. Default: 2")
simulate.add_argument("--fixture", default=None, help="Replay simulated microphone frames from JSONL")
@@ -58,6 +63,7 @@ def main(argv: list[str] | None = None) -> int:
print(
json.dumps(
{
"assistant_mode": config.assistant_mode,
"wake_word": config.wake_word,
"sample_rate": config.sample_rate,
"channels": config.channels,
@@ -113,6 +119,29 @@ def main(argv: list[str] | None = None) -> int:
"end_chime_file": str(config.end_chime_file),
"end_chime_frequency_hz": config.end_chime_frequency_hz,
"end_chime_duration_ms": config.end_chime_duration_ms,
"audio_apm_provider": config.audio_apm_provider,
"audio_aec_enabled": config.audio_aec_enabled,
"audio_ns_enabled": config.audio_ns_enabled,
"audio_agc_enabled": config.audio_agc_enabled,
"audio_apm_required": config.audio_apm_required,
"audio_frame_ms": config.audio_frame_ms,
"audio_ring_buffer_ms": config.audio_ring_buffer_ms,
"interrupt_enabled": config.interrupt_enabled,
"interrupt_target_latency_ms": config.interrupt_target_latency_ms,
"streaming_stt_provider": config.streaming_stt_provider,
"streaming_stt_product_candidate": config.streaming_stt_product_candidate,
"streaming_tts_provider": config.streaming_tts_provider,
"memory_enabled": config.memory_enabled,
"memory_provider": config.memory_provider,
"memory_top_k": config.memory_top_k,
"memory_auto_save_sensitive": config.memory_auto_save_sensitive,
"tool_router_enabled": config.tool_router_enabled,
"tool_max_calls_per_turn": config.tool_max_calls_per_turn,
"tool_timeout_ms": config.tool_timeout_ms,
"openinterpreter_enabled": config.openinterpreter_enabled,
"openinterpreter_command": config.openinterpreter_command,
"browser_playwright_enabled": config.browser_playwright_enabled,
"computer_control_enabled": config.computer_control_enabled,
},
ensure_ascii=False,
sort_keys=True,
@@ -171,6 +200,47 @@ def main(argv: list[str] | None = None) -> int:
return 1
return 0 if summary.completed_turns > 0 or summary.interrupted else 1
if args.command == "run-agent-live":
config = replace(AppConfig.from_dotenv(args.env_file), assistant_mode="full_duplex_agent")
errors = config.validate_basic()
if errors:
print(
json.dumps(
{
"success": False,
"command": "run-agent-live",
"errors": [str(error) for error in errors],
},
ensure_ascii=False,
sort_keys=True,
)
)
return 1
data = {
"success": bool(args.check_config),
"command": "run-agent-live",
"assistant_mode": config.assistant_mode,
"audio_apm_provider": config.audio_apm_provider,
"streaming_stt_provider": config.streaming_stt_provider,
"streaming_tts_provider": config.streaming_tts_provider,
"memory_provider": config.memory_provider,
"memory_enabled": config.memory_enabled,
"tool_router_enabled": config.tool_router_enabled,
"openinterpreter_enabled": config.openinterpreter_enabled,
"browser_playwright_enabled": config.browser_playwright_enabled,
"computer_control_enabled": config.computer_control_enabled,
"turn_based_entry": "run-live",
"full_duplex_runtime_ready": False,
}
if not args.check_config:
data["code"] = "FULL_DUPLEX_RUNTIME_NOT_IMPLEMENTED"
data["message"] = (
"run-agent-live is the reserved full-duplex Agent entry point; "
"runtime wiring will be implemented by later OpenSpec tasks."
)
print(json.dumps(data, ensure_ascii=False, sort_keys=True))
return 0 if args.check_config else 1
if args.command == "simulate-live":
try:
data = run_simulated_live(
+137
View File
@@ -8,6 +8,7 @@ from .models import ErrorCode, ProviderError
@dataclass(frozen=True, slots=True)
class AppConfig:
assistant_mode: str = "turn_based_voice_pet"
wake_word: str = "小杰小杰"
sample_rate: int = 16000
channels: int = 1
@@ -68,6 +69,29 @@ class AppConfig:
end_chime_file: Path = Path("assets/sounds/codex-notification.wav")
end_chime_frequency_hz: int = 880
end_chime_duration_ms: int = 140
audio_apm_provider: str = "webrtc"
audio_aec_enabled: bool = True
audio_ns_enabled: bool = True
audio_agc_enabled: bool = True
audio_apm_required: bool = True
audio_frame_ms: int = 20
audio_ring_buffer_ms: int = 3000
interrupt_enabled: bool = True
interrupt_target_latency_ms: int = 200
streaming_stt_provider: str = "faster_whisper"
streaming_stt_product_candidate: str = "sensevoice"
streaming_tts_provider: str = "cosyvoice"
memory_enabled: bool = False
memory_provider: str = "faiss_sqlite"
memory_top_k: int = 5
memory_auto_save_sensitive: bool = False
tool_router_enabled: bool = False
tool_max_calls_per_turn: int = 5
tool_timeout_ms: int = 30000
openinterpreter_enabled: bool = False
openinterpreter_command: str = "openinterpreter"
browser_playwright_enabled: bool = False
computer_control_enabled: bool = False
@classmethod
def from_dotenv(cls, path: str | Path = ".env", prefix: str = "OWNER_") -> "AppConfig":
@@ -77,7 +101,13 @@ class AppConfig:
value = values.get(f"{prefix}{name}")
return default if value is None or value == "" else value
def get_bool(name: str, default: str = "0") -> bool:
return (get(name, default) or default).lower() not in {"0", "false", "no"}
return cls(
assistant_mode=(
get("ASSISTANT_MODE", "turn_based_voice_pet") or "turn_based_voice_pet"
).lower(),
wake_word=get("WAKE_WORD", "小杰小杰") or "小杰小杰",
sample_rate=int(get("SAMPLE_RATE", "16000") or "16000"),
channels=int(get("CHANNELS", "1") or "1"),
@@ -156,6 +186,37 @@ class AppConfig:
),
end_chime_frequency_hz=int(get("END_CHIME_FREQUENCY_HZ", "880") or "880"),
end_chime_duration_ms=int(get("END_CHIME_DURATION_MS", "140") or "140"),
audio_apm_provider=(get("AUDIO_APM_PROVIDER", "webrtc") or "webrtc").lower(),
audio_aec_enabled=get_bool("AUDIO_AEC_ENABLED", "1"),
audio_ns_enabled=get_bool("AUDIO_NS_ENABLED", "1"),
audio_agc_enabled=get_bool("AUDIO_AGC_ENABLED", "1"),
audio_apm_required=get_bool("AUDIO_APM_REQUIRED", "1"),
audio_frame_ms=int(get("AUDIO_FRAME_MS", "20") or "20"),
audio_ring_buffer_ms=int(get("AUDIO_RING_BUFFER_MS", "3000") or "3000"),
interrupt_enabled=get_bool("INTERRUPT_ENABLED", "1"),
interrupt_target_latency_ms=int(get("INTERRUPT_TARGET_LATENCY_MS", "200") or "200"),
streaming_stt_provider=(
get("STREAMING_STT_PROVIDER", "faster_whisper") or "faster_whisper"
).lower(),
streaming_stt_product_candidate=(
get("STREAMING_STT_PRODUCT_CANDIDATE", "sensevoice") or "sensevoice"
).lower(),
streaming_tts_provider=(
get("STREAMING_TTS_PROVIDER", "cosyvoice") or "cosyvoice"
).lower(),
memory_enabled=get_bool("MEMORY_ENABLED", "0"),
memory_provider=(get("MEMORY_PROVIDER", "faiss_sqlite") or "faiss_sqlite").lower(),
memory_top_k=int(get("MEMORY_TOP_K", "5") or "5"),
memory_auto_save_sensitive=get_bool("MEMORY_AUTO_SAVE_SENSITIVE", "0"),
tool_router_enabled=get_bool("TOOL_ROUTER_ENABLED", "0"),
tool_max_calls_per_turn=int(get("TOOL_MAX_CALLS_PER_TURN", "5") or "5"),
tool_timeout_ms=int(get("TOOL_TIMEOUT_MS", "30000") or "30000"),
openinterpreter_enabled=get_bool("OPENINTERPRETER_ENABLED", "0"),
openinterpreter_command=(
get("OPENINTERPRETER_COMMAND", "openinterpreter") or "openinterpreter"
),
browser_playwright_enabled=get_bool("BROWSER_PLAYWRIGHT_ENABLED", "0"),
computer_control_enabled=get_bool("COMPUTER_CONTROL_ENABLED", "0"),
)
@classmethod
@@ -174,6 +235,16 @@ class AppConfig:
def validate_basic(self) -> list[ProviderError]:
errors: list[ProviderError] = []
if self.assistant_mode not in {"turn_based_voice_pet", "full_duplex_agent"}:
errors.append(
ProviderError(
ErrorCode.CONFIG_MISSING_VALUE,
"OWNER_ASSISTANT_MODE must be turn_based_voice_pet or full_duplex_agent",
False,
"config",
"startup",
)
)
if self.sample_rate <= 0:
errors.append(
ProviderError(
@@ -451,6 +522,12 @@ class AppConfig:
for name, value in {
"OWNER_END_CHIME_FREQUENCY_HZ": self.end_chime_frequency_hz,
"OWNER_END_CHIME_DURATION_MS": self.end_chime_duration_ms,
"OWNER_AUDIO_FRAME_MS": self.audio_frame_ms,
"OWNER_AUDIO_RING_BUFFER_MS": self.audio_ring_buffer_ms,
"OWNER_INTERRUPT_TARGET_LATENCY_MS": self.interrupt_target_latency_ms,
"OWNER_MEMORY_TOP_K": self.memory_top_k,
"OWNER_TOOL_MAX_CALLS_PER_TURN": self.tool_max_calls_per_turn,
"OWNER_TOOL_TIMEOUT_MS": self.tool_timeout_ms,
}.items():
if value <= 0:
errors.append(
@@ -462,6 +539,66 @@ class AppConfig:
"startup",
)
)
if self.audio_apm_provider not in {"webrtc", "fake", "disabled"}:
errors.append(
ProviderError(
ErrorCode.CONFIG_MISSING_VALUE,
"OWNER_AUDIO_APM_PROVIDER must be webrtc, fake, or disabled",
False,
"config",
"startup",
)
)
if self.streaming_stt_provider not in {"faster_whisper", "sensevoice", "sherpa_onnx"}:
errors.append(
ProviderError(
ErrorCode.CONFIG_MISSING_VALUE,
"OWNER_STREAMING_STT_PROVIDER must be faster_whisper, sensevoice, or sherpa_onnx",
False,
"config",
"startup",
)
)
if self.streaming_stt_product_candidate not in {"sensevoice", "faster_whisper", "sherpa_onnx"}:
errors.append(
ProviderError(
ErrorCode.CONFIG_MISSING_VALUE,
"OWNER_STREAMING_STT_PRODUCT_CANDIDATE must be sensevoice, faster_whisper, or sherpa_onnx",
False,
"config",
"startup",
)
)
if self.streaming_tts_provider not in {"cosyvoice", "macos_say"}:
errors.append(
ProviderError(
ErrorCode.CONFIG_MISSING_VALUE,
"OWNER_STREAMING_TTS_PROVIDER must be cosyvoice or macos_say",
False,
"config",
"startup",
)
)
if self.memory_provider not in {"faiss_sqlite"}:
errors.append(
ProviderError(
ErrorCode.CONFIG_MISSING_VALUE,
"OWNER_MEMORY_PROVIDER must be faiss_sqlite",
False,
"config",
"startup",
)
)
if self.openinterpreter_enabled and not self.openinterpreter_command.strip():
errors.append(
ProviderError(
ErrorCode.CONFIG_MISSING_VALUE,
"OWNER_OPENINTERPRETER_COMMAND must be non-empty when Open Interpreter is enabled",
False,
"config",
"startup",
)
)
return errors
def api_url(self, path: str) -> str: