mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-10-09 03:20:54 +00:00
feat(lme): add cli execution and agentic search tooling (#334)
* feat(service): add CLI service for local job execution - Introduce CliService to execute single jobs locally without serving ports - Add prepare_start_config and should_precheck_start functions for CLI job setup - Update reme start command to use CLI service when job argument is provided - Change default service backend from http to cli in jinli_lme config - Modify SearchStep to use constants and rename configuration parameters - Add unit tests for CLI service functionality and configuration handling - Update file extension support to include json format in addition to md and jsonl * feat(search): add BM25 and vector search steps with configuration updates - Add Bm25SearchStep and VectorSearchStep classes with tool context deduplication - Register new search step components in index module - Update configuration to use separate vector_search and bm25_search endpoints - Modify LLM models from qwen3.7-plus/glm-5.1 to glm-5.2 variants - Adjust search parameters and remove hybrid search implementation - Configure embedding store as default in storage settings - Remove auto-memory and file catalog configurations - Update watch directories from multiple paths to session_dir only * feat(agent): add tool result offloading and workspace management - Add tool_results_dir configuration option for offloaded tool results storage - Implement ToolResultOffloadMiddleware to persist large tool results to files - Create WorkspaceBackend to standardize file operations across tools - Add configurable builtin tools selection with sequential execution option - Integrate middleware support for agent wrapper with offloading capability - Update application initialization to create tool results directory - Add safety mechanisms for filesystem operations with sanitized filenames - Enhance agent wrapper with configurable working directory handling - Upgrade agentscope dependency to version 2.0.4 for improved features # Conflicts: # reme/application.py * feat(benchmark): add LongMemEval agentic search and result management - Introduce AgenticAnswerStep for agent-based history search - Add LmePrepareJudgeStep and LmeSaveResultStep for evaluation pipeline - Implement AddDraftStep and ReadAllDraftStep for evidence accumulation - Update configuration with new agent wrapper and search parameters - Add comparison script for analyzing agent run differences - Include documentation for LongMemEval failure analysis - Enhance tool result offloading with skip options - Modify search defaults and indexing behavior * feat(agent): implement tool result offloading with system reminders - Added tool_result_offload_message parameter to agent wrapper reply method - Implemented configurable reminder template for offloaded tool results - Created system reminder messages when tool results are offloaded to files - Added Chinese user message template for agentic answer step - Updated tool result offloading middleware to use custom reminder templates - Enhanced agentic answer instructions to handle long tool results via draft storage * feat(scripts): add LongMemEval results summarization tool - Create summarize_lme_results.py script to analyze result JSON files - Implement command line interface with answer id and dataset root options - Add support for specifying index range with start and end parameters - Include option to show failure details and non-successful completions - Calculate completion statistics and accuracy metrics - Display detailed breakdown of yes/no/other judgements - Handle missing and unreadable result files gracefully - Format output with percentages and comprehensive summary statistics * feat(summarize_lme_results): add question type breakdown to result summary - Import defaultdict from collections module - Add by_type dictionary to track statistics by question type - Count completed, yes, no, and other responses for each question type - Display detailed breakdown table showing accuracy by question type - Include question type column when processing judgements - Print comprehensive summary with question type distribution - Calculate and display accuracy percentage for each question type category * feat(lme): switch to qwen3.7-max model and add shuffle functionality - Changed default LLM model from glm-5.1 to qwen3.7-max in jinli_lme.yaml - Added random module import for shuffle functionality - Implemented --shuffle argument with BooleanOptionalAction for dataset shuffling - Added --seed argument to control random seed for reproducible shuffling - Applied random shuffle to dataset indices when shuffle is enabled - Added console output showing shuffle operation and seed information * fix(cli): set default random seed for shuffle functionality - Changed default seed value from None to 42 for consistent shuffling behavior - Ensures reproducible results when using shuffle option without explicit seed - Maintains backward compatibility while providing deterministic defaults * refactor(benchmark): update agentic answer guidelines for grounding - Updated English instruction to emphasize strict grounding in retrieved context - Modified Chinese instruction to stress evidence-based responses without inference - Removed redundant conciseness requirement in both language versions - Enhanced clarity on proper use of draft saving and retrieval mechanisms - Strengthened emphasis against hallucination of unsupported facts * refactor(benchmark): update agentic search instructions and configuration - Replace separate vector_search and bm25_search with unified search tool - Update agent instructions to use single search tool with multiple strategies - Simplify Chinese instructions for search methodology - Add comprehensive search tool configuration with hybrid vector/BM25 capabilities - Increase model retry attempts from 1 to 3 for better reliability - Remove redundant tool references from job_tools list * feat(search): add configurable search limit with environment variable support - Remove hardcoded limit and min_score parameters from config schema - Increase LLM context size from 200000 to 1000000 - Add REME_SEARCH_LIMIT environment variable support for search configuration - Implement command line argument --search-limit to override default search limit - Add input validation to ensure search limit is positive - Modify subprocess execution to pass environment variables - Update search step to use dynamic default limit from environment or fallback to 5 * refactor(benchmark): remove agentic answer step and related configurations - Removed AgenticAnswerStep class and its registration - Deleted agentic_answer.yaml prompt configuration file - Removed agentic answer related job definitions from jinli_lme.yaml - Cleaned up tool result offloading middleware implementation - Removed tool_results_dir configuration field from application config - Deleted comparison and analysis scripts for agent runs - Removed agentic answer step from LME init module exports - Updated agent wrapper to remove tool result offloading functionality - Removed unused imports and dependencies in agent wrapper module * refactor(benchmark): remove unused LME result processing components - Removed LmePrepareJudgeStep and LmeSaveResultStep classes from benchmark module - Cleaned up imports and exports in lme module initialization - Removed unused middleware configuration from agent wrapper - Deleted obsolete result.py file containing deprecated result processing logic - Simplified agent instantiation by removing middleware parameter - Updated import statements to reflect removed dependencies * refactor(index): remove unused search steps and update imports - Remove Bm25SearchStep and VectorSearchStep from index steps module - Remove unused prepare_start_config and should_precheck_start exports - Move import statements to proper location in reme.py - Update test module to use direct import path for CliService - Remove vector_search and bm25_search configurations from jinli_lme.yaml - Add workspace directory environment variable configuration - Add docstring to getcwd method in agent wrapper - Remove empty middleware list from agent wrapper initialization * feat(index): add BM25 and vector search steps with tool context deduplication - Add Bm25SearchStep for plain BM25 keyword search with tool_context deduplication - Add VectorSearchStep for plain vector search with tool_context deduplication - Implement tool context state management with TTL-based deduplication - Add support for chunk deduplication across tool contexts within TTL window - Update index steps module to include new search step classes - Add test coverage for CLI metadata output functionality - Refactor CLI service to remove unused show_status parameter - Update documentation comments to reflect internal service configuration * feat(steps): add Python code execution capability - Introduce PythonExecuteStep to run Python code in subprocess - Add configuration for python_execute step in jinli_lme.yaml - Register python_execute in available tools list - Implement timeout handling with default 60 second limit - Capture stdout/stderr output and return code metadata - Add comprehensive unit tests for execution scenarios - Support workspace directory context for code execution - Handle timeout errors and runtime exceptions gracefully * refactor(python_execute): replace subprocess with asyncio for Python code execution - Replace subprocess.run with asyncio.create_subprocess_exec for non-blocking execution - Add _PythonResult dataclass to encapsulate execution results and timeout status - Implement proper timeout handling with asyncio.wait_for and process.kill() - Update metadata to include returncode and stderr when timeout occurs - Convert synchronous _run_python method to asynchronous implementation - Maintain backward compatibility while improving execution reliability * refactor(python_execute): replace subprocess with asyncio for Python code execution - Replace subprocess.run with asyncio.create_subprocess_exec for non-blocking execution - Add _PythonResult dataclass to encapsulate execution results and timeout status - Implement proper timeout handling with asyncio.wait_for and process.kill() - Update metadata to include returncode and stderr when timeout occurs - Convert synchronous _run_python method to asynchronous implementation - Maintain backward compatibility while improving execution reliability
This commit is contained in:
parent
82971ac5b0
commit
2612d25959
17 changed files with 838 additions and 92 deletions
|
|
@ -42,6 +42,7 @@ from agentscope.tool import (
|
|||
FunctionTool,
|
||||
Glob,
|
||||
Grep,
|
||||
LocalBackend,
|
||||
Read,
|
||||
ToolBase,
|
||||
ToolChunk,
|
||||
|
|
@ -66,6 +67,24 @@ _UUID_RE = re.compile(
|
|||
)
|
||||
|
||||
|
||||
class WorkspaceBackend(LocalBackend):
|
||||
"""LocalBackend whose reported cwd is the configured agent workspace.
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
def __init__(self, cwd: str) -> None:
|
||||
super().__init__()
|
||||
self._workspace_cwd = cwd
|
||||
|
||||
async def getcwd(self) -> str:
|
||||
"""Return the configured workspace directory."""
|
||||
return self._workspace_cwd
|
||||
|
||||
|
||||
class BypassAnalysisBash(Bash):
|
||||
"""Bash variant that delegates permission decisions to PermissionEngine.
|
||||
|
||||
|
|
@ -106,15 +125,51 @@ class AsAgentWrapper(BaseAgentWrapper):
|
|||
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)
|
||||
tool = FunctionTool(func=run_job, name=job.name, description=job.description, is_concurrency_safe=False)
|
||||
if job.parameters:
|
||||
tool.input_schema = job.parameters
|
||||
return tool
|
||||
|
||||
@classmethod
|
||||
def _builtin_tools(cls) -> list[ToolBase]:
|
||||
"""Return built-in tools expected by local skills."""
|
||||
return [BypassAnalysisBash(), Edit(), Glob(), Grep(), Read(), Write()]
|
||||
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)
|
||||
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:
|
||||
|
|
@ -220,8 +275,15 @@ class AsAgentWrapper(BaseAgentWrapper):
|
|||
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", "all")
|
||||
if "builtin_tools" not in kwargs and not bool(kwargs.get("use_builtin_tools", True)):
|
||||
builtin_tools = []
|
||||
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) for job in resolved_jobs)
|
||||
toolkit = kwargs.get("toolkit") or Toolkit(
|
||||
tools=[*self._builtin_tools(), *(self._make_tool(job, tool_context_id) for job in resolved_jobs)],
|
||||
tools=tools,
|
||||
skills_or_loaders=skills,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,23 @@ class BaseAgentWrapper(BaseComponent):
|
|||
|
||||
component_type = ComponentEnum.AGENT_WRAPPER
|
||||
|
||||
def __init__(self, cwd: str | Path | None = None, **kwargs) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._cwd = cwd
|
||||
|
||||
@property
|
||||
def cwd(self) -> Path:
|
||||
"""Working directory shared by the agent's shell and file tools.
|
||||
|
||||
Defaults to the project root (the workspace) — the same directory
|
||||
Claude Code has always used. Override via the ``cwd`` init argument;
|
||||
a relative value resolves against the workspace root.
|
||||
"""
|
||||
if not self._cwd:
|
||||
return self.project_path
|
||||
cwd = Path(self._cwd)
|
||||
return cwd if cwd.is_absolute() else (self.workspace_path / cwd)
|
||||
|
||||
def set_system_prompt(self, prompt: str) -> "BaseAgentWrapper":
|
||||
"""Set the agent's system prompt. Returns self for chaining."""
|
||||
self.kwargs["system_prompt"] = prompt
|
||||
|
|
|
|||
|
|
@ -277,7 +277,7 @@ class CcAgentWrapper(BaseAgentWrapper):
|
|||
)
|
||||
opts.env.update(extra_env_dict)
|
||||
self.session_path.mkdir(parents=True, exist_ok=True)
|
||||
opts.cwd = opts.cwd or self.project_path
|
||||
opts.cwd = opts.cwd or self.cwd
|
||||
claude_config_dir = self.session_path / "claude_config"
|
||||
opts.env.setdefault("CLAUDE_CONFIG_DIR", str(claude_config_dir))
|
||||
if opts.skills is not None:
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
"""Service components for exposing jobs via different protocols."""
|
||||
|
||||
from .base_service import BaseService
|
||||
from .cli_service import CliService
|
||||
from .http_service import HttpService
|
||||
from .mcp_service import MCPService
|
||||
|
||||
__all__ = [
|
||||
"BaseService",
|
||||
"CliService",
|
||||
"HttpService",
|
||||
"MCPService",
|
||||
]
|
||||
|
|
|
|||
117
reme/components/service/cli_service.py
Normal file
117
reme/components/service/cli_service.py
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
"""CLI service: run one configured job locally, then exit."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from .base_service import BaseService
|
||||
from ..component_registry import R
|
||||
from ..job import BaseJob
|
||||
from ...config import resolve_app_config
|
||||
from ...schema import ApplicationConfig
|
||||
from ...utils import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...application import Application
|
||||
|
||||
_APP_CONFIG_KEYS = set(ApplicationConfig.model_fields)
|
||||
|
||||
|
||||
def prepare_start_config(kwargs: dict) -> dict:
|
||||
"""Resolve ``reme start`` kwargs, translating top-level ``job=...`` into internal cli service config."""
|
||||
if "job" not in kwargs:
|
||||
return resolve_app_config(**kwargs)
|
||||
return _prepare_job_start_config(dict(kwargs))
|
||||
|
||||
|
||||
def should_precheck_start(config: dict) -> bool:
|
||||
"""CLI service does not bind a port, so it should skip service port prechecks."""
|
||||
service = config.get("service")
|
||||
return not (isinstance(service, dict) and service.get("backend") == "cli")
|
||||
|
||||
|
||||
def _prepare_job_start_config(kwargs: dict) -> dict:
|
||||
"""Translate ``reme start job=...`` args into the internal cli service fields."""
|
||||
job = kwargs.pop("job")
|
||||
config_kwargs: dict = {}
|
||||
job_args: dict = {}
|
||||
|
||||
for key, value in kwargs.items():
|
||||
if key == "config" or key in _APP_CONFIG_KEYS:
|
||||
config_kwargs[key] = value
|
||||
else:
|
||||
job_args[key] = value
|
||||
|
||||
# One-shot CLI jobs should print only their answer by default. Reconfigure
|
||||
# before resolve_app_config() so even config-loading logs stay off stdout.
|
||||
if "log_to_console" not in config_kwargs:
|
||||
get_logger(log_to_console=False, log_to_file=False, force_init=True)
|
||||
|
||||
config = resolve_app_config(**config_kwargs)
|
||||
if "enable_logo" not in config_kwargs:
|
||||
config["enable_logo"] = False
|
||||
if "log_to_console" not in config_kwargs:
|
||||
config["log_to_console"] = False
|
||||
service = dict(config.get("service") or {})
|
||||
service.update({"backend": "cli", "job": job, "job_args": job_args})
|
||||
config["service"] = service
|
||||
return config
|
||||
|
||||
|
||||
@R.register("cli")
|
||||
class CliService(BaseService):
|
||||
"""Execute a single job through the normal application lifecycle without serving a port."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
job: str = "",
|
||||
job_args: dict[str, Any] | None = None,
|
||||
show_metadata: bool = False,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(**kwargs)
|
||||
self.job = job
|
||||
self.job_args = job_args or {}
|
||||
self.show_metadata = show_metadata
|
||||
|
||||
def build_service(self, app: "Application") -> None:
|
||||
"""No network framework is needed for local CLI execution."""
|
||||
self.service = None
|
||||
|
||||
def add_job(self, job: BaseJob) -> bool:
|
||||
"""CLI execution does not register jobs; Application already owns them."""
|
||||
return False
|
||||
|
||||
def start_service(self, app: "Application") -> None:
|
||||
"""Run the configured job once and print the same human-facing answer style as CLI clients."""
|
||||
asyncio.run(self._run_job(app))
|
||||
|
||||
def run_app(self, app: "Application") -> None:
|
||||
"""Bypass BaseService.add_jobs(), which is only meaningful for serving protocols."""
|
||||
self.build_service(app)
|
||||
self.start_service(app)
|
||||
|
||||
async def _run_job(self, app: "Application") -> None:
|
||||
if not self.job:
|
||||
raise ValueError("cli service requires service.job")
|
||||
|
||||
await app.start()
|
||||
try:
|
||||
response = await app.run_job(self.job, **self.job_args)
|
||||
output = self._format_response(response.answer, response.metadata)
|
||||
if response.success:
|
||||
print(output)
|
||||
else:
|
||||
print(output, file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
finally:
|
||||
await app.close()
|
||||
|
||||
def _format_response(self, answer: Any, metadata: dict | None) -> str:
|
||||
if not isinstance(answer, str):
|
||||
answer = json.dumps(answer, ensure_ascii=False, indent=2)
|
||||
parts = [answer]
|
||||
if self.show_metadata and metadata:
|
||||
parts.append(json.dumps(metadata, ensure_ascii=False))
|
||||
return "\n".join(parts)
|
||||
|
|
@ -1,49 +1,23 @@
|
|||
service:
|
||||
backend: http
|
||||
backend: cli
|
||||
|
||||
workspace_dir: ${LME_WORKSPACE_DIR:-datasets/longmemeval/1}
|
||||
session_dir: history_session
|
||||
resource_dir: session
|
||||
daily_dir: ""
|
||||
digest_dir: ""
|
||||
|
||||
jobs:
|
||||
update_index:
|
||||
backend: base
|
||||
watch_dirs: [daily_dir, digest_dir, resource_dir]
|
||||
# watch_dirs: [daily_dir, digest_dir]
|
||||
watch_suffixes: [md, jsonl]
|
||||
# watch_suffixes: [md]
|
||||
watch_dirs: [resource_dir]
|
||||
watch_suffixes: [md, json, jsonl]
|
||||
steps:
|
||||
- backend: clear_store_step
|
||||
- backend: init_changes_step
|
||||
monitor_type: file_store
|
||||
monitor_name: default
|
||||
dispatch_steps: [update_index_step]
|
||||
- backend: watch_changes_step
|
||||
dispatch_steps:
|
||||
- backend: update_index_step
|
||||
persist: False
|
||||
|
||||
auto_memory:
|
||||
backend: base
|
||||
description: "Auto-memory: record conversation facts into a daily note"
|
||||
parameters:
|
||||
type: object
|
||||
properties:
|
||||
messages:
|
||||
type: array
|
||||
description: "messages"
|
||||
items:
|
||||
type: object
|
||||
session_id:
|
||||
type: string
|
||||
description: "source conversation session identifier"
|
||||
default: ""
|
||||
memory_hint:
|
||||
type: string
|
||||
description: "optional hint"
|
||||
date:
|
||||
type: string
|
||||
description: "YYYY-MM-DD daily note date; empty = infer from message timestamps or today"
|
||||
default: ""
|
||||
required:
|
||||
- messages
|
||||
steps:
|
||||
- backend: auto_memory_step
|
||||
|
||||
version:
|
||||
backend: base
|
||||
|
|
@ -63,14 +37,6 @@ jobs:
|
|||
query:
|
||||
type: string
|
||||
description: "search query"
|
||||
limit:
|
||||
type: integer
|
||||
description: "max results"
|
||||
default: 5
|
||||
min_score:
|
||||
type: number
|
||||
description: "min fused score"
|
||||
default: 0.0
|
||||
start_date:
|
||||
type: string
|
||||
description: "optional inclusive start date filter (YYYY-MM-DD); results earlier than this date are excluded"
|
||||
|
|
@ -86,6 +52,46 @@ jobs:
|
|||
expand_links: true
|
||||
max_links_per_direction: 10
|
||||
|
||||
add_draft:
|
||||
backend: base
|
||||
description: "Append text to the current draft list."
|
||||
parameters:
|
||||
type: object
|
||||
properties:
|
||||
text:
|
||||
type: string
|
||||
description: "draft text to append"
|
||||
required:
|
||||
- text
|
||||
steps:
|
||||
- backend: add_draft_step
|
||||
|
||||
read_all_draft:
|
||||
backend: base
|
||||
description: "Read all draft text previously appended in the current tool context."
|
||||
parameters:
|
||||
type: object
|
||||
properties: { }
|
||||
steps:
|
||||
- backend: read_all_draft_step
|
||||
|
||||
python_execute:
|
||||
backend: base
|
||||
description: "Execute Python code and return printed stdout."
|
||||
parameters:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
description: "Python code to execute. Print the final result to stdout."
|
||||
timeout:
|
||||
type: number
|
||||
description: "Execution timeout in seconds; defaults to 60."
|
||||
required:
|
||||
- code
|
||||
steps:
|
||||
- backend: python_execute_step
|
||||
|
||||
components:
|
||||
tokenizer:
|
||||
default:
|
||||
|
|
@ -109,16 +115,15 @@ components:
|
|||
as_llm:
|
||||
default:
|
||||
backend: ${LLM_BACKEND:-openai}
|
||||
model: ${LLM_MODEL_NAME:-qwen3.7-plus}
|
||||
model: ${LLM_MODEL_NAME:-qwen3.7-max}
|
||||
stream: true
|
||||
context_size: 200000
|
||||
context_size: 1000000
|
||||
max_retries: 3
|
||||
credential:
|
||||
api_key: ${LLM_API_KEY:-}
|
||||
base_url: ${LLM_BASE_URL:-}
|
||||
parameters:
|
||||
max_tokens: 65536
|
||||
thinking_enable: false
|
||||
|
||||
agent_wrapper:
|
||||
default:
|
||||
|
|
@ -128,14 +133,36 @@ components:
|
|||
react_config:
|
||||
max_iters: 30
|
||||
context_config:
|
||||
trigger_ratio: 0.8
|
||||
trigger_ratio: 0.89
|
||||
reserve_ratio: 0.1
|
||||
tool_result_limit: 50000
|
||||
model_config:
|
||||
max_retries: 1
|
||||
max_retries: 3
|
||||
|
||||
agentic_search_agentwrapper:
|
||||
backend: agentscope
|
||||
as_llm: default
|
||||
cwd: session
|
||||
permission_mode: bypass
|
||||
builtin_tools: false
|
||||
job_tools:
|
||||
- search
|
||||
- add_draft
|
||||
- read_all_draft
|
||||
- python_execute
|
||||
sequential_tool_calls: true
|
||||
react_config:
|
||||
max_iters: 100
|
||||
context_config:
|
||||
trigger_ratio: 0.89
|
||||
reserve_ratio: 0.1
|
||||
tool_result_limit: 1000000
|
||||
model_config:
|
||||
max_retries: 3
|
||||
|
||||
claude_code:
|
||||
backend: claude_code
|
||||
model: ${CLAUDE_CODE_MODEL_NAME:-glm-5.1}
|
||||
model: ${CLAUDE_CODE_MODEL_NAME:-glm-5.2}
|
||||
api_key: ${CLAUDE_CODE_API_KEY:-}
|
||||
base_url: ${CLAUDE_CODE_BASE_URL:-https://dashscope.aliyuncs.com/apps/anthropic}
|
||||
permission_mode: bypassPermissions
|
||||
|
|
@ -144,23 +171,14 @@ components:
|
|||
default:
|
||||
backend: local
|
||||
|
||||
file_catalog:
|
||||
default:
|
||||
backend: local
|
||||
resource:
|
||||
backend: local
|
||||
digest:
|
||||
backend: local
|
||||
dream:
|
||||
backend: local
|
||||
|
||||
file_chunker:
|
||||
markdown:
|
||||
backend: markdown
|
||||
supported_extensions: [ "md" ]
|
||||
default:
|
||||
backend: default
|
||||
supported_extensions: [ "jsonl" ]
|
||||
supported_extensions: [ "json", "jsonl" ]
|
||||
chunk_byte_size: 100000
|
||||
|
||||
keyword_index:
|
||||
default:
|
||||
|
|
@ -171,7 +189,7 @@ components:
|
|||
default:
|
||||
backend: local
|
||||
store_name: local
|
||||
# embedding_store: default
|
||||
embedding_store: ""
|
||||
embedding_store: default
|
||||
# embedding_store: ""
|
||||
keyword_index: default
|
||||
file_graph: default
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import sys
|
|||
|
||||
from .application import Application
|
||||
from .components import R
|
||||
from .components.service.cli_service import prepare_start_config, should_precheck_start
|
||||
from .config import parse_args, resolve_app_config
|
||||
from .enumeration import ComponentEnum
|
||||
from .utils import cli_find_reme, load_env, precheck_start, running_service_config
|
||||
|
|
@ -63,8 +64,8 @@ def main():
|
|||
action, kwargs = parse_args(*sys.argv[1:])
|
||||
if action == "start":
|
||||
load_env()
|
||||
kwargs = resolve_app_config(**kwargs)
|
||||
if not precheck_start(kwargs.get("service")):
|
||||
kwargs = prepare_start_config(kwargs)
|
||||
if should_precheck_start(kwargs) and not precheck_start(kwargs.get("service")):
|
||||
return
|
||||
ReMe(**kwargs).run_app()
|
||||
elif action == "find_reme":
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from .demo import DemoEchoStep1, DemoEchoStep2
|
|||
from .health_check import HealthCheckStep
|
||||
from .help import HelpStep
|
||||
from .llm_demo import LLMDemoStep
|
||||
from .python_execute import PythonExecuteStep
|
||||
from .stream_demo import StreamDemoStep1, StreamDemoStep2
|
||||
from .stream_llm_demo import StreamLLMDemoStep
|
||||
from .version import VersionStep
|
||||
|
|
@ -16,6 +17,7 @@ __all__ = [
|
|||
"HealthCheckStep",
|
||||
"HelpStep",
|
||||
"LLMDemoStep",
|
||||
"PythonExecuteStep",
|
||||
"StreamDemoStep1",
|
||||
"StreamDemoStep2",
|
||||
"StreamLLMDemoStep",
|
||||
|
|
|
|||
100
reme/steps/common/python_execute.py
Normal file
100
reme/steps/common/python_execute.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
"""Execute Python code and return printed stdout."""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from ..base_step import BaseStep
|
||||
from ...components import R
|
||||
|
||||
DEFAULT_TIMEOUT = 60.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _PythonResult:
|
||||
stdout: str
|
||||
stderr: str
|
||||
returncode: int | None
|
||||
timed_out: bool = False
|
||||
|
||||
|
||||
@R.register("python_execute_step")
|
||||
class PythonExecuteStep(BaseStep):
|
||||
"""Run Python code in a subprocess and return stdout as the response answer."""
|
||||
|
||||
async def execute(self):
|
||||
assert self.context is not None
|
||||
|
||||
code = self.context.get("code", "")
|
||||
timeout, timeout_error = self._parse_timeout(self.context.get("timeout", DEFAULT_TIMEOUT))
|
||||
if not isinstance(code, str) or not code.strip():
|
||||
self.context.response.success = False
|
||||
self.context.response.answer = "code is required"
|
||||
return self.context.response
|
||||
if timeout_error:
|
||||
self.context.response.success = False
|
||||
self.context.response.answer = timeout_error
|
||||
return self.context.response
|
||||
|
||||
result = await self._run_python(code, timeout)
|
||||
if result.timed_out:
|
||||
self.context.response.success = False
|
||||
self.context.response.answer = f"Python execution timed out after {timeout:g}s"
|
||||
self.context.response.metadata.update(
|
||||
{
|
||||
"returncode": result.returncode,
|
||||
"stderr": result.stderr,
|
||||
"timeout": timeout,
|
||||
},
|
||||
)
|
||||
return self.context.response
|
||||
|
||||
stdout = result.stdout or ""
|
||||
stderr = result.stderr or ""
|
||||
self.context.response.success = result.returncode == 0
|
||||
self.context.response.answer = stdout if stdout or result.returncode == 0 else stderr
|
||||
self.context.response.metadata.update(
|
||||
{
|
||||
"returncode": result.returncode,
|
||||
"stderr": stderr,
|
||||
"timeout": timeout,
|
||||
},
|
||||
)
|
||||
return self.context.response
|
||||
|
||||
async def _run_python(self, code: str, timeout: float) -> _PythonResult:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
sys.executable,
|
||||
"-c",
|
||||
code,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=self.workspace_path,
|
||||
)
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
|
||||
return _PythonResult(
|
||||
stdout=stdout.decode(),
|
||||
stderr=stderr.decode(),
|
||||
returncode=process.returncode,
|
||||
)
|
||||
except TimeoutError:
|
||||
process.kill()
|
||||
stdout, stderr = await process.communicate()
|
||||
return _PythonResult(
|
||||
stdout=stdout.decode(),
|
||||
stderr=stderr.decode(),
|
||||
returncode=process.returncode,
|
||||
timed_out=True,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _parse_timeout(raw: Any) -> tuple[float, str]:
|
||||
try:
|
||||
timeout = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return DEFAULT_TIMEOUT, "timeout must be a positive number"
|
||||
if timeout <= 0:
|
||||
return DEFAULT_TIMEOUT, "timeout must be a positive number"
|
||||
return timeout, ""
|
||||
|
|
@ -1,12 +1,15 @@
|
|||
"""Index steps."""
|
||||
|
||||
from .bm25_search import Bm25SearchStep
|
||||
from .clear_store import ClearStoreStep
|
||||
from .draft import AddDraftStep, ReadAllDraftStep
|
||||
from .log_changes import LogChangesStep
|
||||
from .node_search import NodeSearchStep
|
||||
from .init_changes import InitChangesStep
|
||||
from .search import SearchStep
|
||||
from .traverse import TraverseStep
|
||||
from .update_changes import ChangeApplyStep, UpdateCatalogStep, UpdateIndexStep
|
||||
from .vector_search import VectorSearchStep
|
||||
from .watch_changes import (
|
||||
DEFAULT_LOW_POWER_POLL_MS,
|
||||
DEFAULT_WATCH_DEBOUNCE_MS,
|
||||
|
|
@ -15,6 +18,8 @@ from .watch_changes import (
|
|||
)
|
||||
|
||||
__all__ = [
|
||||
"AddDraftStep",
|
||||
"Bm25SearchStep",
|
||||
"ChangeApplyStep",
|
||||
"ClearStoreStep",
|
||||
"DEFAULT_LOW_POWER_POLL_MS",
|
||||
|
|
@ -23,9 +28,11 @@ __all__ = [
|
|||
"InitChangesStep",
|
||||
"LogChangesStep",
|
||||
"NodeSearchStep",
|
||||
"ReadAllDraftStep",
|
||||
"SearchStep",
|
||||
"TraverseStep",
|
||||
"UpdateCatalogStep",
|
||||
"UpdateIndexStep",
|
||||
"VectorSearchStep",
|
||||
"WatchChangesStep",
|
||||
]
|
||||
|
|
|
|||
71
reme/steps/index/bm25_search.py
Normal file
71
reme/steps/index/bm25_search.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
"""``bm25_search_step`` — plain BM25 keyword search with tool_context dedup."""
|
||||
|
||||
import datetime
|
||||
from typing import Final
|
||||
|
||||
from ..base_step import BaseStep
|
||||
from ...components import R
|
||||
from ...schema import FileChunk
|
||||
|
||||
_MAX_CANDIDATES: Final = 200
|
||||
|
||||
|
||||
@R.register("bm25_search_step")
|
||||
class Bm25SearchStep(BaseStep):
|
||||
"""BM25-only search: retrieve, filter by min_score, dedup by tool_context, truncate."""
|
||||
|
||||
TOOL_CONTEXTS_KEY: Final[str] = "tool_contexts"
|
||||
SEARCH_SEEN_KEY: Final[str] = "search_seen_chunk_ids"
|
||||
|
||||
def __init__(self, *args, seen_ttl_hours: float = 24, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.seen_ttl_hours = seen_ttl_hours
|
||||
|
||||
def _tool_context_store(self, tool_context_id: str) -> dict:
|
||||
"""Return the mutable state bucket for a tool context."""
|
||||
if self.app_context is not None:
|
||||
contexts = self.app_context.metadata.setdefault(self.TOOL_CONTEXTS_KEY, {})
|
||||
else:
|
||||
contexts = self.kwargs.setdefault(self.TOOL_CONTEXTS_KEY, {})
|
||||
return contexts.setdefault(tool_context_id, {})
|
||||
|
||||
def _dedupe_tool_context(self, chunks: list[FileChunk], tool_context_id: str, limit: int) -> list[FileChunk]:
|
||||
"""Drop chunks already returned for this tool_context within the TTL window."""
|
||||
now = datetime.datetime.now().timestamp()
|
||||
ttl = float(self.seen_ttl_hours) * 60 * 60
|
||||
store = self._tool_context_store(tool_context_id)
|
||||
seen: dict = store.get(self.SEARCH_SEEN_KEY, {})
|
||||
seen = {cid: ts for cid, ts in seen.items() if now - float(ts) < ttl}
|
||||
|
||||
returned = [c for c in chunks if c.id not in seen][:limit]
|
||||
for c in returned:
|
||||
seen[c.id] = now
|
||||
store[self.SEARCH_SEEN_KEY] = seen
|
||||
return returned
|
||||
|
||||
async def execute(self):
|
||||
assert self.context is not None
|
||||
query: str = (self.context.get("query", "") or "").strip()
|
||||
limit: int = int(self.context.get("limit") or 5)
|
||||
tool_context_id: str = (self.context.get("tool_context_id", "") or "").strip()
|
||||
|
||||
if not query:
|
||||
self.context.response.success = False
|
||||
self.context.response.answer = "Error: query cannot be empty"
|
||||
return self.context.response
|
||||
assert limit > 0, f"limit must be positive, got {limit}"
|
||||
|
||||
candidates = min(_MAX_CANDIDATES, max(1, limit * 5))
|
||||
results = await self.file_store.keyword_search(query, candidates, {})
|
||||
self.logger.info(f"[{self.name}] query={query!r} candidates={candidates} hits={len(results)}")
|
||||
|
||||
if tool_context_id:
|
||||
results = self._dedupe_tool_context(results, tool_context_id, limit)
|
||||
else:
|
||||
results = results[:limit]
|
||||
|
||||
self.context.response.answer = "\n\n".join(c.text for c in results)
|
||||
self.context.response.metadata["results"] = [
|
||||
c.model_dump(exclude_none=True, exclude={"embedding"}) for c in results
|
||||
]
|
||||
return self.context.response
|
||||
63
reme/steps/index/draft.py
Normal file
63
reme/steps/index/draft.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
"""Draft accumulation steps scoped by agent tool context."""
|
||||
|
||||
from typing import Final
|
||||
|
||||
from ..base_step import BaseStep
|
||||
from ...components import R
|
||||
|
||||
|
||||
@R.register("add_draft_step")
|
||||
class AddDraftStep(BaseStep):
|
||||
"""Append one draft text item to the current tool context."""
|
||||
|
||||
TOOL_CONTEXTS_KEY: Final[str] = "tool_contexts"
|
||||
DRAFTS_KEY: Final[str] = "drafts"
|
||||
|
||||
def _tool_context_store(self, tool_context_id: str) -> dict:
|
||||
if self.app_context is not None:
|
||||
contexts = self.app_context.metadata.setdefault(self.TOOL_CONTEXTS_KEY, {})
|
||||
else:
|
||||
contexts = self.kwargs.setdefault(self.TOOL_CONTEXTS_KEY, {})
|
||||
return contexts.setdefault(tool_context_id, {})
|
||||
|
||||
async def execute(self):
|
||||
assert self.context is not None
|
||||
text = self.context.get("text", "")
|
||||
tool_context_id: str = (self.context.get("tool_context_id", "") or "").strip()
|
||||
|
||||
if not tool_context_id:
|
||||
self.context.response.success = False
|
||||
self.context.response.answer = "Error: tool_context_id is required"
|
||||
return self.context.response
|
||||
if text is None or not isinstance(text, str):
|
||||
self.context.response.success = False
|
||||
self.context.response.answer = "Error: text must be a string"
|
||||
return self.context.response
|
||||
|
||||
store = self._tool_context_store(tool_context_id)
|
||||
drafts = store.setdefault(self.DRAFTS_KEY, [])
|
||||
drafts.append(text)
|
||||
|
||||
self.context.response.answer = text
|
||||
self.context.response.metadata["draft_count"] = len(drafts)
|
||||
return self.context.response
|
||||
|
||||
|
||||
@R.register("read_all_draft_step")
|
||||
class ReadAllDraftStep(AddDraftStep):
|
||||
"""Read all draft text items for the current tool context."""
|
||||
|
||||
async def execute(self):
|
||||
assert self.context is not None
|
||||
tool_context_id: str = (self.context.get("tool_context_id", "") or "").strip()
|
||||
|
||||
if not tool_context_id:
|
||||
self.context.response.success = False
|
||||
self.context.response.answer = "Error: tool_context_id is required"
|
||||
return self.context.response
|
||||
|
||||
store = self._tool_context_store(tool_context_id)
|
||||
drafts = store.setdefault(self.DRAFTS_KEY, [])
|
||||
self.context.response.answer = "\n".join(drafts)
|
||||
self.context.response.metadata["draft_count"] = len(drafts)
|
||||
return self.context.response
|
||||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
import asyncio
|
||||
import datetime
|
||||
import os
|
||||
from typing import Final
|
||||
|
||||
from ..base_step import BaseStep
|
||||
from ..file_io import extract_daily_date
|
||||
|
|
@ -9,26 +11,37 @@ from ...components import R
|
|||
from ...schema import FileChunk
|
||||
from ...utils import expand_links, render_expansion_lines
|
||||
|
||||
_RRF_K = 60
|
||||
_MAX_CANDIDATES = 200
|
||||
_RRF_K: Final = 60
|
||||
_MAX_CANDIDATES: Final = 200
|
||||
_DEFAULT_LIMIT_ENV: Final = "REME_SEARCH_LIMIT"
|
||||
_DEFAULT_LIMIT: Final = 5
|
||||
|
||||
|
||||
def _default_limit() -> int:
|
||||
value = os.getenv(_DEFAULT_LIMIT_ENV)
|
||||
if value is None:
|
||||
return _DEFAULT_LIMIT
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
return _DEFAULT_LIMIT
|
||||
|
||||
|
||||
@R.register("search_step")
|
||||
class SearchStep(BaseStep):
|
||||
"""Hybrid search: run vector + keyword in parallel, fuse via RRF, filter, truncate."""
|
||||
|
||||
TOOL_CONTEXTS_KEY: Final[str] = "tool_contexts"
|
||||
SEARCH_SEEN_KEY: Final[str] = "search_seen_chunk_ids"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*args,
|
||||
tool_contexts_key: str = "tool_contexts",
|
||||
search_seen_key: str = "search_seen_chunk_ids",
|
||||
tool_context_chunk_ttl_hours: float = 24,
|
||||
seen_ttl_hours: float = 24,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.tool_contexts_key = tool_contexts_key
|
||||
self.search_seen_key = search_seen_key
|
||||
self.tool_context_chunk_ttl_hours = tool_context_chunk_ttl_hours
|
||||
self.seen_ttl_hours = seen_ttl_hours
|
||||
|
||||
@staticmethod
|
||||
def _rrf_merge(
|
||||
|
|
@ -81,9 +94,9 @@ class SearchStep(BaseStep):
|
|||
def _tool_context_store(self, tool_context_id: str) -> dict:
|
||||
"""Return the mutable state bucket for a tool context."""
|
||||
if self.app_context is not None:
|
||||
contexts = self.app_context.metadata.setdefault(self.tool_contexts_key, {})
|
||||
contexts = self.app_context.metadata.setdefault(self.TOOL_CONTEXTS_KEY, {})
|
||||
else:
|
||||
contexts = self.kwargs.setdefault(self.tool_contexts_key, {})
|
||||
contexts = self.kwargs.setdefault(self.TOOL_CONTEXTS_KEY, {})
|
||||
return contexts.setdefault(tool_context_id, {})
|
||||
|
||||
def _dedupe_tool_context(
|
||||
|
|
@ -96,15 +109,15 @@ class SearchStep(BaseStep):
|
|||
ttl = float(
|
||||
self.kwargs.get(
|
||||
"tool_context_chunk_ttl_seconds",
|
||||
float(self.tool_context_chunk_ttl_hours) * 60 * 60,
|
||||
float(self.seen_ttl_hours) * 60 * 60,
|
||||
),
|
||||
)
|
||||
store = self._tool_context_store(tool_context_id)
|
||||
seen = store.get(self.search_seen_key, {})
|
||||
seen = store.get(self.SEARCH_SEEN_KEY, {})
|
||||
if not isinstance(seen, dict):
|
||||
seen = dict.fromkeys(seen, now)
|
||||
before_expire = len(seen)
|
||||
store[self.search_seen_key] = seen = {chunk_id: ts for chunk_id, ts in seen.items() if now - float(ts) < ttl}
|
||||
store[self.SEARCH_SEEN_KEY] = seen = {chunk_id: ts for chunk_id, ts in seen.items() if now - float(ts) < ttl}
|
||||
|
||||
seen_before = len(seen)
|
||||
unvisited = [chunk for chunk in chunks if chunk.id not in seen]
|
||||
|
|
@ -124,7 +137,7 @@ class SearchStep(BaseStep):
|
|||
async def execute(self):
|
||||
assert self.context is not None
|
||||
query: str = (self.context.get("query", "") or "").strip()
|
||||
limit: int = int(self.context.get("limit") or 5)
|
||||
limit: int = int(self.context.get("limit") or _default_limit())
|
||||
min_score: float = float(self.context.get("min_score") or 0.0)
|
||||
vector_weight: float = float(self.kwargs.get("vector_weight", 0.7))
|
||||
candidate_multiplier: float = float(self.kwargs.get("candidate_multiplier", 5.0))
|
||||
|
|
|
|||
71
reme/steps/index/vector_search.py
Normal file
71
reme/steps/index/vector_search.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
"""``vector_search_step`` — plain vector search with tool_context dedup."""
|
||||
|
||||
import datetime
|
||||
from typing import Final
|
||||
|
||||
from ..base_step import BaseStep
|
||||
from ...components import R
|
||||
from ...schema import FileChunk
|
||||
|
||||
_MAX_CANDIDATES: Final = 200
|
||||
|
||||
|
||||
@R.register("vector_search_step")
|
||||
class VectorSearchStep(BaseStep):
|
||||
"""Vector-only search: retrieve, filter by min_score, dedup by tool_context, truncate."""
|
||||
|
||||
TOOL_CONTEXTS_KEY: Final[str] = "tool_contexts"
|
||||
SEARCH_SEEN_KEY: Final[str] = "search_seen_chunk_ids"
|
||||
|
||||
def __init__(self, *args, seen_ttl_hours: float = 24, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.seen_ttl_hours = seen_ttl_hours
|
||||
|
||||
def _tool_context_store(self, tool_context_id: str) -> dict:
|
||||
"""Return the mutable state bucket for a tool context."""
|
||||
if self.app_context is not None:
|
||||
contexts = self.app_context.metadata.setdefault(self.TOOL_CONTEXTS_KEY, {})
|
||||
else:
|
||||
contexts = self.kwargs.setdefault(self.TOOL_CONTEXTS_KEY, {})
|
||||
return contexts.setdefault(tool_context_id, {})
|
||||
|
||||
def _dedupe_tool_context(self, chunks: list[FileChunk], tool_context_id: str, limit: int) -> list[FileChunk]:
|
||||
"""Drop chunks already returned for this tool_context within the TTL window."""
|
||||
now = datetime.datetime.now().timestamp()
|
||||
ttl = float(self.seen_ttl_hours) * 60 * 60
|
||||
store = self._tool_context_store(tool_context_id)
|
||||
seen: dict = store.get(self.SEARCH_SEEN_KEY, {})
|
||||
seen = {cid: ts for cid, ts in seen.items() if now - float(ts) < ttl}
|
||||
|
||||
returned = [c for c in chunks if c.id not in seen][:limit]
|
||||
for c in returned:
|
||||
seen[c.id] = now
|
||||
store[self.SEARCH_SEEN_KEY] = seen
|
||||
return returned
|
||||
|
||||
async def execute(self):
|
||||
assert self.context is not None
|
||||
query: str = (self.context.get("query", "") or "").strip()
|
||||
limit: int = int(self.context.get("limit") or 5)
|
||||
tool_context_id: str = (self.context.get("tool_context_id", "") or "").strip()
|
||||
|
||||
if not query:
|
||||
self.context.response.success = False
|
||||
self.context.response.answer = "Error: query cannot be empty"
|
||||
return self.context.response
|
||||
assert limit > 0, f"limit must be positive, got {limit}"
|
||||
|
||||
candidates = min(_MAX_CANDIDATES, max(1, limit * 5))
|
||||
results = await self.file_store.vector_search(query, candidates, {})
|
||||
self.logger.info(f"[{self.name}] query={query!r} candidates={candidates} hits={len(results)}")
|
||||
|
||||
if tool_context_id:
|
||||
results = self._dedupe_tool_context(results, tool_context_id, limit)
|
||||
else:
|
||||
results = results[:limit]
|
||||
|
||||
self.context.response.answer = "\n\n".join(c.text for c in results)
|
||||
self.context.response.metadata["results"] = [
|
||||
c.model_dump(exclude_none=True, exclude={"embedding"}) for c in results
|
||||
]
|
||||
return self.context.response
|
||||
|
|
@ -8,11 +8,13 @@ import tempfile
|
|||
import warnings
|
||||
|
||||
from reme.components.agent_wrapper import BaseAgentWrapper
|
||||
from reme.components.application_context import ApplicationContext
|
||||
from reme.components.file_store import LocalFileStore
|
||||
from reme.schema import FileLink, FileNode
|
||||
from reme.steps.common.add import AddStep
|
||||
from reme.steps.common.health_check import _file_graph_status
|
||||
from reme.steps.common.llm_demo import LLMDemoStep
|
||||
from reme.steps.common.python_execute import PythonExecuteStep
|
||||
from reme.steps.index import traverse as traverse_mod
|
||||
|
||||
warnings.filterwarnings("ignore", category=DeprecationWarning, module="jieba")
|
||||
|
|
@ -89,6 +91,51 @@ def test_add_step_rejects_invalid_inputs():
|
|||
_run(run())
|
||||
|
||||
|
||||
def test_python_execute_step_returns_printed_stdout(tmp_path):
|
||||
"""python_execute returns stdout as answer and runs under workspace_dir."""
|
||||
|
||||
async def run():
|
||||
app_context = ApplicationContext(workspace_dir=str(tmp_path))
|
||||
step = PythonExecuteStep(app_context=app_context)
|
||||
resp = await step(code="from pathlib import Path\nprint(Path.cwd().name)")
|
||||
assert resp.success is True
|
||||
assert resp.answer == f"{tmp_path.name}\n"
|
||||
assert resp.metadata["returncode"] == 0
|
||||
assert resp.metadata["stderr"] == ""
|
||||
print("✓ test_python_execute_step_returns_printed_stdout passed")
|
||||
|
||||
_run(run())
|
||||
|
||||
|
||||
def test_python_execute_step_reports_stderr_on_failure():
|
||||
"""python_execute captures traceback stderr instead of throwing."""
|
||||
|
||||
async def run():
|
||||
step = PythonExecuteStep()
|
||||
resp = await step(code='raise RuntimeError("boom")')
|
||||
assert resp.success is False
|
||||
assert resp.metadata["returncode"] != 0
|
||||
assert "RuntimeError: boom" in resp.answer
|
||||
assert "RuntimeError: boom" in resp.metadata["stderr"]
|
||||
print("✓ test_python_execute_step_reports_stderr_on_failure passed")
|
||||
|
||||
_run(run())
|
||||
|
||||
|
||||
def test_python_execute_step_times_out():
|
||||
"""python_execute converts subprocess timeout into a failed response."""
|
||||
|
||||
async def run():
|
||||
step = PythonExecuteStep()
|
||||
resp = await step(code="import time\ntime.sleep(1)", timeout=0.01)
|
||||
assert resp.success is False
|
||||
assert resp.answer == "Python execution timed out after 0.01s"
|
||||
assert resp.metadata["timeout"] == 0.01
|
||||
print("✓ test_python_execute_step_times_out passed")
|
||||
|
||||
_run(run())
|
||||
|
||||
|
||||
class _FakeAgentWrapper(BaseAgentWrapper):
|
||||
"""Capture reply kwargs without calling a real model."""
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,146 @@
|
|||
"""Tests for the ReMe CLI entry helpers."""
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from reme.components.service import cli_service
|
||||
from reme.components.service.cli_service import CliService
|
||||
from reme import reme as reme_module
|
||||
|
||||
|
||||
def test_prepare_start_config_moves_unknown_start_args_to_job_args(monkeypatch):
|
||||
"""``reme start job=...`` is translated into a one-shot cli service config."""
|
||||
|
||||
monkeypatch.setattr(
|
||||
cli_service,
|
||||
"resolve_app_config",
|
||||
lambda **kwargs: {
|
||||
**kwargs,
|
||||
"service": {"backend": "http", "host": "127.0.0.1"},
|
||||
},
|
||||
)
|
||||
|
||||
cfg = cli_service.prepare_start_config(
|
||||
{
|
||||
"config": "jinli_lme",
|
||||
"workspace_dir": "/tmp/reme",
|
||||
"job": "search",
|
||||
"query": "hello",
|
||||
"limit": 3,
|
||||
},
|
||||
)
|
||||
|
||||
assert cfg["config"] == "jinli_lme"
|
||||
assert cfg["workspace_dir"] == "/tmp/reme"
|
||||
assert cfg["enable_logo"] is False
|
||||
assert cfg["log_to_console"] is False
|
||||
assert cfg["service"] == {
|
||||
"backend": "cli",
|
||||
"host": "127.0.0.1",
|
||||
"job": "search",
|
||||
"job_args": {"query": "hello", "limit": 3},
|
||||
}
|
||||
|
||||
|
||||
def test_should_precheck_start_skips_cli_service():
|
||||
"""CLI service is local execution and should not run port prechecks."""
|
||||
assert cli_service.should_precheck_start({"service": {"backend": "cli"}}) is False
|
||||
assert cli_service.should_precheck_start({"service": {"backend": "http"}}) is True
|
||||
|
||||
|
||||
def test_cli_service_runs_configured_job_and_closes_app(capsys):
|
||||
"""CLI service runs one local job through app lifecycle and prints its answer."""
|
||||
events = []
|
||||
|
||||
class FakeApp:
|
||||
"""Minimal app stub for exercising CliService lifecycle."""
|
||||
|
||||
async def start(self):
|
||||
"""Record app startup."""
|
||||
events.append("start")
|
||||
|
||||
async def close(self):
|
||||
"""Record app shutdown."""
|
||||
events.append("close")
|
||||
|
||||
async def run_job(self, name, **kwargs):
|
||||
"""Record job execution and return a successful response."""
|
||||
events.append(("run_job", name, kwargs))
|
||||
return SimpleNamespace(answer="found it", success=True, metadata={"hits": 1})
|
||||
|
||||
service = CliService(job="search", job_args={"query": "hello"})
|
||||
|
||||
service.start_service(FakeApp())
|
||||
|
||||
assert events == [
|
||||
"start",
|
||||
("run_job", "search", {"query": "hello"}),
|
||||
"close",
|
||||
]
|
||||
assert capsys.readouterr().out == "found it\n"
|
||||
|
||||
|
||||
def test_cli_service_can_print_metadata_from_service_config(capsys):
|
||||
"""service.show_metadata controls optional CLI metadata output."""
|
||||
|
||||
class FakeApp:
|
||||
"""Minimal app stub for exercising metadata output."""
|
||||
|
||||
async def start(self):
|
||||
"""No-op app startup."""
|
||||
|
||||
async def close(self):
|
||||
"""No-op app shutdown."""
|
||||
|
||||
async def run_job(self, _name, **_kwargs):
|
||||
"""Return a successful response with metadata."""
|
||||
return SimpleNamespace(answer="found it", success=True, metadata={"hits": 1})
|
||||
|
||||
service = CliService(job="search", show_metadata=True)
|
||||
|
||||
service.start_service(FakeApp())
|
||||
|
||||
assert capsys.readouterr().out == 'found it\n{"hits": 1}\n'
|
||||
|
||||
|
||||
def test_cli_service_exits_nonzero_on_failed_response(capsys):
|
||||
"""Failed local CLI jobs write to stderr and produce a failing process status."""
|
||||
events = []
|
||||
|
||||
class FakeApp:
|
||||
"""Minimal app stub for exercising failure handling."""
|
||||
|
||||
async def start(self):
|
||||
"""Record app startup."""
|
||||
events.append("start")
|
||||
|
||||
async def close(self):
|
||||
"""Record app shutdown."""
|
||||
events.append("close")
|
||||
|
||||
async def run_job(self, name, **kwargs):
|
||||
"""Record job execution and return a failed response."""
|
||||
events.append(("run_job", name, kwargs))
|
||||
return SimpleNamespace(answer="boom", success=False, metadata={})
|
||||
|
||||
service = CliService(job="search", job_args={"query": "hello"})
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
service.start_service(FakeApp())
|
||||
|
||||
assert exc_info.value.code == 1
|
||||
assert events == [
|
||||
"start",
|
||||
("run_job", "search", {"query": "hello"}),
|
||||
"close",
|
||||
]
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == ""
|
||||
assert captured.err == "boom\n"
|
||||
|
||||
|
||||
def test_call_server_passes_client_kwargs_to_client(monkeypatch, capsys):
|
||||
"""CLI helper forwards connection options to the selected client."""
|
||||
seen = {}
|
||||
|
|
@ -37,8 +175,6 @@ def test_call_server_passes_client_kwargs_to_client(monkeypatch, capsys):
|
|||
query="hello",
|
||||
)
|
||||
|
||||
import asyncio
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
assert seen["client_kwargs"] == {"host": "127.0.0.2", "port": 2444, "timeout": 1.5}
|
||||
|
|
@ -74,8 +210,6 @@ def test_call_server_treats_show_metadata_as_client_kwarg(monkeypatch, capsys):
|
|||
async def run():
|
||||
await reme_module.call_server("version", backend="http", show_metadata=True)
|
||||
|
||||
import asyncio
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
assert seen["client_kwargs"] == {"show_metadata": True}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,11 @@
|
|||
import asyncio
|
||||
|
||||
from reme.components.file_store import BaseFileStore
|
||||
from reme.components import ApplicationContext
|
||||
from reme.components.runtime_context import RuntimeContext
|
||||
from reme.enumeration import LinkScopeEnum
|
||||
from reme.schema import FileChunk, FileLink, FileNode
|
||||
from reme.steps.index import SearchStep
|
||||
from reme.steps.index import AddDraftStep, ReadAllDraftStep, SearchStep
|
||||
|
||||
|
||||
class FakeSearchStore(BaseFileStore):
|
||||
|
|
@ -109,6 +110,26 @@ def test_search_step_rrf_merges_vector_and_keyword_by_chunk_id():
|
|||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_draft_steps_accumulate_by_tool_context_id():
|
||||
"""Drafts are stored in app metadata and isolated by injected tool_context_id."""
|
||||
|
||||
async def run():
|
||||
app_context = ApplicationContext()
|
||||
add = AddDraftStep(app_context=app_context)
|
||||
read = ReadAllDraftStep(app_context=app_context)
|
||||
|
||||
await add(RuntimeContext(text="first", tool_context_id="ctx-1"))
|
||||
await add(RuntimeContext(text="second", tool_context_id="ctx-1"))
|
||||
await add(RuntimeContext(text="other", tool_context_id="ctx-2"))
|
||||
|
||||
resp = await read(RuntimeContext(tool_context_id="ctx-1"))
|
||||
|
||||
assert resp.answer == "first\nsecond"
|
||||
assert resp.metadata["draft_count"] == 2
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_search_step_keyword_only_uses_keyword_scores_and_min_score():
|
||||
"""When vector has no hits, SearchStep returns keyword results directly and applies min_score."""
|
||||
|
||||
|
|
@ -178,7 +199,7 @@ def test_search_step_tool_context_seen_chunks_expire_after_ttl():
|
|||
step = SearchStep(
|
||||
file_store=store,
|
||||
expand_links=False,
|
||||
tool_context_chunk_ttl_hours=1,
|
||||
seen_ttl_hours=1,
|
||||
clock=lambda: now,
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue