black-yt commited on
Commit
e2a452d
·
1 Parent(s): 949c4d4

Sync Space agent core

Browse files
README.md CHANGED
@@ -44,7 +44,8 @@ These files/directories are copied from the main repo and should be refreshed
44
  when their corresponding upstream implementation changes:
45
 
46
  - `agent_base/`: core ReAct runtime, prompts, tool registry, provider
47
- compatibility, trace/session state, image handling, and compaction logic.
 
48
  - `agent_base/tools/`: hosted-safe tool implementations used by the frontend.
49
  - `frontend/static/`: shared browser UI assets, styles, and client logic.
50
  - `frontend/local_server.py`: WebSocket streaming frontend server base, with
@@ -89,7 +90,6 @@ The Space intentionally does not keep the full main-repo surface area:
89
  - benchmark adapters and benchmark documentation under `benchmarks/`
90
  - long-form tutorials under `docs/`
91
  - local placeholder directories such as `workspace/`, `api_runs/`, and `traces/`
92
- - CLI-only console formatting helpers
93
  - test fixtures and local test suites
94
  - `.env.example`
95
 
 
44
  when their corresponding upstream implementation changes:
45
 
46
  - `agent_base/`: core ReAct runtime, prompts, tool registry, provider
47
+ compatibility, trace/session state, image handling, compaction logic, and
48
+ imported runtime helpers required by that core.
49
  - `agent_base/tools/`: hosted-safe tool implementations used by the frontend.
50
  - `frontend/static/`: shared browser UI assets, styles, and client logic.
51
  - `frontend/local_server.py`: WebSocket streaming frontend server base, with
 
90
  - benchmark adapters and benchmark documentation under `benchmarks/`
91
  - long-form tutorials under `docs/`
92
  - local placeholder directories such as `workspace/`, `api_runs/`, and `traces/`
 
93
  - test fixtures and local test suites
94
  - `.env.example`
95
 
