shellbent 0.1.0__tar.gz

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 @@
1
+ __pycache__/
@@ -0,0 +1,15 @@
1
+ # Configurations
2
+
3
+ | Config file | Model class | The agent gets |
4
+ |---|---|---|
5
+ | `bash.yaml` | `BashModel` | bash, default mini-swe-agent prompt (baseline) |
6
+ | `tools.yaml` | `ToolsModel` | bash + search, default prompt |
7
+ | `minimal.yaml` | either | bash (+ search), short prompt that names no tool |
8
+ | `minimal-directive.yaml` | `ToolsModel` | as minimal, the search description says to use it instead of grep |
9
+ | `minimal-reason.yaml` | either | as minimal, every call states why this tool and why not the others |
10
+ | `minimal-reason-nogrep.yaml` | `ToolsModel` | as reason, grep and rg refused in bash |
11
+ | `minimal-reason-read.yaml` | `ToolsModel` | bash + read, with reasons |
12
+ | `minimal-reason-read-nocat.yaml` | `ToolsModel` | as reason-read, cat, head, nl and sed -n refused in bash |
13
+ | `minimal-search-read.yaml` | `ToolsModel` | bash + search + read, short prompt |
14
+
15
+ Each yaml says what it changes and why in its header comment. `thesis_tools/model.py` documents the reason fields, the blocking and the rate-limit keys.
@@ -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.
@@ -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,22 @@
1
+ # shellbent
2
+
3
+ Coding agents using open-weight LLMs seem to be (s)hellbent on using shell commands for searching and reading code instead of named tools. This is the repo we use for the experiments in my master's thesis at TU Wien.
4
+
5
+ It adds named tools and model classes to [mini-swe-agent](https://github.com/SWE-agent/mini-swe-agent) 2.4.6, without forking it. Each configuration is a config file plus a model class, listed in [CONFIGURATIONS.md](CONFIGURATIONS.md).
6
+
7
+ ## Usage
8
+
9
+ With Pier, pinned to a commit:
10
+
11
+ ```
12
+ --ak 'extra_python_packages=["git+https://github.com/alexander-posztos/shellbent.git@<sha>"]'
13
+ --ak config_file=tools.yaml --ak model_class=thesis_tools.model.ToolsModel
14
+ ```
15
+
16
+ ## Tools
17
+
18
+ `search` and `read` are Python ports of [pi](https://github.com/earendil-works/pi)'s grep and read tools. They run in the task container as shell commands, so trajectories look the same as in stock mini-swe-agent.
19
+
20
+ ## License
21
+
22
+ MIT
@@ -0,0 +1,11 @@
1
+ # config for the baseline (bash only). Pier loads it on top of mini.yaml with
2
+ # --ak config_file=bash.yaml --ak model_class=thesis_tools.model.BashModel
3
+ # the only change is the retry block, since the inference provider caps requests per minute per key and
4
+ # mini doesn't retry RateLimitError on its own.
5
+ #
6
+ # model_class can't be set in yaml. Pier appends its own -c model.model_class after this file,
7
+ # and for openai/ models that defaults to litellm_response, so the CLI flag is the only way to win.
8
+ model:
9
+ model_kwargs:
10
+ num_retries: 15
11
+ retry_strategy: exponential_backoff_retry
@@ -0,0 +1,35 @@
1
+ # config for the tools condition with a tool_directive set. Same as minimal.yaml
2
+ # except model.tool_directive, which goes in front of the search tool's description. The agent block must
3
+ # stay byte-identical to minimal.yaml (tests/test_model.py checks that), the prompt is the control.
4
+ #
5
+ # why: with minimal.yaml GLM never picked the search tool (free choice, named grep then), and every production agent that ships a
6
+ # search tool next to a shell tool steers with an explicit sentence (Claude Code, Codex, Gemini CLI,
7
+ # OpenCode). Wording follows the OpenAI Codex guide: one directive, in the tool, naming the shell habit.
8
+ model:
9
+ tool_directive: Use this tool for every content search instead of running grep or rg through bash.
10
+ model_kwargs:
11
+ num_retries: 15
12
+ retry_strategy: exponential_backoff_retry
13
+ format_error_template: |
14
+ {% if finish_reason is defined and (finish_reason == "length" or (finish_reason == "tool_calls" and not has_tool_calls)) -%}
15
+ Your previous response reached the output token limit (finish_reason={{ finish_reason }}) before you produced a tool call, so it was cut off. Respond more concisely and finish with a tool call.
16
+ {%- else -%}
17
+ Tool call error:
18
+
19
+ <error>
20
+ {{error}}
21
+ </error>
22
+
23
+ Every response must contain at least one tool call. To finish, run `echo COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT` as the only bash command in that call.
24
+ {%- endif %}
25
+ agent:
26
+ instance_template: |
27
+ Please solve this issue: {{task}}
28
+
29
+ Use your tools to inspect the repository and implement the fix. Every call runs in a fresh subshell, so directory changes and environment variables do not persist between calls.
30
+
31
+ When you are done, run `echo COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT` as the only bash command in that call. You cannot continue after it.
32
+
33
+ <system_information>
34
+ {{system}} {{release}} {{version}} {{machine}}
35
+ </system_information>
@@ -0,0 +1,42 @@
1
+ # leave-one-out configuration: minimal-reason.yaml plus model.block_grep, so grep and rg are rejected as commands in bash
2
+ # (allowed as a filter after a pipe), which leaves the search tool as the offered way to search (sed, awk and python are not blocked). One sentence in the
3
+ # instance_template says so up front; a blocked command is not run, the agent just gets the same text back (exit
4
+ # code 127). Everything else is byte-identical to minimal-reason.yaml (tests/test_model.py checks that).
5
+ #
6
+ # why: with grep available the models never pick the search tool (about 2% of calls, 19 Oct 2026), and their stated
7
+ # reasons are post-hoc. Taking grep away shows whether the tool is usable at all: same pass rate means habit,
8
+ # a drop means the tool is worse than grep, and the reasons then say why.
9
+ model:
10
+ block_grep: true
11
+ reason_prompt: One sentence on why you chose to use this tool for this step. Do not describe what the step does.
12
+ reason_against_prompt: One sentence on why you did not choose the {tool} tool for this step.
13
+ model_kwargs:
14
+ num_retries: 15
15
+ retry_strategy: exponential_backoff_retry
16
+ format_error_template: |
17
+ {% if finish_reason is defined and (finish_reason == "length" or (finish_reason == "tool_calls" and not has_tool_calls)) -%}
18
+ Your previous response reached the output token limit (finish_reason={{ finish_reason }}) before you produced a tool call, so it was cut off. Respond more concisely and finish with a tool call.
19
+ {%- else -%}
20
+ Tool call error:
21
+
22
+ <error>
23
+ {{error}}
24
+ </error>
25
+
26
+ Every response must contain at least one tool call. To finish, run `echo COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT` as the only bash command in that call.
27
+ {%- endif %}
28
+ agent:
29
+ instance_template: |
30
+ Please solve this issue: {{task}}
31
+
32
+ Use your tools to inspect the repository and implement the fix. Every call runs in a fresh subshell, so directory changes and environment variables do not persist between calls.
33
+
34
+ grep and rg are currently not available as commands for you. Use the search tool to find text in files.
35
+
36
+ Every tool has a why_this_tool field and, for each other tool you have, a why_not_<tool> field. Fill why_this_tool with one sentence on why you chose that tool for the step and each why_not_<tool> field with one sentence on why you did not choose that tool. Do not describe what the step does, the command already says that.
37
+
38
+ When you are done, run `echo COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT` as the only bash command in that call. You cannot continue after it.
39
+
40
+ <system_information>
41
+ {{system}} {{release}} {{version}} {{machine}}
42
+ </system_information>
@@ -0,0 +1,44 @@
1
+ # leave-one-out configuration for the read tool: minimal-reason-read.yaml plus model.block_read, so cat, head, nl and sed -n
2
+ # are rejected as commands in bash (allowed as a filter after a pipe; cat that writes and sed that edits stay
3
+ # allowed), which leaves the read tool as the offered way to read files (python, awk and the like are not blocked). One sentence in the instance_template says so
4
+ # up front; a blocked command is not run, the agent just gets the same text back (exit code 127). Everything else
5
+ # is byte-identical to minimal-reason-read.yaml (tests/test_model.py checks that).
6
+ #
7
+ # why: same test as minimal-reason-nogrep.yaml for the search tool. Taking the bash readers away shows whether the
8
+ # read tool is usable at all: same pass rate means habit, a drop means the tool is worse than cat, and the reasons
9
+ # then say why.
10
+ model:
11
+ tools: [read]
12
+ block_read: true
13
+ reason_prompt: One sentence on why you chose to use this tool for this step. Do not describe what the step does.
14
+ reason_against_prompt: One sentence on why you did not choose the {tool} tool for this step.
15
+ model_kwargs:
16
+ num_retries: 15
17
+ retry_strategy: exponential_backoff_retry
18
+ format_error_template: |
19
+ {% if finish_reason is defined and (finish_reason == "length" or (finish_reason == "tool_calls" and not has_tool_calls)) -%}
20
+ Your previous response reached the output token limit (finish_reason={{ finish_reason }}) before you produced a tool call, so it was cut off. Respond more concisely and finish with a tool call.
21
+ {%- else -%}
22
+ Tool call error:
23
+
24
+ <error>
25
+ {{error}}
26
+ </error>
27
+
28
+ Every response must contain at least one tool call. To finish, run `echo COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT` as the only bash command in that call.
29
+ {%- endif %}
30
+ agent:
31
+ instance_template: |
32
+ Please solve this issue: {{task}}
33
+
34
+ Use your tools to inspect the repository and implement the fix. Every call runs in a fresh subshell, so directory changes and environment variables do not persist between calls.
35
+
36
+ Every tool has a why_this_tool field and, for each other tool you have, a why_not_<tool> field. Fill why_this_tool with one sentence on why you chose that tool for the step and each why_not_<tool> field with one sentence on why you did not choose that tool. Do not describe what the step does, the command already says that.
37
+
38
+ cat, head, nl and sed -n are currently not available as commands for you. Use the read tool to read files.
39
+
40
+ When you are done, run `echo COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT` as the only bash command in that call. You cannot continue after it.
41
+
42
+ <system_information>
43
+ {{system}} {{release}} {{version}} {{machine}}
44
+ </system_information>
@@ -0,0 +1,40 @@
1
+ # read tool configuration: minimal-reason.yaml with model.tools set to [read], so the surface is bash plus the read tool
2
+ # (no search tool) and the reason fields are on both. Everything else is byte-identical to minimal-reason.yaml
3
+ # (tests/test_model.py checks that). No tool_directive: the read tool is offered as a free choice, like the search
4
+ # tool in minimal-reason.yaml.
5
+ #
6
+ # why: Cito (20 Oct 2026) asked for at least one more basic tool built and piloted like the search tool, so the
7
+ # adoption question ("why do agents not use an available tool") is not a finding about one tool. read is the most
8
+ # used file operation after search (cat, head, sed -n in the bash-only trajectories), so it is the second tool.
9
+ model:
10
+ tools: [read]
11
+ reason_prompt: One sentence on why you chose to use this tool for this step. Do not describe what the step does.
12
+ reason_against_prompt: One sentence on why you did not choose the {tool} tool for this step.
13
+ model_kwargs:
14
+ num_retries: 15
15
+ retry_strategy: exponential_backoff_retry
16
+ format_error_template: |
17
+ {% if finish_reason is defined and (finish_reason == "length" or (finish_reason == "tool_calls" and not has_tool_calls)) -%}
18
+ Your previous response reached the output token limit (finish_reason={{ finish_reason }}) before you produced a tool call, so it was cut off. Respond more concisely and finish with a tool call.
19
+ {%- else -%}
20
+ Tool call error:
21
+
22
+ <error>
23
+ {{error}}
24
+ </error>
25
+
26
+ Every response must contain at least one tool call. To finish, run `echo COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT` as the only bash command in that call.
27
+ {%- endif %}
28
+ agent:
29
+ instance_template: |
30
+ Please solve this issue: {{task}}
31
+
32
+ Use your tools to inspect the repository and implement the fix. Every call runs in a fresh subshell, so directory changes and environment variables do not persist between calls.
33
+
34
+ Every tool has a why_this_tool field and, for each other tool you have, a why_not_<tool> field. Fill why_this_tool with one sentence on why you chose that tool for the step and each why_not_<tool> field with one sentence on why you did not choose that tool. Do not describe what the step does, the command already says that.
35
+
36
+ When you are done, run `echo COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT` as the only bash command in that call. You cannot continue after it.
37
+
38
+ <system_information>
39
+ {{system}} {{release}} {{version}} {{machine}}
40
+ </system_information>
@@ -0,0 +1,51 @@
1
+ # config for the reactivity pilot: minimal.yaml plus model.reason_prompt and reason_against_prompt, which put a
2
+ # required `why_this_tool` and one `why_not_<tool>` per other tool first in every tool (bash in BashModel, bash and
3
+ # search in ToolsModel), and one paragraph in the instance_template that says what goes into them. Everything else
4
+ # must stay byte-identical to minimal.yaml (tests/test_model.py checks that), so the fields and that paragraph are
5
+ # the only difference to the configuration without them.
6
+ #
7
+ # why: stated reasons are the raw material for the tool-choice categorisation, but asking for an explanation can
8
+ # itself change the choice (Fox, Ericsson & Best 2011). Run the same tasks with minimal.yaml and this file first,
9
+ # and compare search-tool share and resolve rate, before the parameter goes into every configuration.
10
+ #
11
+ # wording: two pilots on 18 Oct 2026 ("One sentence on why you chose this tool for this step." and "Explain in one
12
+ # sentence why you chose this tool over the other available tools for this step.", field named `reason`) only
13
+ # produced step labels like "Find ResolveContext definition"; not one of 3,000+ reasons compared tools. A parameter
14
+ # description alone is too weak, so the field is now named why_this_tool, the description asks for one sentence
15
+ # per tool (chosen and not chosen), and the instance_template repeats the instruction. A call that leaves a field
16
+ # out, blank or misnamed is refused with exit 127 (see model.refuse_reason_gaps); until 9 Nov 2026 it got a format
17
+ # error, which killed GLM trials after 3 in a row.
18
+ # 19 Oct 2026: the reason against each other tool moved from a "<chosen tool>: <reason>. <other tool>: <reason
19
+ # against>." skeleton in why_this_tool into its own why_not_<tool> field (reason_against_prompt), because GLM 5.3
20
+ # swapped the two labels in 73 of 547 coded calls. BashModel has a single tool, so it gets why_this_tool only.
21
+ model:
22
+ reason_prompt: One sentence on why you chose to use this tool for this step. Do not describe what the step does.
23
+ reason_against_prompt: One sentence on why you did not choose the {tool} tool for this step.
24
+ model_kwargs:
25
+ num_retries: 15
26
+ retry_strategy: exponential_backoff_retry
27
+ format_error_template: |
28
+ {% if finish_reason is defined and (finish_reason == "length" or (finish_reason == "tool_calls" and not has_tool_calls)) -%}
29
+ Your previous response reached the output token limit (finish_reason={{ finish_reason }}) before you produced a tool call, so it was cut off. Respond more concisely and finish with a tool call.
30
+ {%- else -%}
31
+ Tool call error:
32
+
33
+ <error>
34
+ {{error}}
35
+ </error>
36
+
37
+ Every response must contain at least one tool call. To finish, run `echo COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT` as the only bash command in that call.
38
+ {%- endif %}
39
+ agent:
40
+ instance_template: |
41
+ Please solve this issue: {{task}}
42
+
43
+ Use your tools to inspect the repository and implement the fix. Every call runs in a fresh subshell, so directory changes and environment variables do not persist between calls.
44
+
45
+ Every tool has a why_this_tool field and, for each other tool you have, a why_not_<tool> field. Fill why_this_tool with one sentence on why you chose that tool for the step and each why_not_<tool> field with one sentence on why you did not choose that tool. Do not describe what the step does, the command already says that.
46
+
47
+ When you are done, run `echo COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT` as the only bash command in that call. You cannot continue after it.
48
+
49
+ <system_information>
50
+ {{system}} {{release}} {{version}} {{machine}}
51
+ </system_information>
@@ -0,0 +1,44 @@
1
+ # free-choice configuration with both named tools: minimal.yaml with model.tools set to [search, read], so the surface is bash
2
+ # plus search plus read, no reason fields, no directive, no blocking. The prompt is byte-identical to minimal.yaml
3
+ # (tests/test_model.py checks that). Used with --ak model_class=thesis_tools.model.ToolsModel.
4
+ #
5
+ # why: the question "does the agent keep using bash when both tools are offered and nothing nudges it" needs a configuration
6
+ # with nothing else changed. Qwen's reason fields never moved adoption (2.8% with and without), so they are left out.
7
+ #
8
+ # min_request_interval is run infrastructure, not part of the configuration: 8 parallel trials under a quota of 1,500 requests
9
+ # per hour is 8 * 3600 / 1500 = 19.2 s between calls per trial. Adjust for other n or quotas, drop it without a quota.
10
+ #
11
+ # mini-swe-agent's default instance_template is 3 KB of bash examples and formatting rules from before native tool calling.
12
+ # we replace it with a short one that names no tool, the tools are only described by their schemas.
13
+ #
14
+ # model_kwargs is the retry block from bash.yaml. format_error_template is the one from tools.yaml without
15
+ # the bash specific text.
16
+ model:
17
+ tools: [search, read]
18
+ min_request_interval: 19.2
19
+ model_kwargs:
20
+ num_retries: 15
21
+ retry_strategy: exponential_backoff_retry
22
+ format_error_template: |
23
+ {% if finish_reason is defined and (finish_reason == "length" or (finish_reason == "tool_calls" and not has_tool_calls)) -%}
24
+ Your previous response reached the output token limit (finish_reason={{ finish_reason }}) before you produced a tool call, so it was cut off. Respond more concisely and finish with a tool call.
25
+ {%- else -%}
26
+ Tool call error:
27
+
28
+ <error>
29
+ {{error}}
30
+ </error>
31
+
32
+ Every response must contain at least one tool call. To finish, run `echo COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT` as the only bash command in that call.
33
+ {%- endif %}
34
+ agent:
35
+ instance_template: |
36
+ Please solve this issue: {{task}}
37
+
38
+ Use your tools to inspect the repository and implement the fix. Every call runs in a fresh subshell, so directory changes and environment variables do not persist between calls.
39
+
40
+ When you are done, run `echo COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT` as the only bash command in that call. You cannot continue after it.
41
+
42
+ <system_information>
43
+ {{system}} {{release}} {{version}} {{machine}}
44
+ </system_information>
@@ -0,0 +1,35 @@
1
+ # config for the minimal-prompt run, the same file is used for both model classes. Pier loads it on top of mini.yaml with
2
+ # --ak config_file=minimal.yaml --ak model_class=thesis_tools.model.<BashModel|ToolsModel>
3
+ #
4
+ # mini-swe-agent's default instance_template is 3 KB of bash examples and formatting rules from before native tool calling.
5
+ # we replace it with a short one that names no tool, the tools are only described by their schemas.
6
+ #
7
+ # model_kwargs is the retry block from bash.yaml. format_error_template is the one from tools.yaml without
8
+ # the bash specific text.
9
+ model:
10
+ model_kwargs:
11
+ num_retries: 15
12
+ retry_strategy: exponential_backoff_retry
13
+ format_error_template: |
14
+ {% if finish_reason is defined and (finish_reason == "length" or (finish_reason == "tool_calls" and not has_tool_calls)) -%}
15
+ Your previous response reached the output token limit (finish_reason={{ finish_reason }}) before you produced a tool call, so it was cut off. Respond more concisely and finish with a tool call.
16
+ {%- else -%}
17
+ Tool call error:
18
+
19
+ <error>
20
+ {{error}}
21
+ </error>
22
+
23
+ Every response must contain at least one tool call. To finish, run `echo COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT` as the only bash command in that call.
24
+ {%- endif %}
25
+ agent:
26
+ instance_template: |
27
+ Please solve this issue: {{task}}
28
+
29
+ Use your tools to inspect the repository and implement the fix. Every call runs in a fresh subshell, so directory changes and environment variables do not persist between calls.
30
+
31
+ When you are done, run `echo COMPLETE_TASK_AND_SUBMIT_FINAL_OUTPUT` as the only bash command in that call. You cannot continue after it.
32
+
33
+ <system_information>
34
+ {{system}} {{release}} {{version}} {{machine}}
35
+ </system_information>
@@ -0,0 +1,18 @@
1
+ [project]
2
+ name = "shellbent"
3
+ version = "0.1.0"
4
+ description = "mini-swe-agent 2.4.6 model classes and named tools for a thesis on agent tool surfaces"
5
+ license = "MIT"
6
+ requires-python = ">=3.10"
7
+ dependencies = [
8
+ "mini-swe-agent==2.4.6",
9
+ "ripgrep-bin==15.2.0; sys_platform == 'linux'",
10
+ ]
11
+
12
+ [build-system]
13
+ requires = ["hatchling"]
14
+ build-backend = "hatchling.build"
15
+
16
+ # The import package keeps its original name, so model_class=thesis_tools.model.* in existing launchers stays valid.
17
+ [tool.hatch.build.targets.wheel]
18
+ packages = ["thesis_tools"]
@@ -0,0 +1,225 @@
1
+ import json
2
+ import re
3
+ import subprocess
4
+ from pathlib import Path
5
+ from types import SimpleNamespace
6
+
7
+ import pytest
8
+ import yaml
9
+ from minisweagent.exceptions import FormatError
10
+
11
+ from minisweagent.models.utils.actions_toolcall import BASH_TOOL
12
+
13
+ import litellm
14
+
15
+ from thesis_tools.model import BLOCKED_GREP, BLOCKED_READ, BashModel, ToolsModel, rate_limit_wait, runs_grep, runs_read
16
+ from thesis_tools.read import READ_TOOL
17
+ from thesis_tools.search import SEARCH_TOOL
18
+
19
+ ROOT = Path(__file__).resolve().parent.parent
20
+
21
+
22
+ def test_tool_surface():
23
+ assert [t["function"]["name"] for t in BashModel(model_name="x").tools] == ["bash"]
24
+ assert [t["function"]["name"] for t in ToolsModel(model_name="x").tools] == [
25
+ "bash",
26
+ "search",
27
+ ]
28
+ assert ToolsModel(model_name="x", tools=["read"]).tools == (BASH_TOOL, READ_TOOL)
29
+ assert ToolsModel(model_name="x", tools=["search", "read"]).tools == (BASH_TOOL, SEARCH_TOOL, READ_TOOL)
30
+
31
+
32
+ def test_tool_directive():
33
+ free = ToolsModel(model_name="x").tools[1]["function"]["description"]
34
+ assert free == SEARCH_TOOL["function"]["description"]
35
+ steered = ToolsModel(model_name="x", tool_directive="Use me. ").tools[1][
36
+ "function"
37
+ ]["description"]
38
+ assert steered == "Use me. " + free
39
+ assert (
40
+ SEARCH_TOOL["function"]["description"] == free
41
+ ) # the module constant is untouched
42
+
43
+
44
+ def test_reason_prompt():
45
+ # without the prompt the schemas are the module constants, nothing is added
46
+ assert BashModel(model_name="x").tools == (BASH_TOOL,)
47
+ assert ToolsModel(model_name="x").tools[1] is SEARCH_TOOL
48
+
49
+ for tool in ToolsModel(model_name="x", reason_prompt=" Why? ").tools:
50
+ params = tool["function"]["parameters"]
51
+ assert list(params["properties"])[0] == "why_this_tool" # first, so the model writes it before the action
52
+ assert params["properties"]["why_this_tool"] == {"type": "string", "description": "Why?"}
53
+ assert params["required"][0] == "why_this_tool"
54
+ bash = BashModel(model_name="x", reason_prompt="Why?").tools[0]["function"]["parameters"]
55
+ assert bash["required"] == ["why_this_tool", "command"]
56
+ # with reason_against_prompt every tool gets one why_not_<other> field after why_this_tool, a single tool none
57
+ kw = dict(model_name="x", reason_prompt="Why?", reason_against_prompt="Why not {tool}?")
58
+ assert list(BashModel(**kw).tools[0]["function"]["parameters"]["properties"]) == ["why_this_tool", "command"]
59
+ bash, search = (t["function"]["parameters"] for t in ToolsModel(**kw).tools)
60
+ assert bash["required"][:3] == ["why_this_tool", "why_not_search", "command"]
61
+ assert bash["properties"]["why_not_search"]["description"] == "Why not search?"
62
+ assert list(search["properties"])[:2] == ["why_this_tool", "why_not_bash"]
63
+ assert search["required"][:2] == ["why_this_tool", "why_not_bash"]
64
+ # the module constants are untouched
65
+ assert "why_this_tool" not in BASH_TOOL["function"]["parameters"]["properties"]
66
+ assert "why_this_tool" not in SEARCH_TOOL["function"]["parameters"]["properties"]
67
+
68
+
69
+ def response(*calls):
70
+ """A litellm-shaped response with one tool call per (name, args) pair."""
71
+ tool_calls = [
72
+ SimpleNamespace(id=f"c{i}", function=SimpleNamespace(name=name, arguments=json.dumps(args)))
73
+ for i, (name, args) in enumerate(calls)
74
+ ]
75
+ return SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(tool_calls=tool_calls), finish_reason="stop")])
76
+
77
+
78
+ def test_reason_gap_is_refused():
79
+ # a missing, blank or misnamed reason field: the call is refused with exit 127, the other calls of the response run
80
+ kw = dict(model_name="x", reason_prompt="Why?", reason_against_prompt="Why not {tool}?")
81
+ full = {"why_this_tool": "a.", "why_not_search": "b.", "command": "ls"}
82
+ for model in (BashModel(model_name="x", reason_prompt="Why?"), ToolsModel(**kw)):
83
+ for args in ({"command": "ls"}, {"why_this_tool": " ", "why_not_search": "b.", "command": "ls"}):
84
+ out, code = refused(model._parse_actions(response(("bash", args)))[0])
85
+ assert code == 127 and "missing why_this_tool" in out
86
+ model = ToolsModel(**kw)
87
+ ok, bad = model._parse_actions(response(("bash", full), ("bash", {"why_this_tool": "a.", "why_not_bash": "b.", "command": "ls"})))
88
+ assert ok["command"] == "ls"
89
+ assert refused(bad) == ("Nothing in this bash call was run. It is missing why_not_search. The bash tool has no "
90
+ "why_not_bash argument. Send the call again with every why_ argument filled in.\n", 127)
91
+ assert "thesis_tools.search" not in model._parse_actions(response(("search", {"pattern": "x"})))[0]["command"]
92
+ # after 3 refused responses in a row the gap runs anyway, until a clean response resets the count
93
+ model = ToolsModel(**kw)
94
+ gap = response(("bash", {"command": "ls"}))
95
+ assert [model._parse_actions(gap)[0]["command"] == "ls" for _ in range(5)] == [False, False, False, True, True]
96
+ assert model._parse_actions(response(("bash", full)))[0]["command"] == "ls"
97
+ assert model._parse_actions(gap)[0]["command"] != "ls"
98
+ # without a reason prompt nothing is checked
99
+ assert ToolsModel(model_name="x")._parse_actions(gap)[0]["command"] == "ls"
100
+
101
+
102
+ def test_runs_grep():
103
+ blocked = ["grep -rn foo src", "cd /x && grep -n foo a.py | head", "rg foo", "git grep foo", "find . | xargs grep foo",
104
+ "ls || grep foo", "sleep 5; grep -c BUILD /tmp/log", "egrep foo a", "cat > p.py <<'EOF'\nx = 1\nEOF\ngrep x p.py",
105
+ "ps aux | grep -v grep; grep x a"]
106
+ allowed = ["ps aux | grep -c rustc", "git log --grep=foo", "cat a | grep foo | grep -v bar", "./rg foo", "my_grep x",
107
+ "python3 -c 'import re; re.search(1,2)'", "cat > s.sh <<'EOF'\ngrep -rn foo .\nEOF", "python3 - <<EOF\n grep\nEOF",
108
+ "ps aux | grep x | grep -v grep", "ps -ef | grep -v grep | grep -E java", 'tsc 2>&1 | grep -E x; echo "grep-exit: $?"']
109
+ assert [c for c in blocked if not runs_grep(c)] == []
110
+ assert [c for c in allowed if runs_grep(c)] == []
111
+
112
+
113
+ def refused(action):
114
+ """Runs a blocked action's command in bash and returns (output, exit code)."""
115
+ result = subprocess.run(["bash", "-lc", action["command"]], capture_output=True, text=True)
116
+ return result.stdout, result.returncode
117
+
118
+
119
+ def test_block_grep_is_a_failed_command():
120
+ model = ToolsModel(model_name="x", block_grep=True)
121
+ assert model._parse_actions(response(("bash", {"command": "ps aux | grep x"})))[0]["command"] == "ps aux | grep x"
122
+ assert "thesis_tools.search" in model._parse_actions(response(("search", {"pattern": "grep"})))[0]["command"]
123
+ # the blocked call fails like a missing program, the other calls of the response still run
124
+ ls, grep = model._parse_actions(response(("bash", {"command": "ls"}), ("bash", {"command": "grep -rn x ."})))
125
+ assert ls["command"] == "ls" and grep["tool_call_id"] == "c1"
126
+ assert refused(grep) == (BLOCKED_GREP + "\n", 127)
127
+ assert ToolsModel(model_name="x")._parse_actions(response(("bash", {"command": "grep -rn x ."})))[0]["command"]
128
+
129
+
130
+ def test_runs_read():
131
+ blocked = ["cat a.py", "cd src && cat -n a.py", "head -20 a.py", "head -n 5 a.py | grep x", "nl a.py",
132
+ "sed -n '10,20p' a.py", "sed -ne 10p a.py", "sed -En '/x/p' a.py", "sed --quiet 1p a", "ls; cat a",
133
+ "cat a 2>&1", "cat > p.py <<'EOF'\nx = 1\nEOF\npython3 p.py; cat /tmp/out.log", "xargs cat < l"]
134
+ allowed = ["ls | head -5", "git log | tail -3", "tail -30 a.py", "cat <<'EOF' > a.py\nx\nEOF", "cat > a.py", "cat a > b",
135
+ "sed -i 's/a/b/' a.py", "sed -n -i 's/a/b/' a.py", "sed 's/a/b/' a.py", "./cat x", "my_cat x",
136
+ "echo category", "git commit -m 'more tests'", "python3 - <<'PY'\ns = open('a').read()\nhead = s[:5]\nPY",
137
+ 'javac -cp "$(cat cp.txt)" A.java', "perl -pe \"$(cat fix.pl)\" a", "git cat-file -p HEAD"]
138
+ assert [c for c in blocked if not runs_read(c)] == []
139
+ assert [c for c in allowed if runs_read(c)] == []
140
+
141
+
142
+ def test_block_read_is_a_failed_command():
143
+ kw = dict(model_name="x", tools=["read"], block_read=True, format_error_template="bad: {{error}}")
144
+ model = ToolsModel(**kw)
145
+ assert "thesis_tools.read" in model._parse_actions(response(("read", {"path": "cat"})))[0]["command"]
146
+ assert model._parse_actions(response(("bash", {"command": "grep -rn x ."})))[0]["command"] # only block_read is on
147
+ # the blocked command itself never runs, only the message comes back
148
+ assert refused(model._parse_actions(response(("bash", {"command": "cat 'a b.py'"})))[0]) == (BLOCKED_READ + "\n", 127)
149
+ # a search call on a read-only surface is an unknown tool to mini, not a dispatch
150
+ with pytest.raises(FormatError):
151
+ model._parse_actions(response(("search", {"pattern": "x"})))
152
+
153
+
154
+ def test_minimal_yamls_share_the_prompt():
155
+ base = yaml.safe_load((ROOT / "minimal.yaml").read_text())
156
+ for name, key in [("minimal-directive.yaml", "tool_directive"), ("minimal-reason.yaml", "reason_prompt"),
157
+ ("minimal-reason-nogrep.yaml", "block_grep"), ("minimal-reason-read.yaml", "tools"),
158
+ ("minimal-reason-read-nocat.yaml", "block_read"), ("minimal-search-read.yaml", "tools")]:
159
+ other = yaml.safe_load((ROOT / name).read_text())
160
+ if name not in ("minimal-directive.yaml", "minimal-search-read.yaml"): # the reason configurations add exactly one paragraph about the field, the blocked ones one more
161
+ lines = other["agent"]["instance_template"].splitlines()
162
+ extra = [l for l in lines if "why_this_tool" in l or "not available as commands" in l]
163
+ assert len(extra) == (2 if key.startswith("block_") else 1)
164
+ reason = yaml.safe_load((ROOT / "minimal-reason.yaml").read_text())["model"]
165
+ assert all(other["model"][k] == reason[k] for k in ("reason_prompt", "reason_against_prompt"))
166
+ kept = re.sub(r"\n{3,}", "\n\n", "\n".join(l for l in lines if l not in extra)) + "\n"
167
+ assert kept == base["agent"]["instance_template"]
168
+ assert {k: v for k, v in other["agent"].items() if k != "instance_template"} == {
169
+ k: v for k, v in base["agent"].items() if k != "instance_template"}
170
+ else:
171
+ assert other["agent"] == base["agent"]
172
+ assert other["model"]["format_error_template"] == base["model"]["format_error_template"]
173
+ assert other["model"][key]
174
+ for name in ("minimal-reason-read.yaml", "minimal-reason-read-nocat.yaml"):
175
+ assert yaml.safe_load((ROOT / name).read_text())["model"]["tools"] == ["read"]
176
+ search_read = yaml.safe_load((ROOT / "minimal-search-read.yaml").read_text())["model"]
177
+ assert search_read["tools"] == ["search", "read"]
178
+ assert not any(k in search_read for k in ("reason_prompt", "tool_directive", "block_grep", "block_read"))
179
+
180
+
181
+ def rate_limit(message="Rate limit exceeded. Request limit (375/hour) (Retry after 591s).", headers=None):
182
+ return litellm.exceptions.RateLimitError(message, llm_provider="openai", model="x", headers=headers)
183
+
184
+
185
+ def test_rate_limit_wait():
186
+ assert rate_limit_wait(rate_limit()) == 591 # the message text
187
+ assert rate_limit_wait(rate_limit(headers={"Retry-After": "30"})) == 30 # the header wins when present
188
+ assert rate_limit_wait(rate_limit(headers={"retry-after": "1.5"})) == 1.5
189
+ assert rate_limit_wait(rate_limit("Rate limit exceeded.")) == 60 # neither: the default
190
+
191
+
192
+ def test_query_sleeps_out_the_rate_limit(monkeypatch):
193
+ calls, sleeps = [], []
194
+ monkeypatch.setattr("thesis_tools.model.time.sleep", sleeps.append)
195
+ monkeypatch.setattr("thesis_tools.model.time.monotonic", lambda: 1000.0)
196
+ def completion(**kw):
197
+ calls.append(kw)
198
+ if len(calls) < 3:
199
+ raise rate_limit(f"Request limit (375/hour) (Retry after {len(calls)}0s).")
200
+ return "response"
201
+ monkeypatch.setattr("thesis_tools.model.litellm.completion", completion)
202
+ assert BashModel(model_name="x")._query([]) == "response"
203
+ assert sleeps == [15, 25] # Retry-After plus 5 s of slack, once per 429
204
+ assert len(calls) == 3 and calls[0]["tools"] == (BASH_TOOL,)
205
+ # the total wait is capped, then the error ends the trial as before
206
+ calls.clear(), sleeps.clear()
207
+ with pytest.raises(litellm.exceptions.RateLimitError):
208
+ BashModel(model_name="x", max_rate_limit_wait=20)._query([])
209
+ assert sleeps == [15]
210
+
211
+
212
+ def test_query_paces_requests(monkeypatch):
213
+ now, sleeps = [1000.0], []
214
+ monkeypatch.setattr("thesis_tools.model.time.monotonic", lambda: now[0])
215
+ monkeypatch.setattr("thesis_tools.model.time.sleep", lambda s: (sleeps.append(s), now.__setitem__(0, now[0] + s)))
216
+ monkeypatch.setattr("thesis_tools.model.litellm.completion", lambda **kw: "response")
217
+ model = BashModel(model_name="x", min_request_interval=19.2)
218
+ model._query([]) # the first call never waits
219
+ now[0] += 4 # the model answered and the command ran for 4 s
220
+ model._query([])
221
+ assert sleeps == [pytest.approx(15.2)] # 19.2 s from the start of the previous call
222
+ now[0] += 30
223
+ model._query([])
224
+ assert len(sleeps) == 1 # already past the interval, no wait
225
+ assert BashModel(model_name="x").config.min_request_interval == 0 # the older configs are unpaced