From ef22bfb071bd4a53d45acc1ea6fef78265e40437 Mon Sep 17 00:00:00 2001 From: jinliyl <6469360+jinliyl@users.noreply.github.com> Date: Fri, 29 May 2026 15:19:56 +0800 Subject: [PATCH] refactor(evolve): replace ReActAgent with FlexReActAgent to allow structured output (#265) - Create FlexReActAgent subclass that overrides _reasoning method to handle tool_choice parameter - Modify auto_memory_planner to use FlexReActAgent instead of ReActAgent - Update base_step.py to accept additional kwargs in add_as_tool method - Change run_job function to merge kwargs properly when calling jobs - Remove redundant imports and constants from file_io.py - Simplify _render_notes_block and rename _replace_or_append_notes to _rebuild_body - Update method calls to use new function names in file_io operations --- reme4/steps/base_step.py | 6 +-- reme4/steps/evolve/_evolve.py | 14 ++++++ reme4/steps/evolve/auto_memory_planner.py | 5 +- reme4/steps/file_io/_file_io.py | 56 ++++++----------------- 4 files changed, 32 insertions(+), 49 deletions(-) diff --git a/reme4/steps/base_step.py b/reme4/steps/base_step.py index 0b74620e..f5e1e868 100644 --- a/reme4/steps/base_step.py +++ b/reme4/steps/base_step.py @@ -188,14 +188,14 @@ class BaseStep(ABC): raise RuntimeError(f"Job {name} not found") return await job(**kwargs) - def add_as_tool(self, toolkit: Toolkit, job_name: str) -> None: + def add_as_tool(self, toolkit: Toolkit, job_name: str, **kwargs) -> None: """Add the step as a tool to the toolkit.""" job: "BaseJob | None" = self.get_job(job_name) if job is None: raise RuntimeError(f"Job {job_name} not found") - async def run_job(**kwargs) -> ToolResponse: - response = await job(**kwargs) + async def run_job(**_kwargs) -> ToolResponse: + response = await job(**{**_kwargs, **kwargs}) return ToolResponse(content=[TextBlock(type="text", text=response.answer)]) toolkit.register_tool_function( diff --git a/reme4/steps/evolve/_evolve.py b/reme4/steps/evolve/_evolve.py index bac0a0d4..35e084f6 100644 --- a/reme4/steps/evolve/_evolve.py +++ b/reme4/steps/evolve/_evolve.py @@ -2,7 +2,9 @@ import datetime import zoneinfo +from typing import Literal +from agentscope.agent import ReActAgent from agentscope.message import Msg @@ -27,3 +29,15 @@ def format_history(messages: list[Msg], include_timestamp: bool = True) -> str: header = f"[{speaker} @ {msg.timestamp}]" if include_timestamp else f"[{speaker}]" lines.append(f"{header}\n{text}") return "\n\n".join(lines) or "(empty)" + + +class FlexReActAgent(ReActAgent): + """ReActAgent subclass that allows structured output without forcing tool_choice='required'.""" + + async def _reasoning( + self, + tool_choice: Literal["auto", "none", "required"] | None = None, + ) -> Msg: + if tool_choice == "required": + tool_choice = None + return await super()._reasoning(tool_choice) diff --git a/reme4/steps/evolve/auto_memory_planner.py b/reme4/steps/evolve/auto_memory_planner.py index d39ff0d7..e084b672 100644 --- a/reme4/steps/evolve/auto_memory_planner.py +++ b/reme4/steps/evolve/auto_memory_planner.py @@ -20,12 +20,11 @@ Output (written to context.response): metadata['memory_updates']: list of ``{path, description}`` dicts. """ -from agentscope.agent import ReActAgent from agentscope.message import Msg from agentscope.tool import Toolkit from pydantic import BaseModel, Field -from ._evolve import format_history, now +from ._evolve import FlexReActAgent, format_history, now from ..base_step import BaseStep from ...components import R @@ -76,7 +75,7 @@ class AutoMemoryPlannerStep(BaseStep): for job_name in self.planner_tools: self.add_as_tool(toolkit, job_name) - agent = ReActAgent( + agent = FlexReActAgent( name="auto_memory_planner", model=self.as_llm, sys_prompt=self.prompt_format("system_prompt"), diff --git a/reme4/steps/file_io/_file_io.py b/reme4/steps/file_io/_file_io.py index fad27e10..4b7a6022 100644 --- a/reme4/steps/file_io/_file_io.py +++ b/reme4/steps/file_io/_file_io.py @@ -414,58 +414,28 @@ def validate_slug(slug: str) -> str | None: _NOTES_OPEN = "" _NOTES_CLOSE = "" -_NOTES_BLOCK_RE = re.compile( - rf"{re.escape(_NOTES_OPEN)}(?P.*?){re.escape(_NOTES_CLOSE)}", - re.DOTALL, -) - - -def _wrap_notes_block(inner: str) -> str: - return f"{_NOTES_OPEN}\n{inner}\n{_NOTES_CLOSE}" - - -# Content rendering -# ----------------- - def _render_notes_block(notes: list[dict]) -> str: - """Render each note as a single line with its full frontmatter inlined. - - Format: ``- [[path]] key1: value1 key2: value2 ...``. ``name`` and - ``description`` lead (when present) so columns line up across notes; - remaining keys follow in frontmatter insertion order. Empty / None - values are skipped; newlines in values collapse to spaces so the - single-line invariant holds. - """ + """Render each note as ``- [[path]] key: val ...`` (one line per note).""" if not notes: return "(none)" lines: list[str] = [] for note in notes: meta: dict = note["metadata"] - ordered_keys = [k for k in ("name", "description") if k in meta] - ordered_keys += [k for k in meta if k not in ("name", "description")] - parts = [f"- [[{note['path']}]]"] - for key in ordered_keys: - value = meta[key] - if value is None or value == "": - continue - value_str = str(value).replace("\r\n", " ").replace("\r", " ").replace("\n", " ") - parts.append(f"{key}: {value_str}") + keys = [k for k in ("name", "description") if k in meta] + [k for k in meta if k not in ("name", "description")] + parts = [f"- [[{note['path']}]]"] + [ + f"{k}: {str(v).replace(chr(10), ' ')}" for k in keys if (v := meta[k]) not in (None, "") + ] lines.append(" ".join(parts)) return "\n".join(lines) -# Body manipulation -# ----------------- - - -def _replace_or_append_notes(body: str, fresh_block: str) -> str: - """Replace an existing notes auto block in-place; append at end if absent.""" - if _NOTES_BLOCK_RE.search(body): - replacement = _wrap_notes_block(fresh_block) - return _NOTES_BLOCK_RE.sub(lambda m: replacement, body, count=1) - suffix = _wrap_notes_block(fresh_block) - return f"{body.rstrip()}\n\n{suffix}\n" if body.strip() else f"{suffix}\n" +def _rebuild_body(body: str, notes_content: str) -> str: + """Replace or append the auto block, preserving surrounding content.""" + block = f"{_NOTES_OPEN}\n{notes_content}\n{_NOTES_CLOSE}" + if _NOTES_OPEN in body and _NOTES_CLOSE in body: + return body.split(_NOTES_OPEN, 1)[0] + block + body.split(_NOTES_CLOSE, 1)[1] + return f"{body.rstrip()}\n\n{block}\n" if body.strip() else f"{block}\n" # Public scan + rebuild @@ -539,7 +509,7 @@ async def refresh_day_index(file_store, date: str, daily_dir: str) -> dict: if index_abs.is_file(): post = frontmatter.loads(index_abs.read_text(encoding="utf-8")) - new_body = _replace_or_append_notes(post.content, notes_block) + new_body = _rebuild_body(post.content, notes_block) merged = dict(post.metadata or {}) for key, value in fm.items(): if not merged.get(key): @@ -548,7 +518,7 @@ async def refresh_day_index(file_store, date: str, daily_dir: str) -> dict: was_created = False else: index_abs.parent.mkdir(parents=True, exist_ok=True) - new_body = _wrap_notes_block(notes_block) + "\n" + new_body = f"{_NOTES_OPEN}\n{notes_block}\n{_NOTES_CLOSE}\n" was_created = True out = frontmatter.Post(new_body, **fm) index_abs.write_text(frontmatter.dumps(out), encoding="utf-8")