steerable-agent-runtime 0.6.32__tar.gz → 0.6.34__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 (162) hide show
  1. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/PKG-INFO +1 -1
  2. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/pyproject.toml +1 -1
  3. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/__init__.py +2 -0
  4. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/antihallucination.py +18 -2
  5. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/hooks.py +34 -2
  6. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/llm/openai_compat.py +20 -10
  7. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/loop.py +38 -0
  8. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/native_bridge.py +61 -10
  9. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime.egg-info/PKG-INFO +1 -1
  10. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_antihallucination.py +25 -1
  11. steerable_agent_runtime-0.6.34/tests/test_native_default.py +124 -0
  12. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_provider_compat.py +21 -0
  13. steerable_agent_runtime-0.6.32/tests/test_native_default.py +0 -44
  14. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/README.md +0 -0
  15. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/setup.cfg +0 -0
  16. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/approval.py +0 -0
  17. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/approval_policy.py +0 -0
  18. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/ask_user.py +0 -0
  19. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/branch.py +0 -0
  20. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/cache_control.py +0 -0
  21. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/calibration.py +0 -0
  22. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/compaction.py +0 -0
  23. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/compaction_policy.py +0 -0
  24. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/config.py +0 -0
  25. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/default.harness.json +0 -0
  26. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/default.harness.yaml +0 -0
  27. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/delegation_gate.py +0 -0
  28. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/errors.py +0 -0
  29. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/gateway_catalog.py +0 -0
  30. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/handoff.py +0 -0
  31. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/harness.py +0 -0
  32. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/harness_spec.py +0 -0
  33. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/history.py +0 -0
  34. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/llm/__init__.py +0 -0
  35. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/llm/anthropic_native.py +0 -0
  36. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/llm/compat.py +0 -0
  37. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/llm/errors.py +0 -0
  38. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/llm/google_genai.py +0 -0
  39. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/llm/openai_responses.py +0 -0
  40. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/llm/parts.py +0 -0
  41. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/llm/presets.py +0 -0
  42. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/llm/system_proxy.py +0 -0
  43. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/maintenance.py +0 -0
  44. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/mcp.py +0 -0
  45. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/mcp_server.py +0 -0
  46. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/model_catalog.py +0 -0
  47. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/model_info.py +0 -0
  48. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/model_resolve.py +0 -0
  49. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/observation_aging.py +0 -0
  50. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/orchestration.py +0 -0
  51. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/otel.py +0 -0
  52. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/plugins.py +0 -0
  53. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/pool.py +0 -0
  54. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/pricing.py +0 -0
  55. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/pseudo.py +0 -0
  56. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/recording.py +0 -0
  57. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/reminders.py +0 -0
  58. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/replay.py +0 -0
  59. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/resume.py +0 -0
  60. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/retry.py +0 -0
  61. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/sandboxed.py +0 -0
  62. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/skills.py +0 -0
  63. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/spill.py +0 -0
  64. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/storage/__init__.py +0 -0
  65. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/storage/in_memory.py +0 -0
  66. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/storage/sqlalchemy_store.py +0 -0
  67. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/storage/sqlite_store.py +0 -0
  68. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/storage/write_lease.py +0 -0
  69. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/subagent.py +0 -0
  70. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/todo.py +0 -0
  71. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/tokens.py +0 -0
  72. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/tool_schema.py +0 -0
  73. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/tool_search.py +0 -0
  74. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/tools.py +0 -0
  75. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/tracing.py +0 -0
  76. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/transport/__init__.py +0 -0
  77. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/transport/fastapi_sse.py +0 -0
  78. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/transport/stdio_jsonrpc.py +0 -0
  79. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime/world_state.py +0 -0
  80. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime.egg-info/SOURCES.txt +0 -0
  81. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime.egg-info/dependency_links.txt +0 -0
  82. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime.egg-info/requires.txt +0 -0
  83. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/src/steerable_agent_runtime.egg-info/top_level.txt +0 -0
  84. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_approval.py +0 -0
  85. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_approval_policy.py +0 -0
  86. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_ask_user.py +0 -0
  87. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_branch.py +0 -0
  88. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_cache_control.py +0 -0
  89. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_cache_instrumentation.py +0 -0
  90. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_calibration.py +0 -0
  91. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_compaction.py +0 -0
  92. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_compaction_policy.py +0 -0
  93. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_compaction_strategy.py +0 -0
  94. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_config.py +0 -0
  95. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_content_parts.py +0 -0
  96. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_delegation_gate.py +0 -0
  97. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_error_taxonomy.py +0 -0
  98. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_fragment_bounds.py +0 -0
  99. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_gateway_catalog.py +0 -0
  100. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_golden.py +0 -0
  101. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_handoff.py +0 -0
  102. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_harness.py +0 -0
  103. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_harness_spec.py +0 -0
  104. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_history.py +0 -0
  105. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_history_persistence.py +0 -0
  106. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_hooks.py +0 -0
  107. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_in_memory_storage.py +0 -0
  108. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_llm_gemini_wire.py +0 -0
  109. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_llm_responses_wire.py +0 -0
  110. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_llm_stream_e2e.py +0 -0
  111. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_llm_wire_helpers.py +0 -0
  112. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_long_session.py +0 -0
  113. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_loop.py +0 -0
  114. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_loop_cancellation.py +0 -0
  115. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_loop_replay.py +0 -0
  116. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_loop_sandbox_event.py +0 -0
  117. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_loop_terminal.py +0 -0
  118. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_maintenance.py +0 -0
  119. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_mcp.py +0 -0
  120. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_mcp_server.py +0 -0
  121. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_model_catalog.py +0 -0
  122. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_model_equivalence.py +0 -0
  123. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_model_info.py +0 -0
  124. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_model_resolve.py +0 -0
  125. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_observation_aging.py +0 -0
  126. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_orchestration.py +0 -0
  127. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_otel.py +0 -0
  128. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_parallel_tools.py +0 -0
  129. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_plugins.py +0 -0
  130. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_provider_presets.py +0 -0
  131. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_pseudo.py +0 -0
  132. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_pyo3_api_surface.py +0 -0
  133. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_recording.py +0 -0
  134. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_reminders.py +0 -0
  135. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_replay_crosslang.py +0 -0
  136. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_resume.py +0 -0
  137. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_retry_hooks.py +0 -0
  138. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_safety_gate.py +0 -0
  139. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_sandboxed.py +0 -0
  140. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_skills.py +0 -0
  141. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_soft_timeout.py +0 -0
  142. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_spill.py +0 -0
  143. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_sqlite_storage.py +0 -0
  144. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_steer.py +0 -0
  145. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_storage_contract.py +0 -0
  146. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_stream_strip.py +0 -0
  147. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_subagent.py +0 -0
  148. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_system_proxy.py +0 -0
  149. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_todo.py +0 -0
  150. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_todo_gate.py +0 -0
  151. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_tokens.py +0 -0
  152. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_tool_exposure.py +0 -0
  153. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_tool_hygiene.py +0 -0
  154. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_tool_router.py +0 -0
  155. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_tool_schema.py +0 -0
  156. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_tool_timeout.py +0 -0
  157. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_trace_recorder.py +0 -0
  158. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_transport_jsonrpc.py +0 -0
  159. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_transport_sse.py +0 -0
  160. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_usage_attribution.py +0 -0
  161. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/tests/test_world_state.py +0 -0
  162. {steerable_agent_runtime-0.6.32 → steerable_agent_runtime-0.6.34}/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.32
