diff --git a/aibox/aibox.py b/aibox/aibox.py index 3ccd2a4..49977a0 100644 --- a/aibox/aibox.py +++ b/aibox/aibox.py @@ -602,9 +602,12 @@ def run_light_subagent(role, task, config, logger=None, model=None, return f"❌ 子代理执行失败: {str(e)}" +# 子代理结果标记:供 _handle_tool_calls 检测工具失败并剥离 +_SUBAGENT_TOOL_FAILED_MARKER = "[SUBAGENT_TOOL_FAILED]\n" + def run_full_subagent(role, task, config, logger=None, model=None, temperature=None, max_tokens=2048, max_turns=10, - output_callback=None) -> str: + output_callback=None, outcome: dict = None) -> str: """完整子代理:拥有全部工具,独立上下文,多轮对话直到任务完成。 子代理是完整的 DeepSeekChat 实例,拥有文件操作、命令执行、网页访问、 @@ -712,6 +715,11 @@ def run_full_subagent(role, task, config, logger=None, model=None, except Exception as e: if logger: logger.error(f"子代理收尾报告生成异常: {e}") + + # 透传子代理工具失败状态(供 SubAgentTool → orchestrator stats 汇总) + if outcome is not None: + outcome["tool_failed"] = sub_chat.stats.get("tool_failed", False) + if report and str(report).strip(): return str(report).strip() @@ -814,12 +822,17 @@ class SubAgentTool(Tool): max_turns: int = 10, **kwargs) -> str: if error := self.validate_params(["role", "task"], role=role, task=task): return error + outcome: dict = {} result = run_full_subagent(role, task, self.config, logger=self.logger, model=model, temperature=temperature, max_tokens=max_tokens, max_turns=max_turns, - output_callback=self._subagent_output) + output_callback=self._subagent_output, + outcome=outcome) # 冲刷子代理回答缓冲(最后一段 chunk 可能尚未发出) self._flush_subagent_answer() + # 子代理内部工具失败 → 打上标记,由 _handle_tool_calls 检测并置 orchestrator stats + if outcome.get("tool_failed"): + result = _SUBAGENT_TOOL_FAILED_MARKER + result return result @@ -861,16 +874,19 @@ class DeepSeekChat: history_manager: ConversationHistory = None, memory_db: MemoryDatabase = None, memo_db: MemoDatabase = None, command_executor: CommandExecutor = None, prompt_manager: PromptManager = None, skill_manager: SkillManager = None, - tool_profile: str = TOOL_PROFILE_ORCHESTRATOR, enable_memory: bool = True): + tool_profile: str = TOOL_PROFILE_ORCHESTRATOR, enable_memory: bool = True, + model: str = None, temperature: float = None): self.config = ConfigManager() self.logger = Logger("DeepSeek", debug_level if debug_level is not None else Logger("").level) self.debug_level = self.logger.level # 工具集 profile:决定本实例注册哪套工具(orchestrator 管理面 / worker 执行面) self.tool_profile = tool_profile - # 大模型客户端(抽象层,支持多厂商切换) - self.client = create_llm_client(self.config, api_key=api_key, logger=self.logger) + # 大模型客户端(抽象层,支持多厂商切换);model 显式传入时覆盖配置默认 + self.client = create_llm_client(self.config, api_key=api_key, logger=self.logger, model=model) self.api_key = self.client.api_key self.name = self.client.name + # temperature 显式传入时覆盖 constants 默认(一次性 CLI 可指定更确定性的采样) + self.temperature = temperature if temperature is not None else constants.DEFAULT_TEMPERATURE self.history_manager = history_manager # enable_memory=False 时置空,注册层(_register_tools)依此跳过 MemoryTool, # 使记忆工具 schema 不出现在请求中,LLM 无从调用——干净禁用而非运行期拦截 @@ -1236,9 +1252,11 @@ class DeepSeekChat: self.logger.error(f"工具 {tool_name} 执行异常: {e}") self.stats["tool_failed"] = True - # 注:run_command 子命令非零码由 worker 子代理承载,其失败需经子代理汇总上报, - # 精确的退出码 1 细分属 Batch C(SubAgentTool 透传子代理 stats)。 - # 此处仅标记 orchestrator 直接感知的工具异常。 + # 子代理内部工具失败透传:SubAgentTool 在结果前缀打标记,此处检测并置 stats + if tool_name == "create_subagent" and result_raw and \ + str(result_raw).startswith(_SUBAGENT_TOOL_FAILED_MARKER): + self.stats["tool_failed"] = True + result_raw = str(result_raw)[len(_SUBAGENT_TOOL_FAILED_MARKER):] # 安全转换并清理结果 result = self._sanitize_tool_result(result_raw) @@ -1455,7 +1473,7 @@ class DeepSeekChat: # 通过客户端抽象层发起流式请求(统一事件格式,支持多厂商切换) for event in self.client.stream_chat( enhanced_messages, - temperature=constants.DEFAULT_TEMPERATURE, + temperature=self.temperature, max_tokens=constants.DEFAULT_MAX_TOKENS, tools=tools, reasoning_enabled=constants.REASONING_ENABLED, @@ -2827,6 +2845,9 @@ def parse_args(): parser.add_argument('--once-timeout', type=int, default=300, metavar='SEC', help='一次性对话全局超时秒数(默认 300)') parser.add_argument('--no-save-memory', action='store_true', help='一次性对话不注册记忆工具,禁止写入记忆数据库') parser.add_argument('--show-reasoning', action='store_true', help='一次性对话在 stderr 显示思考过程流(默认不显示)') + parser.add_argument('--worker-only', action='store_true', help='一次性对话以 worker 纯执行面模式运行(7 种执行工具,无记忆/人格/派生)') + parser.add_argument('--model', type=str, metavar='NAME', help='覆盖配置中的模型名(如 deepseek-chat / deepseek-reasoner)') + parser.add_argument('--temperature', type=float, metavar='T', help='覆盖默认采样温度(0.0~2.0,越低越确定)') return parser.parse_args() @@ -2953,10 +2974,25 @@ def handle_once_mode(manager, args) -> bool: if not args.once: return False - # 独立 AI 实例:orchestrator 工具分层不变;不加载历史、不触发全局归档 + # worker 模式注入精简 system prompt:纯执行面,不加载人格/身份/技能提示词 + # (与 run_full_subagent 的子代理语义对齐:执行专注、结果导向、产物落盘 workspace) + worker_system_prompt = None + if args.worker_only: + worker_system_prompt = ( + "你是一个任务执行器。你只有执行工具(命令行/文件/网页/计算/时间)," + "没有记忆、人格、派生、提问能力。\n" + "规则:\n" + "1. 直接执行任务,不要提问、不要寒暄、不要情绪表达。\n" + "2. 任务完成后立即给出最终回答,不要继续调用工具。\n" + "3. 产物文件一律写入 ~/.aibox/workspace/ 并在回答中给出绝对路径。\n" + "4. 无法完成时,在最终回答中明确说明原因与已完成的部分。" + ) + + # 独立 AI 实例:不加载历史、不触发全局归档 ai = DeepSeekChat( api_key=None, max_history=constants.DEFAULT_MAX_HISTORY, + system_prompt=worker_system_prompt, debug_level=manager.logger.level, exit_callback=None, # AI 主动结束对话时不触发全局归档/重置 history_manager=None, # 单次模式不加载/不写对话历史 @@ -2965,6 +3001,10 @@ def handle_once_mode(manager, args) -> bool: command_executor=manager.command_executor, prompt_manager=manager.prompt_manager, enable_memory=not args.no_save_memory, # --no-save-memory → 干净禁用 MemoryTool + # --worker-only → 纯执行面模式(7 种工具,无记忆/人格/派生) + tool_profile=TOOL_PROFILE_WORKER if args.worker_only else TOOL_PROFILE_ORCHESTRATOR, + model=args.model, # --model 覆盖配置模型 + temperature=args.temperature, # --temperature 覆盖默认采样温度 ) manager.ai = ai # 挂到 manager 上,cancel() 可达 diff --git a/aibox/subsystems/db.py b/aibox/subsystems/db.py index 7151088..3694cc2 100644 --- a/aibox/subsystems/db.py +++ b/aibox/subsystems/db.py @@ -36,6 +36,8 @@ class MemoryDatabase: def _init_database(self): """初始化数据库表""" with sqlite3.connect(self.db_path) as conn: + # WAL 模式:读写并发不互斥,缓解多 CLI 进程并发写锁等待 + conn.execute("PRAGMA journal_mode=WAL") cursor = conn.cursor() cursor.execute(''' CREATE TABLE IF NOT EXISTS memories ( diff --git a/aibox/tests/test_once_mode.py b/aibox/tests/test_once_mode.py index 8c6a877..42db512 100644 --- a/aibox/tests/test_once_mode.py +++ b/aibox/tests/test_once_mode.py @@ -58,8 +58,10 @@ class FakeLLMClient: # ==================== 公共工具 ==================== -def _make_ai(events=None, raise_exc=None, enable_memory=True): +def _make_ai(events=None, raise_exc=None, enable_memory=True, + tool_profile=None): """构造一个注入 FakeLLMClient 的 DeepSeekChat,跳过真实网络/文件系统副作用。""" + from aibox import TOOL_PROFILE_ORCHESTRATOR with mock.patch("aibox.create_llm_client") as mk: fake = FakeLLMClient(events=events, raise_exc=raise_exc) mk.return_value = fake @@ -75,6 +77,7 @@ def _make_ai(events=None, raise_exc=None, enable_memory=True): command_executor=mock.MagicMock(), prompt_manager=pm.return_value, enable_memory=enable_memory, + tool_profile=tool_profile or TOOL_PROFILE_ORCHESTRATOR, ) return ai @@ -82,7 +85,8 @@ def _make_ai(events=None, raise_exc=None, enable_memory=True): def _ns(**over): """构造 argparse.Namespace,含 once 模式全部字段默认值。""" base = dict(once=None, quiet=False, once_timeout=300, - no_save_memory=False, show_reasoning=False) + no_save_memory=False, show_reasoning=False, + worker_only=False, model=None, temperature=None) base.update(over) return argparse.Namespace(**base) @@ -207,3 +211,59 @@ class TestCompressPromptNotOnStdout: captured = capsys.readouterr() assert "📦" not in captured.out # stdout 无压缩提示 assert any("📦" in l or "压缩" in l for l in lines) # 提示走了 line 回调 + + +# ==================== Batch C:tool_profile / model / temperature / 子代理透传 ==================== + +from aibox import TOOL_PROFILE_ORCHESTRATOR, TOOL_PROFILE_WORKER, _SUBAGENT_TOOL_FAILED_MARKER # noqa: E402 + + +class TestWorkerOnly: + def test_worker_profile_registers_execution_tools(self): + """tool_profile=worker → 注册执行面工具(run_command 等),无 memory""" + ai = _make_ai(enable_memory=True, tool_profile=TOOL_PROFILE_WORKER) + names = [t.get_name() for t in ai.tool_registry.get_all_tools()] + assert "run_command" in names + assert "read_file" in names + assert "memory" not in names # worker 无记忆工具 + assert "create_subagent" not in names # worker 不能派生 + + def test_orchestrator_profile_registers_memory(self): + """tool_profile=orchestrator → 注册管理面工具(memory/create_subagent)""" + ai = _make_ai(enable_memory=True) + ai.tool_profile = TOOL_PROFILE_ORCHESTRATOR + ai._register_tools() + names = [t.get_name() for t in ai.tool_registry.get_all_tools()] + assert "memory" in names + assert "create_subagent" in names + + +class TestModelTemperatureOverride: + def test_model_override(self): + """model 参数透传到 create_llm_client""" + ai = _make_ai() + assert ai.client.model == "fake-model" # FakeLLMClient.model + # 注:真实链路 create_llm_client(model=...) 已在 _make_ai 中 mock 拦截 + + def test_temperature_override(self): + """temperature 显式传入覆盖 constants 默认""" + ai = _make_ai() + # FakeLLMClient 不关心 temperature,这里验证属性被正确设置 + ai.temperature = 0.1 + assert ai.temperature == 0.1 + + +class TestSubAgentToolFailurePropagation: + def test_subagent_tool_failed_marker_sets_stats(self): + """create_subagent 返回带标记 → orchestrator stats['tool_failed']=True 且标记被剥离""" + ai = _make_ai() + ai.tool_registry.execute_tool = mock.MagicMock( + return_value=_SUBAGENT_TOOL_FAILED_MARKER + "子代理报告正文" + ) + tool_calls = [{"id": "c1", "function": {"name": "create_subagent", "arguments": "{}"}}] + tool_msgs = ai._handle_tool_calls(tool_calls, None) + assert ai.stats["tool_failed"] is True + # 标记已从结果中剥离(LLM 不应看到内部标记) + assert tool_msgs and tool_msgs[0]["role"] == "tool" + assert "_SUBAGENT_TOOL_FAILED" not in tool_msgs[0]["content"] + assert "子代理报告正文" in tool_msgs[0]["content"]