steerable-agent-runtime 0.3.0__tar.gz → 0.4.0__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 (137) hide show
  1. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/PKG-INFO +3 -3
  2. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/pyproject.toml +13 -3
  3. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/src/steerable_agent_runtime/__init__.py +25 -1
  4. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/src/steerable_agent_runtime/approval.py +33 -1
  5. steerable_agent_runtime-0.4.0/src/steerable_agent_runtime/approval_policy.py +243 -0
  6. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/src/steerable_agent_runtime/compaction.py +29 -9
  7. steerable_agent_runtime-0.4.0/src/steerable_agent_runtime/default.harness.json +30 -0
  8. steerable_agent_runtime-0.4.0/src/steerable_agent_runtime/default.harness.yaml +28 -0
  9. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/src/steerable_agent_runtime/errors.py +11 -0
  10. steerable_agent_runtime-0.4.0/src/steerable_agent_runtime/handoff.py +147 -0
  11. steerable_agent_runtime-0.4.0/src/steerable_agent_runtime/harness.py +674 -0
  12. steerable_agent_runtime-0.4.0/src/steerable_agent_runtime/harness_spec.py +339 -0
  13. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/src/steerable_agent_runtime/history.py +124 -20
  14. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/src/steerable_agent_runtime/hooks.py +86 -0
  15. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/src/steerable_agent_runtime/llm/__init__.py +14 -0
  16. steerable_agent_runtime-0.4.0/src/steerable_agent_runtime/llm/compat.py +259 -0
  17. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/src/steerable_agent_runtime/llm/errors.py +23 -2
  18. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/src/steerable_agent_runtime/llm/openai_compat.py +115 -11
  19. steerable_agent_runtime-0.4.0/src/steerable_agent_runtime/llm/system_proxy.py +177 -0
  20. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/src/steerable_agent_runtime/loop.py +660 -96
  21. steerable_agent_runtime-0.4.0/src/steerable_agent_runtime/maintenance.py +238 -0
  22. steerable_agent_runtime-0.4.0/src/steerable_agent_runtime/mcp_server.py +319 -0
  23. steerable_agent_runtime-0.4.0/src/steerable_agent_runtime/model_catalog.py +7619 -0
  24. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/src/steerable_agent_runtime/model_info.py +101 -9
  25. steerable_agent_runtime-0.4.0/src/steerable_agent_runtime/model_resolve.py +151 -0
  26. steerable_agent_runtime-0.4.0/src/steerable_agent_runtime/observation_aging.py +196 -0
  27. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/src/steerable_agent_runtime/orchestration.py +205 -29
  28. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/src/steerable_agent_runtime/otel.py +15 -3
  29. steerable_agent_runtime-0.4.0/src/steerable_agent_runtime/reminders.py +279 -0
  30. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/src/steerable_agent_runtime/resume.py +16 -3
  31. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/src/steerable_agent_runtime/retry.py +5 -1
  32. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/src/steerable_agent_runtime/sandboxed.py +28 -8
  33. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/src/steerable_agent_runtime/storage/__init__.py +32 -7
  34. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/src/steerable_agent_runtime/storage/in_memory.py +20 -0
  35. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/src/steerable_agent_runtime/storage/sqlalchemy_store.py +91 -22
  36. steerable_agent_runtime-0.4.0/src/steerable_agent_runtime/storage/sqlite_store.py +414 -0
  37. steerable_agent_runtime-0.4.0/src/steerable_agent_runtime/storage/write_lease.py +134 -0
  38. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/src/steerable_agent_runtime/tokens.py +23 -4
  39. steerable_agent_runtime-0.4.0/src/steerable_agent_runtime/tool_search.py +177 -0
  40. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/src/steerable_agent_runtime/tracing.py +112 -13
  41. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/src/steerable_agent_runtime.egg-info/PKG-INFO +3 -3
  42. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/src/steerable_agent_runtime.egg-info/SOURCES.txt +32 -1
  43. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/src/steerable_agent_runtime.egg-info/requires.txt +2 -2
  44. steerable_agent_runtime-0.4.0/tests/test_approval_policy.py +185 -0
  45. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/tests/test_compaction.py +52 -0
  46. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/tests/test_error_taxonomy.py +11 -0
  47. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/tests/test_fragment_bounds.py +26 -0
  48. steerable_agent_runtime-0.4.0/tests/test_handoff.py +113 -0
  49. steerable_agent_runtime-0.4.0/tests/test_harness.py +244 -0
  50. steerable_agent_runtime-0.4.0/tests/test_harness_spec.py +251 -0
  51. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/tests/test_history_persistence.py +126 -6
  52. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/tests/test_llm_wire_helpers.py +174 -0
  53. steerable_agent_runtime-0.4.0/tests/test_long_session.py +163 -0
  54. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/tests/test_loop.py +38 -0
  55. steerable_agent_runtime-0.4.0/tests/test_maintenance.py +136 -0
  56. steerable_agent_runtime-0.4.0/tests/test_mcp_server.py +209 -0
  57. steerable_agent_runtime-0.4.0/tests/test_model_catalog.py +183 -0
  58. steerable_agent_runtime-0.4.0/tests/test_model_equivalence.py +185 -0
  59. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/tests/test_model_info.py +64 -1
  60. steerable_agent_runtime-0.4.0/tests/test_model_resolve.py +86 -0
  61. steerable_agent_runtime-0.4.0/tests/test_observation_aging.py +169 -0
  62. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/tests/test_orchestration.py +312 -0
  63. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/tests/test_otel.py +12 -5
  64. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/tests/test_provider_compat.py +54 -0
  65. steerable_agent_runtime-0.4.0/tests/test_reminders.py +136 -0
  66. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/tests/test_retry_hooks.py +13 -0
  67. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/tests/test_sandboxed.py +32 -2
  68. steerable_agent_runtime-0.4.0/tests/test_soft_timeout.py +1174 -0
  69. steerable_agent_runtime-0.4.0/tests/test_sqlite_storage.py +238 -0
  70. steerable_agent_runtime-0.4.0/tests/test_steer.py +281 -0
  71. steerable_agent_runtime-0.4.0/tests/test_storage_contract.py +135 -0
  72. steerable_agent_runtime-0.4.0/tests/test_system_proxy.py +211 -0
  73. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/tests/test_tool_exposure.py +188 -8
  74. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/tests/test_tool_timeout.py +73 -0
  75. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/tests/test_trace_recorder.py +133 -7
  76. steerable_agent_runtime-0.4.0/tests/test_write_lease.py +99 -0
  77. steerable_agent_runtime-0.3.0/src/steerable_agent_runtime/llm/compat.py +0 -130
  78. steerable_agent_runtime-0.3.0/src/steerable_agent_runtime/tool_search.py +0 -126
  79. steerable_agent_runtime-0.3.0/tests/test_soft_timeout.py +0 -165
  80. steerable_agent_runtime-0.3.0/tests/test_steer.py +0 -127
  81. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/README.md +0 -0
  82. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/setup.cfg +0 -0
  83. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/src/steerable_agent_runtime/antihallucination.py +0 -0
  84. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/src/steerable_agent_runtime/branch.py +0 -0
  85. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/src/steerable_agent_runtime/cache_control.py +0 -0
  86. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/src/steerable_agent_runtime/calibration.py +0 -0
  87. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/src/steerable_agent_runtime/llm/anthropic_native.py +0 -0
  88. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/src/steerable_agent_runtime/llm/parts.py +0 -0
  89. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/src/steerable_agent_runtime/mcp.py +0 -0
  90. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/src/steerable_agent_runtime/pricing.py +0 -0
  91. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/src/steerable_agent_runtime/pseudo.py +0 -0
  92. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/src/steerable_agent_runtime/recording.py +0 -0
  93. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/src/steerable_agent_runtime/replay.py +0 -0
  94. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/src/steerable_agent_runtime/skills.py +0 -0
  95. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/src/steerable_agent_runtime/spill.py +0 -0
  96. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/src/steerable_agent_runtime/subagent.py +0 -0
  97. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/src/steerable_agent_runtime/tool_schema.py +0 -0
  98. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/src/steerable_agent_runtime/tools.py +0 -0
  99. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/src/steerable_agent_runtime/transport/__init__.py +0 -0
  100. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/src/steerable_agent_runtime/transport/fastapi_sse.py +0 -0
  101. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/src/steerable_agent_runtime/transport/stdio_jsonrpc.py +0 -0
  102. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/src/steerable_agent_runtime/world_state.py +0 -0
  103. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/src/steerable_agent_runtime.egg-info/dependency_links.txt +0 -0
  104. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/src/steerable_agent_runtime.egg-info/top_level.txt +0 -0
  105. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/tests/test_antihallucination.py +0 -0
  106. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/tests/test_approval.py +0 -0
  107. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/tests/test_branch.py +0 -0
  108. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/tests/test_cache_control.py +0 -0
  109. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/tests/test_cache_instrumentation.py +0 -0
  110. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/tests/test_calibration.py +0 -0
  111. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/tests/test_content_parts.py +0 -0
  112. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/tests/test_golden.py +0 -0
  113. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/tests/test_history.py +0 -0
  114. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/tests/test_hooks.py +0 -0
  115. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/tests/test_in_memory_storage.py +0 -0
  116. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/tests/test_loop_cancellation.py +0 -0
  117. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/tests/test_loop_replay.py +0 -0
  118. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/tests/test_loop_sandbox_event.py +0 -0
  119. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/tests/test_mcp.py +0 -0
  120. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/tests/test_parallel_tools.py +0 -0
  121. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/tests/test_pseudo.py +0 -0
  122. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/tests/test_recording.py +0 -0
  123. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/tests/test_replay_crosslang.py +0 -0
  124. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/tests/test_resume.py +0 -0
  125. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/tests/test_safety_gate.py +0 -0
  126. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/tests/test_skills.py +0 -0
  127. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/tests/test_spill.py +0 -0
  128. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/tests/test_stream_strip.py +0 -0
  129. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/tests/test_subagent.py +0 -0
  130. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/tests/test_tokens.py +0 -0
  131. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/tests/test_tool_hygiene.py +0 -0
  132. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/tests/test_tool_router.py +0 -0
  133. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/tests/test_tool_schema.py +0 -0
  134. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/tests/test_transport_jsonrpc.py +0 -0
  135. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/tests/test_transport_sse.py +0 -0
  136. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/tests/test_usage_attribution.py +0 -0
  137. {steerable_agent_runtime-0.3.0 → steerable_agent_runtime-0.4.0}/tests/test_world_state.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: steerable-agent-runtime
