steerable-agent-runtime 0.6.51__tar.gz → 0.6.52__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 (163) hide show
  1. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/PKG-INFO +1 -1
  2. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/pyproject.toml +1 -1
  3. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/approval.py +86 -0
  4. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/default.harness.json +0 -2
  5. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/default.harness.yaml +4 -2
  6. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/orchestration.py +69 -10
  7. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/pool.py +32 -5
  8. steerable_agent_runtime-0.6.52/src/steerable_agent_runtime/tool_presentation.py +110 -0
  9. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/tools.py +7 -1
  10. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime.egg-info/PKG-INFO +1 -1
  11. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime.egg-info/SOURCES.txt +2 -0
  12. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_approval.py +101 -0
  13. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_orchestration.py +246 -0
  14. steerable_agent_runtime-0.6.52/tests/test_tool_presentation.py +113 -0
  15. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/README.md +0 -0
  16. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/setup.cfg +0 -0
  17. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/__init__.py +0 -0
  18. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/antihallucination.py +0 -0
  19. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/approval_policy.py +0 -0
  20. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/ask_user.py +0 -0
  21. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/branch.py +0 -0
  22. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/cache_control.py +0 -0
  23. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/calibration.py +0 -0
  24. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/compaction.py +0 -0
  25. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/compaction_policy.py +0 -0
  26. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/config.py +0 -0
  27. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/delegation_gate.py +0 -0
  28. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/errors.py +0 -0
  29. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/gateway_catalog.py +0 -0
  30. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/handoff.py +0 -0
  31. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/harness.py +0 -0
  32. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/harness_spec.py +0 -0
  33. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/history.py +0 -0
  34. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/hooks.py +0 -0
  35. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/llm/__init__.py +0 -0
  36. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/llm/anthropic_native.py +0 -0
  37. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/llm/compat.py +0 -0
  38. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/llm/errors.py +0 -0
  39. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/llm/google_genai.py +0 -0
  40. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/llm/openai_compat.py +0 -0
  41. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/llm/openai_responses.py +0 -0
  42. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/llm/parts.py +0 -0
  43. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/llm/presets.py +0 -0
  44. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/llm/system_proxy.py +0 -0
  45. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/loop.py +0 -0
  46. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/maintenance.py +0 -0
  47. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/mcp.py +0 -0
  48. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/mcp_server.py +0 -0
  49. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/model_catalog.py +0 -0
  50. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/model_info.py +0 -0
  51. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/model_resolve.py +0 -0
  52. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/native_bridge.py +0 -0
  53. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/observation_aging.py +0 -0
  54. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/otel.py +0 -0
  55. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/plugins.py +0 -0
  56. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/pricing.py +0 -0
  57. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/pseudo.py +0 -0
  58. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/recording.py +0 -0
  59. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/reminders.py +0 -0
  60. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/replay.py +0 -0
  61. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/resume.py +0 -0
  62. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/retry.py +0 -0
  63. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/sandboxed.py +0 -0
  64. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/skills.py +0 -0
  65. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/spill.py +0 -0
  66. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/storage/__init__.py +0 -0
  67. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/storage/in_memory.py +0 -0
  68. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/storage/sqlalchemy_store.py +0 -0
  69. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/storage/sqlite_store.py +0 -0
  70. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/storage/write_lease.py +0 -0
  71. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/subagent.py +0 -0
  72. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/todo.py +0 -0
  73. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/tokens.py +0 -0
  74. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/tool_schema.py +0 -0
  75. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/tool_search.py +0 -0
  76. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/tracing.py +0 -0
  77. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/transport/__init__.py +0 -0
  78. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/transport/fastapi_sse.py +0 -0
  79. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/transport/stdio_jsonrpc.py +0 -0
  80. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/world_state.py +0 -0
  81. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime.egg-info/dependency_links.txt +0 -0
  82. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime.egg-info/requires.txt +0 -0
  83. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime.egg-info/top_level.txt +0 -0
  84. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_antihallucination.py +0 -0
  85. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_approval_policy.py +0 -0
  86. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_ask_user.py +0 -0
  87. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_branch.py +0 -0
  88. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_cache_control.py +0 -0
  89. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_cache_instrumentation.py +0 -0
  90. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_calibration.py +0 -0
  91. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_compaction.py +0 -0
  92. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_compaction_policy.py +0 -0
  93. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_compaction_strategy.py +0 -0
  94. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_config.py +0 -0
  95. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_content_parts.py +0 -0
  96. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_delegation_gate.py +0 -0
  97. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_error_taxonomy.py +0 -0
  98. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_fragment_bounds.py +0 -0
  99. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_gateway_catalog.py +0 -0
  100. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_golden.py +0 -0
  101. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_handoff.py +0 -0
  102. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_harness.py +0 -0
  103. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_harness_spec.py +0 -0
  104. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_history.py +0 -0
  105. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_history_persistence.py +0 -0
  106. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_hooks.py +0 -0
  107. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_in_memory_storage.py +0 -0
  108. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_llm_gemini_wire.py +0 -0
  109. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_llm_responses_wire.py +0 -0
  110. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_llm_stream_e2e.py +0 -0
  111. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_llm_wire_helpers.py +0 -0
  112. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_long_session.py +0 -0
  113. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_loop.py +0 -0
  114. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_loop_cancellation.py +0 -0
  115. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_loop_replay.py +0 -0
  116. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_loop_sandbox_event.py +0 -0
  117. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_loop_terminal.py +0 -0
  118. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_maintenance.py +0 -0
  119. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_mcp.py +0 -0
  120. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_mcp_server.py +0 -0
  121. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_model_catalog.py +0 -0
  122. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_model_equivalence.py +0 -0
  123. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_model_info.py +0 -0
  124. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_model_resolve.py +0 -0
  125. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_native_default.py +0 -0
  126. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_observation_aging.py +0 -0
  127. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_otel.py +0 -0
  128. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_parallel_tools.py +0 -0
  129. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_plugins.py +0 -0
  130. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_provider_compat.py +0 -0
  131. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_provider_presets.py +0 -0
  132. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_pseudo.py +0 -0
  133. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_pyo3_api_surface.py +0 -0
  134. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_recording.py +0 -0
  135. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_reminders.py +0 -0
  136. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_replay_crosslang.py +0 -0
  137. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_resume.py +0 -0
  138. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_retry_hooks.py +0 -0
  139. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_safety_gate.py +0 -0
  140. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_sandboxed.py +0 -0
  141. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_skills.py +0 -0
  142. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_soft_timeout.py +0 -0
  143. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_spill.py +0 -0
  144. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_sqlite_storage.py +0 -0
  145. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_steer.py +0 -0
  146. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_storage_contract.py +0 -0
  147. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_stream_strip.py +0 -0
  148. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_subagent.py +0 -0
  149. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_system_proxy.py +0 -0
  150. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_todo.py +0 -0
  151. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_todo_gate.py +0 -0
  152. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_tokens.py +0 -0
  153. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_tool_exposure.py +0 -0
  154. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_tool_hygiene.py +0 -0
  155. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_tool_router.py +0 -0
  156. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_tool_schema.py +0 -0
  157. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_tool_timeout.py +0 -0
  158. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_trace_recorder.py +0 -0
  159. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_transport_jsonrpc.py +0 -0
  160. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_transport_sse.py +0 -0
  161. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_usage_attribution.py +0 -0
  162. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_world_state.py +0 -0
  163. {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/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.51
3
+ Version: 0.6.52
4
4
  Summary: Steerable agent runtime: LLM, tool, storage, and transport adapters.
5
5
  License: Apache-2.0
6
6
  Requires-Python: >=3.10
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "steerable-agent-runtime"
3
- version = "0.6.51"
3
+ version = "0.6.52"
4
4
  description = "Steerable agent runtime: LLM, tool, storage, and transport adapters."
5
5
  license = { text = "Apache-2.0" }
6
6
  readme = "README.md"
@@ -53,6 +53,7 @@ __all__ = [
53
53
  "ApprovalStore",
54
54
  "Approver",
55
55
  "AutoApprover",
56
+ "WorkspaceAutoApprover",
56
57
  "InMemoryApprovalStore",
57
58
  "JsonApprovalStore",
58
59
  "SessionApprovalCache",
@@ -130,6 +131,91 @@ class Approver(Protocol):
130
131
  async def approve(self, request: ApprovalRequest) -> ApprovalDecision: ...
131
132
 
132
133
 
134
+ _PATH_ARG_TOOLS = {
135
+ "local_read_file": "path",
136
+ "local_write_file": "path",
137
+ "local_edit_file": "path",
138
+ "view_image": "path",
139
+ }
140
+
141
+
142
+ def declared_target_paths(request: ApprovalRequest) -> list[str] | None:
143
+ """Filesystem targets of a path-scoped tool, or None when the call is not one.
144
+
145
+ ``None`` means the approver cannot prove the call stays inside the
146
+ project, so the interactive prompt still runs. Shell commands stay in
147
+ that bucket: the command text can name paths the cwd does not.
148
+ """
149
+ if request.tool_name == "present_files":
150
+ files = request.arguments.get("files")
151
+ if not isinstance(files, list) or not files:
152
+ return None
153
+ paths: list[str] = []
154
+ for item in files:
155
+ if not isinstance(item, dict):
156
+ return None
157
+ raw = item.get("path")
158
+ if not isinstance(raw, str) or not raw.strip():
159
+ return None
160
+ paths.append(raw.strip())
161
+ return paths
162
+ key = _PATH_ARG_TOOLS.get(request.tool_name)
163
+ if key is None:
164
+ return None
165
+ raw = request.arguments.get(key)
166
+ if not isinstance(raw, str) or not raw.strip():
167
+ return None
168
+ return [raw.strip()]
169
+
170
+
171
+ def path_inside_writable_roots(raw: str, roots: list[str]) -> bool:
172
+ """True when ``raw`` is the configured directory or one of its subdirectories.
173
+
174
+ Relative paths are project-relative. A ``..`` segment can leave the
175
+ project, so those still go to the prompt. Absolute paths must sit under
176
+ one of ``roots``.
177
+ """
178
+ expanded = os.path.expanduser(raw.strip())
179
+ if not os.path.isabs(expanded):
180
+ normalized = os.path.normpath(expanded)
181
+ return normalized != ".." and not normalized.startswith(".." + os.sep)
182
+ target = os.path.abspath(expanded)
183
+ for root in roots:
184
+ if not isinstance(root, str) or not root.strip():
185
+ continue
186
+ root_abs = os.path.abspath(os.path.expanduser(root.strip()))
187
+ try:
188
+ if os.path.commonpath([target, root_abs]) == root_abs:
189
+ return True
190
+ except ValueError:
191
+ continue
192
+ return False
193
+
194
+
195
+ class WorkspaceAutoApprover:
196
+ """Skip the host prompt when every declared path is already inside a writable root.
197
+
198
+ The allow is request-scoped. The next call is judged again, so an
199
+ in-project ``present_files`` does not grant the same tool outside the
200
+ project. Calls with no path we can check, or any path outside the roots,
201
+ fall through to the inner approver.
202
+ """
203
+
204
+ def __init__(self, inner: Approver, writable_roots: list[str]) -> None:
205
+ self._inner = inner
206
+ self._roots = [root for root in writable_roots if isinstance(root, str) and root.strip()]
207
+
208
+ async def approve(self, request: ApprovalRequest) -> ApprovalDecision:
209
+ paths = declared_target_paths(request)
210
+ if (
211
+ self._roots
212
+ and paths is not None
213
+ and all(path_inside_writable_roots(path, self._roots) for path in paths)
214
+ ):
215
+ return ApprovalDecision("allow_once", "path is inside a writable project root")
216
+ return await self._inner.approve(request)
217
+
218
+
133
219
  class AutoApprover:
134
220
  """Headless approver: never blocks, decides by tool mode.
135
221
 
@@ -23,8 +23,6 @@
23
23
  "memory": "stateless",
24
24
  "orchestration": "single",
25
25
  "loop": {
26
- "max_rounds": 80,
27
- "max_tool_errors": 16,
28
26
  "tool_dedup": false
29
27
  }
30
28
  }
@@ -22,7 +22,9 @@ memory: stateless
22
22
  orchestration: single
23
23
  # Loop limits the harness pins (W3.4.2.4): entrypoints read these instead of
24
24
  # hardcoding knobs. Absent fields fall back to the entrypoint's baseline.
25
+ # max_rounds and max_tool_errors are absent on purpose — a cap here ends a
26
+ # long task at budget_exhausted / consecutive tool errors. The baseline is
27
+ # no cap. A caller that wants a wall passes maxRounds / maxToolErrors, and
28
+ # Harbor passes --max-rounds 250.
25
29
  loop:
26
- max_rounds: 80
27
- max_tool_errors: 16
28
30
  tool_dedup: false
@@ -38,6 +38,7 @@ from typing import Any
38
38
 
39
39
  from steerable_agent_protocol.generated import ToolCall, ToolResult
40
40
 
41
+ from .hooks import ChainHooks, CompletionAction, CompletionDraft, NoopHooks
41
42
  from .llm import LLMProvider
42
43
  from .loop import CoreLoop, LoopConfig, LoopContext, LoopHooks, ToolExecutor
43
44
  from .pool import (
@@ -51,12 +52,61 @@ from .subagent import FilteredToolsExecutor, _schema_name
51
52
  __all__ = [
52
53
  "AgentPool",
53
54
  "ChildOutcome",
55
+ "ChildBudgetWrapUp",
54
56
  "OrchestrationBudgetExceeded",
55
57
  "OrchestrationConfig",
56
58
  "OrchestrationExecutor",
57
59
  "orchestration_tool_descriptors",
58
60
  ]
59
61
 
62
+ #: Terminal status of a run stopped by ``max_rounds`` or the token budget.
63
+ _BUDGET_WALL_STATUS = "budget_exhausted"
64
+
65
+ _BUDGET_WRAP_UP_REQUEST = (
66
+ "[system notice] This run has used its round budget and tools are now "
67
+ "withheld. Answer the original task now from what you have already "
68
+ "gathered: give your concrete findings first, then list what you could "
69
+ "not finish or verify. Do not describe the steps you took."
70
+ )
71
+
72
+ _RESUME_HINT = (
73
+ "The child stopped at its round budget; `answer` is its interim report. "
74
+ "To continue, agent_send this childId a message naming what is still "
75
+ "missing — it resumes with its prior context and a fresh budget. Do not "
76
+ "spawn a new child for the same task."
77
+ )
78
+
79
+
80
+ class ChildBudgetWrapUp(NoopHooks):
81
+ """``before_completion``: one no-tools answer round at a child's budget wall.
82
+
83
+ Without it a child stopped by ``max_rounds`` or the token budget returns
84
+ whatever text its last working round produced — usually a progress note,
85
+ not an answer. The parent then gets ``budget_exhausted`` with nothing
86
+ usable. The ``narrate`` action runs one round without tools and keeps the
87
+ ``budget_exhausted`` status, so the parent can still tell the answer is
88
+ interim and resume the child with ``agent_send``.
89
+
90
+ One instance per child loop. A resumed child runs the same loop with a new
91
+ ``LoopContext``, so the one-round limit applies per run.
92
+ """
93
+
94
+ def __init__(self) -> None:
95
+ self._wrapped_run: LoopContext | None = None
96
+
97
+ async def before_completion(
98
+ self, draft: CompletionDraft, ctx: LoopContext
99
+ ) -> CompletionAction:
100
+ """Narrate once per run when the draft stops at the budget wall."""
101
+ if draft.status != _BUDGET_WALL_STATUS or self._wrapped_run is ctx:
102
+ return CompletionAction(kind="accept")
103
+ self._wrapped_run = ctx
104
+ return CompletionAction(
105
+ kind="narrate",
106
+ message=_BUDGET_WRAP_UP_REQUEST,
107
+ reason="child_budget_wrap_up",
108
+ )
109
+
60
110
 
61
111
  def orchestration_tool_descriptors(
62
112
  config: OrchestrationConfig | None = None,
@@ -119,7 +169,10 @@ def orchestration_tool_descriptors(
119
169
  fn(
120
170
  config.wait_tool,
121
171
  "Wait for a sub-agent to finish and return its outcome. A child "
122
- "still running at the timeout returns status 'running'.",
172
+ "still running at the timeout returns status 'running'. A child "
173
+ "that hit its round budget returns status 'budget_exhausted' with "
174
+ "an interim answer and resumable: true — continue it with "
175
+ "agent_send instead of spawning a new child.",
123
176
  {
124
177
  "childId": {"type": "string"},
125
178
  "timeoutMs": {"type": "integer"},
@@ -228,11 +281,15 @@ class OrchestrationExecutor:
228
281
  _lineage=child_id,
229
282
  )
230
283
  schemas = [*schemas, *orchestration_tool_descriptors(self._config)]
284
+ wrap_up = ChildBudgetWrapUp()
231
285
  loop = CoreLoop(
232
286
  self._provider,
233
287
  base,
234
- LoopConfig(max_rounds=self._config.child_max_rounds),
235
- hooks=self._hooks,
288
+ LoopConfig(
289
+ max_rounds=self._config.child_max_rounds,
290
+ max_tool_errors=self._config.child_max_tool_errors,
291
+ ),
292
+ hooks=wrap_up if self._hooks is None else ChainHooks(self._hooks, wrap_up),
236
293
  )
237
294
  return loop, schemas or None
238
295
 
@@ -343,15 +400,17 @@ class OrchestrationExecutor:
343
400
  error=f"unknown_child: {child_id!r}",
344
401
  needsFollowup=True,
345
402
  )
403
+ payload: dict[str, Any] = {
404
+ "childId": outcome.child_id,
405
+ "status": outcome.status,
406
+ "answer": outcome.answer,
407
+ }
408
+ if self._pool.resumable(outcome.child_id):
409
+ payload["resumable"] = True
410
+ payload["hint"] = _RESUME_HINT
346
411
  return ToolResult(
347
412
  success=True,
348
- message=json.dumps(
349
- {
350
- "childId": outcome.child_id,
351
- "status": outcome.status,
352
- "answer": outcome.answer,
353
- }
354
- ),
413
+ message=json.dumps(payload),
355
414
  data={"childId": outcome.child_id, "status": outcome.status},
356
415
  )
357
416
 
@@ -57,7 +57,10 @@ class OrchestrationConfig:
57
57
 
58
58
  ``max_depth`` counts loop nesting: 1 means the parent spawns children
59
59
  that cannot spawn themselves (today's depth-1 delegation). ``max_parallel``
60
- caps concurrently running children per pool. ``wait_timeout_s`` bounds a
60
+ caps concurrently running children per pool. ``child_max_rounds`` and
61
+ ``child_max_tool_errors`` bound each child run; hosts pass the parent
62
+ loop's values so a child is not tighter than its parent.
63
+ ``wait_timeout_s`` bounds an
61
64
  ``agent_wait`` without an explicit ``timeoutMs`` — kept under the loop's
62
65
  per-tool backstop (default 5 min) so a hung child surfaces as a
63
66
  ``running`` outcome, not a tool timeout. The six tool names configure
@@ -67,6 +70,7 @@ class OrchestrationConfig:
67
70
  max_depth: int = 1
68
71
  max_parallel: int = 4
69
72
  child_max_rounds: int = 8
73
+ child_max_tool_errors: int = 3
70
74
  wait_timeout_s: float = 120.0
71
75
  spawn_tool: str = "agent_spawn"
72
76
  send_tool: str = "agent_send"
@@ -244,7 +248,13 @@ class AgentPool:
244
248
  async def _run_child(
245
249
  self, handle: _ChildHandle, seed: list[LLMMessage]
246
250
  ) -> None:
247
- answer_parts: list[str] = []
251
+ # The answer is the text of the run's last model request, not every
252
+ # round's text joined: working rounds narrate progress ("let me read
253
+ # the file") that is not an answer. A last request without text (a
254
+ # tool round cut by the budget wall) falls back to the most recent
255
+ # request that produced text.
256
+ request_text: list[str] = []
257
+ last_text = ""
248
258
  status = "completed"
249
259
  try:
250
260
  async for event in handle.loop.run(
@@ -255,8 +265,11 @@ class AgentPool:
255
265
  or f"subagent:{self._context_namespace}:{handle.child_id}"
256
266
  ),
257
267
  ):
258
- if event.kind == "content_delta":
259
- answer_parts.append(str(event.data.get("delta") or ""))
268
+ if event.kind == "llm_request":
269
+ last_text = "".join(request_text).strip() or last_text
270
+ request_text = []
271
+ elif event.kind == "content_delta":
272
+ request_text.append(str(event.data.get("delta") or ""))
260
273
  elif event.kind == "completion":
261
274
  status = str(event.data.get("status") or "completed")
262
275
  except asyncio.CancelledError:
@@ -276,7 +289,7 @@ class AgentPool:
276
289
  # Clean finish (completion or cooperative cancel): the record has no
277
290
  # dangling tool_calls, so it is a valid resume seed for follow-ups.
278
291
  handle.resume_messages = list(handle.loop.history.projection)
279
- answer = "".join(answer_parts).strip()
292
+ answer = "".join(request_text).strip() or last_text
280
293
  handle.outcome = ChildOutcome(handle.child_id, status, answer)
281
294
  kind = "child_completed" if status == "completed" else "child_failed"
282
295
  self._emit(kind, {"childId": handle.child_id, "status": status})
@@ -370,9 +383,23 @@ class AgentPool:
370
383
  }
371
384
  if handle.outcome is not None and handle.outcome.answer:
372
385
  entry["answerPreview"] = handle.outcome.answer[:200]
386
+ if self.resumable(handle.child_id):
387
+ entry["resumable"] = True
373
388
  entries.append(entry)
374
389
  return entries
375
390
 
391
+ def resumable(self, child_id: str) -> bool:
392
+ """True when the child stopped at its budget wall and ``send`` can
393
+ continue it in place (open, finished cleanly with a resume record)."""
394
+ handle = self._children.get(child_id)
395
+ return (
396
+ handle is not None
397
+ and not handle.closed
398
+ and handle.outcome is not None
399
+ and handle.outcome.status == "budget_exhausted"
400
+ and handle.resume_messages is not None
401
+ )
402
+
376
403
  async def close(self, child_id: str) -> bool:
377
404
  handle = self._children.get(child_id)
378
405
  if handle is None:
@@ -0,0 +1,110 @@
1
+ """Declared presentation for first-party tools.
2
+
3
+ The card and the argument that becomes the title are named here. Callers
4
+ do not infer a card from the tool-name prefix. Names absent from this table
5
+ stay undeclared; the host renders those as a generic card.
6
+
7
+ The desktop host keeps the same names in
8
+ ``packages/agent-shell/ts/src/tool-presentation.ts`` and stamps the view
9
+ onto ``executed_actions``.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any
15
+
16
+ # card is one of generic | terminal | diff | search | read | web.
17
+ # kind is one of read | edit | delete | move | search | execute | fetch | other.
18
+ # title_from names the argument whose string value is the row title.
19
+ BUILTIN_PRESENTATIONS: dict[str, dict[str, str]] = {
20
+ "bash": {"card": "terminal", "kind": "execute", "title_from": "command"},
21
+ "pwsh": {"card": "terminal", "kind": "execute", "title_from": "command"},
22
+ "bash_session": {"card": "terminal", "kind": "execute", "title_from": "command"},
23
+ "write_stdin": {"card": "terminal", "kind": "execute", "title_from": "chars"},
24
+ "local_exec_shell": {"card": "terminal", "kind": "execute", "title_from": "command"},
25
+ "local_run_snippet": {"card": "terminal", "kind": "execute", "title_from": "code"},
26
+ "local_run_script": {"card": "terminal", "kind": "execute", "title_from": "scriptId"},
27
+ "run_code": {"card": "generic", "kind": "execute", "title_from": "description"},
28
+ "run_js": {"card": "generic", "kind": "execute", "title_from": "description"},
29
+ "wait_js": {"card": "generic", "kind": "execute", "title_from": "id"},
30
+ "read_file": {"card": "read", "kind": "read", "title_from": "path"},
31
+ "local_read_file": {"card": "read", "kind": "read", "title_from": "path"},
32
+ "view_image": {"card": "read", "kind": "read", "title_from": "path"},
33
+ "capture_display": {"card": "read", "kind": "read", "title_from": "target"},
34
+ "local_list_scripts": {"card": "read", "kind": "read", "title_from": ""},
35
+ "write_file": {"card": "diff", "kind": "edit", "title_from": "path"},
36
+ "local_write_file": {"card": "diff", "kind": "edit", "title_from": "path"},
37
+ "edit_file": {"card": "diff", "kind": "edit", "title_from": "path"},
38
+ "local_edit_file": {"card": "diff", "kind": "edit", "title_from": "path"},
39
+ "apply_patch": {"card": "diff", "kind": "edit", "title_from": ""},
40
+ "grep": {"card": "search", "kind": "search", "title_from": "pattern"},
41
+ "glob": {"card": "search", "kind": "search", "title_from": "pattern"},
42
+ "tool_search": {"card": "search", "kind": "search", "title_from": "query"},
43
+ "web_search": {"card": "web", "kind": "search", "title_from": "query"},
44
+ "web_fetch": {"card": "web", "kind": "fetch", "title_from": "url"},
45
+ "task_run": {"card": "generic", "kind": "other", "title_from": "task"},
46
+ "task_send": {"card": "generic", "kind": "other", "title_from": "message"},
47
+ "task_status": {"card": "generic", "kind": "read", "title_from": "taskId"},
48
+ "task_result": {"card": "generic", "kind": "read", "title_from": "taskId"},
49
+ "job_list": {"card": "generic", "kind": "read", "title_from": ""},
50
+ "job_output": {"card": "generic", "kind": "read", "title_from": "job_id"},
51
+ "job_kill": {"card": "generic", "kind": "delete", "title_from": "job_id"},
52
+ "worktree_create": {"card": "generic", "kind": "other", "title_from": "name"},
53
+ "worktree_list": {"card": "read", "kind": "read", "title_from": ""},
54
+ "worktree_remove": {"card": "generic", "kind": "delete", "title_from": "name"},
55
+ "get_goal": {"card": "generic", "kind": "read", "title_from": ""},
56
+ "create_goal": {"card": "generic", "kind": "other", "title_from": "objective"},
57
+ "update_goal": {"card": "generic", "kind": "other", "title_from": "action"},
58
+ "present_files": {"card": "generic", "kind": "other", "title_from": ""},
59
+ "local_open_path": {"card": "generic", "kind": "other", "title_from": "target"},
60
+ "todo_write": {"card": "generic", "kind": "other", "title_from": ""},
61
+ "ask_user": {"card": "generic", "kind": "other", "title_from": "intro"},
62
+ "skill": {"card": "generic", "kind": "read", "title_from": "name"},
63
+ "delegate_subagent": {"card": "generic", "kind": "other", "title_from": "task"},
64
+ "mcp_list_tools": {"card": "generic", "kind": "fetch", "title_from": ""},
65
+ "mcp_tool_exec": {"card": "generic", "kind": "fetch", "title_from": "toolName"},
66
+ "plugin_list": {"card": "generic", "kind": "read", "title_from": ""},
67
+ "plugin_enable": {"card": "generic", "kind": "other", "title_from": "name"},
68
+ "plugin_disable": {"card": "generic", "kind": "delete", "title_from": "name"},
69
+ "plugin_reload": {"card": "generic", "kind": "other", "title_from": "name"},
70
+ }
71
+
72
+ _CARDS = frozenset({"generic", "terminal", "diff", "search", "read", "web"})
73
+ _KINDS = frozenset({"read", "edit", "delete", "move", "search", "execute", "fetch", "other"})
74
+
75
+
76
+ def builtin_presentation(name: str) -> dict[str, Any] | None:
77
+ """Return the declared view spec for ``name``, or None when undeclared."""
78
+ spec = BUILTIN_PRESENTATIONS.get(name)
79
+ if spec is None:
80
+ return None
81
+ return {**spec, "declared": True}
82
+
83
+
84
+ def present_call(
85
+ name: str,
86
+ arguments: dict[str, Any] | None,
87
+ *,
88
+ metadata: dict[str, Any] | None = None,
89
+ ) -> dict[str, Any]:
90
+ """Build the row view from a declaration. Undeclared names stay generic."""
91
+ spec = None
92
+ if metadata is not None:
93
+ stored = metadata.get("presentation")
94
+ if isinstance(stored, dict):
95
+ spec = stored
96
+ if spec is None:
97
+ spec = builtin_presentation(name)
98
+ if not spec:
99
+ return {"card": "generic", "kind": "other", "title": name, "declared": False}
100
+ key = spec.get("title_from")
101
+ raw = arguments.get(key) if isinstance(key, str) and key and isinstance(arguments, dict) else None
102
+ title = raw.strip() if isinstance(raw, str) and raw.strip() else name
103
+ if len(title) > 80:
104
+ title = title[:79] + "…"
105
+ return {
106
+ "card": spec["card"],
107
+ "kind": spec["kind"],
108
+ "title": title,
109
+ "declared": True,
110
+ }
@@ -29,6 +29,7 @@ from steerable_agent_harness.safety import CommandSafetyConfig, classify_shell_c
29
29
  from steerable_agent_protocol.generated import ToolCall, ToolResult
30
30
 
31
31
  from .errors import PolicyDeniedError, ToolDispatchError
32
+ from .tool_presentation import builtin_presentation
32
33
  from .tool_schema import derive_schema
33
34
 
34
35
  logger = logging.getLogger(__name__)
@@ -111,6 +112,11 @@ class ToolRouter:
111
112
  resolved_consent = (
112
113
  require_consent if require_consent is not None else resolved_mode == "destructive"
113
114
  )
115
+ merged_metadata = dict(metadata or {})
116
+ if "presentation" not in merged_metadata:
117
+ declared = builtin_presentation(resolved_name)
118
+ if declared is not None:
119
+ merged_metadata["presentation"] = declared
114
120
  tool_meta = RegisteredTool(
115
121
  name=resolved_name,
116
122
  handler=handler,
@@ -124,7 +130,7 @@ class ToolRouter:
124
130
  concurrency_safe if concurrency_safe is not None else resolved_mode == "read"
125
131
  ),
126
132
  exposure=exposure,
127
- metadata=dict(metadata or {}),
133
+ metadata=merged_metadata,
128
134
  )
129
135
  self._tools[resolved_name] = tool_meta
130
136
  return tool_meta
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: steerable-agent-runtime
3
- Version: 0.6.51
3
+ Version: 0.6.52
4
4
  Summary: Steerable agent runtime: LLM, tool, storage, and transport adapters.
5
5
  License: Apache-2.0
6
6
  Requires-Python: >=3.10
@@ -47,6 +47,7 @@ src/steerable_agent_runtime/spill.py
47
47
  src/steerable_agent_runtime/subagent.py
48
48
  src/steerable_agent_runtime/todo.py
49
49
  src/steerable_agent_runtime/tokens.py
50
+ src/steerable_agent_runtime/tool_presentation.py
50
51
  src/steerable_agent_runtime/tool_schema.py
51
52
  src/steerable_agent_runtime/tool_search.py
52
53
  src/steerable_agent_runtime/tools.py
@@ -148,6 +149,7 @@ tests/test_todo_gate.py
148
149
  tests/test_tokens.py
149
150
  tests/test_tool_exposure.py
150
151
  tests/test_tool_hygiene.py
152
+ tests/test_tool_presentation.py
151
153
  tests/test_tool_router.py
152
154
  tests/test_tool_schema.py
153
155
  tests/test_tool_timeout.py
@@ -468,3 +468,104 @@ async def test_loop_abort_ends_turn_with_full_tool_responses() -> None:
468
468
  assert "user cancelled" in aborted
469
469
  assert "not executed" in skipped
470
470
  assert entries[3]["message"]["tool_call_id"] == "d2"
471
+
472
+
473
+ # ---------------------------------------------------------------------------
474
+ # In-project paths skip the prompt
475
+ # ---------------------------------------------------------------------------
476
+
477
+
478
+ def _request(name: str, arguments: dict[str, Any]) -> ApprovalRequest:
479
+ return ApprovalRequest(
480
+ tool_name=name,
481
+ arguments=arguments,
482
+ mode="other",
483
+ category=name,
484
+ round_index=0,
485
+ call_id="c1",
486
+ )
487
+
488
+
489
+ @pytest.mark.asyncio
490
+ async def test_workspace_auto_approver_allows_paths_inside_writable_roots(tmp_path) -> None:
491
+ from steerable_agent_runtime.approval import WorkspaceAutoApprover
492
+
493
+ project = tmp_path / "111-2"
494
+ project.mkdir()
495
+ inner = _ScriptedApprover([ApprovalDecision("deny_once", "should not ask")])
496
+ approver = WorkspaceAutoApprover(inner, [str(project)])
497
+ pptx = project / "自我介绍.pptx"
498
+
499
+ decision = await approver.approve(
500
+ _request("present_files", {"files": [{"path": str(pptx), "description": "deck"}]})
501
+ )
502
+ nested = await approver.approve(
503
+ _request("local_write_file", {"path": str(project / "notes" / "a.md"), "content": "x"})
504
+ )
505
+ relative = await approver.approve(
506
+ _request("local_read_file", {"path": "notes/a.md"})
507
+ )
508
+
509
+ assert decision.kind == "allow_once"
510
+ assert nested.kind == "allow_once"
511
+ assert relative.kind == "allow_once"
512
+ assert inner.requests == []
513
+
514
+
515
+ @pytest.mark.asyncio
516
+ async def test_workspace_auto_approver_asks_when_a_path_leaves_the_project(tmp_path) -> None:
517
+ from steerable_agent_runtime.approval import WorkspaceAutoApprover
518
+
519
+ project = tmp_path / "proj"
520
+ project.mkdir()
521
+ inner = _ScriptedApprover([ApprovalDecision("allow_once", "user allowed")] * 5)
522
+ approver = WorkspaceAutoApprover(inner, [str(project)])
523
+
524
+ outside = await approver.approve(
525
+ _request("present_files", {"files": [{"path": str(tmp_path / "other" / "a.pptx")}]})
526
+ )
527
+ sibling = await approver.approve(
528
+ _request("local_read_file", {"path": str(tmp_path / "proj-evil" / "secret")})
529
+ )
530
+ escaped = await approver.approve(
531
+ _request("local_read_file", {"path": "../outside.txt"})
532
+ )
533
+ mixed = await approver.approve(
534
+ _request(
535
+ "present_files",
536
+ {"files": [{"path": str(project / "in.pptx")}, {"path": str(tmp_path / "out.pptx")}]},
537
+ )
538
+ )
539
+ shell = await approver.approve(
540
+ _request("local_exec_shell", {"command": "ls", "cwd": str(project)})
541
+ )
542
+
543
+ assert [item.kind for item in (outside, sibling, escaped, mixed, shell)] == ["allow_once"] * 5
544
+ assert [item.tool_name for item in inner.requests] == [
545
+ "present_files",
546
+ "local_read_file",
547
+ "local_read_file",
548
+ "present_files",
549
+ "local_exec_shell",
550
+ ]
551
+
552
+
553
+ @pytest.mark.asyncio
554
+ async def test_workspace_auto_approver_does_not_cache_the_tool_name(tmp_path) -> None:
555
+ from steerable_agent_runtime.approval import WorkspaceAutoApprover
556
+
557
+ project = tmp_path / "proj"
558
+ project.mkdir()
559
+ inner = _ScriptedApprover([ApprovalDecision("deny_once", "outside")])
560
+ approver = WorkspaceAutoApprover(inner, [str(project)])
561
+
562
+ inside = await approver.approve(
563
+ _request("present_files", {"files": [{"path": str(project / "a.pptx")}]})
564
+ )
565
+ outside = await approver.approve(
566
+ _request("present_files", {"files": [{"path": str(tmp_path / "b.pptx")}]})
567
+ )
568
+
569
+ assert inside.kind == "allow_once"
570
+ assert outside.kind == "deny_once"
571
+ assert len(inner.requests) == 1