AbstractAgent 0.3.13__tar.gz → 0.3.15__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 (128) hide show
  1. {abstractagent-0.3.13/src/abstractagent.egg-info → abstractagent-0.3.15}/PKG-INFO +14 -10
  2. {abstractagent-0.3.13 → abstractagent-0.3.15}/README.md +8 -6
  3. {abstractagent-0.3.13 → abstractagent-0.3.15}/pyproject.toml +7 -4
  4. abstractagent-0.3.15/src/abstractagent/adapters/announced_calls.py +463 -0
  5. {abstractagent-0.3.13 → abstractagent-0.3.15}/src/abstractagent/adapters/codeact_runtime.py +142 -4
  6. {abstractagent-0.3.13 → abstractagent-0.3.15}/src/abstractagent/adapters/emit_inventory.py +25 -0
  7. {abstractagent-0.3.13 → abstractagent-0.3.15}/src/abstractagent/adapters/memact_runtime.py +143 -3
  8. {abstractagent-0.3.13 → abstractagent-0.3.15}/src/abstractagent/adapters/react_runtime.py +304 -30
  9. {abstractagent-0.3.13 → abstractagent-0.3.15/src/abstractagent.egg-info}/PKG-INFO +14 -10
  10. {abstractagent-0.3.13 → abstractagent-0.3.15}/src/abstractagent.egg-info/SOURCES.txt +3 -0
  11. {abstractagent-0.3.13 → abstractagent-0.3.15}/src/abstractagent.egg-info/requires.txt +5 -3
  12. abstractagent-0.3.15/tests/test_agent_stream_inheritance.py +56 -0
  13. abstractagent-0.3.15/tests/test_announced_tool_reprompt.py +751 -0
  14. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_c2447_visit_lane_honesty.py +2 -2
  15. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_packaging_profiles.py +3 -3
  16. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_react_loop_followthrough_retry.py +3 -1
  17. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_reasoning_first_citizen_agent.py +78 -0
  18. {abstractagent-0.3.13 → abstractagent-0.3.15}/LICENSE +0 -0
  19. {abstractagent-0.3.13 → abstractagent-0.3.15}/setup.cfg +0 -0
  20. {abstractagent-0.3.13 → abstractagent-0.3.15}/src/abstractagent/__init__.py +0 -0
  21. {abstractagent-0.3.13 → abstractagent-0.3.15}/src/abstractagent/adapters/__init__.py +0 -0
  22. {abstractagent-0.3.13 → abstractagent-0.3.15}/src/abstractagent/adapters/generation_params.py +0 -0
  23. {abstractagent-0.3.13 → abstractagent-0.3.15}/src/abstractagent/adapters/loop_hooks.py +0 -0
  24. {abstractagent-0.3.13 → abstractagent-0.3.15}/src/abstractagent/adapters/media.py +0 -0
  25. {abstractagent-0.3.13 → abstractagent-0.3.15}/src/abstractagent/adapters/native_loop_registry.py +0 -0
  26. {abstractagent-0.3.13 → abstractagent-0.3.15}/src/abstractagent/adapters/progress.py +0 -0
  27. {abstractagent-0.3.13 → abstractagent-0.3.15}/src/abstractagent/adapters/read_orchestration.py +0 -0
  28. {abstractagent-0.3.13 → abstractagent-0.3.15}/src/abstractagent/adapters/tool_allowlist.py +0 -0
  29. {abstractagent-0.3.13 → abstractagent-0.3.15}/src/abstractagent/adapters/tool_failure_hints.py +0 -0
  30. {abstractagent-0.3.13 → abstractagent-0.3.15}/src/abstractagent/adapters/transcripts.py +0 -0
  31. {abstractagent-0.3.13 → abstractagent-0.3.15}/src/abstractagent/agents/__init__.py +0 -0
  32. {abstractagent-0.3.13 → abstractagent-0.3.15}/src/abstractagent/agents/base.py +0 -0
  33. {abstractagent-0.3.13 → abstractagent-0.3.15}/src/abstractagent/agents/codeact.py +0 -0
  34. {abstractagent-0.3.13 → abstractagent-0.3.15}/src/abstractagent/agents/defaults.py +0 -0
  35. {abstractagent-0.3.13 → abstractagent-0.3.15}/src/abstractagent/agents/memact.py +0 -0
  36. {abstractagent-0.3.13 → abstractagent-0.3.15}/src/abstractagent/agents/react.py +0 -0
  37. {abstractagent-0.3.13 → abstractagent-0.3.15}/src/abstractagent/agents/task_reset.py +0 -0
  38. {abstractagent-0.3.13 → abstractagent-0.3.15}/src/abstractagent/agents/unattended.py +0 -0
  39. {abstractagent-0.3.13 → abstractagent-0.3.15}/src/abstractagent/logic/__init__.py +0 -0
  40. {abstractagent-0.3.13 → abstractagent-0.3.15}/src/abstractagent/logic/builtins.py +0 -0
  41. {abstractagent-0.3.13 → abstractagent-0.3.15}/src/abstractagent/logic/codeact.py +0 -0
  42. {abstractagent-0.3.13 → abstractagent-0.3.15}/src/abstractagent/logic/memact.py +0 -0
  43. {abstractagent-0.3.13 → abstractagent-0.3.15}/src/abstractagent/logic/react.py +0 -0
  44. {abstractagent-0.3.13 → abstractagent-0.3.15}/src/abstractagent/logic/types.py +0 -0
  45. {abstractagent-0.3.13 → abstractagent-0.3.15}/src/abstractagent/metrics/__init__.py +0 -0
  46. {abstractagent-0.3.13 → abstractagent-0.3.15}/src/abstractagent/metrics/prefix_reuse.py +0 -0
  47. {abstractagent-0.3.13 → abstractagent-0.3.15}/src/abstractagent/repl.py +0 -0
  48. {abstractagent-0.3.13 → abstractagent-0.3.15}/src/abstractagent/sandbox/__init__.py +0 -0
  49. {abstractagent-0.3.13 → abstractagent-0.3.15}/src/abstractagent/sandbox/interface.py +0 -0
  50. {abstractagent-0.3.13 → abstractagent-0.3.15}/src/abstractagent/sandbox/local.py +0 -0
  51. {abstractagent-0.3.13 → abstractagent-0.3.15}/src/abstractagent/scripts/__init__.py +0 -0
  52. {abstractagent-0.3.13 → abstractagent-0.3.15}/src/abstractagent/scripts/lmstudio_tool_eval.py +0 -0
  53. {abstractagent-0.3.13 → abstractagent-0.3.15}/src/abstractagent/tools/__init__.py +0 -0
  54. {abstractagent-0.3.13 → abstractagent-0.3.15}/src/abstractagent/tools/code_execution.py +0 -0
  55. {abstractagent-0.3.13 → abstractagent-0.3.15}/src/abstractagent/tools/self_improve.py +0 -0
  56. {abstractagent-0.3.13 → abstractagent-0.3.15}/src/abstractagent.egg-info/dependency_links.txt +0 -0
  57. {abstractagent-0.3.13 → abstractagent-0.3.15}/src/abstractagent.egg-info/entry_points.txt +0 -0
  58. {abstractagent-0.3.13 → abstractagent-0.3.15}/src/abstractagent.egg-info/top_level.txt +0 -0
  59. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_active_context_policy_adoption.py +0 -0
  60. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_adversary_hardening_2026_07_13.py +0 -0
  61. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_agent_plan_review_modes.py +0 -0
  62. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_agent_speculation_inheritance.py +0 -0
  63. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_agents_integration_llm.py +0 -0
  64. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_base_agent_trace_accessors.py +0 -0
  65. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_batching_prompt_rule.py +0 -0
  66. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_budget_triage_tail.py +0 -0
  67. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_builtin_tool_specs_optional_defaults.py +0 -0
  68. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_claim_0029_residue_2026_07_14.py +0 -0
  69. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_codeact_logic.py +0 -0
  70. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_codeact_runtime_verifier_policy.py +0 -0
  71. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_delegate_session_route_composition.py +0 -0
  72. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_emit_inventory_drift.py +0 -0
  73. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_gated_remainder_wave_2026_07_14.py +0 -0
  74. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_generation_params_media_policies.py +0 -0
  75. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_history_monster_clamp.py +0 -0
  76. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_improvement_wave_2026_07_13.py +0 -0
  77. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_llm_call_payload_requires_prompt_or_messages.py +0 -0
  78. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_lmstudio_tool_eval_metrics.py +0 -0
  79. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_loop_hooks.py +0 -0
  80. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_loop_hooks_0026_follow_ups.py +0 -0
  81. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_memact_codeact_guidance_durable_transcript.py +0 -0
  82. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_memact_session_memory.py +0 -0
  83. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_memact_workflow.py +0 -0
  84. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_memory_recall_tool.py +0 -0
  85. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_native_loop_registry.py +0 -0
  86. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_open_attachment_tool_available.py +0 -0
  87. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_operator_conclude.py +0 -0
  88. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_optimal_wave_batch2_2026_07_13.py +0 -0
  89. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_parse_prompt_cache_capture.py +0 -0
  90. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_prefix_reuse_metric.py +0 -0
  91. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_progress_detection.py +0 -0
  92. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_react_act_only_observation.py +0 -0
  93. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_react_active_attachments_disable_tools_first_iteration.py +0 -0
  94. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_react_adapter_forwards_context_attachments_media.py +0 -0
  95. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_react_delegate_agent_tool.py +0 -0
  96. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_react_election_fence_conformance.py +0 -0
  97. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_react_entity_dress_conformance.py +0 -0
  98. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_react_guidance_durable_transcript.py +0 -0
  99. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_react_logic.py +0 -0
  100. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_react_loop_aborted_generation_retry.py +0 -0
  101. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_react_loop_context_transcript_levels.py +0 -0
  102. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_react_loop_side_effect_dedupe_comms.py +0 -0
  103. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_react_loop_truncated_output_retry.py +0 -0
  104. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_react_max_iterations_concludes.py +0 -0
  105. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_react_phase_grant_boundary.py +0 -0
  106. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_react_planish_heuristic.py +0 -0
  107. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_react_prompt_prefix_stability.py +0 -0
  108. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_react_sight_lane_media_fold.py +0 -0
  109. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_react_turn_byte_stability.py +0 -0
  110. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_react_verifier_and_plan.py +0 -0
  111. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_react_visit_composition.py +0 -0
  112. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_react_visit_merge.py +0 -0
  113. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_react_workflow_system_prompt_and_tools_override.py +0 -0
  114. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_react_workflow_toolset_refresh.py +0 -0
  115. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_read_orchestration_guidance.py +0 -0
  116. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_review_failure_containment.py +0 -0
  117. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_run_start_seeds_runtime_provider_model.py +0 -0
  118. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_session_memory.py +0 -0
  119. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_sibling_loop_defects_batch.py +0 -0
  120. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_sibling_strict_transcripts_0011.py +0 -0
  121. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_stuck_streak_termination.py +0 -0
  122. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_tool_failure_hints.py +0 -0
  123. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_tool_loop_prefix_extension_a3.py +0 -0
  124. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_tool_observe_emits_result_for_tui.py +0 -0
  125. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_tools_editing.py +0 -0
  126. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_unseeded_render_limits.py +0 -0
  127. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_verifier_execution_preference.py +0 -0
  128. {abstractagent-0.3.13 → abstractagent-0.3.15}/tests/test_verifier_schema_strict.py +0 -0