3
+ Version: 0.6.34
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.32"
3
+ version = "0.6.34"
4
4
  description = "Steerable agent runtime: LLM, tool, storage, and transport adapters."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -131,6 +131,7 @@ from .hooks import (
131
131
  ChainHooks,
132
132
  CompletionAction,
133
133
  CompletionDraft,
134
+ DecisionNote,
134
135
  LoopHooks,
135
136
  NoopHooks,
136
137
  PreStepAction,
@@ -339,6 +340,7 @@ __all__ = [
339
340
  "ContextFragment",
340
341
  "ContextManager",
341
342
  "CoreLoop",
343
+ "DecisionNote",
342
344
  "ExecutionBudget",
343
345
  "FamilyTree",
344
346
  "FamilyTreeNode",
@@ -34,7 +34,13 @@ import re
34
34
  from dataclasses import dataclass
35
35
  from typing import TYPE_CHECKING, Any, Literal, Sequence
36
36
 
37
- from .hooks import CompletionAction, CompletionDraft, NoopHooks, PreStepAction
37
+ from .hooks import (
38
+ CompletionAction,
39
+ CompletionDraft,
40
+ DecisionNote,
41
+ NoopHooks,
42
+ PreStepAction,
43
+ )
38
44
 
39
45
  if TYPE_CHECKING:
40
46
  from .llm import LLMMessage, LLMProvider
@@ -473,12 +479,22 @@ class AntiHallucinationHooks(NoopHooks):
473
479
  ):
474
480
  return PreStepAction(kind="proceed")
475
481
  self._route = await self._classify_route()
482
+ # Both branches record the verdict: the allow_no_tool turns are the
483
+ # denominator for how often routing forces a tool at all, and a run
484
+ # with no note at all means routing was skipped (plan mode, no tools,
485
+ # forcing disabled) rather than that it allowed a free turn.
486
+ note = DecisionNote(
487
+ action="data_need_route",
488
+ reason="anti-hallucination data-need routing",
489
+ value=self._route,
490
+ )
476
491
  if self._route == "require_tool":
477
492
  return PreStepAction(
478
493
  kind="proceed",
479
494
  tool_choice=self._config.force_tool_choice,
495
+ notes=(note,),
480
496
  )
481
- return PreStepAction(kind="proceed")
497
+ return PreStepAction(kind="proceed", notes=(note,))
482
498
 
483
499
  async def _classify_route(self) -> DataNeedRoute:
484
500
  from .llm import LLMMessage
@@ -32,7 +32,7 @@ existing behavior.
32
32
  from __future__ import annotations
33
33
 
34
34
  import logging
35
- from dataclasses import dataclass
35
+ from dataclasses import dataclass, replace
36
36
  from typing import TYPE_CHECKING, Literal, Protocol, runtime_checkable
37
37
 
38
38
  from steerable_agent_protocol.generated import ToolCall, ToolResult
@@ -118,6 +118,26 @@ class RewriteRequest:
118
118
  bracket: CompactionBracket | None = None
119
119
 
120
120
 
121
+ @dataclass(frozen=True, slots=True)
122
+ class DecisionNote:
123
+ """A hook verdict the loop should record even though it changed nothing.
124
+
125
+ Verdicts that *act* are already visible as ``hook_action`` (a rewrite, an
126
+ append, a ``tool_choice``). The verdict that declined to act leaves no
127
+ trace, so a trajectory cannot distinguish "the data-need router allowed a
128
+ no-tool turn" from "routing never ran" — and that is exactly the
129
+ denominator a routing-variance measurement needs.
130
+
131
+ ``value`` carries the verdict itself (a route name, a chosen option) and
132
+ ``probability`` the backend's confidence when it reports one.
133
+ """
134
+
135
+ action: str
136
+ reason: str
137
+ value: str | None = None
138
+ probability: float | None = None
139
+
140
+
121
141
  @dataclass(slots=True)
122
142
  class PreStepAction:
123
143
  """Outcome of a ``pre_step`` hook.
@@ -131,6 +151,9 @@ class PreStepAction:
131
151
  this step's LLM call — the data-need router uses it to force a tool call
132
152
  on the first round of a data-seeking turn. Pass-through value, forwarded
133
153
  as a provider kwarg; providers that cannot honor it ignore it.
154
+
155
+ ``notes`` records verdicts that took no action, so a hook's decision rate
156
+ is measurable and not just its intervention rate.
134
157
  """
135
158
 
136
159
  kind: Literal["proceed", "reject"] = "proceed"
@@ -138,6 +161,7 @@ class PreStepAction:
138
161
  rewrite: RewriteRequest | None = None
139
162
  reason: str | None = None
140
163
  tool_choice: str | None = None
164
+ notes: tuple[DecisionNote, ...] = ()
141
165
  #: Label for the ``hook_action`` event when this action appended context
142
166
  #: (a rewrite is labelled by its own ``RewriteRequest.action``). Skill
143
167
  #: catalog injection owns ``"skill_catalog"``; the generic default is
@@ -327,6 +351,9 @@ class ChainHooks:
327
351
  after a rewrite fold into that rewrite — "hook1 rewrites, hook2
328
352
  appends" is exactly ``replace_all`` then ``append``. The first
329
353
  ``reject`` wins; the first non-``None`` ``tool_choice`` wins.
354
+ ``notes`` accumulate from every hook in order: a note describes a
355
+ decision instead of declaring one, so the hook that loses the
356
+ ``tool_choice`` must still have its verdict recorded.
330
357
  - ``post_tool_result``: the result threads through each hook in order.
331
358
  - ``on_request_error``: the first ``retry`` decision wins; if every hook
332
359
  says ``fail``, the first failure reason is surfaced.
@@ -356,10 +383,14 @@ class ChainHooks:
356
383
  appends: list[TranscriptAppend] = []
357
384
  append_action: str | None = None
358
385
  reason: str | None = None
386
+ notes: list[DecisionNote] = []
359
387
  for hook in self._hooks:
360
388
  action = await hook.pre_step(current, ctx)
389
+ notes.extend(action.notes)
361
390
  if action.kind == "reject":
362
- return action
391
+ # A reject ends the turn, but the verdicts that preceded it
392
+ # still describe how the turn got there.
393
+ return replace(action, notes=tuple(notes))
363
394
  if action.rewrite is not None:
364
395
  # The rewriter computed against the current projection, so
365
396
  # its message list already contains any earlier appends.
@@ -391,6 +422,7 @@ class ChainHooks:
391
422
  reason=reason,
392
423
  tool_choice=tool_choice,
393
424
  append_action=append_action,
425
+ notes=tuple(notes),
394
426
  )