agent_base/console_utils.py ADDED
@@ -0,0 +1,223 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse
2
+ import json
3
+ import os
4
+ from pathlib import Path
5
+ import shutil
6
+ import sys
7
+ import unicodedata
8
+ from typing import Any, Optional
9
+
10
+
11
+ ANSI_RESET = "\033[0m"
12
+ ANSI_COLORS = {
13
+ "header": "\033[36m",
14
+ "assistant": "\033[32m",
15
+ "tool": "\033[33m",
16
+ "runtime": "\033[34m",
17
+ "user": "\033[35m",
18
+ "error": "\033[31m",
19
+ }
20
+
21
+
22
+ def _char_display_width(char: str) -> int:
23
+ if unicodedata.combining(char):
24
+ return 0
25
+ if unicodedata.category(char) in {"Cc", "Cf"}:
26
+ return 0
27
+ return 2 if unicodedata.east_asian_width(char) in {"F", "W"} else 1
28
+
29
+
30
+ def _display_width(text: str) -> int:
31
+ return sum(_char_display_width(char) for char in str(text))
32
+
33
+
34
+ def _truncate_display(text: str, width: int) -> str:
35
+ if _display_width(text) <= width:
36
+ return text
37
+ suffix = "..."
38
+ target = max(0, width - _display_width(suffix))
39
+ out = []
40
+ used = 0
41
+ for char in text:
42
+ char_width = _char_display_width(char)
43
+ if used + char_width > target:
44
+ break
45
+ out.append(char)
46
+ used += char_width
47
+ return "".join(out) + suffix
48
+
49
+
50
+ def _pad_display(text: str, width: int) -> str:
51
+ return text + " " * max(0, width - _display_width(text))
52
+
53
+
54
+ def _last_soft_break(chars: list[str]) -> int:
55
+ for index in range(len(chars) - 1, 0, -1):
56
+ if chars[index].isspace() and "".join(chars[:index]).strip():
57
+ return index
58
+ return -1
59
+
60
+
61
+ class ConsoleEventPrinter:
62
+ def __init__(self, *, model_name: str, workspace_root: Path, prompt: str):
63
+ self.model_name = model_name
64
+ self.workspace_root = workspace_root
65
+ self.prompt = prompt.strip()
66
+ self._printed_any = False
67
+ self._use_color = (
68
+ "NO_COLOR" not in os.environ
69
+ and os.environ.get("TERM") != "dumb"
70
+ and (sys.stdout.isatty() or bool(os.environ.get("FORCE_COLOR") or os.environ.get("CLICOLOR_FORCE")))
71
+ )
72
+
73
+ def print_header(self) -> None:
74
+ self._print_box(
75
+ "ResearchHarness CLI",
76
+ f"Model: {self.model_name}\nWorkspace Root: {self.workspace_root}\n\nPrompt:\n{self.prompt}",
77
+ "header",
78
+ )
79
+
80
+ def reset_rounds(self) -> None:
81
+ self._printed_any = False
82
+
83
+ def _paint(self, text: str, color_key: str) -> str:
84
+ if not self._use_color:
85
+ return text
86
+ return f"{ANSI_COLORS.get(color_key, '')}{text}{ANSI_RESET}"
87
+
88
+ def _terminal_width(self) -> int:
89
+ return max(60, min(110, shutil.get_terminal_size((100, 20)).columns))
90
+
91
+ def _wrap_line(self, line: str, width: int) -> list[str]:
92
+ expanded = line.expandtabs(2)
93
+ if expanded == "":
94
+ return [""]
95
+ chunks: list[str] = []
96
+ current: list[str] = []
97
+ current_width = 0
98
+ for char in expanded:
99
+ char_width = _char_display_width(char)
100
+ if current and current_width + char_width > width:
101
+ break_at = _last_soft_break(current)
102
+ if break_at > 0:
103
+ chunks.append("".join(current[:break_at]).rstrip())
104
+ current = list("".join(current[break_at + 1 :]).lstrip())
105
+ current_width = _display_width("".join(current))
106
+ else:
107
+ chunks.append("".join(current))
108
+ current = []
109
+ current_width = 0
110
+ current.append(char)
111
+ current_width += char_width
112
+ if current:
113
+ chunks.append("".join(current))
114
+ return chunks or [""]
115
+
116
+ def _print_box(self, title: str, body: str, color_key: str = "runtime") -> None:
117
+ width = self._terminal_width()
118
+ inner_width = width - 4
119
+ title_text = f" {_truncate_display(title.strip(), width - 6)} "
120
+ top = "+" + title_text + "-" * max(0, width - 2 - _display_width(title_text)) + "+"
121
+ bottom = "+" + "-" * (width - 2) + "+"
122
+ if self._printed_any:
123
+ print()
124
+ print(self._paint(top, color_key))
125
+ for raw_line in str(body or "").splitlines() or [""]:
126
+ for line in self._wrap_line(raw_line, inner_width):
127
+ padded = _pad_display(line, inner_width)
128
+ print(f"{self._paint('|', color_key)} {padded} {self._paint('|', color_key)}")
129
+ print(self._paint(bottom, color_key))
130
+ self._printed_any = True
131
+
132
+ def _title(self, label: str, turn_index: int) -> str:
133
+ return f"{label} | round {turn_index}" if turn_index > 0 else label
134
+
135
+ def _format_tool_call(self, tool_name: str, tool_args: Any) -> str:
136
+ try:
137
+ tool_args_text = json.dumps(tool_args, ensure_ascii=False, indent=2)
138
+ except TypeError:
139
+ tool_args_text = str(tool_args)
140
+ return f"- {tool_name}\n{tool_args_text}"
141
+
142
+ def handle_event(self, row: dict[str, Any]) -> None:
143
+ role = str(row.get("role", ""))
144
+ turn_index = int(row.get("turn_index", 0) or 0)
145
+ text = str(row.get("text", ""))
146
+ capture_type = str(row.get("capture_type", ""))
147
+ tool_names = row.get("tool_names") if isinstance(row.get("tool_names"), list) else []
148
+ tool_arguments = row.get("tool_arguments") if isinstance(row.get("tool_arguments"), list) else []
149
+ finish_reason = str(row.get("finish_reason", ""))
150
+ error = str(row.get("error", ""))
151
+
152
+ if capture_type and not text.strip():
153
+ return
154
+
155
+ if role == "system":
156
+ return
157
+
158
+ if role == "user":
159
+ if turn_index == 0:
160
+ return
161
+ self._print_box(self._title("Runtime Message", turn_index), text, "user")
162
+ return
163
+
164
+ if role == "assistant":
165
+ lines: list[str] = []
166
+ if tool_names:
167
+ if text.strip():
168
+ lines.append(text)
169
+ else:
170
+ suffix = f" finish_reason={finish_reason}" if finish_reason else ""
171
+ lines.append(f"(no text; native tool-calls only.{suffix})")
172
+ lines.append("")
173
+ lines.append("Assistant Tool Calls:")
174
+ for idx, tool_name in enumerate(tool_names):
175
+ tool_args = tool_arguments[idx] if idx < len(tool_arguments) else {}
176
+ lines.append(self._format_tool_call(str(tool_name), tool_args))
177
+ elif text.strip():
178
+ lines.append(text)
179
+ else:
180
+ suffix = f" finish_reason={finish_reason}" if finish_reason else ""
181
+ lines.append(f"(empty assistant output.{suffix})")
182
+ if error:
183
+ lines.append("")
184
+ lines.append(f"Assistant Error: {error}")
185
+ self._print_box(self._title("Assistant", turn_index), "\n".join(lines), "error" if error else "assistant")
186
+ return
187
+
188
+ if role == "tool":
189
+ tool_name = str(tool_names[0]) if tool_names else "Tool"
190
+ lines = [text]
191
+ if error:
192
+ lines.extend(["", f"{tool_name} Error: {error}"])
193
+ self._print_box(self._title(f"{tool_name} Result", turn_index), "\n".join(lines), "error" if error else "tool")
194
+ return
195
+
196
+ if role == "runtime":
197
+ lines = [text]
198
+ if error:
199
+ lines.extend(["", f"Runtime Error: {error}"])
200
+ self._print_box(self._title("Runtime", turn_index), "\n".join(lines), "error" if error else "runtime")
201
+
202
+
203
+ def main(argv: Optional[list[str]] = None) -> int:
204
+ parser = argparse.ArgumentParser(description="Show a minimal example of the CLI console event formatter.")
205
+ parser.parse_args(argv)
206
+ printer = ConsoleEventPrinter(model_name="demo-model", workspace_root=Path("."), prompt="demo question")
207
+ printer.print_header()
208
+ printer.handle_event(
209
+ {
210
+ "role": "assistant",
211
+ "turn_index": 1,
212
+ "text": "",
213
+ "tool_names": ["Read"],
214
+ "tool_arguments": [{"path": "demo.txt"}],
215
+ "termination": "",
216
+ "error": "",
217
+ }
218
+ )
219
+ return 0
220
+
221
+
222
+ if __name__ == "__main__":
223
+ raise SystemExit(main())
agent_base/react_agent.py CHANGED
@@ -1,15 +1,18 @@
 
1
  from contextlib import contextmanager
2
  import json
3
  import os
4
  import re
5
  import signal
 
6
  import threading
7
  from pathlib import Path
8
- from typing import Any, Callable, Dict, List, Optional, Sequence
9
 
10
  from openai import OpenAI, APIError, APIConnectionError, APITimeoutError
11
  import tiktoken
12
  from agent_base.base import BaseAgent
 
13
  from agent_base.context_compact import compact_messages, should_compact_messages
14
  from agent_base.model_profiles import resolve_model_profile
15
  from agent_base.provider_compat import apply_sampling_params