@@ -1,22 +1,24 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: abstractagent
3
- Version: 0.3.13
3
+ Version: 0.3.15
4
4
  Summary: Agent implementations using AbstractRuntime and AbstractCore
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
7
7
  License-File: LICENSE
8
8
  Requires-Dist: abstractcore[tools]>=2.13.41
9
- Requires-Dist: abstractruntime>=0.4.32
9
+ Requires-Dist: abstractruntime>=0.5.0
10
10
  Provides-Extra: apple
11
11
  Requires-Dist: abstractcore[all-apple]>=2.13.41; extra == "apple"
12
- Requires-Dist: abstractruntime[apple]>=0.4.32; extra == "apple"
12
+ Requires-Dist: abstractruntime[apple]>=0.5.0; extra == "apple"
13
13
  Provides-Extra: gpu
14
14
  Requires-Dist: abstractcore[all-gpu]>=2.13.41; extra == "gpu"
15
- Requires-Dist: abstractruntime[gpu]>=0.4.32; extra == "gpu"
15
+ Requires-Dist: abstractruntime[gpu]>=0.5.0; extra == "gpu"
16
16
  Provides-Extra: dev
17
17
  Requires-Dist: pytest>=7.0; extra == "dev"
18
+ Requires-Dist: jinja2>=3.1; extra == "dev"
18
19
  Provides-Extra: test