395
427
 
396
428
  async def compact_now(
@@ -162,6 +162,19 @@ class OpenAICompatProvider:
162
162
  # Public API
163
163
  # ------------------------------------------------------------------
164
164
 
165
+ def honors_forced_tool_choice(self) -> bool:
166
+ """Whether ``tool_choice="required"`` reaches the wire unchanged.
167
+
168
+ ``_build_body`` downgrades it to ``auto`` for vendors that reject the
169
+ forced value, which makes a caller's gate a silent no-op. Callers that
170
+ record the gate (the loop's ``hook_action``) ask first so the trace
171
+ distinguishes a gate that held from one the vendor dropped.
172
+ """
173
+ compat = self.compat or OpenAICompatFlags()
174
+ return compat.supports_forced_tool_choice and not (
175
+ _thinking_rejects_forced_tool_choice(self.model, self.base_url)
176
+ )
177
+
165
178
  async def complete(
166
179
  self,
167
180
  messages: Sequence[LLMMessage],
@@ -385,16 +398,13 @@ class OpenAICompatProvider:
385
398
  for key, value in preset.extra_body.items():
386
399
  if key not in body:
387
400
  body[key] = value
388
- # ``tool_choice=required`` downgrade paths:
389
- # - compat flag: vendors whose thinking mode 400s the forced value
390
- # (DeepSeek: "Thinking mode does not support this tool_choice");
391
- # - model-name paths host flags cannot cover: Z.AI GLM, OpenRouter
392
- # Qwen thinking, OpenRouter DeepSeek thinking. Harbor still logs
393
- # the hook; the wire must send auto or the trial dies on round 0
394
- # (failed-prev 33335200327, cheap-12 Qwen×steerable 34439098611).
395
- if body.get("tool_choice") == "required" and (
396
- not compat.supports_forced_tool_choice
397
- or _thinking_rejects_forced_tool_choice(self.model, self.base_url)
401
+ # ``tool_choice=required`` downgrade (see honors_forced_tool_choice):
402
+ # the wire must send auto for these vendors or the trial dies on round
403
+ # 0 (failed-prev 33335200327, cheap-12 Qwen×steerable 34439098611).
404
+ # The caller's ``hook_action`` carries ``honored: false`` here, so a
405
+ # forced turn that the vendor dropped stays distinguishable in traces.
406
+ if body.get("tool_choice") == "required" and not (
407
+ self.honors_forced_tool_choice()
398
408
  ):
399
409
  body["tool_choice"] = "auto"
400
410
  # W6-8: clamp the requested reasoning effort to a level the model
@@ -1184,6 +1184,22 @@ class CoreLoop:
1184
1184
  "round": round_index,
1185
1185
  },
1186
1186
  )
1187
+ # Verdicts that took no action. Without these a trace shows only
1188
+ # the rounds a hook intervened in, which is a numerator with no
1189
+ # denominator — "routing allowed a no-tool turn" and "routing
1190
+ # never ran" look identical.
1191
+ for note in pre.notes:
1192
+ yield LoopEvent(
1193
+ "hook_action",
1194
+ {
1195
+ "hook": "pre_step",
1196
+ "action": note.action,
1197
+ "reason": note.reason,
1198
+ "value": note.value,
1199
+ "probability": note.probability,
1200
+ "round": round_index,
1201
+ },
1202
+ )
1187
1203
  # ── manual compact: host-requested (agent.chat.compact, CC ────
1188
1204
  # ── /compact parity) ──
1189
1205
  # Consumed AFTER the regular pre_step pass, on the projection
@@ -1227,6 +1243,14 @@ class CoreLoop:
1227
1243
  "hook": "pre_step",
1228
1244
  "action": "tool_choice",
1229
1245
  "value": step_tool_choice,
1246
+ # Several vendors downgrade ``required`` to ``auto`` on
1247
+ # the wire (Z.AI GLM, Qwen/DeepSeek thinking). Logging
1248
+ # the hook without this reads as a gate that held, so
1249
+ # every measurement over forced turns silently mixes
1250
+ # the models where the gate is a no-op.
1251
+ "honored": _honors_forced_tool_choice(
1252
+ self._provider, step_tool_choice
1253
+ ),
1230
1254
  "round": round_index,
1231
1255
  },
