steerable-agent-runtime 0.6.72__tar.gz → 0.6.74__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.72 → steerable_agent_runtime-0.6.74}/PKG-INFO +1 -1
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/pyproject.toml +1 -1
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/__init__.py +2 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/antihallucination.py +13 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/ask_user.py +34 -13
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/history.py +11 -7
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/hooks.py +32 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/native_bridge.py +3 -1
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/pseudo.py +319 -3
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/skills.py +76 -22
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime.egg-info/PKG-INFO +1 -1
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_antihallucination.py +58 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_ask_user.py +53 -10
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_fragment_bounds.py +17 -2
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_pseudo.py +64 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_skills.py +139 -8
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_stream_strip.py +118 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/README.md +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/setup.cfg +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/approval.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/approval_policy.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/branch.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/cache_control.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/calibration.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/compaction.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/compaction_policy.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/config.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/default.harness.json +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/default.harness.yaml +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/delegation_gate.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/errors.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/gateway_catalog.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/handoff.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/harness.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/harness_spec.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/llm/__init__.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/llm/anthropic_native.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/llm/compat.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/llm/errors.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/llm/google_genai.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/llm/openai_compat.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/llm/openai_responses.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/llm/parts.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/llm/presets.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/llm/system_proxy.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/loop.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/maintenance.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/mcp.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/mcp_server.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/model_catalog.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/model_info.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/model_resolve.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/observation_aging.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/orchestration.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/otel.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/plugins.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/pool.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/pricing.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/recording.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/reminders.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/replay.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/resume.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/retry.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/sandboxed.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/spill.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/storage/__init__.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/storage/in_memory.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/storage/sqlalchemy_store.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/storage/sqlite_store.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/storage/write_lease.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/subagent.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/todo.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/tokens.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/tool_presentation.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/tool_schema.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/tool_search.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/tools.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/tracing.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/transport/__init__.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/transport/fastapi_sse.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/transport/stdio_jsonrpc.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime/world_state.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime.egg-info/SOURCES.txt +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime.egg-info/dependency_links.txt +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime.egg-info/requires.txt +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/src/steerable_agent_runtime.egg-info/top_level.txt +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_approval.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_approval_policy.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_branch.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_cache_control.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_cache_instrumentation.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_calibration.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_compaction.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_compaction_policy.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_compaction_strategy.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_config.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_content_parts.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_delegation_gate.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_error_taxonomy.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_gateway_catalog.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_golden.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_handoff.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_harness.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_harness_spec.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_history.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_history_persistence.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_hooks.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_in_memory_storage.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_llm_gemini_wire.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_llm_responses_wire.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_llm_stream_e2e.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_llm_wire_helpers.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_long_session.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_loop.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_loop_cancellation.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_loop_replay.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_loop_sandbox_event.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_loop_terminal.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_maintenance.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_mcp.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_mcp_server.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_model_catalog.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_model_equivalence.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_model_info.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_model_resolve.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_native_default.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_observation_aging.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_orchestration.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_otel.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_parallel_tools.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_plugins.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_provider_compat.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_provider_presets.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_pyo3_api_surface.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_recording.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_reminders.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_replay_crosslang.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_resume.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_retry_hooks.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_safety_gate.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_sandboxed.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_soft_timeout.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_spill.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_sqlite_storage.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_steer.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_storage_contract.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_subagent.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_system_proxy.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_todo.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_todo_gate.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_tokens.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_tool_exposure.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_tool_hygiene.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_tool_presentation.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_tool_router.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_tool_schema.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_tool_timeout.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_trace_recorder.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_transport_jsonrpc.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_transport_sse.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_usage_attribution.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_world_state.py +0 -0
- {steerable_agent_runtime-0.6.72 → steerable_agent_runtime-0.6.74}/tests/test_write_lease.py +0 -0
|
@@ -246,6 +246,7 @@ from .skills import (
|
|
|
246
246
|
matches_conditions,
|
|
247
247
|
render_skill_catalog,
|
|
248
248
|
select_catalog,
|
|
249
|
+
select_pinned_loadable,
|
|
249
250
|
select_skills,
|
|
250
251
|
skill_to_dict,
|
|
251
252
|
skill_tool_descriptor,
|
|
@@ -503,6 +504,7 @@ __all__ = [
|
|
|
503
504
|
"resolve_model_info",
|
|
504
505
|
"rule_from_amendment",
|
|
505
506
|
"select_catalog",
|
|
507
|
+
"select_pinned_loadable",
|
|
506
508
|
"select_skills",
|
|
507
509
|
"should_run_grounding_judge",
|
|
508
510
|
"skill_to_dict",
|
|
@@ -459,6 +459,14 @@ class AntiHallucinationHooks(NoopHooks):
|
|
|
459
459
|
self._route: DataNeedRoute | None = None
|
|
460
460
|
self._retries = 0
|
|
461
461
|
self._narrations = 0
|
|
462
|
+
#: The wall a narration round swallowed (first one wins). The loop
|
|
463
|
+
#: reports ``completed`` once the narration round answers, which the
|
|
464
|
+
#: host would otherwise read as "the model decided it was done".
|
|
465
|
+
self._wall_status: str | None = None
|
|
466
|
+
|
|
467
|
+
def wall_status(self) -> str | None:
|
|
468
|
+
"""Pre-wrap-up terminal status, for the host's completion mapping."""
|
|
469
|
+
return self._wall_status
|
|
462
470
|
|
|
463
471
|
# -- pre_step: data-need routing → tool_choice on the first round ------
|
|
464
472
|
|
|
@@ -535,6 +543,11 @@ class AntiHallucinationHooks(NoopHooks):
|
|
|
535
543
|
and draft.tool_calls_used > 0
|
|
536
544
|
):
|
|
537
545
|
self._narrations += 1
|
|
546
|
+
# Remember the wall the narration is papering over — the loop
|
|
547
|
+
# reports ``completed`` after this round, and the host needs the
|
|
548
|
+
# original status to know the task did not actually finish.
|
|
549
|
+
if self._wall_status is None:
|
|
550
|
+
self._wall_status = draft.status
|
|
538
551
|
return CompletionAction(
|
|
539
552
|
kind="narrate",
|
|
540
553
|
message=(
|
|
@@ -131,11 +131,12 @@ def normalize_ask_user_questions(
|
|
|
131
131
|
) -> list[dict[str, Any]]:
|
|
132
132
|
"""Map known alias keys onto the canonical payload fields, enforce the
|
|
133
133
|
Claude Code ``AskUserQuestion`` bounds (1-4 questions, 2-4 options per
|
|
134
|
-
select, ``
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
134
|
+
select, ``multiSelect`` explicit), and require the two fields the host
|
|
135
|
+
card cannot render without (``id`` to key the answer, ``text`` to label
|
|
136
|
+
the control). An over-long ``header`` is clipped to 12 characters so a
|
|
137
|
+
local model that ignores the cap still gets a card. Any other violation
|
|
138
|
+
raises ``ToolDispatchError`` — the router wraps it as the tool result, so
|
|
139
|
+
the model sees exactly what to fix and retries within the same turn."""
|
|
139
140
|
if not isinstance(questions, list):
|
|
140
141
|
raise ToolDispatchError(
|
|
141
142
|
f"ask_user: questions must be an array, got {type(questions).__name__}"
|
|
@@ -166,8 +167,8 @@ def normalize_ask_user_questions(
|
|
|
166
167
|
f"ask_user: questions[{index}] is missing a non-empty string "
|
|
167
168
|
'"text" (the question label shown to the user).'
|
|
168
169
|
)
|
|
169
|
-
# header: optional on the wire
|
|
170
|
-
#
|
|
170
|
+
# header: optional on the wire. Derive from text when absent, and
|
|
171
|
+
# clip an over-long label so the chip still fits.
|
|
171
172
|
header = q.get("header")
|
|
172
173
|
if header is None:
|
|
173
174
|
q["header"] = _derive_header(q["text"])
|
|
@@ -176,10 +177,7 @@ def normalize_ask_user_questions(
|
|
|
176
177
|
f"ask_user: questions[{index}].header must be a non-empty string."
|
|
177
178
|
)
|
|
178
179
|
elif len(header) > _MAX_HEADER_LEN:
|
|
179
|
-
|
|
180
|
-
f"ask_user: questions[{index}].header is {len(header)} chars; "
|
|
181
|
-
f"the chip label must be <= {_MAX_HEADER_LEN}."
|
|
182
|
-
)
|
|
180
|
+
q["header"] = _derive_header(header)
|
|
183
181
|
# multiSelect: CC requires the model to commit to single vs multi —
|
|
184
182
|
# the field is required, so an omission is a model error to fix, not
|
|
185
183
|
# a default to assume.
|
|
@@ -233,6 +231,23 @@ class AskUserHandler(Protocol):
|
|
|
233
231
|
) -> Awaitable[dict[str, Any]]: ...
|
|
234
232
|
|
|
235
233
|
|
|
234
|
+
def _resolve_intro(intro: str | None, extra: dict[str, Any]) -> str:
|
|
235
|
+
"""Return the framing line, accepting local-model key spellings.
|
|
236
|
+
|
|
237
|
+
Qwen often emits ``"intro questions"`` (a space, or an underscore) instead
|
|
238
|
+
of ``intro``. The published schema still requires ``intro``; this only
|
|
239
|
+
recovers a call that already arrived with the framing text under another
|
|
240
|
+
key.
|
|
241
|
+
"""
|
|
242
|
+
if isinstance(intro, str) and intro.strip():
|
|
243
|
+
return intro.strip()
|
|
244
|
+
for key, value in extra.items():
|
|
245
|
+
folded = key.replace(" ", "_").replace("-", "_").lower()
|
|
246
|
+
if folded.startswith("intro") and isinstance(value, str) and value.strip():
|
|
247
|
+
return value.strip()
|
|
248
|
+
raise ToolDispatchError("ask_user: intro must be a non-empty string")
|
|
249
|
+
|
|
250
|
+
|
|
236
251
|
def make_ask_user_tool(handler: AskUserHandler) -> Callable[..., Awaitable[dict[str, Any]]]:
|
|
237
252
|
"""Build the ``ask_user`` tool handler bound to ``handler``.
|
|
238
253
|
|
|
@@ -244,11 +259,17 @@ def make_ask_user_tool(handler: AskUserHandler) -> Callable[..., Awaitable[dict[
|
|
|
244
259
|
"""
|
|
245
260
|
|
|
246
261
|
async def ask_user(
|
|
247
|
-
intro: str,
|
|
248
|
-
questions: list[dict[str, Any]],
|
|
262
|
+
intro: str | None = None,
|
|
263
|
+
questions: list[dict[str, Any]] | None = None,
|
|
249
264
|
outro: str | None = None,
|
|
250
265
|
context: dict[str, Any] | None = None,
|
|
266
|
+
**extra: Any,
|
|
251
267
|
) -> dict[str, Any]:
|
|
268
|
+
intro = _resolve_intro(intro, extra)
|
|
269
|
+
if not isinstance(questions, list):
|
|
270
|
+
raise ToolDispatchError(
|
|
271
|
+
"ask_user: questions must be an array of question objects."
|
|
272
|
+
)
|
|
252
273
|
normalized = normalize_ask_user_questions(questions)
|
|
253
274
|
answers = await _call_handler(handler, intro, normalized, context)
|
|
254
275
|
return {
|
|
@@ -370,14 +370,18 @@ class SystemPromptFragment(ContextFragment):
|
|
|
370
370
|
|
|
371
371
|
role: LLMRole = "system"
|
|
372
372
|
content_kind: str = "system_prompt"
|
|
373
|
-
|
|
373
|
+
#: Align the system-prompt backstop with the framework's absolute
|
|
374
|
+
#: per-fragment ceiling. Desktop prompts with pinned/eager skills (e.g.
|
|
375
|
+
#: word-master + ppt-master) legitimately reach ~6-8k tokens; the old
|
|
376
|
+
#: 4k cap silently truncated the very skill body the agent bound.
|
|
377
|
+
max_tokens: int | None = FRAGMENT_TOKEN_CEILING
|
|
374
378
|
review_note: str | None = (
|
|
375
|
-
"Desktop production prompts (persona + eager skill
|
|
376
|
-
"environment + tool reality check)
|
|
377
|
-
"
|
|
378
|
-
"
|
|
379
|
-
"
|
|
380
|
-
"
|
|
379
|
+
"Desktop production prompts (persona + pinned/eager skill stack + "
|
|
380
|
+
"runtime environment + tool reality check) legitimately reach 6-8k "
|
|
381
|
+
"tokens when an agent pins large skills. Reusing the absolute "
|
|
382
|
+
"per-fragment ceiling (10k) keeps a bounded backstop without "
|
|
383
|
+
"truncating the bound skill body; crossings still degrade with a "
|
|
384
|
+
"visible marker."
|
|
381
385
|
)
|
|
382
386
|
|
|
383
387
|
def __init__(self, prompt: str) -> None:
|
|
@@ -340,6 +340,22 @@ class NoopHooks:
|
|
|
340
340
|
"""
|
|
341
341
|
return result.success
|
|
342
342
|
|
|
343
|
+
def wall_status(self) -> str | None:
|
|
344
|
+
"""Terminal status a ``before_completion`` wrap-up swallowed, or None.
|
|
345
|
+
|
|
346
|
+
``before_completion`` may grant a *narration* round after the turn
|
|
347
|
+
already hit a wall (``failed`` / ``budget_exhausted``). That round
|
|
348
|
+
produces a natural-language summary, so the loop's own terminal
|
|
349
|
+
status becomes ``completed`` even though the task never finished —
|
|
350
|
+
and the host's auto-continue, which only resumes on
|
|
351
|
+
``budget_exhausted``, reads ``completed`` as "the model decided it
|
|
352
|
+
was done" and stops. Returning the pre-wrap-up status lets the host
|
|
353
|
+
report (and resume) the wall instead of the laundered completion.
|
|
354
|
+
|
|
355
|
+
Default None: no wrap-up disguised a wall.
|
|
356
|
+
"""
|
|
357
|
+
return None
|
|
358
|
+
|
|
343
359
|
|
|
344
360
|
class ChainHooks:
|
|
345
361
|
"""Compose several hooks into one ``LoopHooks``.
|
|
@@ -507,3 +523,19 @@ class ChainHooks:
|
|
|
507
523
|
if callable(judge) and not judge(result, call):
|
|
508
524
|
return False
|
|
509
525
|
return True
|
|
526
|
+
|
|
527
|
+
def wall_status(self) -> str | None:
|
|
528
|
+
"""First hook that recorded a wrap-up-swallowed wall wins.
|
|
529
|
+
|
|
530
|
+
AntiHallucinationHooks is the recorder today. Probed with
|
|
531
|
+
``getattr`` like the other optional capabilities, so a chain of
|
|
532
|
+
product hooks that never wraps up answers None without needing the
|
|
533
|
+
method.
|
|
534
|
+
"""
|
|
535
|
+
for hook in self._hooks:
|
|
536
|
+
probe = getattr(hook, "wall_status", None)
|
|
537
|
+
if callable(probe):
|
|
538
|
+
status = probe()
|
|
539
|
+
if status is not None:
|
|
540
|
+
return status
|
|
541
|
+
return None
|
|
@@ -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={
|
|
@@ -7,6 +7,9 @@ regress into prose) emit tool *intent* as text instead of a structured
|
|
|
7
7
|
- MiniMax XML: ``<invoke name="tool"><parameter name="k">v</parameter></invoke>``
|
|
8
8
|
- DeepSeek XML: ``<function=tool><parameter=k>v</parameter></function>``
|
|
9
9
|
- Markdown pseudo: ``[Tool call: NAME]\\n{"k": "v"}``
|
|
10
|
+
- JSON tool object: a fenced or ``<tool_call>`` ``{"name", "arguments"}``
|
|
11
|
+
object (Qwen / Ollama), or a message that is only that object.
|
|
12
|
+
``{"name": "text", "arguments": {"value": "..."}}`` is a reply, not a tool.
|
|
10
13
|
|
|
11
14
|
If the loop treated that text as a final answer it would end the turn having
|
|
12
15
|
never run the tool — the model "meant" to act but the loop stopped. This module
|
|
@@ -53,6 +56,27 @@ _PSEUDO_TOOL_CALL_HEADER_RE = re.compile(
|
|
|
53
56
|
re.IGNORECASE,
|
|
54
57
|
)
|
|
55
58
|
|
|
59
|
+
# --- JSON tool objects (Qwen / Ollama) --------------------------------------
|
|
60
|
+
#
|
|
61
|
+
# Small local models often answer with a markdown fence (or a <tool_call>
|
|
62
|
+
# wrapper, or a message that is only the object) instead of a structured
|
|
63
|
+
# tool_calls block:
|
|
64
|
+
#
|
|
65
|
+
# ```json
|
|
66
|
+
# {"name": "ask_user", "arguments": {"intro": "..."}}
|
|
67
|
+
# ```
|
|
68
|
+
#
|
|
69
|
+
# Only a payload whose every element is {name, arguments} is a call. A fenced
|
|
70
|
+
# config or a JSON object mentioned inside a sentence stays text.
|
|
71
|
+
|
|
72
|
+
_TOOL_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
73
|
+
_FENCE_RE = re.compile(r"```[^\n`]*\n?(.*?)```", re.DOTALL)
|
|
74
|
+
_TOOL_CALL_BLOCK_RE = re.compile(
|
|
75
|
+
r"<tool_call>\s*(.*?)\s*</tool_call>",
|
|
76
|
+
re.DOTALL | re.IGNORECASE,
|
|
77
|
+
)
|
|
78
|
+
_FENCE_HOLD_CAP = 8192
|
|
79
|
+
|
|
56
80
|
|
|
57
81
|
def _scan_balanced_json_object(text: str, start: int) -> tuple[str, int] | None:
|
|
58
82
|
"""Find the next ``{...}`` JSON object starting at/after ``start``.
|
|
@@ -151,6 +175,9 @@ def extract_inline_tool_calls(text: str) -> tuple[list[dict[str, Any]], str]:
|
|
|
151
175
|
cursor = end_idx
|
|
152
176
|
|
|
153
177
|
if not results:
|
|
178
|
+
recovered = _extract_json_tool_calls(text)
|
|
179
|
+
if recovered is not None:
|
|
180
|
+
return recovered
|
|
154
181
|
return [], text
|
|
155
182
|
|
|
156
183
|
cleaned_segments.append(text[cursor:])
|
|
@@ -159,6 +186,128 @@ def extract_inline_tool_calls(text: str) -> tuple[list[dict[str, Any]], str]:
|
|
|
159
186
|
return results, cleaned_text
|
|
160
187
|
|
|
161
188
|
|
|
189
|
+
def _coerce_arguments(raw: Any) -> dict[str, Any] | None:
|
|
190
|
+
"""Return a dict of tool arguments, parsing a JSON string when needed."""
|
|
191
|
+
if raw is None:
|
|
192
|
+
return {}
|
|
193
|
+
if isinstance(raw, str):
|
|
194
|
+
stripped = raw.strip()
|
|
195
|
+
if not stripped:
|
|
196
|
+
return {}
|
|
197
|
+
try:
|
|
198
|
+
raw = json.loads(stripped)
|
|
199
|
+
except json.JSONDecodeError:
|
|
200
|
+
return None
|
|
201
|
+
if isinstance(raw, dict):
|
|
202
|
+
return raw
|
|
203
|
+
return None
|
|
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
|
+
|
|
231
|
+
def _calls_from_payload(payload: Any) -> list[dict[str, Any]] | None:
|
|
232
|
+
"""Accept one tool-call object or a list of them. Anything else is text."""
|
|
233
|
+
items = payload if isinstance(payload, list) else [payload]
|
|
234
|
+
if not items:
|
|
235
|
+
return None
|
|
236
|
+
calls: list[dict[str, Any]] = []
|
|
237
|
+
for item in items:
|
|
238
|
+
if not isinstance(item, dict):
|
|
239
|
+
return None
|
|
240
|
+
name = item.get("name")
|
|
241
|
+
if not isinstance(name, str) or _TOOL_NAME_RE.fullmatch(name.strip()) is None:
|
|
242
|
+
return None
|
|
243
|
+
arguments = _coerce_arguments(item.get("arguments", {}))
|
|
244
|
+
if arguments is None:
|
|
245
|
+
return None
|
|
246
|
+
calls.append({"name": name.strip(), "arguments": arguments})
|
|
247
|
+
return calls
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _parse_tool_call_blob(blob: str) -> list[dict[str, Any]] | None:
|
|
251
|
+
"""Parse a tool-call JSON blob, unwrapping one surrounding fence."""
|
|
252
|
+
blob = blob.strip()
|
|
253
|
+
if not blob:
|
|
254
|
+
return None
|
|
255
|
+
fenced = _FENCE_RE.fullmatch(blob)
|
|
256
|
+
if fenced is not None:
|
|
257
|
+
blob = fenced.group(1).strip()
|
|
258
|
+
if not blob:
|
|
259
|
+
return None
|
|
260
|
+
try:
|
|
261
|
+
payload = json.loads(blob)
|
|
262
|
+
except json.JSONDecodeError:
|
|
263
|
+
return None
|
|
264
|
+
return _calls_from_payload(payload)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _extract_json_tool_calls(text: str) -> tuple[list[dict[str, Any]], str] | None:
|
|
268
|
+
"""Recover fenced / ``<tool_call>`` / whole-message JSON tool calls.
|
|
269
|
+
|
|
270
|
+
Returns ``None`` when the text is not this format, so the caller can
|
|
271
|
+
leave it untouched.
|
|
272
|
+
"""
|
|
273
|
+
results: list[dict[str, Any]] = []
|
|
274
|
+
spans: list[tuple[int, int]] = []
|
|
275
|
+
|
|
276
|
+
def take(match: re.Match[str], blob: str) -> None:
|
|
277
|
+
if any(match.start() < end and match.end() > start for start, end in spans):
|
|
278
|
+
return
|
|
279
|
+
calls = _parse_tool_call_blob(blob)
|
|
280
|
+
if not calls:
|
|
281
|
+
return
|
|
282
|
+
results.extend(calls)
|
|
283
|
+
spans.append(match.span())
|
|
284
|
+
|
|
285
|
+
for match in _TOOL_CALL_BLOCK_RE.finditer(text):
|
|
286
|
+
take(match, match.group(1))
|
|
287
|
+
for match in _FENCE_RE.finditer(text):
|
|
288
|
+
take(match, match.group(1))
|
|
289
|
+
if not results:
|
|
290
|
+
unclosed = re.search(r"```[^\n`]*\n(.*)\Z", text, re.DOTALL)
|
|
291
|
+
if unclosed is not None:
|
|
292
|
+
calls = _parse_tool_call_blob(unclosed.group(1))
|
|
293
|
+
if calls:
|
|
294
|
+
results.extend(calls)
|
|
295
|
+
spans.append(unclosed.span())
|
|
296
|
+
if not results:
|
|
297
|
+
stripped = text.strip()
|
|
298
|
+
if stripped[:1] in "{[":
|
|
299
|
+
calls = _parse_tool_call_blob(stripped)
|
|
300
|
+
if calls:
|
|
301
|
+
return calls, ""
|
|
302
|
+
return None
|
|
303
|
+
|
|
304
|
+
cleaned = text
|
|
305
|
+
for start, end in sorted(spans, reverse=True):
|
|
306
|
+
cleaned = cleaned[:start] + cleaned[end:]
|
|
307
|
+
cleaned = re.sub(r"\n{3,}", "\n\n", cleaned).strip()
|
|
308
|
+
return results, cleaned
|
|
309
|
+
|
|
310
|
+
|
|
162
311
|
# ---------------------------------------------------------------------------
|
|
163
312
|
# Streaming-time stripping (display path)
|
|
164
313
|
# ---------------------------------------------------------------------------
|
|
@@ -378,8 +527,171 @@ class _MdCallFilter:
|
|
|
378
527
|
return tail
|
|
379
528
|
|
|
380
529
|
|
|
530
|
+
def _split_partial_backticks(buf: str) -> tuple[str, str]:
|
|
531
|
+
"""Hold a trailing 1-2 backticks that may still become a fence opener."""
|
|
532
|
+
hold = 0
|
|
533
|
+
for char in reversed(buf):
|
|
534
|
+
if char != "`" or hold == 2:
|
|
535
|
+
break
|
|
536
|
+
hold += 1
|
|
537
|
+
if hold == 0:
|
|
538
|
+
return buf, ""
|
|
539
|
+
return buf[:-hold], buf[-hold:]
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _opened_fence_body(held: str) -> str | None:
|
|
543
|
+
"""Body of a span that starts with an opening fence.
|
|
544
|
+
|
|
545
|
+
``held`` includes the closing fence when the span is complete. An
|
|
546
|
+
unclosed span returns the text after the language line.
|
|
547
|
+
"""
|
|
548
|
+
if not held.startswith("```"):
|
|
549
|
+
return None
|
|
550
|
+
if held.endswith("```") and len(held) >= 6:
|
|
551
|
+
match = _FENCE_RE.fullmatch(held)
|
|
552
|
+
if match is None:
|
|
553
|
+
return None
|
|
554
|
+
return match.group(1)
|
|
555
|
+
rest = held[3:]
|
|
556
|
+
newline = rest.find("\n")
|
|
557
|
+
if newline < 0:
|
|
558
|
+
return rest
|
|
559
|
+
return rest[newline + 1 :]
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
class _JsonFenceFilter:
|
|
563
|
+
"""Hold a ``` fence and drop it only when the body is a tool-call object.
|
|
564
|
+
|
|
565
|
+
A code sample or a JSON config is released unchanged, including when the
|
|
566
|
+
fence never closes. A fence larger than ``_FENCE_HOLD_CAP`` is released
|
|
567
|
+
so a long code block is not stuck in the filter.
|
|
568
|
+
"""
|
|
569
|
+
|
|
570
|
+
def __init__(self) -> None:
|
|
571
|
+
self._buf = ""
|
|
572
|
+
self._open = False
|
|
573
|
+
self._held = ""
|
|
574
|
+
|
|
575
|
+
def feed(self, chunk: str) -> str:
|
|
576
|
+
buf = self._buf + (chunk or "")
|
|
577
|
+
self._buf = ""
|
|
578
|
+
out: list[str] = []
|
|
579
|
+
while True:
|
|
580
|
+
if self._open:
|
|
581
|
+
close = buf.find("```")
|
|
582
|
+
if close < 0:
|
|
583
|
+
self._held += buf
|
|
584
|
+
if len(self._held) > _FENCE_HOLD_CAP:
|
|
585
|
+
out.append(self._held)
|
|
586
|
+
self._held = ""
|
|
587
|
+
self._open = False
|
|
588
|
+
break
|
|
589
|
+
self._held += buf[: close + 3]
|
|
590
|
+
buf = buf[close + 3 :]
|
|
591
|
+
released = self._release_held()
|
|
592
|
+
if released:
|
|
593
|
+
out.append(released)
|
|
594
|
+
continue
|
|
595
|
+
opener = buf.find("```")
|
|
596
|
+
if opener >= 0:
|
|
597
|
+
if opener:
|
|
598
|
+
out.append(buf[:opener])
|
|
599
|
+
buf = buf[opener + 3 :]
|
|
600
|
+
self._open = True
|
|
601
|
+
self._held = "```"
|
|
602
|
+
continue
|
|
603
|
+
emit, hold = _split_partial_backticks(buf)
|
|
604
|
+
if emit:
|
|
605
|
+
out.append(emit)
|
|
606
|
+
self._buf = hold
|
|
607
|
+
break
|
|
608
|
+
return "".join(out)
|
|
609
|
+
|
|
610
|
+
def _release_held(self) -> str:
|
|
611
|
+
held = self._held
|
|
612
|
+
self._held = ""
|
|
613
|
+
self._open = False
|
|
614
|
+
body = _opened_fence_body(held)
|
|
615
|
+
if body is not None and _parse_tool_call_blob(body):
|
|
616
|
+
return ""
|
|
617
|
+
return held
|
|
618
|
+
|
|
619
|
+
def flush(self) -> str:
|
|
620
|
+
if self._open:
|
|
621
|
+
self._held += self._buf
|
|
622
|
+
self._buf = ""
|
|
623
|
+
return self._release_held()
|
|
624
|
+
tail = self._buf
|
|
625
|
+
self._buf = ""
|
|
626
|
+
return tail
|
|
627
|
+
|
|
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
|
+
|
|
381
693
|
class PseudoStreamStripper:
|
|
382
|
-
"""Display-path filter chaining the fn-echo and markdown
|
|
694
|
+
"""Display-path filter chaining the fn-echo, JSON, and markdown filters.
|
|
383
695
|
|
|
384
696
|
Usage: ``feed`` each raw content delta, emit what comes back; at stream
|
|
385
697
|
end, ``flush`` once and emit the tail. Text held back to detect markers
|
|
@@ -388,10 +700,14 @@ class PseudoStreamStripper:
|
|
|
388
700
|
|
|
389
701
|
def __init__(self) -> None:
|
|
390
702
|
self._fn = _FnEchoFilter()
|
|
703
|
+
self._fence = _JsonFenceFilter()
|
|
704
|
+
self._bare = _BareToolJsonFilter()
|
|
391
705
|
self._md = _MdCallFilter()
|
|
392
706
|
|
|
393
707
|
def feed(self, chunk: str) -> str:
|
|
394
|
-
return self._md.feed(self._fn.feed(chunk))
|
|
708
|
+
return self._md.feed(self._bare.feed(self._fence.feed(self._fn.feed(chunk))))
|
|
395
709
|
|
|
396
710
|
def flush(self) -> str:
|
|
397
|
-
|
|
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()
|