19
20
  Requires-Dist: pytest>=7.0; extra == "test"
21
+ Requires-Dist: jinja2>=3.1; extra == "test"
20
22
  Dynamic: license-file
21
23
 
22
24
  # AbstractAgent
@@ -46,12 +48,14 @@ flowchart LR
46
48
  - Getting started: [`docs/getting-started.md`](docs/getting-started.md)
47
49
  - API reference: [`docs/api.md`](docs/api.md)
48
50
  - Loop hooks (listen/steer/capture): [`docs/hooks.md`](docs/hooks.md)
49
- - FAQ / troubleshooting: [`docs/faq.md`](docs/faq.md)
51
+ - FAQ: [`docs/faq.md`](docs/faq.md)
52
+ - Troubleshooting: [`docs/troubleshooting.md`](docs/troubleshooting.md)
50
53
  - Architecture (diagrams): [`docs/architecture.md`](docs/architecture.md)
51
54
  - Changelog: [`CHANGELOG.md`](CHANGELOG.md)
52
55
  - Contributing: [`CONTRIBUTING.md`](CONTRIBUTING.md)
53
56
  - Security: [`SECURITY.md`](SECURITY.md)
54
57
  - Acknowledgements: [`ACKNOWLEDMENTS.md`](ACKNOWLEDMENTS.md)
58
+ - Code of conduct: [`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md)
55
59
 
56
60
  ## What you get
57
61
 
@@ -103,9 +107,10 @@ Native Python hardware profile cascades are available for deployment manifests:
103
107
  AbstractCore and AbstractRuntime profiles; AbstractAgent itself remains
104
108
  provider/runtime agnostic.
105
109
 
106
- AbstractAgent 0.3.13 requires `abstractcore[tools]>=2.13.41` and
107
- `AbstractRuntime>=0.4.32` (the runtime release that provides turn grounding,
108
- in-flight cancellation and speculation inheritance).
110
+ AbstractAgent 0.3.15 requires `abstractcore[tools]>=2.13.41` and
111
+ `AbstractRuntime>=0.5.0` (the runtime release that provides live token
112
+ streaming, on top of turn grounding, in-flight cancellation and speculation
113
+ inheritance). AbstractRuntime 0.5.0 itself requires AbstractCore 2.16.0 or newer.
109
114
 
110
115
  Note: the repository may be ahead of the latest published PyPI release. To verify what you installed:
111
116
 
@@ -129,8 +134,7 @@ print(state.output["answer"])
129
134
  Tip: these loops send the full transcript plus ~19 tool schemas every cycle —
130
135
  prefer a tool-capable model. On Ollama, raise the context window to the model's
131
136
  maximum available context or the server silently truncates from the oldest
132
- content first: per-call `llm_kwargs={"num_ctx": <model max>}` (forwarded since
133
- 2026-07-13) or server-side `OLLAMA_CONTEXT_LENGTH=<model max> ollama serve`.
137
+ content first: per-call `llm_kwargs={"num_ctx": <model max>}` or server-side `OLLAMA_CONTEXT_LENGTH=<model max> ollama serve`.
134
138
  House rule: maximum available context unless you explicitly choose otherwise —
135
139
  a fixed lower number is a hidden ceiling (see [`docs/faq.md`](docs/faq.md)).
136
140
 
@@ -25,12 +25,14 @@ flowchart LR
25
25
  - Getting started: [`docs/getting-started.md`](docs/getting-started.md)
26
26
  - API reference: [`docs/api.md`](docs/api.md)
27
27
  - Loop hooks (listen/steer/capture): [`docs/hooks.md`](docs/hooks.md)
28
- - FAQ / troubleshooting: [`docs/faq.md`](docs/faq.md)
28
+ - FAQ: [`docs/faq.md`](docs/faq.md)
29
+ - Troubleshooting: [`docs/troubleshooting.md`](docs/troubleshooting.md)
29
30
  - Architecture (diagrams): [`docs/architecture.md`](docs/architecture.md)
30
31
  - Changelog: [`CHANGELOG.md`](CHANGELOG.md)
31
32
  - Contributing: [`CONTRIBUTING.md`](CONTRIBUTING.md)
32
33
  - Security: [`SECURITY.md`](SECURITY.md)
33
34
  - Acknowledgements: [`ACKNOWLEDMENTS.md`](ACKNOWLEDMENTS.md)
35
+ - Code of conduct: [`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md)
34
36
 
