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.
- shellbent-0.1.0.dist-info/METADATA +9 -0
- shellbent-0.1.0.dist-info/RECORD +9 -0
- shellbent-0.1.0.dist-info/WHEEL +4 -0
- shellbent-0.1.0.dist-info/licenses/LICENSE +21 -0
- thesis_tools/__init__.py +0 -0
- thesis_tools/args.py +31 -0
- thesis_tools/model.py +327 -0
- thesis_tools/read.py +127 -0
- thesis_tools/search.py +195 -0
|
@@ -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,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.
|
thesis_tools/__init__.py
ADDED
|
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()
|