1232
1256
  )
@@ -2459,6 +2483,20 @@ async def _safe_aclose(stream: Any) -> None:
2459
2483
  pass
2460
2484
 
2461
2485
 
2486
+ def _honors_forced_tool_choice(provider: LLMProvider, tool_choice: str) -> bool:
2487
+ """Whether ``tool_choice`` survives to the wire on this provider.
2488
+
2489
+ Only ``required`` is ever downgraded, so any other value is honored by
2490
+ definition. The capability is duck-typed like ``on_stream_chunk``: a
2491
+ provider that does not publish ``honors_forced_tool_choice`` is taken at
2492
+ its word, since the downgrade is a vendor quirk the adapter owns.
2493
+ """
2494
+ if tool_choice != "required":
2495
+ return True
2496
+ honors = getattr(provider, "honors_forced_tool_choice", None)
2497
+ return bool(honors()) if callable(honors) else True
2498
+
2499
+
2462
2500
  def _apply_rewrite(manager: ContextManager, rewrite: RewriteRequest) -> None:
2463
2501
  """Apply one declared rewrite to the record.
2464
2502
 
@@ -9,6 +9,7 @@ module is importable; ``STEERABLE_RUST_CORELOOP=0`` forces Python and
9
9
  from __future__ import annotations
10
10
 
11
11
  import asyncio
12
+ import contextvars
12
13
  import json
13
14
  import logging
14
15
  import os
@@ -24,7 +25,13 @@ from steerable_agent_runtime.history import ContextManager
24
25
  from steerable_agent_runtime.llm import LLMMessage
25
26
  from steerable_agent_runtime.llm.errors import LLMError
26
27
  from steerable_agent_runtime.llm.parts import ImagePart, TextPart
27
- from steerable_agent_runtime.loop import CoreLoop, LoopContext, LoopEvent, _apply_rewrite
28
+ from steerable_agent_runtime.loop import (
29
+ CoreLoop,
30
+ LoopContext,
31
+ LoopEvent,
32
+ _apply_rewrite,
33
+ _honors_forced_tool_choice,
34
+ )
28
35
  from steerable_agent_runtime.pseudo import (
29
36
  PseudoStreamStripper,
30
37
  extract_inline_tool_calls,
@@ -403,6 +410,22 @@ async def _pre_step(
403
410
  else action.append_action or "append"
404
411
  ),
405
412
  "tool_choice": action.tool_choice,
413
+ # The Rust engine emits the tool_choice hook_action, but only the
414
+ # Python provider knows whether the vendor downgrades the forced value.
415
+ "tool_choice_honored": (
416
+ _honors_forced_tool_choice(loop._provider, action.tool_choice)
417
+ if action.tool_choice
418
+ else None
419
+ ),
420
+ "notes": [
421
+ {
422
+ "action": note.action,
423
+ "reason": note.reason,
424
+ "value": note.value,
425
+ "probability": note.probability,
426
+ }
427
+ for note in action.notes
428
+ ],
406
429
  "pre_tokens": (
407
430
  rewrite.pre_tokens if rewrite is not None else None
408
431
  ),
@@ -535,8 +558,26 @@ async def _sync_history(
535
558
  return json.dumps({"ok": True})
536
559
 
537
560
 
538
- def _await_on_loop(coro: Any, aio_loop: asyncio.AbstractEventLoop) -> str:
539
- """Schedule ``coro`` on the running asyncio loop; block without holding the GIL."""
561
+ def _await_on_loop(
562
+ coro: Any,
563
+ aio_loop: asyncio.AbstractEventLoop,
564
+ caller_context: contextvars.Context | None = None,
565
+ ) -> str:
566
+ """Schedule ``coro`` on the running asyncio loop; block without holding the GIL.
567
+
568
+ The callback runs under ``caller_context`` so it sees the ContextVars the
569
+ turn was started with. Rust drives these callbacks from its own thread,
570
+ whose context knows nothing the process set up earlier: without this the
571
+ task would copy that empty context and every ContextVar read would fall
572
+ back to its default, while the pure-Python loop — awaiting in the caller's
573
+ own task — reads the value. ``run_code`` binds its nested-tool router this
574
+ way, so the divergence surfaced as "no tool executor bound for nested
575
+ calls" on the native path only.
576
+
577
+ ``Task`` takes no ``context`` argument before 3.11, so the context is
578
+ applied one level up: ``call_soon_threadsafe`` runs ``start`` inside it and
579
+ ``create_task`` copies from there.
580
+ """
540
581
  box: queue.Queue[str] = queue.Queue()
541
582
 
542
583
  def start() -> None:
@@ -551,7 +592,7 @@ def _await_on_loop(coro: Any, aio_loop: asyncio.AbstractEventLoop) -> str:
551
592
 
552
593
  task.add_done_callback(done)
553
594
 
554
- aio_loop.call_soon_threadsafe(start)
595
+ aio_loop.call_soon_threadsafe(start, context=caller_context)
555
596
  return box.get()
556
597
 
557
598
 
@@ -566,6 +607,9 @@ async def run_native(
566
607
  import steerable_agent_runtime_native as native
567
608
 
568
609
  aio_loop = asyncio.get_running_loop()
610
+ # Every Python callback below runs under this snapshot, so the Rust-driven
611
+ # path reads the same ContextVars the pure-Python loop would.
612
+ caller_context = contextvars.copy_context()
569
613
  out: asyncio.Queue[tuple[LoopEvent, threading.Event] | None] = asyncio.Queue()
570
614
  loop._run_context = LoopContext(chat_id=chat_id)
571
615
  loop._wrap_up_active = False
@@ -735,7 +779,7 @@ async def run_native(
735
779
 
736
780
  task.add_done_callback(done)
737
781
 
738
- aio_loop.call_soon_threadsafe(start)
782
+ aio_loop.call_soon_threadsafe(start, context=caller_context)
739
783
  first_frame = output.get()
740
784
  first = json.loads(first_frame)
741
785
  if first.get("kind") == "error":
@@ -783,7 +827,7 @@ async def run_native(
783
827
 
784
828
  task.add_done_callback(done)
785
829
 
786
- aio_loop.call_soon_threadsafe(start)
830
+ aio_loop.call_soon_threadsafe(start, context=caller_context)
787
831
  return box.get()
788
832
 
789
833
  def tool_concurrency_safe(call_json: str) -> bool:
@@ -810,6 +854,7 @@ async def run_native(
810
854
  return _await_on_loop(
811
855
  _sync_history(loop, manager, history_json, chat_id),
812
856
  aio_loop,
857
+ caller_context,
813
858
  )
814
859
 
815
860
  def sync_context(context_json: str) -> str:
@@ -848,17 +893,23 @@ async def run_native(
848
893
  return json.dumps({"ok": True})
849
894
 
850
895
  def before_completion(draft_json: str) -> str:
851
- return _await_on_loop(_before_completion(loop, draft_json), aio_loop)
896
+ return _await_on_loop(
897
+ _before_completion(loop, draft_json), aio_loop, caller_context
898
+ )
852
899
 
853
900
  def post_tool_result(payload_json: str) -> str:
854
- return _await_on_loop(_post_tool_result(loop, payload_json), aio_loop)
901
+ return _await_on_loop(
902
+ _post_tool_result(loop, payload_json), aio_loop, caller_context
903
+ )
855
904
 
856
905
  def pre_step(transcript_json: str) -> str:
857
- return _await_on_loop(_pre_step(loop, manager, transcript_json), aio_loop)
906
+ return _await_on_loop(
907
+ _pre_step(loop, manager, transcript_json), aio_loop, caller_context
908
+ )
858
909
 
859
910
  def on_request_error(payload_json: str) -> str:
860
911
  return _await_on_loop(
861
- _on_request_error(loop, manager, payload_json), aio_loop
912
+ _on_request_error(loop, manager, payload_json), aio_loop, caller_context
862
913
  )
863
914
 
864
915
  def wrap_up_may_drop_tools() -> bool:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: steerable-agent-runtime
3
- Version: 0.6.32
3
+ Version: 0.6.34
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
@@ -107,6 +107,15 @@ def final_completion(events: list[LoopEvent]) -> dict[str, Any]:
107
107
  return completions[-1].data
108
108
 
109
109
 
110
+ def route_notes(events: list[LoopEvent]) -> list[tuple[str, str]]:
111
+ """``(action, value)`` of every recorded data-need routing verdict."""
112
+ return [
113
+ (e.data["action"], e.data["value"])
114
+ for e in events
115
+ if e.kind == "hook_action" and e.data.get("action") == "data_need_route"
116
+ ]
117
+
118
+
110
119
  def make_router_with_tool() -> ToolRouter:
111
120
  router = ToolRouter()
112
121
 
@@ -325,6 +334,13 @@ async def test_routing_require_tool_forces_tool_choice_on_first_call() -> None:
325
334
  # 第二轮起不再强制
326
335
  assert provider.stream_kwargs[1].get("tool_choice") is None
327
336
  assert final_completion(events)["status"] == "completed"
337
+ assert route_notes(events) == [("data_need_route", "require_tool")]
338
+ # 这个 fake provider 不声明 honors_forced_tool_choice,按"照实发送"处理。
339
+ forced = [
340
+ e for e in events
341
+ if e.kind == "hook_action" and e.data.get("action") == "tool_choice"
342
+ ]
343
+ assert [e.data["honored"] for e in forced] == [True]
328
344
 
329
345
 
330
346
  @pytest.mark.asyncio
@@ -341,6 +357,9 @@ async def test_routing_allow_no_tool_passes_through() -> None:
341
357
 
342
358
  assert provider.stream_kwargs[0].get("tool_choice") is None
343
359
  assert final_completion(events)["status"] == "completed"
360
+ # allow_no_tool 不改变这一步,但必须留痕:没有它,"路由放行"和"路由没跑"
361
+ # 在轨迹里长得一样,强制率就没有分母。
362
+ assert route_notes(events) == [("data_need_route", "allow_no_tool")]
344
363
 
345
364
 
346
365
  @pytest.mark.asyncio
@@ -353,8 +372,13 @@ async def test_routing_classification_error_falls_back_to_require_tool() -> None
353
372
  provider, AntiHallucinationConfig(user_question="查一下数值")
354
373
  )
355
374
  loop = CoreLoop(provider, RouterToolExecutor(make_router_with_tool()), hooks=hooks)
356
- await collect(loop.run([LLMMessage.text_of("user", "查一下数值")], tools=TOOLS))
375
+ events = await collect(
376
+ loop.run([LLMMessage.text_of("user", "查一下数值")], tools=TOOLS)
377
+ )
357
378
  assert provider.stream_kwargs[0].get("tool_choice") == "required"
379
+ # 分类失败走保守回退,留痕与真判 require_tool 无法区分——这是有意的:
380
+ # 记录的是生效的裁决,不是它的来源。
381
+ assert route_notes(events) == [("data_need_route", "require_tool")]
358
382
 
359
383
 
360
384
  @pytest.mark.asyncio
@@ -0,0 +1,124 @@
1
+ """Rust CoreLoop is the default when the native binding is importable."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextvars
6
+ from collections.abc import AsyncIterator
7
+ from typing import Any
8
+
9
+ import pytest
10
+ from steerable_agent_protocol.generated import ToolCall
11
+ from steerable_agent_runtime import CoreLoop, RouterToolExecutor, ToolRouter, tool
12
+ from steerable_agent_runtime.llm import LLMMessage, LLMStreamChunk
13
+ from steerable_agent_runtime.native_bridge import native_available, uses_native_coreloop
14
+
15
+
16
+ def test_uses_native_coreloop_follows_availability_when_unset(
17
+ monkeypatch: pytest.MonkeyPatch,
18
+ ) -> None:
19
+ monkeypatch.delenv("STEERABLE_RUST_CORELOOP", raising=False)
20
+ monkeypatch.setattr(
21
+ "steerable_agent_runtime.native_bridge.native_available",
22
+ lambda: True,
23
+ )
24
+ assert uses_native_coreloop() is True
25
+ monkeypatch.setattr(
26
+ "steerable_agent_runtime.native_bridge.native_available",
27
+ lambda: False,
28
+ )
29
+ assert uses_native_coreloop() is False
30
+
31
+
32
+ def test_uses_native_coreloop_zero_forces_python(
33
+ monkeypatch: pytest.MonkeyPatch,
34
+ ) -> None:
35
+ monkeypatch.setenv("STEERABLE_RUST_CORELOOP", "0")
36
+ monkeypatch.setattr(
37
+ "steerable_agent_runtime.native_bridge.native_available",
38
+ lambda: True,
39
+ )
40
+ assert uses_native_coreloop() is False
41
+
42
+
43
+ def test_uses_native_coreloop_one_forces_rust(
44
+ monkeypatch: pytest.MonkeyPatch,
45
+ ) -> None:
46
+ monkeypatch.setenv("STEERABLE_RUST_CORELOOP", "1")
47
+ monkeypatch.setattr(
48
+ "steerable_agent_runtime.native_bridge.native_available",
49
+ lambda: False,
50
+ )
51
+ assert uses_native_coreloop() is True
52
+
53
+
54
+ #: Stand-in for the process-lifetime ContextVars callers bind around a turn —
55
+ #: ``run_code`` keeps its nested-tool router in one.
56
+ _ambient: contextvars.ContextVar[str | None] = contextvars.ContextVar(
57
+ "test_ambient", default=None
58
+ )
59
+
60
+
61
+ def _provider_calling(name: str) -> Any:
62
+ class _FakeProvider:
63
+ name = "fake"
64
+ model = "fake-model"
65
+
66
+ def __init__(self) -> None:
67
+ self._idx = 0
68
+
69
+ async def complete(self, messages, *, tools=None, **kw): # pragma: no cover
70
+ raise NotImplementedError
71
+
72
+ def stream(self, messages, *, tools=None, **kw) -> AsyncIterator[LLMStreamChunk]:
73
+ first = self._idx == 0
74
+ self._idx += 1
75
+
76
+ async def _gen() -> AsyncIterator[LLMStreamChunk]:
77
+ if first:
78
+ yield LLMStreamChunk(
79
+ tool_call_delta=ToolCall(id="c1", name=name, arguments={})
80
+ )
81
+ yield LLMStreamChunk(finish_reason="tool_calls")
82
+ else:
83
+ yield LLMStreamChunk(content_delta="done")
84
+ yield LLMStreamChunk(finish_reason="stop")
85
+
86
+ return _gen()
87
+
88
+ return _FakeProvider()
89
+
90
+
91
+ @pytest.mark.skipif(
92
+ not native_available(), reason="the context bridge only applies to the Rust loop"
93
+ )
94
+ @pytest.mark.asyncio
95
+ async def test_native_tool_execution_sees_the_callers_context_vars() -> None:
96
+ """Rust drives the Python callbacks from its own thread, so a task created
97
+ for them copies that thread's context, not the turn's. A tool that reads an
98
+ ambient ContextVar would then see the default while the pure-Python loop
99
+ sees the value — which is how ``run_code`` lost its nested-tool router.
100
+ """
101
+ router = ToolRouter()
102
+ seen: list[str | None] = []
103
+
104
+ @tool(router=router, name="peek", description="read the ambient var", mode="read")
105
+ async def peek() -> dict[str, Any]:
106
+ seen.append(_ambient.get())
107
+ return {"value": seen[-1]}
108
+
109
+ _ambient.set("bound-by-caller")
110
+ loop = CoreLoop(_provider_calling("peek"), RouterToolExecutor(router))
111
+ tools = [
112
+ {
113
+ "type": "function",
114
+ "function": {
115
+ "name": "peek",
116
+ "description": "read",
117
+ "parameters": {"type": "object", "properties": {}},
118
+ },
119
+ }
120
+ ]
121
+ async for _event in loop.run([LLMMessage.text_of("user", "go")], tools=tools):
122
+ pass
123
+
124
+ assert seen == ["bound-by-caller"]
@@ -114,6 +114,27 @@ def test_forced_tool_choice_downgraded_when_flagged_off() -> None:
114
114
  assert body["tool_choice"] == "auto"
