steerable-agent-runtime 0.6.73__tar.gz → 0.7.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.
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/PKG-INFO +1 -1
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/pyproject.toml +1 -1
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/approval.py +183 -35
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/harness.py +9 -3
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/native_bridge.py +3 -1
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/pseudo.py +97 -5
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/storage/sqlite_store.py +39 -17
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime.egg-info/PKG-INFO +1 -1
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_approval.py +343 -1
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_harness.py +28 -2
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_stream_strip.py +65 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_write_lease.py +30 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/README.md +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/setup.cfg +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/__init__.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/antihallucination.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/approval_policy.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/ask_user.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/branch.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/cache_control.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/calibration.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/compaction.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/compaction_policy.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/config.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/default.harness.json +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/default.harness.yaml +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/delegation_gate.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/errors.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/gateway_catalog.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/handoff.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/harness_spec.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/history.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/hooks.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/llm/__init__.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/llm/anthropic_native.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/llm/compat.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/llm/errors.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/llm/google_genai.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/llm/openai_compat.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/llm/openai_responses.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/llm/parts.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/llm/presets.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/llm/system_proxy.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/loop.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/maintenance.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/mcp.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/mcp_server.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/model_catalog.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/model_info.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/model_resolve.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/observation_aging.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/orchestration.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/otel.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/plugins.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/pool.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/pricing.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/recording.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/reminders.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/replay.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/resume.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/retry.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/sandboxed.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/skills.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/spill.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/storage/__init__.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/storage/in_memory.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/storage/sqlalchemy_store.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/storage/write_lease.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/subagent.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/todo.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/tokens.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/tool_presentation.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/tool_schema.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/tool_search.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/tools.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/tracing.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/transport/__init__.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/transport/fastapi_sse.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/transport/stdio_jsonrpc.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime/world_state.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime.egg-info/SOURCES.txt +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime.egg-info/dependency_links.txt +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime.egg-info/requires.txt +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/src/steerable_agent_runtime.egg-info/top_level.txt +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_antihallucination.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_approval_policy.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_ask_user.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_branch.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_cache_control.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_cache_instrumentation.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_calibration.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_compaction.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_compaction_policy.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_compaction_strategy.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_config.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_content_parts.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_delegation_gate.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_error_taxonomy.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_fragment_bounds.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_gateway_catalog.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_golden.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_handoff.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_harness_spec.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_history.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_history_persistence.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_hooks.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_in_memory_storage.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_llm_gemini_wire.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_llm_responses_wire.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_llm_stream_e2e.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_llm_wire_helpers.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_long_session.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_loop.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_loop_cancellation.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_loop_replay.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_loop_sandbox_event.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_loop_terminal.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_maintenance.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_mcp.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_mcp_server.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_model_catalog.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_model_equivalence.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_model_info.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_model_resolve.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_native_default.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_observation_aging.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_orchestration.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_otel.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_parallel_tools.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_plugins.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_provider_compat.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_provider_presets.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_pseudo.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_pyo3_api_surface.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_recording.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_reminders.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_replay_crosslang.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_resume.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_retry_hooks.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_safety_gate.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_sandboxed.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_skills.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_soft_timeout.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_spill.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_sqlite_storage.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_steer.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_storage_contract.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_subagent.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_system_proxy.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_todo.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_todo_gate.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_tokens.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_tool_exposure.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_tool_hygiene.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_tool_presentation.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_tool_router.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_tool_schema.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_tool_timeout.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_trace_recorder.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_transport_jsonrpc.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_transport_sse.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_usage_attribution.py +0 -0
- {steerable_agent_runtime-0.6.73 → steerable_agent_runtime-0.7.0}/tests/test_world_state.py +0 -0
|
@@ -32,7 +32,7 @@ import tempfile
|
|
|
32
32
|
import time
|
|
33
33
|
from dataclasses import dataclass
|
|
34
34
|
from pathlib import Path
|
|
35
|
-
from typing import TYPE_CHECKING, Any, Literal, Protocol, runtime_checkable
|
|
35
|
+
from typing import TYPE_CHECKING, Any, Literal, Protocol, cast, runtime_checkable
|
|
36
36
|
|
|
37
37
|
from steerable_agent_harness.policy import ToolMode, decide_tool_mode
|
|
38
38
|
from steerable_agent_protocol.generated import ToolCall, ToolResult
|
|
@@ -55,6 +55,7 @@ __all__ = [
|
|
|
55
55
|
"Approver",
|
|
56
56
|
"AutoApprover",
|
|
57
57
|
"WorkspaceAutoApprover",
|
|
58
|
+
"declared_resolver",
|
|
58
59
|
"InMemoryApprovalStore",
|
|
59
60
|
"JsonApprovalStore",
|
|
60
61
|
"SessionApprovalCache",
|
|
@@ -140,6 +141,33 @@ _PATH_ARG_TOOLS = {
|
|
|
140
141
|
}
|
|
141
142
|
|
|
142
143
|
|
|
144
|
+
def _front_load_markers(
|
|
145
|
+
data: dict[str, Any] | None, approval_marker: dict[str, Any] | None
|
|
146
|
+
) -> dict[str, Any]:
|
|
147
|
+
"""Put sandbox and approval markers ahead of the tool payload.
|
|
148
|
+
|
|
149
|
+
A published loop keeps only the first 300 characters of the result
|
|
150
|
+
preview and, for a sibling that finished before a terminal tool in
|
|
151
|
+
the same batch, does not copy those markers onto the event. They
|
|
152
|
+
have to be readable from that prefix.
|
|
153
|
+
"""
|
|
154
|
+
body = dict(data or {})
|
|
155
|
+
sandbox = body.pop("_sandbox", None)
|
|
156
|
+
approval = (
|
|
157
|
+
approval_marker.get("_approval")
|
|
158
|
+
if approval_marker is not None
|
|
159
|
+
else body.get("_approval")
|
|
160
|
+
)
|
|
161
|
+
body.pop("_approval", None)
|
|
162
|
+
ordered: dict[str, Any] = {}
|
|
163
|
+
if isinstance(sandbox, dict):
|
|
164
|
+
ordered["_sandbox"] = sandbox
|
|
165
|
+
if isinstance(approval, dict):
|
|
166
|
+
ordered["_approval"] = approval
|
|
167
|
+
ordered.update(body)
|
|
168
|
+
return ordered
|
|
169
|
+
|
|
170
|
+
|
|
143
171
|
def declared_target_paths(request: ApprovalRequest) -> list[str] | None:
|
|
144
172
|
"""Filesystem targets of a path-scoped tool, or None when the call is not one.
|
|
145
173
|
|
|
@@ -169,31 +197,53 @@ def declared_target_paths(request: ApprovalRequest) -> list[str] | None:
|
|
|
169
197
|
return [raw.strip()]
|
|
170
198
|
|
|
171
199
|
|
|
172
|
-
def path_inside_writable_roots(
|
|
200
|
+
def path_inside_writable_roots(
|
|
201
|
+
raw: str,
|
|
202
|
+
roots: list[str],
|
|
203
|
+
*,
|
|
204
|
+
pathmod: Any | None = None,
|
|
205
|
+
base: str | None = None,
|
|
206
|
+
) -> bool:
|
|
173
207
|
"""True when ``raw`` is the configured directory or one of its subdirectories.
|
|
174
208
|
|
|
175
|
-
Relative paths are project-relative.
|
|
176
|
-
|
|
177
|
-
|
|
209
|
+
Relative paths are project-relative. When ``base`` is the project home,
|
|
210
|
+
they are joined to that directory and then compared like absolute paths,
|
|
211
|
+
so a symlink inside the project that points outside is not inside.
|
|
212
|
+
Without ``base``, a relative path is inside unless it starts with ``..``.
|
|
213
|
+
Absolute paths must sit under one of ``roots`` after symbolic links are
|
|
214
|
+
resolved, so ``/tmp`` and ``/private/tmp`` are the same directory.
|
|
215
|
+
Windows compares paths without letter case, so
|
|
216
|
+
``c:\\work\\repo\\a.txt`` is inside ``C:\\Work\\Repo``. ``pathmod`` selects
|
|
217
|
+
the path implementation; production uses ``os.path``.
|
|
178
218
|
"""
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
219
|
+
paths = os.path if pathmod is None else pathmod
|
|
220
|
+
expanded = paths.expanduser(raw.strip())
|
|
221
|
+
if not paths.isabs(expanded):
|
|
222
|
+
if isinstance(base, str) and base.strip():
|
|
223
|
+
expanded = paths.normpath(paths.join(paths.expanduser(base.strip()), expanded))
|
|
224
|
+
else:
|
|
225
|
+
normalized = paths.normpath(expanded)
|
|
226
|
+
return normalized != ".." and not normalized.startswith(".." + paths.sep)
|
|
227
|
+
target = paths.realpath(expanded)
|
|
184
228
|
for root in roots:
|
|
185
229
|
if not isinstance(root, str) or not root.strip():
|
|
186
230
|
continue
|
|
187
|
-
root_abs =
|
|
231
|
+
root_abs = paths.realpath(paths.expanduser(root.strip()))
|
|
188
232
|
try:
|
|
189
|
-
|
|
190
|
-
return True
|
|
233
|
+
common = paths.commonpath([target, root_abs])
|
|
191
234
|
except ValueError:
|
|
192
235
|
continue
|
|
236
|
+
if paths.normcase(common) == paths.normcase(root_abs):
|
|
237
|
+
return True
|
|
193
238
|
return False
|
|
194
239
|
|
|
195
240
|
|
|
196
|
-
def shell_stays_in_workspace(
|
|
241
|
+
def shell_stays_in_workspace(
|
|
242
|
+
request: ApprovalRequest,
|
|
243
|
+
roots: list[str],
|
|
244
|
+
*,
|
|
245
|
+
base: str | None = None,
|
|
246
|
+
) -> bool:
|
|
197
247
|
"""True when a shell call's working directory is inside a writable root.
|
|
198
248
|
|
|
199
249
|
The command text is not parsed. Confinement is the OS sandbox's job:
|
|
@@ -208,7 +258,7 @@ def shell_stays_in_workspace(request: ApprovalRequest, roots: list[str]) -> bool
|
|
|
208
258
|
return True
|
|
209
259
|
if not isinstance(cwd, str) or not cwd.strip():
|
|
210
260
|
return True
|
|
211
|
-
return path_inside_writable_roots(cwd, roots)
|
|
261
|
+
return path_inside_writable_roots(cwd, roots, base=base)
|
|
212
262
|
|
|
213
263
|
|
|
214
264
|
class WorkspaceAutoApprover:
|
|
@@ -216,13 +266,13 @@ class WorkspaceAutoApprover:
|
|
|
216
266
|
|
|
217
267
|
Embedders may also name host-owned control tools whose effects are limited
|
|
218
268
|
to internal session state and therefore need no interactive consent.
|
|
219
|
-
|
|
220
|
-
``present_files``)
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
request-scoped. The next call is judged
|
|
225
|
-
through to the inner approver.
|
|
269
|
+
A ``read`` mode call with no filesystem target skips the prompt. Path-scoped
|
|
270
|
+
tools (``local_write_file``, ``local_edit_file``, reads, ``present_files``)
|
|
271
|
+
still skip only when every declared path is inside a writable root — a
|
|
272
|
+
``read`` declaration does not open a path outside those roots. Shell tools
|
|
273
|
+
skip only when the OS exec sandbox is on and the working directory is
|
|
274
|
+
inside those roots. The allow is request-scoped. The next call is judged
|
|
275
|
+
again. Anything else falls through to the inner approver.
|
|
226
276
|
"""
|
|
227
277
|
|
|
228
278
|
def __init__(
|
|
@@ -232,11 +282,13 @@ class WorkspaceAutoApprover:
|
|
|
232
282
|
*,
|
|
233
283
|
sandbox_enforced: bool = False,
|
|
234
284
|
auto_allow_tools: tuple[str, ...] = (),
|
|
285
|
+
path_base: str | None = None,
|
|
235
286
|
) -> None:
|
|
236
287
|
self._inner = inner
|
|
237
288
|
self._roots = [root for root in writable_roots if isinstance(root, str) and root.strip()]
|
|
238
289
|
self._sandbox_enforced = sandbox_enforced
|
|
239
290
|
self._auto_allow_tools = frozenset(auto_allow_tools)
|
|
291
|
+
self._path_base = path_base.strip() if isinstance(path_base, str) and path_base.strip() else None
|
|
240
292
|
|
|
241
293
|
def set_sandbox_enforced(self, enforced: bool) -> None:
|
|
242
294
|
"""Match a mid-turn sandbox toggle.
|
|
@@ -254,10 +306,17 @@ class WorkspaceAutoApprover:
|
|
|
254
306
|
if (
|
|
255
307
|
self._roots
|
|
256
308
|
and paths is not None
|
|
257
|
-
and all(
|
|
309
|
+
and all(
|
|
310
|
+
path_inside_writable_roots(path, self._roots, base=self._path_base)
|
|
311
|
+
for path in paths
|
|
312
|
+
)
|
|
258
313
|
):
|
|
259
314
|
return ApprovalDecision("allow_once", "path is inside a writable project root")
|
|
260
|
-
if
|
|
315
|
+
if request.mode == "read" and paths is None:
|
|
316
|
+
return ApprovalDecision("allow_once", "declared read-only tool")
|
|
317
|
+
if self._sandbox_enforced and shell_stays_in_workspace(
|
|
318
|
+
request, self._roots, base=self._path_base
|
|
319
|
+
):
|
|
261
320
|
return ApprovalDecision("allow_once", "command stays inside the workspace sandbox")
|
|
262
321
|
return await self._inner.approve(request)
|
|
263
322
|
|
|
@@ -382,6 +441,86 @@ def _default_resolver(call: ToolCall, ctx: LoopContext) -> ApprovalRequest:
|
|
|
382
441
|
)
|
|
383
442
|
|
|
384
443
|
|
|
444
|
+
#: Modes a host may declare on a tool. A declared value in this set is kept.
|
|
445
|
+
#: Missing or unknown values fall back to ``decide_tool_mode``. Keeping a
|
|
446
|
+
#: declared ``external`` stops a ``get_`` prefix from widening the call to read.
|
|
447
|
+
TOOL_MODES = frozenset(
|
|
448
|
+
{
|
|
449
|
+
"read",
|
|
450
|
+
"safe_write",
|
|
451
|
+
"destructive",
|
|
452
|
+
"other",
|
|
453
|
+
"external",
|
|
454
|
+
"local",
|
|
455
|
+
"ui",
|
|
456
|
+
"synthetic",
|
|
457
|
+
"auto_local",
|
|
458
|
+
}
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _tool_declared_name(tool: dict[str, Any]) -> str | None:
|
|
463
|
+
name = tool.get("name")
|
|
464
|
+
if isinstance(name, str) and name:
|
|
465
|
+
return name
|
|
466
|
+
function = tool.get("function")
|
|
467
|
+
if isinstance(function, dict):
|
|
468
|
+
nested = function.get("name")
|
|
469
|
+
if isinstance(nested, str) and nested:
|
|
470
|
+
return nested
|
|
471
|
+
return None
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _tool_declared_field(tool: dict[str, Any], key: str) -> Any:
|
|
475
|
+
if key in tool:
|
|
476
|
+
return tool.get(key)
|
|
477
|
+
function = tool.get("function")
|
|
478
|
+
if isinstance(function, dict) and key in function:
|
|
479
|
+
return function.get(key)
|
|
480
|
+
return None
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def declared_resolver(
|
|
484
|
+
tools: list[Any] | None,
|
|
485
|
+
) -> Any:
|
|
486
|
+
"""Build an approval resolver from ``agent.chat.stream`` tools.
|
|
487
|
+
|
|
488
|
+
Each tool may carry optional ``mode`` and ``approvalCategory``, either on
|
|
489
|
+
the tool object or under ``function``. Tools without those fields, and
|
|
490
|
+
calls whose name is absent from the list, use name inference and the tool
|
|
491
|
+
name as the category. Old hosts that omit the fields keep the previous
|
|
492
|
+
behavior.
|
|
493
|
+
"""
|
|
494
|
+
declared: dict[str, tuple[Any, Any]] = {}
|
|
495
|
+
for tool in tools or []:
|
|
496
|
+
if not isinstance(tool, dict):
|
|
497
|
+
continue
|
|
498
|
+
name = _tool_declared_name(tool)
|
|
499
|
+
if name is None:
|
|
500
|
+
continue
|
|
501
|
+
declared[name] = (
|
|
502
|
+
_tool_declared_field(tool, "mode"),
|
|
503
|
+
_tool_declared_field(tool, "approvalCategory"),
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
def resolve(call: ToolCall, ctx: LoopContext) -> ApprovalRequest:
|
|
507
|
+
mode, category = declared.get(call.name, (None, None))
|
|
508
|
+
resolved_mode = (
|
|
509
|
+
mode if isinstance(mode, str) and mode in TOOL_MODES else decide_tool_mode(call.name)
|
|
510
|
+
)
|
|
511
|
+
resolved_category = category if isinstance(category, str) and category else call.name
|
|
512
|
+
return ApprovalRequest(
|
|
513
|
+
tool_name=call.name,
|
|
514
|
+
arguments=call.arguments or {},
|
|
515
|
+
mode=cast(ToolMode, resolved_mode),
|
|
516
|
+
category=resolved_category,
|
|
517
|
+
round_index=ctx.round_index,
|
|
518
|
+
call_id=call.id,
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
return resolve
|
|
522
|
+
|
|
523
|
+
|
|
385
524
|
class ToolTimeoutClock:
|
|
386
525
|
"""Pauses the loop's tool cap while an approval prompt is unanswered.
|
|
387
526
|
|
|
@@ -457,10 +596,11 @@ class ApprovalExecutor:
|
|
|
457
596
|
|
|
458
597
|
async def execute(self, call: ToolCall, ctx: LoopContext) -> ToolResult:
|
|
459
598
|
request = self._resolve(call, ctx)
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
#
|
|
463
|
-
#
|
|
599
|
+
stored = self._stored_decision(request)
|
|
600
|
+
decision = stored
|
|
601
|
+
# A fresh decision records the wait so the trace can show approval
|
|
602
|
+
# latency. A session allow reused from the cache carries `cached`
|
|
603
|
+
# instead, so the tool row can say the call was already allowed.
|
|
464
604
|
waited_ms: int | None = None
|
|
465
605
|
if decision is None:
|
|
466
606
|
ask_started = time.monotonic()
|
|
@@ -468,17 +608,24 @@ class ApprovalExecutor:
|
|
|
468
608
|
waited_ms = int((time.monotonic() - ask_started) * 1000)
|
|
469
609
|
self._persist(request, decision)
|
|
470
610
|
|
|
471
|
-
|
|
472
|
-
{
|
|
611
|
+
if stored is not None and stored.kind == "allow_for_session":
|
|
612
|
+
approval_marker = {
|
|
613
|
+
"_approval": {
|
|
614
|
+
"kind": "allow_for_session",
|
|
615
|
+
"category": request.category,
|
|
616
|
+
"cached": True,
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
elif waited_ms is not None:
|
|
620
|
+
approval_marker = {
|
|
473
621
|
"_approval": {
|
|
474
622
|
"kind": decision.kind,
|
|
475
623
|
"category": request.category,
|
|
476
624
|
"waitMs": waited_ms,
|
|
477
625
|
}
|
|
478
626
|
}
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
)
|
|
627
|
+
else:
|
|
628
|
+
approval_marker = None
|
|
482
629
|
|
|
483
630
|
if decision.kind == "abort":
|
|
484
631
|
raise ApprovalAborted(
|
|
@@ -503,9 +650,10 @@ class ApprovalExecutor:
|
|
|
503
650
|
)
|
|
504
651
|
ctx.consent_granted = True
|
|
505
652
|
result = await self._inner.execute(call, ctx)
|
|
506
|
-
if
|
|
653
|
+
data = result.data if isinstance(result.data, dict) else None
|
|
654
|
+
if approval_marker is not None or (data is not None and "_sandbox" in data):
|
|
507
655
|
result = result.model_copy(
|
|
508
|
-
update={"data":
|
|
656
|
+
update={"data": _front_load_markers(data, approval_marker)}
|
|
509
657
|
)
|
|
510
658
|
return result
|
|
511
659
|
|
|
@@ -20,6 +20,7 @@ from __future__ import annotations
|
|
|
20
20
|
|
|
21
21
|
from dataclasses import dataclass, field
|
|
22
22
|
from pathlib import Path
|
|
23
|
+
from collections.abc import Callable
|
|
23
24
|
from typing import Any, Protocol, runtime_checkable
|
|
24
25
|
|
|
25
26
|
from .compaction import CompactionHooks
|
|
@@ -719,10 +720,12 @@ class DiscoveredNotesState:
|
|
|
719
720
|
|
|
720
721
|
Unlike ``FilesystemState`` (one explicit path the model maintains), this
|
|
721
722
|
is read-only discovery: the notes are project/user/managed-owned context
|
|
722
|
-
the session reads, not a working file it edits.
|
|
723
|
+
the session reads, not a working file it edits. ``trusted`` gates the
|
|
724
|
+
``project`` and ``local`` levels; ``managed`` and ``user`` are always read.
|
|
723
725
|
"""
|
|
724
726
|
|
|
725
727
|
cwd: Path | str
|
|
728
|
+
trusted: Callable[[Path], bool]
|
|
726
729
|
max_chars: int = 8_000
|
|
727
730
|
name: str = "discovered-notes"
|
|
728
731
|
assumes: str = (
|
|
@@ -737,15 +740,16 @@ class DiscoveredNotesState:
|
|
|
737
740
|
return InMemoryStorage()
|
|
738
741
|
|
|
739
742
|
def hooks(self, *, provider: LLMProvider | None = None) -> LoopHooks:
|
|
740
|
-
return _DiscoveredNotesInjection(Path(self.cwd), self.max_chars)
|
|
743
|
+
return _DiscoveredNotesInjection(Path(self.cwd), self.max_chars, self.trusted)
|
|
741
744
|
|
|
742
745
|
|
|
743
746
|
class _DiscoveredNotesInjection(NoopHooks):
|
|
744
747
|
"""pre_step slice: discover and append the notes union once per turn."""
|
|
745
748
|
|
|
746
|
-
def __init__(self, cwd: Path, max_chars: int) -> None:
|
|
749
|
+
def __init__(self, cwd: Path, max_chars: int, trusted: Callable[[Path], bool]) -> None:
|
|
747
750
|
self._cwd = cwd
|
|
748
751
|
self._max_chars = max_chars
|
|
752
|
+
self._trusted = trusted
|
|
749
753
|
|
|
750
754
|
async def pre_step(self, transcript: Any, ctx: Any) -> Any:
|
|
751
755
|
from .hooks import PreStepAction, TranscriptAppend
|
|
@@ -755,6 +759,8 @@ class _DiscoveredNotesInjection(NoopHooks):
|
|
|
755
759
|
sections: list[tuple[str, Path, str]] = []
|
|
756
760
|
budget = self._max_chars
|
|
757
761
|
for level, path in discover_notes_files(self._cwd):
|
|
762
|
+
if level in ("project", "local") and not self._trusted(path.parent):
|
|
763
|
+
continue
|
|
758
764
|
if budget <= 0:
|
|
759
765
|
break
|
|
760
766
|
try:
|
|
@@ -33,6 +33,7 @@ from steerable_agent_runtime.loop import (
|
|
|
33
33
|
from steerable_agent_runtime.pseudo import (
|
|
34
34
|
PseudoStreamStripper,
|
|
35
35
|
extract_inline_tool_calls,
|
|
36
|
+
reply_text_from_call,
|
|
36
37
|
split_trailing_high_surrogate,
|
|
37
38
|
)
|
|
38
39
|
from steerable_agent_runtime.replay import build_step_decision_event
|
|
@@ -316,7 +317,8 @@ async def _collect_stream(
|
|
|
316
317
|
send("chunk", chunk={"finish_reason": cut_finish_reason})
|
|
317
318
|
elif not tool_seen:
|
|
318
319
|
recovered, _ = extract_inline_tool_calls("".join(raw_content))
|
|
319
|
-
|
|
320
|
+
actionable = [call for call in recovered if reply_text_from_call(call) is None]
|
|
321
|
+
for index, call in enumerate(actionable):
|
|
320
322
|
send(
|
|
321
323
|
"chunk",
|
|
322
324
|
chunk={
|
|
@@ -8,7 +8,8 @@ regress into prose) emit tool *intent* as text instead of a structured
|
|
|
8
8
|
- DeepSeek XML: ``<function=tool><parameter=k>v</parameter></function>``
|
|
9
9
|
- Markdown pseudo: ``[Tool call: NAME]\\n{"k": "v"}``
|
|
10
10
|
- JSON tool object: a fenced or ``<tool_call>`` ``{"name", "arguments"}``
|
|
11
|
-
object (Qwen / Ollama), or a message that is only that object
|
|
11
|
+
object (Qwen / Ollama), or a message that is only that object.
|
|
12
|
+
``{"name": "text", "arguments": {"value": "..."}}`` is a reply, not a tool.
|
|
12
13
|
|
|
13
14
|
If the loop treated that text as a final answer it would end the turn having
|
|
14
15
|
never run the tool — the model "meant" to act but the loop stopped. This module
|
|
@@ -202,6 +203,31 @@ def _coerce_arguments(raw: Any) -> dict[str, Any] | None:
|
|
|
202
203
|
return None
|
|
203
204
|
|
|
204
205
|
|
|
206
|
+
_REPLY_TOOL_NAMES = frozenset({"text"})
|
|
207
|
+
_REPLY_ARG_KEYS = ("value", "content", "text", "message")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def reply_text_from_call(call: dict[str, Any]) -> str | None:
|
|
211
|
+
"""Return the visible reply when a model wrapped plain text as a tool.
|
|
212
|
+
|
|
213
|
+
Some local models emit ``{"name": "text", "arguments": {"value": "..."}}``
|
|
214
|
+
instead of a message. ``None`` means the call is a real tool.
|
|
215
|
+
|
|
216
|
+
@param call: one ``{"name", "arguments"}`` object from inline recovery.
|
|
217
|
+
"""
|
|
218
|
+
name = call.get("name")
|
|
219
|
+
if not isinstance(name, str) or name.strip().lower() not in _REPLY_TOOL_NAMES:
|
|
220
|
+
return None
|
|
221
|
+
arguments = call.get("arguments") or {}
|
|
222
|
+
if not isinstance(arguments, dict):
|
|
223
|
+
return ""
|
|
224
|
+
for key in _REPLY_ARG_KEYS:
|
|
225
|
+
value = arguments.get(key)
|
|
226
|
+
if isinstance(value, str):
|
|
227
|
+
return value
|
|
228
|
+
return ""
|
|
229
|
+
|
|
230
|
+
|
|
205
231
|
def _calls_from_payload(payload: Any) -> list[dict[str, Any]] | None:
|
|
206
232
|
"""Accept one tool-call object or a list of them. Anything else is text."""
|
|
207
233
|
items = payload if isinstance(payload, list) else [payload]
|
|
@@ -600,8 +626,72 @@ class _JsonFenceFilter:
|
|
|
600
626
|
return tail
|
|
601
627
|
|
|
602
628
|
|
|
629
|
+
class _BareToolJsonFilter:
|
|
630
|
+
"""Hold a message that is only a tool-call JSON object.
|
|
631
|
+
|
|
632
|
+
A real tool object is dropped so recovery can execute it. A ``text``
|
|
633
|
+
reply object is replaced by its value. Any other leading ``{`` or ``[``
|
|
634
|
+
is released once it is clearly not that object.
|
|
635
|
+
"""
|
|
636
|
+
|
|
637
|
+
def __init__(self) -> None:
|
|
638
|
+
self._buf = ""
|
|
639
|
+
self._passthrough = False
|
|
640
|
+
|
|
641
|
+
def feed(self, chunk: str) -> str:
|
|
642
|
+
if self._passthrough:
|
|
643
|
+
return chunk or ""
|
|
644
|
+
self._buf += chunk or ""
|
|
645
|
+
return self._decide(final=False)
|
|
646
|
+
|
|
647
|
+
def flush(self) -> str:
|
|
648
|
+
if self._passthrough:
|
|
649
|
+
return ""
|
|
650
|
+
return self._decide(final=True)
|
|
651
|
+
|
|
652
|
+
def _release(self, text: str) -> str:
|
|
653
|
+
self._passthrough = True
|
|
654
|
+
self._buf = ""
|
|
655
|
+
return text
|
|
656
|
+
|
|
657
|
+
def _decide(self, final: bool) -> str:
|
|
658
|
+
text = self._buf
|
|
659
|
+
stripped = text.lstrip()
|
|
660
|
+
if not stripped:
|
|
661
|
+
if final:
|
|
662
|
+
return self._release(text)
|
|
663
|
+
return ""
|
|
664
|
+
if stripped[0] not in "{[":
|
|
665
|
+
return self._release(text)
|
|
666
|
+
if len(text) > _FENCE_HOLD_CAP and not final:
|
|
667
|
+
return self._release(text)
|
|
668
|
+
try:
|
|
669
|
+
payload, end = json.JSONDecoder().raw_decode(stripped)
|
|
670
|
+
except json.JSONDecodeError as exc:
|
|
671
|
+
# An error at the end, or an open string, can still become JSON.
|
|
672
|
+
# `{not` and `[Tool call:` cannot, so they must stream immediately.
|
|
673
|
+
incomplete = exc.pos >= len(stripped) or "Unterminated" in exc.msg
|
|
674
|
+
if incomplete and not final and len(text) <= _FENCE_HOLD_CAP:
|
|
675
|
+
return ""
|
|
676
|
+
return self._release(text)
|
|
677
|
+
rest = stripped[end:]
|
|
678
|
+
if rest.strip():
|
|
679
|
+
return self._release(text)
|
|
680
|
+
calls = _calls_from_payload(payload)
|
|
681
|
+
if not calls:
|
|
682
|
+
return self._release(text)
|
|
683
|
+
if not final:
|
|
684
|
+
return ""
|
|
685
|
+
self._passthrough = True
|
|
686
|
+
self._buf = ""
|
|
687
|
+
replies = [reply_text_from_call(call) for call in calls]
|
|
688
|
+
if all(reply is not None for reply in replies):
|
|
689
|
+
return "".join(replies)
|
|
690
|
+
return ""
|
|
691
|
+
|
|
692
|
+
|
|
603
693
|
class PseudoStreamStripper:
|
|
604
|
-
"""Display-path filter chaining the fn-echo, JSON
|
|
694
|
+
"""Display-path filter chaining the fn-echo, JSON, and markdown filters.
|
|
605
695
|
|
|
606
696
|
Usage: ``feed`` each raw content delta, emit what comes back; at stream
|
|
607
697
|
end, ``flush`` once and emit the tail. Text held back to detect markers
|
|
@@ -611,11 +701,13 @@ class PseudoStreamStripper:
|
|
|
611
701
|
def __init__(self) -> None:
|
|
612
702
|
self._fn = _FnEchoFilter()
|
|
613
703
|
self._fence = _JsonFenceFilter()
|
|
704
|
+
self._bare = _BareToolJsonFilter()
|
|
614
705
|
self._md = _MdCallFilter()
|
|
615
706
|
|
|
616
707
|
def feed(self, chunk: str) -> str:
|
|
617
|
-
return self._md.feed(self._fence.feed(self._fn.feed(chunk)))
|
|
708
|
+
return self._md.feed(self._bare.feed(self._fence.feed(self._fn.feed(chunk))))
|
|
618
709
|
|
|
619
710
|
def flush(self) -> str:
|
|
620
|
-
|
|
621
|
-
|
|
711
|
+
fenced = self._fence.feed(self._fn.flush()) + self._fence.flush()
|
|
712
|
+
bare = self._bare.feed(fenced) + self._bare.flush()
|
|
713
|
+
return self._md.feed(bare) + self._md.flush()
|
|
@@ -17,10 +17,10 @@ Concurrency: like ``InMemoryStorage``, all mutations run under one asyncio
|
|
|
17
17
|
lock (safe under concurrent ``await`` in a single loop). Across processes
|
|
18
18
|
the database uses WAL, ``busy_timeout``, and a shared lease on the sibling
|
|
19
19
|
``*.lock`` file, so two sidecars can write different sessions. Schema
|
|
20
|
-
migration takes that file exclusively
|
|
21
|
-
|
|
22
|
-
open. Process death releases the
|
|
23
|
-
deleted.
|
|
20
|
+
migration takes that file exclusively. A peer that is applying the schema
|
|
21
|
+
is waited on briefly; a lease that is still held, or a ``user_version``
|
|
22
|
+
newer than this process, refuses to open. Process death releases the
|
|
23
|
+
kernel lock; the lock file is never deleted.
|
|
24
24
|
"""
|
|
25
25
|
|
|
26
26
|
from __future__ import annotations
|
|
@@ -28,6 +28,7 @@ from __future__ import annotations
|
|
|
28
28
|
import asyncio
|
|
29
29
|
import json
|
|
30
30
|
import sqlite3
|
|
31
|
+
import time
|
|
31
32
|
from collections.abc import Iterable
|
|
32
33
|
from typing import Any
|
|
33
34
|
|
|
@@ -45,6 +46,11 @@ from .write_lease import WriteLease, acquire_shared_lease, acquire_write_lease
|
|
|
45
46
|
|
|
46
47
|
SCHEMA_VERSION = 1
|
|
47
48
|
_BUSY_TIMEOUT_MS = 5000
|
|
49
|
+
# A second process can observe version 0 while the first still holds the
|
|
50
|
+
# exclusive lease to create the schema. The hold is the migration itself,
|
|
51
|
+
# not a live sidecar, so wait out that window before refusing.
|
|
52
|
+
_PEER_MIGRATION_WAIT_S = 1.0
|
|
53
|
+
_PEER_MIGRATION_POLL_S = 0.02
|
|
48
54
|
|
|
49
55
|
_SCHEMA = """
|
|
50
56
|
PRAGMA journal_mode = WAL;
|
|
@@ -138,31 +144,47 @@ def _apply_schema(db: sqlite3.Connection) -> None:
|
|
|
138
144
|
db.commit()
|
|
139
145
|
|
|
140
146
|
|
|
147
|
+
def _newer_than_this_program(version: int) -> StorageUpgradeBlockedError:
|
|
148
|
+
return StorageUpgradeBlockedError(
|
|
149
|
+
f"sidecar database version {version} is newer than this program ({SCHEMA_VERSION})"
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
141
153
|
def _open_lease(path: str) -> WriteLease:
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
154
|
+
deadline = time.monotonic() + _PEER_MIGRATION_WAIT_S
|
|
155
|
+
while True:
|
|
156
|
+
version = _read_user_version(path)
|
|
157
|
+
if version > SCHEMA_VERSION:
|
|
158
|
+
raise _newer_than_this_program(version)
|
|
159
|
+
if version == SCHEMA_VERSION:
|
|
160
|
+
return acquire_shared_lease(path)
|
|
148
161
|
try:
|
|
149
162
|
exclusive = acquire_write_lease(path)
|
|
150
163
|
except StoreAlreadyOwnedError as exc:
|
|
151
|
-
|
|
152
|
-
|
|
164
|
+
if time.monotonic() >= deadline:
|
|
165
|
+
version = _read_user_version(path)
|
|
166
|
+
if version > SCHEMA_VERSION:
|
|
167
|
+
raise _newer_than_this_program(version) from exc
|
|
168
|
+
if version == SCHEMA_VERSION:
|
|
169
|
+
return acquire_shared_lease(path)
|
|
153
170
|
raise StorageUpgradeBlockedError(
|
|
154
171
|
"sidecar database needs an upgrade but another process has it open"
|
|
155
172
|
) from exc
|
|
156
|
-
|
|
157
|
-
|
|
173
|
+
time.sleep(_PEER_MIGRATION_POLL_S)
|
|
174
|
+
continue
|
|
175
|
+
try:
|
|
176
|
+
version = _read_user_version(path)
|
|
177
|
+
if version > SCHEMA_VERSION:
|
|
178
|
+
raise _newer_than_this_program(version)
|
|
179
|
+
if version < SCHEMA_VERSION:
|
|
158
180
|
db = _connect(path)
|
|
159
181
|
try:
|
|
160
182
|
_apply_schema(db)
|
|
161
183
|
finally:
|
|
162
184
|
db.close()
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
185
|
+
finally:
|
|
186
|
+
exclusive.release()
|
|
187
|
+
return acquire_shared_lease(path)
|
|
166
188
|
|
|
167
189
|
|
|
168
190
|
class SqliteStorage:
|