3
- Version: 0.3.0
3
+ Version: 0.4.0
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
@@ -13,14 +13,14 @@ Provides-Extra: fastapi
13
13
  Requires-Dist: fastapi>=0.110; extra == "fastapi"
14
14
  Requires-Dist: starlette>=0.37; extra == "fastapi"
15
15
  Provides-Extra: openai
16
- Requires-Dist: httpx>=0.27; extra == "openai"
16
+ Requires-Dist: httpx[socks]>=0.27; extra == "openai"
17
17
  Provides-Extra: anthropic
18
18
  Requires-Dist: anthropic>=0.40; extra == "anthropic"
19
19
  Provides-Extra: all
20
20
  Requires-Dist: sqlalchemy>=2.0; extra == "all"
21
21
  Requires-Dist: fastapi>=0.110; extra == "all"
22
22
  Requires-Dist: starlette>=0.37; extra == "all"
23
- Requires-Dist: httpx>=0.27; extra == "all"
23
+ Requires-Dist: httpx[socks]>=0.27; extra == "all"
24
24
  Requires-Dist: anthropic>=0.40; extra == "all"
25
25
 
26
26
  # steerable-agent-runtime
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "steerable-agent-runtime"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  description = "Steerable agent runtime: LLM, tool, storage, and transport adapters."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -18,13 +18,17 @@ dependencies = [
18
18
  [project.optional-dependencies]
19
19
  sqlalchemy = ["sqlalchemy>=2.0"]
20
20
  fastapi = ["fastapi>=0.110", "starlette>=0.37"]
21
- openai = ["httpx>=0.27"]
21
+ # The `socks` extra is not optional in practice: httpx builds a transport for
22
+ # every proxy the environment declares while the client is constructed, so a
23
+ # host exporting `all_proxy=socks5://…` — the common shell proxy snippet — makes
24
+ # construction raise without it, failing provider calls that never reach a URL.
25
+ openai = ["httpx[socks]>=0.27"]
22
26
  anthropic = ["anthropic>=0.40"]
23
27
  all = [
24
28
  "sqlalchemy>=2.0",
25
29
  "fastapi>=0.110",
26
30
  "starlette>=0.37",
27
- "httpx>=0.27",
31
+ "httpx[socks]>=0.27",
28
32
  "anthropic>=0.40",
29
33
  ]
30
34
 
@@ -38,6 +42,12 @@ package-dir = {"" = "src"}
38
42
  [tool.setuptools.packages.find]
39
43
  where = ["src"]
40
44
 
45
+ # default.harness.yaml/.json ship inside the wheel: the sidecar resolves
46
+ # the spec relative to the installed package (Harbor trial containers
47
+ # pip-install the wheels, so a repo-relative path would not exist there).
48
+ [tool.setuptools.package-data]
49
+ steerable_agent_runtime = ["*.yaml", "*.json"]
50
+
41
51
  [tool.uv.sources]
42
52
  steerable-agent-protocol = { workspace = true }
43
53
  steerable-agent-harness = { workspace = true }
@@ -22,6 +22,13 @@ from .approval import (
22
22
  JsonApprovalStore,
23
23
  SessionApprovalCache,
24
24
  )
25
+ from .approval_policy import (
26
+ ApprovalPolicy,
27
+ ApprovalRule,
28
+ JsonApprovalPolicyStore,
29
+ PolicyApprover,
30
+ rule_from_amendment,
31
+ )
25
32
  from .branch import (
26
33
  BranchPoint,
27
34
  ForkResult,
@@ -59,10 +66,13 @@ from .history import (
59
66
  HistoryStore,
60
67
  RecordEntry,
61
68
  RecordFormatError,
69
+ SystemPromptFragment,
62
70
  entry_from_dict,
71
+ render_fragment_capped,
63
72
  entry_to_dict,
64
73
  message_from_dict,
65
74
  message_to_dict,
75
+ upgrade_entry_dict,
66
76
  )
67
77
  from .hooks import (
68
78
  ChainHooks,
@@ -150,7 +160,12 @@ from .replay import (
150
160
  build_step_decision_event,
151
161
  reduce_execution_state,
152
162
  )
153
- from .resume import load_history_transcript, load_transcript, project_transcript
163
+ from .resume import (
164
+ close_dangling_tool_calls,
165
+ load_history_transcript,
166
+ load_transcript,
167
+ project_transcript,
168
+ )
154
169
  from .retry import RetryHooks
155
170
  from .sandboxed import (
156
171
  DEFAULT_SHELL_TOOLS,
@@ -221,7 +236,9 @@ __all__ = [
221
236
  "ApprovalAborted",
222
237
  "ApprovalDecision",
223
238
  "ApprovalExecutor",
239
+ "ApprovalPolicy",
224
240
  "ApprovalRequest",
241
+ "ApprovalRule",
225
242
  "ApprovalStore",
226
243
  "Approver",
227
244
  "AutoApprover",
@@ -255,6 +272,7 @@ __all__ = [
255
272
  "InMemoryApprovalStore",
256
273
  "InMemoryRequestSink",
257
274
  "InMemorySpillStore",
275
+ "JsonApprovalPolicyStore",
258
276
  "JsonApprovalStore",
259
277
  "JsonlRequestSink",
260
278
  "LLMMessage",
@@ -277,6 +295,7 @@ __all__ = [
277
295
  "OrchestrationBudgetExceeded",
278
296
  "OrchestrationConfig",
279
297
  "OrchestrationExecutor",
298
+ "PolicyApprover",
280
299
  "PolicyDeniedError",
281
300
  "PreStepAction",
282
301
  "PrivacyMode",
@@ -296,6 +315,7 @@ __all__ = [
296
315
  "SessionApprovalCache",
297
316
  "SkillConfig",
298
317
  "SkillDefinition",
318
+ "SystemPromptFragment",
299
319
  "SkillExecutor",
300
320
  "SkillHooks",
301
321
  "SkillProvider",
@@ -329,6 +349,7 @@ __all__ = [
329
349
  "branch_label",
330
350
  "build_step_decision_event",
331
351
  "clamp_reasoning_effort",
352
+ "close_dangling_tool_calls",
332
353
  "detect_claimed_execution",
333
354
  "detect_deferred_execution",
334
355
  "detect_deferred_execution_eager",
@@ -367,9 +388,11 @@ __all__ = [
367
388
  "register_model_info",
368
389
  "register_model_price",
369
390
  "register_tool_search",
391
+ "render_fragment_capped",
370
392
  "render_skill_catalog",
371
393
  "resolve_fork_seq",
372
394
  "resolve_model_info",
395
+ "rule_from_amendment",
373
396
  "select_catalog",
374
397
  "select_skills",
375
398
  "should_run_grounding_judge",
@@ -381,6 +404,7 @@ __all__ = [
381
404
  "to_otlp_json",
382
405
  "tool",
383
406
  "tool_search_descriptor",
407
+ "upgrade_entry_dict",
384
408
  ]
385
409
 
386
410
  __version__ = "0.1.0"
@@ -28,6 +28,7 @@ import json
28
28
  import logging
29
29
  import os
30
30
  import tempfile
31
+ import time
31
32
  from dataclasses import dataclass
32
33
  from pathlib import Path
33
34
  from typing import TYPE_CHECKING, Any, Literal, Protocol, runtime_checkable
@@ -101,6 +102,11 @@ class ApprovalRequest:
101
102
  calls sharing a category share a cached decision. It defaults to the tool
102
103
  name; hosts with parameterized risk (e.g. a shell tool) can pass a
103
104
  resolver that categorizes by command class instead.
105
+
106
+ ``call_id`` is the originating tool call's id, for approver channels that
107
+ correlate the prompt with the tool call in the UI (ACP
108
+ ``session/request_permission`` requires it). Empty for synthesized
109
+ requests.
104
110
  """
105
111
 
106
112
  tool_name: str
@@ -108,6 +114,7 @@ class ApprovalRequest:
108
114
  mode: ToolMode
109
115
  category: str
110
116
  round_index: int = 0
117
+ call_id: str = ""
111
118
 
112
119
 
113
120
  @runtime_checkable
@@ -238,6 +245,7 @@ def _default_resolver(call: ToolCall, ctx: LoopContext) -> ApprovalRequest:
238
245
  mode=decide_tool_mode(call.name),
239
246
  category=call.name,
240
247
  round_index=ctx.round_index,
248
+ call_id=call.id,
241
249
  )
242
250
 
243
251
 
@@ -282,10 +290,28 @@ class ApprovalExecutor:
282
290
  async def execute(self, call: ToolCall, ctx: LoopContext) -> ToolResult:
283
291
  request = self._resolve(call, ctx)
284
292
  decision = self._stored_decision(request)
293
+ # W2.7.2: when the decision required asking (cache miss), record the
294
+ # wait on the result so the trace can show approval latency as its
295
+ # own span. Cache hits carry no marker — no wait happened.
296
+ waited_ms: int | None = None
285
297
  if decision is None:
298
+ ask_started = time.monotonic()
286
299
  decision = await self._ask(request)
300
+ waited_ms = int((time.monotonic() - ask_started) * 1000)
287
301
  self._persist(request, decision)
288
302
 
303
+ approval_marker = (
304
+ {
305
+ "_approval": {
306
+ "kind": decision.kind,
307
+ "category": request.category,
308
+ "waitMs": waited_ms,
309
+ }
310
+ }
311
+ if waited_ms is not None
312
+ else None
313
+ )
314
+
289
315
  if decision.kind == "abort":
290
316
  raise ApprovalAborted(
291
317
  decision.reason or f"tool call '{request.tool_name}' aborted by approval"
@@ -300,6 +326,7 @@ class ApprovalExecutor:
300
326
  "approval": decision.kind,
301
327
  "category": request.category,
302
328
  "reason": reason,
329
+ **(approval_marker or {}),
303
330
  "message": (
304
331
  f"Tool call '{request.tool_name}' was denied "
305
332
  f"({decision.kind}): {reason}"
@@ -307,7 +334,12 @@ class ApprovalExecutor:
307
334
  },
308
335
  )
309
336
  ctx.consent_granted = True
310
- return await self._inner.execute(call, ctx)
337
+ result = await self._inner.execute(call, ctx)
338
+ if approval_marker is not None:
339
+ result = result.model_copy(
340
+ update={"data": {**(result.data or {}), **approval_marker}}
341
+ )
342
+ return result
311
343
 
312
344
  def _stored_decision(self, request: ApprovalRequest) -> ApprovalDecision | None:
313
345
  """Durable wins over session: it is the stronger commitment."""
@@ -0,0 +1,243 @@
1
+ """Approval policy rules: pattern-matched auto-decisions orthogonal to the
2
+ 8-variant lattice (W2.4).
3
+
4
+ Reference: codex execpolicy — durable rules mapping command patterns to
5
+ allow/deny verdicts, consulted before the interactive prompt. The lattice's
6
+ category caches answer "this KIND of call, decided before"; policy rules
7
+ answer "calls matching this PATTERN, decided in advance" — a shell category
8
+ covers every command, while a rule can carve out ``git status`` precisely.
9
+
10
+ Resolution order inside ``ApprovalExecutor`` is unchanged (durable store →
11
+ session cache → approver); ``PolicyApprover`` sits at the approver seam, so
12
+ rules are consulted after the lattice's own caches and before the host
13
+ prompt. Rule hits return request-scoped variants (``allow_once`` /
14
+ ``deny_once``): the rule itself is the durable grant, so the lattice must
15
+ not double-record it into a category cache.
16
+
17
+ Amendments (W2.4.2): a host approval reply may carry an ``amendment``
18
+ payload — "allow, and keep allowing commands like this". The sidecar turns
19
+ it into a rule in the durable policy store; the user is not re-asked.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import logging
26
+ import os
27
+ import shlex
28
+ import tempfile
29
+ from dataclasses import dataclass, field
30
+ from pathlib import Path
31
+ from typing import Any, Literal
32
+
33
+ from .approval import ApprovalDecision, ApprovalRequest, Approver
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ __all__ = [
38
+ "ApprovalPolicy",
39
+ "ApprovalRule",
40
+ "JsonApprovalPolicyStore",
41
+ "PolicyApprover",
42
+ "rule_from_amendment",
43
+ ]
44
+
45
+ RuleDecision = Literal["allow", "deny"]
46
+ _RULE_DECISIONS = frozenset({"allow", "deny"})
47
+
48
+ #: Conventional arguments key holding the shell command string (mirrors the
49
+ #: sidecar's default ``commandArg``).
50
+ _DEFAULT_COMMAND_ARG = "command"
51
+
52
+
53
+ @dataclass(frozen=True, slots=True)
54
+ class ApprovalRule:
55
+ """One pattern → verdict rule.
56
+
57
+ ``tool`` is an exact tool name. ``command_prefix`` is an argv token
58
+ prefix matched against the call's command string (shlex-split); empty
59
+ means "every call of this tool". A rule with a prefix never matches a
60
+ call whose command is missing or unparseable — fail closed, the inner
61
+ approver decides instead.
62
+ """
63
+
64
+ tool: str
65
+ decision: RuleDecision
66
+ command_prefix: tuple[str, ...] = ()
67
+ command_arg: str = _DEFAULT_COMMAND_ARG
68
+
69
+ def __post_init__(self) -> None:
70
+ if not self.tool:
71
+ raise ValueError("rule tool must be non-empty")
72
+ if self.decision not in _RULE_DECISIONS:
73
+ raise ValueError(f"rule decision must be allow|deny, got {self.decision!r}")
74
+
75
+ def matches(self, request: ApprovalRequest) -> bool:
76
+ if request.tool_name != self.tool:
77
+ return False
78
+ if not self.command_prefix:
79
+ return True
80
+ raw = request.arguments.get(self.command_arg)
81
+ if not isinstance(raw, str) or not raw.strip():
82
+ return False
83
+ try:
84
+ argv = shlex.split(raw)
85
+ except ValueError: # unbalanced quotes etc. — no match, fail closed
86
+ return False
87
+ prefix = list(self.command_prefix)
88
+ return argv[: len(prefix)] == prefix
89
+
90
+ def to_dict(self) -> dict[str, Any]:
91
+ return {
92
+ "tool": self.tool,
93
+ "decision": self.decision,
94
+ "commandPrefix": list(self.command_prefix),
95
+ }
96
+
97
+ @classmethod
98
+ def from_dict(cls, data: dict[str, Any]) -> ApprovalRule:
99
+ tool = data.get("tool")
100
+ decision = data.get("decision")
101
+ prefix = data.get("commandPrefix") or []
102
+ if not isinstance(tool, str) or not isinstance(prefix, list):
103
+ raise ValueError(f"invalid rule entry: {data!r}")
104
+ return cls(
105
+ tool=tool,
106
+ decision=decision, # validated in __post_init__
107
+ command_prefix=tuple(str(token) for token in prefix),
108
+ )
109
+
110
+
111
+ @dataclass(slots=True)
112
+ class ApprovalPolicy:
113
+ """Ordered rule list; the first matching rule decides."""
114
+
115
+ rules: list[ApprovalRule] = field(default_factory=list)
116
+
117
+ def decide(self, request: ApprovalRequest) -> ApprovalDecision | None:
118
+ for rule in self.rules:
119
+ if rule.matches(request):
120
+ pattern = (
121
+ " ".join(rule.command_prefix) if rule.command_prefix else "*"
122
+ )
123
+ reason = f"policy rule: {rule.tool} {pattern} → {rule.decision}"
124
+ return ApprovalDecision(
125
+ "allow_once" if rule.decision == "allow" else "deny_once",
126
+ reason,
127
+ )
128
+ return None
129
+
130
+ def add(self, rule: ApprovalRule) -> None:
131
+ """Append a rule. An identical existing rule is a no-op (amendments
132
+ are idempotent — re-approving the same pattern must not grow the
133
+ file)."""
134
+ if rule not in self.rules:
135
+ self.rules.append(rule)
136
+
137
+
138
+ class JsonApprovalPolicyStore:
139
+ """File-backed durable policy: one JSON object ``{"version", "rules"}``.
140
+
141
+ Writes are atomic (tmp file + rename), mirroring ``JsonApprovalStore`` —
142
+ a crash mid-write must not corrupt the policy every future run loads.
143
+ """
144
+
145
+ def __init__(self, path: str | Path) -> None:
146
+ self._path = Path(path)
147
+
148
+ def load(self) -> ApprovalPolicy:
149
+ if not self._path.exists():
150
+ return ApprovalPolicy()
151
+ try:
152
+ data = json.loads(self._path.read_text(encoding="utf-8"))
153
+ rules = [ApprovalRule.from_dict(r) for r in data.get("rules") or []]
154
+ except (ValueError, KeyError, AttributeError) as exc:
155
+ # A corrupt policy fails closed: no rules, so every call falls
156
+ # through to the interactive approver — never an auto-allow.
157
+ logger.warning("ignoring unreadable approval policy %s: %s", self._path, exc)
158
+ return ApprovalPolicy()
159
+ return ApprovalPolicy(rules)
160
+
161
+ def save(self, policy: ApprovalPolicy) -> None:
162
+ payload = {
163
+ "version": 1,
164
+ "rules": [rule.to_dict() for rule in policy.rules],
165
+ }
166
+ self._path.parent.mkdir(parents=True, exist_ok=True)
167
+ fd, tmp = tempfile.mkstemp(
168
+ dir=str(self._path.parent), prefix=self._path.name, suffix=".tmp"
169
+ )
170
+ try:
171
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
172
+ json.dump(payload, handle, ensure_ascii=False, indent=2)
173
+ handle.write("\n")
174
+ os.replace(tmp, self._path)
175
+ except BaseException:
176
+ try:
177
+ os.unlink(tmp)
178
+ except OSError:
179
+ pass
180
+ raise
181
+
182
+ def add_rule(self, rule: ApprovalRule) -> None:
183
+ """Load → add → save. The policy is small (user-authored rules), so
184
+ read-modify-write per amendment is fine and keeps the file the single
185
+ source of truth across processes."""
186
+ policy = self.load()
187
+ policy.add(rule)
188
+ self.save(policy)
189
+
190
+
191
+ class PolicyApprover:
192
+ """``Approver`` decorator consulting the policy before the inner approver.
193
+
194
+ A rule hit short-circuits (no host round-trip); a miss delegates. The
195
+ inner approver is typically ``HostApprover`` (interactive) or
196
+ ``AutoApprover`` (headless) — the policy composes with either.
197
+ """
198
+
199
+ def __init__(self, inner: Approver, policy: ApprovalPolicy) -> None:
200
+ self._inner = inner
201
+ self._policy = policy
202
+
203
+ async def approve(self, request: ApprovalRequest) -> ApprovalDecision:
204
+ decision = self._policy.decide(request)
205
+ if decision is not None:
206
+ return decision
207
+ return await self._inner.approve(request)
208
+
209
+
210
+ def rule_from_amendment(
211
+ request: ApprovalRequest, amendment: Any
212
+ ) -> ApprovalRule | None:
213
+ """Decode a host reply's ``amendment`` payload into a rule (W2.4.2).
214
+
215
+ Wire shape::
216
+
217
+ {"decision": "allow" | "deny", "commandPrefix": ["git", "status"]?}
218
+
219
+ The rule's tool is the approved call's tool — an amendment can only
220
+ widen/narrow the pattern, never retarget another tool. Invalid payloads
221
+ return ``None`` (the decision itself still stands; only the persistence
222
+ is dropped), never raise into the approval path.
223
+ """
224
+ if not isinstance(amendment, dict):
225
+ return None
226
+ decision = amendment.get("decision")
227
+ if decision not in _RULE_DECISIONS:
228
+ logger.warning("ignoring approval amendment with bad decision: %r", amendment)
229
+ return None
230
+ prefix_raw = amendment.get("commandPrefix") or []
231
+ if not isinstance(prefix_raw, list) or not all(
232
+ isinstance(t, str) for t in prefix_raw
233
+ ):
234
+ logger.warning("ignoring approval amendment with bad commandPrefix: %r", amendment)
235
+ return None
236
+ try:
237
+ return ApprovalRule(
238
+ tool=request.tool_name,
239
+ decision=decision,
240
+ command_prefix=tuple(prefix_raw),
241
+ )
242
+ except ValueError:
243
+ return None
@@ -47,8 +47,15 @@ _FOLD_EXCERPT_CHARS = 160
47
47
  __all__ = ["CompactionHooks", "estimate_tokens"]
48
48
 
49
49
 
50
- def _fold_content(content: str | None) -> str:
51
- excerpt = " ".join((content or "").split())[:_FOLD_EXCERPT_CHARS]
50
+ def _fold_content(content: str | None, excerpt_chars: int = _FOLD_EXCERPT_CHARS) -> str:
51
+ text = content or ""
52
+ if len(text) <= excerpt_chars:
53
+ excerpt = text
54
+ else:
55
+ head = max(excerpt_chars // 5, 1)
56
+ tail = excerpt_chars - head
57
+ omitted = len(text) - excerpt_chars
58
+ excerpt = f"{text[:head]}\n...[{omitted} chars truncated]...\n{text[-tail:]}"
52
59
  if excerpt:
53
60
  return f"{_FOLDED_TOOL_MARKER} excerpt: {excerpt}"
54
61
  return _FOLDED_TOOL_MARKER
@@ -67,6 +74,7 @@ class CompactionHooks(NoopHooks):
67
74
  summarizer: LLMProvider | None = None,
68
75
  model: str | None = None,
69
76
  recompact_margin_ratio: float = 0.1,
77
+ fold_excerpt_chars: int = _FOLD_EXCERPT_CHARS,
70
78
  ) -> None:
71
79
  if not 0 < threshold_ratio <= 1:
72
80
  raise ValueError("threshold_ratio must be in (0, 1]")
@@ -76,6 +84,7 @@ class CompactionHooks(NoopHooks):
76
84
  self._threshold = threshold_ratio
77
85
  self._keep_last = keep_last_messages
78
86
  self._keep_last_tools = keep_last_tool_results
87
+ self._fold_excerpt_chars = fold_excerpt_chars
79
88
  self._summarizer = summarizer
80
89
  #: Model name used for calibrated token estimates (see tokens.py).
81
90
  self._model = model
@@ -219,7 +228,7 @@ class CompactionHooks(NoopHooks):
219
228
  return [
220
229
  LLMMessage.text_of(
221
230
  "tool",
222
- _fold_content(m.content_text),
231
+ _fold_content(m.content_text, self._fold_excerpt_chars),
223
232
  name=m.name,
224
233
  tool_call_id=m.tool_call_id,
225
234
  )
@@ -266,12 +275,23 @@ class CompactionHooks(NoopHooks):
266
275
  # discarded: never write it into the prompt cache (pi's
267
276
  # retention=none rule). The kwarg is consumed by
268
277
  # CacheControlProvider; providers without it ignore the key.
269
- message, _usage = await self._summarizer.complete(
270
- prompt, cache_retention="none"
271
- )
272
- return message.content_text
273
- # No summarizer configured: deterministic fallback keeps role + a short
274
- # excerpt per message so the thread of actions survives.
278
+ try:
279
+ message, _usage = await self._summarizer.complete(
280
+ prompt, cache_retention="none"
281
+ )
282
+ except Exception:
283
+ # Transport/protocol errors here run outside the stream
284
+ # retry loop. Falling back keeps wrap-up running so Harbor
285
+ # still scores files instead of a NonZeroAgentExit.
286
+ return self._excerpt_summary(middle)
287
+ text = (message.content_text or "").strip()
288
+ if text:
289
+ return text
290
+ return self._excerpt_summary(middle)
291
+
292
+ def _excerpt_summary(self, middle: list[LLMMessage]) -> str:
293
+ # No summarizer, empty model reply, or a failed complete(): keep
294
+ # role + a short excerpt per message so the thread of actions survives.
275
295
  lines = []
276
296
  for m in middle:
277
297
  excerpt = m.content_text.replace("\n", " ")[:200]
@@ -0,0 +1,30 @@
1
+ {
2
+ "context": [
3
+ {
4
+ "impl": "pressure_compaction"
5
+ },
6
+ {
7
+ "impl": "spill"
8
+ }
9
+ ],
10
+ "retry": [
11
+ {
12
+ "impl": "informed_backtrack"
13
+ },
14
+ {
15
+ "impl": "simple",
16
+ "params": {
17
+ "max_attempts": 3
18
+ }
19
+ }
20
+ ],
21
+ "validator": "null",
22
+ "tools": "full",
23
+ "memory": "stateless",
24
+ "orchestration": "single",
25
+ "loop": {
26
+ "max_rounds": 80,
27
+ "max_tool_errors": 16,
28
+ "tool_dedup": false
29
+ }
30
+ }
@@ -0,0 +1,28 @@
1
+ # The framework's default harness (W1.2.4): a declaration of what the
2
+ # sidecar chat path has always assembled by hand —
3
+ # ChainHooks(CompactionHooks, SpillHooks, RetryHooks) + InMemoryStorage
4
+ # + full tool surface + no delegation.
5
+ # `max_context_tokens` is intentionally absent: the host resolves it from
6
+ # the model catalog at assembly time (runtime_params), per-model.
7
+ context:
8
+ - impl: pressure_compaction
9
+ - impl: spill
10
+ retry:
11
+ # Order matters: overflow backtrack first (it declines non-overflow
12
+ # errors), then taxonomy-routed backoff. Mirrors ChainHooks ordering.
13
+ - impl: informed_backtrack
14
+ # max_attempts 3 pins the production default (RetryHooks()); the strategy
15
+ # default of 4 would silently diverge the spec from the sidecar chat path.
16
+ - impl: simple
17
+ params: {max_attempts: 3}
18
+ # Quoted: bare `null` is YAML's null literal, not the implementation name.
19
+ validator: "null"
20
+ tools: full
21
+ memory: stateless
22
+ orchestration: single
23
+ # Loop limits the harness pins (W3.4.2.4): entrypoints read these instead of
24
+ # hardcoding knobs. Absent fields fall back to the entrypoint's baseline.
25
+ loop:
26
+ max_rounds: 80
27
+ max_tool_errors: 16
28
+ tool_dedup: false
@@ -18,6 +18,17 @@ class StorageError(RuntimeError):
18
18
  """Persistence layer failure."""
19
19
 
20
20
 
21
+ class StoreAlreadyOwnedError(StorageError):
22
+ """Another process already holds the write lease for this database."""
23
+
24
+ def __init__(self, path: str) -> None:
25
+ super().__init__(
26
+ f"store already owned: {path} "
27
+ "(another process has this sqlite database open for write)"
28
+ )
29
+ self.path = path
30
+
31
+
21
32
  class ToolDispatchError(RuntimeError):
22
33
  """Tool router could not satisfy a ToolCall."""
23
34