steerable-agent-runtime 0.6.7__tar.gz → 0.6.9__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 (152) hide show
  1. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/PKG-INFO +1 -1
  2. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/pyproject.toml +1 -1
  3. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/compaction.py +48 -2
  4. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/loop.py +20 -3
  5. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime.egg-info/PKG-INFO +1 -1
  6. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_antihallucination.py +37 -0
  7. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_compaction.py +82 -0
  8. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_loop.py +1 -1
  9. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/README.md +0 -0
  10. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/setup.cfg +0 -0
  11. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/__init__.py +0 -0
  12. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/antihallucination.py +0 -0
  13. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/approval.py +0 -0
  14. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/approval_policy.py +0 -0
  15. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/ask_user.py +0 -0
  16. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/branch.py +0 -0
  17. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/cache_control.py +0 -0
  18. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/calibration.py +0 -0
  19. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/config.py +0 -0
  20. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/default.harness.json +0 -0
  21. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/default.harness.yaml +0 -0
  22. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/errors.py +0 -0
  23. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/gateway_catalog.py +0 -0
  24. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/handoff.py +0 -0
  25. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/harness.py +0 -0
  26. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/harness_spec.py +0 -0
  27. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/history.py +0 -0
  28. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/hooks.py +0 -0
  29. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/llm/__init__.py +0 -0
  30. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/llm/anthropic_native.py +0 -0
  31. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/llm/compat.py +0 -0
  32. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/llm/errors.py +0 -0
  33. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/llm/google_genai.py +0 -0
  34. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/llm/openai_compat.py +0 -0
  35. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/llm/openai_responses.py +0 -0
  36. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/llm/parts.py +0 -0
  37. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/llm/presets.py +0 -0
  38. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/llm/system_proxy.py +0 -0
  39. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/maintenance.py +0 -0
  40. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/mcp.py +0 -0
  41. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/mcp_server.py +0 -0
  42. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/model_catalog.py +0 -0
  43. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/model_info.py +0 -0
  44. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/model_resolve.py +0 -0
  45. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/observation_aging.py +0 -0
  46. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/orchestration.py +0 -0
  47. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/otel.py +0 -0
  48. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/plugins.py +0 -0
  49. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/pool.py +0 -0
  50. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/pricing.py +0 -0
  51. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/pseudo.py +0 -0
  52. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/recording.py +0 -0
  53. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/reminders.py +0 -0
  54. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/replay.py +0 -0
  55. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/resume.py +0 -0
  56. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/retry.py +0 -0
  57. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/sandboxed.py +0 -0
  58. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/skills.py +0 -0
  59. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/spill.py +0 -0
  60. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/storage/__init__.py +0 -0
  61. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/storage/in_memory.py +0 -0
  62. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/storage/sqlalchemy_store.py +0 -0
  63. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/storage/sqlite_store.py +0 -0
  64. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/storage/write_lease.py +0 -0
  65. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/subagent.py +0 -0
  66. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/todo.py +0 -0
  67. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/tokens.py +0 -0
  68. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/tool_schema.py +0 -0
  69. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/tool_search.py +0 -0
  70. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/tools.py +0 -0
  71. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/tracing.py +0 -0
  72. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/transport/__init__.py +0 -0
  73. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/transport/fastapi_sse.py +0 -0
  74. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/transport/stdio_jsonrpc.py +0 -0
  75. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime/world_state.py +0 -0
  76. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime.egg-info/SOURCES.txt +0 -0
  77. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime.egg-info/dependency_links.txt +0 -0
  78. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime.egg-info/requires.txt +0 -0
  79. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/src/steerable_agent_runtime.egg-info/top_level.txt +0 -0
  80. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_approval.py +0 -0
  81. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_approval_policy.py +0 -0
  82. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_ask_user.py +0 -0
  83. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_branch.py +0 -0
  84. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_cache_control.py +0 -0
  85. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_cache_instrumentation.py +0 -0
  86. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_calibration.py +0 -0
  87. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_config.py +0 -0
  88. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_content_parts.py +0 -0
  89. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_error_taxonomy.py +0 -0
  90. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_fragment_bounds.py +0 -0
  91. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_gateway_catalog.py +0 -0
  92. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_golden.py +0 -0
  93. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_handoff.py +0 -0
  94. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_harness.py +0 -0
  95. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_harness_spec.py +0 -0
  96. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_history.py +0 -0
  97. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_history_persistence.py +0 -0
  98. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_hooks.py +0 -0
  99. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_in_memory_storage.py +0 -0
  100. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_llm_gemini_wire.py +0 -0
  101. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_llm_responses_wire.py +0 -0
  102. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_llm_stream_e2e.py +0 -0
  103. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_llm_wire_helpers.py +0 -0
  104. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_long_session.py +0 -0
  105. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_loop_cancellation.py +0 -0
  106. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_loop_replay.py +0 -0
  107. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_loop_sandbox_event.py +0 -0
  108. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_maintenance.py +0 -0
  109. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_mcp.py +0 -0
  110. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_mcp_server.py +0 -0
  111. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_model_catalog.py +0 -0
  112. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_model_equivalence.py +0 -0
  113. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_model_info.py +0 -0
  114. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_model_resolve.py +0 -0
  115. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_observation_aging.py +0 -0
  116. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_orchestration.py +0 -0
  117. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_otel.py +0 -0
  118. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_parallel_tools.py +0 -0
  119. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_plugins.py +0 -0
  120. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_provider_compat.py +0 -0
  121. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_provider_presets.py +0 -0
  122. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_pseudo.py +0 -0
  123. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_recording.py +0 -0
  124. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_reminders.py +0 -0
  125. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_replay_crosslang.py +0 -0
  126. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_resume.py +0 -0
  127. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_retry_hooks.py +0 -0
  128. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_safety_gate.py +0 -0
  129. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_sandboxed.py +0 -0
  130. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_skills.py +0 -0
  131. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_soft_timeout.py +0 -0
  132. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_spill.py +0 -0
  133. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_sqlite_storage.py +0 -0
  134. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_steer.py +0 -0
  135. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_storage_contract.py +0 -0
  136. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_stream_strip.py +0 -0
  137. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_subagent.py +0 -0
  138. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_system_proxy.py +0 -0
  139. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_todo.py +0 -0
  140. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_todo_gate.py +0 -0
  141. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_tokens.py +0 -0
  142. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_tool_exposure.py +0 -0
  143. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_tool_hygiene.py +0 -0
  144. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_tool_router.py +0 -0
  145. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_tool_schema.py +0 -0
  146. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_tool_timeout.py +0 -0
  147. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_trace_recorder.py +0 -0
  148. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_transport_jsonrpc.py +0 -0
  149. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_transport_sse.py +0 -0
  150. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_usage_attribution.py +0 -0
  151. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_world_state.py +0 -0
  152. {steerable_agent_runtime-0.6.7 → steerable_agent_runtime-0.6.9}/tests/test_write_lease.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: steerable-agent-runtime
