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.
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/PKG-INFO +1 -1
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/pyproject.toml +1 -1
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/approval.py +86 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/default.harness.json +0 -2
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/default.harness.yaml +4 -2
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/orchestration.py +69 -10
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/pool.py +32 -5
- steerable_agent_runtime-0.6.52/src/steerable_agent_runtime/tool_presentation.py +110 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/tools.py +7 -1
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime.egg-info/PKG-INFO +1 -1
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime.egg-info/SOURCES.txt +2 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_approval.py +101 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_orchestration.py +246 -0
- steerable_agent_runtime-0.6.52/tests/test_tool_presentation.py +113 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/README.md +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/setup.cfg +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/__init__.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/antihallucination.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/approval_policy.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/ask_user.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/branch.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/cache_control.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/calibration.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/compaction.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/compaction_policy.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/config.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/delegation_gate.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/errors.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/gateway_catalog.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/handoff.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/harness.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/harness_spec.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/history.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/hooks.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/llm/__init__.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/llm/anthropic_native.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/llm/compat.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/llm/errors.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/llm/google_genai.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/llm/openai_compat.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/llm/openai_responses.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/llm/parts.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/llm/presets.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/llm/system_proxy.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/loop.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/maintenance.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/mcp.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/mcp_server.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/model_catalog.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/model_info.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/model_resolve.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/native_bridge.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/observation_aging.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/otel.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/plugins.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/pricing.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/pseudo.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/recording.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/reminders.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/replay.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/resume.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/retry.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/sandboxed.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/skills.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/spill.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/storage/__init__.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/storage/in_memory.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/storage/sqlalchemy_store.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/storage/sqlite_store.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/storage/write_lease.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/subagent.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/todo.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/tokens.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/tool_schema.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/tool_search.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/tracing.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/transport/__init__.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/transport/fastapi_sse.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/transport/stdio_jsonrpc.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime/world_state.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime.egg-info/dependency_links.txt +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime.egg-info/requires.txt +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/src/steerable_agent_runtime.egg-info/top_level.txt +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_antihallucination.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_approval_policy.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_ask_user.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_branch.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_cache_control.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_cache_instrumentation.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_calibration.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_compaction.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_compaction_policy.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_compaction_strategy.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_config.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_content_parts.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_delegation_gate.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_error_taxonomy.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_fragment_bounds.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_gateway_catalog.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_golden.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_handoff.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_harness.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_harness_spec.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_history.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_history_persistence.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_hooks.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_in_memory_storage.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_llm_gemini_wire.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_llm_responses_wire.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_llm_stream_e2e.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_llm_wire_helpers.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_long_session.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_loop.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_loop_cancellation.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_loop_replay.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_loop_sandbox_event.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_loop_terminal.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_maintenance.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_mcp.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_mcp_server.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_model_catalog.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_model_equivalence.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_model_info.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_model_resolve.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_native_default.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_observation_aging.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_otel.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_parallel_tools.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_plugins.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_provider_compat.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_provider_presets.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_pseudo.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_pyo3_api_surface.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_recording.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_reminders.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_replay_crosslang.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_resume.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_retry_hooks.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_safety_gate.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_sandboxed.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_skills.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_soft_timeout.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_spill.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_sqlite_storage.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_steer.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_storage_contract.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_stream_strip.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_subagent.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_system_proxy.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_todo.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_todo_gate.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_tokens.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_tool_exposure.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_tool_hygiene.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_tool_router.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_tool_schema.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_tool_timeout.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_trace_recorder.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_transport_jsonrpc.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_transport_sse.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_usage_attribution.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_world_state.py +0 -0
- {steerable_agent_runtime-0.6.51 → steerable_agent_runtime-0.6.52}/tests/test_write_lease.py +0 -0
|
@@ -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
|
|
|
@@ -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(
|
|
235
|
-
|
|
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. ``
|
|
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
|
-
|
|
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 == "
|
|
259
|
-
|
|
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(
|
|
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=
|
|
133
|
+
metadata=merged_metadata,
|
|
128
134
|
)
|
|
129
135
|
self._tools[resolved_name] = tool_meta
|
|
130
136
|
return tool_meta
|
|
@@ -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
|