drydock-cli 3.0.114__tar.gz → 3.0.115__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.
Files changed (124) hide show
  1. {drydock_cli-3.0.114/drydock_cli.egg-info → drydock_cli-3.0.115}/PKG-INFO +1 -1
  2. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/drydock/agent.py +14 -10
  3. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/drydock/providers.py +28 -4
  4. {drydock_cli-3.0.114 → drydock_cli-3.0.115/drydock_cli.egg-info}/PKG-INFO +1 -1
  5. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/drydock_cli.egg-info/SOURCES.txt +1 -0
  6. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/pyproject.toml +1 -1
  7. drydock_cli-3.0.115/tests/test_repetition_interrupt.py +68 -0
  8. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/LICENSE +0 -0
  9. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/NOTICE +0 -0
  10. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/README.md +0 -0
  11. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/drydock/__init__.py +0 -0
  12. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/drydock/__main__.py +0 -0
  13. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/drydock/advisor.py +0 -0
  14. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/drydock/bash_safety.py +0 -0
  15. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/drydock/builtin_skills/__init__.py +0 -0
  16. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/drydock/builtin_skills/rmf-categorize.md +0 -0
  17. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/drydock/builtin_skills/rmf-control.md +0 -0
  18. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/drydock/builtin_skills/rmf-poam.md +0 -0
  19. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/drydock/builtin_skills/rmf-review.md +0 -0
  20. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/drydock/builtin_skills/stig-assess.md +0 -0
  21. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/drydock/builtin_skills/stig-remediate.md +0 -0
  22. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/drydock/cci.py +0 -0
  23. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/drydock/cli.py +0 -0
  24. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/drydock/compaction.py +0 -0
  25. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/drydock/config.py +0 -0
  26. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/drydock/detect.py +0 -0
  27. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/drydock/extract.py +0 -0
  28. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/drydock/gittools.py +0 -0
  29. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/drydock/graphrag.py +0 -0
  30. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/drydock/guards.py +0 -0
  31. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/drydock/loop_detect.py +0 -0
  32. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/drydock/mcp.py +0 -0
  33. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/drydock/poam.py +0 -0
  34. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/drydock/rmf.py +0 -0
  35. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/drydock/rmf_graph.py +0 -0
  36. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/drydock/skills.py +0 -0
  37. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/drydock/stig.py +0 -0
  38. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/drydock/tool_registry.py +0 -0
  39. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/drydock/tools/__init__.py +0 -0
  40. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/drydock/tui/__init__.py +0 -0
  41. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/drydock/tui/app.py +0 -0
  42. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/drydock/tui/approval.py +0 -0
  43. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/drydock/tui/messages.py +0 -0
  44. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/drydock/tui/widgets.py +0 -0
  45. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/drydock/tuning.py +0 -0
  46. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/drydock/web.py +0 -0
  47. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/drydock_cli.egg-info/dependency_links.txt +0 -0
  48. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/drydock_cli.egg-info/entry_points.txt +0 -0
  49. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/drydock_cli.egg-info/requires.txt +0 -0
  50. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/drydock_cli.egg-info/top_level.txt +0 -0
  51. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/setup.cfg +0 -0
  52. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_advisor.py +0 -0
  53. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_approval.py +0 -0
  54. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_back_command.py +0 -0
  55. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_bash_background.py +0 -0
  56. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_bash_binary_output.py +0 -0
  57. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_bash_crossplatform.py +0 -0
  58. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_bash_output_bounding.py +0 -0
  59. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_bash_process_group.py +0 -0
  60. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_bash_safety.py +0 -0
  61. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_bash_sanitize.py +0 -0
  62. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_bash_shell.py +0 -0
  63. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_bash_stdin.py +0 -0
  64. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_bash_stop_partial.py +0 -0
  65. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_bash_timeout_network.py +0 -0
  66. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_bash_timeout_param.py +0 -0
  67. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_cci.py +0 -0
  68. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_cli_agents.py +0 -0
  69. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_compact_command.py +0 -0
  70. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_compaction.py +0 -0
  71. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_config.py +0 -0
  72. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_config_migration.py +0 -0
  73. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_context_limit_config.py +0 -0
  74. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_detect.py +0 -0
  75. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_dispatch.py +0 -0
  76. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_e2e_connected.py +0 -0
  77. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_edit_replace_all.py +0 -0
  78. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_empty_response.py +0 -0
  79. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_extract.py +0 -0
  80. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_failure_loop.py +0 -0
  81. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_first_run_setup.py +0 -0
  82. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_gittools.py +0 -0
  83. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_glob_edit_edges.py +0 -0
  84. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_graphify_example.py +0 -0
  85. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_graphrag.py +0 -0
  86. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_graphrag_quoted_path.py +0 -0
  87. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_grep_and_read_robust.py +0 -0
  88. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_guards_and_tools.py +0 -0
  89. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_hallucinated_tools.py +0 -0
  90. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_leaked_tool_call.py +0 -0
  91. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_loop_detect.py +0 -0
  92. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_mcp.py +0 -0
  93. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_oneshot_unreachable.py +0 -0
  94. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_overthink_interrupt.py +0 -0
  95. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_plan_autocontinue.py +0 -0
  96. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_poam.py +0 -0
  97. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_providers_unreachable.py +0 -0
  98. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_read_index.py +0 -0
  99. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_rmf.py +0 -0
  100. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_rmf_graph.py +0 -0
  101. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_rmf_stig_graph.py +0 -0
  102. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_runaway_repetition.py +0 -0
  103. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_screenshot.py +0 -0
  104. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_server_probe.py +0 -0
  105. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_skills.py +0 -0
  106. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_stall_retry.py +0 -0
  107. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_stig.py +0 -0
  108. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_stop.py +0 -0
  109. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_streaming_newlines.py +0 -0
  110. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_subagent.py +0 -0
  111. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_system_prompt_help.py +0 -0
  112. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_todo.py +0 -0
  113. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_tool_arg_coercion.py +0 -0
  114. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_tool_arg_coercion_more.py +0 -0
  115. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_tool_arg_parsing.py +0 -0
  116. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_tools_undo.py +0 -0
  117. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_tui.py +0 -0
  118. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_tuning.py +0 -0
  119. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_viewimage.py +0 -0
  120. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_vision_input.py +0 -0
  121. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_web_tools.py +0 -0
  122. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_windows_shell.py +0 -0
  123. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_write_content_coerce.py +0 -0
  124. {drydock_cli-3.0.114 → drydock_cli-3.0.115}/tests/test_xccdf.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.114