3
- Version: 0.6.7
3
+ Version: 0.6.9
4
4
  Summary: Steerable agent runtime: LLM, tool, storage, and transport adapters.
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "steerable-agent-runtime"
3
- version = "0.6.7"
3
+ version = "0.6.9"
4
4
  description = "Steerable agent runtime: LLM, tool, storage, and transport adapters."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -22,7 +22,9 @@ Strategy (deterministic first, LLM optional):
22
22
  message; otherwise drop the middle span behind a marker.
23
23
 
24
24
  System messages and the first user message (the goal) are always kept; the
25
- most recent ``keep_last_messages`` are never touched. Between compactions
25
+ most recent ``keep_last_messages`` are never touched, and the kept tail is
26
+ widened when that count would cut a tool-call group in half (an orphan
27
+ ``tool`` message is a provider-level 400). Between compactions
26
28
  the transcript is append-only, so provider prompt caches keep hitting; a
27
29
  rewrite invalidates the cache once, then the prefix is stable again.
28
30
 
@@ -440,6 +442,50 @@ class CompactionHooks(NoopHooks):
440
442
  for i, m in enumerate(transcript)
441
443
  ]
442
444
 
445
+ @staticmethod
446
+ def _orphan_tool_ids(tail: Sequence[LLMMessage]) -> set[str]:
447
+ """``tool_call_id``s answered inside ``tail`` but issued before it."""
448
+ issued: set[str] = set()
449
+ orphans: set[str] = set()
450
+ for m in tail:
451
+ for call in m.tool_calls or ():
452
+ issued.add(call.id)
453
+ if (
454
+ m.role == "tool"
455
+ and m.tool_call_id is not None
456
+ and m.tool_call_id not in issued
457
+ ):
458
+ orphans.add(m.tool_call_id)
459
+ return orphans
460
+
461
+ def _tail_start(self, transcript: list[LLMMessage], head_end: int) -> int:
462
+ """Where the untouched tail begins: ``keep_last_messages`` back, then
463
+ widened until the tail answers no tool call it does not also issue.
464
+
465
+ OpenAI-compatible providers reject a ``tool`` message whose
466
+ ``tool_call_id`` was never issued by a preceding ``assistant``
467
+ message (DeepSeek answers HTTP 400 ``invalid_request``). A count-only
468
+ cut can land inside a tool-call group, summarizing the issuing
469
+ assistant away while its results stay in the tail. Widening only ever
470
+ keeps more, and is bounded by one round's tool calls — including the
471
+ ``user`` image messages the read path interleaves between results.
472
+
473
+ Only orphans this cut created are worth widening for. A transcript
474
+ that arrives already missing an issuer (a host that resumed from a
475
+ truncated log) would otherwise widen to ``head_end`` and disable
476
+ summarization for the rest of the run, trading a provider 400 for a
477
+ context overflow.
478
+ """
479
+ tail_start = max(head_end, len(transcript) - self._keep_last)
480
+ issuable = {
481
+ call.id for m in transcript[head_end:] for call in m.tool_calls or ()
482
+ }
483
+ while tail_start > head_end and (
484
+ self._orphan_tool_ids(transcript[tail_start:]) & issuable
485
+ ):
486
+ tail_start -= 1
487
+ return tail_start
488
+
443
489
  async def _summarize_middle(self, transcript: list[LLMMessage]) -> list[LLMMessage]:
