drydock-cli 3.0.18__tar.gz → 3.0.19__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.18/drydock_cli.egg-info → drydock_cli-3.0.19}/PKG-INFO +1 -1
- {drydock_cli-3.0.18 → drydock_cli-3.0.19}/drydock/__init__.py +1 -1
- {drydock_cli-3.0.18 → drydock_cli-3.0.19}/drydock/agent.py +14 -0
- {drydock_cli-3.0.18 → drydock_cli-3.0.19}/drydock/compaction.py +50 -7
- {drydock_cli-3.0.18 → drydock_cli-3.0.19}/drydock/tui/app.py +33 -1
- {drydock_cli-3.0.18 → drydock_cli-3.0.19/drydock_cli.egg-info}/PKG-INFO +1 -1
- {drydock_cli-3.0.18 → drydock_cli-3.0.19}/drydock_cli.egg-info/SOURCES.txt +1 -0
- {drydock_cli-3.0.18 → drydock_cli-3.0.19}/pyproject.toml +1 -1
- {drydock_cli-3.0.18 → drydock_cli-3.0.19}/tests/test_compaction.py +34 -0
- drydock_cli-3.0.19/tests/test_stop.py +64 -0
- {drydock_cli-3.0.18 → drydock_cli-3.0.19}/LICENSE +0 -0
- {drydock_cli-3.0.18 → drydock_cli-3.0.19}/NOTICE +0 -0
- {drydock_cli-3.0.18 → drydock_cli-3.0.19}/README.md +0 -0
- {drydock_cli-3.0.18 → drydock_cli-3.0.19}/drydock/__main__.py +0 -0
- {drydock_cli-3.0.18 → drydock_cli-3.0.19}/drydock/bash_safety.py +0 -0
- {drydock_cli-3.0.18 → drydock_cli-3.0.19}/drydock/cli.py +0 -0
- {drydock_cli-3.0.18 → drydock_cli-3.0.19}/drydock/config.py +0 -0
- {drydock_cli-3.0.18 → drydock_cli-3.0.19}/drydock/detect.py +0 -0
- {drydock_cli-3.0.18 → drydock_cli-3.0.19}/drydock/guards.py +0 -0
- {drydock_cli-3.0.18 → drydock_cli-3.0.19}/drydock/loop_detect.py +0 -0
- {drydock_cli-3.0.18 → drydock_cli-3.0.19}/drydock/providers.py +0 -0
- {drydock_cli-3.0.18 → drydock_cli-3.0.19}/drydock/tool_registry.py +0 -0
- {drydock_cli-3.0.18 → drydock_cli-3.0.19}/drydock/tools/__init__.py +0 -0
- {drydock_cli-3.0.18 → drydock_cli-3.0.19}/drydock/tui/__init__.py +0 -0
- {drydock_cli-3.0.18 → drydock_cli-3.0.19}/drydock/tui/approval.py +0 -0
- {drydock_cli-3.0.18 → drydock_cli-3.0.19}/drydock/tui/messages.py +0 -0
- {drydock_cli-3.0.18 → drydock_cli-3.0.19}/drydock/tui/widgets.py +0 -0
- {drydock_cli-3.0.18 → drydock_cli-3.0.19}/drydock/tuning.py +0 -0
- {drydock_cli-3.0.18 → drydock_cli-3.0.19}/drydock_cli.egg-info/dependency_links.txt +0 -0
- {drydock_cli-3.0.18 → drydock_cli-3.0.19}/drydock_cli.egg-info/entry_points.txt +0 -0
- {drydock_cli-3.0.18 → drydock_cli-3.0.19}/drydock_cli.egg-info/requires.txt +0 -0
- {drydock_cli-3.0.18 → drydock_cli-3.0.19}/drydock_cli.egg-info/top_level.txt +0 -0
- {drydock_cli-3.0.18 → drydock_cli-3.0.19}/setup.cfg +0 -0
- {drydock_cli-3.0.18 → drydock_cli-3.0.19}/tests/test_approval.py +0 -0
- {drydock_cli-3.0.18 → drydock_cli-3.0.19}/tests/test_back_command.py +0 -0
- {drydock_cli-3.0.18 → drydock_cli-3.0.19}/tests/test_bash_safety.py +0 -0
- {drydock_cli-3.0.18 → drydock_cli-3.0.19}/tests/test_cli_agents.py +0 -0
- {drydock_cli-3.0.18 → drydock_cli-3.0.19}/tests/test_config.py +0 -0
- {drydock_cli-3.0.18 → drydock_cli-3.0.19}/tests/test_detect.py +0 -0
- {drydock_cli-3.0.18 → drydock_cli-3.0.19}/tests/test_guards_and_tools.py +0 -0
- {drydock_cli-3.0.18 → drydock_cli-3.0.19}/tests/test_hallucinated_tools.py +0 -0
- {drydock_cli-3.0.18 → drydock_cli-3.0.19}/tests/test_leaked_tool_call.py +0 -0
- {drydock_cli-3.0.18 → drydock_cli-3.0.19}/tests/test_loop_detect.py +0 -0
- {drydock_cli-3.0.18 → drydock_cli-3.0.19}/tests/test_plan_autocontinue.py +0 -0
- {drydock_cli-3.0.18 → drydock_cli-3.0.19}/tests/test_providers_unreachable.py +0 -0
- {drydock_cli-3.0.18 → drydock_cli-3.0.19}/tests/test_subagent.py +0 -0
- {drydock_cli-3.0.18 → drydock_cli-3.0.19}/tests/test_todo.py +0 -0
- {drydock_cli-3.0.18 → drydock_cli-3.0.19}/tests/test_tools_undo.py +0 -0
- {drydock_cli-3.0.18 → drydock_cli-3.0.19}/tests/test_tui.py +0 -0
- {drydock_cli-3.0.18 → drydock_cli-3.0.19}/tests/test_tuning.py +0 -0
|
@@ -103,6 +103,13 @@ def run(
|
|
|
103
103
|
# a call to anything else is refused — never executed. Keeps a read-only
|
|
104
104
|
# sub-agent read-only and stops it from recursing into `task`.
|
|
105
105
|
allow = config.get("tool_allowlist")
|
|
106
|
+
# User STOP signal (a threading.Event the TUI sets on Escape / "/stop").
|
|
107
|
+
# Checked only at SAFE points — top of the loop and after a turn's tool
|
|
108
|
+
# results are all appended — so a stop never leaves an assistant tool_call
|
|
109
|
+
# without its matching tool result (which would corrupt the history).
|
|
110
|
+
cancel = config.get("_cancel")
|
|
111
|
+
def _stopped() -> bool:
|
|
112
|
+
return cancel is not None and cancel.is_set()
|
|
106
113
|
tool_call_count = 0
|
|
107
114
|
session_has_edited = False
|
|
108
115
|
leaked_call_retries = 0
|
|
@@ -111,6 +118,8 @@ def run(
|
|
|
111
118
|
loop_tracker = LoopTracker()
|
|
112
119
|
|
|
113
120
|
while state.turn_count < max_turns:
|
|
121
|
+
if _stopped():
|
|
122
|
+
break
|
|
114
123
|
state.turn_count += 1
|
|
115
124
|
run_iteration += 1
|
|
116
125
|
assistant_turn: AssistantTurn | None = None
|
|
@@ -264,6 +273,11 @@ def run(
|
|
|
264
273
|
"content": result,
|
|
265
274
|
})
|
|
266
275
|
|
|
276
|
+
# Safe point (all tool results appended): honor a STOP requested while
|
|
277
|
+
# this turn's tools were running, before spending another LLM call.
|
|
278
|
+
if _stopped():
|
|
279
|
+
break
|
|
280
|
+
|
|
267
281
|
# Real progress this turn — reset the consecutive stall-nudge counter so
|
|
268
282
|
# a long, productive plan can run as far as it needs (the cap only
|
|
269
283
|
# bounds back-to-back stalls).
|
|
@@ -35,18 +35,52 @@ def is_context_length_error(err: str) -> bool:
|
|
|
35
35
|
|
|
36
36
|
|
|
37
37
|
def estimate_tokens(messages: list) -> int:
|
|
38
|
-
"""Rough token estimate: chars / 3.5
|
|
38
|
+
"""Rough token estimate: chars / 3.5.
|
|
39
|
+
|
|
40
|
+
CRUCIAL: a tool call's arguments live in a nested ``input`` dict — e.g. a
|
|
41
|
+
full-file Write puts the whole file in tool_calls[].input.content. Counting
|
|
42
|
+
only top-level strings missed that entirely, so a 40k-char Write read as ~0
|
|
43
|
+
tokens, compaction never fired, and the real request blew past the context
|
|
44
|
+
window. Count nested strings so the estimate reflects what's actually sent.
|
|
45
|
+
"""
|
|
39
46
|
total = 0
|
|
40
47
|
for m in messages:
|
|
41
48
|
content = m.get("content", "")
|
|
42
49
|
if isinstance(content, str):
|
|
43
50
|
total += len(content)
|
|
44
|
-
for tc in m.get("tool_calls", []):
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
51
|
+
for tc in m.get("tool_calls", []) or []:
|
|
52
|
+
total += _count_chars(tc)
|
|
53
|
+
# /3.0, not /3.5: code is symbol-dense and tokenizes to MORE tokens than
|
|
54
|
+
# prose, so a conservative (slightly high) estimate keeps us off the wall.
|
|
55
|
+
return int(total / 3.0)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _count_chars(v) -> int:
|
|
59
|
+
"""Total length of every string nested in v (str / dict / list)."""
|
|
60
|
+
if isinstance(v, str):
|
|
61
|
+
return len(v)
|
|
62
|
+
if isinstance(v, dict):
|
|
63
|
+
return sum(_count_chars(x) for x in v.values())
|
|
64
|
+
if isinstance(v, (list, tuple)):
|
|
65
|
+
return sum(_count_chars(x) for x in v)
|
|
66
|
+
return 0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _truncate_tool_call_args(messages: list, max_len: int) -> None:
|
|
70
|
+
"""Shrink oversized string arguments inside OLD assistant tool calls (a
|
|
71
|
+
full-file Write/Edit is a top bloat source and nothing else truncates it).
|
|
72
|
+
The most recent tool-call message is left intact; truncating only the
|
|
73
|
+
argument *values* keeps each call's id, so tool-result pairing stays valid."""
|
|
74
|
+
last = max((i for i, m in enumerate(messages) if m.get("tool_calls")), default=-1)
|
|
75
|
+
for i, m in enumerate(messages):
|
|
76
|
+
if i == last:
|
|
77
|
+
continue
|
|
78
|
+
for tc in m.get("tool_calls") or []:
|
|
79
|
+
inp = tc.get("input") if isinstance(tc, dict) else None
|
|
80
|
+
if isinstance(inp, dict):
|
|
81
|
+
for k, v in inp.items():
|
|
82
|
+
if isinstance(v, str) and len(v) > max_len:
|
|
83
|
+
inp[k] = v[: max_len // 2] + "\n[... arg truncated ...]"
|
|
50
84
|
|
|
51
85
|
|
|
52
86
|
def compact(messages: list, context_limit: int = 131072) -> list:
|
|
@@ -92,6 +126,10 @@ def compact(messages: list, context_limit: int = 131072) -> list:
|
|
|
92
126
|
if current <= target:
|
|
93
127
|
break
|
|
94
128
|
|
|
129
|
+
# Pass 3: shrink big old tool-call arguments (full-file Write/Edit bodies).
|
|
130
|
+
if estimate_tokens(messages) > target:
|
|
131
|
+
_truncate_tool_call_args(messages, max_len=1500)
|
|
132
|
+
|
|
95
133
|
return messages
|
|
96
134
|
|
|
97
135
|
|
|
@@ -135,6 +173,11 @@ def emergency_compact(messages: list, context_limit: int = 131072) -> list:
|
|
|
135
173
|
if isinstance(content, str) and len(content) > 500:
|
|
136
174
|
messages[i]["content"] = content[:300] + "\n[... truncated ...]"
|
|
137
175
|
|
|
176
|
+
# Pass 4: shrink big tool-CALL arguments — a full-file Write/Edit puts the
|
|
177
|
+
# whole file in tool_calls[].input and nothing above touches it. This was
|
|
178
|
+
# THE leak that let the real request stay over the window after compaction.
|
|
179
|
+
_truncate_tool_call_args(messages, max_len=300)
|
|
180
|
+
|
|
138
181
|
return messages
|
|
139
182
|
|
|
140
183
|
|
|
@@ -8,6 +8,7 @@ messages (see tui/messages.py). Nautical theme, original branding.
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
10
|
import random
|
|
11
|
+
import threading
|
|
11
12
|
import time
|
|
12
13
|
|
|
13
14
|
from textual import work
|
|
@@ -119,6 +120,10 @@ class DrydockApp(App):
|
|
|
119
120
|
BINDINGS = [
|
|
120
121
|
Binding("ctrl+c", "copy_selection", "Copy", priority=True),
|
|
121
122
|
Binding("ctrl+d", "quit", "Quit", priority=True),
|
|
123
|
+
# Escape stops a running turn (the agent ends cleanly, session kept) —
|
|
124
|
+
# the way out of a runaway task without quitting the whole app. "/stop"
|
|
125
|
+
# does the same. priority so it isn't swallowed by the focused prompt.
|
|
126
|
+
Binding("escape", "stop", "Stop the running turn", priority=True),
|
|
122
127
|
Binding("ctrl+o", "toggle_tools", "Expand/collapse details"),
|
|
123
128
|
# Scroll the transcript from the keyboard (focus stays on the prompt,
|
|
124
129
|
# and SSH sessions often don't forward the mouse wheel). priority=True
|
|
@@ -129,6 +134,21 @@ class DrydockApp(App):
|
|
|
129
134
|
Binding("ctrl+end", "scroll_bottom", "Bottom", priority=True, show=False),
|
|
130
135
|
]
|
|
131
136
|
|
|
137
|
+
def action_stop(self) -> None:
|
|
138
|
+
"""STOP the running turn: signal the agent loop to end at its next safe
|
|
139
|
+
point, drop any queued prompts, and hand control back. The session
|
|
140
|
+
(history, files) is preserved — this is not a quit."""
|
|
141
|
+
if not self._busy:
|
|
142
|
+
return
|
|
143
|
+
self._cancel.set()
|
|
144
|
+
dropped = len(self._queue)
|
|
145
|
+
self._queue.clear()
|
|
146
|
+
note = "⏹ stopping after the current step…"
|
|
147
|
+
if dropped:
|
|
148
|
+
note += f" (discarded {dropped} queued)"
|
|
149
|
+
self._info(note)
|
|
150
|
+
self._refresh_status()
|
|
151
|
+
|
|
132
152
|
def action_scroll_up(self) -> None:
|
|
133
153
|
self._scroll.scroll_page_up()
|
|
134
154
|
|
|
@@ -185,6 +205,11 @@ class DrydockApp(App):
|
|
|
185
205
|
self._current_assistant: AssistantMessage | None = None
|
|
186
206
|
self._last_card: ToolCard | None = None
|
|
187
207
|
self._busy = False
|
|
208
|
+
# STOP signal: Escape / "/stop" sets it; the agent loop checks it at safe
|
|
209
|
+
# points and ends the turn cleanly (session preserved). Lives in config
|
|
210
|
+
# so run() — and any sub-agent that copies config — can see it.
|
|
211
|
+
self._cancel = threading.Event()
|
|
212
|
+
self.config["_cancel"] = self._cancel
|
|
188
213
|
self._queue: list[str] = [] # prompts submitted while a turn is running
|
|
189
214
|
self._ctx_tokens = 0 # current context size (last turn's prompt tokens)
|
|
190
215
|
self._ctrl_c_armed = False # first Ctrl+C arms; second within ~2s exits
|
|
@@ -318,6 +343,7 @@ class DrydockApp(App):
|
|
|
318
343
|
def _begin(self, text: str) -> None:
|
|
319
344
|
"""Start an agent turn for an already-displayed user prompt."""
|
|
320
345
|
self._current_assistant = None
|
|
346
|
+
self._cancel.clear() # fresh turn — clear any prior STOP
|
|
321
347
|
self._busy = True
|
|
322
348
|
self._work_start = time.monotonic()
|
|
323
349
|
self._work_word = random.choice(_WORKING_WORDS)
|
|
@@ -336,6 +362,11 @@ class DrydockApp(App):
|
|
|
336
362
|
arg = parts[1].strip() if len(parts) > 1 else ""
|
|
337
363
|
if cmd in ("/quit", "/exit", "/q"):
|
|
338
364
|
self.exit()
|
|
365
|
+
elif cmd in ("/stop", "/cancel"):
|
|
366
|
+
if self._busy:
|
|
367
|
+
self.action_stop()
|
|
368
|
+
else:
|
|
369
|
+
self._info("Nothing is running.")
|
|
339
370
|
elif cmd == "/clear":
|
|
340
371
|
self.state = AgentState()
|
|
341
372
|
self._scroll.remove_children()
|
|
@@ -377,10 +408,11 @@ class DrydockApp(App):
|
|
|
377
408
|
" /cwd [path] show or change the working directory\n"
|
|
378
409
|
" /undo revert the last file write/edit\n"
|
|
379
410
|
" /back rewind the last turn from the model's context\n"
|
|
411
|
+
" /stop stop the running turn (or press Esc)\n"
|
|
380
412
|
" /status session model, cwd, turns, tokens\n"
|
|
381
413
|
" /clear reset the conversation\n"
|
|
382
414
|
" /quit exit\n"
|
|
383
|
-
"Type a task and press Enter. ↑/↓ recall history · Ctrl+O expands tools."
|
|
415
|
+
"Type a task and press Enter. ↑/↓ recall history · Esc stops · Ctrl+O expands tools."
|
|
384
416
|
)
|
|
385
417
|
else:
|
|
386
418
|
self._mount(ErrorMessage(f"unknown command: {cmd} (try /help)"))
|
|
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
|
|
7
7
|
# PyPI distribution name is drydock-cli (the established install name, continued
|
|
8
8
|
# from the retired v2 fork); the import package + CLI command stay `drydock`.
|
|
9
9
|
name = "drydock-cli"
|
|
10
|
-
version = "3.0.
|
|
10
|
+
version = "3.0.19"
|
|
11
11
|
description = "Drydock — a local, provider-agnostic terminal coding agent for local LLMs"
|
|
12
12
|
readme = "README.md"
|
|
13
13
|
requires-python = ">=3.12"
|
|
@@ -15,6 +15,40 @@ from drydock.compaction import (
|
|
|
15
15
|
)
|
|
16
16
|
|
|
17
17
|
|
|
18
|
+
def test_estimate_counts_tool_call_input_args():
|
|
19
|
+
# A full-file Write hides its bulk in tool_calls[].input.content. The
|
|
20
|
+
# estimate MUST see it (this was the bug: it read as ~0 tokens).
|
|
21
|
+
big = "x" * 40000
|
|
22
|
+
msgs = [{"role": "assistant", "content": "",
|
|
23
|
+
"tool_calls": [{"id": "1", "name": "Write",
|
|
24
|
+
"input": {"file_path": "a.py", "content": big}}]}]
|
|
25
|
+
assert estimate_tokens(msgs) > 10000 # ~40000/3.0, not ~0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_emergency_compact_shrinks_big_write_arg_below_limit():
|
|
29
|
+
# Reproduce the operator's crash shape: history dominated by an OLD
|
|
30
|
+
# full-file Write arg. Emergency compaction must get the estimate under the
|
|
31
|
+
# real 64k window (it returned the SAME oversized request before).
|
|
32
|
+
limit = 65536
|
|
33
|
+
big = "y" * 250000 # ~83k est tokens — over the window
|
|
34
|
+
msgs = [
|
|
35
|
+
{"role": "user", "content": "refactor cli.py"},
|
|
36
|
+
{"role": "assistant", "content": "",
|
|
37
|
+
"tool_calls": [{"id": "1", "name": "Write",
|
|
38
|
+
"input": {"file_path": "cli.py", "content": big}}]},
|
|
39
|
+
{"role": "tool", "tool_call_id": "1", "name": "Write", "content": "ok"},
|
|
40
|
+
{"role": "user", "content": "now run tests"},
|
|
41
|
+
{"role": "assistant", "content": "",
|
|
42
|
+
"tool_calls": [{"id": "2", "name": "Bash", "input": {"command": "pytest"}}]},
|
|
43
|
+
{"role": "tool", "tool_call_id": "2", "name": "Bash", "content": "passed"},
|
|
44
|
+
]
|
|
45
|
+
assert estimate_tokens(msgs) > limit
|
|
46
|
+
emergency_compact(msgs, limit)
|
|
47
|
+
assert estimate_tokens(msgs) < limit # actually under the window now
|
|
48
|
+
# The recent Bash tool_call (last) is preserved intact.
|
|
49
|
+
assert msgs[4]["tool_calls"][0]["input"]["command"] == "pytest"
|
|
50
|
+
|
|
51
|
+
|
|
18
52
|
def test_is_context_length_error_matches_provider_phrasings():
|
|
19
53
|
for msg in [
|
|
20
54
|
"This model's maximum context length is 131072 tokens",
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""STOP/interrupt: a threading.Event in config["_cancel"] lets the TUI halt a
|
|
2
|
+
running turn at a safe point. The agent must (a) not start a turn when stop is
|
|
3
|
+
already set, and (b) end after the current turn's tools rather than looping —
|
|
4
|
+
without leaving an assistant tool_call missing its tool result."""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import threading
|
|
8
|
+
|
|
9
|
+
import drydock.agent as agent_mod
|
|
10
|
+
from drydock.agent import AgentState, run
|
|
11
|
+
from drydock.providers import AssistantTurn
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_no_turn_when_already_stopped(monkeypatch):
|
|
15
|
+
calls = {"n": 0}
|
|
16
|
+
|
|
17
|
+
def fake_stream(**kw):
|
|
18
|
+
calls["n"] += 1
|
|
19
|
+
yield AssistantTurn("hi", [], 1, 1)
|
|
20
|
+
|
|
21
|
+
monkeypatch.setattr(agent_mod, "stream", fake_stream)
|
|
22
|
+
ev = threading.Event()
|
|
23
|
+
ev.set()
|
|
24
|
+
st = AgentState()
|
|
25
|
+
list(run("go", st, {"model": "m", "_cancel": ev}, "sys"))
|
|
26
|
+
assert calls["n"] == 0 # loop broke before any model call
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_stop_ends_after_current_turn(monkeypatch):
|
|
30
|
+
# The model would loop forever (always returns a tool call); a stop set
|
|
31
|
+
# during the first turn must end the run after that turn's tools.
|
|
32
|
+
calls = {"n": 0}
|
|
33
|
+
ev = threading.Event()
|
|
34
|
+
|
|
35
|
+
def fake_stream(**kw):
|
|
36
|
+
calls["n"] += 1
|
|
37
|
+
ev.set() # user hits STOP while this turn runs
|
|
38
|
+
yield AssistantTurn(
|
|
39
|
+
"", [{"id": str(calls["n"]), "name": "Read", "input": {"file_path": "nope"}}], 1, 1
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
monkeypatch.setattr(agent_mod, "stream", fake_stream)
|
|
43
|
+
st = AgentState()
|
|
44
|
+
list(run("go", st, {"model": "m", "_cancel": ev}, "sys"))
|
|
45
|
+
assert calls["n"] == 1 # exactly one turn, then stopped
|
|
46
|
+
|
|
47
|
+
# History is still valid: every assistant tool_call has a matching tool msg.
|
|
48
|
+
call_ids = [
|
|
49
|
+
tc["id"]
|
|
50
|
+
for msg in st.messages
|
|
51
|
+
if msg.get("role") == "assistant"
|
|
52
|
+
for tc in (msg.get("tool_calls") or [])
|
|
53
|
+
]
|
|
54
|
+
tool_ids = [m["tool_call_id"] for m in st.messages if m["role"] == "tool"]
|
|
55
|
+
assert sorted(call_ids) == sorted(tool_ids)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_no_cancel_event_runs_normally(monkeypatch):
|
|
59
|
+
# Absent a _cancel event, behaviour is unchanged.
|
|
60
|
+
seq = [AssistantTurn("done", [], 1, 1)]
|
|
61
|
+
monkeypatch.setattr(agent_mod, "stream", lambda **kw: iter([seq.pop(0)]))
|
|
62
|
+
st = AgentState()
|
|
63
|
+
list(run("go", st, {"model": "m"}, "sys"))
|
|
64
|
+
assert any(m.get("role") == "assistant" for m in st.messages)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|