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.
- shellbent-0.1.0/.gitignore +1 -0
- shellbent-0.1.0/CONFIGURATIONS.md +15 -0
- shellbent-0.1.0/LICENSE +21 -0
- shellbent-0.1.0/PKG-INFO +9 -0
- shellbent-0.1.0/README.md +22 -0
- shellbent-0.1.0/bash.yaml +11 -0
- shellbent-0.1.0/minimal-directive.yaml +35 -0
- shellbent-0.1.0/minimal-reason-nogrep.yaml +42 -0
- shellbent-0.1.0/minimal-reason-read-nocat.yaml +44 -0
- shellbent-0.1.0/minimal-reason-read.yaml +40 -0
- shellbent-0.1.0/minimal-reason.yaml +51 -0
- shellbent-0.1.0/minimal-search-read.yaml +44 -0
- shellbent-0.1.0/minimal.yaml +35 -0
- shellbent-0.1.0/pyproject.toml +18 -0
- shellbent-0.1.0/tests/test_model.py +225 -0
- shellbent-0.1.0/tests/test_read.py +61 -0
- shellbent-0.1.0/tests/test_search.py +57 -0
- shellbent-0.1.0/thesis_tools/__init__.py +0 -0
- shellbent-0.1.0/thesis_tools/args.py +31 -0
- shellbent-0.1.0/thesis_tools/model.py +327 -0
- shellbent-0.1.0/thesis_tools/read.py +127 -0
- shellbent-0.1.0/thesis_tools/search.py +195 -0
- shellbent-0.1.0/tools.yaml +135 -0
|
@@ -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.
|
shellbent-0.1.0/LICENSE
ADDED
|
@@ -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.
|
shellbent-0.1.0/PKG-INFO
ADDED
|
@@ -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
|