444
490
  # Keep every system message plus the first user message (the goal) as
445
491
  # the head; keep the recent tail untouched; summarize the middle.
@@ -451,7 +497,7 @@ class CompactionHooks(NoopHooks):
451
497
  head_end = i + 1
452
498
  else:
453
499
  break
454
- tail_start = max(head_end, len(transcript) - self._keep_last)
500
+ tail_start = self._tail_start(transcript, head_end)
455
501
  middle = transcript[head_end:tail_start]
456
502
  if not middle:
457
503
  return transcript
@@ -877,6 +877,13 @@ class CoreLoop:
877
877
  else None
878
878
  )
879
879
  wrap_up = False
880
+ # Terminal that triggered the current wrap-up when a budget wall
881
+ # (maxRounds) offered narration. The wrap-up summary is an interim
882
+ # report, not task completion: the no-tools terminal below restores
883
+ # this decision so the host's auto-continue sees budget_exhausted and
884
+ # resumes with a fresh budget instead of mistaking the summary for a
885
+ # finished task.
886
+ wrap_up_terminal: CompletionDecision | None = None
880
887
  wrap_up_tool_rounds_used = 0
881
888
  idle_cut_count = 0
882
889
 
@@ -1051,7 +1058,10 @@ class CoreLoop:
1051
1058
  )
1052
1059
  yield LoopEvent(
1053
1060
  "budget_exhausted",
1054
- {"kind": "rounds", "rounds": self._config.max_rounds},
1061
+ # "budget" not "kind": the sidecar wraps loop events as
1062
+ # {"kind": <event kind>, **data} — a data["kind"] would
1063
+ # clobber the envelope kind hosts dispatch on.
1064
+ {"budget": "rounds", "rounds": self._config.max_rounds},
1055
1065
  )