@@ -17,13 +20,22 @@ from agent_base.prompt import composed_system_prompt
17
  from agent_base.session_state import AgentSessionState, CompactionRecord, persist_session_state, resolve_session_state_path
18
  from agent_base.trace_utils import FlatTraceWriter
19
  from agent_base.tools.tooling import normalize_workspace_root
 
20
  from agent_base.tools.tool_file import Edit, Glob, Grep, Read, ReadImage, ReadPDF, Write
21
  from agent_base.tools.tool_runtime import Bash, TerminalInterrupt, TerminalKill, TerminalRead, TerminalStart, TerminalWrite
22
  from agent_base.tools.tool_user import AskUser
23
  from agent_base.tools.tool_web import ScholarSearch, WebFetch, WebSearch
24
  from agent_base.utils import (
 
 
 
25
  env_flag,
 
 
 
 
26
  safe_jsonable,
 
27
  )
28
 
29
  import datetime
@@ -50,6 +62,11 @@ AVAILABLE_TOOLS = [
50
  TerminalKill(),
51
  ]
52
  AVAILABLE_TOOL_MAP = {tool.name: tool for tool in AVAILABLE_TOOLS}
 
 
 
 
 
53
  DEFAULT_IMAGE_TOKEN_ESTIMATE = 1536
54
  DEFAULT_MODEL_NAME = "gpt-5.4"
55
  DEFAULT_MAX_LLM_CALLS = 100
@@ -384,7 +401,32 @@ def resolved_tool_names(function_list: Optional[Sequence[str]]) -> list[str]:
384
 
385
 
386
  def available_tool_schemas(function_list: Optional[Sequence[str]] = None) -> list[dict[str, Any]]:
387
- return [tool_schema(AVAILABLE_TOOL_MAP[name]) for name in resolved_tool_names(function_list)]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
388
 
389
 
390
  def normalized_tool_call(tool_call: Any) -> dict[str, Any]:
@@ -587,7 +629,7 @@ class MultiTurnReactAgent(BaseAgent):
587
  requested_tools = self.resolve_function_list(function_list)
588
  if requested_tools is None:
589
  requested_tools = list(AVAILABLE_TOOL_MAP.keys())
590
- unknown_tools = [tool for tool in requested_tools if tool not in AVAILABLE_TOOL_MAP]
591
  if unknown_tools:
592
  raise ValueError(f"Unknown tools requested: {unknown_tools}")
593
  if "model" not in llm or not str(llm["model"]).strip():
@@ -595,7 +637,7 @@ class MultiTurnReactAgent(BaseAgent):
595
  if "generate_cfg" not in llm or not isinstance(llm["generate_cfg"], dict):
596
  raise ValueError('llm["generate_cfg"] must be a dict.')
597
 
598
- self.tool_map = {tool_name: AVAILABLE_TOOL_MAP[tool_name] for tool_name in requested_tools}
599
  self.tool_names = list(self.tool_map.keys())
600
  self.model = str(llm["model"])
601
  self.llm_generate_cfg = llm["generate_cfg"]
@@ -1306,3 +1348,157 @@ class MultiTurnReactAgent(BaseAgent):
1306
 
1307
  def custom_call_tool(self, tool_name: str, tool_args: Any, **kwargs):
1308
  return execute_tool_by_name(self.tool_map, tool_name, tool_args, **kwargs)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse
2
  from contextlib import contextmanager
3
  import json
4
  import os
5
  import re
6
  import signal
7
+ import sys
8
  import threading
9
  from pathlib import Path
10
+ from typing import Any, Callable, Dict, List, Optional, Sequence, Type
11
 
12
  from openai import OpenAI, APIError, APIConnectionError, APITimeoutError
13
  import tiktoken
14
  from agent_base.base import BaseAgent
15
+ from agent_base.console_utils import ConsoleEventPrinter
16
  from agent_base.context_compact import compact_messages, should_compact_messages
17
  from agent_base.model_profiles import resolve_model_profile
18
  from agent_base.provider_compat import apply_sampling_params
 
20
  from agent_base.session_state import AgentSessionState, CompactionRecord, persist_session_state, resolve_session_state_path
21
  from agent_base.trace_utils import FlatTraceWriter
22
  from agent_base.tools.tooling import normalize_workspace_root
23
+ from agent_base.tools.tool_extra import StrReplaceEditor
24
  from agent_base.tools.tool_file import Edit, Glob, Grep, Read, ReadImage, ReadPDF, Write
25
  from agent_base.tools.tool_runtime import Bash, TerminalInterrupt, TerminalKill, TerminalRead, TerminalStart, TerminalWrite
26
  from agent_base.tools.tool_user import AskUser
27
  from agent_base.tools.tool_web import ScholarSearch, WebFetch, WebSearch
28
  from agent_base.utils import (
29
+ PROJECT_ROOT,
30
+ MissingRequiredEnvError,
31
+ append_saved_image_paths_to_prompt,
32
  env_flag,
33
+ image_input_content_parts,
34
+ load_dotenv,
35
+ read_role_prompt_files,
36
+ require_required_env,
37
  safe_jsonable,
38
+ stage_image_file_for_input,
39
  )
40
 
41
  import datetime
 
62
  TerminalKill(),
63
  ]
64
  AVAILABLE_TOOL_MAP = {tool.name: tool for tool in AVAILABLE_TOOLS}
65
+ OPTIONAL_TOOLS = [
66
+ StrReplaceEditor(),
67
+ ]
68
+ OPTIONAL_TOOL_MAP = {tool.name: tool for tool in OPTIONAL_TOOLS}
69
+ ALL_TOOL_MAP = {**AVAILABLE_TOOL_MAP, **OPTIONAL_TOOL_MAP}
70
  DEFAULT_IMAGE_TOKEN_ESTIMATE = 1536
