ReMe/reme/components/agent_wrapper/as_agent_wrapper.py
jinliyl 215c1f72f2
Some checks are pending
NPM Format / Website checks (push) Waiting to run
Pre-commit / run (ubuntu-latest) (push) Waiting to run
Tests ReMe / Unit Tests - py3.13 (push) Waiting to run
Tests ReMe / Unit Tests - py3.11 (push) Waiting to run
Tests ReMe / Unit Tests - py3.12 (push) Waiting to run
Windows Smoke / CLI smoke - py3.11 (push) Waiting to run
feat: refine local-first research and memory workflows (#444)
* feat: refine local-first research workflows

* fix: delegate structured output tool choice

* refactor(auto-fin): fetch and filter rolling CLS news

* fix(auto-fin): keep imports portable across platforms

* feat(auto-fin): expose CLS fetch controls

* fix(auto-fin): propagate configurable news window

* feat(auto_fin): normalize hybrid wikilinks in report body

- Add _normalize_hybrid_wikilinks method to remove redundant Markdown destinations
- Use regex to identify hybrid wikilinks with optional destinations
- Replace redundant destinations with simpler wikilink format for clarity
- Ensure normalization is failure-safe with exception handling and logging
- Update report body normalization process to apply hybrid wikilink fix
- Add unit tests to verify correct normalization and failure safety behavior

* fix(dream): serialize integration with application-wide asyncio lock

- Add application-wide asyncio.Lock to serialize digest writes during integration
- Update _snapshot_digest to capture metadata per bucket
- Validate bucket association when recovering from file changes
- Add tests ensuring recovery only from the correct bucket
- Add tests confirming integration lock is shared across application context
- Enhance strict topic YAML loading validation in dream utils
- Add tests for strict topic loading rejecting invalid or lossy fields

* fix(cookbook): enable configurable job_tools for digest and merge steps

- Update daily_cookbook.yaml to add job_tools: [memory_search, read] in digest steps
- Modify DailyPaperDigestStep to read job_tools from kwargs instead of fixed list
- Modify AutoFinMergeStep to similarly read job_tools from kwargs
- Update tests to pass job_tools explicitly when invoking these steps
- Remove hardcoded _TOOLS constants and replace with dynamic job_tools handling

* fix: retry incomplete dream receipts

* perf(pdf): increase max PDF pages limit from 20 to 35

- Updated configuration max_pdf_pages from 20 to 35 in daily_cookbook.yaml
- Modified code to extract up to 35 pages instead of 20 in analyze.py
- Updated README and README_ZH to document the increased max_pdf_pages
- Adjusted unit test assertions to reflect new max_pdf_pages limit of 35

* fix memory integration and daily paper links

* docs clarify cookbook tool usage
2026-08-11 23:32:34 +08:00

499 lines
20 KiB
Python

"""AgentScope backend for the unified agent wrapper."""
import asyncio
import json
import os
import re
import subprocess
import time
from collections.abc import AsyncGenerator
from pathlib import Path
from typing import Any, TYPE_CHECKING
from uuid import uuid4
from agentscope.agent import Agent, ContextConfig, ReActConfig
from agentscope.agent._config import ModelConfig
from agentscope.event import (
DataBlockDeltaEvent,
DataBlockEndEvent,
DataBlockStartEvent,
ExceedMaxItersEvent,
ModelCallEndEvent,
ModelCallStartEvent,
ReplyEndEvent,
ReplyStartEvent,
ThinkingBlockDeltaEvent,
ThinkingBlockEndEvent,
ThinkingBlockStartEvent,
TextBlockDeltaEvent,
TextBlockEndEvent,
TextBlockStartEvent,
ToolCallDeltaEvent,
ToolCallEndEvent,
ToolCallStartEvent,
ToolResultDataDeltaEvent,
ToolResultEndEvent,
ToolResultStartEvent,
ToolResultTextDeltaEvent,
)
from agentscope.message import TextBlock, ToolResultState, UserMsg
from agentscope.permission import PermissionBehavior, PermissionContext, PermissionDecision, PermissionMode
from agentscope.state import AgentState
from agentscope.tool import (
Bash,
Edit,
ExecResult,
FunctionTool,
Glob,
Grep,
LocalBackend,
Read,
ToolBase,
ToolChunk,
Toolkit,
Write,
)
from .base_agent_wrapper import BaseAgentWrapper
from ..as_llm import BaseAsLLM
from ..component_registry import R
from ...enumeration import ChunkEnum
from ...schema import StreamChunk, TokenUsage
from ...utils import AsStateHandler
if TYPE_CHECKING:
from ..job.base_job import BaseJob
_UUID_RE = re.compile(
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
re.IGNORECASE,
)
class WorkspaceBackend(LocalBackend):
"""Local backend pinned to the agent cwd and configured environment.
Some AgentScope builtin tools use ``backend.getcwd()`` for default search
paths or safety checks. Pinning it here keeps those operations aligned with
the cwd passed to Bash. Tools that require absolute file paths still keep
their own validation behavior. Subprocesses receive the startup environment
captured in ``ApplicationConfig.environment`` in addition to the parent
process environment.
"""
def __init__(self, cwd: str, environment: dict[str, str] | None = None) -> None:
super().__init__()
self._workspace_cwd = cwd
self._environment = {**os.environ, **(environment or {})}
async def getcwd(self) -> str:
"""Return the configured workspace directory."""
return self._workspace_cwd
async def exec_shell(
self,
command: list[str],
*,
cwd: str | None = None,
timeout: float | None = None,
) -> ExecResult:
"""Run a local subprocess with the configured agent environment."""
kwargs: dict[str, Any] = {
"env": self._environment,
"stderr": asyncio.subprocess.PIPE,
"stdout": asyncio.subprocess.PIPE,
}
if cwd is not None:
kwargs["cwd"] = cwd
if os.name == "nt":
kwargs["creationflags"] = getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000)
try:
process = await asyncio.create_subprocess_exec(*command, **kwargs)
except (FileNotFoundError, NotADirectoryError, OSError) as exc:
return ExecResult(exit_code=127, stdout=b"", stderr=str(exc).encode("utf-8"))
try:
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
except asyncio.TimeoutError:
process.kill()
await process.communicate()
return ExecResult(exit_code=-1, stdout=b"", stderr=b"timed out")
return ExecResult(exit_code=process.returncode or 0, stdout=stdout, stderr=stderr)
class BypassAnalysisBash(Bash):
"""Bash variant that delegates permission decisions to PermissionEngine.
AgentScope's built-in Bash performs bypass-immune static analysis before
the engine can apply `permission_mode: bypass`. For this app we want the
configured permission mode to be authoritative.
"""
async def check_permissions(
self,
_tool_input: dict[str, Any],
_context: PermissionContext,
) -> PermissionDecision:
"""Bypass Bash static analysis and let the permission engine decide."""
return PermissionDecision(
behavior=PermissionBehavior.PASSTHROUGH,
message="Bash static analysis skipped; delegating to permission engine.",
)
@R.register("agentscope")
class AsAgentWrapper(BaseAgentWrapper):
"""Agent wrapper backed by AgentScope framework."""
SDK_PACKAGE = "agentscope"
@staticmethod
def _agentscope_usage(usage: Any) -> TokenUsage:
"""Normalize AgentScope's portable input/output usage."""
return TokenUsage.from_provider(usage)
def __init__(self, as_llm: str = "default", session_retention_days: int = 10, **kwargs):
super().__init__(**kwargs)
self.as_llm = self.bind(as_llm, BaseAsLLM, optional=False)
self.session_retention_days = int(session_retention_days)
self._session_cleanup_done = False
@classmethod
def _make_tool(
cls,
job: "BaseJob",
tool_context_id: str | None = None,
injected_job_kwargs: dict[str, Any] | None = None,
) -> FunctionTool:
injected = cls._resolve_injected_job_kwargs(
{"tool_context_id": tool_context_id, "injected_job_kwargs": injected_job_kwargs},
)
async def run_job(**kwargs) -> ToolChunk:
response = await job(**cls._merge_injected_job_kwargs(kwargs, injected))
state = ToolResultState.SUCCESS if response.success else ToolResultState.ERROR
return ToolChunk(content=[TextBlock(text=str(response.answer))], state=state)
tool = FunctionTool(func=run_job, name=job.name, description=job.description, is_concurrency_safe=False)
if parameters := cls._strip_injected_parameters(job.parameters, injected):
tool.input_schema = parameters
return tool
def _builtin_tools(
self,
names: list[str] | str | bool | None = "all",
*,
sequential_tool_calls: bool = False,
) -> list[ToolBase]:
"""Return selected AgentScope built-in tools rooted at ``self.cwd``."""
cwd = str(self.cwd)
backend = WorkspaceBackend(cwd, self.bash_environment)
factories = {
"bash": lambda: BypassAnalysisBash(cwd=cwd, backend=backend),
"edit": lambda: Edit(backend=backend),
"glob": lambda: Glob(backend=backend),
"grep": lambda: Grep(backend=backend),
"read": lambda: Read(backend=backend),
"write": lambda: Write(backend=backend),
}
if names is False:
selected_names = []
elif names is True or names is None or names == "all":
selected_names = list(factories)
elif names in ("none", "no", "false"):
selected_names = []
elif isinstance(names, str):
selected_names = [names]
else:
selected_names = names
tools: list[ToolBase] = []
for name in selected_names:
key = name.lower()
if key not in factories:
allowed = ", ".join(factories)
raise ValueError(f"Unknown builtin tool {name!r}; expected one of: {allowed}")
tools.append(factories[key]())
if sequential_tool_calls:
for tool in tools:
tool.is_concurrency_safe = False
return tools
@property
def session_path(self) -> Path:
"""Directory used for persisted AgentScope sessions."""
if self.app_context is None:
return self.workspace_path / "mem_session" / "agentscope"
return self.workspace_path / self.app_context.app_config.mem_session_dir / "agentscope"
@staticmethod
def _validate_session_id(session_id: str, field: str = "session_id") -> str:
if not _UUID_RE.match(session_id):
raise ValueError(f"{field} must be a valid UUID: {session_id!r}")
return session_id.lower()
def _cleanup_expired_sessions(self) -> None:
"""Delete persisted session files older than ``session_retention_days``."""
if self._session_cleanup_done or self.session_retention_days <= 0:
self._session_cleanup_done = True
return
session_path = self.session_path
if not session_path.is_dir():
self._session_cleanup_done = True
return
cutoff = time.time() - self.session_retention_days * 24 * 60 * 60
removed = 0
for path in session_path.glob("*.jsonl"):
try:
if path.is_file() and path.stat().st_mtime < cutoff:
path.unlink()
removed += 1
except OSError as exc:
self.logger.warning(f"Failed to clean expired AgentScope session {path}: {exc}")
if removed:
self.logger.info(
f"Cleaned {removed} AgentScope session(s) older than {self.session_retention_days} day(s)",
)
self._session_cleanup_done = True
async def _load_state(self, kwargs: dict[str, Any], perm_mode: PermissionMode) -> AgentState:
resume = kwargs.get("resume") or ""
session_id = kwargs.get("session_id") or ""
fork_session = bool(kwargs.get("fork_session", False))
if resume:
resume = self._validate_session_id(resume, "resume")
if session_id:
session_id = self._validate_session_id(session_id)
if session_id and resume and not fork_session:
raise ValueError("session_id cannot be used with resume unless fork_session=True")
if resume:
handler = AsStateHandler.for_session(self.session_path, resume)
state = await handler.load_or_none()
if state is None:
raise FileNotFoundError(f"AgentScope session not found: {resume}")
state.permission_context = PermissionContext(mode=perm_mode)
state.session_id = resume
if fork_session:
forked = AgentState(
session_id=session_id or str(uuid4()),
summary=state.summary,
context=list(state.context),
permission_context=PermissionContext(mode=perm_mode),
)
return forked
return state
return AgentState(session_id=session_id or str(uuid4()), permission_context=PermissionContext(mode=perm_mode))
async def _dump_state(self, state: AgentState) -> None:
await AsStateHandler.for_session(self.session_path, state.session_id).dump(state)
def _resolve_skills(self, skills: list[str] | str | None) -> list[str]:
"""Resolve configured skill names to AgentScope local skill directories."""
return [str(path) for path in self._resolve_project_skills(skills).values()]
async def _build_agent(self, inputs: Any, **kwargs) -> tuple[Agent, Any]:
"""Build an Agent instance from kwargs. Returns (agent, processed_inputs)."""
model = self.as_llm.model if self.as_llm else None
if model is None:
raise ValueError("AsAgentWrapper requires a bound as_llm component with a valid model.")
self._cleanup_expired_sessions()
system_prompt = kwargs.get("system_prompt", "You are a helpful assistant.")
job_tools: list[str] = kwargs.get("job_tools", [])
resolved_jobs = self._resolve_job_tools(job_tools)
skills = self._resolve_skills(kwargs.get("skills"))
tool_context_id = kwargs.get("tool_context_id")
sequential_tool_calls = bool(kwargs.get("sequential_tool_calls", True))
builtin_tools = kwargs.get("builtin_tools", [])
if "builtin_tools" not in kwargs and bool(kwargs.get("use_builtin_tools", False)):
builtin_tools = "all"
tools: list[ToolBase] = []
tools.extend(self._builtin_tools(builtin_tools, sequential_tool_calls=sequential_tool_calls))
tools.extend(self._make_tool(job, tool_context_id, kwargs.get("injected_job_kwargs")) for job in resolved_jobs)
toolkit = kwargs.get("toolkit") or Toolkit(
tools=tools,
skills_or_loaders=skills,
)
perm_mode = PermissionMode(kwargs.get("permission_mode", "bypass"))
state = await self._load_state(kwargs, perm_mode)
agent = Agent(
name=self.name,
system_prompt=system_prompt,
model=model,
toolkit=toolkit,
state=state,
model_config=ModelConfig(**(kwargs.get("model_config") or {})),
context_config=ContextConfig(**(kwargs.get("context_config") or {})),
react_config=ReActConfig(**(kwargs.get("react_config") or {})),
)
if isinstance(inputs, str):
inputs = UserMsg(name="user", content=inputs)
return agent, inputs
async def reply(self, inputs: Any, **kwargs) -> dict:
kwargs = self._merged_kwargs(kwargs)
agent, inputs = await self._build_agent(inputs, **kwargs)
await agent.observe(inputs)
last_msg = await agent.reply()
usage = self._agentscope_usage(last_msg.usage) if last_msg.usage is not None else None
await self._dump_state(agent.state)
result = {
"session_id": agent.state.session_id,
"last_message": last_msg.model_dump(),
"result": last_msg.get_text_content(),
}
if usage is None:
self.logger.error("AgentScope did not return token usage; token accounting is unavailable for this reply.")
output_schema: dict | None = kwargs.get("output_schema")
if output_schema is not None:
assert self.as_llm is not None, "AsAgentWrapper requires a bound as_llm component with a valid model."
model = self.as_llm.model
assert model is not None, "AsAgentWrapper requires a bound as_llm component with a valid model."
res = await model.generate_structured_output(
messages=agent.state.context,
structured_model=output_schema,
)
result["structured_output"] = res.content
if res.usage is None:
usage = None
self.logger.error(
"AgentScope did not return structured-output token usage; token accounting is unavailable.",
)
elif usage is not None:
usage = TokenUsage.combine([usage, self._agentscope_usage(res.usage)])
result["usage"] = usage.model_dump() if usage is not None else None
if usage is not None:
self._record_token_usage(usage)
return result
async def compact_session(self, session_id: str) -> None:
"""Force compression of an AgentScope session."""
kwargs = self._merged_kwargs({"resume": session_id})
agent, _ = await self._build_agent(None, **kwargs)
config = {**(kwargs.get("context_config") or {}), "trigger_ratio": 1e-9}
await agent.compress_context(ContextConfig(**config))
await self._dump_state(agent.state)
# ----- StreamChunk conversion -------------------------------------------
@classmethod
# pylint: disable=too-many-return-statements
def _event_to_chunk(cls, event: Any) -> StreamChunk | None:
"""Convert an AgentScope event to a unified StreamChunk.
Returns ``None`` for events that should be silently skipped
(e.g. ``RequireUserConfirmEvent``).
"""
if isinstance(event, ReplyStartEvent):
meta = {"reply_id": event.reply_id, "name": event.name, "role": event.role}
return cls._chunk(ChunkEnum.REPLY_START, session_id=event.session_id, chunk="", metadata=meta)
if isinstance(event, ReplyEndEvent):
return cls._chunk(
ChunkEnum.REPLY_END,
session_id=event.session_id,
chunk="",
metadata={"reply_id": event.reply_id},
)
for event_cls, chunk_type, attr in (
(TextBlockStartEvent, ChunkEnum.CONTENT, None),
(TextBlockDeltaEvent, ChunkEnum.CONTENT, "delta"),
(TextBlockEndEvent, ChunkEnum.CONTENT, None),
(ThinkingBlockStartEvent, ChunkEnum.THINK, None),
(ThinkingBlockDeltaEvent, ChunkEnum.THINK, "delta"),
(ThinkingBlockEndEvent, ChunkEnum.THINK, None),
(DataBlockStartEvent, ChunkEnum.DATA, None),
(DataBlockDeltaEvent, ChunkEnum.DATA, "data"),
(DataBlockEndEvent, ChunkEnum.DATA, None),
):
if isinstance(event, event_cls):
kwargs = {"block_id": event.block_id, "chunk": getattr(event, attr) if attr else ""}
if isinstance(event, (DataBlockStartEvent, DataBlockDeltaEvent)):
kwargs["media_type"] = event.media_type
return cls._chunk(chunk_type, **kwargs)
if isinstance(event, ToolCallStartEvent):
payload = {"name": event.tool_call_name, "id": event.tool_call_id}
return cls._chunk(
ChunkEnum.TOOL_CALL,
tool_call_id=event.tool_call_id,
tool_call_name=event.tool_call_name,
chunk=json.dumps(payload),
)
if isinstance(event, ToolCallDeltaEvent):
return cls._chunk(ChunkEnum.TOOL_CALL, tool_call_id=event.tool_call_id, chunk=event.delta)
if isinstance(event, ToolCallEndEvent):
return cls._chunk(ChunkEnum.TOOL_CALL, tool_call_id=event.tool_call_id, chunk="")
if isinstance(event, ToolResultStartEvent):
return cls._chunk(
ChunkEnum.TOOL_RESULT,
tool_call_id=event.tool_call_id,
tool_call_name=event.tool_call_name,
chunk="",
)
if isinstance(event, ToolResultTextDeltaEvent):
return cls._chunk(ChunkEnum.TOOL_RESULT, tool_call_id=event.tool_call_id, chunk=event.delta)
if isinstance(event, ToolResultDataDeltaEvent):
return cls._chunk(
ChunkEnum.TOOL_RESULT,
tool_call_id=event.tool_call_id,
chunk=event.data,
media_type=event.media_type,
metadata={"url": event.url} if event.url else {},
)
if isinstance(event, ToolResultEndEvent):
return cls._chunk(
ChunkEnum.TOOL_RESULT,
tool_call_id=event.tool_call_id,
chunk="",
metadata={"state": str(event.state)},
)
if isinstance(event, ModelCallStartEvent):
return cls._chunk(ChunkEnum.USAGE, chunk="", metadata={"model_name": getattr(event, "model_name", None)})
if isinstance(event, ModelCallEndEvent):
usage = TokenUsage(input_tokens=event.input_tokens, output_tokens=event.output_tokens)
return cls._chunk(
ChunkEnum.USAGE,
chunk=json.dumps(usage.model_dump()),
input_tokens=usage.input_tokens,
output_tokens=usage.output_tokens,
metadata={
"model_name": getattr(event, "model_name", None),
"usage": usage.model_dump(),
},
)
if isinstance(event, ExceedMaxItersEvent):
return cls._chunk(ChunkEnum.ERROR, chunk="Exceeded max iterations")
return None
async def reply_stream(self, inputs: Any, **kwargs) -> AsyncGenerator[StreamChunk, None]:
"""Stream agent events as unified StreamChunk objects."""
kwargs = self._merged_stream_kwargs(kwargs)
agent, inputs = await self._build_agent(inputs, **kwargs)
async for event in agent.reply_stream(inputs):
chunk = self._event_to_chunk(event)
if chunk is not None:
chunk.session_id = chunk.session_id or agent.state.session_id
yield chunk
await self._dump_state(agent.state)