1056
1066
  narrate = await self._offer_narration(
1057
1067
  "", round_index, had_tool_calls=True, ctx=ctx,
@@ -1060,6 +1070,7 @@ class CoreLoop:
1060
1070
  if narrate is not None:
1061
1071
  prompt, reason = narrate
1062
1072
  completion_redos += 1
1073
+ wrap_up_terminal = decision
1063
1074
  mark_wrap_up()
1064
1075
  manager.append_fragment(NarrationRequest(prompt))
1065
1076
  yield LoopEvent(
@@ -1362,7 +1373,7 @@ class CoreLoop:
1362
1373
  if exhausted:
1363
1374
  yield LoopEvent(
1364
1375
  "budget_exhausted",
1365
- {"kind": "tokens", "used": budget_state.tokens_used},
1376
+ {"budget": "tokens", "used": budget_state.tokens_used},
1366
1377
  )
1367
1378
  decision = CompletionDecision(
1368
1379
  status="budget_exhausted", reason="token budget exceeded"
@@ -1586,7 +1597,13 @@ class CoreLoop:
1586
1597
 
1587
1598
  # ── decide: no tool calls → terminal (unless a hook vetoes) ──
1588
1599
  if not tool_calls:
1589
- if content.strip():
1600
+ if wrap_up_terminal is not None:
1601
+ # Budget-wall wrap-up: the narration summary is an interim
1602
+ # report, not a finished task. Restore the wall's terminal
1603
+ # (budget_exhausted) so the host auto-continues with a
1604
+ # fresh budget instead of stopping on a half-done task.
1605
+ decision = wrap_up_terminal
1606
+ elif content.strip():
1590
1607
  decision = CompletionDecision(
1591
1608
  status="completed",
1592
1609
  reason="assistant produced final response with no pending tools",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: steerable-agent-runtime
3
- Version: 0.6.7
3
+ Version: 0.6.9
4
4
  Summary: Steerable agent runtime: LLM, tool, storage, and transport adapters.
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -17,6 +17,7 @@ from steerable_agent_runtime import (
17
17
  AntiHallucinationConfig,
18
18
  AntiHallucinationHooks,
19
19
  CoreLoop,
20
+ LoopConfig,
20
21
  LoopEvent,
21
22
  RouterToolExecutor,
22
23
  ToolRouter,
@@ -521,6 +522,42 @@ async def test_narration_round_on_empty_terminal() -> None:
521
522
  )
522
523
 
523
524
 
525
+ @pytest.mark.asyncio
526
+ async def test_narration_at_max_rounds_wall_stays_budget_exhausted() -> None:
527
+ """The maxRounds wall interrupts work; the narration summary it triggers
528
+ is an interim report, not task completion. The terminal must keep
529
+ budget_exhausted so the host's auto-continue resumes with a fresh budget
530
+ (a completed status would silently stop a half-done task)."""
531
+ provider = make_provider(
532
+ [
533
+ {"content": "", "tool_calls": [tc("get_data", call_id="c1")]},
534
+ {"content": "", "tool_calls": [tc("get_data", {"source": "a"}, "c2")]},
535
+ {"content": "", "tool_calls": [tc("get_data", {"source": "b"}, "c3")]},
536
+ # narration wrap-up round (tools withheld): interim summary
537
+ {"content": "阶段性总结:已取数 3 次。"},
538
+ ]
539
+ )
540
+ hooks = AntiHallucinationHooks(provider, AntiHallucinationConfig())
541
+ loop = CoreLoop(
542
+ provider,
543
+ RouterToolExecutor(make_router_with_tool()),
544
+ LoopConfig(max_rounds=3),
545
+ hooks=hooks,
546
+ )
547
+ events = await collect(loop.run([LLMMessage.text_of("user", "取数")], tools=TOOLS))
548
+
549
+ final = final_completion(events)
550
+ assert final["status"] == "budget_exhausted"
551
+ assert "maxRounds" in final["reason"]
552
+ # The interim summary still rides the completion and the transcript.
553
+ assert final["textLength"] == len("阶段性总结:已取数 3 次。")
554
+ narrations = [
555
+ e for e in events
556
+ if e.kind == "hook_action" and e.data.get("action") == "narrate"
557
+ ]
558
+ assert len(narrations) == 1
559
+
560
+
524
561
  @pytest.mark.asyncio
525
562
  async def test_narration_second_chance_on_empty_wrap_up() -> None:
526
563
  """Real-data replay finding: the wrap-up round itself can come back empty
@@ -437,6 +437,88 @@ async def test_compaction_preserves_system_and_first_user_message() -> None:
437
437
  assert call[1].role == "user" and call[1].content_text == "the original goal"
438
438
 
439
439
 
440
+ def tool_result(call_id: str) -> LLMMessage:
441
+ return LLMMessage.text_of("tool", "ok", name="emit", tool_call_id=call_id)
442
+
443
+
444
+ @pytest.mark.asyncio
445
+ async def test_summarize_middle_widens_the_tail_off_an_orphan_tool_result() -> None:
446
+ # A count-only tail cut can land inside a tool-call group: the issuing
447
+ # assistant goes into the summarized middle while its results stay in the
448
+ # tail, and the request opens on a `tool` message nothing answered.
449
+ # DeepSeek rejects that with HTTP 400 invalid_request.
450
+ calls = [
451
+ ToolCall(id="call_a", name="emit", arguments={}),
452
+ ToolCall(id="call_b", name="emit", arguments={}),
453
+ ]
454
+ transcript = [
455
+ LLMMessage.text_of("system", "you are helpful"),
456
+ LLMMessage.text_of("user", "make a deck"),
457
+ LLMMessage.text_of("assistant", "reading the skill files"),
458
+ LLMMessage.text_of("assistant", "", tool_calls=calls),
459
+ tool_result("call_a"),
460
+ # the read path interleaves image messages between tool results
461
+ LLMMessage.text_of("user", "slide.png:"),
462
+ tool_result("call_b"),
463
+ LLMMessage.text_of("assistant", "wrapping up"),
464
+ ]
465
+ hooks = CompactionHooks(max_context_tokens=1_000, keep_last_messages=3)
466
+
467
+ out = await hooks._summarize_middle(transcript)
468
+
469
+ assert CompactionHooks._orphan_tool_ids(out) == set()
470
+ assert any("[context compacted" in m.content_text for m in out)
471
+ # the group survived whole, in order, with its interleaved image message
472
+ assert [m.role for m in out[-5:]] == [
473
+ "assistant",
474
+ "tool",
475
+ "user",
476
+ "tool",
477
+ "assistant",
478
+ ]
479
+
480
+
481
+ @pytest.mark.asyncio
482
+ async def test_summarize_middle_still_compacts_an_already_orphaned_transcript() -> None:
483
+ # The issuer is missing from the input (a host resuming from a truncated
484
+ # log), so widening could never repair the tail. Compaction proceeds
485
+ # rather than widening to the head and going silent for the rest of the
486
+ # run — the alternative to the provider 400 is a context overflow.
487
+ transcript = [
488
+ LLMMessage.text_of("user", "make a deck"),
489
+ LLMMessage.text_of("assistant", "first"),
490
+ tool_result("call_gone_a"),
491
+ tool_result("call_gone_b"),
492
+ LLMMessage.text_of("assistant", "wrapping up"),
493
+ ]
494
+ hooks = CompactionHooks(max_context_tokens=1_000, keep_last_messages=2)
495
+
496
+ out = await hooks._summarize_middle(transcript)
497
+
498
+ assert len(out) < len(transcript)
499
+ assert any("[context compacted" in m.content_text for m in out)
500
+
501
+
502
+ @pytest.mark.asyncio
503
+ async def test_summarize_middle_keeps_an_aligned_tail_at_keep_last() -> None:
504
+ # No tool-call group is cut, so the tail stays exactly keep_last long —
505
+ # widening must not cost compaction on a clean boundary.
506
+ transcript = [
507
+ LLMMessage.text_of("system", "you are helpful"),
508
+ LLMMessage.text_of("user", "make a deck"),
509
+ LLMMessage.text_of("assistant", "first"),
510
+ LLMMessage.text_of("assistant", "second"),
511
+ LLMMessage.text_of("user", "keep going"),
512
+ LLMMessage.text_of("assistant", "third"),
513
+ ]
514
+ hooks = CompactionHooks(max_context_tokens=1_000, keep_last_messages=3)
515
+
516
+ out = await hooks._summarize_middle(transcript)
517
+
518
+ assert [m.content_text for m in out[-3:]] == ["second", "keep going", "third"]
519
+ assert len(out) == 6 # system + goal + summary + 3 kept
520
+
521
+
440
522
  @pytest.mark.asyncio
441
523
  async def test_micro_compact_trigger_schedule() -> None:
442
524
  """Periodic prune fires on interval multiples (never round 0), and an
@@ -410,7 +410,7 @@ async def test_token_budget_exhausted() -> None:
410
410
  events = await collect(loop.run([LLMMessage.text_of("user", "hi")]))
411
411
  decision = final_completion(events)
412
412
  assert decision["status"] == "budget_exhausted"
413
- assert any(e.kind == "budget_exhausted" and e.data["kind"] == "tokens" for e in events)
413
+ assert any(e.kind == "budget_exhausted" and e.data["budget"] == "tokens" for e in events)
414
414
 
415
415
 
416
416
  @pytest.mark.asyncio