diff --git a/.github/assets/asta_bench_code.png b/.github/assets/asta_bench_code.png new file mode 100644 index 0000000..de67c5a Binary files /dev/null and b/.github/assets/asta_bench_code.png differ diff --git a/EvoScientist/EvoScientist.py b/EvoScientist/EvoScientist.py index 9274edb..d72456c 100644 --- a/EvoScientist/EvoScientist.py +++ b/EvoScientist/EvoScientist.py @@ -141,8 +141,8 @@ def _inject_subagent_middleware(subs: list[dict]) -> None: from .middleware import ContextOverflowMapperMiddleware, ToolErrorHandlerMiddleware for sa in subs: - sa.setdefault("middleware", []).append( - ToolErrorHandlerMiddleware(), ContextOverflowMapperMiddleware() + sa.setdefault("middleware", []).extend( + [ToolErrorHandlerMiddleware(), ContextOverflowMapperMiddleware()] ) diff --git a/EvoScientist/cli/file_mentions.py b/EvoScientist/cli/file_mentions.py index 5c06d4f..4451505 100644 --- a/EvoScientist/cli/file_mentions.py +++ b/EvoScientist/cli/file_mentions.py @@ -182,7 +182,10 @@ def _read_file(path: Path) -> str: return f"\n### {path.name}\nPath: `{path}`\n```\n{content}\n```" -def parse_file_mentions(text: str, cwd: Path | None = None) -> list[Path]: +def parse_file_mentions( + text: str, + cwd: Path | None = None, +) -> tuple[list[Path], list[str]]: """Extract resolved ``@file`` paths from *text*. Args: @@ -191,13 +194,19 @@ def parse_file_mentions(text: str, cwd: Path | None = None) -> list[Path]: process working directory. Returns: - List of resolved, existing ``Path`` objects (directories excluded). - Unresolvable or missing paths are skipped with a printed warning. + ``(files, warnings)`` — deduplicated list of resolved, existing + ``Path`` objects (directories excluded) in order of first appearance, + and a list of human-readable warning strings to be displayed by the + caller. Callers must display the warnings themselves using the + appropriate UI mechanism (Rich console, Textual widget, etc.). """ if cwd is None: cwd = Path.cwd() + workspace_root = cwd.resolve() files: list[Path] = [] + warnings: list[str] = [] + seen: set[Path] = set() for match in FILE_MENTION_PATTERN.finditer(text): # Skip email addresses — character immediately before @ is alphanumeric before = text[: match.start()] @@ -212,21 +221,35 @@ def parse_file_mentions(text: str, cwd: Path | None = None) -> list[Path]: if not p.is_absolute(): p = cwd / p resolved = p.resolve() - if resolved.exists() and resolved.is_file(): - files.append(resolved) - else: - print(f"[warning] @file not found: {raw}") + if not resolved.exists() or not resolved.is_file(): + warnings.append(f"@file not found: {raw}") + continue + # Deduplicate: skip paths already seen in this message. + if resolved in seen: + continue + seen.add(resolved) + files.append(resolved) + # Warn when the file lives outside the workspace root — it may + # contain sensitive content (e.g. @~/.ssh/id_rsa). + # Checked after dedup so a repeated mention only warns once. + try: + resolved.relative_to(workspace_root) + except ValueError: + warnings.append( + f"@{raw} is outside the workspace " + f"({workspace_root}) — embedding may expose sensitive files" + ) except (OSError, RuntimeError) as exc: - print(f"[warning] invalid @file path {raw!r}: {exc}") + warnings.append(f"invalid @file path {raw!r}: {exc}") - return files + return files, warnings def resolve_file_mentions( text: str, workspace_dir: str | None = None, -) -> tuple[str, str]: - """Parse ``@file`` mentions and return *(original_text, final_prompt)*. +) -> tuple[str, str, list[str]]: + """Parse ``@file`` mentions and return *(original_text, final_prompt, warnings)*. *final_prompt* equals *original_text* when no valid mentions are found, otherwise it appends a ``## Referenced Files`` section with the file @@ -237,14 +260,15 @@ def resolve_file_mentions( workspace_dir: Workspace root used for resolving relative paths. Returns: - ``(original_text, final_prompt)`` — the first element is always the - unchanged input; the second is the prompt to send to the agent. + ``(original_text, final_prompt, warnings)`` — the first element is + always the unchanged input; the second is the prompt to send to the + agent; the third is a list of warning strings to display to the user. """ cwd = Path(workspace_dir) if workspace_dir else None - files = parse_file_mentions(text, cwd=cwd) + files, warnings = parse_file_mentions(text, cwd=cwd) if not files: - return text, text + return text, text, warnings parts = [text, "\n\n## Referenced Files\n"] for path in files: @@ -253,7 +277,7 @@ def resolve_file_mentions( except (OSError, UnicodeDecodeError) as exc: parts.append(f"\n### {path.name}\n[Error reading file: {exc}]") - return text, "\n".join(parts) + return text, "\n".join(parts), warnings # --------------------------------------------------------------------------- diff --git a/EvoScientist/cli/interactive.py b/EvoScientist/cli/interactive.py index 6aab52c..c42bcb1 100644 --- a/EvoScientist/cli/interactive.py +++ b/EvoScientist/cli/interactive.py @@ -922,11 +922,15 @@ def cmd_interactive( continue # Resolve @file mentions — inject file contents inline - _, message_to_send = resolve_file_mentions( + _, message_to_send, file_warnings = resolve_file_mentions( user_input, state["workspace_dir"] ) # Stream agent response with metadata for persistence + # Warnings printed here so they appear just before the + # model response, not before the user input echo. + for w in file_warnings: + console.print(f"[yellow]⚠ {escape(w)}[/yellow]") console.print() meta = build_metadata(state["workspace_dir"], model) run_streaming( diff --git a/EvoScientist/cli/tui_interactive.py b/EvoScientist/cli/tui_interactive.py index 50ff647..94bfb47 100644 --- a/EvoScientist/cli/tui_interactive.py +++ b/EvoScientist/cli/tui_interactive.py @@ -700,6 +700,7 @@ def run_textual_interactive( on_todo_cb: Callable[[list[dict]], None] | None = None, on_media_cb: Callable[[str], None] | None = None, skip_user_message: bool = False, + file_warnings: list[str] | None = None, channel_hitl_fn: Callable[[list], list[dict] | None] | None = None, channel_ask_user_fn: Callable[[dict], dict] | None = None, ) -> str: @@ -723,6 +724,10 @@ def run_textual_interactive( # 1. Mount user message + loading spinner if not skip_user_message: await container.mount(UserMessage(user_text)) + # Mount file warnings after user message so they appear in the + # correct position (between user input and model response). + for w in file_warnings or []: + self._append_system(f"⚠ {w}", style="yellow") loading = LoadingWidget() await container.mount(loading) container.scroll_end(animate=False) @@ -1388,13 +1393,17 @@ def run_textual_interactive( self._render_status() cancelled = False - # Resolve @file mentions — inject file contents before sending to agent - _, message_to_send = await asyncio.to_thread( - resolve_file_mentions, user_text, workspace_dir + # Resolve @file mentions — inject file contents before sending to agent. + # Use self._workspace_dir (current session) not the startup-captured + # workspace_dir closure, which becomes stale after /new or /resume. + _, message_to_send, file_warnings = await asyncio.to_thread( + resolve_file_mentions, user_text, self._workspace_dir ) try: - await self._stream_with_widgets(message_to_send) + await self._stream_with_widgets( + message_to_send, file_warnings=file_warnings + ) except asyncio.CancelledError: cancelled = True self._append_system("\nInterrupted by user", style="dim italic #ffe082") @@ -2000,6 +2009,10 @@ def run_textual_interactive( lambda: setattr(self, "_quit_pending", False), ) + def force_quit(self) -> None: + """Exit immediately without double-press confirmation (used by /exit command).""" + self._do_exit() + def _do_exit(self) -> None: """Clean up channels and exit.""" if self._channel_timer is not None: diff --git a/EvoScientist/cli/widgets/thread_selector.py b/EvoScientist/cli/widgets/thread_selector.py index 1d6d386..186b55a 100644 --- a/EvoScientist/cli/widgets/thread_selector.py +++ b/EvoScientist/cli/widgets/thread_selector.py @@ -73,19 +73,28 @@ def _group_by_ancestor(norm_paths: list[str]) -> dict[str, list[str]]: full path. The returned dict is ordered by first appearance in *norm_paths*. + + Complexity: O(n log n) — paths are sorted lexicographically so the + maximum common prefix depth for each path is found by comparing only + its immediate neighbours in sorted order (not all pairs). """ - path_to_ancestor: dict[str, str] = {} - for i, p in enumerate(norm_paths): + sorted_paths = sorted(norm_paths) + n = len(sorted_paths) + + # Single pass over sorted list: max common prefix is always with a neighbour. + sorted_best: dict[str, int] = {} + for i, p in enumerate(sorted_paths): best = 1 # at minimum depth 1 (~) - for j, other in enumerate(norm_paths): - if i != j: - best = max(best, _common_prefix_depth(p, other)) - # Only group if they truly share a meaningful ancestor (>= 2 levels) - if best >= 2: - ancestor = "/".join(p.split("/")[:best]) - else: - ancestor = p # standalone - path_to_ancestor[p] = ancestor + if i > 0: + best = max(best, _common_prefix_depth(p, sorted_paths[i - 1])) + if i < n - 1: + best = max(best, _common_prefix_depth(p, sorted_paths[i + 1])) + sorted_best[p] = best + + path_to_ancestor: dict[str, str] = { + p: ("/".join(p.split("/")[:best]) if best >= 2 else p) + for p, best in sorted_best.items() + } groups: dict[str, list[str]] = {} for p in norm_paths: diff --git a/EvoScientist/commands/base.py b/EvoScientist/commands/base.py index be8ead9..4ad1c05 100644 --- a/EvoScientist/commands/base.py +++ b/EvoScientist/commands/base.py @@ -37,6 +37,7 @@ class CommandUI(Protocol): ) -> list | None: ... def clear_chat(self) -> None: ... def request_quit(self) -> None: ... + def force_quit(self) -> None: ... def start_new_session(self) -> None: ... async def handle_session_resume( self, thread_id: str, workspace_dir: str | None = None diff --git a/EvoScientist/commands/channel_ui.py b/EvoScientist/commands/channel_ui.py index 7e0181a..b8de8b1 100644 --- a/EvoScientist/commands/channel_ui.py +++ b/EvoScientist/commands/channel_ui.py @@ -126,6 +126,9 @@ class ChannelCommandUI(CommandUI): def request_quit(self) -> None: self.append_system("Quit command ignored in channel.") + def force_quit(self) -> None: + self.request_quit() + def start_new_session(self) -> None: if self.start_new_session_callback: self.start_new_session_callback() diff --git a/EvoScientist/commands/implementation/session.py b/EvoScientist/commands/implementation/session.py index 0447c7a..3ee492d 100644 --- a/EvoScientist/commands/implementation/session.py +++ b/EvoScientist/commands/implementation/session.py @@ -261,7 +261,7 @@ class ExitCommand(Command): description = "Quit EvoScientist" async def execute(self, ctx: CommandContext, args: list[str]) -> None: - ctx.ui.request_quit() + ctx.ui.force_quit() # Register session commands diff --git a/README.md b/README.md index e941d43..dc62854 100644 --- a/README.md +++ b/README.md @@ -46,21 +46,26 @@ Moving beyond traditional human-in-the-loop systems, EvoScientist adopts a human
| + |
Best Paper & Appraisal Award |
- + |
AI-Generated Best Paper |
- + |
#1 on DeepResearch Bench II |
+
+
+ + #1 on AstaBench Code & Execution + |
| + |
Best Paper & Appraisal Award |
- + |
AI-Generated Best Paper |
- + |
DeepResearch Bench II 第一名 |
+
+
+ + AstaBench 代码与执行榜 第一名 + |