3
+ Version: 3.0.115
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
@@ -33,7 +33,7 @@ def _plan_has_unfinished(config: dict) -> bool:
33
33
  todo = config.get("_todo")
34
34
  return bool(todo) and any(status != "done" for _, status in todo)
35
35
 
36
- from drydock.providers import stream, AssistantTurn, ReasoningChunk, TextChunk, StallRetry
36
+ from drydock.providers import stream, AssistantTurn, ReasoningChunk, TextChunk, StallRetry, RepetitionDetected
37
37
  from drydock.tool_registry import schemas, execute
38
38
  from drydock.tools import register_all
39
39
 
@@ -194,24 +194,28 @@ def run(
194
194
  elif isinstance(event, AssistantTurn):
195
195
  assistant_turn = event
196
196
  break # success
197
- except StallRetry:
198
- # The call stalled or over-thought past stall_retry_secs. First
199
- # retry re-issues as-is (handles a transient server hang cheaply);
200
- # if it KEEPS happening it's over-thinking, so escalate to decisive
201
- # mode a forcing suffix + a hard token cap that make a long
202
- # reasoning turn impossible. Bounded; on exhaustion end cleanly.
197
+ except StallRetry as _sr:
198
+ # Stalled/over-thought past stall_retry_secs (wall-time), OR collapsed
199
+ # into a pure-repetition loop (RepetitionDetected, content-based). A
200
+ # first wall-time retry re-issues as-is (a transient hang clears); a
201
+ # persistent stall OR any repetition loop goes straight to decisive
202
+ # mode a forcing suffix + hard token cap that make a long reasoning
203
+ # turn impossible. Repetition escalates immediately (re-issuing a loop
204
+ # just loops again). Bounded; on exhaustion end cleanly.
205
+ is_rep = isinstance(_sr, RepetitionDetected)
203
206
  stall_retries += 1
204
207
  if stall_retries > 3:
205
- yield TextChunk("\n[model kept stalling/over-thinking — giving up on this step.]\n")
208
+ yield TextChunk("\n[model kept stalling/looping — giving up on this step.]\n")
206
209
  assistant_turn = None
207
210
  break
208
- if stall_retries >= 2 and not decisive:
211
+ if (is_rep or stall_retries >= 2) and not decisive:
209
212
  decisive = True
210
213
  turn_config = dict(turn_config)
211
214
  cur = int(turn_config.get("max_tokens", 8192) or 8192)
212
215
  turn_config["max_tokens"] = min(cur, _DECISIVE_MAX_TOKENS)
213
216
  turn_config["reasoning_effort"] = "low"
214
- yield TextChunk("\n[taking too long forcing a decisive, single-action step...]\n")
217
+ why = "output looping" if is_rep else "taking too long"
218
+ yield TextChunk(f"\n[{why} — forcing a decisive, single-action step...]\n")
215
219
  else:
216
220
  yield TextChunk("\n[model server stalled — retrying...]\n")
217
221
  continue
@@ -30,6 +30,14 @@ class StallRetry(Exception):
30
30
  abandons the wedged request and re-issues it; a fresh generation usually
31
31
  doesn't stall. Opt-in via config `stall_retry_secs` (0 = off)."""
32
32
 
33
+
34
+ class RepetitionDetected(StallRetry):
35
+ """CONTENT trigger for the over-think interrupt: the model collapsed into a
36
+ pure-repetition loop (same short unit ×6+, 600+ chars) and produced no usable
37
+ action. Unlike the wall-time StallRetry this fires on the *content*, so it
38
+ never trips on productive-but-long reasoning — only genuine spinning. A plain
39
+ re-issue would just loop again, so the agent goes straight to decisive mode."""
40
+
33
41
  from drydock.loop_detect import runaway_repetition_len
34
42
  from drydock.tuning import extract_thinking, strip_leaked_tool_calls, strip_thinking_tokens, use_streaming
35
43
 
@@ -540,6 +548,11 @@ def stream(
540
548
  _rep_checked_at = len(text)
541
549
  run = runaway_repetition_len(text)
542
550
  if run:
551
+ # With the over-think interrupt on, a genuine loop escalates
552
+ # to a decisive retry (recover) instead of returning a
553
+ # truncated turn. Otherwise keep the advisory trim+stop.
554
+ if stall_secs:
555
+ raise RepetitionDetected(0)
543
556
  text = (
544
557
  text[: len(text) - run]
545
558
  + "\n[… output began repeating — stopped by drydock]"
@@ -599,10 +612,6 @@ def _complete_nonstreaming(
599
612
  # the generic special-token pass would eat the tool_call markers.
600
613
  text, had_leak = strip_leaked_tool_calls(msg.content or "")
601
614
  thinking, text = extract_thinking(text)
602
- if thinking:
603
- yield ReasoningChunk(thinking)
604
- if text.strip():
605
- yield TextChunk(text)
606
615
 
607
616
  tool_calls = []
608
617
  for i, tc in enumerate(getattr(msg, "tool_calls", None) or []):
@@ -613,6 +622,21 @@ def _complete_nonstreaming(
613
622
  "input": inp,
614
623
  })
615
624
 
625
+ # Repetition trigger for the over-think interrupt (gated by the same
626
+ # stall_secs feature). Non-streaming gemma turns are where the over-think
627
+ # lives; if the turn collapsed into a pure loop and produced NO usable action,
628
+ # go decisive rather than feeding the garbage turn into the conversation. Only
629
+ # fires on genuine repetition, so productive-but-long reasoning is never hit.
630
+ if stall_secs and not tool_calls and (
631
+ runaway_repetition_len(thinking or "") or runaway_repetition_len(text or "")
632
+ ):
633
+ raise RepetitionDetected(0)
634
+
635
+ if thinking:
636
+ yield ReasoningChunk(thinking)
637
+ if text.strip():
638
+ yield TextChunk(text)
639
+
616
640
  usage = getattr(resp, "usage", None)
617
641
  in_tok = usage.prompt_tokens if usage else 0
618
642
  out_tok = usage.completion_tokens if usage else 0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.114
3
+ Version: 3.0.115
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
@@ -93,6 +93,7 @@ tests/test_plan_autocontinue.py
93
93
  tests/test_poam.py
94
94
  tests/test_providers_unreachable.py
95
95
  tests/test_read_index.py
96
+ tests/test_repetition_interrupt.py
96
97
  tests/test_rmf.py
97
98
  tests/test_rmf_graph.py
98
99
  tests/test_rmf_stig_graph.py
@@ -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.114"
10
+ version = "3.0.115"
11
11
  description = "Drydock — a local, provider-agnostic terminal coding agent for local LLMs"
12
12
  readme = "README.md"
13
13
  requires-python = ">=3.11"
@@ -0,0 +1,68 @@
1
+ """Content-based repetition trigger for the over-think interrupt: a non-streaming
2
+ turn that collapses into a pure loop AND produces no action escalates straight to
3
+ decisive mode. It must NOT fire on varied reasoning, on a turn with a tool call,
4
+ or when the interrupt is disabled (stall_secs=0)."""
5
+ from __future__ import annotations
6
+
7
+ import pytest
8
+
9
+ from drydock import agent, providers
10
+ from drydock.providers import AssistantTurn, RepetitionDetected, _complete_nonstreaming
11
+
12
+
13
+ class _Fn:
14
+ def __init__(self, name, args): self.name = name; self.arguments = args
15
+ class _TC:
16
+ def __init__(self, name, args): self.id = "c1"; self.function = _Fn(name, args)
17
+ class _Msg:
18
+ def __init__(self, content, tcs=None): self.content = content; self.tool_calls = tcs or []
19
+ class _Choice:
20
+ def __init__(self, msg): self.message = msg
21
+ class _Resp:
22
+ def __init__(self, content, tcs=None): self.choices = [_Choice(_Msg(content, tcs))]; self.usage = None
23
+
24
+
25
+ def _patch(monkeypatch, content, tcs=None):
26
+ monkeypatch.setattr(providers, "_create_abortable", lambda *a, **k: _Resp(content, tcs))
27
+
28
+
29
+ def test_pure_loop_no_action_raises(monkeypatch):
30
+ _patch(monkeypatch, "loop " * 300) # 1500 chars of one unit
31
+ with pytest.raises(RepetitionDetected):
32
+ list(_complete_nonstreaming(None, {}, stall_secs=300))
33
+
34
+
35
+ def test_varied_reasoning_does_not_raise(monkeypatch):
36
+ _patch(monkeypatch, "First check the file, then run grep, then write the result and verify it.")
37
+ out = list(_complete_nonstreaming(None, {}, stall_secs=300))
38
+ assert any(isinstance(o, AssistantTurn) for o in out) # completed normally
39
+
40
+
41
+ def test_loop_but_has_toolcall_does_not_raise(monkeypatch):
42
+ _patch(monkeypatch, "loop " * 300, [_TC("Bash", '{"command":"ls"}')])
43
+ out = list(_complete_nonstreaming(None, {}, stall_secs=300))
44
+ turn = [o for o in out if isinstance(o, AssistantTurn)][0]
45
+ assert turn.tool_calls and turn.tool_calls[0]["name"] == "Bash" # action preserved
46
+
47
+
48
+ def test_disabled_when_stall_secs_zero(monkeypatch):
49
+ _patch(monkeypatch, "loop " * 300)
50
+ out = list(_complete_nonstreaming(None, {}, stall_secs=0)) # feature off
51
+ assert any(isinstance(o, AssistantTurn) for o in out)
52
+
53
+
54
+ def test_agent_goes_decisive_immediately_on_repetition(monkeypatch):
55
+ calls = []
56
+ def fake_stream(model, system, messages, tool_schemas, config):
57
+ calls.append({"system": system, "max_tokens": config.get("max_tokens")})
58
+ if len(calls) == 1:
59
+ raise RepetitionDetected(0)
60
+ yield AssistantTurn("done", [], 10, 5)
61
+ monkeypatch.setattr(agent, "stream", fake_stream)
62
+ st = agent.AgentState()
63
+ cfg = {"model": "gemma4", "max_tokens": 8192, "stall_retry_secs": 300, "context_limit": 65536}
64
+ list(agent.run("go", st, cfg, "SYS"))
65
+ # first call raised repetition → SECOND call is already decisive (no plain retry)
66
+ assert len(calls) == 2
67
+ assert agent._DECISIVE_SUFFIX in calls[1]["system"]
68
+ assert calls[1]["max_tokens"] <= agent._DECISIVE_MAX_TOKENS
File without changes
File without changes
File without changes
File without changes