35
37
  ## What you get
36
38
 
@@ -82,9 +84,10 @@ Native Python hardware profile cascades are available for deployment manifests:
82
84
  AbstractCore and AbstractRuntime profiles; AbstractAgent itself remains
83
85
  provider/runtime agnostic.
84
86
 
85
- AbstractAgent 0.3.13 requires `abstractcore[tools]>=2.13.41` and
86
- `AbstractRuntime>=0.4.32` (the runtime release that provides turn grounding,
87
- in-flight cancellation and speculation inheritance).
87
+ AbstractAgent 0.3.15 requires `abstractcore[tools]>=2.13.41` and
88
+ `AbstractRuntime>=0.5.0` (the runtime release that provides live token
89
+ streaming, on top of turn grounding, in-flight cancellation and speculation
90
+ inheritance). AbstractRuntime 0.5.0 itself requires AbstractCore 2.16.0 or newer.
88
91
 
89
92
  Note: the repository may be ahead of the latest published PyPI release. To verify what you installed:
90
93
 
@@ -108,8 +111,7 @@ print(state.output["answer"])
108
111
  Tip: these loops send the full transcript plus ~19 tool schemas every cycle —
109
112
  prefer a tool-capable model. On Ollama, raise the context window to the model's
110
113
  maximum available context or the server silently truncates from the oldest
111
- content first: per-call `llm_kwargs={"num_ctx": <model max>}` (forwarded since
112
- 2026-07-13) or server-side `OLLAMA_CONTEXT_LENGTH=<model max> ollama serve`.
114
+ content first: per-call `llm_kwargs={"num_ctx": <model max>}` or server-side `OLLAMA_CONTEXT_LENGTH=<model max> ollama serve`.
113
115
  House rule: maximum available context unless you explicitly choose otherwise —
114
116
  a fixed lower number is a hidden ceiling (see [`docs/faq.md`](docs/faq.md)).
115
117
 
@@ -4,29 +4,32 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "abstractagent"
7
- version = "0.3.13"
7
+ version = "0.3.15"
8
8
  description = "Agent implementations using AbstractRuntime and AbstractCore"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
11
11
  dependencies = [
12
12
  "abstractcore[tools]>=2.13.41",
13
- "abstractruntime>=0.4.32",
13
+ "abstractruntime>=0.5.0",
14
14
  ]
15
15
 
16
16
  [project.optional-dependencies]
17
17
  apple = [
18
18
  "abstractcore[all-apple]>=2.13.41",
19
- "abstractruntime[apple]>=0.4.32",
19
+ "abstractruntime[apple]>=0.5.0",
20
20
  ]
21
21
  gpu = [
22
22
  "abstractcore[all-gpu]>=2.13.41",
23
- "abstractruntime[gpu]>=0.4.32",
23
+ "abstractruntime[gpu]>=0.5.0",
24
24
  ]
25
25
  dev = [
26
26
  "pytest>=7.0",
27
+ "jinja2>=3.1",
27
28
  ]
28
29
  test = [
29
30
  "pytest>=7.0",
31
+ # Renders the vendored Qwen3.6 chat template (tests/test_announced_tool_reprompt.py).
32
+ "jinja2>=3.1",
30
33
  ]
31
34
 
32
35
  [tool.setuptools.packages.find]