115
115
 
116
116
 
117
+ def test_honors_forced_tool_choice_matches_the_downgrade() -> None:
118
+ """The probe callers record must agree with what the body actually sends.
119
+
120
+ Two sources downgrade: the compat flag and the model-name paths host
121
+ flags cannot cover (Z.AI GLM, Qwen/DeepSeek thinking over OpenRouter).
122
+ """
123
+ assert _provider().honors_forced_tool_choice() is True
124
+ assert (
125
+ _provider(
126
+ compat=OpenAICompatFlags(supports_forced_tool_choice=False)
127
+ ).honors_forced_tool_choice()
128
+ is False
129
+ )
130
+ assert (
131
+ _provider(
132
+ model="z-ai/glm-5", base_url="https://openrouter.ai/api/v1"
133
+ ).honors_forced_tool_choice()
134
+ is False
135
+ )
136
+
137
+
117
138
  def test_forced_tool_choice_kept_by_default() -> None:
118
139
  from steerable_agent_runtime.llm import LLMMessage
119
140
 
@@ -1,44 +0,0 @@
1
- """Rust CoreLoop is the default when the native binding is importable."""
2
-
3
- from __future__ import annotations
4
-
5
- import pytest
6
- from steerable_agent_runtime.native_bridge import uses_native_coreloop
7
-
8
-
9
- def test_uses_native_coreloop_follows_availability_when_unset(
10
- monkeypatch: pytest.MonkeyPatch,
11
- ) -> None:
12
- monkeypatch.delenv("STEERABLE_RUST_CORELOOP", raising=False)
13
- monkeypatch.setattr(
14
- "steerable_agent_runtime.native_bridge.native_available",
15
- lambda: True,
16
- )
17
- assert uses_native_coreloop() is True
18
- monkeypatch.setattr(
19
- "steerable_agent_runtime.native_bridge.native_available",
20
- lambda: False,
21
- )
22
- assert uses_native_coreloop() is False
23
-
24
-
25
- def test_uses_native_coreloop_zero_forces_python(
26
- monkeypatch: pytest.MonkeyPatch,
27
- ) -> None:
28
- monkeypatch.setenv("STEERABLE_RUST_CORELOOP", "0")
29
- monkeypatch.setattr(
30
- "steerable_agent_runtime.native_bridge.native_available",
31
- lambda: True,
32
- )
33
- assert uses_native_coreloop() is False
34
-
35
-
36
- def test_uses_native_coreloop_one_forces_rust(
37
- monkeypatch: pytest.MonkeyPatch,
38
- ) -> None:
39
- monkeypatch.setenv("STEERABLE_RUST_CORELOOP", "1")
40
- monkeypatch.setattr(
41
- "steerable_agent_runtime.native_bridge.native_available",
42
- lambda: False,
43
- )
44
- assert uses_native_coreloop() is True