drydock-cli 3.0.32__tar.gz → 3.0.47__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.
- {drydock_cli-3.0.32/drydock_cli.egg-info → drydock_cli-3.0.47}/PKG-INFO +2 -2
- {drydock_cli-3.0.32 → drydock_cli-3.0.47}/drydock/__init__.py +1 -1
- {drydock_cli-3.0.32 → drydock_cli-3.0.47}/drydock/agent.py +1 -1
- {drydock_cli-3.0.32 → drydock_cli-3.0.47}/drydock/cli.py +52 -8
- {drydock_cli-3.0.32 → drydock_cli-3.0.47}/drydock/config.py +30 -5
- {drydock_cli-3.0.32 → drydock_cli-3.0.47}/drydock/detect.py +1 -1
- {drydock_cli-3.0.32 → drydock_cli-3.0.47}/drydock/loop_detect.py +43 -0
- {drydock_cli-3.0.32 → drydock_cli-3.0.47}/drydock/providers.py +134 -15
- {drydock_cli-3.0.32 → drydock_cli-3.0.47}/drydock/tools/__init__.py +76 -8
- {drydock_cli-3.0.32 → drydock_cli-3.0.47}/drydock/tui/app.py +53 -7
- {drydock_cli-3.0.32 → drydock_cli-3.0.47}/drydock/tui/widgets.py +1 -1
- {drydock_cli-3.0.32 → drydock_cli-3.0.47}/drydock/tuning.py +27 -0
- {drydock_cli-3.0.32 → drydock_cli-3.0.47/drydock_cli.egg-info}/PKG-INFO +2 -2
- {drydock_cli-3.0.32 → drydock_cli-3.0.47}/drydock_cli.egg-info/SOURCES.txt +8 -1
- {drydock_cli-3.0.32 → drydock_cli-3.0.47}/pyproject.toml +2 -2
- drydock_cli-3.0.47/tests/test_bash_process_group.py +57 -0
- drydock_cli-3.0.47/tests/test_bash_timeout_network.py +52 -0
- drydock_cli-3.0.47/tests/test_compact_command.py +47 -0
- {drydock_cli-3.0.32 → drydock_cli-3.0.47}/tests/test_config.py +6 -6
- drydock_cli-3.0.47/tests/test_config_migration.py +123 -0
- drydock_cli-3.0.47/tests/test_oneshot_unreachable.py +39 -0
- drydock_cli-3.0.47/tests/test_providers_unreachable.py +95 -0
- drydock_cli-3.0.47/tests/test_runaway_repetition.py +128 -0
- {drydock_cli-3.0.32 → drydock_cli-3.0.47}/tests/test_tui.py +40 -6
- drydock_cli-3.0.47/tests/test_vision_input.py +84 -0
- drydock_cli-3.0.32/tests/test_providers_unreachable.py +0 -51
- {drydock_cli-3.0.32 → drydock_cli-3.0.47}/LICENSE +0 -0
- {drydock_cli-3.0.32 → drydock_cli-3.0.47}/NOTICE +0 -0
- {drydock_cli-3.0.32 → drydock_cli-3.0.47}/README.md +0 -0
- {drydock_cli-3.0.32 → drydock_cli-3.0.47}/drydock/__main__.py +0 -0
- {drydock_cli-3.0.32 → drydock_cli-3.0.47}/drydock/bash_safety.py +0 -0
- {drydock_cli-3.0.32 → drydock_cli-3.0.47}/drydock/compaction.py +0 -0
- {drydock_cli-3.0.32 → drydock_cli-3.0.47}/drydock/guards.py +0 -0
- {drydock_cli-3.0.32 → drydock_cli-3.0.47}/drydock/tool_registry.py +0 -0
- {drydock_cli-3.0.32 → drydock_cli-3.0.47}/drydock/tui/__init__.py +0 -0
- {drydock_cli-3.0.32 → drydock_cli-3.0.47}/drydock/tui/approval.py +0 -0
- {drydock_cli-3.0.32 → drydock_cli-3.0.47}/drydock/tui/messages.py +0 -0
- {drydock_cli-3.0.32 → drydock_cli-3.0.47}/drydock_cli.egg-info/dependency_links.txt +0 -0
- {drydock_cli-3.0.32 → drydock_cli-3.0.47}/drydock_cli.egg-info/entry_points.txt +0 -0
- {drydock_cli-3.0.32 → drydock_cli-3.0.47}/drydock_cli.egg-info/requires.txt +0 -0
- {drydock_cli-3.0.32 → drydock_cli-3.0.47}/drydock_cli.egg-info/top_level.txt +0 -0
- {drydock_cli-3.0.32 → drydock_cli-3.0.47}/setup.cfg +0 -0
- {drydock_cli-3.0.32 → drydock_cli-3.0.47}/tests/test_approval.py +0 -0
- {drydock_cli-3.0.32 → drydock_cli-3.0.47}/tests/test_back_command.py +0 -0
- {drydock_cli-3.0.32 → drydock_cli-3.0.47}/tests/test_bash_safety.py +0 -0
- {drydock_cli-3.0.32 → drydock_cli-3.0.47}/tests/test_cli_agents.py +0 -0
- {drydock_cli-3.0.32 → drydock_cli-3.0.47}/tests/test_compaction.py +0 -0
- {drydock_cli-3.0.32 → drydock_cli-3.0.47}/tests/test_detect.py +0 -0
- {drydock_cli-3.0.32 → drydock_cli-3.0.47}/tests/test_empty_response.py +0 -0
- {drydock_cli-3.0.32 → drydock_cli-3.0.47}/tests/test_failure_loop.py +0 -0
- {drydock_cli-3.0.32 → drydock_cli-3.0.47}/tests/test_first_run_setup.py +0 -0
- {drydock_cli-3.0.32 → drydock_cli-3.0.47}/tests/test_guards_and_tools.py +0 -0
- {drydock_cli-3.0.32 → drydock_cli-3.0.47}/tests/test_hallucinated_tools.py +0 -0
- {drydock_cli-3.0.32 → drydock_cli-3.0.47}/tests/test_leaked_tool_call.py +0 -0
- {drydock_cli-3.0.32 → drydock_cli-3.0.47}/tests/test_loop_detect.py +0 -0
- {drydock_cli-3.0.32 → drydock_cli-3.0.47}/tests/test_plan_autocontinue.py +0 -0
- {drydock_cli-3.0.32 → drydock_cli-3.0.47}/tests/test_stop.py +0 -0
- {drydock_cli-3.0.32 → drydock_cli-3.0.47}/tests/test_streaming_newlines.py +0 -0
- {drydock_cli-3.0.32 → drydock_cli-3.0.47}/tests/test_subagent.py +0 -0
- {drydock_cli-3.0.32 → drydock_cli-3.0.47}/tests/test_todo.py +0 -0
- {drydock_cli-3.0.32 → drydock_cli-3.0.47}/tests/test_tool_arg_parsing.py +0 -0
- {drydock_cli-3.0.32 → drydock_cli-3.0.47}/tests/test_tools_undo.py +0 -0
- {drydock_cli-3.0.32 → drydock_cli-3.0.47}/tests/test_tuning.py +0 -0
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: drydock-cli
|
|
3
|
-
Version: 3.0.
|
|
3
|
+
Version: 3.0.47
|
|
4
4
|
Summary: Drydock — a local, provider-agnostic terminal coding agent for local LLMs
|
|
5
5
|
Author: Frank Bobe III
|
|
6
6
|
License-Expression: Apache-2.0
|
|
7
7
|
Project-URL: Homepage, https://github.com/fbobe321/drydock
|
|
8
8
|
Project-URL: Repository, https://github.com/fbobe321/drydock
|
|
9
9
|
Project-URL: Issues, https://github.com/fbobe321/drydock/issues
|
|
10
|
-
Requires-Python: >=3.
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
11
|
Description-Content-Type: text/markdown
|
|
12
12
|
License-File: LICENSE
|
|
13
13
|
License-File: NOTICE
|
|
@@ -323,7 +323,7 @@ def run(
|
|
|
323
323
|
# Safety valve: the same call has run identically (same args AND result)
|
|
324
324
|
# too many times in a row — repeating it changes nothing. End the turn
|
|
325
325
|
# and hand control back rather than burning turns toward MAX_TOOL_TURNS.
|
|
326
|
-
if identical_repeat_streak >= IDENTICAL_REPEAT_CAP:
|
|
326
|
+
if identical_repeat_streak >= IDENTICAL_REPEAT_CAP and last_call_sig:
|
|
327
327
|
yield TextChunk(
|
|
328
328
|
f"\n[Stopped: the same {last_call_sig[0]} call ran "
|
|
329
329
|
f"{identical_repeat_streak}× in a row with the same result — "
|
|
@@ -116,17 +116,35 @@ def run_interactive(config: dict) -> None:
|
|
|
116
116
|
|
|
117
117
|
def run_oneshot(prompt: str, config: dict) -> None:
|
|
118
118
|
"""One-shot mode: run a single prompt and exit."""
|
|
119
|
+
from drydock.providers import LLMUnreachable
|
|
120
|
+
|
|
119
121
|
system = SYSTEM_PROMPT + load_project_instructions()
|
|
120
122
|
state = AgentState()
|
|
121
123
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
124
|
+
try:
|
|
125
|
+
for event in run(prompt, state, config, system):
|
|
126
|
+
if isinstance(event, TextChunk):
|
|
127
|
+
print(event.text, end="", flush=True)
|
|
128
|
+
elif isinstance(event, ToolStart):
|
|
129
|
+
# Include an input summary so the trace (captured to drydock.log
|
|
130
|
+
# under -p) shows WHAT each tool did, not just its name. Without
|
|
131
|
+
# this, a timed-out run is an opaque wall of "[Bash]" with no way
|
|
132
|
+
# to tell a genuine 100-command exploration from a tight loop.
|
|
133
|
+
print(f" [{event.name}] {_summarize(event.inputs)}",
|
|
134
|
+
file=sys.stderr, flush=True)
|
|
135
|
+
elif isinstance(event, ToolEnd):
|
|
136
|
+
# One-line outcome so failures/repeats are visible in the trace.
|
|
137
|
+
preview = " ".join(str(event.result).split())[:100]
|
|
138
|
+
print(f" -> {preview}", file=sys.stderr, flush=True)
|
|
139
|
+
print()
|
|
140
|
+
except LLMUnreachable as e:
|
|
141
|
+
# The model server is down, the URL is wrong, or a turn overran the read
|
|
142
|
+
# timeout. The TUI and --cli paths already surface this; one-shot mode
|
|
143
|
+
# (-p, used by automation/harnesses) had no handler, so it crashed with a
|
|
144
|
+
# raw traceback that buried the actionable message. Print the remediation
|
|
145
|
+
# cleanly to stderr and exit non-zero so callers can detect the failure.
|
|
146
|
+
print(f"\nError: {e}", file=sys.stderr, flush=True)
|
|
147
|
+
sys.exit(2)
|
|
130
148
|
|
|
131
149
|
|
|
132
150
|
def handle_command(cmd: str, state: AgentState, config: dict) -> bool:
|
|
@@ -145,6 +163,25 @@ def handle_command(cmd: str, state: AgentState, config: dict) -> bool:
|
|
|
145
163
|
state.turn_count = 0
|
|
146
164
|
print_colored(" Conversation cleared.", "green")
|
|
147
165
|
return True
|
|
166
|
+
elif cmd == "/compact":
|
|
167
|
+
from drydock.compaction import compact, estimate_tokens
|
|
168
|
+
|
|
169
|
+
if not state.messages:
|
|
170
|
+
print_colored(" Nothing to compact.", "dim")
|
|
171
|
+
return True
|
|
172
|
+
before = estimate_tokens(state.messages)
|
|
173
|
+
limit = config.get("context_limit", 65536) or 65536
|
|
174
|
+
state.messages = compact(state.messages, limit)
|
|
175
|
+
after = estimate_tokens(state.messages)
|
|
176
|
+
saved = before - after
|
|
177
|
+
if saved > 0:
|
|
178
|
+
print_colored(
|
|
179
|
+
f" Compacted: ~{before:,} → ~{after:,} tokens (freed ~{saved:,}).",
|
|
180
|
+
"green",
|
|
181
|
+
)
|
|
182
|
+
else:
|
|
183
|
+
print_colored(f" Already compact (~{before:,} tokens).", "dim")
|
|
184
|
+
return True
|
|
148
185
|
elif cmd == "/status":
|
|
149
186
|
print_colored(f" Turns: {state.turn_count}", "cyan")
|
|
150
187
|
print_colored(f" Messages: {len(state.messages)}", "cyan")
|
|
@@ -213,6 +250,12 @@ def main():
|
|
|
213
250
|
parser.add_argument("--max-tool-calls", type=int, default=0, help="Max tool calls (0=unlimited)")
|
|
214
251
|
parser.add_argument("--force-first-tool", action="store_true", help="Force tool_choice=required on first turn")
|
|
215
252
|
parser.add_argument("--cli", action="store_true", help="Plain readline mode instead of the TUI")
|
|
253
|
+
parser.add_argument(
|
|
254
|
+
"--dangerously-skip-permissions",
|
|
255
|
+
dest="dangerously_skip_permissions",
|
|
256
|
+
action="store_true",
|
|
257
|
+
help="Auto-approve all tool calls without prompting (for CI/cron use)",
|
|
258
|
+
)
|
|
216
259
|
args = parser.parse_args()
|
|
217
260
|
|
|
218
261
|
from drydock import config as cfgmod
|
|
@@ -251,6 +294,7 @@ def main():
|
|
|
251
294
|
"context_limit": 65536,
|
|
252
295
|
"max_tool_calls": args.max_tool_calls,
|
|
253
296
|
"force_first_tool": args.force_first_tool,
|
|
297
|
+
"_approve_all": args.dangerously_skip_permissions,
|
|
254
298
|
"cwd": os.getcwd(),
|
|
255
299
|
"history_path": str(Path.home() / ".drydock" / "history"),
|
|
256
300
|
"onboarding": onboarding,
|
|
@@ -21,7 +21,11 @@ from pathlib import Path
|
|
|
21
21
|
DEFAULTS: dict[str, object] = {
|
|
22
22
|
"model": "gemma4",
|
|
23
23
|
"provider": "vllm",
|
|
24
|
-
"
|
|
24
|
+
# Concrete (not "") so a freshly-written config SHOWS the endpoint and the
|
|
25
|
+
# user can edit it / point at another box. Empty fell back to the provider
|
|
26
|
+
# default invisibly, which left base_url absent from the file. For a non-vllm
|
|
27
|
+
# provider, override base_url (or use --base-url / the first-run prompt).
|
|
28
|
+
"base_url": "http://localhost:8000/v1",
|
|
25
29
|
"max_tokens": 8192, # 4096 truncated large file writes mid-JSON (→ _raw fail)
|
|
26
30
|
"temperature": 0.2,
|
|
27
31
|
"theme": "harbor",
|
|
@@ -93,10 +97,14 @@ def resolve(cli_overrides: dict, path: Path | None = None) -> dict:
|
|
|
93
97
|
|
|
94
98
|
A NON-existent file is created once with pure DEFAULTS (no CLI flags — a
|
|
95
99
|
transient --base-url for one run must not be baked in permanently). An
|
|
96
|
-
EXISTING file is never modified: missing keys are backfilled only in the
|
|
97
|
-
returned dict, never written back. This is deliberately conservative —
|
|
98
|
-
|
|
99
|
-
|
|
100
|
+
EXISTING v3 file is never modified: missing keys are backfilled only in the
|
|
101
|
+
returned dict, never written back. This is deliberately conservative — the
|
|
102
|
+
file may be shared or hand-edited, so we never reorder, reformat, or drop
|
|
103
|
+
keys we don't recognize.
|
|
104
|
+
|
|
105
|
+
A LEGACY/foreign file (a v2 config, or a malformed one) is migrated: backed
|
|
106
|
+
up to <name>.bak once, then replaced with a fresh DEFAULTS file so the user
|
|
107
|
+
has an editable v3 config with a visible endpoint.
|
|
100
108
|
"""
|
|
101
109
|
path = path or default_config_path()
|
|
102
110
|
if not path.exists():
|
|
@@ -104,4 +112,21 @@ def resolve(cli_overrides: dict, path: Path | None = None) -> dict:
|
|
|
104
112
|
file_cfg = {}
|
|
105
113
|
else:
|
|
106
114
|
file_cfg = load_file(path)
|
|
115
|
+
# Legacy/foreign-config migration. A file with NONE of our keys is either
|
|
116
|
+
# a v2 mistral-vibe config (nested [[providers]]/[[models]]/active_model)
|
|
117
|
+
# or malformed/empty — in every case the user can't edit it to set
|
|
118
|
+
# base_url, model, etc., and v2's nested tables can't round-trip through
|
|
119
|
+
# our flat emitter. Back it up once and write a fresh, complete v3 file so
|
|
120
|
+
# the endpoint is visible and editable (the v2->v3 upgrade path). A real
|
|
121
|
+
# v3 file (>=1 recognized key) is left untouched — we never reformat it or
|
|
122
|
+
# drop keys we don't recognize.
|
|
123
|
+
if file_cfg and not any(k in DEFAULTS for k in file_cfg):
|
|
124
|
+
backup = path.with_name(path.name + ".bak")
|
|
125
|
+
try:
|
|
126
|
+
if not backup.exists():
|
|
127
|
+
backup.write_bytes(path.read_bytes())
|
|
128
|
+
except OSError:
|
|
129
|
+
pass
|
|
130
|
+
save_file(dict(DEFAULTS), path)
|
|
131
|
+
file_cfg = {}
|
|
107
132
|
return merge(file_cfg, cli_overrides)
|
|
@@ -63,7 +63,7 @@ def onboarding_message(found: list[dict]) -> str:
|
|
|
63
63
|
return (
|
|
64
64
|
"No local LLM detected (" + ports + "). Point Drydock at your model "
|
|
65
65
|
"right here:\n"
|
|
66
|
-
" /model url <http://
|
|
66
|
+
" /model url <http://localhost:8000/v1> then /model <model-name>\n"
|
|
67
67
|
"(saved to ~/.drydock/config.toml). Or start a local server "
|
|
68
68
|
"(llama.cpp / vLLM / Ollama / LM Studio) and restart."
|
|
69
69
|
)
|
|
@@ -86,6 +86,49 @@ def path_thrash_note(path: str, count: int) -> str | None:
|
|
|
86
86
|
)
|
|
87
87
|
|
|
88
88
|
|
|
89
|
+
def runaway_repetition_len(
|
|
90
|
+
text: str,
|
|
91
|
+
*,
|
|
92
|
+
window: int = 2000,
|
|
93
|
+
min_run: int = 600,
|
|
94
|
+
max_unit: int = 80,
|
|
95
|
+
min_reps: int = 6,
|
|
96
|
+
) -> int:
|
|
97
|
+
"""Length (in chars) of a runaway repeated SUFFIX of *text*, else 0.
|
|
98
|
+
|
|
99
|
+
A weak local model sometimes collapses mid-generation into emitting the same
|
|
100
|
+
short unit hundreds of times (observed: gemma4 streamed ``295:`` ~1365× on
|
|
101
|
+
make-mips-interpreter). The tool-call loop guard never sees this — it's
|
|
102
|
+
streamed assistant TEXT, not a tool call — so the turn balloons unchecked.
|
|
103
|
+
|
|
104
|
+
This detects that one failure mode and nothing else. It is deliberately
|
|
105
|
+
CONSERVATIVE: it requires a long pure-repetition run (>= ``min_run`` chars
|
|
106
|
+
AND >= ``min_reps`` repeats of one <= ``max_unit``-char unit) so that
|
|
107
|
+
legitimate repetition — a ``---`` rule, a short bulleted list, an ASCII
|
|
108
|
+
table — never trips it. Pure-whitespace units (the blank line between
|
|
109
|
+
markdown blocks) are ignored. Returns the run length so the caller can trim
|
|
110
|
+
exactly the repeated tail; 0 means "looks fine, keep going".
|
|
111
|
+
"""
|
|
112
|
+
if len(text) < min_run:
|
|
113
|
+
return 0
|
|
114
|
+
tail = text[-window:]
|
|
115
|
+
n = len(tail)
|
|
116
|
+
best = 0
|
|
117
|
+
for p in range(1, min(max_unit, n // min_reps) + 1):
|
|
118
|
+
unit = tail[-p:]
|
|
119
|
+
if not unit.strip():
|
|
120
|
+
continue # ignore blank-line / whitespace units
|
|
121
|
+
reps = 0
|
|
122
|
+
i = n
|
|
123
|
+
while i - p >= 0 and tail[i - p:i] == unit:
|
|
124
|
+
reps += 1
|
|
125
|
+
i -= p
|
|
126
|
+
run = reps * p
|
|
127
|
+
if reps >= min_reps and run >= min_run:
|
|
128
|
+
best = max(best, run)
|
|
129
|
+
return best
|
|
130
|
+
|
|
131
|
+
|
|
89
132
|
class LoopTracker:
|
|
90
133
|
"""Counts identical tool calls across a run and produces advisory notes."""
|
|
91
134
|
|
|
@@ -23,7 +23,8 @@ _LLM_POOL = concurrent.futures.ThreadPoolExecutor(max_workers=8, thread_name_pre
|
|
|
23
23
|
class _StopRequested(Exception):
|
|
24
24
|
"""Internal: STOP was pressed during a blocking LLM call."""
|
|
25
25
|
|
|
26
|
-
from drydock.
|
|
26
|
+
from drydock.loop_detect import runaway_repetition_len
|
|
27
|
+
from drydock.tuning import extract_thinking, strip_leaked_tool_calls, strip_thinking_tokens, use_streaming
|
|
27
28
|
|
|
28
29
|
# ── Provider registry ─────────────────────────────────────────────────────
|
|
29
30
|
|
|
@@ -71,18 +72,43 @@ def _friendly_unreachable(base_url: str, provider: str) -> str:
|
|
|
71
72
|
)
|
|
72
73
|
|
|
73
74
|
|
|
74
|
-
def
|
|
75
|
+
def _friendly_timeout(base_url: str, timeout_s: float) -> str:
|
|
76
|
+
"""The server IS reachable but a single generation overran the read timeout.
|
|
77
|
+
Common with slow local models doing long reasoning turns (a non-streaming
|
|
78
|
+
response only arrives once the whole generation finishes). Distinct from
|
|
79
|
+
'unreachable' so the user fixes the right thing."""
|
|
80
|
+
mins = timeout_s / 60.0
|
|
81
|
+
return (
|
|
82
|
+
f"The model server at {base_url} is reachable, but the request exceeded "
|
|
83
|
+
f"the {timeout_s:.0f}s ({mins:.0f} min) read timeout before finishing.\n"
|
|
84
|
+
f" This usually means the model is generating a very long response "
|
|
85
|
+
f"(e.g. a big reasoning turn on a slow local model), not that the server "
|
|
86
|
+
f"is down.\n"
|
|
87
|
+
f" 1. Raise the limit: set request_timeout (seconds) in "
|
|
88
|
+
f"~/.drydock/config.toml.\n"
|
|
89
|
+
f" 2. Or shorten turns: lower the server's reasoning budget "
|
|
90
|
+
f"(llama.cpp --reasoning-budget).\n"
|
|
91
|
+
f" Your last message was not lost — just send it again."
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _safe_create(client, kwargs: dict, base_url: str, provider: str, timeout_s: float = 600.0):
|
|
75
96
|
"""Call chat.completions.create, mapping a connection failure to a clean
|
|
76
97
|
LLMUnreachable instead of a raw traceback / 12-minute hang."""
|
|
77
98
|
import openai
|
|
78
99
|
|
|
79
100
|
try:
|
|
80
101
|
return client.chat.completions.create(**kwargs)
|
|
102
|
+
# APITimeoutError subclasses APIConnectionError — catch it FIRST so a slow
|
|
103
|
+
# (but alive) server gets the accurate "timed out" message, not "down".
|
|
104
|
+
except openai.APITimeoutError as e:
|
|
105
|
+
raise LLMUnreachable(_friendly_timeout(base_url, timeout_s)) from e
|
|
81
106
|
except openai.APIConnectionError as e:
|
|
82
107
|
raise LLMUnreachable(_friendly_unreachable(base_url, provider)) from e
|
|
83
108
|
|
|
84
109
|
|
|
85
|
-
def _create_abortable(client, kwargs: dict, base_url: str, provider: str, cancel
|
|
110
|
+
def _create_abortable(client, kwargs: dict, base_url: str, provider: str, cancel,
|
|
111
|
+
timeout_s: float = 600.0):
|
|
86
112
|
"""Like _safe_create, but runs off-thread and polls a cancel Event so STOP
|
|
87
113
|
can abandon a blocked decode (raising _StopRequested). The orphaned request
|
|
88
114
|
runs to completion in the pool thread and its result is dropped."""
|
|
@@ -95,6 +121,9 @@ def _create_abortable(client, kwargs: dict, base_url: str, provider: str, cancel
|
|
|
95
121
|
except concurrent.futures.TimeoutError:
|
|
96
122
|
if cancel is not None and cancel.is_set():
|
|
97
123
|
raise _StopRequested
|
|
124
|
+
# APITimeoutError subclasses APIConnectionError — catch it FIRST.
|
|
125
|
+
except openai.APITimeoutError as e:
|
|
126
|
+
raise LLMUnreachable(_friendly_timeout(base_url, timeout_s)) from e
|
|
98
127
|
except openai.APIConnectionError as e:
|
|
99
128
|
raise LLMUnreachable(_friendly_unreachable(base_url, provider)) from e
|
|
100
129
|
|
|
@@ -139,6 +168,14 @@ def _stream_chunks(response, cancel):
|
|
|
139
168
|
class TextChunk:
|
|
140
169
|
text: str
|
|
141
170
|
|
|
171
|
+
@dataclass
|
|
172
|
+
class ReasoningChunk:
|
|
173
|
+
"""Thinking tokens emitted by the model before its answer (Gemma 4 only).
|
|
174
|
+
Yielded once per non-streaming turn, before any TextChunk, when the model
|
|
175
|
+
included a <|channel>…<channel|> block in its response.
|
|
176
|
+
"""
|
|
177
|
+
text: str
|
|
178
|
+
|
|
142
179
|
@dataclass
|
|
143
180
|
class AssistantTurn:
|
|
144
181
|
text: str
|
|
@@ -177,13 +214,67 @@ def _parse_tool_args(raw: str) -> dict:
|
|
|
177
214
|
|
|
178
215
|
# ── Message format conversion ─────────────────────────────────────────────
|
|
179
216
|
|
|
217
|
+
_IMAGE_MIME = {
|
|
218
|
+
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
|
219
|
+
".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp",
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _user_content_with_images(content):
|
|
224
|
+
"""Vision support: if the user's text references image file paths that exist
|
|
225
|
+
on disk, attach them as OpenAI multimodal image_url blocks (works with any
|
|
226
|
+
--mmproj-enabled server). Text-only prompts pass through unchanged as a plain
|
|
227
|
+
string, so display / loop-detection / compaction / token-counting (which all
|
|
228
|
+
assume string content) are untouched — the multimodal list is built ONLY here,
|
|
229
|
+
at the API boundary."""
|
|
230
|
+
if not isinstance(content, str) or "." not in content:
|
|
231
|
+
return content
|
|
232
|
+
import base64
|
|
233
|
+
import os
|
|
234
|
+
import re
|
|
235
|
+
|
|
236
|
+
exts = "png|jpe?g|gif|webp|bmp"
|
|
237
|
+
cands = re.findall(
|
|
238
|
+
rf'"([^"]+?\.(?:{exts}))"|\'([^\']+?\.(?:{exts}))\'|(\S+\.(?:{exts}))',
|
|
239
|
+
content, re.IGNORECASE,
|
|
240
|
+
)
|
|
241
|
+
seen: list[str] = []
|
|
242
|
+
for tup in cands:
|
|
243
|
+
raw = next((x for x in tup if x), None)
|
|
244
|
+
if not raw:
|
|
245
|
+
continue
|
|
246
|
+
# the greedy \S+ branch grabs surrounding markdown/punctuation, e.g. a
|
|
247
|
+
# path in backticks `/app/code.png`, parens, or with trailing .,;: —
|
|
248
|
+
# strip it so os.path.isfile sees the real path (else vision silently
|
|
249
|
+
# never attaches).
|
|
250
|
+
raw = raw.strip("`'\"()[]{}<>").rstrip(".,;:!?")
|
|
251
|
+
p = os.path.expanduser(raw)
|
|
252
|
+
if os.path.isfile(p) and p not in seen:
|
|
253
|
+
seen.append(p)
|
|
254
|
+
if not seen:
|
|
255
|
+
return content
|
|
256
|
+
blocks: list[dict] = [{"type": "text", "text": content}]
|
|
257
|
+
for p in seen:
|
|
258
|
+
mime = _IMAGE_MIME.get(os.path.splitext(p)[1].lower(), "image/png")
|
|
259
|
+
try:
|
|
260
|
+
with open(p, "rb") as f:
|
|
261
|
+
b64 = base64.b64encode(f.read()).decode()
|
|
262
|
+
except OSError:
|
|
263
|
+
continue
|
|
264
|
+
blocks.append({"type": "image_url",
|
|
265
|
+
"image_url": {"url": f"data:{mime};base64,{b64}"}})
|
|
266
|
+
return blocks if len(blocks) > 1 else content
|
|
267
|
+
|
|
268
|
+
|
|
180
269
|
def messages_to_openai(messages: list, system: str) -> list:
|
|
181
270
|
"""Convert neutral messages to OpenAI API format."""
|
|
182
|
-
|
|
271
|
+
# value type is mixed (str content, multimodal list content, tool_calls list)
|
|
272
|
+
result: list[dict] = [{"role": "system", "content": system}]
|
|
183
273
|
for m in messages:
|
|
184
274
|
role = m["role"]
|
|
185
275
|
if role == "user":
|
|
186
|
-
result.append({"role": "user",
|
|
276
|
+
result.append({"role": "user",
|
|
277
|
+
"content": _user_content_with_images(m["content"])})
|
|
187
278
|
elif role == "assistant":
|
|
188
279
|
msg = {"role": "assistant", "content": m.get("content") or None}
|
|
189
280
|
tcs = m.get("tool_calls", [])
|
|
@@ -250,13 +341,19 @@ def stream(
|
|
|
250
341
|
)
|
|
251
342
|
|
|
252
343
|
# Fail fast on a dead endpoint: a 10s connect timeout and no retries surface
|
|
253
|
-
# an unreachable server in seconds. Generation itself can
|
|
254
|
-
#
|
|
344
|
+
# an unreachable server in seconds. Generation itself can take many minutes —
|
|
345
|
+
# a slow local model doing a long reasoning turn (non-streaming, so the whole
|
|
346
|
+
# response only lands once it finishes) can exceed 10 min. Default the read
|
|
347
|
+
# timeout to 30 min and let the operator raise it via config request_timeout.
|
|
348
|
+
try:
|
|
349
|
+
read_timeout = float(config.get("request_timeout") or 1800.0)
|
|
350
|
+
except (TypeError, ValueError):
|
|
351
|
+
read_timeout = 1800.0
|
|
255
352
|
client = OpenAI(
|
|
256
353
|
api_key=api_key,
|
|
257
354
|
base_url=base_url,
|
|
258
355
|
max_retries=0,
|
|
259
|
-
timeout=httpx.Timeout(
|
|
356
|
+
timeout=httpx.Timeout(read_timeout, connect=10.0),
|
|
260
357
|
)
|
|
261
358
|
# Expose the client so the TUI's STOP (Esc / Ctrl+C) can close it mid-call —
|
|
262
359
|
# closing the connection aborts an in-flight blocking decode immediately
|
|
@@ -303,7 +400,7 @@ def stream(
|
|
|
303
400
|
# streaming. If STOP closes the client mid-call the blocking request
|
|
304
401
|
# raises — swallow that as a clean cancel rather than an error.
|
|
305
402
|
try:
|
|
306
|
-
yield from _complete_nonstreaming(client, kwargs, base_url, provider, cancel)
|
|
403
|
+
yield from _complete_nonstreaming(client, kwargs, base_url, provider, cancel, read_timeout)
|
|
307
404
|
except _StopRequested:
|
|
308
405
|
return # STOP — abandon the in-flight request, clean stop
|
|
309
406
|
except Exception:
|
|
@@ -317,9 +414,14 @@ def stream(
|
|
|
317
414
|
text = ""
|
|
318
415
|
tool_buf: dict = {} # index → {id, name, args}
|
|
319
416
|
in_tok = out_tok = 0
|
|
417
|
+
# Runaway-repetition guard: a weak model can collapse into streaming one
|
|
418
|
+
# short unit hundreds of times (gemma4: `295:` ×1365). Advisory, never
|
|
419
|
+
# raises — on detection we trim the repeated tail, emit a note, and stop
|
|
420
|
+
# reading (the orphaned producer drains in the background, like STOP).
|
|
421
|
+
_rep_checked_at = 0
|
|
320
422
|
|
|
321
423
|
try:
|
|
322
|
-
response = _create_abortable(client, kwargs, base_url, provider, cancel)
|
|
424
|
+
response = _create_abortable(client, kwargs, base_url, provider, cancel, read_timeout)
|
|
323
425
|
except _StopRequested:
|
|
324
426
|
config.get("_abort", {}).pop("client", None)
|
|
325
427
|
return
|
|
@@ -344,6 +446,20 @@ def stream(
|
|
|
344
446
|
if chunk_text:
|
|
345
447
|
text += chunk_text
|
|
346
448
|
yield TextChunk(chunk_text)
|
|
449
|
+
# Throttled: only scan once text is long enough and every ~200
|
|
450
|
+
# new chars, so the common path pays almost nothing.
|
|
451
|
+
if len(text) - _rep_checked_at >= 200:
|
|
452
|
+
_rep_checked_at = len(text)
|
|
453
|
+
run = runaway_repetition_len(text)
|
|
454
|
+
if run:
|
|
455
|
+
text = (
|
|
456
|
+
text[: len(text) - run]
|
|
457
|
+
+ "\n[… output began repeating — stopped by drydock]"
|
|
458
|
+
)
|
|
459
|
+
yield TextChunk(
|
|
460
|
+
"\n[stopped — model output began repeating itself]"
|
|
461
|
+
)
|
|
462
|
+
break
|
|
347
463
|
|
|
348
464
|
if delta.tool_calls:
|
|
349
465
|
for tc in delta.tool_calls:
|
|
@@ -379,21 +495,24 @@ def stream(
|
|
|
379
495
|
|
|
380
496
|
|
|
381
497
|
def _complete_nonstreaming(
|
|
382
|
-
client, kwargs: dict, base_url: str = "", provider: str = "", cancel=None
|
|
498
|
+
client, kwargs: dict, base_url: str = "", provider: str = "", cancel=None,
|
|
499
|
+
timeout_s: float = 600.0,
|
|
383
500
|
) -> Generator:
|
|
384
501
|
"""Single non-streaming completion. Yields one TextChunk (if any text)
|
|
385
502
|
then the AssistantTurn. Used when streaming would corrupt tool-call JSON.
|
|
386
503
|
Runs off-thread so STOP can abandon a blocked decode.
|
|
387
504
|
"""
|
|
388
|
-
resp = _create_abortable(client, kwargs, base_url, provider, cancel)
|
|
505
|
+
resp = _create_abortable(client, kwargs, base_url, provider, cancel, timeout_s)
|
|
389
506
|
choice = resp.choices[0]
|
|
390
507
|
msg = choice.message
|
|
391
508
|
|
|
392
509
|
# Order matters: remove <|tool_call> blobs FIRST (sets had_leak for the
|
|
393
|
-
# retry nudge), THEN strip channel/special-token markers — otherwise
|
|
394
|
-
# generic special-token pass would eat the tool_call markers.
|
|
510
|
+
# retry nudge), THEN extract+strip channel/special-token markers — otherwise
|
|
511
|
+
# the generic special-token pass would eat the tool_call markers.
|
|
395
512
|
text, had_leak = strip_leaked_tool_calls(msg.content or "")
|
|
396
|
-
text =
|
|
513
|
+
thinking, text = extract_thinking(text)
|
|
514
|
+
if thinking:
|
|
515
|
+
yield ReasoningChunk(thinking)
|
|
397
516
|
if text.strip():
|
|
398
517
|
yield TextChunk(text)
|
|
399
518
|
|
|
@@ -9,6 +9,7 @@ import os
|
|
|
9
9
|
import re
|
|
10
10
|
import difflib
|
|
11
11
|
import glob as _glob
|
|
12
|
+
import signal
|
|
12
13
|
import subprocess
|
|
13
14
|
import time
|
|
14
15
|
from pathlib import Path
|
|
@@ -83,7 +84,7 @@ SCHEMAS = [
|
|
|
83
84
|
"type": "object",
|
|
84
85
|
"properties": {
|
|
85
86
|
"command": {"type": "string"},
|
|
86
|
-
"timeout": {"type": "integer", "description": "Seconds (default
|
|
87
|
+
"timeout": {"type": "integer", "description": "Seconds (default 120; bump to 1800 for builds/training/cracking)"},
|
|
87
88
|
},
|
|
88
89
|
"required": ["command"],
|
|
89
90
|
},
|
|
@@ -438,6 +439,57 @@ def tool_edit(params: dict, config: dict) -> str:
|
|
|
438
439
|
return f"Error editing {fp}: {e}"
|
|
439
440
|
|
|
440
441
|
|
|
442
|
+
def kill_process_group(proc) -> None:
|
|
443
|
+
"""Kill a Popen launched with start_new_session AND all its descendants by
|
|
444
|
+
signalling the whole process group. proc.kill() alone only kills the direct
|
|
445
|
+
shell, orphaning children (which keep running and hold the stdout pipe open,
|
|
446
|
+
hanging communicate() and freezing the TUI on STOP). Falls back to proc.kill()
|
|
447
|
+
if the group can't be resolved (e.g. process already gone)."""
|
|
448
|
+
if proc is None:
|
|
449
|
+
return
|
|
450
|
+
try:
|
|
451
|
+
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
|
452
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
453
|
+
try:
|
|
454
|
+
proc.kill()
|
|
455
|
+
except Exception: # noqa: BLE001
|
|
456
|
+
pass
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
_OFFLINE_HINT = (
|
|
460
|
+
"\n[Note: this looks like a network/download command and the environment "
|
|
461
|
+
"appears to be OFFLINE — downloads will keep failing no matter how many "
|
|
462
|
+
"times you retry. Do NOT repeat the fetch. Use only files/data already "
|
|
463
|
+
"present in the project; if the required data genuinely isn't here, say so "
|
|
464
|
+
"and stop rather than looping on the download.]"
|
|
465
|
+
)
|
|
466
|
+
# Commands that reach the network (so an offline failure is expected, not a bug).
|
|
467
|
+
_NETWORK_CMD_RE = re.compile(
|
|
468
|
+
r"\b(pip3?\s+install|pip\s+download|uv\s+(pip\s+)?(install|add)|conda\s+install|"
|
|
469
|
+
r"npm\s+(install|i|ci)|yarn\s+add|wget|curl\s+[^|]*https?://|git\s+clone|"
|
|
470
|
+
r"apt(-get)?\s+(install|update)|hf\s+download|huggingface-cli\s+download|"
|
|
471
|
+
r"load_dataset|hf_hub_download|snapshot_download|from_pretrained)\b",
|
|
472
|
+
re.IGNORECASE,
|
|
473
|
+
)
|
|
474
|
+
# Output signatures of a name-resolution / connectivity failure.
|
|
475
|
+
_NETWORK_FAIL_RE = re.compile(
|
|
476
|
+
r"(could not resolve|name or service not known|temporary failure in name "
|
|
477
|
+
r"resolution|network is unreachable|no route to host|connection (timed out|"
|
|
478
|
+
r"refused)|failed to establish a new connection|max retries exceeded|"
|
|
479
|
+
r"getaddrinfo|no address associated|could not find a version|offline mode "
|
|
480
|
+
r"is enabled|connectionerror|read timed out)",
|
|
481
|
+
re.IGNORECASE,
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _is_network_command(cmd: str) -> bool:
|
|
486
|
+
return bool(_NETWORK_CMD_RE.search(cmd or ""))
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _looks_like_network_failure(output: str) -> bool:
|
|
490
|
+
return bool(_NETWORK_FAIL_RE.search(output or ""))
|
|
491
|
+
|
|
492
|
+
|
|
441
493
|
def tool_bash(params: dict, config: dict) -> str:
|
|
442
494
|
cmd = params.get("command")
|
|
443
495
|
if not cmd:
|
|
@@ -449,7 +501,11 @@ def tool_bash(params: dict, config: dict) -> str:
|
|
|
449
501
|
"(escape any newline as \\n; avoid raw line breaks in the JSON)."
|
|
450
502
|
)
|
|
451
503
|
return "Error: Bash needs a non-empty 'command'."
|
|
452
|
-
|
|
504
|
+
# Default 120s (was 30s): real coding tasks compile, train, run test suites,
|
|
505
|
+
# and crack hashes — a 30s wall killed legitimate long work before it could
|
|
506
|
+
# finish (terminal-bench: 16 tasks died at 30s on builds/training). Bounded,
|
|
507
|
+
# and the model can pass a larger `timeout` for genuinely heavy commands.
|
|
508
|
+
timeout = params.get("timeout", 120)
|
|
453
509
|
reason = bash_safety.dangerous_command(cmd)
|
|
454
510
|
if reason is not None:
|
|
455
511
|
return bash_safety.refusal_message(cmd, reason)
|
|
@@ -469,7 +525,12 @@ def tool_bash(params: dict, config: dict) -> str:
|
|
|
469
525
|
f"({approval_reason}).\nCommand: {cmd.strip()}"
|
|
470
526
|
)
|
|
471
527
|
# Run via Popen (not subprocess.run) so STOP can kill it mid-execution:
|
|
472
|
-
# the handle is stashed in config["_abort"]["proc"] for action_stop to
|
|
528
|
+
# the handle is stashed in config["_abort"]["proc"] for action_stop to kill.
|
|
529
|
+
# start_new_session=True puts the shell in its OWN process group so we can
|
|
530
|
+
# kill the WHOLE tree (kill_process_group): a bare proc.kill() only kills the
|
|
531
|
+
# /bin/sh shell, orphaning its children (e.g. a brute-force script and its
|
|
532
|
+
# subprocesses) which keep running AND keep the stdout pipe open — so the
|
|
533
|
+
# follow-up communicate() blocks forever and the TUI is stuck "working".
|
|
473
534
|
# We poll communicate() in short slices, checking the cancel Event and the
|
|
474
535
|
# overall timeout between slices.
|
|
475
536
|
cancel = config.get("_cancel")
|
|
@@ -477,7 +538,7 @@ def tool_bash(params: dict, config: dict) -> str:
|
|
|
477
538
|
try:
|
|
478
539
|
proc = subprocess.Popen(
|
|
479
540
|
cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
480
|
-
text=True, cwd=config.get("cwd"),
|
|
541
|
+
text=True, cwd=config.get("cwd"), start_new_session=True,
|
|
481
542
|
)
|
|
482
543
|
config.setdefault("_abort", {})["proc"] = proc
|
|
483
544
|
start = time.monotonic()
|
|
@@ -487,22 +548,29 @@ def tool_bash(params: dict, config: dict) -> str:
|
|
|
487
548
|
break
|
|
488
549
|
except subprocess.TimeoutExpired:
|
|
489
550
|
if cancel is not None and cancel.is_set():
|
|
490
|
-
proc
|
|
551
|
+
kill_process_group(proc)
|
|
491
552
|
proc.communicate()
|
|
492
553
|
return "[stopped by user]"
|
|
493
554
|
if time.monotonic() - start > timeout:
|
|
494
|
-
proc
|
|
555
|
+
kill_process_group(proc)
|
|
495
556
|
proc.communicate()
|
|
496
|
-
bigger = min(timeout * 4,
|
|
497
|
-
|
|
557
|
+
bigger = min(timeout * 4, 1800)
|
|
558
|
+
msg = (
|
|
498
559
|
f"Error: command timed out after {timeout}s. If it is "
|
|
499
560
|
f"legitimately slow (a big query, build, download, or "
|
|
500
561
|
f"test run), retry with a larger timeout — pass "
|
|
501
562
|
f"timeout: {bigger}. Otherwise it may be hung."
|
|
502
563
|
)
|
|
564
|
+
if _is_network_command(cmd):
|
|
565
|
+
msg += _OFFLINE_HINT
|
|
566
|
+
return msg
|
|
503
567
|
output = out or ""
|
|
504
568
|
if proc.returncode != 0:
|
|
505
569
|
output += f"\n[exit code: {proc.returncode}]"
|
|
570
|
+
# Offline environments make downloads fail forever; the model tends
|
|
571
|
+
# to retry the same fetch in a loop. Tell it to stop and work local.
|
|
572
|
+
if _is_network_command(cmd) and _looks_like_network_failure(output):
|
|
573
|
+
output += _OFFLINE_HINT
|
|
506
574
|
return output.strip() or "(no output)"
|
|
507
575
|
except Exception as e:
|
|
508
576
|
return f"Error: {e}"
|