71
  DEFAULT_MODEL_NAME = "gpt-5.4"
72
  DEFAULT_MAX_LLM_CALLS = 100
 
401
 
402
 
403
  def available_tool_schemas(function_list: Optional[Sequence[str]] = None) -> list[dict[str, Any]]:
404
+ names = resolved_tool_names(function_list)
405
+ unknown_tools = [name for name in names if name not in ALL_TOOL_MAP]
406
+ if unknown_tools:
407
+ raise ValueError(f"Unknown tools requested: {unknown_tools}")
408
+ return [tool_schema(ALL_TOOL_MAP[name]) for name in names]
409
+
410
+
411
+ def resolve_extra_tool_names(extra_tools: Optional[Sequence[str]]) -> list[str]:
412
+ resolved: list[str] = []
413
+ for raw_name in extra_tools or []:
414
+ name = str(raw_name).strip()
415
+ if not name:
416
+ continue
417
+ if name not in OPTIONAL_TOOL_MAP:
418
+ raise ValueError(f"Unknown extra tool requested: {name}")
419
+ if name not in resolved:
420
+ resolved.append(name)
421
+ return resolved
422
+
423
+
424
+ def default_tool_names(*, include_ask_user: bool = True, extra_tools: Optional[Sequence[str]] = None) -> list[str]:
425
+ names = [name for name in AVAILABLE_TOOL_MAP if include_ask_user or name != "AskUser"]
426
+ for name in resolve_extra_tool_names(extra_tools):
427
+ if name not in names:
428
+ names.append(name)
429
+ return names
430
 
431
 
432
  def normalized_tool_call(tool_call: Any) -> dict[str, Any]:
 
629
  requested_tools = self.resolve_function_list(function_list)
630
  if requested_tools is None:
631
  requested_tools = list(AVAILABLE_TOOL_MAP.keys())
632
+ unknown_tools = [tool for tool in requested_tools if tool not in ALL_TOOL_MAP]
633
  if unknown_tools:
634
  raise ValueError(f"Unknown tools requested: {unknown_tools}")
635
  if "model" not in llm or not str(llm["model"]).strip():
 
637
  if "generate_cfg" not in llm or not isinstance(llm["generate_cfg"], dict):
638
  raise ValueError('llm["generate_cfg"] must be a dict.')
639
 
640
+ self.tool_map = {tool_name: ALL_TOOL_MAP[tool_name] for tool_name in requested_tools}
641
  self.tool_names = list(self.tool_map.keys())
642
  self.model = str(llm["model"])
643
  self.llm_generate_cfg = llm["generate_cfg"]
 
1348
 
1349
  def custom_call_tool(self, tool_name: str, tool_args: Any, **kwargs):
1350
  return execute_tool_by_name(self.tool_map, tool_name, tool_args, **kwargs)