@@ -0,0 +1,463 @@
1
+ """Replies that announce tool use but carry no runnable call (mission AGX, 2026-09-26).
2
+
3
+ Two shapes end a loop step with NO tool call while the model clearly meant to
4
+ act. Before this module both became the run's final answer:
5
+
6
+ 1. ANNOUNCED — a short intent sentence and nothing else. Evidence
7
+ (untracked/missions-2026-09-25/XP/REPORT.md, Qwen3.8-27B on MLX): at
8
+ iteration 3 the model wrote "I have strong material. Let me verify a couple
9
+ of key specifics (oil price level, gold, ...) before writing the digest."
10
+ and stopped, 5/5 gateway runs and 15/15 provider-level generations. The run
11
+ "completed" with that sentence as its answer and no digest.
12
+ 2. UNRUNNABLE — tool-call markup the provider could not turn into a call: a
13
+ tool name that was not offered, an envelope cut off mid-parameter, calls
14
+ drafted inside the thinking block that abstractcore >= 2.16.0 could not
15
+ recover (it recovers the clean trailing ones itself and reports the rest in
16
+ `metadata.warnings` / `metadata.unparsed_tool_call`). Published as an
17
+ answer, the user sees raw markup (backlog 0918).
18
+
19
+ The fix is ONE re-prompt that shows the model its own reply VERBATIM. The XP
20
+ evidence is specific about the verbatim part: re-prompting with the raw reply
21
+ made the model re-issue the same calls as visible calls 5/5; re-prompting with
22
+ the reply as the runtime records it (content "") made it believe the tools had
23
+ already run, 5/5. A second failure ends the step with a visible error — the
24
+ announcement or the markup is never published as the answer.
25
+
26
+ Pure functions only; each loop adapter owns its own routing.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import re
32
+ from typing import Any, Dict, List, Optional, Tuple
33
+
34
+ from .transcripts import extract_reasoning_text
35
+
36
+ __all__ = [
37
+ "NOT_AN_ANSWER_KINDS",
38
+ "REASON_ANNOUNCED",
39
+ "REASON_UNRUNNABLE",
40
+ "classify_no_call_reply",
41
+ "has_negated_intent",
42
+ "is_not_an_answer",
43
+ "resolve_checks",
44
+ "looks_like_tool_announcement",
45
+ "no_call_error_text",
46
+ "no_call_stop_reason",
47
+ "quote_reply",
48
+ "reprompt_text",
49
+ "tool_calls_from_reasoning",
50
+ "verbatim_reply",
51
+ "visible_content",
52
+ ]
53
+
54
+ REASON_ANNOUNCED = "announced_tool_use"
55
+ REASON_UNRUNNABLE = "unrunnable_tool_call"
56
+
57
+ # Replies at or above this many prose characters are never judged
58
+ # "announcement-only": a real answer is longer than one intent sentence, and a
59
+ # false positive costs a model turn and, twice, the answer itself.
60
+ ANNOUNCEMENT_MAX_CHARS = 300
61
+
62
+ _FENCED_BLOCK_RE = re.compile(r"(?s)```[^\n`]*\n.*?```")
63
+ _THINK_BLOCK_RE = re.compile(r"(?is)<think>.*?(</think>|$)")
64
+
65
+ # Openers of the tool-call envelopes the prompted parsers understand (mirrors
66
+ # abstractcore's `_TOOL_ENVELOPE_OPENER_RE`), plus Qwen's bare `<function=`.
67
+ _MARKUP_RE = re.compile(
68
+ r"(?i)<tool_call\b|<\|tool_call\|>|<\|tool_call>|<\|tool_call_start\|>|<function_call>|```tool_code|<function=[\w.:-]+>"
69
+ )
70
+ _MARKUP_STRIP_RE = re.compile(
71
+ r"(?is)"
72
+ r"<tool_call\b.*?(</tool_call>|$)|"
73
+ r"<\|tool_call\|>.*?(<\|/tool_call\|>|$)|"
74
+ r"<\|tool_call>.*?(<tool_call\|>|$)|"
75
+ r"<\|tool_call_start\|>.*?(<\|tool_call_end\|>|$)|"
76
+ r"<function_call>.*?(</function_call>|$)|"
77
+ r"```tool_code.*?(```|$)|"
78
+ r"<function=[\w.:-]+>.*?(</function>|$)"
79
+ )
80
+
81
+ # abstractcore's warning sentences for tool syntax that produced no call
82
+ # (providers/base.py `_warn_unrecognized_tool_syntax`,
83
+ # `_recover_tool_calls_from_reasoning`; streaming.py unparsed envelopes).
84
+ _TOOL_WARNING_RE = re.compile(r"(?i)^\s*(tool call not recognized|tool-call syntax|unparsed tool call)")
85
+ _UNKNOWN_NAMES_RE = re.compile(r"the model called ((?:'[^']*'(?:, )?)+)")
86
+
87
+ # First-person, forward-looking intent. The apostrophe is REQUIRED in "I'll":
88
+ # the optional-apostrophe spelling matches the word "ill".
89
+ _INTENT_RE = re.compile(
90
+ r"(?i)\b(let me|let['’]s|i will|i['’]ll|i am going to|i['’]m going to|i (?:just )?need to|i should|next,? i|now i)\b"
91
+ )
92
+ _NEGATED_INTENT_RE = re.compile(
93
+ r"(?i)\b(i will not|i won['’]t|i['’]ll not|i cannot|i can['’]t|i do not need to|i don['’]t need to|"
94
+ r"i need to stop|i should not|i shouldn['’]t|no need to)\b"
95
+ )
96
+ # Verbs that name work a tool would do. Broader than the long-reply heuristic
97
+ # in react_runtime (`_DEFERRED_ACTION_VERB_RE`) because it only applies to
98
+ # SHORT replies: every XP failure used one of verify/get/gather/confirm/fill/
99
+ # pull/do ... pass/check, none of which the old list knew.
100
+ _TOOL_VERBS = (
101
+ r"read|open|search|list|skim|inspect|explore|scan|run|execute|edit|fetch|download|creat(?:e|ing)|"
102
+ r"verify|check|double-check|confirm|validate|get|gather|grab|pull|look(?:\s+up|\s+into|\s+at)?|find|"
103
+ r"research|dig|fill|collect|query|call|test|browse|visit|retrieve|load|review|investigate|grep|"
104
+ r"examine|analy[sz]e|compare|write|save|update|modify|patch|install|build|compile|apply|"
105
+ r"do (?:one|a|another|two|some)"
106
+ )
107
+ _TOOL_VERB_RE = re.compile(r"(?i)\b(" + _TOOL_VERBS + r")\b")
108
+ # The model waiting on the user is a legitimate final reply.
109
+ _WAITING_RE = re.compile(
110
+ r"(?i)\b(let me know|what would you like|would you like|do you want|tell me|shall i|should i|"
111
+ r"i['’]ll wait|i will wait|waiting for|if you (?:want|need|like|prefer)|"
112
+ r"once you|when you|after you|unless you|until you|if you say|your (?:go|confirmation|approval))\b"
113
+ )
114
+ # Intent followed by a verb that TALKS rather than acts: "Let me summarize:",
115
+ # "Next, I recommend ...". Never an announcement of tool work.
116
+ _NON_ACTION_AFTER_INTENT_RE = re.compile(
117
+ r"(?i)\b(?:let me|let['’]s|i will|i['’]ll|i am going to|i['’]m going to|i should|next,? i|now i)\s+"
118
+ r"(?:also\s+|briefly\s+|just\s+)?"
119
+ r"(?:summari[sz]e|explain|recommend|suggest|mention|note|clarify|add|point out|conclude|answer|say|"
120
+ r"be (?:clear|brief|honest)|start by saying|wrap up)\b"
121
+ )
122
+ _PROGRESSIVE_TOOL_VERBS = (
123
+ r"(?:checking|fetching|searching|looking up|reading|running|verifying|pulling|gathering|grabbing|opening|"
124
+ r"loading|querying|downloading|inspecting|scanning|grepping|confirming|retrieving)"
125
+ )
126
+ # Present-progressive or elliptical action statements with no first person:
127
+ # "Checking the remaining two sources now.", "Proceeding to fetch ...",
128
+ # "One more search to confirm ..., then the digest.", "First, a quick search for ...",
129
+ # "Okay, fetching the Reuters article next.", "Time to check the CI logs.".
130
+ _ACTION_STATEMENT_RE = re.compile(
131
+ r"(?i)^(?:"
132
+ r"proceeding to \w+|"
133
+ + _PROGRESSIVE_TOOL_VERBS
134
+ + r"\b.*\b(?:now|next|then|first)\b|"
135
+ r"(?:okay|ok),?\s+" + _PROGRESSIVE_TOOL_VERBS + r"\b|"
136
+ r"time to (?:" + _TOOL_VERBS + r")\b|"
137
+ r"(?:one|two|a few) more (?:search|searches|check|checks|lookup|lookups|fetch|fetches|pass|read|reads)\b|"
138
+ r"first,? (?:a|one) (?:quick )?(?:search|check|lookup|fetch|pass|read)\b|"
139
+ r"i need (?:a bit |a little |some )?more (?:data|info|information|detail|details|context|evidence|sources)\b"
140
+ r")"
141
+ )
142
+ _SENTENCE_SPLIT_RE = re.compile(r"(?<=[.!])\s+")
143
+ # A reply that STARTS by delivering is a final answer, whatever intent words follow.
144
+ _DELIVERY_START_RE = re.compile(
145
+ r"(?i)^\s*(done|finished|completed|all set|all done|here (?:is|are)|here['’]s|final answer|in summary|"
146
+ r"to summarize|summary)\b"
147
+ )
148
+ _HEADING_RE = re.compile(r"(?m)^(#{1,6}\s+\S|\*\*\S)")
149
+
150
+
151
+ # Transcript message kinds that hold a FAILED reply. Budget terminals that pick
152
+ # "the agent's last words" from the transcript must skip them — they are the
153
+ # replies this module exists to keep from being published.
154
+ NOT_AN_ANSWER_KINDS = frozenset({"reprompted_reply", "reprompt_failed_reply"})
155
+
156
+
157
+ def is_not_an_answer(message: Any) -> bool:
158
+ meta = message.get("metadata") if isinstance(message, dict) else None
159
+ return isinstance(meta, dict) and meta.get("kind") in NOT_AN_ANSWER_KINDS
160
+
161
+
162
+ def _flag(raw: Any) -> bool:
163
+ if isinstance(raw, str):
164
+ return raw.strip().lower() in {"1", "true", "yes", "on"}
165
+ return bool(raw)
166
+
167
+
168
+ def resolve_checks(runtime_ns: Any, *, suppressed: bool) -> Tuple[bool, bool]:
169
+ """(check_announcement, check_unrunnable), two independent switches.
170
+
171
+ - `_runtime.check_plan` controls ONLY the announcement heuristic (a guess
172
+ about intent): absent = ON in task lanes, OFF in visit lanes, where
173
+ "I will read that entry again" is musing.
174
+ - `_runtime.check_unrunnable_calls` controls the unrunnable-markup check
175
+ (a fact about the reply): absent = ON in every lane. Turning the
176
+ heuristic off must never re-enable publishing raw tool markup.
177
+ """
178
+ ns = runtime_ns if isinstance(runtime_ns, dict) else {}
179
+ raw_plan = ns.get("check_plan")
180
+ check_announcement = (not suppressed) if raw_plan is None else _flag(raw_plan)
181
+ raw_unrunnable = ns.get("check_unrunnable_calls")
182
+ check_unrunnable = True if raw_unrunnable is None else _flag(raw_unrunnable)
183
+ return check_announcement, check_unrunnable
184
+
185
+
186
+ def _prose(text: Any) -> str:
187
+ s = str(text or "")
188
+ s = _FENCED_BLOCK_RE.sub("", s)
189
+ return s.strip()
190
+
191
+
192
+ def _strip_markup(text: str) -> str:
193
+ try:
194
+ return _MARKUP_STRIP_RE.sub("", text or "").strip()
195
+ except Exception:
196
+ return str(text or "").strip()
197
+
198
+
199
+ def _metadata(response: Any) -> Dict[str, Any]:
200
+ meta = response.get("metadata") if isinstance(response, dict) else None
201
+ return meta if isinstance(meta, dict) else {}
202
+
203
+
204
+ def tool_calls_from_reasoning(response: Any) -> int:
205
+ """How many of this reply's tool calls abstractcore recovered from the thinking block."""
206
+ try:
207
+ n = int(_metadata(response).get("tool_calls_from_reasoning") or 0)
208
+ except (TypeError, ValueError):
209
+ return 0
210
+ return n if n > 0 else 0
211
+
212
+
213
+ def has_negated_intent(text: Any) -> bool:
214
+ """"I will not run that", "I can't ...": a refusal, never an announcement."""
215
+ return bool(_NEGATED_INTENT_RE.search(_prose(text)))
216
+
217
+
218
+ def _last_sentence(prose: str) -> str:
219
+ lines = [ln.strip() for ln in prose.splitlines() if ln.strip()]
220
+ last_line = lines[-1] if lines else prose
221
+ parts = [p.strip() for p in _SENTENCE_SPLIT_RE.split(last_line) if p.strip()]
222
+ return parts[-1] if parts else last_line
223
+
224
+
225
+ def looks_like_tool_announcement(text: Any) -> bool:
226
+ """True when a SHORT reply ENDS on an announcement of tool work.
227
+
228
+ Conservative on purpose — a false positive costs a model turn and, twice,
229
+ the answer. It fires only when the prose (fences and markup removed) is
230
+ shorter than `ANNOUNCEMENT_MAX_CHARS` and its LAST sentence is the
231
+ announcement: first-person intent followed by a tool verb ("Let me verify
232
+ ..."), or an action statement ("Checking the remaining two sources now.").
233
+ Every XP failure ends that way. Never an announcement: questions,
234
+ replies waiting on the user ("... unless you say otherwise", "once you
235
+ confirm"), intent that talks rather than acts ("Let me summarize: ..."),
236
+ refusals ("I will not run that"), replies that start by delivering
237
+ ("Done.", "Here is ..."), structured answers. English only (documented
238
+ limit: docs/agents.md, backlog 0033).
239
+ """
240
+ prose = _strip_markup(_prose(text))
241
+ if not prose or len(prose) >= ANNOUNCEMENT_MAX_CHARS:
242
+ return False
243
+ if prose.rstrip().endswith("?"):
244
+ return False
245
+ if _WAITING_RE.search(prose) or _DELIVERY_START_RE.search(prose) or _HEADING_RE.search(prose):
246
+ return False
247
+ if _NEGATED_INTENT_RE.search(prose):
248
+ return False
249
+ last = _last_sentence(prose)
250
+ if _ACTION_STATEMENT_RE.search(last):
251
+ return True
252
+ intent = _INTENT_RE.search(last)
253
+ if not intent:
254
+ return False
255
+ if _NON_ACTION_AFTER_INTENT_RE.search(last):
256
+ return False
257
+ return bool(_TOOL_VERB_RE.search(last[intent.end():]))
258
+
259
+
260
+ def _unrunnable_detail(response: Any, content: str) -> Optional[str]:
261
+ """Name the tool syntax that produced no call, or None when there is none."""
262
+ meta = _metadata(response)
263
+ unparsed = meta.get("unparsed_tool_call")
264
+ if unparsed:
265
+ reason = unparsed.get("reason") if isinstance(unparsed, dict) else None
266
+ return f"the tool call was cut off or malformed ({reason})" if reason else "the tool call was cut off or malformed"
267
+ warnings = meta.get("warnings")
268
+ if isinstance(warnings, list):
269
+ for w in warnings:
270
+ if not isinstance(w, str) or not _TOOL_WARNING_RE.search(w):
271
+ continue
272
+ names = _UNKNOWN_NAMES_RE.search(w)
273
+ if names:
274
+ return f"the model asked for {names.group(1)}, which is not an available tool"
275
+ if "inside the model's reasoning" in w:
276
+ return "the tool calls were written inside the model's thinking, where they cannot run"
277
+ return "the tool-call syntax could not be parsed"
278
+ raw_content = str(response.get("content") or "") if isinstance(response, dict) else content
279
+ if _MARKUP_RE.search(_prose(_THINK_BLOCK_RE.sub("", raw_content))):
280
+ return "the reply contains tool-call markup that did not become a tool call"
281
+ # The thinking block only counts when there is no visible reply: a model
282
+ # that considered a call while thinking and then answered has answered.
283
+ if not _THINK_BLOCK_RE.sub("", raw_content).strip():
284
+ for text in (raw_content, content, extract_reasoning_text(response)):
285
+ if _MARKUP_RE.search(_prose(text)):
286
+ return "the tool calls were written inside the model's thinking, where they cannot run"
287
+ return None
288
+
289
+
290
+ def classify_no_call_reply(
291
+ response: Any,
292
+ content: Any,
293
+ *,
294
+ tools_offered: bool,
295
+ check_announcement: bool = True,
296
+ check_unrunnable: bool = True,
297
+ ) -> Tuple[str, str]:
298
+ """Classify a reply that carried NO tool call: (reason, detail) or ("", "").
299
+
300
+ `content` is the loop's parsed content (which may already be the reasoning
301
+ fallback when the visible content was empty). The caller must only ask
302
+ about replies whose tool-call list is empty.
303
+ """
304
+ text = str(content or "")
305
+ raw_content = str(response.get("content") or "") if isinstance(response, dict) else text
306
+ visible = _strip_markup(_prose(_THINK_BLOCK_RE.sub("", raw_content)))
307
+
308
+ if check_unrunnable:
309
+ detail = _unrunnable_detail(response, text)
310
+ # Markup/warnings with no visible answer, or with only a short one: the
311
+ # reply is the failed call. A long visible answer that merely also
312
+ # carries markup (docs about tool syntax, say) stays an answer.
313
+ if detail and (not visible or len(visible) < ANNOUNCEMENT_MAX_CHARS):
314
+ if not tools_offered:
315
+ detail = f"{detail}; no tools are available in this step"
316
+ return REASON_UNRUNNABLE, detail
317
+
318
+ if check_announcement and tools_offered and looks_like_tool_announcement(text):
319
+ return REASON_ANNOUNCED, "the reply announced tool use but contained no tool call"
320
+ return "", ""
321
+
322
+
323
+ def visible_content(response: Any) -> str:
324
+ """The reply's visible content exactly as the provider returned it (may be "")."""
325
+ return str(response.get("content") or "") if isinstance(response, dict) else ""
326
+
327
+
328
+ def verbatim_reply(response: Any, *, fallback: str = "") -> str:
329
+ """The assistant's reply as the provider returned it: reasoning AND content.
330
+
331
+ Never the emptied record: the runtime's recorded turn for a think-held
332
+ call is `content: ""`, and a model shown that believes its tools ran.
333
+ Reasoning is re-wrapped in `<think>` (the tag every thinking family this
334
+ framework serves uses); an unparsed envelope that the provider reported in
335
+ metadata instead of content is appended so the model sees it too.
336
+ """
337
+ reasoning = extract_reasoning_text(response).strip()
338
+ content = str(response.get("content") or "").strip() if isinstance(response, dict) else ""
339
+ parts: List[str] = []
340
+ if reasoning:
341
+ parts.append(f"<think>\n{reasoning}\n</think>")
342
+ if content:
343
+ parts.append(content)
344
+ unparsed = _metadata(response).get("unparsed_tool_call")
345
+ if isinstance(unparsed, dict):
346
+ utext = str(unparsed.get("text") or "").strip()
347
+ if utext and utext not in reasoning and utext not in content:
348
+ parts.append(utext)
349
+ out = "\n\n".join(parts).strip()
350
+ return out or str(fallback or "").strip()
351
+
352
+
353
+ def quote_reply(reply: str) -> str:
354
+ """Fence `reply` so it survives as quoted DATA inside a user message."""
355
+ longest = max((len(m) for m in re.findall(r"`+", reply or "")), default=0)
356
+ fence = "`" * max(3, longest + 1)
357
+ return f"{fence}text\n{reply}\n{fence}"
358
+
359
+
360
+ def reprompt_text(
361
+ reason: str,
362
+ *,
363
+ tools_offered: bool,
364
+ suppressed: bool = False,
365
+ reply: str = "",
366
+ ) -> str:
367
+ """The corrective user message, with the failed reply QUOTED inside it.
368
+
369
+ The reply rides here, not (only) in the assistant history turn: Qwen3.5 /
370
+ Qwen3.6 chat templates strip `<think>…</think>` from every assistant turn
371
+ before the last user message, so a think-held call carried by the history
372
+ turn reaches the model as an EMPTY reply — the XP "as recorded" case that
373
+ made the model believe its tools had run (5/5). User content is rendered
374
+ verbatim by every template.
375
+ """
376
+ quoted = f"Your previous reply was, verbatim:\n{quote_reply(reply)}\n\n" if reply else ""
377
+ if suppressed and reason == REASON_UNRUNNABLE:
378
+ # Visit lanes: host-voiced, no loop vocabulary (c2447 lane honesty).
379
+ # (The announcement check only runs there on an EXPLICIT
380
+ # `check_plan=true`, which asks for the task-lane wording.)
381
+ quoted = f"This is what came through:\n{quote_reply(reply)}\n\n" if reply else ""
382
+ return quoted + (
383
+ "It didn't come through as words — part of it was in a form that can't be carried "
384
+ "out here, so nothing happened. Say it again: do what you meant to do now, or "
385
+ "reply in plain words."
386
+ )
387
+ if not tools_offered:
388
+ return quoted + (
389
+ "That reply contained tool-call markup, but no tools are available here, so nothing "
390
+ "ran. Reply again with a direct answer, without tool-call markup."
391
+ )
392
+ if reason == REASON_UNRUNNABLE:
393
+ return quoted + (
394
+ "That reply placed tool calls where they cannot run (inside your thinking, with an "
395
+ "unknown tool name, or cut off), so none ran. Reply again: call the tools now in the "
396
+ "required format as your visible reply, or answer directly."
397
+ )
398
+ return quoted + (
399
+ "That reply announced tool calls but none ran: it contained no tool call. Reply again: "
400
+ "call the tools now in the required format as your visible reply, or answer directly."
401
+ )
402
+
403
+
404
+ def no_call_error_text(reason: str, detail: str, *, reprompted: bool = True) -> str:
405
+ """The visible error that ends a step whose no-call reply could not be recovered."""
406
+ what = (
407
+ "tool calls it could not run"
408
+ if reason == REASON_UNRUNNABLE
409
+ else "tool use without calling any tool"
410
+ )
411
+ tail = f" ({detail})" if detail else ""
412
+ how = (
413
+ "and it did the same again after one re-prompt"
414
+ if reprompted
415
+ else "and no iteration was left to re-prompt it"
416
+ )
417
+ return (
418
+ f"Error: the model's reply announced {what}{tail}, {how}. "
419
+ "Nothing ran, and the reply was not used as an answer."
420
+ )
421
+
422
+
423
+ def no_call_stop_reason(no_call: Dict[str, Any], *, iterations: int) -> Dict[str, Any]:
424
+ """Host-facing `stop_reason` for a turn ended by an unrecovered no-call reply.
425
+
426
+ One wording for all three loops (hosts render it; they never re-derive it).
427
+ `reprompted: False` means the reply came on the last iteration, so
428
+ `budget_exhausted` is True as well.
429
+ """
430
+ unrunnable = str(no_call.get("reason") or "") == REASON_UNRUNNABLE
431
+ reprompted = bool(no_call.get("reprompted", True))
432
+ detail = str(no_call.get("detail") or "").strip()
433
+ iters_txt = f" after {iterations} iterations" if iterations > 0 else ""
434
+ what = "wrote tool calls that could not run" if unrunnable else "announced tool use without calling any tool"
435
+ again = (
436
+ ", and did the same after one re-prompt, so nothing ran."
437
+ if reprompted
438
+ else ", with no iteration left to re-prompt it, so nothing ran."
439
+ )
440
+ return {
441
+ "code": "no_tool_call",
442
+ "finished": False,
443
+ # Not re-prompted = the last iteration: the budget is spent too.
444
+ "budget_exhausted": not reprompted,
445
+ "iterations": iterations,
446
+ "label": (
447
+ f"stopped: tool call could not run{iters_txt}"
448
+ if unrunnable
449
+ else f"stopped: announced tools, made no call{iters_txt}"
450
+ ),
451
+ "headline": (
452
+ (f"The agent stopped early{iters_txt}" if reprompted else f"The agent used its last iteration{iters_txt}")
453
+ + f": the model {what}"
454
+ + (f" ({detail})" if detail else "")
455
+ + again
456
+ ),
457
+ "remedy": (
458
+ "Check that the tool the model asked for is enabled for this agent, then retry."
459
+ if unrunnable
460
+ else "Retry; if it recurs, use another model or turn thinking off for this agent "
461
+ "(the model announces calls it does not emit)."
462
+ ),
463
+ }