From e730883c64970a080a70df461e8e25d2d21d3469 Mon Sep 17 00:00:00 2001 From: mkbk Date: Thu, 18 Jun 2026 22:32:23 +0800 Subject: [PATCH] =?UTF-8?q?[=E6=96=87=E6=A1=A3=E9=AA=8C=E6=94=B6]=EF=BC=9A?= =?UTF-8?q?=E5=AE=8C=E6=88=90=E5=85=A8=E5=8F=8C=E5=B7=A5Agent=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E5=92=8C=E6=9C=80=E7=BB=88=E9=97=A8=E7=A6=81=EF=BC=8C?= =?UTF-8?q?=E5=8C=85=E5=90=AB=E5=AE=89=E5=85=A8=E7=AD=96=E7=95=A5=E3=80=81?= =?UTF-8?q?=E9=AA=8C=E6=94=B6=E5=91=BD=E4=BB=A4=E5=92=8COpenSpec=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 1 + .gitignore | 1 + README.md | 16 ++++++- docs/full_duplex_agent_security.md | 47 +++++++++++++++++++ .../tasks.md | 18 +++---- src/owner_voice_pet/cli.py | 1 + src/owner_voice_pet/config.py | 8 +++- tests/test_models_config.py | 22 +++++++++ 8 files changed, 103 insertions(+), 11 deletions(-) create mode 100644 docs/full_duplex_agent_security.md diff --git a/.env.example b/.env.example index ddd6ea4..f711011 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,7 @@ OWNER_LLM_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1 OWNER_LLM_API_KEY= OWNER_LLM_MODEL=mimo-v2.5 OWNER_LLM_API_STYLE=chat_completions +OWNER_LLM_STREAMING_ENABLED=1 OWNER_ASSISTANT_MODE=turn_based_voice_pet OWNER_REALTIME_TRANSCRIPT_ENABLED=1 OWNER_REALTIME_TRANSCRIPT_IDLE_TIMEOUT_MS=1500 diff --git a/.gitignore b/.gitignore index 85fa157..6d364d7 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ dist/ *.egg-info/ logs/ tmp/ +openinterpreter/ diff --git a/README.md b/README.md index f9813f7..4e19384 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ OWNER_LLM_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1 OWNER_LLM_API_KEY= OWNER_LLM_MODEL=mimo-v2.5 OWNER_LLM_API_STYLE=chat_completions +OWNER_LLM_STREAMING_ENABLED=1 OWNER_ASSISTANT_MODE=turn_based_voice_pet OWNER_REALTIME_TRANSCRIPT_ENABLED=1 OWNER_REALTIME_TRANSCRIPT_IDLE_TIMEOUT_MS=1500 @@ -130,7 +131,18 @@ OWNER_COMPUTER_CONTROL_ENABLED=0 这个命令会把有效运行模式固定为 `full_duplex_agent` 并输出全双工配置摘要,但当前不会启动未接线的全双工运行时。真实语音交互仍继续使用 `run-live`。后续实现会在这个入口下逐步接入 WebRTC APM、持续监听、Streaming STT/TTS、长期记忆和 Tool Router。 -全双工 Agent 相关配置默认只做规划和安全关闭:`OWNER_AUDIO_APM_PROVIDER=webrtc` 代表目标音频底座,`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 相关配置默认只做规划和安全关闭:`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` 默认关闭,避免尚未完成安全边界前执行长期记忆或工具任务。 + +当前已落地的全双工基础模块: + +- `full_duplex_audio`:音频帧 fixture、capture/render ring buffer、fake WebRTC APM、APM 探针和 fallback 决策。 +- `full_duplex_control`:全双工状态机、事件诊断字段、取消 token graph 和恢复协调器。 +- `full_duplex_speech`:VAD provider contract、Silero VAD 边界、Streaming STT contract、fake STT 和 interruption detector。 +- `full_duplex_response`:LLM streaming contract、句子切分、TTS 文本净化、fake Streaming TTS 和可中断播放队列。 +- `agent_memory`:SQLite memory schema、FAISS index manifest 校验、fake/disabled memory manager、敏感写入策略和 memory recall 注入。 +- `tool_router`:结构化工具调用、风险分类、预算防循环、审计脱敏、`memory.search`、`memory.save` 和 `shell.readonly`。 +- `external_adapters`:Open Interpreter、Playwright 和 Computer Control 的默认关闭边界。 +- `full_duplex_testing`:fake 全双工 fixture、性能指标和诊断脱敏测试工具。 可选依赖按能力分组,不会被默认安装强制拉入: @@ -141,6 +153,8 @@ OWNER_COMPUTER_CONTROL_ENABLED=0 .venv/bin/python -m pip install -e '.[browser]' ``` +工具执行安全策略见 `docs/full_duplex_agent_security.md`。 + ## 本地模型 首次运行前必须准备本地语音模型;同一脚本会下载 wake、VAD、2025 中文 CTC STT 和 GTCRN denoiser 模型: diff --git a/docs/full_duplex_agent_security.md b/docs/full_duplex_agent_security.md new file mode 100644 index 0000000..44f8c04 --- /dev/null +++ b/docs/full_duplex_agent_security.md @@ -0,0 +1,47 @@ +# 全双工 Agent 安全确认策略 + +## 默认原则 + +全双工 Agent 的工具执行默认最小权限。语音输入可能被误触发,工具请求必须经过结构化校验、风险分类、预算限制、输出脱敏和审计记录。 + +## 自动允许 + +以下请求可在第一版策略中自动执行: + +- `memory.search`:只读长期记忆检索。 +- `shell.readonly`:允许目录内的只读命令,例如 `pwd`、`ls`、`find`、`rg`、`cat`、`sed`、`git status`、`git diff`、`git log`、`git show`。 +- 低风险 dry-run adapter 检查,例如 `run-agent-live --check-config` 这类不改变外部状态的诊断。 + +## 需要确认 + +以下请求必须进入确认流程: + +- `memory.save` 保存疑似敏感内容。 +- `openinterpreter.run` 中包含写文件、删除、上传、账号、权限、安装依赖、执行宽泛代码的任务。 +- `browser.playwright` 中包含登录、支付、购买、提交表单、私信、上传、账号修改或不可逆动作的任务。 +- 任何自然语言意图中出现删除、上传、支付、购买、交易、账号、权限等高风险关键词的工具调用。 + +## 默认拒绝 + +以下请求第一版直接拒绝: + +- `shell.readonly` 中出现 `rm`、`chmod`、`chown`、`pip install`、`npm install`、`brew install`、重定向写入、`mv`、`cp` 等修改外部状态的命令。 +- 未注册工具名、未知参数结构或超过单轮工具预算的请求。 +- 直接 GUI 点击、键盘输入、屏幕控制或 macOS Accessibility 自动化请求。 +- 试图绕过确认策略、读取或输出密钥、上传本地敏感文件的请求。 + +## 审计和脱敏 + +工具审计记录只保存工具名、风险级别、确认状态、耗时、状态和脱敏摘要。API key、Authorization header、原始音频、PCM payload、密码和 token 形态文本必须被替换为 `[redacted]` 或被截断。 + +## Open Interpreter 边界 + +本地 `openinterpreter/` 是外部候选后端,不复制、不 vendoring、不提交到 Owner。Owner 后续只通过 `OWNER_OPENINTERPRETER_COMMAND` 指定的 CLI/子进程调用它,并由 Tool Router 负责目录限制、超时、输出截断和确认策略。 + +## 浏览器边界 + +`browser.playwright` 默认关闭,默认使用隔离 browser context。是否允许复用用户当前 Chrome 登录态需要后续人工确认;第一版不默认复用登录态。 + +## Computer Control 边界 + +Codex Computer Use 只作为安全确认策略参考,不复制私有实现。后续 GUI 控制必须另开 OpenSpec,基于公共能力实现,例如 macOS Accessibility、Playwright 或 trycua-style provider。 diff --git a/openspec/changes/add-full-duplex-agent-voice-assistant/tasks.md b/openspec/changes/add-full-duplex-agent-voice-assistant/tasks.md index 473fa98..746d38a 100644 --- a/openspec/changes/add-full-duplex-agent-voice-assistant/tasks.md +++ b/openspec/changes/add-full-duplex-agent-voice-assistant/tasks.md @@ -91,12 +91,12 @@ ## 10. 文档、验收与模块提交 -- [ ] 10.1 更新 README 全双工架构说明;前置条件:入口和配置确认;优先级:P1;验收标准:说明 APM、Streaming STT/TTS、记忆、工具、限制;测试要点:命令示例和默认关闭项准确。 -- [ ] 10.2 更新 `.env.example` 规划变量;前置条件:配置清单冻结;优先级:P1;验收标准:新增全双工、APM、memory、tool、adapter 配置;测试要点:默认值不启用高风险工具。 -- [ ] 10.3 编写模型和依赖准备说明;前置条件:provider 候选确认;优先级:P1;验收标准:区分开发默认和产品候选;测试要点:不承诺未验证模型已可用。 -- [ ] 10.4 编写安全确认策略文档;前置条件:Tool Router 风险规则完成;优先级:P0;验收标准:列明自动允许、需要确认、默认拒绝类别;测试要点:与工具测试用例一致。 -- [ ] 10.5 执行 Python 编译验证;前置条件:实现代码变更完成;优先级:P0;验收标准:`.venv/bin/python -m compileall src tests scripts` 通过;测试要点:无语法错误。 -- [ ] 10.6 执行单元测试验证;前置条件:测试实现完成;优先级:P0;验收标准:`.venv/bin/python -m unittest discover -s tests` 通过;测试要点:覆盖 full-duplex、memory、tool、security。 -- [ ] 10.7 执行安全和模型检查;前置条件:相关命令实现完成;优先级:P0;验收标准:security-check、model-check、device-check 按阶段通过或给出明确跳过理由;测试要点:不泄露密钥。 -- [ ] 10.8 执行 OpenSpec 校验;前置条件:docs 和 spec 更新完成;优先级:P0;验收标准:`openspec validate add-full-duplex-agent-voice-assistant --strict` 与 `openspec validate --all --strict` 通过;测试要点:无 spec 格式错误。 -- [ ] 10.9 按模块提交;前置条件:对应模块验证通过;优先级:P0;验收标准:提交信息符合 `[模块名]:完成[具体功能描述],包含[关键变更]`;测试要点:`git status --short` 不含本模块未提交改动。 +- [x] 10.1 更新 README 全双工架构说明;前置条件:入口和配置确认;优先级:P1;验收标准:说明 APM、Streaming STT/TTS、记忆、工具、限制;测试要点:命令示例和默认关闭项准确。 +- [x] 10.2 更新 `.env.example` 规划变量;前置条件:配置清单冻结;优先级:P1;验收标准:新增全双工、APM、memory、tool、adapter 配置;测试要点:默认值不启用高风险工具。 +- [x] 10.3 编写模型和依赖准备说明;前置条件:provider 候选确认;优先级:P1;验收标准:区分开发默认和产品候选;测试要点:不承诺未验证模型已可用。 +- [x] 10.4 编写安全确认策略文档;前置条件:Tool Router 风险规则完成;优先级:P0;验收标准:列明自动允许、需要确认、默认拒绝类别;测试要点:与工具测试用例一致。 +- [x] 10.5 执行 Python 编译验证;前置条件:实现代码变更完成;优先级:P0;验收标准:`.venv/bin/python -m compileall src tests scripts` 通过;测试要点:无语法错误。 +- [x] 10.6 执行单元测试验证;前置条件:测试实现完成;优先级:P0;验收标准:`.venv/bin/python -m unittest discover -s tests` 通过;测试要点:覆盖 full-duplex、memory、tool、security。 +- [x] 10.7 执行安全和模型检查;前置条件:相关命令实现完成;优先级:P0;验收标准:security-check、model-check、device-check 按阶段通过或给出明确跳过理由;测试要点:不泄露密钥。 +- [x] 10.8 执行 OpenSpec 校验;前置条件:docs 和 spec 更新完成;优先级:P0;验收标准:`openspec validate add-full-duplex-agent-voice-assistant --strict` 与 `openspec validate --all --strict` 通过;测试要点:无 spec 格式错误。 +- [x] 10.9 按模块提交;前置条件:对应模块验证通过;优先级:P0;验收标准:提交信息符合 `[模块名]:完成[具体功能描述],包含[关键变更]`;测试要点:`git status --short` 不含本模块未提交改动。 diff --git a/src/owner_voice_pet/cli.py b/src/owner_voice_pet/cli.py index 526b7dc..22b0a58 100644 --- a/src/owner_voice_pet/cli.py +++ b/src/owner_voice_pet/cli.py @@ -223,6 +223,7 @@ def main(argv: list[str] | None = None) -> int: "audio_apm_provider": config.audio_apm_provider, "streaming_stt_provider": config.streaming_stt_provider, "streaming_tts_provider": config.streaming_tts_provider, + "llm_streaming_enabled": config.llm_stream, "memory_provider": config.memory_provider, "memory_enabled": config.memory_enabled, "tool_router_enabled": config.tool_router_enabled, diff --git a/src/owner_voice_pet/config.py b/src/owner_voice_pet/config.py index 3dde43b..8714ec1 100644 --- a/src/owner_voice_pet/config.py +++ b/src/owner_voice_pet/config.py @@ -104,6 +104,12 @@ class AppConfig: def get_bool(name: str, default: str = "0") -> bool: return (get(name, default) or default).lower() not in {"0", "false", "no"} + def get_bool_compat(name: str, legacy_name: str, default: str = "0") -> bool: + value = get(name) + if value is None: + value = get(legacy_name, default) + return (value or default).lower() not in {"0", "false", "no"} + return cls( assistant_mode=( get("ASSISTANT_MODE", "turn_based_voice_pet") or "turn_based_voice_pet" @@ -115,7 +121,7 @@ class AppConfig: llm_api_key=get("LLM_API_KEY"), llm_model=get("LLM_MODEL", "mimo-v2.5") or "mimo-v2.5", llm_api_style=get("LLM_API_STYLE", "chat_completions") or "chat_completions", - llm_stream=(get("LLM_STREAM", "1") or "1").lower() not in {"0", "false", "no"}, + llm_stream=get_bool_compat("LLM_STREAMING_ENABLED", "LLM_STREAM", "1"), realtime_transcript_enabled=(get("REALTIME_TRANSCRIPT_ENABLED", "1") or "1").lower() not in {"0", "false", "no"}, realtime_transcript_idle_timeout_ms=int( diff --git a/tests/test_models_config.py b/tests/test_models_config.py index cb2010a..5a6d015 100644 --- a/tests/test_models_config.py +++ b/tests/test_models_config.py @@ -166,6 +166,7 @@ class ModelsConfigTests(unittest.TestCase): "OWNER_AUDIO_RING_BUFFER_MS=1200", "OWNER_INTERRUPT_ENABLED=0", "OWNER_INTERRUPT_TARGET_LATENCY_MS=180", + "OWNER_LLM_STREAMING_ENABLED=0", "OWNER_STREAMING_STT_PROVIDER=sherpa_onnx", "OWNER_STREAMING_STT_PRODUCT_CANDIDATE=faster_whisper", "OWNER_STREAMING_TTS_PROVIDER=macos_say", @@ -196,6 +197,7 @@ class ModelsConfigTests(unittest.TestCase): self.assertEqual(config.audio_ring_buffer_ms, 1200) self.assertFalse(config.interrupt_enabled) self.assertEqual(config.interrupt_target_latency_ms, 180) + self.assertFalse(config.llm_stream) 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") @@ -212,6 +214,26 @@ class ModelsConfigTests(unittest.TestCase): self.assertTrue(config.computer_control_enabled) self.assertEqual(config.validate_basic(), []) + def test_llm_streaming_enabled_takes_precedence_over_legacy_llm_stream(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + path = f"{tmp}/.env" + with open(path, "w", encoding="utf-8") as handle: + handle.write("OWNER_LLM_STREAM=0\nOWNER_LLM_STREAMING_ENABLED=1\n") + + config = AppConfig.from_dotenv(path) + + self.assertTrue(config.llm_stream) + + def test_legacy_llm_stream_still_disables_streaming(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + path = f"{tmp}/.env" + with open(path, "w", encoding="utf-8") as handle: + handle.write("OWNER_LLM_STREAM=0\n") + + config = AppConfig.from_dotenv(path) + + self.assertFalse(config.llm_stream) + def test_speech_provider_must_be_cloud_or_local(self) -> None: config = AppConfig(speech_provider="invalid") errors = config.validate_basic()