diff --git a/agentboard/agents/__init__.py b/agentboard/agents/__init__.py index c2d144b..7dcffbf 100644 --- a/agentboard/agents/__init__.py +++ b/agentboard/agents/__init__.py @@ -1,11 +1,12 @@ from .vanilla_agent import VanillaAgent from .react_agent import ReactAgent from .custom_react import CustomReactAgent -from common.registry import registry -from .ours_agent import OurAgent -from .cme_final import ContextEfficientAgentV2 - -__all__ = ["VanillaAgent", "ReactAgent", "CustomReactAgent", "OurAgent", "ContextEfficientAgentV2"] +from common.registry import registry +from .ours_agent import OurAgent +from .cme_final import ContextEfficientAgentV2 +from .plugmem_agent import PlugMemContextEfficientAgent + +__all__ = ["VanillaAgent", "ReactAgent", "CustomReactAgent", "OurAgent", "ContextEfficientAgentV2", "PlugMemContextEfficientAgent"] def load_agent(name, config, llm_model): diff --git a/agentboard/agents/plugmem_agent.py b/agentboard/agents/plugmem_agent.py new file mode 100644 index 0000000..114d8df --- /dev/null +++ b/agentboard/agents/plugmem_agent.py @@ -0,0 +1,224 @@ +"""Context-efficient HiAgent variant with PlugMem recall/upload hooks.""" +from __future__ import annotations + +import logging +import os +import re +from contextlib import redirect_stdout +from io import StringIO +from typing import Any, Dict, List, Optional + +from common.registry import registry + +from .cme_final import ContextEfficientAgentV2 +from .plugmem_client import PlugMemClient + + +logger = logging.getLogger(__name__) + + +@registry.register_agent("PlugMemContextEfficientAgent") +class PlugMemContextEfficientAgent(ContextEfficientAgentV2): + def __init__( + self, + llm_model, + memory_size=100, + examples=[], + instruction="", + init_prompt_path=None, + system_message="You are a helpful assistant.", + need_goal=False, + check_actions=None, + check_inventory=None, + use_parser=True, + plugmem: Optional[Dict[str, Any]] = None, + ): + super().__init__( + llm_model, + memory_size, + examples, + instruction, + init_prompt_path, + system_message, + need_goal, + check_actions, + check_inventory, + use_parser, + ) + self.plugmem_config = plugmem or {} + self.plugmem_enabled = bool(self.plugmem_config.get("enabled", False)) + self.plugmem_recall_on_reset = bool(self.plugmem_config.get("recall_on_reset", True)) + self.plugmem_upload_on_finish = bool(self.plugmem_config.get("upload_on_finish", True)) + self.plugmem_session_id = self.plugmem_config.get("session_id") + self.plugmem_recall_mode = self.plugmem_config.get("recall_mode", "reason") + self.plugmem_max_context_chars = int(self.plugmem_config.get("max_context_chars", 700)) + self.plugmem_context = "" + self.plugmem_client = self._build_plugmem_client() + + def _build_plugmem_client(self) -> Optional[PlugMemClient]: + if not self.plugmem_enabled: + return None + base_url = self.plugmem_config.get("base_url") + api_key = self.plugmem_config.get("api_key") + graph_id = self.plugmem_config.get("graph_id") + if not base_url or not api_key or not graph_id: + logger.warning("PlugMem is enabled but base_url/api_key/graph_id is incomplete") + return None + timeout = self.plugmem_config.get("timeout", 10.0) + return PlugMemClient(base_url=base_url, api_key=api_key, graph_id=graph_id, timeout=timeout) + + def reset(self, goal, init_obs, init_act=None): + super().reset(goal, init_obs, init_act) + self.plugmem_context = "" + if not self.plugmem_enabled or not self.plugmem_recall_on_reset or self.plugmem_client is None: + return + try: + raw_context = self.plugmem_client.recall( + observation=init_obs, + goal=goal, + task_type=os.environ.get("EVALTASK", ""), + session_id=self.plugmem_session_id, + mode=self.plugmem_recall_mode, + ) + self.plugmem_context = self._filter_plugmem_context(raw_context) + except Exception as exc: + self.plugmem_context = "" + logger.warning("PlugMem recall failed: %s", exc) + + def make_prompt(self, need_goal=False, check_actions="check valid actions", check_inventory="inventory", system_message=''): + with redirect_stdout(StringIO()): + prompt = super().make_prompt( + need_goal=need_goal, + check_actions=check_actions, + check_inventory=check_inventory, + system_message=system_message, + ) + if self.plugmem_context: + prompt = self._inject_plugmem_context(prompt) + print(f'------------[Prompt Start]-----------\n{prompt}\n----------[Prompt END]------------') + return prompt + + def _inject_plugmem_context(self, prompt: str) -> str: + block = ( + "Past task hints:\n" + "Use these as general strategy hints only.\n" + "Do not copy concrete object names unless they also appear in the current observation.\n\n" + f"{self.plugmem_context}\n\n" + "These hints may help with the current task, but the current observation and goal are authoritative.\n\n" + ) + marker = self._current_history_marker() + if marker: + idx = prompt.find(marker) + if idx >= 0: + return prompt[:idx] + block + prompt[idx:] + return block + prompt + + def _filter_plugmem_context(self, text: str) -> str: + if not text: + return "" + + context = str(text).strip() + final_marker = "### Final Information" + marker_idx = context.find(final_marker) + if marker_idx >= 0: + context = context[marker_idx + len(final_marker):] + + lines = [] + for line in context.splitlines(): + stripped = line.strip() + if not stripped: + lines.append("") + continue + if stripped in {"---", "### Reasoning", "### Final Information"}: + continue + lines.append(stripped) + + context = "\n".join(lines) + context = re.sub(r"\n{3,}", "\n\n", context).strip() + if not context: + return "" + + max_chars = max(0, self.plugmem_max_context_chars) + if max_chars and len(context) > max_chars: + context = context[:max_chars].rstrip() + return context + + def _current_history_marker(self) -> Optional[str]: + history = getattr(self, "memory", [])[-self.memory_size:] + if not history or not history[0]: + return None + key, value = history[0][0] + marker = f"{key}: {value}" + return marker if value is not None else f"{key}: " + + def remember_current_task(self, task_type: str = "") -> Optional[Dict[str, Any]]: + if not self.plugmem_enabled or not self.plugmem_upload_on_finish or self.plugmem_client is None: + return None + + payload = self._memory_to_plugmem_trajectory() + if payload is None: + return None + + try: + return self.plugmem_client.upload_trajectory( + goal=payload["goal"], + initial_observation=payload["initial_observation"], + steps=payload["steps"], + session_id=self.plugmem_session_id, + ) + except Exception as exc: + logger.warning("PlugMem upload failed: %s", exc) + return None + + def _memory_to_plugmem_trajectory(self) -> Optional[Dict[str, Any]]: + observations: List[str] = [] + actions: List[str] = [] + + for turn in getattr(self, "memory", []): + for key, value in turn: + if key == "Observation" and value: + observations.append(value) + elif key == "Action" and value: + actions.append(value) + + if not self.goal or not observations or not actions: + return None + + steps = [ + {"action": actions[i], "observation": observations[i + 1]} + for i in range(min(len(actions), len(observations) - 1)) + ] + if not steps: + return None + + return { + "goal": self.goal, + "initial_observation": observations[0], + "steps": steps, + } + + @classmethod + def from_config(cls, llm_model, config): + memory_size = config.get("memory_size", 100) + instruction = config.get("instruction", "") + examples = config.get("examples", []) + init_prompt_path = config.get("init_prompt_path", None) + system_message = config.get("system_message", "You are a helpful assistant.") + check_actions = config.get("check_actions", None) + check_inventory = config.get("check_inventory", None) + use_parser = config.get("use_parser", True) + need_goal = config.get("need_goal", False) + plugmem = config.get("plugmem", {}) + return cls( + llm_model, + memory_size, + examples, + instruction, + init_prompt_path, + system_message, + need_goal, + check_actions, + check_inventory, + use_parser, + plugmem, + ) diff --git a/agentboard/agents/plugmem_client.py b/agentboard/agents/plugmem_client.py new file mode 100644 index 0000000..ee5a9f9 --- /dev/null +++ b/agentboard/agents/plugmem_client.py @@ -0,0 +1,80 @@ +"""Lightweight HTTP client for the PlugMem API.""" +from __future__ import annotations + +import json +from typing import Any, Dict, List, Optional +from urllib import error, request + + +class PlugMemClient: + def __init__(self, base_url: str, api_key: str, graph_id: str, timeout: float = 10.0): + self.base_url = base_url.rstrip("/") + self.api_key = api_key + self.graph_id = graph_id + self.timeout = timeout + + def recall( + self, + observation: str, + goal: Optional[str], + task_type: str = "", + session_id: Optional[str] = None, + mode: str = "reason", + ) -> str: + payload: Dict[str, Any] = { + "observation": observation, + "goal": goal, + "task_type": task_type, + } + if session_id: + payload["session_id"] = session_id + if mode == "recall_text": + return self._post_text(f"/api/v1/graphs/{self.graph_id}/recall_text", payload) + data = self._post(f"/api/v1/graphs/{self.graph_id}/reason", payload) + return data.get("reasoning", "") + + def upload_trajectory( + self, + goal: str, + initial_observation: str, + steps: List[Dict[str, str]], + session_id: Optional[str] = None, + ) -> Dict[str, Any]: + payload: Dict[str, Any] = { + "mode": "trajectory", + "goal": goal, + "initial_observation": initial_observation, + "steps": steps, + } + if session_id: + payload["session_id"] = session_id + return self._post(f"/api/v1/graphs/{self.graph_id}/memories", payload) + + def _post(self, path: str, payload: Dict[str, Any]) -> Dict[str, Any]: + response_body = self._post_raw(path, payload) + if not response_body: + return {} + return json.loads(response_body) + + def _post_text(self, path: str, payload: Dict[str, Any]) -> str: + return self._post_raw(path, payload).strip() + + def _post_raw(self, path: str, payload: Dict[str, Any]) -> str: + body = json.dumps(payload).encode("utf-8") + req = request.Request( + self.base_url + path, + data=body, + headers={ + "Content-Type": "application/json", + "X-API-Key": self.api_key, + }, + method="POST", + ) + try: + with request.urlopen(req, timeout=self.timeout) as resp: + return resp.read().decode("utf-8") + except error.HTTPError as exc: + response_body = exc.read().decode("utf-8", errors="replace") + raise RuntimeError(f"PlugMem request failed with HTTP {exc.code}: {response_body}") from exc + except error.URLError as exc: + raise RuntimeError(f"PlugMem request failed: {exc}") from exc diff --git a/agentboard/tasks/pddl.py b/agentboard/tasks/pddl.py index 70943dd..8dea8dc 100644 --- a/agentboard/tasks/pddl.py +++ b/agentboard/tasks/pddl.py @@ -59,6 +59,14 @@ def __init__(self, self.agentboard = TaskLogger(task_name="pddl", log_path=log_path, max_num_steps=self.max_num_steps, baseline_dir=self.baseline_dir) + def _remember_current_task(self, game_name): + if not hasattr(self.agent, "remember_current_task"): + return + try: + self.agent.remember_current_task(task_type=game_name) + except Exception as exc: + logger.warning("remember_current_task failed: {}".format(exc)) + def load_seq(self, path): all_seqs = [] with open(path, 'r') as f: @@ -169,6 +177,7 @@ def evaluate_env(self, id): try: example_prompt = self.agent.get_example_prompt() except: example_prompt = None self.agentboard.log_example(id, env.won, progress_rate, grounding_acc_count / (step_id + 1), score_change_record, env_details, trajectory, example_prompt) + self._remember_current_task(game_name) return env.won, progress_rate, step_id + 1, grounding_acc_count / (step_id + 1), score_change_record @@ -182,6 +191,7 @@ def evaluate_env(self, id): progress_rate = reward self.agentboard.log_example(id, False, progress_rate, grounding_acc_count / (step_id + 1), score_change_record, env_details, trajectory, example_prompt) + self._remember_current_task(game_name) return False, progress_rate, step_id + 1, grounding_acc_count / (step_id + 1), score_change_record @@ -261,4 +271,4 @@ def from_config(cls, Num_Problems = { "barman":20, "blockworld":10,"gripper":20, "tyreworld":10, "blocks_medium": 10 -} \ No newline at end of file +} diff --git a/docs/hiagent_plugmem_integration_execution_plan.md b/docs/hiagent_plugmem_integration_execution_plan.md new file mode 100644 index 0000000..cc34a34 --- /dev/null +++ b/docs/hiagent_plugmem_integration_execution_plan.md @@ -0,0 +1,473 @@ +# HiAgent 与 PlugMem 集成分阶段执行计划 + +## 总体目标 + +基于以下两份设计文档,分阶段实现 HiAgent 与 PlugMem 的跨任务记忆集成: + +- `docs/plugmem_cross_task_memory_flow.md` +- `docs/plugmem_trajectory_api_initial_observation.md` + +总体技术目标不变: + +1. PlugMem trajectory API 支持 `initial_observation`。 +2. HiAgent 启动任务时从 PlugMem recall 跨任务经验,并注入 prompt。 +3. HiAgent 任务结束后把 trajectory 上传到 PlugMem。 +4. recall/upload 失败不能中断 HiAgent 评测流程。 + +## Codex 执行原则 + +- 每次只执行一个阶段。 +- 不要一次性完成全部集成。 +- 阶段测试通过并 commit 后,再进入下一阶段。 +- 不做无关重构。 +- 不格式化整个文件。 +- 不修改当前阶段未列出的文件,除非先说明原因。 +- 每阶段结束后输出:修改文件、diff 摘要、测试命令、风险点和未完成项。 + +## 项目拆分与执行位置 + +本计划分成两个独立项目执行: + +- PlugMem 项目:只执行阶段 1,在 PlugMem 独立仓库根目录操作和提交。 +- HiAgent 项目:执行阶段 2、阶段 3、阶段 4,在 HiAgent 仓库根目录操作和提交。 + +不要在 HiAgent 仓库中提交复制版 `PlugMem/` 目录。阶段 2 之后的 HiAgent 联调依赖阶段 1 已经在 PlugMem 独立项目中完成并可启动服务。 + +## PlugMem 项目:阶段 1 API 修复与测试 + +执行位置:PlugMem 独立仓库根目录。 + +### 阶段目标 + +修复 PlugMem HTTP trajectory API 的初始 observation 语义,使其与 PlugMem 原生逻辑一致: + +```python +Memory(goal=goal, observation=obs_0) +append(action_t0=action_1, observation_t1=obs_1) +append(action_t0=action_2, observation_t1=obs_2) +``` + +同时保持旧 payload 兼容。 + +### 允许修改的文件 + +- `plugmem/api/schemas.py` +- `plugmem/api/routes/memories.py` +- `tests/test_api_memories.py` + +### 禁止修改的内容 + +- 不修改 HiAgent 侧任何文件。 +- 不修改 PlugMem retrieval/reasoning 逻辑。 +- 不修改 Chroma storage、MemoryGraph、Memory 的核心结构化逻辑。 +- 不引入新依赖。 +- 不改变旧 trajectory payload 的兼容行为。 + +### 核心实现要求 + +在 `MemoryInsertRequest` 中新增可选字段: + +```python +initial_observation: Optional[str] = None +``` + +在 `_insert_trajectory()` 中使用: + +```python +initial_observation = body.initial_observation or body.steps[0].observation +``` + +并用它构造 `Memory`: + +```python +mem = Memory( + goal=body.goal, + observation=initial_observation, + llm=llm, + embedder=embedder, + time=graph.semantic_time, + session_id=body.session_id, +) +``` + +`steps` 的空值校验保持不变。即使提供 `initial_observation`,没有 `steps` 也应该返回错误。 + +测试至少覆盖: + +- 新 payload 带 `initial_observation` 时可正常写入。 +- 旧 payload 不带 `initial_observation` 时仍可正常写入。 +- 空 `steps` 仍然失败。 + +### 验收标准 + +- `MemoryInsertRequest` 支持 `initial_observation`。 +- 新旧 payload 都能通过 `/graphs/{graph_id}/memories` 写入。 +- 新测试能证明新字段没有破坏旧格式。 +- PlugMem memories API 相关测试通过。 + +### 建议测试命令 + +```powershell +pytest tests/test_api_memories.py +``` + +如环境无法运行 pytest,需要在阶段结束说明阻塞原因和未验证风险。 + +### 建议 commit message + +```text +fix(plugmem): support initial observation in trajectory API +``` + +## HiAgent 项目:阶段 2 PlugMem 接入 + +执行位置:HiAgent 仓库根目录。 + +前置条件:阶段 1 已在 PlugMem 独立项目中完成,PlugMem 服务端支持 `initial_observation`。 + +### 阶段目标 + +在 HiAgent agent 层接入 PlugMem recall 和 upload 能力,但暂不修改 PDDL 任务循环和 yaml 配置。 + +本阶段只完成 agent 能力: + +- 新增 `PlugMemClient` +- 新增 `PlugMemContextEfficientAgent` +- `reset()` 时 recall +- `make_prompt()` 时注入 `Past task hints` +- `remember_current_task()` 时整理 trajectory payload +- 注册新 agent + +### 允许修改的文件 + +- `agentboard/agents/plugmem_client.py` +- `agentboard/agents/plugmem_agent.py` +- `agentboard/agents/__init__.py` + +如确实需要读取公共工具或 logger,可说明原因后最小修改相关文件。 + +### 禁止修改的内容 + +- 不修改 `agentboard/tasks/pddl.py`。 +- 不修改任何 `eval_configs/` 配置文件。 +- 不修改 PlugMem 侧文件。 +- 不改变 `ContextEfficientAgentV2` 原有行为。 +- 不改变现有 agent 的注册名和默认加载方式。 + +### 核心实现要求 + +新增轻量 HTTP client: + +- `recall(observation, goal, task_type="", session_id=None)` + - 调用 `POST /api/v1/graphs/{graph_id}/reason` + - 返回响应中的 `reasoning` + +- `upload_trajectory(goal, initial_observation, steps, session_id=None)` + - 调用 `POST /api/v1/graphs/{graph_id}/memories` + - payload 使用 `initial_observation` + +新增 `PlugMemContextEfficientAgent`,继承 `ContextEfficientAgentV2`。 + +`reset()` 要求: + +- 先调用父类 `reset()`。 +- 如果 `plugmem.enabled` 为 true 且 `recall_on_reset` 为 true,则调用 recall。 +- recall 成功后保存到 `self.plugmem_context`。 +- recall 失败只记录 warning,并把 `self.plugmem_context` 置空。 + +`make_prompt()` 要求: + +- 保留原始 HiAgent prompt 结构。 +- 在当前任务 history 前注入: + +```text +Past task hints: +{plugmem_context} + +These hints come from previous tasks and may help with the current task. +Use relevant strategies and patterns when choosing the next Subgoal or Action. +``` + +- 如果 `plugmem_context` 为空,不插入该区域。 + +`remember_current_task()` 要求: + +- 方法内部负责判断 PlugMem 上传开关,而不是让 PDDL 任务层判断。 +- 以下情况应直接 no-op 返回,不发起 `/memories` 请求: + - `plugmem.enabled=false` + - `plugmem.upload_on_finish=false` + - PlugMem client 未初始化 + - 当前 trajectory 为空或无法构造有效 payload +- 从 `self.memory` 提取 observations 和 actions。 +- 生成: + +```python +initial_observation = observations[0] +steps = [ + {"action": actions[i], "observation": observations[i + 1]} + for i in range(min(len(actions), len(observations) - 1)) +] +``` + +- 不上传 HiAgent 自身生成的 subgoal。 +- 不使用 synthetic no-op step。 +- upload 失败只记录 warning,不抛出到评测流程。 + +注册新 agent: + +```python +from .plugmem_agent import PlugMemContextEfficientAgent +``` + +确保 `--agent PlugMemContextEfficientAgent` 可以被 registry 加载。 + +### 验收标准 + +- 新 agent 可以通过 registry 加载。 +- `plugmem.enabled=false` 或缺失 `plugmem` 配置时,新 agent 行为应接近原 `ContextEfficientAgentV2`。 +- `reset()` recall 失败不会抛出异常。 +- `make_prompt()` 在有 `plugmem_context` 时包含 `Past task hints:`。 +- `remember_current_task()` 能从 HiAgent `self.memory` 构造新格式 payload。 + +### 建议测试命令 + +优先做轻量导入和构造检查: + +```powershell +python - <<'PY' +import sys +sys.path.insert(0, "agentboard") +from agents import load_agent +from common.registry import registry +assert registry.get_agent_class("PlugMemContextEfficientAgent") is not None +print("PlugMemContextEfficientAgent registered") +PY +``` + +如果 PowerShell 不支持 heredoc,可改用等价的 `python -c`。 + +### 建议 commit message + +```text +feat(hiagent): add plugmem-aware context agent +``` + +## HiAgent 项目:阶段 3 PDDL 任务上传与配置 + +执行位置:HiAgent 仓库根目录。 + +### 阶段目标 + +把阶段 2 的 `remember_current_task()` 接入 PDDL 任务结束路径,并补充可关闭的 PlugMem 配置。 + +默认配置必须是 `enabled=false`,避免无意中改变 baseline 行为。 + +### 允许修改的文件 + +- `agentboard/tasks/pddl.py` +- `eval_configs/hiagent/blocksworld.yaml` +- 如需要,也可同步修改: + - `eval_configs/hiagent/barman.yaml` + - `eval_configs/hiagent/gripper.yaml` + - `eval_configs/hiagent/tyreworld.yaml` + +### 禁止修改的内容 + +- 不修改 PlugMem 侧文件。 +- 不修改 agent prompt 文本。 +- 不改动 PDDL 环境逻辑。 +- 不改变 `log_example()` 的字段结构。 +- 不改变 success/progress/grounding accuracy 的计算方式。 + +### 核心实现要求 + +在 `EvalPddl.evaluate_env()` 的结束路径中调用: + +```python +if hasattr(self.agent, "remember_current_task"): + self.agent.remember_current_task(task_type=game_name) +``` + +PDDL 侧只负责弱耦合触发任务结束回调,不直接读取 `plugmem.upload_on_finish`。 +`upload_on_finish` 的判断必须由 `remember_current_task()` 内部完成。 + +需要覆盖: + +- 任务成功 `done` 后返回前 +- 达到最大步数或失败返回前 + +调用位置要保证: + +- 不影响 `self.agentboard.log_example(...)` +- 不影响原本 return 的 success/progress/steps/grounding accuracy +- upload 失败不能中断评测 + +配置中补充: + +```yaml +plugmem: + enabled: False + base_url: http://localhost:8080 + api_key: dev-key-change-me + graph_id: hiagent-cross-task + recall_on_reset: True + upload_on_finish: True +``` + +注意:默认 `enabled: False`。 + +### 验收标准 + +- 使用普通 agent 或 `plugmem.enabled=false` 时,PDDL 评测流程不依赖 PlugMem。 +- 使用 `PlugMemContextEfficientAgent` 且 `plugmem.enabled=false` 时,不发起 recall,也不 upload。 +- 使用 `PlugMemContextEfficientAgent` 且 `plugmem.enabled=true`、`upload_on_finish=false` 时,可以 recall,但任务结束不 upload。 +- 使用 `PlugMemContextEfficientAgent` 且 `plugmem.enabled=true`、`upload_on_finish=true` 时,任务结束会尝试 upload。 +- PDDL 侧不直接读取 `plugmem.upload_on_finish`。 +- upload 失败不影响原评测返回。 + +### 建议测试命令 + +先跑禁用 PlugMem 的最小 smoke test: + +```powershell +$env:EVALTASK="blocksworld" +python agentboard/eval_main.py ` + --cfg-path eval_configs/hiagent/blocksworld.yaml ` + --tasks pddl ` + --model qwen3_5_4b_vllm_server ` + --agent PlugMemContextEfficientAgent ` + --max_num_steps 2 ` + --memory_size 100 ` + --log_path ./logs/hiagent/smoke_plugmem_disabled +``` + +如本地 LLM 服务不可用,可只运行导入测试,并说明未完成端到端验证。 + +### 建议 commit message + +```text +feat(hiagent): upload pddl trajectories to plugmem +``` + +## HiAgent 项目:阶段 4 Smoke test 与最小 ablation + +执行位置:HiAgent 仓库根目录。 + +前置条件:启动的 PlugMem 服务必须来自阶段 1 修复后的 PlugMem 项目版本。 + +### 阶段目标 + +验证完整链路: + +- PlugMem 未启动时可降级运行。 +- PlugMem 启动后可以 recall 和 upload。 +- prompt 中出现 `Past task hints`。 +- PlugMem stats 节点数量增加。 +- 建立最小 ablation 运行方案。 + +### 允许修改的文件 + +- 默认不修改代码文件。 +- 如需要记录 smoke test 和 ablation 命令,可新增: + - `docs/hiagent_plugmem_smoke_test_notes.md` + +如果 smoke test 暴露出前面阶段的 bug,应回到对应阶段做最小修复,并在阶段总结里说明原因。 + +### 禁止修改的内容 + +- 不做新功能。 +- 不修改 prompt 文本,除非 smoke test 证明它无法工作。 +- 不修改 PlugMem API 语义。 +- 不修改 `docs/hiagent_plugmem_integration_execution_plan.md`。 +- 不扩大到非 PDDL 任务。 +- 不做大规模实验脚本重构。 + +### 核心实现要求 + +执行以下 smoke test: + +1. PlugMem 未启动时运行 HiAgent。 + - 预期 recall/upload 失败只记录 warning。 + - HiAgent 评测流程继续。 + +2. PlugMem 启动后创建 graph: + +```text +hiagent-cross-task +``` + +3. 运行小规模 PDDL 任务。 + - 检查 prompt 日志里有 `Past task hints:`。 + - 检查任务结束后调用 `/memories`。 + - 检查 `/api/v1/graphs/{graph_id}/stats` 中节点数量增加。 + +最小 ablation: + +- baseline:原 `ContextEfficientAgentV2`。 +- agent 但禁用 PlugMem:`PlugMemContextEfficientAgent` + `plugmem.enabled=false`。 +- recall only:`plugmem.enabled=true` + `recall_on_reset=true` + `upload_on_finish=false`。 +- recall+upload:`plugmem.enabled=true` + `recall_on_reset=true` + `upload_on_finish=true`。 + +### 验收标准 + +- 降级场景不崩溃。 +- PlugMem 启动后能完成 recall 和 upload。 +- prompt 中能看到 `Past task hints:`。 +- PlugMem graph stats 在 upload 后增加。 +- 四种 ablation 的运行命令可复现并记录。 + +### 建议测试命令 + +PlugMem 健康检查: + +```powershell +curl http://localhost:8080/api/v1/health +``` + +创建 graph: + +```powershell +curl -X POST http://localhost:8080/api/v1/graphs ` + -H "X-API-Key: dev-key-change-me" ` + -H "Content-Type: application/json" ` + -d "{\"graph_id\":\"hiagent-cross-task\"}" +``` + +检查 stats: + +```powershell +curl http://localhost:8080/api/v1/graphs/hiagent-cross-task/stats ` + -H "X-API-Key: dev-key-change-me" +``` + +HiAgent smoke test 命令根据本地可用模型调整。 + +### 建议 commit message + +```text +test(hiagent): document plugmem smoke tests and ablations +``` + +## 给 Codex 的阶段执行模板 + +后续执行时,可以复制下面模板,并把 `<阶段编号>` 替换为目标阶段。 + +```text +请只执行 `docs/hiagent_plugmem_integration_execution_plan.md` 中的阶段 <阶段编号>。 + +执行要求: +- 只修改该阶段“允许修改的文件”。 +- 不修改该阶段“禁止修改的内容”。 +- 不做无关重构。 +- 不格式化整个文件。 +- 如果必须修改阶段外文件,先说明原因并等待确认。 +- 完成后运行该阶段建议测试命令;如果无法运行,说明原因。 +- 测试通过后给出建议 commit message,但不要自动 commit,除非我明确要求。 + +最终输出: +- 修改文件列表 +- diff 摘要 +- 实际运行的测试命令和结果 +- 风险点 +- 未完成项 +``` diff --git a/docs/plugmem_cross_task_memory_flow.md b/docs/plugmem_cross_task_memory_flow.md new file mode 100644 index 0000000..1f02599 --- /dev/null +++ b/docs/plugmem_cross_task_memory_flow.md @@ -0,0 +1,589 @@ +# PlugMem 插件版为 HiAgent 提供跨任务记忆的全流程 + +本文说明如何复用 PlugMem 插件版背后的服务能力,为 HiAgent 提供跨任务长期记忆。这里的关键点是:PlugMem 的 OpenClaw 插件不能直接安装到 HiAgent,但插件使用的 PlugMem HTTP 服务可以被 HiAgent 的 Python agent 调用。 + +## 1. 总体输入输出关系 + +完整链路如下: + +```text +HiAgent 当前任务输入 + ├─ goal + ├─ init observation + ├─ step observation + └─ action-observation trajectory + │ + ▼ +Python PlugMem bridge + ├─ recall: 用当前 goal/observation 查询历史记忆 + └─ remember/upload: 将当前任务轨迹上传到 PlugMem + │ + ▼ +PlugMem 服务 + ├─ LLM: 结构化轨迹、推理召回结果 + ├─ Embedding: 建立向量索引 + └─ Chroma: 持久化 graph 记忆 + │ + ▼ +HiAgent prompt 增强 + ├─ 当前任务原始上下文 + ├─ 当前任务短期 working memory + └─ PlugMem 跨任务 recall 结果 + │ + ▼ +HiAgent 输出 action +``` + +跨任务记忆的核心输入是每个任务执行后的轨迹,核心输出是下一个任务开始或执行中可召回的经验。 + +## 2. 构建 PlugMem 服务 + +### 输入 + +需要准备: + +- Python 3.10+ +- `uv` +- OpenAI-compatible LLM endpoint +- OpenAI-compatible embedding endpoint,或直接使用 `OPENAI_API_KEY` 走 OpenAI embedding fallback +- 一个 PlugMem 服务 API key +- 一个 Chroma 持久化目录 + +建议在 `PlugMem` 目录下配置环境变量: + +```powershell +cd PlugMem + +$env:LLM_BASE_URL="https://api.openai.com/v1" +$env:LLM_API_KEY="你的 LLM API key" +$env:LLM_MODEL="gpt-4o-mini" + +# 如果没有单独 embedding 服务,可以使用 OpenAI fallback +$env:OPENAI_API_KEY="你的 OpenAI API key" + +$env:CHROMA_MODE="persistent" +$env:CHROMA_PATH="./data/chroma" + +$env:PLUGMEM_API_KEY="dev-key-change-me" +``` + +### 处理 + +安装 PlugMem 依赖: + +```powershell +uv sync +``` + +启动服务: + +```powershell +uv run uvicorn plugmem.api.app:app --host 0.0.0.0 --port 8080 +``` + +### 输出 + +服务启动后应暴露: + +- `http://localhost:8080/api/v1/health` +- `http://localhost:8080/api/v1/graphs` +- `http://localhost:8080/api/v1/graphs/{graph_id}/memories` +- `http://localhost:8080/api/v1/graphs/{graph_id}/reason` +- `http://localhost:8080/inspector/` + +健康检查: + +```powershell +curl http://localhost:8080/api/v1/health +``` + +期望输出中: + +```json +{ + "status": "ok", + "llm_available": true, + "embedding_available": true, + "chroma_available": true +} +``` + +如果状态是 `degraded`,说明 LLM、embedding 或 Chroma 至少有一个不可用。 + +## 3. 创建跨任务记忆 graph + +### 输入 + +需要一个跨任务共享的 graph id。建议: + +```text +hiagent-cross-task +``` + +这个 graph 是 PlugMem 中的记忆命名空间。所有任务都可以写入它,也可以从它召回经验。 + +### 处理 + +创建 graph: + +```powershell +curl -X POST http://localhost:8080/api/v1/graphs ` + -H "X-API-Key: dev-key-change-me" ` + -H "Content-Type: application/json" ` + -d "{\"graph_id\":\"hiagent-cross-task\"}" +``` + +### 输出 + +期望输出: + +```json +{ + "graph_id": "hiagent-cross-task", + "stats": {} +} +``` + +后续所有跨任务 remember 和 recall 都围绕这个 graph 进行。 + +## 4. 构建 HiAgent 到 PlugMem 的 Python 桥接层 + +### 输入 + +HiAgent agent 在运行时已经有这些信息: + +- `self.goal` +- `self.init_obs` +- `self.memory` +- 当前 action +- 当前 observation/state + +PlugMem API 需要的主要输入是: + +召回输入: + +```json +{ + "observation": "当前观察或问题", + "goal": "当前任务目标", + "task_type": "任务类型,例如 blockworld" +} +``` + +上传输入: + +```json +{ + "mode": "trajectory", + "goal": "当前任务目标", + "steps": [ + { + "observation": "某一步观察", + "action": "该步执行动作" + } + ], + "session_id": "可选,用于区分运行实例" +} +``` + +### 处理 + +建议新增一个 Python client,例如: + +```python +import requests + + +class PlugMemClient: + def __init__(self, base_url, api_key, graph_id): + self.base_url = base_url.rstrip("/") + self.graph_id = graph_id + self.headers = { + "X-API-Key": api_key, + "Content-Type": "application/json", + } + + def recall(self, observation, goal=None, task_type=""): + response = requests.post( + f"{self.base_url}/api/v1/graphs/{self.graph_id}/reason", + headers=self.headers, + json={ + "observation": observation, + "goal": goal, + "task_type": task_type, + }, + timeout=30, + ) + response.raise_for_status() + return response.json()["reasoning"] + + def upload_trajectory(self, goal, steps, session_id=None): + response = requests.post( + f"{self.base_url}/api/v1/graphs/{self.graph_id}/memories", + headers=self.headers, + json={ + "mode": "trajectory", + "goal": goal, + "steps": steps, + "session_id": session_id, + }, + timeout=60, + ) + response.raise_for_status() + return response.json() +``` + +### 输出 + +桥接层输出两类结果: + +- `recall()` 输出一段自然语言记忆推理结果,用于加入 HiAgent prompt。 +- `upload_trajectory()` 输出 PlugMem 写入结果和 graph 统计信息。 + +## 5. 构建 PlugMem 版 HiAgent agent + +### 输入 + +建议以现有 `ContextEfficientAgentV2` 为基础,因为它已经实现: + +- 任务内 working memory +- subgoal/action 机制 +- prompt 构建 +- action 解析 + +新增 agent 可以命名为: + +```text +PlugMemContextEfficientAgent +``` + +配置输入建议包括: + +```yaml +agent: + name: PlugMemContextEfficientAgent + memory_size: 100 + need_goal: True + use_parser: True + plugmem: + enabled: True + base_url: http://localhost:8080 + api_key: dev-key-change-me + graph_id: hiagent-cross-task + recall_on_reset: True + upload_on_finish: True +``` + +### 处理 + +在新 agent 中做三件事: + +1. `reset(goal, init_obs)`: + - 接收当前任务 goal 和初始 observation。 + - 调用 PlugMem recall。 + - 将召回结果保存在 `self.plugmem_context`。 + +2. `make_prompt(...)`: + - 保留原始 HiAgent prompt。 + - 将 `self.plugmem_context` 拼接到 prompt 中。 + - 明确告诉 LLM:这部分是跨任务历史经验,只能作为参考。 + +3. `remember_current_task(...)`: + - 将 `self.memory` 转换成 PlugMem trajectory steps。 + - 调用 PlugMem `/memories` 上传。 + +### 输出 + +新 agent 输出: + +- 与原 agent 一样的 `(success, action)`。 +- 额外产生 PlugMem 上传结果。 +- 后续任务可以从 PlugMem graph 中召回这次任务经验。 + +## 6. 运行 HiAgent + +### 输入 + +运行前需要确认: + +- PlugMem 服务已经运行在 `http://localhost:8080` +- `hiagent-cross-task` graph 已经创建 +- HiAgent 的 `.env` 或 shell 环境中有 `PROJECT_PATH` +- 目标配置文件存在,例如 `eval_configs/hiagent/blocksworld.yaml` +- 新 agent 已经注册到 `agentboard/agents/__init__.py` + +### 处理 + +运行示例: + +```powershell +python agentboard/eval_main.py ` + --cfg-path eval_configs/hiagent/blocksworld.yaml ` + --tasks pddl ` + --model gpt-4-turbo ` + --agent PlugMemContextEfficientAgent ` + --memory_size 100 ` + --max_num_steps 50 ` + --log_path ./logs/hiagent/plugmem_cross_task ` + --project_name none ` + --baseline_dir ./data/baseline_results +``` + +### 输出 + +HiAgent 输出: + +- 每一步 action +- 每一步 observation +- 成功率、进度率、grounding accuracy 等评估结果 +- 日志目录中的 trajectory 和 prompt 记录 + +PlugMem 输出: + +- 新增 episodic memory +- 新增 semantic memory +- 新增 procedural memory +- 可在 `/inspector/` 查看 graph、节点和 session + +## 7. 上传当前任务经验到 PlugMem + +这里的“上传”指把 HiAgent 的任务执行轨迹写入 PlugMem 服务。 + +### 输入 + +HiAgent 原始 memory 结构通常类似: + +```python +[ + [("Observation", init_obs)], + [("Action", action_1), ("Observation", obs_1)], + [("Action", action_2), ("Observation", obs_2)], +] +``` + +需要转换为 PlugMem trajectory: + +```python +[ + { + "observation": init_obs, + "action": action_1, + }, + { + "observation": obs_1, + "action": action_2, + } +] +``` + +### 处理 + +在每个环境结束时调用: + +```python +if hasattr(self.agent, "remember_current_task"): + self.agent.remember_current_task(task_type=game_name) +``` + +PDDL 中适合放在 `evaluate_env()` 的成功返回前和失败返回前,确保无论任务成功或失败都能上传经验。 + +### 输出 + +PlugMem 会返回: + +```json +{ + "status": "ok", + "stats": { + "episodic": 10, + "semantic": 5, + "procedural": 3 + } +} +``` + +这些数量代表当前 graph 中已持久化的记忆节点。 + +## 8. 召回跨任务记忆并注入 prompt + +### 输入 + +下一次任务开始时,使用当前任务上下文作为查询: + +```json +{ + "observation": "当前任务初始状态", + "goal": "当前任务目标", + "task_type": "blockworld" +} +``` + +### 处理 + +调用: + +```text +POST /api/v1/graphs/hiagent-cross-task/reason +``` + +PlugMem 会: + +1. 根据 observation/goal 选择记忆类型。 +2. 从 Chroma 中检索相关节点。 +3. 用 LLM 对检索结果做推理总结。 +4. 返回可直接放入 prompt 的文本。 + +### 输出 + +返回值示例: + +```text +Past relevant memory: +- Similar blockworld tasks often require clearing the target block first. +- Invalid move attempts should be followed by checking valid actions. +- When stacking blocks, preserve already satisfied tower order. +``` + +建议注入 prompt 时使用单独区域: + +```text +Past task hints: +{plugmem_context} + +These hints come from previous tasks and may help with the current task. +Use relevant strategies and patterns when choosing the next Subgoal or Action. +``` + +## 9. 多任务共享策略 + +最简单策略: + +```text +所有任务写入 hiagent-cross-task +所有任务从 hiagent-cross-task 召回 +``` + +输入输出关系: + +```text +blockworld trajectory ─┐ +gripper trajectory ├─ upload ─▶ hiagent-cross-task ─▶ recall ─▶ tyreworld prompt +tyreworld trajectory ┘ +``` + +更精细策略: + +```text +hiagent-cross-task 保存通用经验 +hiagent-blockworld 保存 blockworld 私有经验 +hiagent-gripper 保存 gripper 私有经验 +hiagent-tyreworld 保存 tyreworld 私有经验 +``` + +召回时同时查询: + +```text +当前任务私有 graph + hiagent-cross-task +``` + +这样可以避免不同任务之间的 procedural memory 互相污染,同时保留通用语义经验。 + +## 10. 检查和验证 + +### 检查服务 + +```powershell +curl http://localhost:8080/api/v1/health +``` + +### 检查 graph + +```powershell +curl http://localhost:8080/api/v1/graphs/hiagent-cross-task/stats ` + -H "X-API-Key: dev-key-change-me" +``` + +### 检查记忆节点 + +```powershell +curl "http://localhost:8080/api/v1/graphs/hiagent-cross-task/nodes?node_type=semantic&limit=10" ` + -H "X-API-Key: dev-key-change-me" +``` + +### 使用 Inspector + +浏览器打开: + +```text +http://localhost:8080/inspector/ +``` + +可以查看: + +- graph 拓扑 +- semantic memory +- procedural memory +- episodic memory +- session timeline +- recall audit + +## 11. 最终数据流总结 + +### 第一次任务 + +```text +输入: + 当前 task goal + init observation + +处理: + PlugMem graph 为空,recall 结果为空或很少 + HiAgent 正常执行任务 + 任务结束后上传 trajectory + +输出: + HiAgent 评估日志 + PlugMem graph 中新增长期记忆 +``` + +### 后续任务 + +```text +输入: + 新 task goal + init observation + PlugMem graph 中已有历史任务记忆 + +处理: + recall 相关历史经验 + 将 recall 结果加入 prompt + HiAgent 基于当前 observation + working memory + cross-task memory 生成 action + 任务结束后继续上传新 trajectory + +输出: + 更丰富的 prompt + 新 action + 更新后的 PlugMem graph +``` + +## 12. 实施边界 + +已有代码已经提供: + +- HiAgent agent registry +- `ContextEfficientAgentV2` +- 任务运行与日志记录 +- PlugMem FastAPI 服务 +- PlugMem graph、memory、retrieval API +- OpenClaw 插件中的 remember/recall 设计参考 + +仍需新增: + +- HiAgent 侧 Python PlugMem client +- `PlugMemContextEfficientAgent` +- 任务结束时的 `remember_current_task()` 调用 +- 配置项读取与错误降级逻辑 + +建议错误降级: + +- PlugMem 服务不可用时,HiAgent 继续按原始 agent 运行。 +- recall 失败时,不注入跨任务记忆。 +- upload 失败时,只记录 warning,不中断评估。 diff --git a/docs/plugmem_trajectory_api_initial_observation.md b/docs/plugmem_trajectory_api_initial_observation.md new file mode 100644 index 0000000..b068dec --- /dev/null +++ b/docs/plugmem_trajectory_api_initial_observation.md @@ -0,0 +1,189 @@ +# PlugMem trajectory API initial_observation 修复方案 + +## 背景 + +PlugMem 原生 `Memory` 的正确使用方式是显式传入初始观察,然后逐步追加动作后的观察: + +```python +memory = Memory(goal=goal, observation=obs_0) +memory.append(action_t0=action_1, observation_t1=obs_1) +memory.append(action_t0=action_2, observation_t1=obs_2) +memory.close() +graph.insert(memory) +``` + +语义是: + +```text +obs_0 --action_1--> obs_1 +obs_1 --action_2--> obs_2 +``` + +`Memory.append()` 内部会使用当前 `self.observation_t0` 和 `action_t0` 抽取 subgoal/reward/state,然后把 `self.observation_t0` 更新为 `observation_t1`。 + +## 当前 HTTP trajectory API 的问题 + +当前 `PlugMem/plugmem/api/routes/memories.py` 的 trajectory mode 使用: + +```python +mem = Memory( + goal=body.goal, + observation=body.steps[0].observation, + llm=llm, + embedder=embedder, + time=graph.semantic_time, + session_id=body.session_id, +) +for step in body.steps: + mem.append(action_t0=step.action, observation_t1=step.observation) +mem.close() +graph.insert(mem) +``` + +因为 request schema 只有 `steps[].observation` 和 `steps[].action`,没有单独的初始观察字段,所以 `steps[0].observation` 会同时被当作: + +- 初始 observation +- 第一步 action 执行后的 observation + +这会让第一步状态转移变成: + +```text +obs_1 --action_1--> obs_1 +``` + +而不是期望的: + +```text +obs_0 --action_1--> obs_1 +``` + +## 推荐改动 + +在 `PlugMem/plugmem/api/schemas.py` 的 `MemoryInsertRequest` 中新增可选字段: + +```python +initial_observation: Optional[str] = None +``` + +然后在 `PlugMem/plugmem/api/routes/memories.py` 的 `_insert_trajectory()` 中优先使用该字段: + +```python +initial_observation = body.initial_observation or body.steps[0].observation + +mem = Memory( + goal=body.goal, + observation=initial_observation, + llm=llm, + embedder=embedder, + time=graph.semantic_time, + session_id=body.session_id, +) +for step in body.steps: + mem.append(action_t0=step.action, observation_t1=step.observation) +mem.close() +graph.insert(mem) +``` + +这样旧请求仍然兼容;新请求可以严格对齐 PlugMem 原生 `Memory(initial_obs) + append(action, next_obs)` 逻辑。 + +## 新 payload 格式 + +HiAgent 上传 trajectory 时应使用: + +```json +{ + "mode": "trajectory", + "goal": "current task goal", + "initial_observation": "obs_0", + "steps": [ + { + "action": "action_1", + "observation": "obs_1" + }, + { + "action": "action_2", + "observation": "obs_2" + } + ], + "session_id": "optional-run-id" +} +``` + +服务端解释为: + +```text +Memory(goal, observation=obs_0) +append(action_1, obs_1) +append(action_2, obs_2) +``` + +这与 PlugMem 原始逻辑一致。 + +## HiAgent 上传侧转换 + +HiAgent 的 `self.memory` 通常类似: + +```python +[ + [("Observation", obs_0)], + [("Action", action_1), ("Observation", obs_1)], + [("Action", action_2), ("Observation", obs_2)], +] +``` + +转换时应拆成 `initial_observation` 和 `steps`: + +```python +def memory_to_plugmem_payload(goal, memory, session_id=None): + observations = [] + actions = [] + + for turn in memory: + for key, value in turn: + if key == "Observation" and value: + observations.append(value) + elif key == "Action" and value: + actions.append(value) + + if not observations or not actions: + return None + + steps = [ + { + "action": actions[i], + "observation": observations[i + 1], + } + for i in range(min(len(actions), len(observations) - 1)) + ] + + if not steps: + return None + + return { + "mode": "trajectory", + "goal": goal, + "initial_observation": observations[0], + "steps": steps, + "session_id": session_id, + } +``` + +## 兼容策略 + +- `initial_observation` 是可选字段,旧 payload 不传时仍 fallback 到 `body.steps[0].observation`。 +- 新的 HiAgent 集成应使用 `initial_observation`,不要再使用 synthetic no-op step。 +- 不要同时使用 `initial_observation` 和 no-op step;二者是替代方案,叠加会产生一条多余的假轨迹。 + +## 风险 + +改动范围较小,主要影响: + +- `PlugMem/plugmem/api/schemas.py` +- `PlugMem/plugmem/api/routes/memories.py` +- `PlugMem/tests/test_api_memories.py` + +潜在风险: + +- 旧调用方如果已经用 no-op step 绕过问题,升级后不应再传 `initial_observation`,否则会保留多余 no-op。 +- 空 `steps` 仍不应允许;即使提供 `initial_observation`,没有 action 也无法形成可结构化 trajectory。 +- 旧格式请求仍是旧语义;严格对齐只对新格式请求成立。 diff --git a/docs/split_subgoal_action_agent_plan.md b/docs/split_subgoal_action_agent_plan.md new file mode 100644 index 0000000..9124591 --- /dev/null +++ b/docs/split_subgoal_action_agent_plan.md @@ -0,0 +1,273 @@ +# 拆分 Subgoal-Action Agent 计划 + +## 目标 + +实现一个新的 HiAgent 变体,把 subgoal 生成和 action 生成拆开,同时不改变原始 HiAgent 的运行逻辑。 + +原始 `ContextEfficientAgentV2` 目前会让 LLM 在一次回复里同时生成 subgoal 和第一个 action: + +```text +Subgoal: clear b6 +Action: unstack b3 b6 +``` + +新的变体应该尽量保留原始 prompt。第一步仍然使用原始 HiAgent prompt,让模型判断是否需要新 subgoal,并生成该 subgoal。如果回复里包含新 subgoal,agent 保存这个 subgoal,但丢弃同一次回复里的第一个 action,然后再进行第二次 LLM 调用,把保存下来的 subgoal grounding 成可执行 action。 + +因此拆分方式是: + +```text +Stage 1: 原始 HiAgent prompt -> Subgoal + draft Action,或 Action +Stage 2: 如果 Stage 1 生成了 Subgoal,则基于该 Subgoal 重新生成 Action +``` + +这个设计用于支持云边协同实验:云端大模型负责生成高层 subgoal,本地或边缘模型负责把 subgoal grounding 成可执行 action。 + +## 设计原则 + +- 不改变原始 `ContextEfficientAgentV2` 的执行逻辑。 +- 新行为放在新文件里实现。 +- 只有显式指定新的 agent 名称时,才启用新逻辑。 +- 尽量复用原始 HiAgent 的 memory、history serialization、解析逻辑和 prompt 结构。 +- subgoal 生成条件尽量和原代码保持一致。 +- 第一次 `Subgoal + Action` 回复里的 action 视为 draft action,用第二次 grounding 调用替换它。 +- 第一版不加入额外 verifier 或 LLM judge,避免和原始实现差异过大。 + +## 拟新增/修改文件 + +```text +agentboard/agents/cme_split.py +agentboard/agents/__init__.py +``` + +可选: + +```text +eval_configs/hiagent/blocksworld.yaml +``` + +## 新 Agent + +新增 agent 类: + +```python +SplitSubgoalActionAgent +``` + +推荐实现方式: + +```python +from agents.cme_final import ContextEfficientAgentV2 + +class SplitSubgoalActionAgent(ContextEfficientAgentV2): + ... +``` + +这样新 agent 可以复用: + +- `reset()` +- `update()` +- `make_prompt()` 的组件 +- memory 结构 +- action parser +- retrieve 相关逻辑 +- 现有 PDDL prompt 上下文 + +## 运行流程 + +原始 HiAgent 流程: + +```text +agent.run() + -> make_prompt() + -> LLM 生成 Subgoal + Action 或 Action + -> 如果有 Subgoal,则解析并保存 + -> 返回 action 给环境 +``` + +新的 split-agent 流程: + +```text +agent.run() + -> 使用原始 HiAgent prompt 结构调用 make_prompt() + -> 第一次调用 LLM,和 ContextEfficientAgentV2 一致 + -> 如果回复只包含 Action: + 按原始 agent 逻辑解析并返回 action + -> 如果回复包含 Subgoal + Action: + 解析并保存 Subgoal 到 memory + 丢弃第一次回复里的 draft Action + 调用 generate_action_for_subgoal() + 解析并返回重新生成的 action + -> 返回 action 给环境 +``` + +## LLM 调用 + +### 1. 原始 HiAgent 决策调用 + +这一步应尽量复用原始 `make_prompt()` 输出。 + +期望输出格式不变: + +```text +Subgoal: clear b6 +Action: unstack b3 b6 +``` + +或者: + +```text +Action: putdown b1 +``` + +这样可以保留原模型关于“是否需要新 subgoal”的判断方式。 + +### 2. 新 Subgoal 的 Action 重新生成 + +只有当第一次调用产生 `Subgoal + Action` 时,才执行这一步。 + +第一次调用得到的 subgoal 会被保存,但第一次 action 被视为 draft,不执行。 + +期望输出: + +```text +Action: unstack b3 b6 +``` + +第二次 prompt 应包含: + +- final goal +- current observation +- current subgoal +- relevant history +- 可用辅助命令,例如 `check valid actions` +- 在可行情况下,保留和原 prompt 一致的 domain instruction 与 examples + +第二次 prompt 应要求模型只输出一个 action,并且该 action 要直接服务于当前 subgoal。 + +如果第一次调用只产生 `Action`,则不进行第二次调用。 + +## Subgoal-Action 一致性 + +第一版只使用 prompt-level 的一致性约束,保持和原代码风格接近。 + +示例 action prompt 约束: + +```text +Current Subgoal: clear b6 + +You must output one executable action that directly helps achieve the current subgoal. +Do not pursue another subgoal. +If the current action cannot be determined, use: +Action: check valid actions +``` + +第一版不加入额外的规则检查器或 LLM judge。 + +## Subgoal 刷新逻辑 + +为了贴近原始行为,新 agent 第一版不加入显式的 subgoal completion verifier。 + +第一次 LLM 调用仍然使用原始 HiAgent prompt,由它判断是否需要新 subgoal: + +- 如果第一次回复包含 `Subgoal`,保存 subgoal,并重新生成对应 action。 +- 如果第一次回复只包含 `Action`,直接执行该 action。 + +这样可以避免引入新的 `Subgoal complete` 信号,保持 subgoal 刷新行为接近原始实现。 + +## 日志 + +原始 agent 的日志保持不变。 + +对于新 agent,可以选择在终端 debug 输出中区分两次调用: + +```text +-------------Subgoal Response--------- +Subgoal: clear b6 +---------------[END]------------ + +-------------Action Response--------- +Action: unstack b3 b6 +---------------[END]------------ +``` + +运行日志应使用单独路径,例如: + +```text +logs/hiagent/blocksworld_split_qwen3_5_4b +``` + +## 测试计划 + +先做 smoke test: + +```bash +OPENAI_API_BASE=http://127.0.0.1:8000/v1 \ +OPENAI_API_KEY=dummy \ +EVALTASK=blocksworld \ +python agentboard/eval_main.py \ + --cfg-path eval_configs/hiagent/blocksworld.yaml \ + --tasks pddl \ + --model qwen3_5_4b_vllm_server \ + --agent SplitSubgoalActionAgent \ + --max_num_steps 5 \ + --memory_size 100 \ + --log_path ./logs/hiagent/smoke_blocksworld_split_qwen3_5_4b +``` + +正式测试: + +```bash +OPENAI_API_BASE=http://127.0.0.1:8000/v1 \ +OPENAI_API_KEY=dummy \ +EVALTASK=blocksworld \ +python agentboard/eval_main.py \ + --cfg-path eval_configs/hiagent/blocksworld.yaml \ + --tasks pddl \ + --model qwen3_5_4b_vllm_server \ + --agent SplitSubgoalActionAgent \ + --max_num_steps 30 \ + --memory_size 100 \ + --log_path ./logs/hiagent/blocksworld_split_qwen3_5_4b +``` + +对比对象: + +```text +ContextEfficientAgentV2 +VanillaAgent +``` + +## 风险 + +1. LLM 调用次数会增加,尤其是在频繁生成新 subgoal 时。 +2. 拆分 subgoal 和 action 生成后,二者之间的语义绑定可能变弱。 +3. action generator 可能输出合法但对当前 subgoal 没有帮助的 action。 +4. 第一次调用仍然会生成 draft action,但这部分输出会被丢弃。 +5. 第二次 action-grounding prompt 必须尽量贴近原始上下文,否则行为可能偏离原始 HiAgent。 + +## 预期收益 + +该拆分设计可以分别评估: + +- 高层规划 / subgoal 生成能力 +- 低层 action grounding 能力 + +它也为云边协同提供清晰接口: + +```text +Cloud LLM: generate subgoal +Edge/local LLM: generate executable action +Environment: execute action and return observation +``` + +原始 HiAgent 仍然通过下面命令保持不变: + +```bash +--agent ContextEfficientAgentV2 +``` + +拆分版本只通过下面参数启用: + +```bash +--agent SplitSubgoalActionAgent +``` diff --git a/eval_configs/hiagent/blocksworld.yaml b/eval_configs/hiagent/blocksworld.yaml index 11dc3b0..d2c7dff 100644 --- a/eval_configs/hiagent/blocksworld.yaml +++ b/eval_configs/hiagent/blocksworld.yaml @@ -12,6 +12,13 @@ agent: memory_size: 100 need_goal: True use_parser: True + plugmem: + enabled: False + base_url: http://localhost:8080 + api_key: dev-key-change-me + graph_id: hiagent-cross-task + recall_on_reset: True + upload_on_finish: True llm: gpt-4-turbo: @@ -25,6 +32,18 @@ llm: max_retry_iters: 100 stop: "\n\n" use_parser: False + qwen3_5_4b_vllm_server: + name: gpt + engine: qwen3_5_4b_vllm_server + context_length: 32768 + use_azure: False + temperature: 0. + top_p: 1 + retry_delays: 5 + max_retry_iters: 3 + max_tokens: 512 + stop: + use_parser: False msal-gpt-4: name: msal-gpt engine: gpt-4-0125-preview