1351
+
1352
+
1353
+ def _path_has_suffix(path: Path, suffix_parts: Sequence[str]) -> bool:
1354
+ normalized_parts = tuple(part.casefold() for part in path.parts)
1355
+ normalized_suffix = tuple(part.casefold() for part in suffix_parts)
1356
+ if len(normalized_parts) < len(normalized_suffix):
1357
+ return False
1358
+ return normalized_parts[-len(normalized_suffix) :] == normalized_suffix
1359
+
1360
+
1361
+ def resolve_agent_class_for_role_prompt_files(role_prompt_files: Sequence[str]) -> Type[MultiTurnReactAgent]:
1362
+ for raw_path in role_prompt_files:
1363
+ path_text = str(raw_path).strip()
1364
+ if not path_text:
1365
+ continue
1366
+ path = Path(path_text).expanduser().resolve(strict=False)
1367
+ if _path_has_suffix(path, ("benchmarks", "ResearchClawBench", "role_prompt.md")):
1368
+ from benchmarks.ResearchClawBench.adapter import ResearchClawBenchAgent
1369
+
1370
+ return ResearchClawBenchAgent
1371
+ return MultiTurnReactAgent
1372
+
1373
+
1374
+ def _parse_cli_args(argv: list[str]) -> tuple[str, Optional[str], Optional[str], str, list[str], list[str], Optional[bool], list[str]]:
1375
+ parser = argparse.ArgumentParser(description="Run the local agent directly from agent_base.react_agent.")
1376
+ parser.add_argument("prompt", nargs="*", help="Prompt text.")
1377
+ parser.add_argument("--prompt-file", help="Optional UTF-8 text file containing the prompt.")
1378
+ parser.add_argument("--trace-dir", help="Optional directory where the run trace JSONL should be created.")
1379
+ parser.add_argument(
1380
+ "--workspace-root",
1381
+ help="Optional workspace root for local file tools, Bash, and TerminalStart.",
1382
+ )
1383
+ parser.add_argument(
1384
+ "--role-prompt-file",
1385
+ action="append",
1386
+ default=[],
1387
+ dest="role_prompt_files",
1388
+ metavar="PATH",
1389
+ help="Append one role-specific prompt file to the base system prompt. May be passed multiple times.",
1390
+ )
1391
+ parser.add_argument(
1392
+ "--images",
1393
+ action="append",
1394
+ nargs="+",
1395
+ default=[],
1396
+ dest="image_paths",
1397
+ metavar="PATH",
1398
+ help="Attach one or more local image paths to the initial user message.",
1399
+ )
1400
+ parser.add_argument(
1401
+ "--chat",
1402
+ action=argparse.BooleanOptionalAction,
1403
+ default=None,
1404
+ help="Continue asking for follow-up user messages after each final answer. Defaults to on only in an interactive terminal.",
1405
+ )
1406
+ parser.add_argument(
1407
+ "--extra-tool",
1408
+ action="append",
1409
+ default=[],
1410
+ dest="extra_tools",
1411
+ metavar="NAME",
1412
+ help="Enable one optional extra tool for this run. Currently supported: str_replace_editor. May be passed multiple times.",
1413
+ )
1414
+ args = parser.parse_args(argv)
1415
+
1416
+ prompt_text = ""
1417
+ if args.prompt_file:
1418
+ prompt_text = Path(args.prompt_file).read_text(encoding="utf-8").strip()
1419
+ elif args.prompt:
1420
+ prompt_text = " ".join(args.prompt).strip()
1421
+
1422
+ if not prompt_text:
1423
+ raise ValueError("A non-empty prompt is required via positional args or --prompt-file.")
1424
+ role_prompt = read_role_prompt_files(args.role_prompt_files)
1425
+ return (
1426
+ prompt_text,
1427
+ args.trace_dir,
1428
+ args.workspace_root,
1429
+ role_prompt,
1430
+ list(args.role_prompt_files),
1431
+ [path for group in args.image_paths for path in group],
1432
+ args.chat,
1433
+ resolve_extra_tool_names(args.extra_tools),
1434
+ )
1435
+
1436
+
1437
+ def main(argv: Optional[list[str]] = None) -> int:
1438
+ load_dotenv(PROJECT_ROOT / ".env")
1439
+ try:
1440
+ require_required_env("ResearchHarness agent")
1441
+ prompt_text, trace_dir, workspace_root, role_prompt, role_prompt_files, image_paths, chat_arg, extra_tools = _parse_cli_args(argv or sys.argv[1:])
1442
+ agent_cls = resolve_agent_class_for_role_prompt_files(role_prompt_files)
1443
+ forbidden_tools = set(getattr(agent_cls, "forbidden_tool_names", set()))
1444
+ agent = agent_cls(
1445
+ function_list=(
1446
+ default_tool_names(include_ask_user="AskUser" not in forbidden_tools, extra_tools=extra_tools)
1447
+ if extra_tools
1448
+ else None
1449
+ ),
1450
+ llm=default_llm_config(),
1451
+ trace_dir=trace_dir,
1452
+ role_prompt=role_prompt or None,
1453
+ )
1454
+ resolved_workspace_root = normalize_workspace_root(workspace_root)
1455
+ initial_content_parts: list[dict[str, Any]] = []
1456
+ saved_image_paths: list[str] = []
1457
+ for image_index, image_path in enumerate(image_paths):
1458
+ saved_path, data_url = stage_image_file_for_input(
1459
+ image_path,
1460
+ workspace_root=resolved_workspace_root,
1461
+ image_index=image_index,
1462
+ )
1463
+ saved_image_paths.append(saved_path)
1464
+ initial_content_parts.extend(image_input_content_parts(data_url, saved_path))
1465
+ run_prompt = append_saved_image_paths_to_prompt(prompt_text, saved_image_paths)
1466
+ printer = ConsoleEventPrinter(
1467
+ model_name=agent.model,
1468
+ workspace_root=resolved_workspace_root,
1469
+ prompt=run_prompt,
1470
+ )
1471
+ printer.print_header()
1472
+ session = agent._run_session(
1473
+ run_prompt,
1474
+ workspace_root=str(resolved_workspace_root),
1475
+ event_callback=printer.handle_event,
1476
+ initial_content_parts=initial_content_parts or None,
1477
+ )
1478
+ chat_enabled = chat_arg if chat_arg is not None else (sys.stdin.isatty() and sys.stdout.isatty())
1479
+ messages = session.get("messages", [])
1480
+ while chat_enabled:
1481
+ try:
1482
+ followup = input("\n[ResearchHarness] Follow-up (Ctrl+C to exit): ").strip()
1483
+ except (KeyboardInterrupt, EOFError):
1484
+ print("\n[ResearchHarness] Chat ended.")
1485
+ break
1486
+ if not followup:
1487
+ continue
1488
+ print(f"\n[ResearchHarness] Continuing conversation: {followup}")
1489
+ printer.reset_rounds()
1490
+ session = agent._run_session(
1491
+ followup,
1492
+ workspace_root=str(resolved_workspace_root),
1493
+ event_callback=printer.handle_event,
1494
+ prior_messages=messages,
1495
+ )
1496
+ messages = session.get("messages", messages)
1497
+ return 0
1498
+ except (MissingRequiredEnvError, ValueError) as exc:
1499
+ print(str(exc), file=sys.stderr)
1500
+ return 1
1501
+
1502
+
1503
+ if __name__ == "__main__":
1504
+ raise SystemExit(main())
agent_base/tools/__init__.py CHANGED
@@ -10,6 +10,7 @@ __all__ = [
10
  "ReadImage",
11
  "ReadPDF",
12
  "ScholarSearch",
 
13
  "TerminalInterrupt",
14
  "TerminalKill",
15
  "TerminalRead",
@@ -30,6 +31,7 @@ _EXPORT_TO_MODULE = {
30
  "ReadImage": "agent_base.tools.tool_file",
31
  "ReadPDF": "agent_base.tools.tool_file",
32
  "ScholarSearch": "agent_base.tools.tool_web",
 
33
  "TerminalInterrupt": "agent_base.tools.tool_runtime",
34
  "TerminalKill": "agent_base.tools.tool_runtime",
35
  "TerminalRead": "agent_base.tools.tool_runtime",
 
10
  "ReadImage",
11
  "ReadPDF",
12
  "ScholarSearch",
13
+ "StrReplaceEditor",
14
  "TerminalInterrupt",
15
  "TerminalKill",
16
  "TerminalRead",
 
31
  "ReadImage": "agent_base.tools.tool_file",
32
  "ReadPDF": "agent_base.tools.tool_file",
33
  "ScholarSearch": "agent_base.tools.tool_web",
34
+ "StrReplaceEditor": "agent_base.tools.tool_extra",
35
  "TerminalInterrupt": "agent_base.tools.tool_runtime",
36
  "TerminalKill": "agent_base.tools.tool_runtime",
37
  "TerminalRead": "agent_base.tools.tool_runtime",
agent_base/tools/tool_extra.py ADDED
@@ -0,0 +1,343 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import threading
5
+ import zipfile
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any, Optional, Union
9
+ from xml.etree import ElementTree
10
+
11
+ from agent_base.tools.tool_file import ReadPDF
12
+ from agent_base.tools.tooling import ToolBase, validate_tool_path
13
+
14
+
15
+ DEFAULT_EDITOR_MAX_CHARS = 20000
16
+ BINARY_MARKDOWN_SUFFIXES = {".xlsx", ".pptx", ".wav", ".mp3", ".m4a", ".flac", ".pdf", ".docx"}
17
+
18
+
19
+ @dataclass
20
+ class _UndoRecord:
21
+ existed: bool
22
+ previous_text: str
23
+
24
+
25
+ class StrReplaceEditor(ToolBase):
26
+ name = "str_replace_editor"
27
+ description = (
28
+ "Optional compatibility editor for viewing, creating, exact string replacement, "
29
+ "line insertion, and undoing the last edit to a workspace text file."
30
+ )
31
+ parameters = {
32
+ "type": "object",
33
+ "properties": {
34
+ "command": {
35
+ "type": "string",
36
+ "description": "Allowed commands: view, create, str_replace, insert, undo_edit.",
37
+ "enum": ["view", "create", "str_replace", "insert", "undo_edit"],
38
+ },
39
+ "path": {
40
+ "type": "string",
41
+ "description": "Absolute path to a file or directory inside the workspace.",
42
+ },
43
+ "file_text": {
44
+ "type": "string",
45
+ "description": "Required for create; full text content of the file to create.",
46
+ },
47
+ "old_str": {
48
+ "type": "string",
49
+ "description": "Required for str_replace; exact unique string to replace.",
50
+ },
51
+ "new_str": {
52
+ "type": "string",
53
+ "description": "Replacement text for str_replace, or text block for insert.",
54
+ },
55
+ "insert_line": {
56
+ "type": "integer",
57
+ "description": (
58
+ "Required for insert; insert new_str after this 1-based line. "
59
+ "Use 0 to insert at the start."
60
+ ),
61
+ },
62
+ "view_range": {
63
+ "type": "array",
64
+ "description": "Optional [start_line, end_line] for file view. Use [start_line, -1] to read to EOF.",
65
+ "items": {"type": "integer"},
66
+ },
67
+ },
68
+ "required": ["command", "path"],
69
+ "additionalProperties": False,
70
+ }
71
+
72
+ def __init__(self, cfg: Optional[dict] = None):
73
+ super().__init__(cfg)
74
+ self._undo_records: dict[Path, _UndoRecord] = {}
75
+ self._lock = threading.Lock()
76
+
77
+ def call(self, params: Union[str, dict], **kwargs: Any) -> str:
78
+ try:
79
+ parsed = self.parse_json_args(params)
80
+ command = str(parsed.get("command", "")).strip()
81
+ path = self._resolve_absolute_path(
82
+ str(parsed.get("path", "")),
83
+ workspace_root=kwargs.get("workspace_root"),
84
+ )
85
+ except ValueError as exc:
86
+ return f"[str_replace_editor] {exc}"
87
+
88
+ if command == "view":
89
+ return self._view(path, parsed, workspace_root=kwargs.get("workspace_root"))
90
+ if command == "create":
91
+ return self._create(path, parsed)
92
+ if command == "str_replace":
93
+ return self._str_replace(path, parsed)
94
+ if command == "insert":
95
+ return self._insert(path, parsed)
96
+ if command == "undo_edit":
97
+ return self._undo_edit(path)
98
+ return "[str_replace_editor] command must be one of: view, create, str_replace, insert, undo_edit."
99
+
100
+ def _resolve_absolute_path(self, path_value: str, *, workspace_root: Any) -> Path:
101
+ path_text = str(path_value or "").strip()
102
+ if not path_text:
103
+ raise ValueError("path must be a non-empty absolute path.")
104
+ path = Path(path_text).expanduser()
105
+ if not path.is_absolute():
106
+ raise ValueError("path must be absolute.")
107
+ return validate_tool_path(path, "str_replace_editor access", base_root=workspace_root)
108
+
109
+ def _view(self, path: Path, params: dict[str, Any], *, workspace_root: Any) -> str:
110
+ if not path.exists():
111
+ return f"[str_replace_editor] Path not found: {path}"
112
+ if path.is_dir():
113
+ return self._clip(self._view_directory(path))
114
+ if not path.is_file():
115
+ return f"[str_replace_editor] Path is neither a file nor a directory: {path}"
116
+ if path.suffix.lower() in BINARY_MARKDOWN_SUFFIXES:
117
+ return self._clip(self._view_binary_markdown(path, workspace_root=workspace_root))
118
+ try:
119
+ text = self._read_text(path)
120
+ except (OSError, UnicodeDecodeError) as exc:
121
+ return f"[str_replace_editor] Error reading text file: {exc}"
122
+ try:
123
+ selected, start_line, end_line = self._select_view_range(text, params.get("view_range"))
124
+ except ValueError as exc:
125
+ return f"[str_replace_editor] {exc}"
126
+ numbered = self._number_lines(selected, start_line=start_line)
127
+ header = {
128
+ "path": str(path),
129
+ "start_line": start_line,
130
+ "end_line": end_line,
131
+ "total_lines": len(text.splitlines()),
132
+ }
133
+ return self._clip(json.dumps(header, ensure_ascii=False) + "\n" + numbered)
134
+
135
+ def _create(self, path: Path, params: dict[str, Any]) -> str:
136
+ if "file_text" not in params:
137
+ return "[str_replace_editor] Missing required parameter for create: file_text"
138
+ if path.exists():
139
+ return f"[str_replace_editor] Cannot create because file already exists: {path}"
140
+ if not path.parent.exists() or not path.parent.is_dir():
141
+ return f"[str_replace_editor] Parent directory does not exist: {path.parent}"
142
+ file_text = str(params.get("file_text", ""))
143
+ try:
144
+ path.write_text(file_text, encoding="utf-8")
145
+ except OSError as exc:
146
+ return f"[str_replace_editor] Error creating file: {exc}"
147
+ with self._lock:
148
+ self._undo_records[path] = _UndoRecord(existed=False, previous_text="")
149
+ return f"[str_replace_editor] Created file: {path}"
150
+
151
+ def _str_replace(self, path: Path, params: dict[str, Any]) -> str:
152
+ if "old_str" not in params:
153
+ return "[str_replace_editor] Missing required parameter for str_replace: old_str"
154
+ old_str = str(params.get("old_str", ""))
155
+ new_str = str(params.get("new_str", ""))
156
+ if not old_str:
157
+ return "[str_replace_editor] old_str must be non-empty."
158
+ if old_str == new_str:
159
+ return "[str_replace_editor] old_str and new_str must be different."
160
+ try:
161
+ original = self._read_existing_file(path)
162
+ except (ValueError, OSError, UnicodeDecodeError) as exc:
163
+ return f"[str_replace_editor] {exc}"
164
+ match_count = original.count(old_str)
165
+ if match_count == 0:
166
+ return "[str_replace_editor] old_str was not found exactly once; found 0 matches."
167
+ if match_count > 1:
168
+ return f"[str_replace_editor] old_str must be unique; found {match_count} matches."
169
+ updated = original.replace(old_str, new_str, 1)
170
+ try:
171
+ path.write_text(updated, encoding="utf-8")
172
+ except OSError as exc:
173
+ return f"[str_replace_editor] Error writing file: {exc}"
174
+ with self._lock:
175
+ self._undo_records[path] = _UndoRecord(existed=True, previous_text=original)
176
+ return f"[str_replace_editor] Replaced text in file: {path}"
177
+
178
+ def _insert(self, path: Path, params: dict[str, Any]) -> str:
179
+ if "insert_line" not in params:
180
+ return "[str_replace_editor] Missing required parameter for insert: insert_line"
181
+ if "new_str" not in params:
182
+ return "[str_replace_editor] Missing required parameter for insert: new_str"
183
+ try:
184
+ insert_line = int(params.get("insert_line"))
185
+ except (TypeError, ValueError):
186
+ return "[str_replace_editor] insert_line must be an integer."
187
+ new_str = str(params.get("new_str", ""))
188
+ try:
189
+ original = self._read_existing_file(path)
190
+ except (ValueError, OSError, UnicodeDecodeError) as exc:
191
+ return f"[str_replace_editor] {exc}"
192
+ lines = original.splitlines(keepends=True)
193
+ if insert_line < 0 or insert_line > len(lines):
194
+ return f"[str_replace_editor] insert_line must be between 0 and {len(lines)}."
195
+ insert_text = new_str if new_str.endswith("\n") else new_str + "\n"
196
+ updated = "".join(lines[:insert_line]) + insert_text + "".join(lines[insert_line:])
197
+ try:
198
+ path.write_text(updated, encoding="utf-8")
199
+ except OSError as exc:
200
+ return f"[str_replace_editor] Error writing file: {exc}"
201
+ with self._lock:
202
+ self._undo_records[path] = _UndoRecord(existed=True, previous_text=original)
203
+ return f"[str_replace_editor] Inserted text after line {insert_line} in file: {path}"
204
+
205
+ def _undo_edit(self, path: Path) -> str:
206
+ with self._lock:
207
+ record = self._undo_records.pop(path, None)
208
+ if record is None:
209
+ return f"[str_replace_editor] No edit to undo for file: {path}"
210
+ try:
211
+ if record.existed:
212
+ path.write_text(record.previous_text, encoding="utf-8")
213
+ return f"[str_replace_editor] Reverted last edit to file: {path}"
214
+ if path.exists():
215
+ path.unlink()
216
+ return f"[str_replace_editor] Removed file created by last edit: {path}"
217
+ except OSError as exc:
218
+ return f"[str_replace_editor] Error undoing edit: {exc}"
219
+
220
+ def _read_existing_file(self, path: Path) -> str:
221
+ if not path.exists():
222
+ raise ValueError(f"File not found: {path}")
223
+ if not path.is_file():
224
+ raise ValueError(f"Path is not a file: {path}")
225
+ if path.suffix.lower() in BINARY_MARKDOWN_SUFFIXES:
226
+ raise ValueError(f"Editing binary/derived formats is not supported: {path}")
227
+ return self._read_text(path)
228
+
229
+ def _read_text(self, path: Path) -> str:
230
+ return path.read_text(encoding="utf-8")
231
+
232
+ def _select_view_range(self, text: str, view_range: Any) -> tuple[list[str], int, int]:
233
+ lines = text.splitlines()
234
+ if view_range is None:
235
+ return lines, 1, len(lines)
236
+ if not isinstance(view_range, list) or len(view_range) != 2:
237
+ raise ValueError("view_range must be [start_line, end_line].")
238
+ try:
239
+ start_line = int(view_range[0])
240
+ end_line = int(view_range[1])
241
+ except (TypeError, ValueError) as exc:
242
+ raise ValueError("view_range entries must be integers.") from exc
243
+ if start_line < 1:
244
+ raise ValueError("view_range start_line must be >= 1.")
245
+ if end_line != -1 and end_line < start_line:
246
+ raise ValueError("view_range end_line must be -1 or >= start_line.")
247
+ resolved_end = len(lines) if end_line == -1 else min(end_line, len(lines))
248
+ return lines[start_line - 1:resolved_end], start_line, resolved_end
249
+
250
+ def _number_lines(self, lines: list[str], *, start_line: int) -> str:
251
+ return "\n".join(f"{line_number:6}\t{line}" for line_number, line in enumerate(lines, start=start_line))
252
+
253
+ def _view_directory(self, path: Path) -> str:
254
+ rows = [str(path)]
255
+ for child in self._iter_non_hidden(path):
256
+ rel = child.relative_to(path)
257
+ rows.append(f"{rel}/" if child.is_dir() else str(rel))
258
+ if child.is_dir():
259
+ for grandchild in self._iter_non_hidden(child):
260
+ grand_rel = grandchild.relative_to(path)
261
+ rows.append(f"{grand_rel}/" if grandchild.is_dir() else str(grand_rel))
262
+ return "\n".join(rows)
263
+
264
+ def _iter_non_hidden(self, path: Path) -> list[Path]:
265
+ try:
266
+ return sorted(
267
+ (child for child in path.iterdir() if not child.name.startswith(".")),
268
+ key=lambda item: item.name.casefold(),
269
+ )
270
+ except OSError:
271
+ return []
272
+
273
+ def _view_binary_markdown(self, path: Path, *, workspace_root: Any) -> str:
274
+ suffix = path.suffix.lower()
275
+ if suffix == ".pdf":
276
+ return ReadPDF().call(
277
+ {"path": str(path), "max_chars": DEFAULT_EDITOR_MAX_CHARS},
278
+ workspace_root=workspace_root,
279
+ )
280
+ if suffix == ".docx":
281
+ return self._extract_docx_text(path)
282
+ if suffix == ".pptx":
283
+ return self._extract_pptx_text(path)
284
+ if suffix == ".xlsx":
285
+ return self._extract_xlsx_text(path)
286
+ stat = path.stat()
287
+ return f"# {path.name}\n\nBinary media file.\n\n- path: {path}\n- size_bytes: {stat.st_size}"
288
+
289
+ def _extract_docx_text(self, path: Path) -> str:
290
+ try:
291
+ with zipfile.ZipFile(path) as archive:
292
+ xml = archive.read("word/document.xml")
293
+ except (KeyError, OSError, zipfile.BadZipFile) as exc:
294
+ return f"[str_replace_editor] Error reading DOCX: {exc}"
295
+ try:
296
+ text = self._xml_text(xml)
297
+ except ElementTree.ParseError as exc:
298
+ return f"[str_replace_editor] Error reading DOCX XML: {exc}"
299
+ return f"# {path.name}\n\n" + text
300
+
301
+ def _extract_pptx_text(self, path: Path) -> str:
302
+ try:
303
+ with zipfile.ZipFile(path) as archive:
304
+ slide_names = sorted(
305
+ name
306
+ for name in archive.namelist()
307
+ if name.startswith("ppt/slides/slide") and name.endswith(".xml")
308
+ )
309
+ sections = []
310
+ for index, name in enumerate(slide_names, start=1):
311
+ text = self._xml_text(archive.read(name)).strip()
312
+ if text:
313
+ sections.append(f"## Slide {index}\n\n{text}")
314
+ except (OSError, zipfile.BadZipFile, ElementTree.ParseError) as exc:
315
+ return f"[str_replace_editor] Error reading PPTX: {exc}"
316
+ return f"# {path.name}\n\n" + "\n\n".join(sections)
317
+
318
+ def _extract_xlsx_text(self, path: Path) -> str:
319
+ try:
320
+ with zipfile.ZipFile(path) as archive:
321
+ names = sorted(
322
+ name
323
+ for name in archive.namelist()
324
+ if name.startswith("xl/worksheets/") and name.endswith(".xml")
325
+ )
326
+ sections = []
327
+ for index, name in enumerate(names, start=1):
328
+ text = self._xml_text(archive.read(name)).strip()
329
+ if text:
330
+ sections.append(f"## Sheet {index}\n\n{text}")
331
+ except (OSError, zipfile.BadZipFile, ElementTree.ParseError) as exc:
332
+ return f"[str_replace_editor] Error reading XLSX: {exc}"
333
+ return f"# {path.name}\n\n" + "\n\n".join(sections)
334
+
335
+ def _xml_text(self, xml_bytes: bytes) -> str:
336
+ root = ElementTree.fromstring(xml_bytes)
337
+ values = [text.strip() for text in root.itertext() if text and text.strip()]
338
+ return "\n".join(values)
339
+
340
+ def _clip(self, text: str, max_chars: int = DEFAULT_EDITOR_MAX_CHARS) -> str:
341
+ if len(text) <= max_chars:
342
+ return text
343
+ return text[:max_chars] + "\n<response clipped>"
agent_base/utils.py CHANGED
@@ -87,6 +87,21 @@ def require_required_env(context: str = "ResearchHarness") -> None:
87
  )
88
 
89
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  def _safe_image_stem(name: str, fallback: str) -> str:
91
  stem = re.sub(r"[^A-Za-z0-9_.-]+", "_", Path(name).stem).strip("._")
92
  return stem or fallback
 
87
  )
88
 
89
 
90
+ def read_role_prompt_files(paths: Iterable[str]) -> str:
91
+ blocks: list[str] = []
92
+ for raw_path in paths:
93
+ path_text = str(raw_path).strip()
94
+ if not path_text:
95
+ continue
96
+ path = Path(path_text).expanduser()
97
+ if not path.exists():
98
+ raise ValueError(f"Role prompt file does not exist: {path}")
99
+ if not path.is_file():
100
+ raise ValueError(f"Role prompt path is not a file: {path}")
101
+ blocks.append(path.read_text(encoding="utf-8").strip())
102
+ return "\n\n".join(block for block in blocks if block.strip())
103
+
104
+
105
  def _safe_image_stem(name: str, fallback: str) -> str:
106
  stem = re.sub(r"[^A-Za-z0-9_.-]+", "_", Path(name).stem).strip("._")
107
  return stem or fallback