@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.
Files changed (287) hide show
  1. package/README.md +132 -0
  2. package/ciel-runtime-menu.py +56 -6
  3. package/ciel_runtime.py +9379 -35909
  4. package/ciel_runtime_support/advisor_client.py +193 -0
  5. package/ciel_runtime_support/advisor_policy.py +320 -0
  6. package/ciel_runtime_support/advisor_refinement.py +160 -0
  7. package/ciel_runtime_support/advisor_request_builder.py +261 -0
  8. package/ciel_runtime_support/agy_installer.py +169 -0
  9. package/ciel_runtime_support/agy_mcp_restore.py +182 -0
  10. package/ciel_runtime_support/anthropic_model_policy.py +186 -0
  11. package/ciel_runtime_support/anthropic_response_writer.py +255 -0
  12. package/ciel_runtime_support/anthropic_tool_turns.py +130 -0
  13. package/ciel_runtime_support/api_key_cooldown.py +159 -0
  14. package/ciel_runtime_support/architecture.py +488 -1
  15. package/ciel_runtime_support/architecture_budget.py +42 -0
  16. package/ciel_runtime_support/channel_backlog.py +90 -0
  17. package/ciel_runtime_support/channel_cli.py +119 -0
  18. package/ciel_runtime_support/channel_compact_injection.py +82 -0
  19. package/ciel_runtime_support/channel_compact_poll.py +67 -0
  20. package/ciel_runtime_support/channel_compact_request_repository.py +113 -0
  21. package/ciel_runtime_support/channel_config_service.py +281 -0
  22. package/ciel_runtime_support/channel_connection_lifecycle.py +180 -0
  23. package/ciel_runtime_support/channel_connection_registry.py +128 -0
  24. package/ciel_runtime_support/channel_connection_worker.py +284 -0
  25. package/ciel_runtime_support/channel_cursor_recovery.py +92 -0
  26. package/ciel_runtime_support/channel_cursor_repository.py +89 -0
  27. package/ciel_runtime_support/channel_cursor_service.py +178 -0
  28. package/ciel_runtime_support/channel_event_identity.py +212 -0
  29. package/ciel_runtime_support/channel_event_projection.py +315 -0
  30. package/ciel_runtime_support/channel_inflight.py +127 -0
  31. package/ciel_runtime_support/channel_injection.py +115 -0
  32. package/ciel_runtime_support/channel_launch_guard_repository.py +58 -0
  33. package/ciel_runtime_support/channel_launch_policy.py +180 -0
  34. package/ciel_runtime_support/channel_llm_context.py +156 -0
  35. package/ciel_runtime_support/channel_mcp_discovery.py +186 -0
  36. package/ciel_runtime_support/channel_mcp_http_controller.py +239 -0
  37. package/ciel_runtime_support/channel_mcp_ownership.py +148 -0
  38. package/ciel_runtime_support/channel_mcp_tools.py +240 -0
  39. package/ciel_runtime_support/channel_mcp_transport.py +394 -0
  40. package/ciel_runtime_support/channel_message_dedupe.py +65 -0
  41. package/ciel_runtime_support/channel_message_policy.py +256 -0
  42. package/ciel_runtime_support/channel_message_prompt.py +305 -0
  43. package/ciel_runtime_support/channel_message_repository.py +234 -0
  44. package/ciel_runtime_support/channel_notification_projection.py +217 -0
  45. package/ciel_runtime_support/channel_panel.py +162 -0
  46. package/ciel_runtime_support/channel_pending_injection.py +209 -0
  47. package/ciel_runtime_support/channel_pending_poll.py +109 -0
  48. package/ciel_runtime_support/channel_probe_cache.py +433 -0
  49. package/ciel_runtime_support/channel_probe_report.py +101 -0
  50. package/ciel_runtime_support/channel_runtime_environment.py +181 -0
  51. package/ciel_runtime_support/channel_session_lifecycle.py +114 -0
  52. package/ciel_runtime_support/channel_session_repository.py +90 -0
  53. package/ciel_runtime_support/channel_terminal_dispatch.py +115 -0
  54. package/ciel_runtime_support/channel_terminal_input.py +277 -0
  55. package/ciel_runtime_support/channel_terminal_proxy.py +447 -0
  56. package/ciel_runtime_support/channel_tool_context.py +166 -0
  57. package/ciel_runtime_support/channel_transcript.py +414 -0
  58. package/ciel_runtime_support/channel_transcript_repository.py +96 -0
  59. package/ciel_runtime_support/channel_wake_claim_repository.py +126 -0
  60. package/ciel_runtime_support/channel_wake_delivery_repository.py +88 -0
  61. package/ciel_runtime_support/chat_files.py +138 -0
  62. package/ciel_runtime_support/chat_http_controller.py +235 -0
  63. package/ciel_runtime_support/claude_environment.py +375 -0
  64. package/ciel_runtime_support/claude_router.py +247 -193
  65. package/ciel_runtime_support/cli_dispatch.py +792 -0
  66. package/ciel_runtime_support/cli_parser.py +165 -0
  67. package/ciel_runtime_support/cli_usage.py +100 -0
  68. package/ciel_runtime_support/codex_app_server.py +20 -5
  69. package/ciel_runtime_support/codex_channel_sse_launch.py +87 -0
  70. package/ciel_runtime_support/codex_cli.py +42 -6
  71. package/ciel_runtime_support/codex_config.py +323 -0
  72. package/ciel_runtime_support/codex_launch_configuration.py +240 -0
  73. package/ciel_runtime_support/codex_launch_policy.py +66 -0
  74. package/ciel_runtime_support/codex_mcp_integration.py +195 -0
  75. package/ciel_runtime_support/codex_mcp_restore.py +304 -0
  76. package/ciel_runtime_support/codex_model_catalog.py +133 -0
  77. package/ciel_runtime_support/codex_process_lifecycle.py +271 -0
  78. package/ciel_runtime_support/codex_router.py +147 -1
  79. package/ciel_runtime_support/codex_session_repository.py +115 -0
  80. package/ciel_runtime_support/codex_session_selection.py +114 -0
  81. package/ciel_runtime_support/command_asset_installer.py +103 -0
  82. package/ciel_runtime_support/compatibility_probe.py +295 -0
  83. package/ciel_runtime_support/compatibility_protocol.py +251 -0
  84. package/ciel_runtime_support/compatibility_runtime.py +166 -0
  85. package/ciel_runtime_support/compatibility_test.py +370 -0
  86. package/ciel_runtime_support/config_migrations.py +307 -0
  87. package/ciel_runtime_support/config_repository.py +175 -0
  88. package/ciel_runtime_support/config_value_codec.py +64 -0
  89. package/ciel_runtime_support/configuration_cli.py +374 -0
  90. package/ciel_runtime_support/context_compaction.py +280 -0
  91. package/ciel_runtime_support/context_setup.py +208 -0
  92. package/ciel_runtime_support/context_summary_policy.py +392 -0
  93. package/ciel_runtime_support/credential_cli.py +104 -0
  94. package/ciel_runtime_support/credential_management.py +261 -0
  95. package/ciel_runtime_support/credentials.py +269 -0
  96. package/ciel_runtime_support/executable_discovery.py +141 -0
  97. package/ciel_runtime_support/github_copilot_oauth.py +335 -0
  98. package/ciel_runtime_support/github_copilot_oauth_runtime.py +213 -0
  99. package/ciel_runtime_support/header_forwarding.py +73 -0
  100. package/ciel_runtime_support/headless_config.py +221 -0
  101. package/ciel_runtime_support/http_response.py +129 -0
  102. package/ciel_runtime_support/install_diagnostics.py +149 -0
  103. package/ciel_runtime_support/kimi_identity.py +123 -0
  104. package/ciel_runtime_support/launch_diagnostics.py +204 -0
  105. package/ciel_runtime_support/launch_state.py +127 -0
  106. package/ciel_runtime_support/live_api_key_controller.py +58 -0
  107. package/ciel_runtime_support/llm_config_http.py +148 -0
  108. package/ciel_runtime_support/llm_option_config.py +259 -0
  109. package/ciel_runtime_support/llm_presentation_data.py +447 -0
  110. package/ciel_runtime_support/llm_presets.py +773 -0
  111. package/ciel_runtime_support/lm_studio_runtime.py +401 -0
  112. package/ciel_runtime_support/managed_mcp_config.py +144 -0
  113. package/ciel_runtime_support/managed_mcp_discovery.py +151 -0
  114. package/ciel_runtime_support/managed_service_cleanup.py +89 -0
  115. package/ciel_runtime_support/mcp_config_reader.py +230 -0
  116. package/ciel_runtime_support/mcp_http_proxy.py +607 -0
  117. package/ciel_runtime_support/mcp_inventory.py +59 -0
  118. package/ciel_runtime_support/mcp_notification_wait_policy.py +159 -0
  119. package/ciel_runtime_support/mcp_probe_codec.py +136 -0
  120. package/ciel_runtime_support/mcp_probe_transport.py +328 -0
  121. package/ciel_runtime_support/mcp_proxy_codec.py +345 -0
  122. package/ciel_runtime_support/mcp_proxy_config.py +107 -0
  123. package/ciel_runtime_support/mcp_proxy_notifications.py +261 -0
  124. package/ciel_runtime_support/mcp_proxy_process.py +560 -0
  125. package/ciel_runtime_support/mcp_split_proxy_http.py +165 -0
  126. package/ciel_runtime_support/mcp_stdio_probe.py +240 -0
  127. package/ciel_runtime_support/mcp_transport.py +146 -0
  128. package/ciel_runtime_support/model_cache_lifecycle.py +78 -0
  129. package/ciel_runtime_support/model_catalog_projection.py +61 -0
  130. package/ciel_runtime_support/model_context_hints.py +109 -0
  131. package/ciel_runtime_support/model_panel.py +147 -0
  132. package/ciel_runtime_support/model_registry_repository.py +231 -0
  133. package/ciel_runtime_support/npm_runtime.py +191 -0
  134. package/ciel_runtime_support/ollama_catalog.py +462 -0
  135. package/ciel_runtime_support/ollama_catalog_cli.py +41 -0
  136. package/ciel_runtime_support/ollama_catalog_repository.py +75 -0
  137. package/ciel_runtime_support/ollama_context_sync.py +87 -0
  138. package/ciel_runtime_support/ollama_forwarding.py +449 -0
  139. package/ciel_runtime_support/openai_chat_passthrough.py +67 -0
  140. package/ciel_runtime_support/openai_chat_router.py +64 -0
  141. package/ciel_runtime_support/openai_forwarding.py +194 -0
  142. package/ciel_runtime_support/openai_responses_router.py +291 -0
  143. package/ciel_runtime_support/openai_responses_stream.py +135 -0
  144. package/ciel_runtime_support/output_budget.py +89 -0
  145. package/ciel_runtime_support/package_lifecycle.py +217 -0
  146. package/ciel_runtime_support/plan_artifact_controller.py +104 -0
  147. package/ciel_runtime_support/prelaunch.py +959 -0
  148. package/ciel_runtime_support/prelaunch_launch_preference.py +75 -0
  149. package/ciel_runtime_support/prelaunch_panel_projection.py +396 -0
  150. package/ciel_runtime_support/prelaunch_terminal.py +764 -0
  151. package/ciel_runtime_support/process_control.py +708 -0
  152. package/ciel_runtime_support/prompt_compaction.py +322 -0
  153. package/ciel_runtime_support/prompt_injection.py +176 -0
  154. package/ciel_runtime_support/protocols/__init__.py +24 -0
  155. package/ciel_runtime_support/protocols/anthropic_content.py +29 -0
  156. package/ciel_runtime_support/protocols/anthropic_thinking_policy.py +336 -0
  157. package/ciel_runtime_support/protocols/chat_projection.py +315 -0
  158. package/ciel_runtime_support/protocols/conversation_policy.py +217 -0
  159. package/ciel_runtime_support/protocols/conversation_turn_policy.py +972 -0
  160. package/ciel_runtime_support/protocols/ollama_chat.py +111 -0
  161. package/ciel_runtime_support/protocols/ollama_response.py +231 -0
  162. package/ciel_runtime_support/protocols/openai_reasoning.py +75 -0
  163. package/ciel_runtime_support/protocols/openai_responses.py +271 -0
  164. package/ciel_runtime_support/protocols/pseudo_tool_history.py +248 -0
  165. package/ciel_runtime_support/protocols/tool_result_projection.py +112 -0
  166. package/ciel_runtime_support/provider_adapters.py +160 -0
  167. package/ciel_runtime_support/provider_catalog_sources.py +316 -0
  168. package/ciel_runtime_support/provider_choice.py +201 -0
  169. package/ciel_runtime_support/provider_compatibility.py +165 -0
  170. package/ciel_runtime_support/provider_config_mutations.py +361 -0
  171. package/ciel_runtime_support/provider_configuration_service.py +162 -0
  172. package/ciel_runtime_support/provider_context.py +308 -0
  173. package/ciel_runtime_support/provider_contract_projection.py +73 -0
  174. package/ciel_runtime_support/provider_descriptor.py +82 -0
  175. package/ciel_runtime_support/provider_endpoint_policy.py +130 -0
  176. package/ciel_runtime_support/provider_endpoint_probe.py +165 -0
  177. package/ciel_runtime_support/provider_launch_endpoint.py +109 -0
  178. package/ciel_runtime_support/provider_limits.py +457 -0
  179. package/ciel_runtime_support/provider_model_identity.py +139 -0
  180. package/ciel_runtime_support/provider_model_selection.py +431 -0
  181. package/ciel_runtime_support/provider_model_specs.py +142 -0
  182. package/ciel_runtime_support/provider_models.py +263 -0
  183. package/ciel_runtime_support/provider_network.py +176 -0
  184. package/ciel_runtime_support/provider_option_cli.py +238 -0
  185. package/ciel_runtime_support/provider_option_panel.py +275 -0
  186. package/ciel_runtime_support/provider_option_status.py +192 -0
  187. package/ciel_runtime_support/provider_policy.py +101 -0
  188. package/ciel_runtime_support/provider_query_policy.py +67 -0
  189. package/ciel_runtime_support/provider_readiness.py +112 -0
  190. package/ciel_runtime_support/provider_request_access.py +131 -0
  191. package/ciel_runtime_support/provider_request_builder.py +250 -0
  192. package/ciel_runtime_support/provider_responses_passthrough.py +81 -0
  193. package/ciel_runtime_support/provider_runtime_info.py +113 -0
  194. package/ciel_runtime_support/provider_runtime_modes.py +150 -0
  195. package/ciel_runtime_support/provider_sampling_policy.py +46 -0
  196. package/ciel_runtime_support/provider_status.py +145 -0
  197. package/ciel_runtime_support/provider_timeout_policy.py +184 -0
  198. package/ciel_runtime_support/provider_tool_policy.py +145 -0
  199. package/ciel_runtime_support/providers/__init__.py +65 -0
  200. package/ciel_runtime_support/providers/anthropic.py +160 -0
  201. package/ciel_runtime_support/providers/anthropic_catalog.py +119 -0
  202. package/ciel_runtime_support/providers/base.py +232 -0
  203. package/ciel_runtime_support/providers/catalog.py +326 -0
  204. package/ciel_runtime_support/providers/cloud.py +194 -0
  205. package/ciel_runtime_support/providers/constants.py +54 -0
  206. package/ciel_runtime_support/providers/deepseek.py +115 -0
  207. package/ciel_runtime_support/providers/fireworks.py +158 -0
  208. package/ciel_runtime_support/providers/github_copilot_oauth.py +199 -0
  209. package/ciel_runtime_support/providers/kimi.py +297 -0
  210. package/ciel_runtime_support/providers/lm_studio.py +76 -0
  211. package/ciel_runtime_support/providers/meta.py +257 -0
  212. package/ciel_runtime_support/providers/native.py +194 -0
  213. package/ciel_runtime_support/providers/nim.py +69 -0
  214. package/ciel_runtime_support/providers/nvidia.py +158 -0
  215. package/ciel_runtime_support/providers/nvidia_runtime.py +285 -0
  216. package/ciel_runtime_support/providers/ollama.py +181 -0
  217. package/ciel_runtime_support/providers/ollama_context.py +195 -0
  218. package/ciel_runtime_support/providers/ollama_runtime.py +213 -0
  219. package/ciel_runtime_support/providers/opencode.py +220 -0
  220. package/ciel_runtime_support/providers/opencode_go.py +37 -0
  221. package/ciel_runtime_support/providers/openrouter.py +57 -0
  222. package/ciel_runtime_support/providers/vllm.py +65 -0
  223. package/ciel_runtime_support/providers/zai.py +119 -0
  224. package/ciel_runtime_support/pseudo_tool_parser.py +115 -0
  225. package/ciel_runtime_support/rate_limit_policy.py +117 -0
  226. package/ciel_runtime_support/rate_limit_repository.py +154 -0
  227. package/ciel_runtime_support/registry.py +46 -0
  228. package/ciel_runtime_support/request_shortcuts.py +253 -0
  229. package/ciel_runtime_support/request_trace.py +323 -0
  230. package/ciel_runtime_support/response_collection.py +209 -0
  231. package/ciel_runtime_support/router_access.py +238 -0
  232. package/ciel_runtime_support/router_client_lifecycle.py +366 -0
  233. package/ciel_runtime_support/router_health_policy.py +101 -0
  234. package/ciel_runtime_support/router_http.py +513 -0
  235. package/ciel_runtime_support/router_process_lifecycle.py +401 -0
  236. package/ciel_runtime_support/router_rate_limit_service.py +285 -0
  237. package/ciel_runtime_support/router_server_runtime.py +103 -0
  238. package/ciel_runtime_support/router_shortcuts.py +201 -0
  239. package/ciel_runtime_support/routing_fallback.py +73 -0
  240. package/ciel_runtime_support/runtime_activity_repository.py +143 -0
  241. package/ciel_runtime_support/runtime_adapters.py +104 -0
  242. package/ciel_runtime_support/runtime_command_factory.py +73 -0
  243. package/ciel_runtime_support/runtime_compatibility.py +50 -0
  244. package/ciel_runtime_support/runtime_constants.py +178 -0
  245. package/ciel_runtime_support/runtime_launch.py +1602 -0
  246. package/ciel_runtime_support/runtime_llm_options.py +312 -0
  247. package/ciel_runtime_support/runtime_logging.py +161 -0
  248. package/ciel_runtime_support/runtime_paths.py +157 -0
  249. package/ciel_runtime_support/runtime_restart.py +84 -0
  250. package/ciel_runtime_support/runtime_upgrade.py +149 -0
  251. package/ciel_runtime_support/secure_json_repository.py +55 -0
  252. package/ciel_runtime_support/session_import.py +356 -0
  253. package/ciel_runtime_support/settings_repository.py +8 -0
  254. package/ciel_runtime_support/slash_command_assets.py +211 -0
  255. package/ciel_runtime_support/sse_stream.py +57 -0
  256. package/ciel_runtime_support/sse_trace.py +225 -0
  257. package/ciel_runtime_support/statusline_script.py +593 -0
  258. package/ciel_runtime_support/statusline_settings.py +53 -0
  259. package/ciel_runtime_support/stream_chunk_policy.py +18 -0
  260. package/ciel_runtime_support/streaming_anthropic.py +1955 -0
  261. package/ciel_runtime_support/synthetic_tool_policy.py +105 -0
  262. package/ciel_runtime_support/terminal_platform_io.py +127 -0
  263. package/ciel_runtime_support/timeout_profile.py +196 -0
  264. package/ciel_runtime_support/tool_dialects.py +85 -0
  265. package/ciel_runtime_support/tool_exposure_policy.py +63 -0
  266. package/ciel_runtime_support/tool_guard_hooks.py +218 -0
  267. package/ciel_runtime_support/tool_request_projection.py +96 -0
  268. package/ciel_runtime_support/tool_schema.py +483 -0
  269. package/ciel_runtime_support/tool_side_effect_dedupe.py +95 -0
  270. package/ciel_runtime_support/ui_text.py +266 -0
  271. package/ciel_runtime_support/upstream_error_policy.py +104 -0
  272. package/ciel_runtime_support/upstream_retry.py +419 -0
  273. package/ciel_runtime_support/upstream_stream_io.py +106 -0
  274. package/ciel_runtime_support/usage_events.py +96 -0
  275. package/ciel_runtime_support/visible_stream_filters.py +130 -0
  276. package/ciel_runtime_support/web_endpoints.py +447 -0
  277. package/ciel_runtime_support/web_ui.py +915 -0
  278. package/ciel_runtime_support/web_ui_controller.py +189 -0
  279. package/ciel_runtime_support/windows_console_input.py +137 -0
  280. package/ciel_runtime_support/windows_console_mode.py +112 -0
  281. package/docs/Architecture.md +54 -0
  282. package/docs/Configuration.md +17 -0
  283. package/docs/Module-Map.md +1093 -26
  284. package/docs/Providers.md +46 -1
  285. package/docs/adr/0001-runtime-bounded-contexts.md +295 -0
  286. package/npm-bin/run-ciel-runtime.js +22 -2
  287. package/package.json +9 -2
