shellbent 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.5
2
+ Name: shellbent
3
+ Version: 0.1.0
4
+ Summary: mini-swe-agent 2.4.6 model classes and named tools for a thesis on agent tool surfaces
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: mini-swe-agent==2.4.6
9
+ Requires-Dist: ripgrep-bin==15.2.0; sys_platform == 'linux'
@@ -0,0 +1,9 @@
1
+ thesis_tools/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ thesis_tools/args.py,sha256=ZcSDZ2YmJcdBdRHuH9jYciIzvcTUgGgrhnmtmxGjzPI,1649
3
+ thesis_tools/model.py,sha256=juoW0f5gZMKIckOBCrs-j0UKepcY7Ar0NMksAc7oaf8,17285
4
+ thesis_tools/read.py,sha256=uIt3WXOOG-WrjcAV-ZT5ArE_PqzkvTcUokCbSzmEF_U,5420
5
+ thesis_tools/search.py,sha256=kVZq7Oo0IIbjGYz3bWTqwDab1WW80PpTbrNlkInyurg,7985
6
+ shellbent-0.1.0.dist-info/METADATA,sha256=VVfncNNxHcRuS1HkwwIWHQGN1XgLxclhDwuNuceVfq4,316
7
+ shellbent-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
8
+ shellbent-0.1.0.dist-info/licenses/LICENSE,sha256=Y7EDeoDYcAiZCDcVCfOelB2z4xa9gogiWZdsoiV73jc,1074
9
+ shellbent-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alexander Posztos
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
File without changes
thesis_tools/args.py ADDED
@@ -0,0 +1,31 @@
1
+ """Argument checking shared by the tool CLIs. Each tool gets its JSON arguments as one argv string, checks them
2
+ against its own schema and reports problems as the tool result, in pi's message format."""
3
+
4
+ import json
5
+
6
+
7
+ def validate(raw_args, tool, error):
8
+ """Parses the JSON arguments and checks them against `tool`'s schema (an OpenAI function tool dict). Returns
9
+ the kwargs for the tool function. Bad arguments raise `error` (the tool's own exception class) with the message
10
+ the model sees. No type conversion. Unknown keys are ignored, like in pi; this also drops the `why_this_tool`
11
+ and `why_not_<tool>` keys that the reason prompts add."""
12
+ try:
13
+ args = json.loads(raw_args)
14
+ except ValueError as e:
15
+ raise error(f"Error parsing tool call arguments: {e}.") from e
16
+ if not isinstance(args, dict):
17
+ raise error("Tool call arguments must be a JSON object.")
18
+ name = tool["function"]["name"]
19
+ params = tool["function"]["parameters"]
20
+ props = params["properties"]
21
+ types = {"string": str, "boolean": bool, "number": (int, float)}
22
+ errors = [f" - /{key}: Expected required property" for key in params.get("required", []) if key not in args]
23
+ for key, value in args.items():
24
+ if key in props and not isinstance(value, types[props[key]["type"]]):
25
+ errors.append(f" - /{key}: Expected {props[key]['type']}")
26
+ if errors:
27
+ raise error(
28
+ f'Validation failed for tool "{name}":\n' + "\n".join(errors)
29
+ + f"\n\nReceived arguments:\n{json.dumps(args, indent=2)}"
30
+ )
31
+ return {key: value for key, value in args.items() if key in props}
thesis_tools/model.py ADDED
@@ -0,0 +1,327 @@
1
+ """The model classes behind the conditions. Pier selects one with --ak model_class=thesis_tools.model.<class>. The
2
+ model class sends every LLM request and the tools list is part of it, so this is where the tool surface is set.
3
+
4
+ BashModel is the baseline, with bash as the only tool. ToolsModel adds the named tools from the `tools` yaml key
5
+ (search and/or read) next to bash. tool_directive is put in front of the search tool description, so it can change
6
+ in the yaml without a new package version.
7
+
8
+ reason_prompt adds a required `why_this_tool` string as the first parameter of every tool, and reason_against_prompt
9
+ one `why_not_<tool>` string per other tool. The tool names are in the field names so the model cannot mix up which
10
+ reason belongs to which tool. The fields are stored with the call in the trajectory and never reach the agent. A call
11
+ with a missing, blank or misnamed field is refused. After MAX_REASON_REFUSALS refused responses in a row such calls
12
+ run anyway, so a model that ignores the fields or gets them wrong does not loop and does not kill the trial.
13
+
14
+ block_grep and block_read (ToolsModel only) are the leave-one-out configurations. A bash command that runs grep or rg
15
+ (block_grep), or cat, head, nl or sed -n (block_read), is refused with a nudge to use the named tool instead. The
16
+ same commands as a filter after a pipe stay allowed (ps aux | grep, git log | head), and heredoc bodies are not
17
+ blocked.
18
+
19
+ When we refuse a call, we do not run it and return a message saying what to do instead, with exit code 127. We do
20
+ not return a format error, because mini-swe-agent ends a trial after 3 format errors in a row. The other calls in
21
+ the same response still run.
22
+
23
+ Both classes abort the trial on a context overflow.
24
+ """
25
+
26
+ import copy
27
+ import json
28
+ import re
29
+ import shlex
30
+ import sys
31
+ import time
32
+
33
+ import litellm
34
+ from minisweagent.models.litellm_model import LitellmModel, LitellmModelConfig
35
+ from minisweagent.models.utils.actions_toolcall import BASH_TOOL, parse_toolcall_actions
36
+
37
+ from thesis_tools.read import READ_TOOL
38
+ from thesis_tools.search import SEARCH_TOOL
39
+
40
+ NAMED_TOOLS = {"search": (SEARCH_TOOL, "thesis_tools.search"), "read": (READ_TOOL, "thesis_tools.read")}
41
+
42
+ class BashModelConfig(LitellmModelConfig):
43
+ reason_prompt: str = ""
44
+ """Description of a required `why_this_tool` parameter put first in every tool's schema. Empty means no parameter."""
45
+ reason_against_prompt: str = ""
46
+ """Description of a required `why_not_<tool>` parameter per other tool, after `why_this_tool`. `{tool}` is
47
+ replaced by the other tool's name. Only used together with reason_prompt."""
48
+ min_request_interval: float = 0
49
+ """Seconds a trial waits at least between the starts of two model calls. Trials run in separate containers and
50
+ cannot share a rate limiter, so each one paces itself: with n parallel trials and a quota of C requests per hour,
51
+ n * 3600 / C keeps the whole run under the quota. 0 means no pacing (the default, fine without a quota)."""
52
+ max_rate_limit_wait: float = 7200
53
+ """Total seconds a trial may sleep on Retry-After waits (see rate_limit_wait) before the RateLimitError is
54
+ raised and ends the trial."""
55
+
56
+
57
+ REASON_FIELD = "why_this_tool"
58
+
59
+
60
+ def reason_fields(names, prompt, against):
61
+ """Maps each tool name to its reason parameters (field name -> description): REASON_FIELD, then why_not_<other>
62
+ per other tool if `against` is set. Empty dicts without a prompt."""
63
+ fields = {}
64
+ for name in names:
65
+ fields[name] = {REASON_FIELD: prompt.strip()} if prompt else {}
66
+ if prompt and against:
67
+ fields[name] |= {f"why_not_{o}": against.strip().replace("{tool}", o) for o in names if o != name}
68
+ return fields
69
+
70
+
71
+ def add_reasons(tool, fields):
72
+ """Returns a copy of a tool schema with `fields` as its first, required parameters. Properties keep their order
73
+ in the JSON schema, and models fill the arguments in that order, so the reasons come before the action.
74
+ The original dict is untouched (BASH_TOOL and SEARCH_TOOL are module constants)."""
75
+ if not fields:
76
+ return tool
77
+ tool = copy.deepcopy(tool)
78
+ params = tool["function"]["parameters"]
79
+ params["properties"] = {k: {"type": "string", "description": v} for k, v in fields.items()} | params["properties"]
80
+ params["required"] = list(fields) + [k for k in params.get("required", []) if k not in fields]
81
+ return tool
82
+
83
+
84
+ # after this many refused responses in a row, calls with missing reasons are no longer refused and just run, so
85
+ # the model cannot loop
86
+ MAX_REASON_REFUSALS = 3
87
+
88
+
89
+ def reason_gap(tool_call, fields):
90
+ """Returns the refusal message if the call is missing a reason field, else None."""
91
+ try:
92
+ args = json.loads(tool_call.function.arguments)
93
+ except Exception:
94
+ return None
95
+ if not isinstance(args, dict):
96
+ return None
97
+ name = tool_call.function.name
98
+ missing = [f for f in fields.get(name, {}) if not str(args.get(f, "")).strip()]
99
+ if not missing:
100
+ return None
101
+ text = f"Nothing in this {name} call was run. It is missing {', '.join(missing)}."
102
+ wrong = [k for k in args if k.startswith("why_") and k not in fields[name]]
103
+ if wrong:
104
+ text += f" The {name} tool has no {', '.join(wrong)} argument."
105
+ return text + " Send the call again with every why_ argument filled in."
106
+
107
+
108
+ RETRY_AFTER = re.compile(r"Retry after (\d+)s")
109
+
110
+
111
+ def rate_limit_wait(error, default=60):
112
+ """Seconds to wait after a 429 from litellm's RateLimitError: the Retry-After header if present, else a
113
+ "(Retry after Ns)" in the message, else `default`. Providers with hourly or daily quotas answer with waits of up to
114
+ an hour; litellm's own backoff caps every sleep at 10 s and gives up long before that."""
115
+ headers = getattr(error, "headers", None) or getattr(getattr(error, "response", None), "headers", None) or {}
116
+ value = headers.get("retry-after") or headers.get("Retry-After")
117
+ try:
118
+ return float(value)
119
+ except (TypeError, ValueError):
120
+ pass
121
+ match = RETRY_AFTER.search(str(error))
122
+ return float(match.group(1)) if match else default
123
+
124
+
125
+ class BashModel(LitellmModel):
126
+ """Baseline. A copy of mini-swe-agent's LitellmModel with two changes.
127
+
128
+ 1. tools=[BASH_TOOL] is not hardcoded, we send self.tools instead, so ToolsModel can add tools.
129
+ 2. the except block immediately stops the trial on a context overflow. Some providers send it as a plain
130
+ BadRequestError, which mini retries until the rate limit or the timeout kills the trial.
131
+ Re-raising it as ContextWindowExceededError makes mini abort right away.
132
+ 3. rate limits (optional, both default off): min_request_interval paces the calls so a run stays under a
133
+ provider's request quota, and a RateLimitError that survives litellm's own retries (num_retries in
134
+ model_kwargs) is slept out for the Retry-After time and retried instead of ending the trial. Not needed
135
+ for a self-served model or a provider without quotas.
136
+ """
137
+
138
+ def __init__(self, *, config_class=BashModelConfig, **kwargs):
139
+ super().__init__(config_class=config_class, **kwargs)
140
+ self.last_request = -float("inf")
141
+ tools = self._tools()
142
+ names = [tool["function"]["name"] for tool in tools]
143
+ self.reason_fields = reason_fields(names, self.config.reason_prompt, self.config.reason_against_prompt)
144
+ self.tools = tuple(add_reasons(tool, self.reason_fields[name]) for tool, name in zip(tools, names))
145
+ self.reason_refusals = 0
146
+
147
+ def _tools(self):
148
+ """The tool surface of this condition, before the reason parameter is added. Subclasses override."""
149
+ return [BASH_TOOL]
150
+
151
+ def _parse_actions(self, response):
152
+ actions = super()._parse_actions(response)
153
+ return self.refuse_reason_gaps(response.choices[0].message.tool_calls or [], actions)
154
+
155
+ def refuse_reason_gaps(self, tool_calls, actions):
156
+ """Refuses every call that is missing a reason field. A response with all fields filled in resets the
157
+ count, and after MAX_REASON_REFUSALS refused responses in a row the calls run anyway."""
158
+ gaps = {tool_call.id: reason_gap(tool_call, self.reason_fields) for tool_call in tool_calls}
159
+ if not any(gaps.values()):
160
+ self.reason_refusals = 0
161
+ return actions
162
+ if self.reason_refusals >= MAX_REASON_REFUSALS:
163
+ return actions
164
+ self.reason_refusals += 1
165
+ for action in actions:
166
+ if gaps.get(action["tool_call_id"]):
167
+ action["command"] = refusal(gaps[action["tool_call_id"]])
168
+ return actions
169
+
170
+ def _query(self, messages, **kwargs):
171
+ slept = 0
172
+ while True:
173
+ pause = self.last_request + self.config.min_request_interval - time.monotonic()
174
+ if pause > 0:
175
+ time.sleep(pause)
176
+ self.last_request = time.monotonic()
177
+ try:
178
+ return litellm.completion(
179
+ model=self.config.model_name,
180
+ messages=messages,
181
+ tools=self.tools,
182
+ **(self.config.model_kwargs | kwargs),
183
+ )
184
+ except litellm.exceptions.BadRequestError as e:
185
+ if "maximum context length" in str(e) and not isinstance(e, litellm.exceptions.ContextWindowExceededError):
186
+ raise litellm.exceptions.ContextWindowExceededError(
187
+ str(e), self.config.model_name, getattr(e, "llm_provider", "")
188
+ ) from e
189
+ raise
190
+ except litellm.exceptions.RateLimitError as e:
191
+ wait = rate_limit_wait(e) + 5 # the window reopens at a clock boundary, 5 s of slack for clock skew
192
+ if slept + wait > self.config.max_rate_limit_wait:
193
+ raise
194
+ print(f"thesis_tools: rate limited, sleeping {wait:.0f}s ({str(e)[:200]})", file=sys.stderr, flush=True)
195
+ time.sleep(wait)
196
+ slept += wait
197
+
198
+
199
+ class ToolsModelConfig(BashModelConfig):
200
+ tools: list[str] = ["search"]
201
+ """The named tools, in this order. Names are defined in NAMED_TOOLS."""
202
+ tool_directive: str = ""
203
+ """String put in front of the search tool description (to optionally nudge the model to use this tool). Empty means nothing is prepended."""
204
+ block_grep: bool = False
205
+ """Reject bash commands that run grep or rg as a command (not as a filter after a pipe)."""
206
+ block_read: bool = False
207
+ """Reject bash commands that run cat, head, nl or sed -n as a command (not as a filter after a pipe)."""
208
+
209
+
210
+ # grep, rg, egrep or fgrep as a command word, not part of a longer token (--grep=, ./rg, my_grep, grep-exit) and not
211
+ # directly after a single pipe. `git grep`, `xargs grep`, `cd x && grep` and `find | xargs grep` are all blocked.
212
+ GREP_COMMAND = re.compile(r"(?<![\w./-])(?:grep|rg|egrep|fgrep)(?![\w-])")
213
+ BLOCKED_GREP = "Nothing in this command was run. grep and rg are currently not available as commands for you. Use the search tool to find text in files."
214
+ # a heredoc from its `<<WORD` line to the terminator line; the body is data (patch scripts, file contents), not commands
215
+ HEREDOC = re.compile(r"<<-?\s*(['\"]?)(\w+)\1[^\n]*\n.*?\n\s*\2(?=\s|$)", re.S)
216
+
217
+
218
+ def command_words(command):
219
+ """The command with heredoc bodies cut out, so the block rules only see what bash runs as commands."""
220
+ return HEREDOC.sub("<<HEREDOC", command)
221
+
222
+
223
+ def after_pipe(command, pos):
224
+ """True if the command word at `pos` directly follows a single `|` (a filter, allowed by every block rule)."""
225
+ before = command[:pos].rstrip()
226
+ return before.endswith("|") and not before.endswith("||")
227
+
228
+
229
+ def runs_grep(command):
230
+ """True if the bash command runs grep/rg as a command. A match right after a single `|` is a filter and allowed,
231
+ and a grep word after another one in the same segment is that grep's argument (`ps aux | grep -v grep`)."""
232
+ command = command_words(command)
233
+ previous = -1
234
+ for m in GREP_COMMAND.finditer(command):
235
+ argument = previous >= 0 and not SEGMENT_END.search(command, previous, m.start())
236
+ if not argument and not after_pipe(command, m.start()):
237
+ return True
238
+ previous = m.end()
239
+ return False
240
+
241
+
242
+ # These five regexes are the parts of the rule (runs_read below) that decides whether a bash command reads a file.
243
+ # READ_COMMAND finds cat, head, nl or sed standing as a command, not inside a longer token like ./cat, my_cat or
244
+ # git cat-file (same word rule as GREP_COMMAND). SEGMENT_END marks where that command's arguments stop.
245
+ # WRITE_REDIRECT checks those arguments for a heredoc or a stdout redirect, because `cat > file` and `cat <<EOF`
246
+ # write a file. SED_QUIET checks for -n, because only `sed -n` prints lines and counts as reading. SED_IN_PLACE
247
+ # checks for -i, because `sed -i` edits the file, even with -n.
248
+ #
249
+ # tail is left out on purpose. In the bash-only GLM run it polls logs of background jobs in 1,667 of 1,818 calls,
250
+ # and the read tool has no way to read the end of a file (no negative offset). The pagers less and more are left
251
+ # out too, because nobody runs them in a non-interactive shell and "more" is an English word that shows up in
252
+ # commit messages.
253
+ READ_COMMAND = re.compile(r"(?<![\w./-])(?:cat|head|nl|sed)(?![\w-])")
254
+ SEGMENT_END = re.compile(r"\|\|?|;|&&|\n") # the next |, ;, && or line break
255
+ WRITE_REDIRECT = re.compile(r"<<|(?<!\d)>") # 2> is stderr and does not count
256
+ SED_QUIET = re.compile(r"\s-(?:[a-zA-Z]*n|-quiet|-silent)") # also bundled, like -En or -ne
257
+ SED_IN_PLACE = re.compile(r"\s-(?:[a-zA-Z]*i|-in-place)")
258
+ BLOCKED_READ = "Nothing in this command was run. cat, head, nl and sed -n are currently not available as commands for you. Use the read tool to read files."
259
+
260
+
261
+ def refusal(message):
262
+ """We run this instead of a blocked bash command or a call with a reason gap. It just prints the text telling the
263
+ agent what to do instead and exits with 127, so the agent sees a normal failed command and not a format error."""
264
+ return f"printf '%s\\n' {shlex.quote(message)}; exit 127"
265
+
266
+
267
+ def runs_read(command):
268
+ """True if the bash command reads a file with cat, head, nl or sed -n as a command. Allowed: a filter after a
269
+ single `|`, `cat` that writes (heredoc or redirect), `sed` without -n or with -i (an edit, not a read), and a
270
+ command right inside $(...) or `...`, whose output becomes data for the outer command (javac -cp "$(cat cp.txt)")."""
271
+ command = command_words(command)
272
+ for m in READ_COMMAND.finditer(command):
273
+ if after_pipe(command, m.start()) or command[: m.start()].rstrip().endswith(("$(", "`")):
274
+ continue
275
+ args = SEGMENT_END.split(command[m.end():], 1)[0]
276
+ if m.group() == "cat" and WRITE_REDIRECT.search(args):
277
+ continue
278
+ if m.group() == "sed" and (not SED_QUIET.search(args) or SED_IN_PLACE.search(args)):
279
+ continue
280
+ return True
281
+ return False
282
+
283
+
284
+ class ToolsModel(BashModel):
285
+ """Extension. Adds the named tools from config.tools next to bash. A named tool call becomes a shell command
286
+ that runs the tool's module in the container, so environment, observations and trajectories stay as in
287
+ mini-swe-agent."""
288
+
289
+ def __init__(self, **kwargs):
290
+ super().__init__(config_class=ToolsModelConfig, **kwargs)
291
+
292
+ def _tools(self):
293
+ tools = [BASH_TOOL]
294
+ for name in self.config.tools:
295
+ tool = NAMED_TOOLS[name][0]
296
+ if name == "search" and self.config.tool_directive:
297
+ tool = copy.deepcopy(tool)
298
+ tool["function"]["description"] = self.config.tool_directive.strip() + " " + tool["function"]["description"]
299
+ tools.append(tool)
300
+ return tools
301
+
302
+ def _parse_actions(self, response):
303
+ tool_calls = response.choices[0].message.tool_calls or []
304
+ if not tool_calls:
305
+ return super()._parse_actions(response) # raises mini-swe-agent's "no tool calls" FormatError
306
+ finish_reason = response.choices[0].finish_reason
307
+ actions = []
308
+ for tool_call in tool_calls:
309
+ if tool_call.function.name in self.config.tools:
310
+ # the module checks the arguments itself and reports problems as its output
311
+ module = NAMED_TOOLS[tool_call.function.name][1]
312
+ command = shlex.join([sys.executable, "-m", module, tool_call.function.arguments])
313
+ actions.append({"command": command, "tool_call_id": tool_call.id})
314
+ continue
315
+ # bash and unknown tool names get mini-swe-agent's own checks
316
+ bash_actions = parse_toolcall_actions(
317
+ [tool_call],
318
+ format_error_template=self.config.format_error_template,
319
+ template_kwargs={"finish_reason": finish_reason},
320
+ )
321
+ for action in bash_actions:
322
+ if self.config.block_grep and runs_grep(action["command"]):
323
+ action["command"] = refusal(BLOCKED_GREP)
324
+ elif self.config.block_read and runs_read(action["command"]):
325
+ action["command"] = refusal(BLOCKED_READ)
326
+ actions += bash_actions
327
+ return self.refuse_reason_gaps(tool_calls, actions)
thesis_tools/read.py ADDED
@@ -0,0 +1,127 @@
1
+ """Read tool. A Python port of pi's read tool (earendil-works/pi @ 98d2e19 ),
2
+ text files only. ToolsModel runs it in the task container as `python -m thesis_tools.read '<json args>'`.
3
+ Differences from pi are marked "Deviation". Left out on purpose: images, the SSH-style pluggable file operations,
4
+ abort signals, the UI renderers and pi's macOS filename variants (NFD, curly quotes, the no-break space in
5
+ screenshot names), none of which exist in a benchmark container.
6
+ """
7
+
8
+ import os
9
+ import sys
10
+
11
+ from thesis_tools.args import validate as validate_args
12
+
13
+ DEFAULT_MAX_LINES = 2000
14
+ DEFAULT_MAX_BYTES = 50 * 1024
15
+
16
+ READ_TOOL = {
17
+ "type": "function",
18
+ "function": {
19
+ "name": "read",
20
+ "description": (
21
+ "Read the contents of a file. Each line is prefixed with its line number. "
22
+ f"Output is truncated to {DEFAULT_MAX_LINES} lines or {DEFAULT_MAX_BYTES // 1024}KB (whichever is hit "
23
+ "first). Use offset/limit for large files. When you need the full file, continue with offset until complete."
24
+ ),
25
+ "parameters": {
26
+ "type": "object",
27
+ "properties": {
28
+ "path": {"type": "string", "description": "Path to the file to read (relative or absolute)"},
29
+ "offset": {"type": "number", "description": "Line number to start reading from (1-indexed)"},
30
+ "limit": {"type": "number", "description": "Maximum number of lines to read"},
31
+ },
32
+ "required": ["path"],
33
+ },
34
+ },
35
+ }
36
+
37
+
38
+ class ReadError(Exception):
39
+ """An error the model sees as the tool result. Any other exception is a bug and crashes."""
40
+
41
+
42
+ def format_size(size):
43
+ if size < 1024:
44
+ return f"{size}B"
45
+ if size < 1024 * 1024:
46
+ return f"{size / 1024:.1f}KB"
47
+ return f"{size / (1024 * 1024):.1f}MB"
48
+
49
+
50
+ def truncate_head(lines):
51
+ """Keeps the first lines that fit into DEFAULT_MAX_LINES and DEFAULT_MAX_BYTES, never a partial line. Returns
52
+ (kept lines, what cut them: None, "lines", "bytes" or "first line" when even the first line is over the byte cap)."""
53
+ if len(lines) <= DEFAULT_MAX_LINES and sum(len(line.encode()) + 1 for line in lines) - 1 <= DEFAULT_MAX_BYTES:
54
+ return lines, None
55
+ if len(lines[0].encode()) > DEFAULT_MAX_BYTES:
56
+ return [], "first line"
57
+ kept, size = [], 0
58
+ for line in lines[:DEFAULT_MAX_LINES]:
59
+ size += len(line.encode()) + (1 if kept else 0)
60
+ if size > DEFAULT_MAX_BYTES:
61
+ return kept, "bytes"
62
+ kept.append(line)
63
+ return kept, "lines"
64
+
65
+
66
+ def read(path, offset=None, limit=None):
67
+ """Returns the text the model sees. Expected problems (missing file, offset past the end) raise ReadError."""
68
+ file_path = os.path.abspath(os.path.expanduser(path))
69
+ if os.path.isdir(file_path):
70
+ raise ReadError(f"Path is a directory, not a file: {path}")
71
+ try:
72
+ with open(file_path, encoding="utf-8", errors="replace", newline="") as f:
73
+ text = f.read()
74
+ except OSError as e:
75
+ raise ReadError(f"Cannot read {path}: {e.strerror}") from e
76
+
77
+ # Deviation: pi counts the empty string after a final newline as a line, so its "of N" is one too high
78
+ # for every file that ends in a newline. We count lines the way nl and wc -l do.
79
+ if not text:
80
+ return "(empty file)" # Deviation: pi returns the empty string, which models read as a failed call
81
+ all_lines = text.split("\n")
82
+ if text.endswith("\n"):
83
+ all_lines.pop()
84
+
85
+ # 1-indexed offset to a 0-indexed start
86
+ start = max(int(offset) - 1, 0) if offset else 0
87
+ if start >= len(all_lines):
88
+ raise ReadError(f"Offset {offset} is beyond end of file ({len(all_lines)} lines total)")
89
+ end = min(start + int(limit), len(all_lines)) if limit is not None else len(all_lines)
90
+
91
+ # Deviation: pi returns the raw text. We prefix every line with its number, as OpenCode and Claude Code do and as
92
+ # cat -n would, because the search tool reports file:line and this surface has no edit tool that needs the
93
+ # exact raw text. The caps apply to the numbered output, which is what the model gets.
94
+ numbered = [f"{n}: {line}" for n, line in enumerate(all_lines[start:end], start + 1)]
95
+ kept, cut_by = truncate_head(numbered)
96
+ if cut_by == "first line":
97
+ size = format_size(len(all_lines[start].encode()))
98
+ return (
99
+ f"[Line {start + 1} is {size}, exceeds {format_size(DEFAULT_MAX_BYTES)} limit. "
100
+ f"Use bash: sed -n '{start + 1}p' {path} | head -c {DEFAULT_MAX_BYTES}]"
101
+ )
102
+ output = "\n".join(kept)
103
+ last = start + len(kept)
104
+ if cut_by:
105
+ cap = f" ({format_size(DEFAULT_MAX_BYTES)} limit)" if cut_by == "bytes" else ""
106
+ output += f"\n\n[Showing lines {start + 1}-{last} of {len(all_lines)}{cap}. Use offset={last + 1} to continue.]"
107
+ elif last < len(all_lines):
108
+ output += f"\n\n[{len(all_lines) - last} more lines in file. Use offset={last + 1} to continue.]"
109
+ return output
110
+
111
+
112
+ def validate(raw_args):
113
+ """Checks the JSON arguments against READ_TOOL's schema, see args.validate."""
114
+ return validate_args(raw_args, READ_TOOL, ReadError)
115
+
116
+
117
+ def main():
118
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
119
+ try:
120
+ sys.stdout.write(read(**validate(sys.argv[1])))
121
+ except ReadError as e:
122
+ sys.stdout.write(str(e))
123
+ sys.exit(1)
124
+
125
+
126
+ if __name__ == "__main__":
127
+ main()
thesis_tools/search.py ADDED
@@ -0,0 +1,195 @@
1
+ """Search tool. A Python port of pi's grep tool (earendil-works/pi @ 98d2e19, MIT,
2
+ Copyright (c) 2025 Mario Zechner). ToolsModel runs it in the task container as
3
+ `python -m thesis_tools.search '<json args>'`. Differences from pi are marked "Deviation".
4
+ """
5
+
6
+ import functools
7
+ import json
8
+ import os
9
+ import subprocess
10
+ import sys
11
+ import tempfile
12
+ from pathlib import Path
13
+
14
+ from thesis_tools.args import validate as validate_args
15
+
16
+ DEFAULT_LIMIT = 100
17
+ DEFAULT_MAX_BYTES = 50 * 1024
18
+ MAX_LINE_LENGTH = 500
19
+
20
+ RG = Path(sys.executable).parent / "rg"
21
+
22
+ SEARCH_TOOL = {
23
+ "type": "function",
24
+ "function": {
25
+ "name": "search",
26
+ "description": (
27
+ "Search file contents for a pattern. Returns matching lines with file paths and line numbers. "
28
+ f"Respects .gitignore. Output is truncated to {DEFAULT_LIMIT} matches or "
29
+ f"{DEFAULT_MAX_BYTES // 1024}KB (whichever is hit first). "
30
+ f"Long lines are truncated to {MAX_LINE_LENGTH} chars."
31
+ ),
32
+ "parameters": {
33
+ "type": "object",
34
+ "properties": {
35
+ "pattern": {"type": "string", "description": "Search pattern (regex or literal string)"},
36
+ "path": {
37
+ "type": "string",
38
+ "description": "Directory or file to search (default: current directory)",
39
+ },
40
+ "glob": {
41
+ "type": "string",
42
+ "description": "Filter files by glob pattern, e.g. '*.ts' or '**/*.spec.ts'",
43
+ },
44
+ "ignoreCase": {"type": "boolean", "description": "Case-insensitive search (default: false)"},
45
+ "literal": {
46
+ "type": "boolean",
47
+ "description": "Treat pattern as literal string instead of regex (default: false)",
48
+ },
49
+ "context": {
50
+ "type": "number",
51
+ "description": "Number of lines to show before and after each match (default: 0)",
52
+ },
53
+ "limit": {"type": "number", "description": "Maximum number of matches to return (default: 100)"},
54
+ },
55
+ "required": ["pattern"],
56
+ },
57
+ },
58
+ }
59
+
60
+ class SearchError(Exception):
61
+ """An error the model sees as the tool result. Any other exception is a bug and crashes."""
62
+
63
+
64
+ @functools.cache
65
+ def file_lines(file_path):
66
+ """Reads a file's lines, cached so a file with many matches is read only once. Empty if unreadable."""
67
+ try:
68
+ # newline="" so only "\n" ends a line, like in rg. otherwise a lone "\r" shifts the line numbers.
69
+ # Deviation: pi splits on "\r" too, which misaligns context lines in old Mac-style files.
70
+ with open(file_path, encoding="utf-8", errors="replace", newline="") as f:
71
+ return f.read().split("\n")
72
+ except OSError:
73
+ return []
74
+
75
+
76
+ def truncate_line(line):
77
+ if len(line) <= MAX_LINE_LENGTH:
78
+ return line, False
79
+ return f"{line[:MAX_LINE_LENGTH]}... [truncated]", True
80
+
81
+
82
+ def truncate_head(content):
83
+ """Cuts the whole output at 50KB, never mid-line. Returns (text, whether it cut)."""
84
+ if len(content.encode()) <= DEFAULT_MAX_BYTES:
85
+ return content, False
86
+ kept, size = [], 0
87
+ for i, line in enumerate(content.split("\n")):
88
+ size += len(line.encode()) + (1 if i else 0)
89
+ if size > DEFAULT_MAX_BYTES:
90
+ break
91
+ kept.append(line)
92
+ return "\n".join(kept), True
93
+
94
+
95
+ def search(pattern, path=None, glob=None, ignoreCase=False, literal=False, context=0, limit=DEFAULT_LIMIT):
96
+ """Runs rg and returns the text the model sees. Expected problems (bad path, bad regex) raise SearchError."""
97
+ search_path = os.path.abspath(os.path.expanduser(path or "."))
98
+ if not os.path.exists(search_path):
99
+ raise SearchError(f"Path not found: {search_path}")
100
+ is_dir = os.path.isdir(search_path)
101
+
102
+ # how many lines to show above and below each match
103
+ context = max(int(context), 0)
104
+
105
+ # maximum number of matches to return
106
+ limit = max(int(limit), 1)
107
+
108
+ args = [RG, "--json", "--line-number", "--color=never", "--hidden"]
109
+ if ignoreCase:
110
+ args.append("--ignore-case")
111
+ if literal:
112
+ args.append("--fixed-strings")
113
+ if glob:
114
+ args += ["--glob", glob]
115
+ # Deviation: we exclude .git since hits in there aren't useful. pi doesn't, but most
116
+ # other harnesses do it this way. Has to come after the user's glob, the last glob wins in rg.
117
+ args += ["--glob", "!.git"]
118
+ args += ["--", pattern, search_path]
119
+
120
+ # we use the temporary file to write error messages into, since we only read stdout. a stderr pipe would fill up at 64KB
121
+ with tempfile.TemporaryFile() as stderr:
122
+ proc = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=stderr)
123
+ matches, limit_reached = [], False
124
+ for line in proc.stdout:
125
+ event = json.loads(line)
126
+ if event["type"] != "match":
127
+ continue
128
+ data = event["data"]
129
+ # rg sends "bytes" instead of "text" for paths that aren't valid UTF-8
130
+ file_path = data["path"].get("text")
131
+ if file_path:
132
+ matches.append((file_path, data["line_number"]))
133
+ if len(matches) >= limit:
134
+ limit_reached = True
135
+ proc.kill()
136
+ break
137
+ proc.stdout.close()
138
+ code = proc.wait()
139
+ stderr.seek(0)
140
+ stderr_text = stderr.read().decode(errors="replace").strip()
141
+
142
+ if not limit_reached and code not in (0, 1):
143
+ raise SearchError(stderr_text or f"ripgrep exited with code {code}")
144
+ if not matches:
145
+ return "No matches found"
146
+
147
+ lines_truncated = False
148
+ output_lines = []
149
+ # search is done, now we format each match as one line like "src/app.py:12: def target():".
150
+ # the model only sees this text, so it needs the file and line number to find the match again.
151
+ for file_path, line_number in matches:
152
+ # shorten the full path rg gives us. for a folder search, the path from that folder. for a file search, just the file name.
153
+ relative = os.path.relpath(file_path, search_path) if is_dir else os.path.basename(file_path)
154
+ lines = file_lines(file_path)
155
+ if not lines:
156
+ output_lines.append(f"{relative}:{line_number}: (unable to read file)")
157
+ continue
158
+ # print the match plus `context` lines above and below. ":" marks the match, "-" the lines around it
159
+ for current in range(max(1, line_number - context), min(len(lines), line_number + context) + 1):
160
+ text, cut = truncate_line(lines[current - 1].replace("\r", ""))
161
+ lines_truncated |= cut
162
+ sep = ":" if current == line_number else "-"
163
+ output_lines.append(f"{relative}{sep}{current}{sep} {text}")
164
+
165
+ output, bytes_truncated = truncate_head("\n".join(output_lines))
166
+ notices = []
167
+ if limit_reached:
168
+ notices.append(f"{limit} matches limit reached. Use limit={limit * 2} for more, or refine pattern")
169
+ if bytes_truncated:
170
+ notices.append(f"{DEFAULT_MAX_BYTES / 1024:.1f}KB limit reached")
171
+ if lines_truncated:
172
+ # Deviation: pi says "Use read tool to see full lines", and we dont have a read tool yet
173
+ notices.append(f"Some lines truncated to {MAX_LINE_LENGTH} chars. Use bash to see full lines")
174
+ if notices:
175
+ output += f"\n\n[{'. '.join(notices)}]"
176
+ return output
177
+
178
+
179
+ def validate(raw_args):
180
+ """Checks the JSON arguments against SEARCH_TOOL's schema, see args.validate."""
181
+ return validate_args(raw_args, SEARCH_TOOL, SearchError)
182
+
183
+
184
+ def main():
185
+ # always write UTF-8, so a container with an odd locale can't crash us on non-ASCII matches
186
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
187
+ try:
188
+ sys.stdout.write(search(**validate(sys.argv[1])))
189
+ except SearchError as e:
190
+ sys.stdout.write(str(e))
191
+ sys.exit(1)
192
+
193
+
194
+ if __name__ == "__main__":
195
+ main()