@oneciel-ai/ciel-runtime 0.1.1 → 0.2.0
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.
- package/README.md +132 -0
- package/ciel-runtime-menu.py +56 -6
- package/ciel_runtime.py +9379 -35909
- package/ciel_runtime_support/advisor_client.py +193 -0
- package/ciel_runtime_support/advisor_policy.py +320 -0
- package/ciel_runtime_support/advisor_refinement.py +160 -0
- package/ciel_runtime_support/advisor_request_builder.py +261 -0
- package/ciel_runtime_support/agy_installer.py +169 -0
- package/ciel_runtime_support/agy_mcp_restore.py +182 -0
- package/ciel_runtime_support/anthropic_model_policy.py +186 -0
- package/ciel_runtime_support/anthropic_response_writer.py +255 -0
- package/ciel_runtime_support/anthropic_tool_turns.py +130 -0
- package/ciel_runtime_support/api_key_cooldown.py +159 -0
- package/ciel_runtime_support/architecture.py +488 -1
- package/ciel_runtime_support/architecture_budget.py +42 -0
- package/ciel_runtime_support/channel_backlog.py +90 -0
- package/ciel_runtime_support/channel_cli.py +119 -0
- package/ciel_runtime_support/channel_compact_injection.py +82 -0
- package/ciel_runtime_support/channel_compact_poll.py +67 -0
- package/ciel_runtime_support/channel_compact_request_repository.py +113 -0
- package/ciel_runtime_support/channel_config_service.py +281 -0
- package/ciel_runtime_support/channel_connection_lifecycle.py +180 -0
- package/ciel_runtime_support/channel_connection_registry.py +128 -0
- package/ciel_runtime_support/channel_connection_worker.py +284 -0
- package/ciel_runtime_support/channel_cursor_recovery.py +92 -0
- package/ciel_runtime_support/channel_cursor_repository.py +89 -0
- package/ciel_runtime_support/channel_cursor_service.py +178 -0
- package/ciel_runtime_support/channel_event_identity.py +212 -0
- package/ciel_runtime_support/channel_event_projection.py +315 -0
- package/ciel_runtime_support/channel_inflight.py +127 -0
- package/ciel_runtime_support/channel_injection.py +115 -0
- package/ciel_runtime_support/channel_launch_guard_repository.py +58 -0
- package/ciel_runtime_support/channel_launch_policy.py +180 -0
- package/ciel_runtime_support/channel_llm_context.py +156 -0
- package/ciel_runtime_support/channel_mcp_discovery.py +186 -0
- package/ciel_runtime_support/channel_mcp_http_controller.py +239 -0
- package/ciel_runtime_support/channel_mcp_ownership.py +148 -0
- package/ciel_runtime_support/channel_mcp_tools.py +240 -0
- package/ciel_runtime_support/channel_mcp_transport.py +394 -0
- package/ciel_runtime_support/channel_message_dedupe.py +65 -0
- package/ciel_runtime_support/channel_message_policy.py +256 -0
- package/ciel_runtime_support/channel_message_prompt.py +305 -0
- package/ciel_runtime_support/channel_message_repository.py +234 -0
- package/ciel_runtime_support/channel_notification_projection.py +217 -0
- package/ciel_runtime_support/channel_panel.py +162 -0
- package/ciel_runtime_support/channel_pending_injection.py +209 -0
- package/ciel_runtime_support/channel_pending_poll.py +109 -0
- package/ciel_runtime_support/channel_probe_cache.py +433 -0
- package/ciel_runtime_support/channel_probe_report.py +101 -0
- package/ciel_runtime_support/channel_runtime_environment.py +181 -0
- package/ciel_runtime_support/channel_session_lifecycle.py +114 -0
- package/ciel_runtime_support/channel_session_repository.py +90 -0
- package/ciel_runtime_support/channel_terminal_dispatch.py +115 -0
- package/ciel_runtime_support/channel_terminal_input.py +277 -0
- package/ciel_runtime_support/channel_terminal_proxy.py +447 -0
- package/ciel_runtime_support/channel_tool_context.py +166 -0
- package/ciel_runtime_support/channel_transcript.py +414 -0
- package/ciel_runtime_support/channel_transcript_repository.py +96 -0
- package/ciel_runtime_support/channel_wake_claim_repository.py +126 -0
- package/ciel_runtime_support/channel_wake_delivery_repository.py +88 -0
- package/ciel_runtime_support/chat_files.py +138 -0
- package/ciel_runtime_support/chat_http_controller.py +235 -0
- package/ciel_runtime_support/claude_environment.py +375 -0
- package/ciel_runtime_support/claude_router.py +247 -193
- package/ciel_runtime_support/cli_dispatch.py +792 -0
- package/ciel_runtime_support/cli_parser.py +165 -0
- package/ciel_runtime_support/cli_usage.py +100 -0
- package/ciel_runtime_support/codex_app_server.py +20 -5
- package/ciel_runtime_support/codex_channel_sse_launch.py +87 -0
- package/ciel_runtime_support/codex_cli.py +42 -6
- package/ciel_runtime_support/codex_config.py +323 -0
- package/ciel_runtime_support/codex_launch_configuration.py +240 -0
- package/ciel_runtime_support/codex_launch_policy.py +66 -0
- package/ciel_runtime_support/codex_mcp_integration.py +195 -0
- package/ciel_runtime_support/codex_mcp_restore.py +304 -0
- package/ciel_runtime_support/codex_model_catalog.py +133 -0
- package/ciel_runtime_support/codex_process_lifecycle.py +271 -0
- package/ciel_runtime_support/codex_router.py +147 -1
- package/ciel_runtime_support/codex_session_repository.py +115 -0
- package/ciel_runtime_support/codex_session_selection.py +114 -0
- package/ciel_runtime_support/command_asset_installer.py +103 -0
- package/ciel_runtime_support/compatibility_probe.py +295 -0
- package/ciel_runtime_support/compatibility_protocol.py +251 -0
- package/ciel_runtime_support/compatibility_runtime.py +166 -0
- package/ciel_runtime_support/compatibility_test.py +370 -0
- package/ciel_runtime_support/config_migrations.py +307 -0
- package/ciel_runtime_support/config_repository.py +175 -0
- package/ciel_runtime_support/config_value_codec.py +64 -0
- package/ciel_runtime_support/configuration_cli.py +374 -0
- package/ciel_runtime_support/context_compaction.py +280 -0
- package/ciel_runtime_support/context_setup.py +208 -0
- package/ciel_runtime_support/context_summary_policy.py +392 -0
- package/ciel_runtime_support/credential_cli.py +104 -0
- package/ciel_runtime_support/credential_management.py +261 -0
- package/ciel_runtime_support/credentials.py +269 -0
- package/ciel_runtime_support/executable_discovery.py +141 -0
- package/ciel_runtime_support/github_copilot_oauth.py +335 -0
- package/ciel_runtime_support/github_copilot_oauth_runtime.py +213 -0
- package/ciel_runtime_support/header_forwarding.py +73 -0
- package/ciel_runtime_support/headless_config.py +221 -0
- package/ciel_runtime_support/http_response.py +129 -0
- package/ciel_runtime_support/install_diagnostics.py +149 -0
- package/ciel_runtime_support/kimi_identity.py +123 -0
- package/ciel_runtime_support/launch_diagnostics.py +204 -0
- package/ciel_runtime_support/launch_state.py +127 -0
- package/ciel_runtime_support/live_api_key_controller.py +58 -0
- package/ciel_runtime_support/llm_config_http.py +148 -0
- package/ciel_runtime_support/llm_option_config.py +259 -0
- package/ciel_runtime_support/llm_presentation_data.py +447 -0
- package/ciel_runtime_support/llm_presets.py +773 -0
- package/ciel_runtime_support/lm_studio_runtime.py +401 -0
- package/ciel_runtime_support/managed_mcp_config.py +144 -0
- package/ciel_runtime_support/managed_mcp_discovery.py +151 -0
- package/ciel_runtime_support/managed_service_cleanup.py +89 -0
- package/ciel_runtime_support/mcp_config_reader.py +230 -0
- package/ciel_runtime_support/mcp_http_proxy.py +607 -0
- package/ciel_runtime_support/mcp_inventory.py +59 -0
- package/ciel_runtime_support/mcp_notification_wait_policy.py +159 -0
- package/ciel_runtime_support/mcp_probe_codec.py +136 -0
- package/ciel_runtime_support/mcp_probe_transport.py +328 -0
- package/ciel_runtime_support/mcp_proxy_codec.py +345 -0
- package/ciel_runtime_support/mcp_proxy_config.py +107 -0
- package/ciel_runtime_support/mcp_proxy_notifications.py +261 -0
- package/ciel_runtime_support/mcp_proxy_process.py +560 -0
- package/ciel_runtime_support/mcp_split_proxy_http.py +165 -0
- package/ciel_runtime_support/mcp_stdio_probe.py +240 -0
- package/ciel_runtime_support/mcp_transport.py +146 -0
- package/ciel_runtime_support/model_cache_lifecycle.py +78 -0
- package/ciel_runtime_support/model_catalog_projection.py +61 -0
- package/ciel_runtime_support/model_context_hints.py +109 -0
- package/ciel_runtime_support/model_panel.py +147 -0
- package/ciel_runtime_support/model_registry_repository.py +231 -0
- package/ciel_runtime_support/npm_runtime.py +191 -0
- package/ciel_runtime_support/ollama_catalog.py +462 -0
- package/ciel_runtime_support/ollama_catalog_cli.py +41 -0
- package/ciel_runtime_support/ollama_catalog_repository.py +75 -0
- package/ciel_runtime_support/ollama_context_sync.py +87 -0
- package/ciel_runtime_support/ollama_forwarding.py +449 -0
- package/ciel_runtime_support/openai_chat_passthrough.py +67 -0
- package/ciel_runtime_support/openai_chat_router.py +64 -0
- package/ciel_runtime_support/openai_forwarding.py +194 -0
- package/ciel_runtime_support/openai_responses_router.py +291 -0
- package/ciel_runtime_support/openai_responses_stream.py +135 -0
- package/ciel_runtime_support/output_budget.py +89 -0
- package/ciel_runtime_support/package_lifecycle.py +217 -0
- package/ciel_runtime_support/plan_artifact_controller.py +104 -0
- package/ciel_runtime_support/prelaunch.py +959 -0
- package/ciel_runtime_support/prelaunch_launch_preference.py +75 -0
- package/ciel_runtime_support/prelaunch_panel_projection.py +396 -0
- package/ciel_runtime_support/prelaunch_terminal.py +764 -0
- package/ciel_runtime_support/process_control.py +708 -0
- package/ciel_runtime_support/prompt_compaction.py +322 -0
- package/ciel_runtime_support/prompt_injection.py +176 -0
- package/ciel_runtime_support/protocols/__init__.py +24 -0
- package/ciel_runtime_support/protocols/anthropic_content.py +29 -0
- package/ciel_runtime_support/protocols/anthropic_thinking_policy.py +336 -0
- package/ciel_runtime_support/protocols/chat_projection.py +315 -0
- package/ciel_runtime_support/protocols/conversation_policy.py +217 -0
- package/ciel_runtime_support/protocols/conversation_turn_policy.py +972 -0
- package/ciel_runtime_support/protocols/ollama_chat.py +111 -0
- package/ciel_runtime_support/protocols/ollama_response.py +231 -0
- package/ciel_runtime_support/protocols/openai_reasoning.py +75 -0
- package/ciel_runtime_support/protocols/openai_responses.py +271 -0
- package/ciel_runtime_support/protocols/pseudo_tool_history.py +248 -0
- package/ciel_runtime_support/protocols/tool_result_projection.py +112 -0
- package/ciel_runtime_support/provider_adapters.py +160 -0
- package/ciel_runtime_support/provider_catalog_sources.py +316 -0
- package/ciel_runtime_support/provider_choice.py +201 -0
- package/ciel_runtime_support/provider_compatibility.py +165 -0
- package/ciel_runtime_support/provider_config_mutations.py +361 -0
- package/ciel_runtime_support/provider_configuration_service.py +162 -0
- package/ciel_runtime_support/provider_context.py +308 -0
- package/ciel_runtime_support/provider_contract_projection.py +73 -0
- package/ciel_runtime_support/provider_descriptor.py +82 -0
- package/ciel_runtime_support/provider_endpoint_policy.py +130 -0
- package/ciel_runtime_support/provider_endpoint_probe.py +165 -0
- package/ciel_runtime_support/provider_launch_endpoint.py +109 -0
- package/ciel_runtime_support/provider_limits.py +457 -0
- package/ciel_runtime_support/provider_model_identity.py +139 -0
- package/ciel_runtime_support/provider_model_selection.py +431 -0
- package/ciel_runtime_support/provider_model_specs.py +142 -0
- package/ciel_runtime_support/provider_models.py +263 -0
- package/ciel_runtime_support/provider_network.py +176 -0
- package/ciel_runtime_support/provider_option_cli.py +238 -0
- package/ciel_runtime_support/provider_option_panel.py +275 -0
- package/ciel_runtime_support/provider_option_status.py +192 -0
- package/ciel_runtime_support/provider_policy.py +101 -0
- package/ciel_runtime_support/provider_query_policy.py +67 -0
- package/ciel_runtime_support/provider_readiness.py +112 -0
- package/ciel_runtime_support/provider_request_access.py +131 -0
- package/ciel_runtime_support/provider_request_builder.py +250 -0
- package/ciel_runtime_support/provider_responses_passthrough.py +81 -0
- package/ciel_runtime_support/provider_runtime_info.py +113 -0
- package/ciel_runtime_support/provider_runtime_modes.py +150 -0
- package/ciel_runtime_support/provider_sampling_policy.py +46 -0
- package/ciel_runtime_support/provider_status.py +145 -0
- package/ciel_runtime_support/provider_timeout_policy.py +184 -0
- package/ciel_runtime_support/provider_tool_policy.py +145 -0
- package/ciel_runtime_support/providers/__init__.py +65 -0
- package/ciel_runtime_support/providers/anthropic.py +160 -0
- package/ciel_runtime_support/providers/anthropic_catalog.py +119 -0
- package/ciel_runtime_support/providers/base.py +232 -0
- package/ciel_runtime_support/providers/catalog.py +326 -0
- package/ciel_runtime_support/providers/cloud.py +194 -0
- package/ciel_runtime_support/providers/constants.py +54 -0
- package/ciel_runtime_support/providers/deepseek.py +115 -0
- package/ciel_runtime_support/providers/fireworks.py +158 -0
- package/ciel_runtime_support/providers/github_copilot_oauth.py +199 -0
- package/ciel_runtime_support/providers/kimi.py +297 -0
- package/ciel_runtime_support/providers/lm_studio.py +76 -0
- package/ciel_runtime_support/providers/meta.py +257 -0
- package/ciel_runtime_support/providers/native.py +194 -0
- package/ciel_runtime_support/providers/nim.py +69 -0
- package/ciel_runtime_support/providers/nvidia.py +158 -0
- package/ciel_runtime_support/providers/nvidia_runtime.py +285 -0
- package/ciel_runtime_support/providers/ollama.py +181 -0
- package/ciel_runtime_support/providers/ollama_context.py +195 -0
- package/ciel_runtime_support/providers/ollama_runtime.py +213 -0
- package/ciel_runtime_support/providers/opencode.py +220 -0
- package/ciel_runtime_support/providers/opencode_go.py +37 -0
- package/ciel_runtime_support/providers/openrouter.py +57 -0
- package/ciel_runtime_support/providers/vllm.py +65 -0
- package/ciel_runtime_support/providers/zai.py +119 -0
- package/ciel_runtime_support/pseudo_tool_parser.py +115 -0
- package/ciel_runtime_support/rate_limit_policy.py +117 -0
- package/ciel_runtime_support/rate_limit_repository.py +154 -0
- package/ciel_runtime_support/registry.py +46 -0
- package/ciel_runtime_support/request_shortcuts.py +253 -0
- package/ciel_runtime_support/request_trace.py +323 -0
- package/ciel_runtime_support/response_collection.py +209 -0
- package/ciel_runtime_support/router_access.py +238 -0
- package/ciel_runtime_support/router_client_lifecycle.py +366 -0
- package/ciel_runtime_support/router_health_policy.py +101 -0
- package/ciel_runtime_support/router_http.py +513 -0
- package/ciel_runtime_support/router_process_lifecycle.py +401 -0
- package/ciel_runtime_support/router_rate_limit_service.py +285 -0
- package/ciel_runtime_support/router_server_runtime.py +103 -0
- package/ciel_runtime_support/router_shortcuts.py +201 -0
- package/ciel_runtime_support/routing_fallback.py +73 -0
- package/ciel_runtime_support/runtime_activity_repository.py +143 -0
- package/ciel_runtime_support/runtime_adapters.py +104 -0
- package/ciel_runtime_support/runtime_command_factory.py +73 -0
- package/ciel_runtime_support/runtime_compatibility.py +50 -0
- package/ciel_runtime_support/runtime_constants.py +178 -0
- package/ciel_runtime_support/runtime_launch.py +1602 -0
- package/ciel_runtime_support/runtime_llm_options.py +312 -0
- package/ciel_runtime_support/runtime_logging.py +161 -0
- package/ciel_runtime_support/runtime_paths.py +157 -0
- package/ciel_runtime_support/runtime_restart.py +84 -0
- package/ciel_runtime_support/runtime_upgrade.py +149 -0
- package/ciel_runtime_support/secure_json_repository.py +55 -0
- package/ciel_runtime_support/session_import.py +356 -0
- package/ciel_runtime_support/settings_repository.py +8 -0
- package/ciel_runtime_support/slash_command_assets.py +211 -0
- package/ciel_runtime_support/sse_stream.py +57 -0
- package/ciel_runtime_support/sse_trace.py +225 -0
- package/ciel_runtime_support/statusline_script.py +593 -0
- package/ciel_runtime_support/statusline_settings.py +53 -0
- package/ciel_runtime_support/stream_chunk_policy.py +18 -0
- package/ciel_runtime_support/streaming_anthropic.py +1955 -0
- package/ciel_runtime_support/synthetic_tool_policy.py +105 -0
- package/ciel_runtime_support/terminal_platform_io.py +127 -0
- package/ciel_runtime_support/timeout_profile.py +196 -0
- package/ciel_runtime_support/tool_dialects.py +85 -0
- package/ciel_runtime_support/tool_exposure_policy.py +63 -0
- package/ciel_runtime_support/tool_guard_hooks.py +218 -0
- package/ciel_runtime_support/tool_request_projection.py +96 -0
- package/ciel_runtime_support/tool_schema.py +483 -0
- package/ciel_runtime_support/tool_side_effect_dedupe.py +95 -0
- package/ciel_runtime_support/ui_text.py +266 -0
- package/ciel_runtime_support/upstream_error_policy.py +104 -0
- package/ciel_runtime_support/upstream_retry.py +419 -0
- package/ciel_runtime_support/upstream_stream_io.py +106 -0
- package/ciel_runtime_support/usage_events.py +96 -0
- package/ciel_runtime_support/visible_stream_filters.py +130 -0
- package/ciel_runtime_support/web_endpoints.py +447 -0
- package/ciel_runtime_support/web_ui.py +915 -0
- package/ciel_runtime_support/web_ui_controller.py +189 -0
- package/ciel_runtime_support/windows_console_input.py +137 -0
- package/ciel_runtime_support/windows_console_mode.py +112 -0
- package/docs/Architecture.md +54 -0
- package/docs/Configuration.md +17 -0
- package/docs/Module-Map.md +1093 -26
- package/docs/Providers.md +46 -1
- package/docs/adr/0001-runtime-bounded-contexts.md +295 -0
- package/npm-bin/run-ciel-runtime.js +22 -2
- package/package.json +9 -2
|
@@ -0,0 +1,972 @@
|
|
|
1
|
+
"""Plan-mode and TaskList conversation-turn state machine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, Callable
|
|
8
|
+
|
|
9
|
+
CHANNEL_LLM_WAKE_PREFIX = "[external input pending]"
|
|
10
|
+
CHANNEL_LLM_WAKE_LEGACY_PREFIXES = ("[ciel-runtime channel wake]", "[channel pending]")
|
|
11
|
+
PLAN_GUARD_MARKER = "[ciel-runtime-plan-guard]"
|
|
12
|
+
SYSTEM_REMINDER_BLOCK_RE = re.compile(
|
|
13
|
+
r"<system-reminder>.*?</system-reminder>", re.DOTALL
|
|
14
|
+
)
|
|
15
|
+
CLAUDE_CODE_SUGGESTION_MODE_PREFIX = "[SUGGESTION MODE:"
|
|
16
|
+
WORK_CONTINUATION_RESULT_TOOLS = frozenset(
|
|
17
|
+
{
|
|
18
|
+
"Bash",
|
|
19
|
+
"Glob",
|
|
20
|
+
"Grep",
|
|
21
|
+
"LS",
|
|
22
|
+
"Read",
|
|
23
|
+
"Write",
|
|
24
|
+
"Edit",
|
|
25
|
+
"MultiEdit",
|
|
26
|
+
"TaskCreate",
|
|
27
|
+
"TaskList",
|
|
28
|
+
"TaskUpdate",
|
|
29
|
+
"TaskStop",
|
|
30
|
+
"ExitPlanMode",
|
|
31
|
+
}
|
|
32
|
+
)
|
|
33
|
+
WORK_COMPLETION_RESULT_TOOLS = frozenset(
|
|
34
|
+
{"Write", "Edit", "MultiEdit", "TaskUpdate", "TaskStop"}
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, slots=True)
|
|
39
|
+
class ConversationTurnPorts:
|
|
40
|
+
content_blocks: Callable[[dict[str, Any]], list[Any]]
|
|
41
|
+
lookup_tool_schema: Callable[[str], dict[str, Any] | None]
|
|
42
|
+
tool_schema: Callable[[dict[str, Any], str], dict[str, Any] | None]
|
|
43
|
+
log: Callable[[str, str], None]
|
|
44
|
+
has_tool: Callable[[dict[str, Any], str], bool]
|
|
45
|
+
ultracode_preferred: Callable[[dict[str, Any]], bool]
|
|
46
|
+
content_to_text: Callable[[Any], str]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ConversationTurnPolicy:
|
|
50
|
+
def __init__(self, ports: ConversationTurnPorts) -> None:
|
|
51
|
+
self.ports = ports
|
|
52
|
+
|
|
53
|
+
def plan_mode_active(self, body: dict[str, Any]) -> bool:
|
|
54
|
+
"""Infer Claude Code Plan Mode from tool history and plan-mode attachments."""
|
|
55
|
+
active = False
|
|
56
|
+
tool_names_by_id: dict[str, str] = {}
|
|
57
|
+
for message in body.get("messages") or []:
|
|
58
|
+
if not isinstance(message, dict):
|
|
59
|
+
continue
|
|
60
|
+
attachment = message.get("attachment")
|
|
61
|
+
if isinstance(attachment, dict):
|
|
62
|
+
attachment_type = attachment.get("type")
|
|
63
|
+
if attachment_type in {"plan_mode", "plan_mode_reentry"}:
|
|
64
|
+
active = True
|
|
65
|
+
elif attachment_type == "plan_mode_exit":
|
|
66
|
+
active = False
|
|
67
|
+
if message.get("role") == "assistant":
|
|
68
|
+
for block in self.ports.content_blocks(message):
|
|
69
|
+
if not isinstance(block, dict) or block.get("type") != "tool_use":
|
|
70
|
+
continue
|
|
71
|
+
tool_id = str(block.get("id") or "")
|
|
72
|
+
name = str(block.get("name") or "")
|
|
73
|
+
if tool_id and name:
|
|
74
|
+
tool_names_by_id[tool_id] = name
|
|
75
|
+
elif message.get("role") == "user":
|
|
76
|
+
for block in self.ports.content_blocks(message):
|
|
77
|
+
if not isinstance(block, dict):
|
|
78
|
+
continue
|
|
79
|
+
if block.get("type") == "tool_result":
|
|
80
|
+
tool_use_id = str(block.get("tool_use_id") or "")
|
|
81
|
+
tool_name = tool_names_by_id.get(tool_use_id)
|
|
82
|
+
if tool_name == "EnterPlanMode":
|
|
83
|
+
active = True
|
|
84
|
+
elif tool_name == "ExitPlanMode":
|
|
85
|
+
active = False
|
|
86
|
+
elif block.get("type") in {"plan_mode", "plan_mode_reentry"}:
|
|
87
|
+
active = True
|
|
88
|
+
elif block.get("type") == "plan_mode_exit":
|
|
89
|
+
active = False
|
|
90
|
+
return active
|
|
91
|
+
|
|
92
|
+
def channel_llm_wake_text(self, text: str) -> bool:
|
|
93
|
+
text = re.sub(r"^[\x00-\x1f\x7f\s]+", "", text)
|
|
94
|
+
return text.startswith(CHANNEL_LLM_WAKE_PREFIX) or any(
|
|
95
|
+
text.startswith(prefix) for prefix in CHANNEL_LLM_WAKE_LEGACY_PREFIXES
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def channel_llm_wake_request(self, body: dict[str, Any]) -> bool:
|
|
99
|
+
return self.channel_llm_wake_text(self.latest_user_text(body))
|
|
100
|
+
|
|
101
|
+
def body_without_channel_llm_wake_prompt(
|
|
102
|
+
self, body: dict[str, Any]
|
|
103
|
+
) -> dict[str, Any]:
|
|
104
|
+
if not self.channel_llm_wake_request(body):
|
|
105
|
+
return body
|
|
106
|
+
messages = [m for m in body.get("messages", []) if isinstance(m, dict)]
|
|
107
|
+
removed = False
|
|
108
|
+
for index in range(len(messages) - 1, -1, -1):
|
|
109
|
+
if self.channel_llm_wake_text(
|
|
110
|
+
self.user_intent_text_from_message(messages[index])
|
|
111
|
+
):
|
|
112
|
+
del messages[index]
|
|
113
|
+
removed = True
|
|
114
|
+
break
|
|
115
|
+
if not removed:
|
|
116
|
+
return body
|
|
117
|
+
out = dict(body)
|
|
118
|
+
out["messages"] = messages
|
|
119
|
+
self.ports.log("INFO", "channel_llm_wake_prompt_stripped")
|
|
120
|
+
return out
|
|
121
|
+
|
|
122
|
+
def has_plan_mode_exit(self, body: dict[str, Any]) -> bool:
|
|
123
|
+
for message in body.get("messages") or []:
|
|
124
|
+
if not isinstance(message, dict):
|
|
125
|
+
continue
|
|
126
|
+
attachment = message.get("attachment")
|
|
127
|
+
if (
|
|
128
|
+
isinstance(attachment, dict)
|
|
129
|
+
and attachment.get("type") == "plan_mode_exit"
|
|
130
|
+
):
|
|
131
|
+
return True
|
|
132
|
+
if message.get("role") != "assistant":
|
|
133
|
+
continue
|
|
134
|
+
for block in self.ports.content_blocks(message):
|
|
135
|
+
if (
|
|
136
|
+
isinstance(block, dict)
|
|
137
|
+
and block.get("type") == "tool_use"
|
|
138
|
+
and block.get("name") == "ExitPlanMode"
|
|
139
|
+
):
|
|
140
|
+
return True
|
|
141
|
+
return False
|
|
142
|
+
|
|
143
|
+
def allowed_prompt_tools_for_exit_plan_mode(
|
|
144
|
+
self, body: dict[str, Any]
|
|
145
|
+
) -> list[str]:
|
|
146
|
+
schema = self.ports.tool_schema(
|
|
147
|
+
body, "ExitPlanMode"
|
|
148
|
+
) or self.ports.lookup_tool_schema("ExitPlanMode")
|
|
149
|
+
if not isinstance(schema, dict):
|
|
150
|
+
return []
|
|
151
|
+
properties = (
|
|
152
|
+
schema.get("properties")
|
|
153
|
+
if isinstance(schema.get("properties"), dict)
|
|
154
|
+
else {}
|
|
155
|
+
)
|
|
156
|
+
allowed_schema = (
|
|
157
|
+
properties.get("allowedPrompts")
|
|
158
|
+
if isinstance(properties.get("allowedPrompts"), dict)
|
|
159
|
+
else None
|
|
160
|
+
)
|
|
161
|
+
if not allowed_schema:
|
|
162
|
+
return []
|
|
163
|
+
items = (
|
|
164
|
+
allowed_schema.get("items")
|
|
165
|
+
if isinstance(allowed_schema.get("items"), dict)
|
|
166
|
+
else {}
|
|
167
|
+
)
|
|
168
|
+
item_properties = (
|
|
169
|
+
items.get("properties") if isinstance(items.get("properties"), dict) else {}
|
|
170
|
+
)
|
|
171
|
+
tool_schema = (
|
|
172
|
+
item_properties.get("tool")
|
|
173
|
+
if isinstance(item_properties.get("tool"), dict)
|
|
174
|
+
else {}
|
|
175
|
+
)
|
|
176
|
+
enum_values = tool_schema.get("enum")
|
|
177
|
+
if not isinstance(enum_values, list):
|
|
178
|
+
return []
|
|
179
|
+
return [
|
|
180
|
+
str(item) for item in enum_values if isinstance(item, str) and item.strip()
|
|
181
|
+
]
|
|
182
|
+
|
|
183
|
+
def exit_plan_mode_default_prompt_for_tool(self, tool_name: str) -> str:
|
|
184
|
+
return f"use {tool_name} as needed to implement and verify the approved plan"
|
|
185
|
+
|
|
186
|
+
def backfill_exit_plan_mode_allowed_prompts(
|
|
187
|
+
self, body: dict[str, Any], tool_input: dict[str, Any]
|
|
188
|
+
) -> dict[str, Any]:
|
|
189
|
+
existing = (
|
|
190
|
+
tool_input.get("allowedPrompts") if isinstance(tool_input, dict) else None
|
|
191
|
+
)
|
|
192
|
+
if isinstance(existing, list) and any(
|
|
193
|
+
isinstance(item, dict) for item in existing
|
|
194
|
+
):
|
|
195
|
+
return tool_input
|
|
196
|
+
allowed_tools = self.allowed_prompt_tools_for_exit_plan_mode(body)
|
|
197
|
+
if not allowed_tools:
|
|
198
|
+
return tool_input
|
|
199
|
+
out = dict(tool_input)
|
|
200
|
+
out["allowedPrompts"] = [
|
|
201
|
+
{
|
|
202
|
+
"tool": tool_name,
|
|
203
|
+
"prompt": self.exit_plan_mode_default_prompt_for_tool(tool_name),
|
|
204
|
+
}
|
|
205
|
+
for tool_name in allowed_tools
|
|
206
|
+
]
|
|
207
|
+
self.ports.log(
|
|
208
|
+
"INFO",
|
|
209
|
+
f"backfilled ExitPlanMode allowedPrompts tools={','.join(allowed_tools)}",
|
|
210
|
+
)
|
|
211
|
+
return out
|
|
212
|
+
|
|
213
|
+
def plan_mode_tool_name_for_emit(
|
|
214
|
+
self, body: dict[str, Any], name: str, tool_input: dict[str, Any]
|
|
215
|
+
) -> tuple[str | None, dict[str, Any]]:
|
|
216
|
+
active = self.plan_mode_active(body)
|
|
217
|
+
if name == "EnterPlanMode" and self.body_is_channel_prompt(body):
|
|
218
|
+
self.ports.log("WARN", "dropped EnterPlanMode for external channel prompt")
|
|
219
|
+
return None, tool_input
|
|
220
|
+
if (
|
|
221
|
+
name == "EnterPlanMode"
|
|
222
|
+
and "ExitPlanMode" in self.latest_user_tool_result_names(body)
|
|
223
|
+
):
|
|
224
|
+
self.ports.log(
|
|
225
|
+
"WARN", "dropped EnterPlanMode immediately after ExitPlanMode result"
|
|
226
|
+
)
|
|
227
|
+
return None, tool_input
|
|
228
|
+
if name == "EnterPlanMode" and active:
|
|
229
|
+
self.ports.log(
|
|
230
|
+
"WARN", "dropped repeated EnterPlanMode while plan mode is active"
|
|
231
|
+
)
|
|
232
|
+
return None, tool_input
|
|
233
|
+
if name == "EnterPlanMode" and self.ports.ultracode_preferred(body):
|
|
234
|
+
self.ports.log(
|
|
235
|
+
"WARN", "dropped EnterPlanMode because ultracode workflow is preferred"
|
|
236
|
+
)
|
|
237
|
+
return None, tool_input
|
|
238
|
+
if name == "ExitPlanMode" and not active:
|
|
239
|
+
self.ports.log("WARN", "dropped ExitPlanMode while plan mode is not active")
|
|
240
|
+
return None, tool_input
|
|
241
|
+
if name == "ExitPlanMode":
|
|
242
|
+
tool_input = self.backfill_exit_plan_mode_allowed_prompts(body, tool_input)
|
|
243
|
+
return name, tool_input
|
|
244
|
+
|
|
245
|
+
def is_guard_feedback_text(self, text: str) -> bool:
|
|
246
|
+
stripped = (text or "").strip()
|
|
247
|
+
return (
|
|
248
|
+
stripped.startswith("Stop hook feedback:")
|
|
249
|
+
or stripped.startswith("Ciel Runtime plan guard:")
|
|
250
|
+
or PLAN_GUARD_MARKER in stripped
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
SYSTEM_REMINDER_BLOCK_RE = re.compile(
|
|
254
|
+
r"<system-reminder>.*?</system-reminder>", re.DOTALL
|
|
255
|
+
)
|
|
256
|
+
CLAUDE_CODE_SUGGESTION_MODE_PREFIX = "[SUGGESTION MODE:"
|
|
257
|
+
|
|
258
|
+
def strip_claude_code_system_reminders(self, text: str) -> str:
|
|
259
|
+
return SYSTEM_REMINDER_BLOCK_RE.sub("", text or "").strip()
|
|
260
|
+
|
|
261
|
+
def is_claude_code_suggestion_mode_text(self, text: str) -> bool:
|
|
262
|
+
return (
|
|
263
|
+
self.strip_claude_code_system_reminders(text)
|
|
264
|
+
.lstrip()
|
|
265
|
+
.startswith(CLAUDE_CODE_SUGGESTION_MODE_PREFIX)
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
def user_intent_text_from_message(self, message: dict[str, Any]) -> str:
|
|
269
|
+
if not isinstance(message, dict) or message.get("role") != "user":
|
|
270
|
+
return ""
|
|
271
|
+
if message.get("isMeta") is True:
|
|
272
|
+
return ""
|
|
273
|
+
content = message.get("content")
|
|
274
|
+
if isinstance(content, str):
|
|
275
|
+
text = self.strip_claude_code_system_reminders(content)
|
|
276
|
+
return "" if self.is_guard_feedback_text(text) else text
|
|
277
|
+
if not isinstance(content, list):
|
|
278
|
+
# Claude Code can inject user-role attachment records such as
|
|
279
|
+
# plan_mode_exit. They are state metadata, not new user intent.
|
|
280
|
+
return ""
|
|
281
|
+
# Claude Code sends tool_result blocks as user-role messages. Those are not
|
|
282
|
+
# user intent. System reminders can also arrive as text blocks adjacent to
|
|
283
|
+
# the real prompt, so remove them before classifying resume prompts.
|
|
284
|
+
parts: list[str] = []
|
|
285
|
+
for block in content:
|
|
286
|
+
if isinstance(block, str):
|
|
287
|
+
text = self.strip_claude_code_system_reminders(block)
|
|
288
|
+
elif isinstance(block, dict) and block.get("type") == "text":
|
|
289
|
+
text = self.strip_claude_code_system_reminders(
|
|
290
|
+
str(block.get("text", ""))
|
|
291
|
+
)
|
|
292
|
+
else:
|
|
293
|
+
continue
|
|
294
|
+
if text and not self.is_guard_feedback_text(text):
|
|
295
|
+
parts.append(text)
|
|
296
|
+
return "\n".join(parts).strip()
|
|
297
|
+
|
|
298
|
+
def latest_user_text(self, body: dict[str, Any]) -> str:
|
|
299
|
+
for message in reversed(body.get("messages") or []):
|
|
300
|
+
text = (
|
|
301
|
+
self.user_intent_text_from_message(message)
|
|
302
|
+
if isinstance(message, dict)
|
|
303
|
+
else ""
|
|
304
|
+
)
|
|
305
|
+
if not text:
|
|
306
|
+
continue
|
|
307
|
+
return text
|
|
308
|
+
return ""
|
|
309
|
+
|
|
310
|
+
def latest_user_intent_message_index(self, body: dict[str, Any]) -> int | None:
|
|
311
|
+
messages = body.get("messages") or []
|
|
312
|
+
for index in range(len(messages) - 1, -1, -1):
|
|
313
|
+
message = messages[index]
|
|
314
|
+
if isinstance(message, dict) and self.user_intent_text_from_message(
|
|
315
|
+
message
|
|
316
|
+
):
|
|
317
|
+
return index
|
|
318
|
+
return None
|
|
319
|
+
|
|
320
|
+
def latest_user_is_claude_code_suggestion_mode(self, body: dict[str, Any]) -> bool:
|
|
321
|
+
latest = self.latest_user_text(body)
|
|
322
|
+
return bool(latest and self.is_claude_code_suggestion_mode_text(latest))
|
|
323
|
+
|
|
324
|
+
def likely_implementation_planning_request(self, text: str) -> bool:
|
|
325
|
+
normalized = re.sub(r"\s+", " ", text or "").strip()
|
|
326
|
+
if len(normalized) >= 120:
|
|
327
|
+
return True
|
|
328
|
+
# Multi-line prompts usually carry enough task structure that a one-line
|
|
329
|
+
# "I'll make a plan" style response is not a useful final answer.
|
|
330
|
+
non_empty_lines = [line for line in (text or "").splitlines() if line.strip()]
|
|
331
|
+
if len(non_empty_lines) >= 3 and len(normalized) >= 80:
|
|
332
|
+
return True
|
|
333
|
+
return False
|
|
334
|
+
|
|
335
|
+
def non_actionable_short_response(self, text: str) -> bool:
|
|
336
|
+
normalized = re.sub(r"\s+", " ", text or "").strip()
|
|
337
|
+
if not normalized:
|
|
338
|
+
return True
|
|
339
|
+
# Language-agnostic: for a long implementation request, a short single-line
|
|
340
|
+
# text response with no tool call is not actionable. Do not inspect words.
|
|
341
|
+
if len(normalized) <= 80 and "\n" not in (text or ""):
|
|
342
|
+
return True
|
|
343
|
+
if (
|
|
344
|
+
len(normalized) <= 160
|
|
345
|
+
and "\n" not in (text or "")
|
|
346
|
+
and not re.search(r"[`{};/\\\\]|https?://", normalized)
|
|
347
|
+
):
|
|
348
|
+
return True
|
|
349
|
+
return False
|
|
350
|
+
|
|
351
|
+
def body_is_channel_prompt(self, body: dict[str, Any]) -> bool:
|
|
352
|
+
metadata = (
|
|
353
|
+
body.get("metadata") if isinstance(body.get("metadata"), dict) else {}
|
|
354
|
+
)
|
|
355
|
+
latest_text = self.latest_user_text(body)
|
|
356
|
+
return bool(
|
|
357
|
+
metadata.get("ciel_runtime_channel_injected")
|
|
358
|
+
or latest_text.startswith("[external channel input]")
|
|
359
|
+
or latest_text.startswith("[ciel-runtime channel inbox]")
|
|
360
|
+
or latest_text.startswith(CHANNEL_LLM_WAKE_PREFIX)
|
|
361
|
+
or any(
|
|
362
|
+
latest_text.startswith(prefix)
|
|
363
|
+
for prefix in CHANNEL_LLM_WAKE_LEGACY_PREFIXES
|
|
364
|
+
)
|
|
365
|
+
or latest_text.startswith("[ciel-runtime external channel message")
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
def should_auto_enter_plan_mode(
|
|
369
|
+
self, body: dict[str, Any], response_text: str, tool_calls: list[dict[str, Any]]
|
|
370
|
+
) -> bool:
|
|
371
|
+
if tool_calls:
|
|
372
|
+
return False
|
|
373
|
+
if self.body_is_channel_prompt(body):
|
|
374
|
+
return False
|
|
375
|
+
if self.ports.ultracode_preferred(body):
|
|
376
|
+
return False
|
|
377
|
+
if not self.ports.has_tool(body, "EnterPlanMode"):
|
|
378
|
+
return False
|
|
379
|
+
if self.plan_mode_active(body):
|
|
380
|
+
return False
|
|
381
|
+
if self.has_plan_mode_exit(body):
|
|
382
|
+
return False
|
|
383
|
+
if self.latest_tool_result_indicates_completed_work(body):
|
|
384
|
+
return False
|
|
385
|
+
if not self.non_actionable_short_response(response_text):
|
|
386
|
+
return False
|
|
387
|
+
return self.likely_implementation_planning_request(self.latest_user_text(body))
|
|
388
|
+
|
|
389
|
+
def response_text_signals_plan_exit(self, text: str) -> bool:
|
|
390
|
+
lowered = str(text or "").lower()
|
|
391
|
+
if "exitplanmode" in lowered:
|
|
392
|
+
return True
|
|
393
|
+
mentions_plan_mode = (
|
|
394
|
+
"plan mode" in lowered
|
|
395
|
+
or "plan-mode" in lowered
|
|
396
|
+
or "플랜모드" in lowered
|
|
397
|
+
or "플랜 모드" in lowered
|
|
398
|
+
)
|
|
399
|
+
if not mentions_plan_mode:
|
|
400
|
+
return False
|
|
401
|
+
return any(
|
|
402
|
+
token in lowered
|
|
403
|
+
for token in ("exit", "leave", "exiting", "leaving", "종료", "탈출", "나가")
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
def should_auto_exit_plan_mode(
|
|
407
|
+
self, body: dict[str, Any], response_text: str, tool_calls: list[dict[str, Any]]
|
|
408
|
+
) -> bool:
|
|
409
|
+
if tool_calls:
|
|
410
|
+
return False
|
|
411
|
+
if not self.plan_mode_active(body):
|
|
412
|
+
return False
|
|
413
|
+
if not self.ports.has_tool(body, "ExitPlanMode"):
|
|
414
|
+
return False
|
|
415
|
+
if not self.response_text_signals_plan_exit(response_text):
|
|
416
|
+
return False
|
|
417
|
+
return True
|
|
418
|
+
|
|
419
|
+
WORK_CONTINUATION_RESULT_TOOLS: frozenset[str] = frozenset(
|
|
420
|
+
{
|
|
421
|
+
"Bash",
|
|
422
|
+
"Glob",
|
|
423
|
+
"Grep",
|
|
424
|
+
"LS",
|
|
425
|
+
"Read",
|
|
426
|
+
"Write",
|
|
427
|
+
"Edit",
|
|
428
|
+
"MultiEdit",
|
|
429
|
+
"TaskCreate",
|
|
430
|
+
"TaskList",
|
|
431
|
+
"TaskUpdate",
|
|
432
|
+
"TaskStop",
|
|
433
|
+
"ExitPlanMode",
|
|
434
|
+
}
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
WORK_COMPLETION_RESULT_TOOLS: frozenset[str] = frozenset(
|
|
438
|
+
{
|
|
439
|
+
"Write",
|
|
440
|
+
"Edit",
|
|
441
|
+
"MultiEdit",
|
|
442
|
+
"TaskUpdate",
|
|
443
|
+
"TaskStop",
|
|
444
|
+
}
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
def bash_command_looks_mutating(self, command: str) -> bool:
|
|
448
|
+
normalized = re.sub(r"\s+", " ", command or "").strip()
|
|
449
|
+
if not normalized:
|
|
450
|
+
return False
|
|
451
|
+
return bool(
|
|
452
|
+
re.search(
|
|
453
|
+
r"(^|[;&|]\s*|\b)(rm|rmdir|mv|cp|mkdir|touch|chmod|chown|ln|install|git\s+(commit|push|pull|merge|rebase|checkout|switch|restore|reset|clean)|npm\s+(install|update|run|publish)|pnpm\s+(install|update|run|publish)|yarn\s+(install|add|run|publish)|python\d*\s+-m\s+pip\s+install|pip\d*\s+install|docker\s+(run|compose|build|up|down|rm|rmi)|kubectl\s+(apply|delete|create|replace|patch))\b",
|
|
454
|
+
normalized,
|
|
455
|
+
)
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
def latest_user_tool_result_details(
|
|
459
|
+
self, body: dict[str, Any]
|
|
460
|
+
) -> list[dict[str, Any]]:
|
|
461
|
+
tools_by_id: dict[str, tuple[str, dict[str, Any]]] = {}
|
|
462
|
+
latest: list[dict[str, Any]] = []
|
|
463
|
+
for message in body.get("messages") or []:
|
|
464
|
+
if not isinstance(message, dict):
|
|
465
|
+
continue
|
|
466
|
+
content = message.get("content")
|
|
467
|
+
if message.get("role") == "assistant" and isinstance(content, list):
|
|
468
|
+
for block in content:
|
|
469
|
+
if not isinstance(block, dict) or block.get("type") != "tool_use":
|
|
470
|
+
continue
|
|
471
|
+
tool_id = str(block.get("id") or "")
|
|
472
|
+
name = str(block.get("name") or "")
|
|
473
|
+
tool_input = (
|
|
474
|
+
block.get("input")
|
|
475
|
+
if isinstance(block.get("input"), dict)
|
|
476
|
+
else {}
|
|
477
|
+
)
|
|
478
|
+
if tool_id and name:
|
|
479
|
+
tools_by_id[tool_id] = (name, tool_input)
|
|
480
|
+
elif message.get("role") == "user" and isinstance(content, list):
|
|
481
|
+
current: list[dict[str, Any]] = []
|
|
482
|
+
for block in content:
|
|
483
|
+
if (
|
|
484
|
+
not isinstance(block, dict)
|
|
485
|
+
or block.get("type") != "tool_result"
|
|
486
|
+
):
|
|
487
|
+
continue
|
|
488
|
+
tool_use_id = str(block.get("tool_use_id") or "")
|
|
489
|
+
name, tool_input = tools_by_id.get(tool_use_id, ("tool", {}))
|
|
490
|
+
current.append(
|
|
491
|
+
{
|
|
492
|
+
"name": name,
|
|
493
|
+
"input": tool_input,
|
|
494
|
+
"text": self.ports.content_to_text(
|
|
495
|
+
block.get("content", "")
|
|
496
|
+
),
|
|
497
|
+
"is_error": bool(block.get("is_error")),
|
|
498
|
+
}
|
|
499
|
+
)
|
|
500
|
+
if current:
|
|
501
|
+
latest = current
|
|
502
|
+
return latest
|
|
503
|
+
|
|
504
|
+
def latest_tool_result_indicates_completed_work(self, body: dict[str, Any]) -> bool:
|
|
505
|
+
details = self.latest_user_tool_result_details(body)
|
|
506
|
+
if not details:
|
|
507
|
+
return False
|
|
508
|
+
for item in details:
|
|
509
|
+
if item.get("is_error"):
|
|
510
|
+
continue
|
|
511
|
+
name = str(item.get("name") or "")
|
|
512
|
+
tool_input = (
|
|
513
|
+
item.get("input") if isinstance(item.get("input"), dict) else {}
|
|
514
|
+
)
|
|
515
|
+
if name in WORK_COMPLETION_RESULT_TOOLS:
|
|
516
|
+
return True
|
|
517
|
+
if name == "Bash" and self.bash_command_looks_mutating(
|
|
518
|
+
str(tool_input.get("command") or "")
|
|
519
|
+
):
|
|
520
|
+
return True
|
|
521
|
+
return False
|
|
522
|
+
|
|
523
|
+
def latest_user_tool_result_names(self, body: dict[str, Any]) -> list[str]:
|
|
524
|
+
tool_names_by_id: dict[str, str] = {}
|
|
525
|
+
latest: list[str] = []
|
|
526
|
+
for message in body.get("messages") or []:
|
|
527
|
+
if not isinstance(message, dict):
|
|
528
|
+
continue
|
|
529
|
+
content = message.get("content")
|
|
530
|
+
if message.get("role") == "assistant" and isinstance(content, list):
|
|
531
|
+
for block in content:
|
|
532
|
+
if not isinstance(block, dict) or block.get("type") != "tool_use":
|
|
533
|
+
continue
|
|
534
|
+
tool_id = str(block.get("id") or "")
|
|
535
|
+
name = str(block.get("name") or "")
|
|
536
|
+
if tool_id and name:
|
|
537
|
+
tool_names_by_id[tool_id] = name
|
|
538
|
+
elif message.get("role") == "user" and isinstance(content, list):
|
|
539
|
+
current: list[str] = []
|
|
540
|
+
for block in content:
|
|
541
|
+
if (
|
|
542
|
+
not isinstance(block, dict)
|
|
543
|
+
or block.get("type") != "tool_result"
|
|
544
|
+
):
|
|
545
|
+
continue
|
|
546
|
+
tool_use_id = str(block.get("tool_use_id") or "")
|
|
547
|
+
if tool_use_id:
|
|
548
|
+
current.append(tool_names_by_id.get(tool_use_id, "tool"))
|
|
549
|
+
if current:
|
|
550
|
+
latest = current
|
|
551
|
+
return latest
|
|
552
|
+
|
|
553
|
+
def latest_user_tool_result_text(self, body: dict[str, Any]) -> str:
|
|
554
|
+
latest = ""
|
|
555
|
+
for message in body.get("messages") or []:
|
|
556
|
+
if not isinstance(message, dict):
|
|
557
|
+
continue
|
|
558
|
+
if message.get("role") != "user" or not isinstance(
|
|
559
|
+
message.get("content"), list
|
|
560
|
+
):
|
|
561
|
+
continue
|
|
562
|
+
parts: list[str] = []
|
|
563
|
+
for block in message.get("content") or []:
|
|
564
|
+
if not isinstance(block, dict) or block.get("type") != "tool_result":
|
|
565
|
+
continue
|
|
566
|
+
parts.append(self.ports.content_to_text(block.get("content", "")))
|
|
567
|
+
if parts:
|
|
568
|
+
latest = "\n".join(part for part in parts if part)
|
|
569
|
+
return latest
|
|
570
|
+
|
|
571
|
+
def synthetic_tasklist_tool_use_id(self, tool_id: str, name: str) -> bool:
|
|
572
|
+
if name != "TaskList":
|
|
573
|
+
return False
|
|
574
|
+
prefixes = (
|
|
575
|
+
"toolu_ollama_keepalive_",
|
|
576
|
+
"toolu_ollama_choice_",
|
|
577
|
+
"toolu_openai_keepalive_",
|
|
578
|
+
"toolu_openai_choice_",
|
|
579
|
+
"toolu_anthropic_choice_",
|
|
580
|
+
"toolu_ciel_runtime_TaskList_",
|
|
581
|
+
)
|
|
582
|
+
return any(tool_id.startswith(prefix) for prefix in prefixes)
|
|
583
|
+
|
|
584
|
+
def recent_synthetic_tasklist_count(
|
|
585
|
+
self, body: dict[str, Any], after_message_index: int | None = None
|
|
586
|
+
) -> int:
|
|
587
|
+
count = 0
|
|
588
|
+
messages = body.get("messages") or []
|
|
589
|
+
if after_message_index is not None:
|
|
590
|
+
messages = messages[after_message_index + 1 :]
|
|
591
|
+
for message in reversed(messages):
|
|
592
|
+
if not isinstance(message, dict):
|
|
593
|
+
continue
|
|
594
|
+
if (
|
|
595
|
+
after_message_index is None
|
|
596
|
+
and message.get("role") == "user"
|
|
597
|
+
and self.user_intent_text_from_message(message)
|
|
598
|
+
):
|
|
599
|
+
break
|
|
600
|
+
content = message.get("content")
|
|
601
|
+
if message.get("role") != "assistant" or not isinstance(content, list):
|
|
602
|
+
continue
|
|
603
|
+
found_keepalive = False
|
|
604
|
+
for block in content:
|
|
605
|
+
if not isinstance(block, dict) or block.get("type") != "tool_use":
|
|
606
|
+
continue
|
|
607
|
+
if self.synthetic_tasklist_tool_use_id(
|
|
608
|
+
str(block.get("id") or ""), str(block.get("name") or "")
|
|
609
|
+
):
|
|
610
|
+
found_keepalive = True
|
|
611
|
+
if found_keepalive:
|
|
612
|
+
count += 1
|
|
613
|
+
return count
|
|
614
|
+
|
|
615
|
+
def tasklist_result_has_active_work(self, text: str) -> bool:
|
|
616
|
+
normalized = re.sub(r"\s+", " ", text or "").strip().lower()
|
|
617
|
+
if not normalized:
|
|
618
|
+
return False
|
|
619
|
+
# This parses Claude Code's task-list tool output, not user-facing prose.
|
|
620
|
+
if re.search(r"\[\s*(in progress|open|pending)\s*\]", normalized):
|
|
621
|
+
return True
|
|
622
|
+
for label in ("in progress", "open", "pending"):
|
|
623
|
+
for match in re.finditer(rf"\b(\d+)\s+{re.escape(label)}\b", normalized):
|
|
624
|
+
if int(match.group(1)) > 0:
|
|
625
|
+
return True
|
|
626
|
+
return False
|
|
627
|
+
|
|
628
|
+
def latest_tasklist_result_has_no_active_work(self, body: dict[str, Any]) -> bool:
|
|
629
|
+
latest_names = self.latest_user_tool_result_names(body)
|
|
630
|
+
if "TaskList" not in latest_names:
|
|
631
|
+
return False
|
|
632
|
+
return not self.tasklist_result_has_active_work(
|
|
633
|
+
self.latest_user_tool_result_text(body)
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
def latest_assistant_text(self, body: dict[str, Any]) -> str:
|
|
637
|
+
for message in reversed(body.get("messages") or []):
|
|
638
|
+
if not isinstance(message, dict) or message.get("role") != "assistant":
|
|
639
|
+
continue
|
|
640
|
+
return self.ports.content_to_text(message.get("content"))
|
|
641
|
+
return ""
|
|
642
|
+
|
|
643
|
+
def short_resume_prompt(self, text: str) -> bool:
|
|
644
|
+
normalized = re.sub(r"\s+", " ", text or "").strip()
|
|
645
|
+
if not normalized:
|
|
646
|
+
return False
|
|
647
|
+
if len(normalized) > 32:
|
|
648
|
+
return False
|
|
649
|
+
# Language-agnostic: a very short imperative with no question or code-like
|
|
650
|
+
# syntax after an unfinished assistant turn is a request to proceed.
|
|
651
|
+
return not re.search(r"[??`{};/\\\\]|https?://", normalized)
|
|
652
|
+
|
|
653
|
+
def latest_user_looks_like_work_request(self, body: dict[str, Any]) -> bool:
|
|
654
|
+
latest = self.latest_user_text(body)
|
|
655
|
+
normalized = re.sub(r"\s+", " ", latest or "").strip()
|
|
656
|
+
if not normalized:
|
|
657
|
+
return False
|
|
658
|
+
if self.likely_implementation_planning_request(latest):
|
|
659
|
+
return True
|
|
660
|
+
if self.short_resume_prompt(latest) and self.latest_assistant_text(body):
|
|
661
|
+
return True
|
|
662
|
+
if self.short_resume_prompt(latest) and self.latest_user_tool_result_names(
|
|
663
|
+
body
|
|
664
|
+
):
|
|
665
|
+
return True
|
|
666
|
+
return False
|
|
667
|
+
|
|
668
|
+
def response_asks_for_user_choice_or_permission(self, text: str) -> bool:
|
|
669
|
+
normalized = re.sub(r"\s+", " ", text or "").strip()
|
|
670
|
+
if not normalized:
|
|
671
|
+
return False
|
|
672
|
+
if "?" not in normalized and "?" not in normalized:
|
|
673
|
+
return False
|
|
674
|
+
# Structural guard: do not inspect language-specific words. A question-only
|
|
675
|
+
# end_turn inside Plan Mode is a pause, not progress.
|
|
676
|
+
return len(normalized) <= 1200
|
|
677
|
+
|
|
678
|
+
def should_auto_continue_choice_question_with_tasklist(
|
|
679
|
+
self, body: dict[str, Any], response_text: str, tool_calls: list[dict[str, Any]]
|
|
680
|
+
) -> bool:
|
|
681
|
+
if tool_calls:
|
|
682
|
+
return False
|
|
683
|
+
if self.latest_user_is_claude_code_suggestion_mode(body):
|
|
684
|
+
return False
|
|
685
|
+
if not self.ports.has_tool(body, "TaskList"):
|
|
686
|
+
return False
|
|
687
|
+
latest_names = self.latest_user_tool_result_names(body)
|
|
688
|
+
if self.latest_tool_result_indicates_completed_work(body):
|
|
689
|
+
return False
|
|
690
|
+
if "TaskList" in latest_names and not self.tasklist_result_has_active_work(
|
|
691
|
+
self.latest_user_tool_result_text(body)
|
|
692
|
+
):
|
|
693
|
+
return False
|
|
694
|
+
intent_index = self.latest_user_intent_message_index(body)
|
|
695
|
+
if (
|
|
696
|
+
self.recent_synthetic_tasklist_count(body, after_message_index=intent_index)
|
|
697
|
+
>= 2
|
|
698
|
+
):
|
|
699
|
+
return False
|
|
700
|
+
if not self.response_asks_for_user_choice_or_permission(response_text):
|
|
701
|
+
return False
|
|
702
|
+
if self.plan_mode_active(body):
|
|
703
|
+
return True
|
|
704
|
+
return bool(
|
|
705
|
+
self.latest_user_tool_result_names(body)
|
|
706
|
+
and self.latest_user_looks_like_work_request(body)
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
def should_synthesize_tasklist_for_provider(self, provider: str) -> bool:
|
|
710
|
+
# TaskList synthesis is a recovery path for non-Anthropic backends that
|
|
711
|
+
# return empty or prose-only turns. Anthropic routed mode can emit native
|
|
712
|
+
# tool_use blocks, so synthesizing an extra TaskList there corrupts the turn.
|
|
713
|
+
return (provider or "").strip().lower() != "anthropic"
|
|
714
|
+
|
|
715
|
+
def should_keep_work_alive_with_tasklist(
|
|
716
|
+
self, body: dict[str, Any], response_text: str, tool_calls: list[dict[str, Any]]
|
|
717
|
+
) -> bool:
|
|
718
|
+
if tool_calls:
|
|
719
|
+
return False
|
|
720
|
+
if self.latest_user_is_claude_code_suggestion_mode(body):
|
|
721
|
+
return False
|
|
722
|
+
if not self.ports.has_tool(body, "TaskList"):
|
|
723
|
+
return False
|
|
724
|
+
latest_names = self.latest_user_tool_result_names(body)
|
|
725
|
+
if not latest_names:
|
|
726
|
+
return False
|
|
727
|
+
latest_result_text = self.latest_user_tool_result_text(body)
|
|
728
|
+
if latest_names == ["TaskList"] and "No tasks found" in latest_result_text:
|
|
729
|
+
return False
|
|
730
|
+
if "TaskList" in latest_names:
|
|
731
|
+
if not self.tasklist_result_has_active_work(latest_result_text):
|
|
732
|
+
return False
|
|
733
|
+
max_keepalive = 6
|
|
734
|
+
intent_index = self.latest_user_intent_message_index(body)
|
|
735
|
+
if (
|
|
736
|
+
self.recent_synthetic_tasklist_count(
|
|
737
|
+
body, after_message_index=intent_index
|
|
738
|
+
)
|
|
739
|
+
>= max_keepalive
|
|
740
|
+
):
|
|
741
|
+
return False
|
|
742
|
+
if not any(name in WORK_CONTINUATION_RESULT_TOOLS for name in latest_names):
|
|
743
|
+
return False
|
|
744
|
+
if (
|
|
745
|
+
self.latest_tool_result_indicates_completed_work(body)
|
|
746
|
+
and response_text.strip()
|
|
747
|
+
):
|
|
748
|
+
return False
|
|
749
|
+
if self.non_actionable_short_response(response_text):
|
|
750
|
+
return True
|
|
751
|
+
normalized = re.sub(r"\s+", " ", response_text or "").strip()
|
|
752
|
+
# A resume/continue prompt after a tool result should not end the loop with
|
|
753
|
+
# prose only. Keep this structural and bounded: do not inspect task names,
|
|
754
|
+
# domains, languages, or provider-specific text.
|
|
755
|
+
return (
|
|
756
|
+
self.latest_user_looks_like_work_request(body) and len(normalized) <= 1200
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
def should_recover_empty_end_turn_with_tasklist(
|
|
760
|
+
self, body: dict[str, Any], response_text: str, tool_calls: list[dict[str, Any]]
|
|
761
|
+
) -> bool:
|
|
762
|
+
"""Recover from non-Anthropic providers returning an empty end_turn.
|
|
763
|
+
|
|
764
|
+
Claude Code treats an assistant end_turn with no text and no tool call as a
|
|
765
|
+
completed turn. For implementation/resume prompts, ask Claude Code for the
|
|
766
|
+
task list to give the model a concrete next tool result and keep the loop
|
|
767
|
+
alive.
|
|
768
|
+
"""
|
|
769
|
+
if tool_calls:
|
|
770
|
+
return False
|
|
771
|
+
if self.latest_user_is_claude_code_suggestion_mode(body):
|
|
772
|
+
return False
|
|
773
|
+
if response_text.strip():
|
|
774
|
+
return False
|
|
775
|
+
if not self.ports.has_tool(body, "TaskList"):
|
|
776
|
+
return False
|
|
777
|
+
latest_tool_results = self.latest_user_tool_result_names(body)
|
|
778
|
+
if latest_tool_results:
|
|
779
|
+
if (
|
|
780
|
+
"TaskList" in latest_tool_results
|
|
781
|
+
and not self.tasklist_result_has_active_work(
|
|
782
|
+
self.latest_user_tool_result_text(body)
|
|
783
|
+
)
|
|
784
|
+
):
|
|
785
|
+
return False
|
|
786
|
+
return True
|
|
787
|
+
latest = self.latest_user_text(body)
|
|
788
|
+
if not latest.strip():
|
|
789
|
+
return False
|
|
790
|
+
if self.short_resume_prompt(latest) and (
|
|
791
|
+
self.latest_assistant_text(body) or self.plan_mode_active(body)
|
|
792
|
+
):
|
|
793
|
+
return True
|
|
794
|
+
return self.likely_implementation_planning_request(latest)
|
|
795
|
+
|
|
796
|
+
def empty_end_turn_notice(
|
|
797
|
+
self,
|
|
798
|
+
) -> str:
|
|
799
|
+
return (
|
|
800
|
+
"[ciel-runtime] Upstream model returned an empty end_turn with no text or "
|
|
801
|
+
"tool call. No work was performed; please retry or ask me to continue."
|
|
802
|
+
)
|
|
803
|
+
|
|
804
|
+
def empty_end_turn_notice_for_body(self, body: dict[str, Any] | None) -> str:
|
|
805
|
+
if isinstance(body, dict) and self.latest_tasklist_result_has_no_active_work(
|
|
806
|
+
body
|
|
807
|
+
):
|
|
808
|
+
return (
|
|
809
|
+
"[ciel-runtime] TaskList returned no active tasks. No automatic continuation "
|
|
810
|
+
"is available; provide the next instruction or ask for current status."
|
|
811
|
+
)
|
|
812
|
+
return self.empty_end_turn_notice()
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
@dataclass(frozen=True, slots=True)
|
|
816
|
+
class ConversationTurnCompatibilityApi:
|
|
817
|
+
"""Typed compatibility adapter that preserves late-bound policy assembly."""
|
|
818
|
+
|
|
819
|
+
policy_factory: Callable[[], ConversationTurnPolicy]
|
|
820
|
+
|
|
821
|
+
def plan_mode_active(self, body: dict[str, Any]) -> bool:
|
|
822
|
+
return self.policy_factory().plan_mode_active(body)
|
|
823
|
+
|
|
824
|
+
def channel_llm_wake_text(self, text: str) -> bool:
|
|
825
|
+
return self.policy_factory().channel_llm_wake_text(text)
|
|
826
|
+
|
|
827
|
+
def channel_llm_wake_request(self, body: dict[str, Any]) -> bool:
|
|
828
|
+
return self.policy_factory().channel_llm_wake_request(body)
|
|
829
|
+
|
|
830
|
+
def body_without_channel_llm_wake_prompt(self, body: dict[str, Any]) -> dict[str, Any]:
|
|
831
|
+
return self.policy_factory().body_without_channel_llm_wake_prompt(body)
|
|
832
|
+
|
|
833
|
+
def has_plan_mode_exit(self, body: dict[str, Any]) -> bool:
|
|
834
|
+
return self.policy_factory().has_plan_mode_exit(body)
|
|
835
|
+
|
|
836
|
+
def allowed_prompt_tools_for_exit_plan_mode(self, body: dict[str, Any]) -> list[str]:
|
|
837
|
+
return self.policy_factory().allowed_prompt_tools_for_exit_plan_mode(body)
|
|
838
|
+
|
|
839
|
+
def exit_plan_mode_default_prompt_for_tool(self, tool_name: str) -> str:
|
|
840
|
+
return self.policy_factory().exit_plan_mode_default_prompt_for_tool(tool_name)
|
|
841
|
+
|
|
842
|
+
def backfill_exit_plan_mode_allowed_prompts(
|
|
843
|
+
self, body: dict[str, Any], tool_input: dict[str, Any]
|
|
844
|
+
) -> dict[str, Any]:
|
|
845
|
+
return self.policy_factory().backfill_exit_plan_mode_allowed_prompts(
|
|
846
|
+
body, tool_input
|
|
847
|
+
)
|
|
848
|
+
|
|
849
|
+
def plan_mode_tool_name_for_emit(
|
|
850
|
+
self, body: dict[str, Any], name: str, tool_input: dict[str, Any]
|
|
851
|
+
) -> tuple[str | None, dict[str, Any]]:
|
|
852
|
+
return self.policy_factory().plan_mode_tool_name_for_emit(body, name, tool_input)
|
|
853
|
+
|
|
854
|
+
def is_guard_feedback_text(self, text: str) -> bool:
|
|
855
|
+
return self.policy_factory().is_guard_feedback_text(text)
|
|
856
|
+
|
|
857
|
+
def strip_claude_code_system_reminders(self, text: str) -> str:
|
|
858
|
+
return self.policy_factory().strip_claude_code_system_reminders(text)
|
|
859
|
+
|
|
860
|
+
def is_claude_code_suggestion_mode_text(self, text: str) -> bool:
|
|
861
|
+
return self.policy_factory().is_claude_code_suggestion_mode_text(text)
|
|
862
|
+
|
|
863
|
+
def user_intent_text_from_message(self, message: dict[str, Any]) -> str:
|
|
864
|
+
return self.policy_factory().user_intent_text_from_message(message)
|
|
865
|
+
|
|
866
|
+
def latest_user_text(self, body: dict[str, Any]) -> str:
|
|
867
|
+
return self.policy_factory().latest_user_text(body)
|
|
868
|
+
|
|
869
|
+
def latest_user_intent_message_index(self, body: dict[str, Any]) -> int | None:
|
|
870
|
+
return self.policy_factory().latest_user_intent_message_index(body)
|
|
871
|
+
|
|
872
|
+
def latest_user_is_claude_code_suggestion_mode(self, body: dict[str, Any]) -> bool:
|
|
873
|
+
return self.policy_factory().latest_user_is_claude_code_suggestion_mode(body)
|
|
874
|
+
|
|
875
|
+
def likely_implementation_planning_request(self, text: str) -> bool:
|
|
876
|
+
return self.policy_factory().likely_implementation_planning_request(text)
|
|
877
|
+
|
|
878
|
+
def non_actionable_short_response(self, text: str) -> bool:
|
|
879
|
+
return self.policy_factory().non_actionable_short_response(text)
|
|
880
|
+
|
|
881
|
+
def body_is_channel_prompt(self, body: dict[str, Any]) -> bool:
|
|
882
|
+
return self.policy_factory().body_is_channel_prompt(body)
|
|
883
|
+
|
|
884
|
+
def should_auto_enter_plan_mode(
|
|
885
|
+
self, body: dict[str, Any], response_text: str, tool_calls: list[dict[str, Any]]
|
|
886
|
+
) -> bool:
|
|
887
|
+
return self.policy_factory().should_auto_enter_plan_mode(
|
|
888
|
+
body, response_text, tool_calls
|
|
889
|
+
)
|
|
890
|
+
|
|
891
|
+
def response_text_signals_plan_exit(self, text: str) -> bool:
|
|
892
|
+
return self.policy_factory().response_text_signals_plan_exit(text)
|
|
893
|
+
|
|
894
|
+
def should_auto_exit_plan_mode(
|
|
895
|
+
self, body: dict[str, Any], response_text: str, tool_calls: list[dict[str, Any]]
|
|
896
|
+
) -> bool:
|
|
897
|
+
return self.policy_factory().should_auto_exit_plan_mode(
|
|
898
|
+
body, response_text, tool_calls
|
|
899
|
+
)
|
|
900
|
+
|
|
901
|
+
def bash_command_looks_mutating(self, command: str) -> bool:
|
|
902
|
+
return self.policy_factory().bash_command_looks_mutating(command)
|
|
903
|
+
|
|
904
|
+
def latest_user_tool_result_details(self, body: dict[str, Any]) -> list[dict[str, Any]]:
|
|
905
|
+
return self.policy_factory().latest_user_tool_result_details(body)
|
|
906
|
+
|
|
907
|
+
def latest_tool_result_indicates_completed_work(self, body: dict[str, Any]) -> bool:
|
|
908
|
+
return self.policy_factory().latest_tool_result_indicates_completed_work(body)
|
|
909
|
+
|
|
910
|
+
def latest_user_tool_result_names(self, body: dict[str, Any]) -> list[str]:
|
|
911
|
+
return self.policy_factory().latest_user_tool_result_names(body)
|
|
912
|
+
|
|
913
|
+
def latest_user_tool_result_text(self, body: dict[str, Any]) -> str:
|
|
914
|
+
return self.policy_factory().latest_user_tool_result_text(body)
|
|
915
|
+
|
|
916
|
+
def synthetic_tasklist_tool_use_id(self, tool_id: str, name: str) -> bool:
|
|
917
|
+
return self.policy_factory().synthetic_tasklist_tool_use_id(tool_id, name)
|
|
918
|
+
|
|
919
|
+
def recent_synthetic_tasklist_count(
|
|
920
|
+
self, body: dict[str, Any], after_message_index: int | None = None
|
|
921
|
+
) -> int:
|
|
922
|
+
return self.policy_factory().recent_synthetic_tasklist_count(
|
|
923
|
+
body, after_message_index
|
|
924
|
+
)
|
|
925
|
+
|
|
926
|
+
def tasklist_result_has_active_work(self, text: str) -> bool:
|
|
927
|
+
return self.policy_factory().tasklist_result_has_active_work(text)
|
|
928
|
+
|
|
929
|
+
def latest_tasklist_result_has_no_active_work(self, body: dict[str, Any]) -> bool:
|
|
930
|
+
return self.policy_factory().latest_tasklist_result_has_no_active_work(body)
|
|
931
|
+
|
|
932
|
+
def latest_assistant_text(self, body: dict[str, Any]) -> str:
|
|
933
|
+
return self.policy_factory().latest_assistant_text(body)
|
|
934
|
+
|
|
935
|
+
def short_resume_prompt(self, text: str) -> bool:
|
|
936
|
+
return self.policy_factory().short_resume_prompt(text)
|
|
937
|
+
|
|
938
|
+
def latest_user_looks_like_work_request(self, body: dict[str, Any]) -> bool:
|
|
939
|
+
return self.policy_factory().latest_user_looks_like_work_request(body)
|
|
940
|
+
|
|
941
|
+
def response_asks_for_user_choice_or_permission(self, text: str) -> bool:
|
|
942
|
+
return self.policy_factory().response_asks_for_user_choice_or_permission(text)
|
|
943
|
+
|
|
944
|
+
def should_auto_continue_choice_question_with_tasklist(
|
|
945
|
+
self, body: dict[str, Any], response_text: str, tool_calls: list[dict[str, Any]]
|
|
946
|
+
) -> bool:
|
|
947
|
+
return self.policy_factory().should_auto_continue_choice_question_with_tasklist(
|
|
948
|
+
body, response_text, tool_calls
|
|
949
|
+
)
|
|
950
|
+
|
|
951
|
+
def should_synthesize_tasklist_for_provider(self, provider: str) -> bool:
|
|
952
|
+
return self.policy_factory().should_synthesize_tasklist_for_provider(provider)
|
|
953
|
+
|
|
954
|
+
def should_keep_work_alive_with_tasklist(
|
|
955
|
+
self, body: dict[str, Any], response_text: str, tool_calls: list[dict[str, Any]]
|
|
956
|
+
) -> bool:
|
|
957
|
+
return self.policy_factory().should_keep_work_alive_with_tasklist(
|
|
958
|
+
body, response_text, tool_calls
|
|
959
|
+
)
|
|
960
|
+
|
|
961
|
+
def should_recover_empty_end_turn_with_tasklist(
|
|
962
|
+
self, body: dict[str, Any], response_text: str, tool_calls: list[dict[str, Any]]
|
|
963
|
+
) -> bool:
|
|
964
|
+
return self.policy_factory().should_recover_empty_end_turn_with_tasklist(
|
|
965
|
+
body, response_text, tool_calls
|
|
966
|
+
)
|
|
967
|
+
|
|
968
|
+
def empty_end_turn_notice(self) -> str:
|
|
969
|
+
return self.policy_factory().empty_end_turn_notice()
|
|
970
|
+
|
|
971
|
+
def empty_end_turn_notice_for_body(self, body: dict[str, Any] | None) -> str:
|
|
972
|
+
return self.policy_factory().empty_end_turn_notice_for_body(body)
|