@@ -0,0 +1,483 @@
1
+ """Tool schema registry and input repair service."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from typing import Any, Callable
8
+
9
+
10
+ def _noop_log(level: str, message: str) -> None:
11
+ del level, message
12
+
13
+
14
+ def tool_schema_in_body(body: dict[str, Any], tool_name: str) -> dict[str, Any] | None:
15
+ tools = body.get("tools")
16
+ if not isinstance(tools, list):
17
+ return None
18
+ for tool in tools:
19
+ if isinstance(tool, dict) and str(tool.get("name") or "") == tool_name:
20
+ schema = tool.get("input_schema")
21
+ return schema if isinstance(schema, dict) else None
22
+ return None
23
+ TASK_UPDATE_STATUSES = {"pending", "in_progress", "completed", "deleted"}
24
+ TASK_UPDATE_STATUS_ALIASES = {
25
+ "active": "in_progress",
26
+ "assigned": "in_progress",
27
+ "current": "in_progress",
28
+ "doing": "in_progress",
29
+ "inprogress": "in_progress",
30
+ "in_progress": "in_progress",
31
+ "in-progress": "in_progress",
32
+ "in progress": "in_progress",
33
+ "ongoing": "in_progress",
34
+ "processing": "in_progress",
35
+ "running": "in_progress",
36
+ "started": "in_progress",
37
+ "working": "in_progress",
38
+ "complete": "completed",
39
+ "completed": "completed",
40
+ "done": "completed",
41
+ "finished": "completed",
42
+ "resolved": "completed",
43
+ "success": "completed",
44
+ "closed": "completed",
45
+ "open": "pending",
46
+ "pending": "pending",
47
+ "queued": "pending",
48
+ "todo": "pending",
49
+ "to_do": "pending",
50
+ "to-do": "pending",
51
+ "waiting": "pending",
52
+ "cancel": "deleted",
53
+ "cancelled": "deleted",
54
+ "canceled": "deleted",
55
+ "delete": "deleted",
56
+ "deleted": "deleted",
57
+ "remove": "deleted",
58
+ "removed": "deleted",
59
+ }
60
+
61
+ _TOOL_SCHEMA_REGISTRY: dict[str, dict[str, Any]] = {}
62
+
63
+ _BUILTIN_TOOL_SCHEMAS: dict[str, dict[str, Any]] = {
64
+ "Bash": {
65
+ "required": ["command"],
66
+ "properties": {
67
+ "command": {"type": "string"},
68
+ "description": {"type": "string"},
69
+ "timeout": {"type": "integer"},
70
+ "run_in_background": {"type": "boolean"},
71
+ },
72
+ },
73
+ "Read": {
74
+ "required": ["file_path"],
75
+ "properties": {
76
+ "file_path": {"type": "string"},
77
+ "offset": {"type": "integer"},
78
+ "limit": {"type": "integer"},
79
+ },
80
+ },
81
+ "Write": {
82
+ "required": ["file_path", "content"],
83
+ "properties": {
84
+ "file_path": {"type": "string"},
85
+ "content": {"type": "string"},
86
+ },
87
+ },
88
+ "Edit": {
89
+ "required": ["file_path", "old_string", "new_string"],
90
+ "properties": {
91
+ "file_path": {"type": "string"},
92
+ "old_string": {"type": "string"},
93
+ "new_string": {"type": "string"},
94
+ "replace_all": {"type": "boolean"},
95
+ },
96
+ },
97
+ "Glob": {
98
+ "required": ["pattern"],
99
+ "properties": {
100
+ "pattern": {"type": "string"},
101
+ "path": {"type": "string"},
102
+ },
103
+ },
104
+ "Grep": {
105
+ "required": ["pattern"],
106
+ "properties": {
107
+ "pattern": {"type": "string"},
108
+ "path": {"type": "string"},
109
+ "output_mode": {"type": "string"},
110
+ },
111
+ },
112
+ "TaskList": {
113
+ "required": [],
114
+ "properties": {},
115
+ },
116
+ "TaskUpdate": {
117
+ "required": ["taskId"],
118
+ "properties": {
119
+ "taskId": {"type": "string"},
120
+ "subject": {"type": "string"},
121
+ "description": {"type": "string"},
122
+ "activeForm": {"type": "string"},
123
+ "status": {"type": "string", "enum": ["pending", "in_progress", "completed", "deleted"]},
124
+ "owner": {"type": "string"},
125
+ "addBlocks": {"type": "array"},
126
+ "addBlockedBy": {"type": "array"},
127
+ "metadata": {"type": "object"},
128
+ },
129
+ },
130
+ "TaskCreate": {
131
+ "required": ["subject", "description"],
132
+ "properties": {
133
+ "subject": {"type": "string"},
134
+ "description": {"type": "string"},
135
+ },
136
+ },
137
+ "TaskGet": {
138
+ "required": ["taskId"],
139
+ "properties": {
140
+ "taskId": {"type": "string"},
141
+ },
142
+ },
143
+ "TaskStop": {
144
+ "required": ["task_id"],
145
+ "properties": {
146
+ "task_id": {"type": "string"},
147
+ },
148
+ },
149
+ "CronCreate": {
150
+ "required": ["cron", "prompt"],
151
+ "properties": {
152
+ "cron": {"type": "string"},
153
+ "prompt": {"type": "string"},
154
+ "recurring": {"type": "boolean"},
155
+ "durable": {"type": "boolean"},
156
+ },
157
+ },
158
+ "CronDelete": {
159
+ "required": ["id"],
160
+ "properties": {
161
+ "id": {"type": "string"},
162
+ },
163
+ },
164
+ "CronList": {
165
+ "required": [],
166
+ "properties": {},
167
+ },
168
+ "advisor": {
169
+ "required": ["question"],
170
+ "properties": {
171
+ "question": {"type": "string"},
172
+ },
173
+ },
174
+ }
175
+
176
+
177
+ def _update_tool_schema_registry(tools: Any) -> None:
178
+ """Cache tool schemas from incoming Anthropic requests."""
179
+ if not isinstance(tools, list):
180
+ return
181
+ for tool in tools:
182
+ if not isinstance(tool, dict):
183
+ continue
184
+ name = tool.get("name")
185
+ if not name:
186
+ continue
187
+ _TOOL_SCHEMA_REGISTRY[name] = tool.get("input_schema") or {}
188
+
189
+
190
+ def _lookup_tool_schema(tool_name: str) -> dict[str, Any] | None:
191
+ """Look up a tool schema by name, checking registry then builtins."""
192
+ if tool_name in _TOOL_SCHEMA_REGISTRY:
193
+ return _TOOL_SCHEMA_REGISTRY[tool_name]
194
+ if tool_name in _BUILTIN_TOOL_SCHEMAS:
195
+ return _BUILTIN_TOOL_SCHEMAS[tool_name]
196
+ return None
197
+
198
+
199
+ def _fuzzy_match_tool_name(name: str) -> str | None:
200
+ """Fuzzy match a tool name against known schemas (case-insensitive, prefix)."""
201
+ low = name.lower()
202
+ candidates = list(_TOOL_SCHEMA_REGISTRY.keys()) + list(_BUILTIN_TOOL_SCHEMAS.keys())
203
+ # Exact match first
204
+ for c in candidates:
205
+ if c == name:
206
+ return c
207
+ # Case-insensitive
208
+ for c in candidates:
209
+ if c.lower() == low:
210
+ return c
211
+ # Prefix/substring match
212
+ for c in candidates:
213
+ if low in c.lower() or c.lower() in low:
214
+ return c
215
+ return None
216
+
217
+
218
+ def _coerce_value(value: Any, expected_type: str | None) -> Any:
219
+ """Coerce a value to the expected JSON schema type."""
220
+ if expected_type is None:
221
+ return value
222
+ if isinstance(value, bool) and expected_type == "boolean":
223
+ return value
224
+ if isinstance(value, (int, float)) and expected_type == "integer":
225
+ return int(value)
226
+ if isinstance(value, (int, float)) and expected_type == "number":
227
+ return float(value)
228
+ if isinstance(value, str) and expected_type == "string":
229
+ return value
230
+ if expected_type == "array":
231
+ if isinstance(value, list):
232
+ return value
233
+ if isinstance(value, tuple):
234
+ return list(value)
235
+ if isinstance(value, str) and value.strip():
236
+ text = value.strip()
237
+ try:
238
+ parsed = json.loads(text)
239
+ if isinstance(parsed, list):
240
+ return parsed
241
+ except Exception:
242
+ pass
243
+ return [text]
244
+ if value is None:
245
+ return []
246
+ return value
247
+ if expected_type == "object":
248
+ if isinstance(value, dict):
249
+ return value
250
+ if isinstance(value, str) and value.strip():
251
+ try:
252
+ parsed = json.loads(value)
253
+ if isinstance(parsed, dict):
254
+ return parsed
255
+ except Exception:
256
+ pass
257
+ return value
258
+ # Coerce string -> integer
259
+ if isinstance(value, str) and expected_type in ("integer", "number"):
260
+ try:
261
+ return int(value) if expected_type == "integer" else float(value)
262
+ except Exception:
263
+ pass
264
+ # Coerce string -> boolean
265
+ if isinstance(value, str) and expected_type == "boolean":
266
+ low = value.lower()
267
+ if low in ("true", "yes", "on", "1"):
268
+ return True
269
+ if low in ("false", "no", "off", "0"):
270
+ return False
271
+ # Coerce int/float -> string
272
+ if isinstance(value, (int, float)) and expected_type == "string":
273
+ return str(value)
274
+ # Coerce anything -> string as last resort
275
+ if expected_type == "string" and value is not None:
276
+ return str(value)
277
+ return value
278
+
279
+
280
+ def normalize_task_update_status(value: Any) -> str | None:
281
+ if value is None:
282
+ return None
283
+ text = str(value).strip()
284
+ if not text:
285
+ return None
286
+ normalized = re.sub(r"[\s\-]+", "_", text.lower())
287
+ normalized = re.sub(r"[^a-z0-9_]", "", normalized)
288
+ if normalized in TASK_UPDATE_STATUSES:
289
+ return normalized
290
+ return TASK_UPDATE_STATUS_ALIASES.get(text.lower()) or TASK_UPDATE_STATUS_ALIASES.get(normalized)
291
+
292
+
293
+ def _default_for_missing_required(tool_name: str, field: str) -> Any:
294
+ """Return a safe default for known required fields."""
295
+ defaults: dict[str, dict[str, Any]] = {
296
+ "Bash": {"command": "true", "timeout": 30000, "description": "", "run_in_background": False},
297
+ "Read": {"offset": 0, "limit": 0},
298
+ "Edit": {"replace_all": False},
299
+ "Glob": {"path": "."},
300
+ "Grep": {"output_mode": "content"},
301
+ "TaskCreate": {"description": ""},
302
+ "TaskStop": {},
303
+ }
304
+ return defaults.get(tool_name, {}).get(field)
305
+
306
+
307
+ def _is_empty_value(value: Any) -> bool:
308
+ """Check if a value is effectively empty and should be defaulted."""
309
+ if value is None:
310
+ return True
311
+ if isinstance(value, str) and value.strip() == "":
312
+ return True
313
+ return False
314
+
315
+
316
+ def _move_first_present(fixed: dict[str, Any], target: str, aliases: tuple[str, ...]) -> None:
317
+ """Move the first non-empty alias value to the target field."""
318
+ if target in fixed and not _is_empty_value(fixed.get(target)):
319
+ return
320
+ for alias in aliases:
321
+ value = fixed.get(alias)
322
+ if not _is_empty_value(value):
323
+ fixed[target] = value
324
+ break
325
+
326
+
327
+ def _validate_and_fix_tool_input(
328
+ tool_name: str,
329
+ input_dict: dict[str, Any],
330
+ source_body: dict[str, Any] | None = None,
331
+ log: Callable[[str, str], None] | None = None,
332
+ ) -> dict[str, Any]:
333
+ """
334
+ Validate tool_use input against schema and fix common errors:
335
+ - fuzzy-match tool name
336
+ - coerce types to match schema
337
+ - add defaults for missing required fields
338
+ - keep unknown fields (Claude Code may accept extra fields)
339
+ """
340
+ schema = tool_schema_in_body(source_body, tool_name) if isinstance(source_body, dict) else None
341
+ if schema is None:
342
+ schema = _lookup_tool_schema(tool_name)
343
+ matched_name = tool_name
344
+ if schema is None:
345
+ matched = _fuzzy_match_tool_name(tool_name)
346
+ if matched:
347
+ matched_name = matched
348
+ schema = _lookup_tool_schema(matched)
349
+
350
+ if schema is None:
351
+ # No schema known: just ensure it's a dict and return
352
+ return input_dict if isinstance(input_dict, dict) else {}
353
+
354
+ properties = schema.get("properties") or {}
355
+ required = set(schema.get("required") or [])
356
+ fixed: dict[str, Any] = {}
357
+
358
+ for key, raw_value in input_dict.items():
359
+ prop_schema = properties.get(key)
360
+ if prop_schema is None:
361
+ # Unknown field: keep it rather than dropping it.
362
+ # Claude Code may accept fields not in our static registry.
363
+ fixed[key] = raw_value
364
+ continue
365
+ expected_type = prop_schema.get("type") if isinstance(prop_schema, dict) else None
366
+ fixed[key] = _coerce_value(raw_value, expected_type)
367
+
368
+ if matched_name == "TaskUpdate":
369
+ if "taskId" not in fixed or _is_empty_value(fixed.get("taskId")):
370
+ for alias in ("task_id", "id"):
371
+ value = fixed.get(alias)
372
+ if isinstance(value, (str, int, float)) and not isinstance(value, bool) and str(value).strip():
373
+ fixed["taskId"] = str(value)
374
+ break
375
+ if "status" in fixed:
376
+ status = normalize_task_update_status(fixed.get("status"))
377
+ if status:
378
+ fixed["status"] = status
379
+ for alias in ("task_id", "id"):
380
+ fixed.pop(alias, None)
381
+
382
+ if matched_name == "CronCreate":
383
+ _move_first_present(
384
+ fixed,
385
+ "cron",
386
+ ("schedule", "cronExpression", "cron_expression", "expression", "interval", "time"),
387
+ )
388
+ _move_first_present(
389
+ fixed,
390
+ "prompt",
391
+ ("message", "task", "instruction", "instructions", "command", "query"),
392
+ )
393
+ for key in ("cron", "prompt", "recurring", "durable"):
394
+ prop_schema = properties.get(key)
395
+ expected_type = prop_schema.get("type") if isinstance(prop_schema, dict) else None
396
+ if key in fixed:
397
+ fixed[key] = _coerce_value(fixed[key], expected_type)
398
+ for alias in (
399
+ "schedule",
400
+ "cronExpression",
401
+ "cron_expression",
402
+ "expression",
403
+ "interval",
404
+ "time",
405
+ "message",
406
+ "task",
407
+ "instruction",
408
+ "instructions",
409
+ "command",
410
+ "query",
411
+ ):
412
+ fixed.pop(alias, None)
413
+
414
+ if matched_name == "CronDelete":
415
+ _move_first_present(fixed, "id", ("taskId", "task_id", "jobId", "job_id", "cronId", "cron_id"))
416
+ if "id" in fixed:
417
+ fixed["id"] = _coerce_value(fixed["id"], "string")
418
+ for alias in ("taskId", "task_id", "jobId", "job_id", "cronId", "cron_id"):
419
+ fixed.pop(alias, None)
420
+
421
+ if matched_name == "CronList":
422
+ fixed = {}
423
+
424
+ # Fill in missing or empty required fields with defaults
425
+ injected: list[str] = []
426
+ for req in required:
427
+ if req not in fixed or _is_empty_value(fixed.get(req)):
428
+ default = _default_for_missing_required(matched_name, req)
429
+ if default is not None:
430
+ fixed[req] = default
431
+ elif matched_name == "TaskUpdate" and req == "taskId":
432
+ continue
433
+ elif req not in fixed:
434
+ # No known default: inject empty value matching expected type
435
+ prop_schema = properties.get(req)
436
+ expected_type = prop_schema.get("type") if isinstance(prop_schema, dict) else None
437
+ if expected_type == "string":
438
+ fixed[req] = ""
439
+ elif expected_type == "integer":
440
+ fixed[req] = 0
441
+ elif expected_type == "number":
442
+ fixed[req] = 0.0
443
+ elif expected_type == "boolean":
444
+ fixed[req] = False
445
+ elif expected_type == "array":
446
+ fixed[req] = []
447
+ elif expected_type == "object":
448
+ fixed[req] = {}
449
+ else:
450
+ fixed[req] = ""
451
+ injected.append(req)
452
+
453
+ if injected:
454
+ (log or _noop_log)("WARN", f"tool_guard: {matched_name}: injected missing required fields: {', '.join(injected)}")
455
+
456
+ return fixed
457
+
458
+
459
+ def _missing_required_tool_fields(tool_name: str, input_dict: dict[str, Any], source_body: dict[str, Any] | None = None) -> list[str]:
460
+ schema = tool_schema_in_body(source_body, tool_name) if isinstance(source_body, dict) else None
461
+ if schema is None:
462
+ schema = _lookup_tool_schema(tool_name)
463
+ if not isinstance(schema, dict):
464
+ return []
465
+ required = schema.get("required") or []
466
+ if not isinstance(required, list):
467
+ return []
468
+ missing: list[str] = []
469
+ for field in required:
470
+ if not isinstance(field, str):
471
+ continue
472
+ if field not in input_dict or _is_empty_value(input_dict.get(field)):
473
+ missing.append(field)
474
+ return missing
475
+
476
+ __all__ = [
477
+ "_fuzzy_match_tool_name",
478
+ "_lookup_tool_schema",
479
+ "_missing_required_tool_fields",
480
+ "_update_tool_schema_registry",
481
+ "_validate_and_fix_tool_input",
482
+ "normalize_task_update_status",
483
+ ]
@@ -0,0 +1,95 @@
1
+ """Deduplicate exact repeated side-effect tool calls within a bounded window."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ from dataclasses import dataclass
7
+ from threading import Lock, RLock
8
+ from typing import Any, Callable
9
+
10
+
11
+ @dataclass(frozen=True, slots=True)
12
+ class ToolSideEffectDedupePolicy:
13
+ side_effect_suffixes: frozenset[str]
14
+ ttl_seconds: float = 600.0
15
+
16
+
17
+ class ToolSideEffectDedupeRepository:
18
+ def __init__(self, recent: dict[str, float], lock: Lock | RLock) -> None:
19
+ self.recent = recent
20
+ self._lock = lock
21
+
22
+ def previous_or_record(self, key: str, now: float, ttl_seconds: float) -> float | None:
23
+ with self._lock:
24
+ expired = [name for name, seen_at in self.recent.items() if now - seen_at > ttl_seconds]
25
+ for name in expired:
26
+ self.recent.pop(name, None)
27
+ previous = self.recent.get(key)
28
+ if previous is None or now - previous > ttl_seconds:
29
+ self.recent[key] = now
30
+ return None
31
+ return previous
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class ToolSideEffectDedupePorts:
36
+ now: Callable[[], float]
37
+ audit: Callable[[str, dict[str, Any]], None]
38
+ log: Callable[[str, str], None]
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class ToolSideEffectDedupeService:
43
+ policy: ToolSideEffectDedupePolicy
44
+ repository: ToolSideEffectDedupeRepository
45
+ ports: ToolSideEffectDedupePorts
46
+
47
+ def key(self, tool_name: str, tool_input: dict[str, Any]) -> str | None:
48
+ if not isinstance(tool_name, str) or not tool_name:
49
+ return None
50
+ normalized_name = tool_name.strip()
51
+ tool_leaf = normalized_name.rsplit("__", 1)[-1].strip().lower()
52
+ if tool_leaf not in self.policy.side_effect_suffixes:
53
+ return None
54
+ try:
55
+ payload = json.dumps(
56
+ tool_input or {}, sort_keys=True, ensure_ascii=False, separators=(",", ":"), default=str
57
+ )
58
+ except Exception:
59
+ payload = repr(tool_input)
60
+ digest = hashlib.sha256(payload.encode("utf-8", errors="replace")).hexdigest()
61
+ return f"{normalized_name}:{digest}"
62
+
63
+ def should_drop(self, tool_name: str, tool_input: dict[str, Any], raw_name: str = "") -> bool:
64
+ key = self.key(tool_name, tool_input)
65
+ if not key:
66
+ return False
67
+ now = self.ports.now()
68
+ previous = self.repository.previous_or_record(key, now, self.policy.ttl_seconds)
69
+ if previous is None:
70
+ return False
71
+ age = now - previous
72
+ self.ports.audit(
73
+ "dropped_duplicate_side_effect_tool_call",
74
+ {
75
+ "raw_name": raw_name or tool_name,
76
+ "matched_name": tool_name,
77
+ "emitted_input": tool_input,
78
+ "age_seconds": round(age, 3),
79
+ "ttl_seconds": self.policy.ttl_seconds,
80
+ },
81
+ )
82
+ self.ports.log(
83
+ "WARN",
84
+ f"dropped duplicate side-effect tool call raw_name={raw_name or tool_name!r} "
85
+ f"matched_name={tool_name!r} age={age:.1f}s",
86
+ )
87
+ return True
88
+
89
+
90
+ __all__ = [
91
+ "ToolSideEffectDedupePolicy",
92
+ "ToolSideEffectDedupePorts",
93
+ "ToolSideEffectDedupeRepository",
94
+ "ToolSideEffectDedupeService",
95
+ ]