ummaya 0.2.3 → 0.2.5

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 (534) hide show
  1. package/README.md +17 -3
  2. package/bin/ummaya +10 -1
  3. package/npm-shrinkwrap.json +253 -2
  4. package/package.json +5 -1
  5. package/prompts/manifest.yaml +2 -2
  6. package/prompts/session_guidance_v1.md +3 -1
  7. package/prompts/system_v1.md +9 -7
  8. package/pyproject.toml +26 -7
  9. package/specs/2803-document-production-hardening/contracts/document-tools.schema.json +1043 -0
  10. package/src/ummaya/_canonical/__init__.py +2 -0
  11. package/src/ummaya/context/builder.py +17 -11
  12. package/src/ummaya/engine/engine.py +30 -113
  13. package/src/ummaya/engine/query.py +20 -0
  14. package/src/ummaya/evidence/__init__.py +44 -0
  15. package/src/ummaya/evidence/__main__.py +7 -0
  16. package/src/ummaya/evidence/dataset_contract.py +193 -0
  17. package/src/ummaya/evidence/document_authoring_cases.py +33 -0
  18. package/src/ummaya/evidence/document_harness.py +313 -0
  19. package/src/ummaya/evidence/document_viewer_ux.py +391 -0
  20. package/src/ummaya/evidence/gates.py +70 -0
  21. package/src/ummaya/evidence/json_types.py +20 -0
  22. package/src/ummaya/evidence/models.py +145 -0
  23. package/src/ummaya/evidence/output_payload.py +89 -0
  24. package/src/ummaya/evidence/payload_documents.py +233 -0
  25. package/src/ummaya/evidence/route_contracts.py +224 -0
  26. package/src/ummaya/evidence/route_helpers.py +150 -0
  27. package/src/ummaya/evidence/runner.py +177 -0
  28. package/src/ummaya/evidence/source_provenance.py +246 -0
  29. package/src/ummaya/evidence/source_provenance_redaction.py +176 -0
  30. package/src/ummaya/evidence/task_registry.py +264 -0
  31. package/src/ummaya/evidence/tool_layer.py +39 -0
  32. package/src/ummaya/evidence/tool_layer_models.py +151 -0
  33. package/src/ummaya/ipc/adapter_manifest_emitter.py +26 -10
  34. package/src/ummaya/ipc/document_intent_normalization.py +185 -0
  35. package/src/ummaya/ipc/frame_schema.py +52 -5
  36. package/src/ummaya/ipc/route_diagnostics.py +73 -0
  37. package/src/ummaya/ipc/stdio.py +2282 -417
  38. package/src/ummaya/llm/client.py +234 -59
  39. package/src/ummaya/llm/config.py +8 -3
  40. package/src/ummaya/llm/reasoning.py +84 -0
  41. package/src/ummaya/primitives/__init__.py +6 -2
  42. package/src/ummaya/primitives/delegation.py +1 -1
  43. package/src/ummaya/primitives/document.py +28 -0
  44. package/src/ummaya/settings.py +0 -3
  45. package/src/ummaya/tools/discovery_bridge.py +34 -2
  46. package/src/ummaya/tools/documents/__init__.py +297 -0
  47. package/src/ummaya/tools/documents/adapter_registry.py +487 -0
  48. package/src/ummaya/tools/documents/archive_container_probe.py +167 -0
  49. package/src/ummaya/tools/documents/artifact_store.py +454 -0
  50. package/src/ummaya/tools/documents/authoring.py +283 -0
  51. package/src/ummaya/tools/documents/baselines.py +114 -0
  52. package/src/ummaya/tools/documents/capability.py +331 -0
  53. package/src/ummaya/tools/documents/contracts.py +112 -0
  54. package/src/ummaya/tools/documents/conversion.py +521 -0
  55. package/src/ummaya/tools/documents/diff.py +275 -0
  56. package/src/ummaya/tools/documents/engines.py +163 -0
  57. package/src/ummaya/tools/documents/evaluation.py +291 -0
  58. package/src/ummaya/tools/documents/explicit_values.py +108 -0
  59. package/src/ummaya/tools/documents/fixtures.py +174 -0
  60. package/src/ummaya/tools/documents/format_completion_audit.py +471 -0
  61. package/src/ummaya/tools/documents/formats/__init__.py +2 -0
  62. package/src/ummaya/tools/documents/formats/archive.py +528 -0
  63. package/src/ummaya/tools/documents/formats/base.py +41 -0
  64. package/src/ummaya/tools/documents/formats/code_file.py +211 -0
  65. package/src/ummaya/tools/documents/formats/data_file.py +272 -0
  66. package/src/ummaya/tools/documents/formats/hwp.py +284 -0
  67. package/src/ummaya/tools/documents/formats/hwpx.py +1837 -0
  68. package/src/ummaya/tools/documents/formats/odf.py +435 -0
  69. package/src/ummaya/tools/documents/formats/ooxml.py +1030 -0
  70. package/src/ummaya/tools/documents/formats/passive.py +766 -0
  71. package/src/ummaya/tools/documents/formats/pdf.py +702 -0
  72. package/src/ummaya/tools/documents/formats/text_web.py +268 -0
  73. package/src/ummaya/tools/documents/hwp_conversion_probe.py +178 -0
  74. package/src/ummaya/tools/documents/hwp_direct_candidate.py +141 -0
  75. package/src/ummaya/tools/documents/inspection.py +289 -0
  76. package/src/ummaya/tools/documents/intake.py +1079 -0
  77. package/src/ummaya/tools/documents/legacy_office_promotion_probe.py +366 -0
  78. package/src/ummaya/tools/documents/models.py +1598 -0
  79. package/src/ummaya/tools/documents/odf_promotion_probe.py +167 -0
  80. package/src/ummaya/tools/documents/orchestrator.py +96 -0
  81. package/src/ummaya/tools/documents/passive_capability_probe.py +251 -0
  82. package/src/ummaya/tools/documents/patch.py +170 -0
  83. package/src/ummaya/tools/documents/pdfa_conformance.py +284 -0
  84. package/src/ummaya/tools/documents/pdfa_promotion_probe.py +198 -0
  85. package/src/ummaya/tools/documents/permissions.py +110 -0
  86. package/src/ummaya/tools/documents/planner.py +616 -0
  87. package/src/ummaya/tools/documents/registry.py +2733 -0
  88. package/src/ummaya/tools/documents/render.py +978 -0
  89. package/src/ummaya/tools/documents/render_comparison.py +113 -0
  90. package/src/ummaya/tools/documents/render_comparison_models.py +74 -0
  91. package/src/ummaya/tools/documents/render_comparison_regions.py +73 -0
  92. package/src/ummaya/tools/documents/render_comparison_style.py +161 -0
  93. package/src/ummaya/tools/documents/reread.py +157 -0
  94. package/src/ummaya/tools/documents/runtime_authoring.py +244 -0
  95. package/src/ummaya/tools/documents/runtime_authoring_bundle.py +76 -0
  96. package/src/ummaya/tools/documents/scorecard.py +184 -0
  97. package/src/ummaya/tools/documents/socratic_planner.py +193 -0
  98. package/src/ummaya/tools/documents/style.py +48 -0
  99. package/src/ummaya/tools/documents/tool_defs.py +523 -0
  100. package/src/ummaya/tools/documents/validate.py +347 -0
  101. package/src/ummaya/tools/executor.py +61 -12
  102. package/src/ummaya/tools/geocoding/kakao_client.py +1 -2
  103. package/src/ummaya/tools/kma/apihub_catalog.py +984 -1
  104. package/src/ummaya/tools/kma/apihub_structured_adapter.py +86 -6
  105. package/src/ummaya/tools/kma/apihub_url_adapter.py +593 -0
  106. package/src/ummaya/tools/kma/apihub_url_catalog.py +296 -0
  107. package/src/ummaya/tools/live_proxy.py +0 -3
  108. package/src/ummaya/tools/location_adapters.py +8 -6
  109. package/src/ummaya/tools/manifest_metadata.py +16 -3
  110. package/src/ummaya/tools/models.py +5 -1
  111. package/src/ummaya/tools/mvp_surface.py +2 -2
  112. package/src/ummaya/tools/nmc/emergency_search.py +8 -6
  113. package/src/ummaya/tools/register_all.py +17 -0
  114. package/src/ummaya/tools/registry.py +10 -1
  115. package/src/ummaya/tools/resolve_location.py +4 -4
  116. package/src/ummaya/tools/routing/__init__.py +59 -0
  117. package/src/ummaya/tools/routing/builder.py +105 -0
  118. package/src/ummaya/tools/routing/cards.py +29 -0
  119. package/src/ummaya/tools/routing/decision_service.py +534 -0
  120. package/src/ummaya/tools/routing/decision_types.py +74 -0
  121. package/src/ummaya/tools/routing/feasibility.py +122 -0
  122. package/src/ummaya/tools/routing/intent.py +17 -0
  123. package/src/ummaya/tools/routing/intent_extractor.py +207 -0
  124. package/src/ummaya/tools/routing/intent_patterns.py +160 -0
  125. package/src/ummaya/tools/routing/intent_public_data.py +150 -0
  126. package/src/ummaya/tools/routing/intent_types.py +48 -0
  127. package/src/ummaya/tools/routing/lint.py +78 -0
  128. package/src/ummaya/tools/routing/metadata.py +174 -0
  129. package/src/ummaya/tools/routing/projection.py +340 -0
  130. package/src/ummaya/tools/routing/retrieval_policy.py +629 -0
  131. package/src/ummaya/tools/routing/schema.py +81 -0
  132. package/src/ummaya/tools/routing/types.py +96 -0
  133. package/src/ummaya/tools/routing_index.py +2 -2
  134. package/src/ummaya/tools/search.py +40 -106
  135. package/src/ummaya/tools/verified_data_go_kr/_manifest.py +115 -25
  136. package/src/ummaya/tools/verified_data_go_kr/airkorea_air_quality.py +109 -4
  137. package/src/ummaya/tools/verified_data_go_kr/nmc_aed_site.py +108 -2
  138. package/src/ummaya/tools/verified_data_go_kr/pps_bid_public_info.py +174 -9
  139. package/src/ummaya/tools/verified_data_go_kr/tago_bus_arrival.py +66 -3
  140. package/src/ummaya/tools/verified_data_go_kr/tago_bus_location.py +12 -2
  141. package/src/ummaya/tools/verified_data_go_kr/tago_bus_route.py +8 -2
  142. package/src/ummaya/tools/verified_data_go_kr/tago_bus_route_station.py +114 -0
  143. package/src/ummaya/tools/verified_data_go_kr/tago_bus_station.py +14 -3
  144. package/src/ummaya/tools/verify_canonical_map.py +21 -0
  145. package/tests/fixtures/documents/public_forms/baselines.yaml +113 -0
  146. package/tui/package.json +1 -2
  147. package/tui/src/.cc-byte-identical-whitelist.yaml +266 -0
  148. package/tui/src/QueryEngine.ts +12 -4
  149. package/tui/src/bridge/inboundAttachments.ts +3 -3
  150. package/tui/src/cli/handlers/auth.ts +4 -13
  151. package/tui/src/cli/handlers/mcp.tsx +3 -3
  152. package/tui/src/cli/print.ts +69 -18
  153. package/tui/src/cli/update.ts +13 -13
  154. package/tui/src/commands/copy/index.ts +1 -1
  155. package/tui/src/commands/cost/cost.ts +2 -2
  156. package/tui/src/commands/init-verifiers.ts +5 -5
  157. package/tui/src/commands/init.ts +30 -30
  158. package/tui/src/commands/insights.ts +44 -44
  159. package/tui/src/commands/install-github-app/install-github-app.tsx +2 -2
  160. package/tui/src/commands/install-github-app/setupGitHubActions.ts +3 -3
  161. package/tui/src/commands/install-github-app/types.ts +8 -30
  162. package/tui/src/commands/install.tsx +5 -5
  163. package/tui/src/commands/mcp/addCommand.ts +5 -5
  164. package/tui/src/commands/mcp/xaaIdpCommand.ts +2 -2
  165. package/tui/src/commands/plugin/ManageMarketplaces.tsx +2 -2
  166. package/tui/src/commands/plugin/types.ts +6 -28
  167. package/tui/src/commands/plugin/unifiedTypes.ts +4 -26
  168. package/tui/src/commands/reasoning/index.ts +13 -0
  169. package/tui/src/commands/reasoning/reasoning.tsx +177 -0
  170. package/tui/src/commands/rename/generateSessionName.ts +1 -1
  171. package/tui/src/commands/thinkback/thinkback.tsx +3 -3
  172. package/tui/src/commands.ts +2 -0
  173. package/tui/src/components/Feedback.tsx +1 -1
  174. package/tui/src/components/LogoV2/EmergencyTip.tsx +11 -2
  175. package/tui/src/components/LogoV2/WelcomeV2.tsx +1 -3
  176. package/tui/src/components/Messages.tsx +2 -1
  177. package/tui/src/components/ScrollKeybindingHandler.tsx +6 -6
  178. package/tui/src/components/Spinner/types.ts +6 -28
  179. package/tui/src/components/Spinner.tsx +2 -2
  180. package/tui/src/components/agents/generateAgent.ts +1 -1
  181. package/tui/src/components/agents/new-agent-creation/types.ts +4 -26
  182. package/tui/src/components/config/EnvSecretIsolatedEditor.tsx +1 -1
  183. package/tui/src/components/design-system/LoadingState.tsx +2 -2
  184. package/tui/src/components/mcp/types.ts +16 -38
  185. package/tui/src/components/messages/AssistantToolUseMessage.tsx +3 -2
  186. package/tui/src/components/messages/UserCrossSessionMessage.ts +16 -4
  187. package/tui/src/components/messages/UserForkBoilerplateMessage.ts +16 -4
  188. package/tui/src/components/messages/UserGitHubWebhookMessage.ts +16 -4
  189. package/tui/src/components/messages/UserToolResultMessage/utils.tsx +3 -2
  190. package/tui/src/components/permissions/MonitorPermissionRequest/MonitorPermissionRequest.ts +9 -4
  191. package/tui/src/components/permissions/ReviewArtifactPermissionRequest/ReviewArtifactPermissionRequest.ts +9 -4
  192. package/tui/src/components/primitive/DocumentSocraticReviewBlock.tsx +129 -0
  193. package/tui/src/components/primitive/DocumentToolResultCard.tsx +224 -0
  194. package/tui/src/components/primitive/documentSocraticReview.ts +215 -0
  195. package/tui/src/components/primitive/index.tsx +43 -1
  196. package/tui/src/components/primitive/types.ts +137 -0
  197. package/tui/src/components/ui/option.ts +4 -26
  198. package/tui/src/constants/common.ts +0 -2
  199. package/tui/src/constants/prompts.ts +4 -3
  200. package/tui/src/constants/querySource.ts +4 -26
  201. package/tui/src/entrypoints/sdk/controlTypes.ts +26 -48
  202. package/tui/src/entrypoints/sdk/coreTypes.generated.ts +3 -25
  203. package/tui/src/entrypoints/sdk/runtimeTypes.ts +38 -60
  204. package/tui/src/entrypoints/sdk/sdkUtilityTypes.ts +4 -26
  205. package/tui/src/entrypoints/sdk/settingsTypes.generated.ts +3 -25
  206. package/tui/src/entrypoints/sdk/toolTypes.ts +3 -25
  207. package/tui/src/hooks/toolPermission/handlers/interactiveHandler.ts +10 -0
  208. package/tui/src/hooks/useApiKeyVerification.ts +1 -1
  209. package/tui/src/hooks/useVirtualScroll.ts +1 -1
  210. package/tui/src/ink/ink.tsx +33 -14
  211. package/tui/src/ink/reconciler.ts +2 -3
  212. package/tui/src/ink/render-to-screen.ts +30 -10
  213. package/tui/src/ipc/bridge.ts +62 -15
  214. package/tui/src/ipc/bridgeSingleton.ts +5 -1
  215. package/tui/src/ipc/codec.ts +29 -3
  216. package/tui/src/ipc/frames.generated.ts +407 -312
  217. package/tui/src/ipc/llmClient.ts +279 -76
  218. package/tui/src/ipc/llmTypes.ts +16 -1
  219. package/tui/src/ipc/schema/frame.schema.json +1 -3475
  220. package/tui/src/keybindings/defaultBindings.ts +4 -0
  221. package/tui/src/main.tsx +32 -11
  222. package/tui/src/native-ts/file-index/index.ts +33 -3
  223. package/tui/src/observability/surface.ts +2 -2
  224. package/tui/src/probes/toolRegistryProbe.tsx +3 -1
  225. package/tui/src/projectOnboardingState.ts +7 -6
  226. package/tui/src/query/chatMessageTypes.ts +18 -0
  227. package/tui/src/query/chatMessagesBuilder.ts +1 -1
  228. package/tui/src/query/deps.ts +1 -1
  229. package/tui/src/query/messageGuards.ts +106 -0
  230. package/tui/src/query/publicDataTerminalRepair.ts +384 -0
  231. package/tui/src/query/run.ts +1075 -0
  232. package/tui/src/query/supportBoundary.ts +168 -0
  233. package/tui/src/query/toolResultErrors.ts +103 -0
  234. package/tui/src/query/toolRunner.ts +687 -0
  235. package/tui/src/query/unavailableToolRepair.ts +118 -0
  236. package/tui/src/query.ts +9 -1721
  237. package/tui/src/screens/REPL.tsx +42 -31
  238. package/tui/src/services/api/adapterManifest.ts +4 -0
  239. package/tui/src/services/api/backendChat/events.ts +117 -0
  240. package/tui/src/services/api/backendChat/finalMessage.ts +40 -0
  241. package/tui/src/services/api/backendChat/frame.ts +9 -0
  242. package/tui/src/services/api/backendChat/streaming.ts +430 -0
  243. package/tui/src/services/api/backendChat/types.ts +62 -0
  244. package/tui/src/services/api/backendChat.ts +1 -0
  245. package/tui/src/services/api/client.ts +98 -14
  246. package/tui/src/services/api/errorUtils.ts +5 -5
  247. package/tui/src/services/api/errors.ts +1 -1
  248. package/tui/src/services/api/logging.ts +1 -1
  249. package/tui/src/services/api/ummaya/evidence.ts +194 -0
  250. package/tui/src/services/api/ummaya/messages.ts +255 -0
  251. package/tui/src/services/api/ummaya/nonStreaming.ts +66 -0
  252. package/tui/src/services/api/ummaya/provider.ts +200 -0
  253. package/tui/src/services/api/ummaya/reasoning.ts +24 -0
  254. package/tui/src/services/api/ummaya/request.ts +200 -0
  255. package/tui/src/services/api/ummaya/selectionContext.ts +240 -0
  256. package/tui/src/services/api/ummaya/streaming.ts +365 -0
  257. package/tui/src/services/api/ummaya/streamingPayload.ts +129 -0
  258. package/tui/src/services/api/ummaya/streamingReader.ts +40 -0
  259. package/tui/src/services/api/ummaya/toolSelection.ts +217 -0
  260. package/tui/src/services/api/ummaya/types.ts +110 -0
  261. package/tui/src/services/api/ummaya/usage.ts +30 -0
  262. package/tui/src/services/api/ummaya.ts +26 -364
  263. package/tui/src/services/api/withRetry.ts +1 -1
  264. package/tui/src/services/awaySummary.ts +2 -2
  265. package/tui/src/services/claudeAiLimits.ts +1 -1
  266. package/tui/src/services/compact/autoCompact.ts +1 -1
  267. package/tui/src/services/compact/compact.ts +1 -1
  268. package/tui/src/services/lsp/types.ts +8 -30
  269. package/tui/src/services/tips/types.ts +6 -28
  270. package/tui/src/services/tokenEstimation.ts +1 -1
  271. package/tui/src/services/toolRegistry/bootGuard.ts +5 -5
  272. package/tui/src/services/toolUseSummary/toolUseSummaryGenerator.ts +1 -1
  273. package/tui/src/services/tools/toolExecution.ts +94 -1
  274. package/tui/src/skills/bundled/stuck.ts +12 -12
  275. package/tui/src/state/AppStateStore.ts +7 -0
  276. package/tui/src/store/pendingPermissionSlot.ts +1 -1
  277. package/tui/src/store/session-store.ts +10 -36
  278. package/tui/src/stubs/any-stub.ts +15 -10
  279. package/tui/src/stubs/color-diff-napi.ts +37 -23
  280. package/tui/src/stubs/globals.d.ts +3 -3
  281. package/tui/src/stubs/macro-preload.ts +23 -12
  282. package/tui/src/tools/AdapterTool/AdapterTool.ts +1239 -163
  283. package/tui/src/tools/AdapterTool/routeDiagnostics.ts +75 -0
  284. package/tui/src/tools/AgentTool/AgentTool.tsx +84 -1371
  285. package/tui/src/tools/AgentTool/agentToolHandoff.ts +114 -0
  286. package/tui/src/tools/AgentTool/agentToolPartialResult.ts +16 -0
  287. package/tui/src/tools/AgentTool/agentToolProgress.ts +32 -0
  288. package/tui/src/tools/AgentTool/agentToolResolver.ts +161 -0
  289. package/tui/src/tools/AgentTool/agentToolResult.ts +163 -0
  290. package/tui/src/tools/AgentTool/agentToolUtils.ts +14 -686
  291. package/tui/src/tools/AgentTool/asyncAgentLifecycle.ts +208 -0
  292. package/tui/src/tools/AgentTool/asyncLifecycle.ts +153 -0
  293. package/tui/src/tools/AgentTool/backgroundedCompletion.ts +126 -0
  294. package/tui/src/tools/AgentTool/backgroundedLifecycle.ts +174 -0
  295. package/tui/src/tools/AgentTool/foregroundBackground.ts +83 -0
  296. package/tui/src/tools/AgentTool/foregroundDrain.tsx +133 -0
  297. package/tui/src/tools/AgentTool/foregroundFinalize.ts +98 -0
  298. package/tui/src/tools/AgentTool/foregroundLifecycle.tsx +237 -0
  299. package/tui/src/tools/AgentTool/foregroundProgress.tsx +169 -0
  300. package/tui/src/tools/AgentTool/foregroundTask.ts +89 -0
  301. package/tui/src/tools/AgentTool/forkSubagent.ts +1 -12
  302. package/tui/src/tools/AgentTool/forkSubagentGate.ts +34 -0
  303. package/tui/src/tools/AgentTool/launchRouting.ts +203 -0
  304. package/tui/src/tools/AgentTool/lifecycle.ts +244 -0
  305. package/tui/src/tools/AgentTool/mcpRouting.ts +73 -0
  306. package/tui/src/tools/AgentTool/orchestrationSupport.ts +70 -0
  307. package/tui/src/tools/AgentTool/permissions.ts +39 -0
  308. package/tui/src/tools/AgentTool/promptSetup.ts +181 -0
  309. package/tui/src/tools/AgentTool/remoteRouting.ts +62 -0
  310. package/tui/src/tools/AgentTool/resultMapping.ts +116 -0
  311. package/tui/src/tools/AgentTool/resumeAgent.ts +39 -107
  312. package/tui/src/tools/AgentTool/resumeAgentHelpers.ts +140 -0
  313. package/tui/src/tools/AgentTool/runAgent.ts +1 -1
  314. package/tui/src/tools/AgentTool/runtimeConfig.ts +57 -0
  315. package/tui/src/tools/AgentTool/schemas.ts +196 -0
  316. package/tui/src/tools/AgentTool/sourceVerificationPropagation.ts +263 -0
  317. package/tui/src/tools/AgentTool/worktreeLifecycle.ts +105 -0
  318. package/tui/src/tools/AskUserQuestionTool/AskUserQuestionTool.tsx +174 -202
  319. package/tui/src/tools/BashTool/BashTool.tsx +71 -1072
  320. package/tui/src/tools/BashTool/bashCommandHelpers.ts +12 -12
  321. package/tui/src/tools/BashTool/bashPermissions/astPreflight.ts +173 -0
  322. package/tui/src/tools/BashTool/bashPermissions/classifierChecks.ts +199 -0
  323. package/tui/src/tools/BashTool/bashPermissions/compoundGuards.ts +53 -0
  324. package/tui/src/tools/BashTool/bashPermissions/constants.ts +99 -0
  325. package/tui/src/tools/BashTool/bashPermissions/index.ts +38 -0
  326. package/tui/src/tools/BashTool/bashPermissions/legacyMisparsing.ts +62 -0
  327. package/tui/src/tools/BashTool/bashPermissions/main.ts +135 -0
  328. package/tui/src/tools/BashTool/bashPermissions/normalizedCommands.ts +33 -0
  329. package/tui/src/tools/BashTool/bashPermissions/operatorFlow.ts +98 -0
  330. package/tui/src/tools/BashTool/bashPermissions/permissionChecks.ts +200 -0
  331. package/tui/src/tools/BashTool/bashPermissions/prefixSuggestions.ts +88 -0
  332. package/tui/src/tools/BashTool/bashPermissions/promptClassifierRules.ts +125 -0
  333. package/tui/src/tools/BashTool/bashPermissions/ruleDelegates.ts +19 -0
  334. package/tui/src/tools/BashTool/bashPermissions/ruleMatching.ts +145 -0
  335. package/tui/src/tools/BashTool/bashPermissions/sandboxAutoAllow.ts +75 -0
  336. package/tui/src/tools/BashTool/bashPermissions/subcommandFlow.ts +205 -0
  337. package/tui/src/tools/BashTool/bashPermissions/subcommandGuards.ts +73 -0
  338. package/tui/src/tools/BashTool/bashPermissions/subcommandResultHelpers.ts +116 -0
  339. package/tui/src/tools/BashTool/bashPermissions/types.ts +26 -0
  340. package/tui/src/tools/BashTool/bashPermissions/wrapperStripping.ts +139 -0
  341. package/tui/src/tools/BashTool/bashPermissions.ts +26 -2621
  342. package/tui/src/tools/BashTool/call.ts +202 -0
  343. package/tui/src/tools/BashTool/callLoader.ts +35 -0
  344. package/tui/src/tools/BashTool/commandClassification.ts +151 -0
  345. package/tui/src/tools/BashTool/commandClassificationLoader.ts +40 -0
  346. package/tui/src/tools/BashTool/cwdReset.ts +33 -0
  347. package/tui/src/tools/BashTool/lineTruncation.ts +11 -0
  348. package/tui/src/tools/BashTool/modeValidation.ts +13 -1
  349. package/tui/src/tools/BashTool/outputPersistence.ts +42 -0
  350. package/tui/src/tools/BashTool/permissionClassification.ts +66 -0
  351. package/tui/src/tools/BashTool/permissionLoader.ts +44 -0
  352. package/tui/src/tools/BashTool/resultLoader.ts +29 -0
  353. package/tui/src/tools/BashTool/resultMapping.ts +83 -0
  354. package/tui/src/tools/BashTool/sandboxPolicy.ts +79 -0
  355. package/tui/src/tools/BashTool/schemas.ts +65 -0
  356. package/tui/src/tools/BashTool/sedEditExecution.ts +59 -0
  357. package/tui/src/tools/BashTool/shellExecution.tsx +245 -0
  358. package/tui/src/tools/BashTool/shellOutputUtils.ts +85 -0
  359. package/tui/src/tools/BashTool/shellPermissionGauntlet.ts +97 -0
  360. package/tui/src/tools/BashTool/uiLoader.ts +37 -0
  361. package/tui/src/tools/BriefTool/upload.ts +1 -1
  362. package/tui/src/tools/CalculatorTool/parser.ts +2 -2
  363. package/tui/src/tools/DocumentPrimitive/DocumentPrimitive.ts +262 -0
  364. package/tui/src/tools/DocumentPrimitive/dispatchNormalization.ts +270 -0
  365. package/tui/src/tools/DocumentPrimitive/documentDestinationPath.ts +18 -0
  366. package/tui/src/tools/DocumentPrimitive/documentMutationGuard.ts +22 -0
  367. package/tui/src/tools/DocumentPrimitive/documentPatchNormalization.ts +248 -0
  368. package/tui/src/tools/DocumentPrimitive/documentSourceVerification.ts +245 -0
  369. package/tui/src/tools/DocumentPrimitive/documentSourceVerificationFields.ts +103 -0
  370. package/tui/src/tools/DocumentPrimitive/modelVisibleOutput.ts +40 -0
  371. package/tui/src/tools/DocumentPrimitive/prompt.ts +35 -0
  372. package/tui/src/tools/FileEditTool/FileEditTool.ts +9 -507
  373. package/tui/src/tools/FileEditTool/call.ts +228 -0
  374. package/tui/src/tools/FileEditTool/validateInput.ts +196 -0
  375. package/tui/src/tools/FileReadTool/imageProcessor.ts +13 -0
  376. package/tui/src/tools/FileWriteTool/FileWriteTool.ts +7 -300
  377. package/tui/src/tools/FileWriteTool/call.ts +223 -0
  378. package/tui/src/tools/FileWriteTool/validateInput.ts +80 -0
  379. package/tui/src/tools/ListMcpResourcesTool/ListMcpResourcesTool.ts +19 -3
  380. package/tui/src/tools/LookupPrimitive/LookupPrimitive.ts +48 -29
  381. package/tui/src/tools/LookupPrimitive/prompt.ts +6 -7
  382. package/tui/src/tools/MCPTool/trustPolicy.ts +118 -0
  383. package/tui/src/tools/McpAuthTool/McpAuthTool.ts +21 -3
  384. package/tui/src/tools/NotebookEditTool/NotebookEditTool.ts +7 -326
  385. package/tui/src/tools/NotebookEditTool/call.ts +254 -0
  386. package/tui/src/tools/NotebookEditTool/notebookModel.ts +51 -0
  387. package/tui/src/tools/NotebookEditTool/validateInput.ts +142 -0
  388. package/tui/src/tools/PowerShellTool/PowerShellTool.tsx +46 -937
  389. package/tui/src/tools/PowerShellTool/acceptEditsCommandValidation.ts +162 -0
  390. package/tui/src/tools/PowerShellTool/call.ts +179 -0
  391. package/tui/src/tools/PowerShellTool/callLoader.ts +37 -0
  392. package/tui/src/tools/PowerShellTool/commandClassification.ts +86 -0
  393. package/tui/src/tools/PowerShellTool/modeValidation.ts +25 -332
  394. package/tui/src/tools/PowerShellTool/outputPersistence.ts +42 -0
  395. package/tui/src/tools/PowerShellTool/permissionClassification.ts +28 -0
  396. package/tui/src/tools/PowerShellTool/resultLoader.ts +31 -0
  397. package/tui/src/tools/PowerShellTool/resultMapping.ts +75 -0
  398. package/tui/src/tools/PowerShellTool/schemas.ts +40 -0
  399. package/tui/src/tools/PowerShellTool/shellExecution.tsx +258 -0
  400. package/tui/src/tools/PowerShellTool/symlinkModeValidation.ts +44 -0
  401. package/tui/src/tools/PowerShellTool/uiLoader.ts +37 -0
  402. package/tui/src/tools/PowerShellTool/validation.ts +39 -0
  403. package/tui/src/tools/ReadMcpResourceTool/ReadMcpResourceTool.ts +19 -3
  404. package/tui/src/tools/ResolveLocationPrimitive/ResolveLocationPrimitive.ts +30 -19
  405. package/tui/src/tools/ResolveLocationPrimitive/prompt.ts +2 -6
  406. package/tui/src/tools/SkillTool/SkillTool.ts +2 -2
  407. package/tui/src/tools/SubmitPrimitive/SubmitPrimitive.ts +51 -18
  408. package/tui/src/tools/TaskCreateTool/TaskCreateTool.ts +16 -2
  409. package/tui/src/tools/TaskGetTool/TaskGetTool.ts +23 -3
  410. package/tui/src/tools/TaskListTool/TaskListTool.ts +22 -4
  411. package/tui/src/tools/TaskOutputTool/TaskOutputTool.tsx +46 -547
  412. package/tui/src/tools/TaskOutputTool/lookup.ts +216 -0
  413. package/tui/src/tools/TaskOutputTool/render.tsx +257 -0
  414. package/tui/src/tools/TaskOutputTool/schemas.ts +55 -0
  415. package/tui/src/tools/TaskOutputTool/serialization.ts +36 -0
  416. package/tui/src/tools/TaskStopTool/TaskStopTool.ts +10 -0
  417. package/tui/src/tools/TaskUpdateTool/TaskUpdateTool.ts +14 -364
  418. package/tui/src/tools/TaskUpdateTool/completion.ts +62 -0
  419. package/tui/src/tools/TaskUpdateTool/schemas.ts +62 -0
  420. package/tui/src/tools/TaskUpdateTool/serialization.ts +46 -0
  421. package/tui/src/tools/TaskUpdateTool/statusUpdate.ts +247 -0
  422. package/tui/src/tools/TodoWriteTool/TodoWriteTool.ts +21 -2
  423. package/tui/src/tools/ToolSearchTool/ToolSearchTool.ts +21 -302
  424. package/tui/src/tools/ToolSearchTool/ccSupportTools.ts +223 -0
  425. package/tui/src/tools/ToolSearchTool/descriptionCache.ts +50 -0
  426. package/tui/src/tools/ToolSearchTool/keywordSearch.ts +216 -0
  427. package/tui/src/tools/ToolSearchTool/prompt.ts +10 -4
  428. package/tui/src/tools/ToolSearchTool/resultMapping.ts +30 -0
  429. package/tui/src/tools/ToolSearchTool/schemas.ts +30 -0
  430. package/tui/src/tools/ToolSearchTool/searchPool.ts +47 -0
  431. package/tui/src/tools/ToolSearchTool/supportIntentHints.ts +140 -0
  432. package/tui/src/tools/TranslateTool/TranslateTool.ts +1 -1
  433. package/tui/src/tools/VerifyPrimitive/VerifyPrimitive.ts +27 -10
  434. package/tui/src/tools/WebFetchTool/WebFetchTool.ts +43 -138
  435. package/tui/src/tools/WebFetchTool/call.ts +227 -0
  436. package/tui/src/tools/WebFetchTool/resolvedAddressSafety.ts +78 -0
  437. package/tui/src/tools/WebFetchTool/sourceVerification.ts +204 -0
  438. package/tui/src/tools/WebFetchTool/types.ts +23 -0
  439. package/tui/src/tools/WebFetchTool/urlSafety.ts +181 -0
  440. package/tui/src/tools/WebFetchTool/utils.ts +1 -1
  441. package/tui/src/tools/WebSearchTool/UI.tsx +0 -1
  442. package/tui/src/tools/WebSearchTool/WebSearchTool.ts +9 -313
  443. package/tui/src/tools/WebSearchTool/call.ts +33 -0
  444. package/tui/src/tools/WebSearchTool/responseMapping.ts +190 -0
  445. package/tui/src/tools/WebSearchTool/resultBlock.ts +47 -0
  446. package/tui/src/tools/WebSearchTool/schemas.ts +47 -0
  447. package/tui/src/tools/WebSearchTool/toolSchema.ts +12 -0
  448. package/tui/src/tools/WorkspaceToolAdapter/WorkspaceToolAdapter.ts +79 -0
  449. package/tui/src/tools/WorkspaceToolAdapter/allowedRootPolicy.ts +85 -0
  450. package/tui/src/tools/WorkspaceToolAdapter/documentFormatGuards.ts +73 -0
  451. package/tui/src/tools/WorkspaceToolAdapter/inputNormalization.ts +105 -0
  452. package/tui/src/tools/WorkspaceToolAdapter/mcpExposurePolicy.ts +64 -0
  453. package/tui/src/tools/WorkspaceToolAdapter/toolDefFactory.ts +215 -0
  454. package/tui/src/tools/WorkspaceToolAdapter/toolNames.ts +6 -0
  455. package/tui/src/tools/WorkspaceToolAdapter/workspacePolicy.ts +15 -0
  456. package/tui/src/tools/_shared/citizenUserText.ts +49 -0
  457. package/tui/src/tools/_shared/dispatchPrimitive.ts +6 -6
  458. package/tui/src/tools/_shared/documentChangeToPatch.ts +125 -0
  459. package/tui/src/tools/_shared/documentDispatchArguments.ts +87 -0
  460. package/tui/src/tools/_shared/documentPrimitiveTimeout.ts +13 -0
  461. package/tui/src/tools/_shared/documentToolResultRender.ts +98 -0
  462. package/tui/src/tools/_shared/locationInputRepair.ts +112 -0
  463. package/tui/src/tools/_shared/pendingCallRegistry.ts +1 -6
  464. package/tui/src/tools/_shared/rootPrimitiveInput.ts +68 -0
  465. package/tui/src/tools/_shared/toolChoiceRepair/documentCompletionPatterns.ts +58 -0
  466. package/tui/src/tools/_shared/toolChoiceRepair/documentCompletionPrompt.ts +271 -0
  467. package/tui/src/tools/_shared/toolChoiceRepair/documentRepair.ts +452 -0
  468. package/tui/src/tools/_shared/toolChoiceRepair/messageAccess.ts +80 -0
  469. package/tui/src/tools/_shared/toolChoiceRepair/publicDataRepair.ts +92 -0
  470. package/tui/src/tools/_shared/toolChoiceRepair/supportRepair.ts +135 -0
  471. package/tui/src/tools/_shared/toolChoiceRepair.ts +61 -0
  472. package/tui/src/tools/shared/mockDisclaimer.ts +1 -1
  473. package/tui/src/tools.ts +39 -190
  474. package/tui/src/types/fileSuggestion.ts +4 -26
  475. package/tui/src/types/generated/events_mono/claude_code/v1/claude_code_internal_event.ts +186 -148
  476. package/tui/src/types/generated/events_mono/common/v1/auth.ts +25 -11
  477. package/tui/src/types/generated/events_mono/growthbook/v1/growthbook_experiment_event.ts +47 -30
  478. package/tui/src/types/generated/google/protobuf/timestamp.ts +21 -7
  479. package/tui/src/types/message.ts +80 -102
  480. package/tui/src/types/messageQueueTypes.ts +6 -28
  481. package/tui/src/types/notebook.ts +16 -38
  482. package/tui/src/types/statusLine.ts +4 -26
  483. package/tui/src/types/tools.ts +24 -46
  484. package/tui/src/types/utils.ts +6 -28
  485. package/tui/src/upstreamproxy/relay.ts +7 -3
  486. package/tui/src/upstreamproxy/upstreamproxy.ts +1 -1
  487. package/tui/src/utils/assistantMessageFactories.ts +9 -3
  488. package/tui/src/utils/attachments.ts +1 -1
  489. package/tui/src/utils/auth.ts +129 -139
  490. package/tui/src/utils/bash/ast.ts +23 -23
  491. package/tui/src/utils/bash/bashParser.ts +5 -5
  492. package/tui/src/utils/billing.ts +1 -1
  493. package/tui/src/utils/collapseReadSearch.ts +3 -3
  494. package/tui/src/utils/cronTasks.ts +1 -1
  495. package/tui/src/utils/execFileNoThrow.ts +1 -1
  496. package/tui/src/utils/filePersistence/types.ts +16 -38
  497. package/tui/src/utils/forkedAgent.ts +1 -1
  498. package/tui/src/utils/gracefulShutdown.ts +4 -4
  499. package/tui/src/utils/heapDumpService.ts +12 -8
  500. package/tui/src/utils/hooks/apiQueryHookHelper.ts +1 -1
  501. package/tui/src/utils/hooks/execPromptHook.ts +1 -1
  502. package/tui/src/utils/hooks/skillImprovement.ts +1 -1
  503. package/tui/src/utils/kExaoneReasoning.ts +138 -0
  504. package/tui/src/utils/mcp/dateTimeParser.ts +1 -1
  505. package/tui/src/utils/messages.ts +19 -0
  506. package/tui/src/utils/migrateSessions.ts +3 -3
  507. package/tui/src/utils/model/model.ts +6 -6
  508. package/tui/src/utils/multiToolLayout.ts +13 -0
  509. package/tui/src/utils/permissions/yoloClassifier.ts +1 -1
  510. package/tui/src/utils/plugins/headlessPluginInstall.ts +1 -1
  511. package/tui/src/utils/plugins/mcpPluginIntegration.ts +1 -1
  512. package/tui/src/utils/plugins/mcpbHandler.ts +1 -1
  513. package/tui/src/utils/plugins/pluginLoader.ts +8 -8
  514. package/tui/src/utils/processUserInput/processSlashCommand.tsx +2 -2
  515. package/tui/src/utils/processUserInput/processUserInput.ts +26 -0
  516. package/tui/src/utils/protectedNamespace.ts +5 -3
  517. package/tui/src/utils/rawJsonToolCall.ts +242 -0
  518. package/tui/src/utils/ripgrep.ts +16 -7
  519. package/tui/src/utils/sessionTitle.ts +1 -1
  520. package/tui/src/utils/settings/applySettingsChange.ts +4 -0
  521. package/tui/src/utils/settings/permissionValidation.ts +14 -2
  522. package/tui/src/utils/settings/types.ts +9 -3
  523. package/tui/src/utils/shell/prefix.ts +1 -1
  524. package/tui/src/utils/sideQuery.ts +1 -1
  525. package/tui/src/utils/stats.ts +1 -1
  526. package/tui/src/utils/systemThemeWatcher.ts +13 -3
  527. package/tui/src/utils/teleport.tsx +1 -1
  528. package/uv.lock +394 -22
  529. package/assets/copilot-gate-logo.svg +0 -58
  530. package/assets/govon-logo.svg +0 -40
  531. package/src/ummaya/eval/__init__.py +0 -5
  532. package/src/ummaya/eval/retrieval.py +0 -713
  533. package/tui/src/services/api/claude.ts +0 -3510
  534. package/tui/src/utils/messageStream.ts +0 -186
@@ -33,7 +33,7 @@ import signal
33
33
  import sys
34
34
  import time
35
35
  import uuid
36
- from collections.abc import Callable, Collection
36
+ from collections.abc import Callable, Collection, Iterable
37
37
  from datetime import UTC, datetime, timedelta
38
38
  from types import FrameType
39
39
  from typing import TYPE_CHECKING, Any, Final, Literal, cast
@@ -42,6 +42,10 @@ from opentelemetry import trace
42
42
  from opentelemetry.trace import Status, StatusCode
43
43
  from pydantic import TypeAdapter, ValidationError
44
44
 
45
+ from ummaya.evidence.source_provenance_redaction import redact_source_text
46
+ from ummaya.ipc.document_intent_normalization import (
47
+ _normalize_document_root_call_for_user_intent,
48
+ )
45
49
  from ummaya.ipc.envelope import attach_envelope_span_attributes
46
50
  from ummaya.ipc.frame_schema import (
47
51
  ErrorFrame,
@@ -53,6 +57,7 @@ if TYPE_CHECKING:
53
57
  from ummaya.session.manager import SessionManager
54
58
  from ummaya.tools.executor import ToolExecutor
55
59
  from ummaya.tools.registry import ToolRegistry
60
+ from ummaya.tools.routing import RouteDecision
56
61
 
57
62
  logger = logging.getLogger(__name__)
58
63
 
@@ -74,6 +79,31 @@ _LEGACY_SCOPE_VERB_ALIASES: Final[dict[str, str]] = {
74
79
  "submit": "send",
75
80
  "verify": "check",
76
81
  }
82
+
83
+
84
+ class _BackendSecretRedactionFilter(logging.Filter):
85
+ def filter(self, record: logging.LogRecord) -> bool:
86
+ record.msg = self._redact_log_value(record.msg)
87
+ if isinstance(record.args, tuple):
88
+ record.args = tuple(self._redact_log_value(arg) for arg in record.args)
89
+ elif isinstance(record.args, dict):
90
+ record.args = {key: self._redact_log_value(value) for key, value in record.args.items()}
91
+ return True
92
+
93
+ @staticmethod
94
+ def _redact_log_value(value: object) -> object:
95
+ if isinstance(value, str):
96
+ redacted, _categories = redact_source_text(value)
97
+ return redacted if redacted is not None else ""
98
+ if isinstance(value, (bool, int, float)) or value is None:
99
+ return value
100
+ text = str(value)
101
+ redacted, categories = redact_source_text(text)
102
+ if categories or redacted != text:
103
+ return redacted if redacted is not None else ""
104
+ return value
105
+
106
+
77
107
  _CANONICAL_SCOPE_ALIASES: Final[dict[str, str]] = {
78
108
  "find:mock_lookup_module_hometax_simplified": "find:hometax.simplified",
79
109
  "find:mock.lookup_module_hometax_simplified": "find:hometax.simplified",
@@ -188,6 +218,69 @@ _PRIMITIVE_ERROR_REASONS: Final[frozenset[str]] = frozenset(
188
218
  "verify_tool_choice_mismatch",
189
219
  }
190
220
  )
221
+ _ROOT_PRIMITIVE_TOOL_NAMES: Final[frozenset[str]] = frozenset({"find", "locate", "check", "send"})
222
+ _KMA_AIR_TOOL_IDS: Final[frozenset[str]] = frozenset(
223
+ {
224
+ "kma_apihub_url_air_amos_minute",
225
+ "kma_apihub_url_air_metar_decoded",
226
+ }
227
+ )
228
+ _KMA_ORDINARY_WEATHER_TOOL_IDS: Final[frozenset[str]] = frozenset(
229
+ {
230
+ "kma_current_observation",
231
+ "kma_ultra_short_term_forecast",
232
+ "kma_short_term_forecast",
233
+ }
234
+ )
235
+ _KMA_LOCATION_TOOL_IDS: Final[frozenset[str]] = frozenset(
236
+ {
237
+ "kakao_keyword_search",
238
+ "kakao_address_search",
239
+ "kakao_coord_to_region",
240
+ }
241
+ )
242
+ _KMA_AIRPORT_PLACE_RE: Final = re.compile(
243
+ r"(김해|김포|김해공항|김포공항|gimhae|gimpo|rkpk|rkss|\bairport\b|공항)",
244
+ re.IGNORECASE,
245
+ )
246
+ _KMA_AIRPORT_AVIATION_RE: Final = re.compile(
247
+ r"(비행기|항공편|비행편|운항|이륙|착륙|결항|지연|뜰\s*만|뜨나|뜰\s*수|"
248
+ r"flight|take\s*off|landing|delay|cancel|metar|speci|amos|rvr|활주로|"
249
+ r"시정|visibility|공항기상|항공기상)",
250
+ re.IGNORECASE,
251
+ )
252
+ _SYNTHETIC_USER_CONTEXT_RE: Final = re.compile(
253
+ r"<available_adapters\b|</available_adapters>|"
254
+ r"Pick a concrete adapter from <available_adapters>|"
255
+ r"Prefer concrete adapter function calls",
256
+ re.IGNORECASE,
257
+ )
258
+ _TOOL_RESULT_USER_CONTEXT_RE: Final = re.compile(
259
+ r"^\s*(?:<tool_use_error>|AdapterNotFound:|Permission delegation required:|Error:)"
260
+ r"|</tool_use_error>",
261
+ re.IGNORECASE,
262
+ )
263
+
264
+
265
+ def _is_citizen_user_utterance_text(content: object) -> bool:
266
+ if not isinstance(content, str):
267
+ return False
268
+ text = content.strip()
269
+ if not text:
270
+ return False
271
+ if _SYNTHETIC_USER_CONTEXT_RE.search(text):
272
+ return False
273
+ return _TOOL_RESULT_USER_CONTEXT_RE.search(text) is None
274
+
275
+
276
+ def _latest_citizen_user_utterance(messages: Collection[Any]) -> str:
277
+ for message in reversed(list(messages)):
278
+ if getattr(message, "role", None) != "user":
279
+ continue
280
+ content = getattr(message, "content", None)
281
+ if _is_citizen_user_utterance_text(content):
282
+ return cast(str, content)
283
+ return ""
191
284
 
192
285
 
193
286
  def _should_append_tui_tool_to_llm_tools(
@@ -203,6 +296,48 @@ def _should_append_tui_tool_to_llm_tools(
203
296
  return True
204
297
 
205
298
 
299
+ def _is_local_document_harness_tool(tool: object) -> bool:
300
+ endpoint = getattr(tool, "endpoint", "")
301
+ return isinstance(endpoint, str) and endpoint.startswith("local://document-harness/")
302
+
303
+
304
+ def _normalize_root_primitive_adapter_envelope(
305
+ fname: str,
306
+ args_obj: dict[str, object],
307
+ ) -> dict[str, object]:
308
+ """Normalize root primitive envelopes before strict adapter validation."""
309
+ if fname not in _ROOT_PRIMITIVE_TOOL_NAMES:
310
+ return args_obj
311
+ params_raw = args_obj.get("params")
312
+ if not isinstance(params_raw, dict):
313
+ return args_obj
314
+ nested_tool_id = params_raw.get("tool_id")
315
+ if not isinstance(nested_tool_id, str) or not nested_tool_id:
316
+ return args_obj
317
+ top_level_tool_id = args_obj.get("tool_id")
318
+ if top_level_tool_id == fname and nested_tool_id not in _ROOT_PRIMITIVE_TOOL_NAMES:
319
+ normalized = dict(args_obj)
320
+ normalized["tool_id"] = nested_tool_id
321
+ normalized["params"] = {key: value for key, value in params_raw.items() if key != "tool_id"}
322
+ return normalized
323
+ if nested_tool_id == top_level_tool_id:
324
+ normalized = dict(args_obj)
325
+ normalized["params"] = {key: value for key, value in params_raw.items() if key != "tool_id"}
326
+ return normalized
327
+ return args_obj
328
+
329
+
330
+ def _function_tool_choice(tool_name: str) -> dict[str, object]:
331
+ """Return OpenAI-compatible forced function-call syntax."""
332
+ return {"type": "function", "function": {"name": tool_name}}
333
+
334
+
335
+ def _tool_definition_names(tool_defs: list[Any] | None) -> set[str]:
336
+ if tool_defs is None:
337
+ return set()
338
+ return {tool.function.name for tool in tool_defs}
339
+
340
+
206
341
  _VERIFY_QUERY_REQUIREMENTS: Final[tuple[tuple[tuple[str, ...], dict[str, str]], ...]] = (
207
342
  (
208
343
  ("간편인증", "pass 인증", "kakao 인증", "naver 인증"),
@@ -259,6 +394,19 @@ _VERIFY_QUERY_REQUIREMENTS: Final[tuple[tuple[tuple[str, ...], dict[str, str]],
259
394
  "purpose_en": "Gov24 resident registration certificate civil petition",
260
395
  },
261
396
  ),
397
+ (
398
+ ("소득금액증명원", "소득금액증명"),
399
+ {
400
+ "verify_tool_id": "mock_verify_module_simple_auth",
401
+ "allowed_tool_ids": (
402
+ "mock_verify_module_simple_auth,mock_verify_mobile_id,mock_verify_ganpyeon_injeung"
403
+ ),
404
+ "scope": "check:ganpyeon.identity",
405
+ "allowed_scopes": "check:ganpyeon.identity,check:mobile_id.identity",
406
+ "purpose_ko": "소득금액증명원 발급 본인확인",
407
+ "purpose_en": "Income certificate identity verification",
408
+ },
409
+ ),
262
410
  (
263
411
  ("복지 급여 신청", "한부모가족", "아동양육비"),
264
412
  {
@@ -304,6 +452,8 @@ _LOCATION_INDEPENDENT_WORKFLOW_HINTS_KO: Final[frozenset[str]] = frozenset(
304
452
  "모바일신분증",
305
453
  "마이데이터",
306
454
  "공공마이데이터",
455
+ "소득금액증명원",
456
+ "소득금액증명",
307
457
  "과태료",
308
458
  "교통범칙금",
309
459
  "범칙금",
@@ -543,23 +693,44 @@ def _kma_observation_base_slot_hint(now_kst: datetime) -> tuple[str, str, str]:
543
693
 
544
694
 
545
695
  def _final_answer_looks_like_pending_tool_plan(text: str) -> bool:
546
- """Return true when final prose says it will call tools after tools already ran."""
696
+ """Return true when final prose is still planning after tools already ran."""
547
697
  normalized = " ".join(text.strip().split())
548
698
  if not normalized:
549
699
  return False
550
700
  pending_markers = (
551
701
  "호출하겠습니다",
552
702
  "조회하겠습니다",
703
+ "조회해 보겠습니다",
553
704
  "찾아보겠습니다",
554
705
  "검색하겠습니다",
555
706
  "진행하겠습니다",
556
707
  "확인하겠습니다",
708
+ "확인해 보겠습니다",
557
709
  "will call",
558
710
  "i'll call",
559
711
  "i will call",
560
712
  "will look up",
561
713
  )
562
- return any(marker in normalized.lower() for marker in pending_markers)
714
+ lowered = normalized.lower()
715
+ if any(marker in lowered for marker in pending_markers):
716
+ return True
717
+
718
+ meta_instruction_markers = (
719
+ "이제 응급 상황에 대한 지침을 제공해야 합니다",
720
+ "최종 답변은 다음과 같아야 합니다",
721
+ "도구 결과에서 그대로 가져와야 합니다",
722
+ "final answer should",
723
+ "the final answer should",
724
+ "should provide",
725
+ "should answer",
726
+ )
727
+ if any(marker in lowered for marker in meta_instruction_markers):
728
+ return True
729
+ return bool(
730
+ re.search(r"(?:답변|응답|최종 답변)[^.?!。]{0,40}해야 합니다", normalized)
731
+ or re.search(r"이제 [^.?!。]{0,60}(?:제공|안내|작성)해야 합니다", normalized)
732
+ or re.search(r"도구 결과[^.?!。]{0,60}가져와야 합니다", normalized)
733
+ )
563
734
 
564
735
 
565
736
  def _final_answer_looks_like_recursive_tool_message(text: str) -> bool:
@@ -675,6 +846,8 @@ def _final_answer_looks_like_tool_call_narration(text: str) -> bool:
675
846
  normalized = " ".join(text.strip().split())
676
847
  if not normalized:
677
848
  return False
849
+ if "<tool_call>" in normalized or "</tool_call>" in normalized:
850
+ return True
678
851
  head = normalized[:700]
679
852
  if "도구" not in head:
680
853
  return False
@@ -723,6 +896,244 @@ def _final_answer_looks_like_generic_retry_after_success(text: str) -> bool:
723
896
  return not bool(re.search(r"\d", normalized))
724
897
 
725
898
 
899
+ _KMA_ANALYSIS_USER_QUERY_RE: Final = re.compile(
900
+ r"(분석자료|이미\s*분석|고해상도\s*격자|객관분석|AWS\s*객관|지도\s*자료|"
901
+ r"일기도|분석일기도|비구름|바람\s*흐름|synoptic|weather\s*chart|"
902
+ r"objective\s*analysis|high[-\s]?resolution|grid)",
903
+ re.IGNORECASE,
904
+ )
905
+ _KMA_ANALYSIS_MAP_USER_QUERY_RE: Final = re.compile(
906
+ r"(일기도|분석일기도|지도\s*자료|비구름|바람\s*흐름|synoptic|weather\s*chart)",
907
+ re.IGNORECASE,
908
+ )
909
+ _DOCUMENT_WRITE_REQUEST_RE: Final = re.compile(
910
+ r"(작성|수정|편집|채우|채워|입력|변경|저장|write|edit|fill|apply|save)",
911
+ re.IGNORECASE,
912
+ )
913
+ _DOCUMENT_REVIEW_REQUEST_RE: Final = re.compile(
914
+ r"(diff|compact|변경사항|렌더|미리보기|render|viewport|page)",
915
+ re.IGNORECASE,
916
+ )
917
+ _DOCUMENT_DIFF_ONLY_FINAL_REQUEST_RE: Final = re.compile(
918
+ r"(실제(?:로)?\s*바뀐\s*내용만|바뀐\s*내용만|변경된\s*부분만|변경사항만|"
919
+ r"actual\s+changed\s+content\s+only|only\s+changed)",
920
+ re.IGNORECASE,
921
+ )
922
+ _DOCUMENT_ARTIFACT_ID_RE: Final = re.compile(
923
+ r"(?:^|[\s\"'`(])(?:artifact_id|artifact\s*id|artifact|아티팩트)?\s*"
924
+ r"((?:source|working|derivative|render|export|viewport)-[A-Za-z0-9][A-Za-z0-9_.-]{0,127})"
925
+ r"(?=$|[^A-Za-z0-9_.-])",
926
+ re.IGNORECASE,
927
+ )
928
+ _DOCUMENT_MUTATION_TOOL_IDS: Final[frozenset[str]] = frozenset(
929
+ {"document_apply_fill", "document_apply_style"}
930
+ )
931
+ _DOCUMENT_FINAL_ANSWER_OVERCLAIM_MARKERS: Final[frozenset[str]] = frozenset(
932
+ {
933
+ "활동 내용",
934
+ "주요 성과",
935
+ "문제점",
936
+ "개선사항",
937
+ "다음 주 계획",
938
+ "향후 계획",
939
+ "개발 활동",
940
+ "시각적 비교 기능",
941
+ "구현 완료",
942
+ "UMMAYA Phase",
943
+ "도구 결과",
944
+ "작성하겠습니다",
945
+ "작성해 드리겠습니다",
946
+ "보여드리겠습니다",
947
+ "확인해보겠습니다",
948
+ "구성하고",
949
+ "작성하여",
950
+ "활동일지로 작성",
951
+ "주간 활동일지",
952
+ "다음 주차 활동일지",
953
+ "다음 주 활동 계획",
954
+ "활동 계획",
955
+ "주요 일정",
956
+ "주요 변경사항",
957
+ "변경 요약",
958
+ "도큐먼트 하네스",
959
+ "CLI 툴 체인",
960
+ "품질 검증",
961
+ "파이프라인 최적화",
962
+ "시각적 diff",
963
+ "시각적 차이 비교",
964
+ "렌더링 아티팩트",
965
+ "아티팩트도 생성",
966
+ "아티팩트 생성",
967
+ "저장되었습니다",
968
+ "저장 완료",
969
+ "성공적으로 저장",
970
+ "업데이트가 완료되었습니다",
971
+ "표시되어 있습니다",
972
+ "표시하겠습니다",
973
+ "확인하실 수 있습니다",
974
+ "changes in the TUI",
975
+ "준비되었습니다",
976
+ "📋",
977
+ "📅",
978
+ "🔄",
979
+ "📊",
980
+ "| 항목 |",
981
+ }
982
+ )
983
+ _KMA_ANALYSIS_TOOL_IDS: Final[frozenset[str]] = frozenset(
984
+ {
985
+ "kma_apihub_url_high_resolution_grid_point",
986
+ "kma_apihub_url_aws_objective_analysis_grid",
987
+ "kma_apihub_url_analysis_weather_chart_image",
988
+ }
989
+ )
990
+ _PPS_BID_USER_QUERY_RE: Final = re.compile(
991
+ r"(입찰|나라장터|조달청|공고|공사조회|전기공사|bid|procurement)",
992
+ re.IGNORECASE,
993
+ )
994
+ _AIRKOREA_USER_QUERY_RE: Final = re.compile(
995
+ r"(미세먼지|초미세먼지|초미세|대기질|대기오염|공기질|마스크|"
996
+ r"pm\s*2\.?5|pm\s*10|air\s*korea|airkorea|air\s*quality|airquality)",
997
+ re.IGNORECASE,
998
+ )
999
+ _TAGO_BUS_USER_QUERY_RE: Final = re.compile(
1000
+ r"(버스|시내버스|정류장|정류소|노선|도착|언제\s*와|몇\s*분|bus|route|arrival|station)",
1001
+ re.IGNORECASE,
1002
+ )
1003
+ _TAGO_ROUTE_NO_RE: Final = re.compile(r"(?:^|[^\d])(\d{1,4}(?:-\d)?)\s*번")
1004
+ _TAGO_TOOL_IDS: Final[frozenset[str]] = frozenset(
1005
+ {
1006
+ "tago_bus_station_search",
1007
+ "tago_bus_arrival_search",
1008
+ "tago_bus_route_search",
1009
+ "tago_bus_route_station_search",
1010
+ "tago_bus_location_search",
1011
+ }
1012
+ )
1013
+ _AIRKOREA_TOOL_ID: Final = "airkorea_ctprvn_air_quality"
1014
+ _PPS_BID_TOOL_ID: Final = "pps_bid_public_info"
1015
+ _KMA_ANALYSIS_CHART_TOOL_ID: Final = "kma_apihub_url_analysis_weather_chart_image"
1016
+
1017
+
1018
+ def _initial_concrete_tool_choice_for_query(
1019
+ user_query: str,
1020
+ available_tool_names: Collection[str],
1021
+ ) -> str | None:
1022
+ """Force direct first calls only for unambiguous single-adapter lookups."""
1023
+ available = set(available_tool_names)
1024
+ return _document_tool_choice_for_query(user_query, available)
1025
+
1026
+
1027
+ def _document_tool_choice_for_query(
1028
+ user_query: str,
1029
+ available: set[str],
1030
+ ) -> str | None:
1031
+ """Force unambiguous local document turns through the single document primitive."""
1032
+ from ummaya.tools.search import is_document_harness_query # noqa: PLC0415
1033
+
1034
+ if (
1035
+ _DOCUMENT_ARTIFACT_ID_RE.search(user_query)
1036
+ and _DOCUMENT_REVIEW_REQUEST_RE.search(user_query)
1037
+ and "document" in available
1038
+ ):
1039
+ return "document"
1040
+ if is_document_harness_query(user_query) and "document" in available:
1041
+ return "document"
1042
+ return None
1043
+
1044
+
1045
+ def _final_answer_looks_like_kma_analysis_fabrication(text: str, user_query: str) -> bool:
1046
+ """Detect KMA analysis answers that fill failed lookups with general knowledge."""
1047
+ if not _KMA_ANALYSIS_USER_QUERY_RE.search(user_query):
1048
+ return False
1049
+ normalized = " ".join(text.strip().split())
1050
+ if not normalized:
1051
+ return False
1052
+ failure_markers = (
1053
+ "데이터가 비어",
1054
+ "확인할 수 없",
1055
+ "접근할 수 없",
1056
+ "조회가 어려",
1057
+ "직접 접근할 수 없는",
1058
+ "전체 내용을 확인할 수 없",
1059
+ "실패",
1060
+ )
1061
+ fabrication_markers = (
1062
+ "일반적인 지식",
1063
+ "일반적 정보",
1064
+ "일반적으로",
1065
+ "판단됩니다",
1066
+ "특별한 기상 상황은 아닌",
1067
+ )
1068
+ return any(marker in normalized for marker in failure_markers) and any(
1069
+ marker in normalized for marker in fabrication_markers
1070
+ )
1071
+
1072
+
1073
+ def _conversation_has_kma_chart_upstream_failure(llm_messages: list[Any]) -> bool:
1074
+ """Return True when a KMA analyzed weather-chart lookup failed upstream."""
1075
+ for msg in reversed(llm_messages):
1076
+ if getattr(msg, "role", None) != "tool":
1077
+ continue
1078
+ content = str(getattr(msg, "content", "") or "")
1079
+ name = str(getattr(msg, "name", "") or "")
1080
+ if (
1081
+ "kma_apihub_url_analysis_weather_chart_image" not in content
1082
+ and name != "kma_apihub_url_analysis_weather_chart_image"
1083
+ ):
1084
+ continue
1085
+ normalized = " ".join(content.split())
1086
+ return any(
1087
+ marker in normalized
1088
+ for marker in (
1089
+ "활용신청",
1090
+ "approval",
1091
+ "upstream_error",
1092
+ "403",
1093
+ "error",
1094
+ "failed",
1095
+ )
1096
+ )
1097
+ return False
1098
+
1099
+
1100
+ def _final_answer_substitutes_after_kma_chart_failure(
1101
+ text: str,
1102
+ user_query: str,
1103
+ llm_messages: list[Any],
1104
+ ) -> bool:
1105
+ """Detect map/chart answers that substitute other evidence after chart failure."""
1106
+ if not _KMA_ANALYSIS_MAP_USER_QUERY_RE.search(user_query):
1107
+ return False
1108
+ if not _conversation_has_kma_chart_upstream_failure(llm_messages):
1109
+ return False
1110
+ normalized = " ".join(text.strip().split())
1111
+ if not normalized:
1112
+ return False
1113
+ substitution_markers = (
1114
+ "AWS 객관",
1115
+ "고해상도",
1116
+ "현재 관측망",
1117
+ "관측값",
1118
+ "기온",
1119
+ "풍속",
1120
+ "풍향",
1121
+ "시정",
1122
+ "강수량",
1123
+ "상대습도",
1124
+ "대안으로",
1125
+ "일반적인",
1126
+ "일반적",
1127
+ "패턴상",
1128
+ "가능성",
1129
+ "추정",
1130
+ "보입니다",
1131
+ "서풍",
1132
+ "남해안",
1133
+ )
1134
+ return any(marker in normalized for marker in substitution_markers)
1135
+
1136
+
726
1137
  def _conversation_has_successful_any_primitive_result(llm_messages: list[Any]) -> bool:
727
1138
  """Return True when the loop already has a successful primitive result."""
728
1139
  return (
@@ -730,6 +1141,7 @@ def _conversation_has_successful_any_primitive_result(llm_messages: list[Any]) -
730
1141
  or _conversation_has_successful_primitive_any_tool(llm_messages, primitive="locate")
731
1142
  or _conversation_has_successful_primitive_any_tool(llm_messages, primitive="check")
732
1143
  or _conversation_has_successful_primitive_any_tool(llm_messages, primitive="send")
1144
+ or _conversation_has_successful_primitive_any_tool(llm_messages, primitive="document")
733
1145
  )
734
1146
 
735
1147
 
@@ -1347,6 +1759,80 @@ def _payload_dict_is_error_like(payload: dict[str, object]) -> bool:
1347
1759
  return isinstance(error, str) and bool(error)
1348
1760
 
1349
1761
 
1762
+ def _message_role(msg: Any) -> object:
1763
+ """Return a transcript message role across SDK and dict shapes."""
1764
+ return getattr(msg, "role", None) or (msg.get("role") if isinstance(msg, dict) else None)
1765
+
1766
+
1767
+ def _message_content(msg: Any) -> object:
1768
+ """Return a transcript message content across SDK and dict shapes."""
1769
+ content = getattr(msg, "content", None)
1770
+ if content is None and isinstance(msg, dict):
1771
+ content = msg.get("content")
1772
+ return content
1773
+
1774
+
1775
+ def _decode_tool_result_payload_content(content: object) -> object | None:
1776
+ """Decode the payload stored inside a tool_result message/content part."""
1777
+ if isinstance(content, str):
1778
+ try:
1779
+ payload: object = _stdlib_json.loads(content)
1780
+ return payload
1781
+ except _stdlib_json.JSONDecodeError:
1782
+ return None
1783
+ if isinstance(content, (dict, list)):
1784
+ return content
1785
+ return None
1786
+
1787
+
1788
+ def _iter_tool_result_payloads(msg: Any) -> list[tuple[str | None, str | None, object]]:
1789
+ """Return tool_result payloads from OpenAI tool-role or CC user-role shapes."""
1790
+ role = _message_role(msg)
1791
+ if role == "tool":
1792
+ content = _message_content(msg)
1793
+ payload = _decode_tool_result_payload_content(content)
1794
+ if payload is None:
1795
+ return []
1796
+ call_id = getattr(msg, "tool_call_id", None) or (
1797
+ msg.get("tool_call_id") if isinstance(msg, dict) else None
1798
+ )
1799
+ name = getattr(msg, "name", None) or (msg.get("name") if isinstance(msg, dict) else None)
1800
+ return [
1801
+ (
1802
+ call_id if isinstance(call_id, str) else None,
1803
+ name if isinstance(name, str) else None,
1804
+ payload,
1805
+ )
1806
+ ]
1807
+ if role != "user":
1808
+ return []
1809
+ content = _message_content(msg)
1810
+ if not isinstance(content, list):
1811
+ return []
1812
+ payloads: list[tuple[str | None, str | None, object]] = []
1813
+ for item in content:
1814
+ if not isinstance(item, dict) or item.get("type") != "tool_result":
1815
+ continue
1816
+ payload = _decode_tool_result_payload_content(item.get("content"))
1817
+ if payload is None:
1818
+ continue
1819
+ call_id = item.get("tool_use_id")
1820
+ payloads.append((call_id if isinstance(call_id, str) else None, None, payload))
1821
+ return payloads
1822
+
1823
+
1824
+ def _message_is_tool_result_only(msg: Any) -> bool:
1825
+ """Return True for CC-style user messages that only carry tool_result blocks."""
1826
+ if _message_role(msg) != "user":
1827
+ return False
1828
+ content = _message_content(msg)
1829
+ return (
1830
+ isinstance(content, list)
1831
+ and bool(content)
1832
+ and all(isinstance(item, dict) and item.get("type") == "tool_result" for item in content)
1833
+ )
1834
+
1835
+
1350
1836
  def _tool_result_payload_is_error(payload: object) -> bool:
1351
1837
  """Return True for structured tool-result payloads that are errors."""
1352
1838
  if not isinstance(payload, dict):
@@ -1359,6 +1845,224 @@ def _tool_result_payload_is_error(payload: object) -> bool:
1359
1845
  )
1360
1846
 
1361
1847
 
1848
+ def _payload_has_successful_document_tool_id(
1849
+ payload: object,
1850
+ tool_ids: frozenset[str],
1851
+ ) -> bool:
1852
+ """Return True when a structured tool result contains a successful document tool."""
1853
+ if isinstance(payload, list):
1854
+ return any(_payload_has_successful_document_tool_id(item, tool_ids) for item in payload)
1855
+ if not isinstance(payload, dict):
1856
+ return False
1857
+ tool_id = payload.get("tool_id")
1858
+ if isinstance(tool_id, str) and tool_id in tool_ids:
1859
+ status = payload.get("status")
1860
+ status_text = str(status).lower() if status is not None else "ok"
1861
+ if status_text in {"ok", "succeeded", "completed", "ready"} and not (
1862
+ _tool_result_payload_is_error(payload)
1863
+ ):
1864
+ return True
1865
+ return any(
1866
+ _payload_has_successful_document_tool_id(value, tool_ids) for value in payload.values()
1867
+ )
1868
+
1869
+
1870
+ def _conversation_has_successful_document_tool_result(
1871
+ llm_messages: list[Any],
1872
+ *,
1873
+ tool_ids: frozenset[str],
1874
+ ) -> bool:
1875
+ """Return True when a concrete document tool has a successful tool_result."""
1876
+ return _conversation_has_successful_document_tool_result_after(
1877
+ llm_messages,
1878
+ tool_ids=tool_ids,
1879
+ after_index=-1,
1880
+ )
1881
+
1882
+
1883
+ def _conversation_has_successful_document_tool_result_after(
1884
+ llm_messages: list[Any],
1885
+ *,
1886
+ tool_ids: frozenset[str],
1887
+ after_index: int,
1888
+ ) -> bool:
1889
+ """Return True when a successful document tool_result appears after an index."""
1890
+ start_index = max(0, after_index + 1)
1891
+ for msg in llm_messages[start_index:]:
1892
+ for _, _, payload in _iter_tool_result_payloads(msg):
1893
+ if _payload_has_successful_document_tool_id(payload, tool_ids):
1894
+ return True
1895
+ return False
1896
+
1897
+
1898
+ def _conversation_has_successful_document_diff_result_after(
1899
+ llm_messages: list[Any],
1900
+ *,
1901
+ after_index: int,
1902
+ ) -> bool:
1903
+ """Return True when a successful document mutation/render diff appears."""
1904
+ start_index = max(0, after_index + 1)
1905
+ for msg in llm_messages[start_index:]:
1906
+ for _, _, payload in _iter_tool_result_payloads(msg):
1907
+ document_result = _extract_successful_document_result_payload(payload)
1908
+ if document_result is not None and _document_diff_changes(document_result):
1909
+ return True
1910
+ return False
1911
+
1912
+
1913
+ def _conversation_has_successful_document_completion_result_after(
1914
+ llm_messages: list[Any],
1915
+ *,
1916
+ after_index: int,
1917
+ ) -> bool:
1918
+ """Return True when the latest turn has a completed document/review result."""
1919
+ start_index = max(0, after_index + 1)
1920
+ for msg in llm_messages[start_index:]:
1921
+ for _, _, payload in _iter_tool_result_payloads(msg):
1922
+ if _extract_successful_document_completion_payload(payload) is not None:
1923
+ return True
1924
+ return False
1925
+
1926
+
1927
+ def _extract_successful_document_completion_payload(payload: object) -> dict[str, object] | None:
1928
+ """Return a successful document completion payload, excluding inspect-only results."""
1929
+ if isinstance(payload, list):
1930
+ return _extract_successful_document_completion_from_sequence(payload)
1931
+ if isinstance(payload, dict):
1932
+ return _extract_successful_document_completion_from_dict(payload)
1933
+ return None
1934
+
1935
+
1936
+ def _extract_successful_document_completion_from_sequence(
1937
+ payload: list[object],
1938
+ ) -> dict[str, object] | None:
1939
+ for item in reversed(payload):
1940
+ result = _extract_successful_document_completion_payload(item)
1941
+ if result is not None:
1942
+ return result
1943
+ return None
1944
+
1945
+
1946
+ def _extract_successful_document_completion_from_dict(
1947
+ payload: dict[str, object],
1948
+ ) -> dict[str, object] | None:
1949
+ if _tool_result_payload_is_error(payload):
1950
+ return None
1951
+
1952
+ direct = _direct_successful_document_completion_payload(payload)
1953
+ if direct is not None:
1954
+ return direct
1955
+
1956
+ result = payload.get("result")
1957
+ if isinstance(result, dict):
1958
+ nested = _extract_successful_document_completion_payload(result)
1959
+ if nested is not None:
1960
+ return nested
1961
+ return _extract_successful_document_completion_from_sequence(list(payload.values()))
1962
+
1963
+
1964
+ def _direct_successful_document_completion_payload(
1965
+ payload: dict[str, object],
1966
+ ) -> dict[str, object] | None:
1967
+ tool_id = payload.get("tool_id")
1968
+ if not isinstance(tool_id, str) or tool_id not in {"document", "document_render"}:
1969
+ return None
1970
+ status = payload.get("status")
1971
+ status_text = str(status).lower() if status is not None else "ok"
1972
+ if status_text not in {"ok", "succeeded", "completed", "ready"}:
1973
+ return None
1974
+ if tool_id == "document" and _document_result_is_inspect_only(payload):
1975
+ return None
1976
+ return payload
1977
+
1978
+
1979
+ def _document_result_is_inspect_only(payload: dict[str, object]) -> bool:
1980
+ """Return True for document primitive results that only inspected a file."""
1981
+ if _document_diff_changes(payload):
1982
+ return False
1983
+ diff = payload.get("diff")
1984
+ if diff is None and isinstance(payload.get("extraction"), dict):
1985
+ return True
1986
+ if diff is None and payload.get("render_artifacts") == []:
1987
+ artifact_refs = payload.get("artifact_refs")
1988
+ if isinstance(artifact_refs, list) and artifact_refs:
1989
+ has_only_source_refs = all(
1990
+ isinstance(ref, str) and ref.startswith("source-") for ref in artifact_refs
1991
+ )
1992
+ if has_only_source_refs:
1993
+ return True
1994
+ summary = str(payload.get("text_summary") or "").casefold()
1995
+ return (
1996
+ "inspection completed through the document primitive" in summary
1997
+ or "document inspection completed" in summary
1998
+ or "document extraction completed" in summary
1999
+ )
2000
+
2001
+
2002
+ def _latest_user_message_index(llm_messages: list[Any]) -> int:
2003
+ """Return the latest user-message index in the LLM transcript."""
2004
+ for index in range(len(llm_messages) - 1, -1, -1):
2005
+ msg = llm_messages[index]
2006
+ if _message_role(msg) == "user" and not _message_is_tool_result_only(msg):
2007
+ return index
2008
+ return -1
2009
+
2010
+
2011
+ def _check_document_workflow_terminated_without_required_tool(
2012
+ llm_messages: list[Any],
2013
+ latest_user_utt: str,
2014
+ *,
2015
+ candidate_final_answer: str = "",
2016
+ ) -> dict[str, str] | None:
2017
+ """Return the missing document tool when a document workflow tries to stop early."""
2018
+ from ummaya.tools.search import is_document_harness_query # noqa: PLC0415
2019
+
2020
+ text_for_intent = f"{latest_user_utt}\n{candidate_final_answer}"
2021
+ latest_user_index = _latest_user_message_index(llm_messages)
2022
+ has_document_workflow_activity = _conversation_has_successful_document_tool_result(
2023
+ llm_messages,
2024
+ tool_ids=frozenset(
2025
+ {
2026
+ "document",
2027
+ "document_render",
2028
+ "document_inspect",
2029
+ "document_extract",
2030
+ "document_form_schema",
2031
+ "document_copy_for_edit",
2032
+ "document_apply_fill",
2033
+ "document_apply_style",
2034
+ "document_validate_public_form",
2035
+ "document_save",
2036
+ }
2037
+ ),
2038
+ )
2039
+ if not is_document_harness_query(latest_user_utt) and not has_document_workflow_activity:
2040
+ return None
2041
+ wants_write = bool(_DOCUMENT_WRITE_REQUEST_RE.search(text_for_intent))
2042
+ wants_review = bool(_DOCUMENT_REVIEW_REQUEST_RE.search(text_for_intent))
2043
+ if not wants_write and not wants_review:
2044
+ return None
2045
+ has_document_result_for_latest_request = (
2046
+ _conversation_has_successful_document_completion_result_after(
2047
+ llm_messages,
2048
+ after_index=latest_user_index,
2049
+ )
2050
+ )
2051
+ if has_document_result_for_latest_request:
2052
+ return None
2053
+ return {
2054
+ "tool_id": "document",
2055
+ "message": (
2056
+ "Document workflow request has no successful document primitive result "
2057
+ "for the latest user turn. Do NOT answer from intended edits or "
2058
+ "fabricate compact diff text. Call document once with the document "
2059
+ "locator, requested operation, instruction, and any inferred patches; "
2060
+ "the runtime will inspect, copy, mutate, render, and return the "
2061
+ "automatic compact diff."
2062
+ ),
2063
+ }
2064
+
2065
+
1362
2066
  def _lookup_call_ids_for_tool(
1363
2067
  llm_messages: list[Any],
1364
2068
  *,
@@ -1402,24 +2106,10 @@ def _tool_result_payload_for_call(
1402
2106
  matching_call_ids: set[str],
1403
2107
  ) -> object | None:
1404
2108
  """Parse a lookup tool-result message when it matches one of call IDs."""
1405
- role = getattr(msg, "role", None) or (msg.get("role") if isinstance(msg, dict) else None)
1406
- if role != "tool":
1407
- return None
1408
- call_id = getattr(msg, "tool_call_id", None) or (
1409
- msg.get("tool_call_id") if isinstance(msg, dict) else None
1410
- )
1411
- if not isinstance(call_id, str) or call_id not in matching_call_ids:
1412
- return None
1413
- content = getattr(msg, "content", None) or (
1414
- msg.get("content") if isinstance(msg, dict) else None
1415
- )
1416
- if not isinstance(content, str):
1417
- return None
1418
- try:
1419
- payload: object = _stdlib_json.loads(content)
1420
- return payload
1421
- except _stdlib_json.JSONDecodeError:
1422
- return None
2109
+ for call_id, _, payload in _iter_tool_result_payloads(msg):
2110
+ if isinstance(call_id, str) and call_id in matching_call_ids:
2111
+ return payload
2112
+ return None
1423
2113
 
1424
2114
 
1425
2115
  def _conversation_has_successful_lookup(
@@ -1485,24 +2175,11 @@ def _tool_result_payload_for_primitive_call(
1485
2175
  matching_call_ids: set[str],
1486
2176
  ) -> object | None:
1487
2177
  """Parse a primitive tool-result message when it matches one of call IDs."""
1488
- role = getattr(msg, "role", None) or (msg.get("role") if isinstance(msg, dict) else None)
1489
- if role != "tool":
1490
- return None
1491
- call_id = getattr(msg, "tool_call_id", None) or (
1492
- msg.get("tool_call_id") if isinstance(msg, dict) else None
1493
- )
1494
- if not isinstance(call_id, str) or call_id not in matching_call_ids:
1495
- return None
1496
- content = getattr(msg, "content", None) or (
1497
- msg.get("content") if isinstance(msg, dict) else None
1498
- )
1499
- if not isinstance(content, str):
1500
- return None
1501
- try:
1502
- payload: object = _stdlib_json.loads(content)
1503
- return payload
1504
- except _stdlib_json.JSONDecodeError:
1505
- return None
2178
+ _ = primitive
2179
+ for call_id, _, payload in _iter_tool_result_payloads(msg):
2180
+ if isinstance(call_id, str) and call_id in matching_call_ids:
2181
+ return payload
2182
+ return None
1506
2183
 
1507
2184
 
1508
2185
  def _tool_result_payload_for_primitive(
@@ -1517,24 +2194,17 @@ def _tool_result_payload_for_primitive(
1517
2194
  resolved state of the most recent primitive invocation, not a specific
1518
2195
  call handle.
1519
2196
  """
1520
- role = getattr(msg, "role", None) or (msg.get("role") if isinstance(msg, dict) else None)
1521
- if role != "tool":
1522
- return None
1523
- name = getattr(msg, "name", None) or (msg.get("name") if isinstance(msg, dict) else None)
1524
- content = getattr(msg, "content", None) or (
1525
- msg.get("content") if isinstance(msg, dict) else None
1526
- )
1527
- if not isinstance(content, str):
1528
- return None
1529
- try:
1530
- payload: object = _stdlib_json.loads(content)
2197
+ for _, name, payload in _iter_tool_result_payloads(msg):
1531
2198
  if name == primitive:
1532
2199
  return payload
1533
2200
  if isinstance(payload, dict) and payload.get("kind") == primitive:
1534
2201
  return payload
1535
- return None
1536
- except _stdlib_json.JSONDecodeError:
1537
- return None
2202
+ result = _primitive_payload_result_dict(payload)
2203
+ if primitive == "document" and result is not None:
2204
+ tool_id = result.get("tool_id")
2205
+ if tool_id in {"document", "document_render"}:
2206
+ return payload
2207
+ return None
1538
2208
 
1539
2209
 
1540
2210
  def _primitive_payload_is_success(payload: object, *, primitive: str) -> bool:
@@ -1548,6 +2218,10 @@ def _primitive_payload_is_success(payload: object, *, primitive: str) -> bool:
1548
2218
  if isinstance(result, dict) and result.get("status") == "succeeded":
1549
2219
  return True
1550
2220
  return payload.get("status") == "succeeded"
2221
+ if primitive == "document":
2222
+ if isinstance(result, dict):
2223
+ return result.get("status") == "ok"
2224
+ return payload.get("status") == "ok"
1551
2225
  return True
1552
2226
 
1553
2227
 
@@ -1754,6 +2428,43 @@ def _latest_successful_primitive_result(
1754
2428
  return None
1755
2429
 
1756
2430
 
2431
+ def _latest_successful_locate_result_with_coords(
2432
+ llm_messages: list[Any],
2433
+ *,
2434
+ registry: Any = None,
2435
+ ) -> dict[str, object] | None:
2436
+ """Return the most recent successful locate result that carries WGS-84 coords."""
2437
+ if registry is not None:
2438
+ matching_call_ids = _primitive_call_ids_for_tool(
2439
+ llm_messages,
2440
+ primitive="locate",
2441
+ registry=registry,
2442
+ )
2443
+ for msg in reversed(llm_messages):
2444
+ payload = _tool_result_payload_for_primitive_call(
2445
+ msg,
2446
+ primitive="locate",
2447
+ matching_call_ids=matching_call_ids,
2448
+ )
2449
+ if payload is None or not _primitive_payload_is_success(
2450
+ payload,
2451
+ primitive="locate",
2452
+ ):
2453
+ continue
2454
+ result = _primitive_payload_result_dict(payload)
2455
+ if result is not None and _locate_result_coords(result) is not None:
2456
+ return result
2457
+
2458
+ for msg in reversed(llm_messages):
2459
+ payload = _tool_result_payload_for_primitive(msg, primitive="locate")
2460
+ if payload is None or not _primitive_payload_is_success(payload, primitive="locate"):
2461
+ continue
2462
+ result = _primitive_payload_result_dict(payload)
2463
+ if result is not None and _locate_result_coords(result) is not None:
2464
+ return result
2465
+ return None
2466
+
2467
+
1757
2468
  def _latest_successful_primitive_result_for_tool(
1758
2469
  llm_messages: list[Any],
1759
2470
  *,
@@ -1829,7 +2540,7 @@ def _latest_successful_primitive_observation(
1829
2540
  )
1830
2541
  primitive: object = tool_message_name
1831
2542
  payload: object | None = None
1832
- if primitive not in {"find", "locate", "check", "send"}:
2543
+ if primitive not in {"find", "locate", "check", "send", "document"}:
1833
2544
  if not isinstance(content, str):
1834
2545
  continue
1835
2546
  try:
@@ -1839,7 +2550,7 @@ def _latest_successful_primitive_observation(
1839
2550
  if not isinstance(parsed_payload, dict):
1840
2551
  continue
1841
2552
  primitive = parsed_payload.get("kind")
1842
- if primitive not in {"find", "locate", "check", "send"}:
2553
+ if primitive not in {"find", "locate", "check", "send", "document"}:
1843
2554
  continue
1844
2555
  payload = parsed_payload
1845
2556
  if payload is None:
@@ -1857,6 +2568,183 @@ def _latest_successful_primitive_observation(
1857
2568
  return None
1858
2569
 
1859
2570
 
2571
+ def _latest_successful_document_result(llm_messages: list[Any]) -> dict[str, object] | None:
2572
+ """Return the latest successful document primitive result payload."""
2573
+ for msg in reversed(llm_messages):
2574
+ payload = _tool_result_payload_for_primitive(msg, primitive="document")
2575
+ if payload is not None and _primitive_payload_is_success(payload, primitive="document"):
2576
+ result = _primitive_payload_result_dict(payload)
2577
+ if result is not None:
2578
+ return result
2579
+ if isinstance(payload, dict):
2580
+ return cast("dict[str, object]", payload)
2581
+ role = getattr(msg, "role", None) or (msg.get("role") if isinstance(msg, dict) else None)
2582
+ if role != "tool":
2583
+ continue
2584
+ content = getattr(msg, "content", None)
2585
+ if content is None and isinstance(msg, dict):
2586
+ content = msg.get("content")
2587
+ parsed_payload: object = None
2588
+ if isinstance(content, str):
2589
+ try:
2590
+ parsed_payload = _stdlib_json.loads(content)
2591
+ except _stdlib_json.JSONDecodeError:
2592
+ continue
2593
+ else:
2594
+ parsed_payload = content
2595
+ document_result = _extract_successful_document_result_payload(parsed_payload)
2596
+ if document_result is not None:
2597
+ return document_result
2598
+ return None
2599
+
2600
+
2601
+ def _extract_successful_document_result_payload(payload: object) -> dict[str, object] | None:
2602
+ """Return a successful document result from direct, wrapped, or nested payloads."""
2603
+ if isinstance(payload, list):
2604
+ return _extract_successful_document_result_from_sequence(payload)
2605
+ if isinstance(payload, dict):
2606
+ return _extract_successful_document_result_from_dict(payload)
2607
+ return None
2608
+
2609
+
2610
+ def _extract_successful_document_result_from_sequence(
2611
+ payload: list[object],
2612
+ ) -> dict[str, object] | None:
2613
+ """Return the last successful document result from a payload sequence."""
2614
+ for item in reversed(payload):
2615
+ document_result = _extract_successful_document_result_payload(item)
2616
+ if document_result is not None:
2617
+ return document_result
2618
+ return None
2619
+
2620
+
2621
+ def _extract_successful_document_result_from_dict(
2622
+ payload: dict[str, object],
2623
+ ) -> dict[str, object] | None:
2624
+ """Return a successful document result from a payload mapping."""
2625
+ if _tool_result_payload_is_error(payload):
2626
+ return None
2627
+ result = payload.get("result")
2628
+ if isinstance(result, dict):
2629
+ document_result = _extract_successful_document_result_payload(result)
2630
+ if document_result is not None:
2631
+ return document_result
2632
+ if _payload_is_document_result_with_diff(payload):
2633
+ return payload
2634
+ for nested in reversed(list(payload.values())):
2635
+ document_result = _extract_successful_document_result_payload(nested)
2636
+ if document_result is not None:
2637
+ return document_result
2638
+ return None
2639
+
2640
+
2641
+ def _payload_is_document_result_with_diff(payload: dict[str, object]) -> bool:
2642
+ """Return True when a payload has the user-visible document diff contract."""
2643
+ diff = payload.get("diff")
2644
+ status = str(payload.get("status") or "ok").lower()
2645
+ return (
2646
+ isinstance(diff, dict)
2647
+ and isinstance(diff.get("changes"), list)
2648
+ and status in {"ok", "succeeded", "completed", "ready"}
2649
+ )
2650
+
2651
+
2652
+ def _document_diff_changes(result: dict[str, object]) -> list[dict[str, object]]:
2653
+ """Return structured document diff changes from a document result."""
2654
+ diff = result.get("diff")
2655
+ if not isinstance(diff, dict):
2656
+ return []
2657
+ changes = diff.get("changes")
2658
+ if not isinstance(changes, list):
2659
+ return []
2660
+ return [change for change in changes if isinstance(change, dict)]
2661
+
2662
+
2663
+ def _document_result_allowed_claim_text(result: dict[str, object]) -> str:
2664
+ """Build the bounded document-result text a final answer may claim from."""
2665
+ parts: list[str] = []
2666
+ for key in ("tool_id", "status", "text_summary"):
2667
+ value = result.get(key)
2668
+ if value is not None:
2669
+ parts.append(str(value))
2670
+ for change in _document_diff_changes(result):
2671
+ for key in (
2672
+ "change_id",
2673
+ "change_type",
2674
+ "display_label",
2675
+ "target_path",
2676
+ "before_value",
2677
+ "after_value",
2678
+ ):
2679
+ value = change.get(key)
2680
+ if value is not None:
2681
+ parts.append(str(value))
2682
+ return "\n".join(parts)
2683
+
2684
+
2685
+ def _document_diff_only_final_answer(
2686
+ latest_user_utt: str,
2687
+ llm_messages: list[Any],
2688
+ ) -> str | None:
2689
+ """Build a diff-only final answer when the citizen explicitly asks for it."""
2690
+ if not _DOCUMENT_DIFF_ONLY_FINAL_REQUEST_RE.search(latest_user_utt):
2691
+ return None
2692
+ result = _latest_successful_document_result(llm_messages)
2693
+ if result is None:
2694
+ return None
2695
+ changes = _document_diff_changes(result)
2696
+ if not changes:
2697
+ return None
2698
+ lines = ["실제 변경된 내용:"]
2699
+ for change in changes:
2700
+ target_path = str(change.get("display_label") or change.get("target_path") or "document")
2701
+ before = str(change.get("before_value") or "")
2702
+ after = str(change.get("after_value") or "")
2703
+ lines.append(f"- {target_path}: {before} -> {after}")
2704
+ return "\n".join(lines)
2705
+
2706
+
2707
+ def _compact_claim_text(text: str) -> str:
2708
+ """Normalize claim text for marker comparison without losing Korean terms."""
2709
+ return re.sub(r"\s+", "", text).casefold()
2710
+
2711
+
2712
+ def _final_answer_overclaims_document_edit(
2713
+ text: str,
2714
+ llm_messages: list[Any],
2715
+ ) -> bool:
2716
+ """Return True when a document final answer adds content absent from the diff."""
2717
+ if not text.strip():
2718
+ return False
2719
+ result = _latest_successful_document_result(llm_messages)
2720
+ if result is None:
2721
+ return False
2722
+ changes = _document_diff_changes(result)
2723
+ if not changes:
2724
+ return False
2725
+ nonempty_lines = [line.strip() for line in text.splitlines() if line.strip()]
2726
+ max_expected_lines = max(8, len(changes) * 4)
2727
+ if len(nonempty_lines) > max_expected_lines:
2728
+ return True
2729
+ allowed = _compact_claim_text(_document_result_allowed_claim_text(result))
2730
+ answer = _compact_claim_text(text)
2731
+ for change in changes:
2732
+ display_label = str(change.get("display_label") or "").strip()
2733
+ target_path = str(change.get("target_path") or "").strip()
2734
+ if not display_label or not target_path:
2735
+ continue
2736
+ if (
2737
+ _compact_claim_text(target_path) in answer
2738
+ and _compact_claim_text(display_label) not in answer
2739
+ ):
2740
+ return True
2741
+ for marker in _DOCUMENT_FINAL_ANSWER_OVERCLAIM_MARKERS:
2742
+ marker_text = _compact_claim_text(marker)
2743
+ if marker_text in answer and marker_text not in allowed:
2744
+ return True
2745
+ return False
2746
+
2747
+
1860
2748
  def _final_answer_observation_message(
1861
2749
  *,
1862
2750
  message: str,
@@ -1877,6 +2765,16 @@ def _final_answer_observation_message(
1877
2765
  observation_json[:_FINAL_ANSWER_OBSERVATION_JSON_LIMIT] + "...[truncated]"
1878
2766
  )
1879
2767
 
2768
+ document_guidance = ""
2769
+ if observation is not None and observation.get("primitive") == "document":
2770
+ document_guidance = (
2771
+ "\nDocument diff changes are the only approved edit claims. "
2772
+ "For document results, mention only result.status, text_summary, "
2773
+ "and diff.changes display_label/target_path/before_value/after_value. Do not add "
2774
+ "activity contents, achievements, plans, problems, improvements, "
2775
+ "or saved fields that are absent from diff.changes.\n"
2776
+ )
2777
+
1880
2778
  return (
1881
2779
  "[UMMAYA FINAL ANSWER OBSERVATION]\n"
1882
2780
  f"{message}\n\n"
@@ -1884,6 +2782,7 @@ def _final_answer_observation_message(
1884
2782
  f"{latest_user_utt}\n\n"
1885
2783
  "Latest successful primitive tool_result JSON:\n"
1886
2784
  f"{observation_json}\n\n"
2785
+ f"{document_guidance}"
1887
2786
  "Use only the observed tool_result data above and the prior tool_result "
1888
2787
  "messages. Do not call another tool. Do not invent names, addresses, "
1889
2788
  "phone numbers, timestamps, weather values, receipt IDs, or source "
@@ -1950,6 +2849,38 @@ def _region_pair_from_address_text(text: object) -> tuple[str, str] | None:
1950
2849
  return q0, q1
1951
2850
 
1952
2851
 
2852
+ def _sido_name_from_user_query(user_query: str) -> str | None:
2853
+ """Extract a Korean 시도 name when citizen wording contains one."""
2854
+
2855
+ for full_name in _KOREAN_SIDO_ABBREVIATIONS.values():
2856
+ if full_name in user_query:
2857
+ return full_name
2858
+ for short_name, full_name in _KOREAN_SIDO_ABBREVIATIONS.items():
2859
+ pattern = re.compile(
2860
+ rf"{re.escape(short_name)}(?:시|도|특별시|광역시|특별자치시|특별자치도)?"
2861
+ )
2862
+ if pattern.search(user_query):
2863
+ return full_name
2864
+ return None
2865
+
2866
+
2867
+ def _pps_current_week_window(now: datetime | None = None) -> tuple[str, str]:
2868
+ """Return PPS YYYYMMDDHHMM bounds for the current KST week through today."""
2869
+
2870
+ from zoneinfo import ZoneInfo # noqa: PLC0415
2871
+
2872
+ kst = ZoneInfo("Asia/Seoul")
2873
+ kst_now = datetime.now(kst) if now is None else now.astimezone(kst)
2874
+ week_start = (kst_now - timedelta(days=kst_now.weekday())).replace(
2875
+ hour=0,
2876
+ minute=0,
2877
+ second=0,
2878
+ microsecond=0,
2879
+ )
2880
+ today_end = kst_now.replace(hour=23, minute=59, second=0, microsecond=0)
2881
+ return week_start.strftime("%Y%m%d%H%M"), today_end.strftime("%Y%m%d%H%M")
2882
+
2883
+
1953
2884
  def _locate_result_region_pair(result: dict[str, object]) -> tuple[str, str] | None: # noqa: C901
1954
2885
  """Extract NMC region-mode q0/q1 from a locate result."""
1955
2886
  for key in ("region", "coords"):
@@ -2060,6 +2991,13 @@ def _nmc_lookup_params_with_clean_qn(
2060
2991
  return raw_params, params
2061
2992
 
2062
2993
 
2994
+ def _nmc_origin_needs_locate_repair(params: dict[str, object]) -> bool:
2995
+ """Return True when region-mode origin coords lost locate precision."""
2996
+ if params.get("origin_lat") is None and params.get("origin_lon") is None:
2997
+ return True
2998
+ return _is_whole_degree_pair(params.get("origin_lat"), params.get("origin_lon"))
2999
+
3000
+
2063
3001
  def _is_whole_degree_pair(lat: object, lon: object) -> bool:
2064
3002
  """Return True for rounded whole-degree WGS-84 coordinate pairs."""
2065
3003
  if isinstance(lat, bool) or isinstance(lon, bool):
@@ -2084,10 +3022,15 @@ def _normalize_reverse_geocode_args_from_prior_locate(
2084
3022
  was already available in the prior locate result, keep the selected adapter
2085
3023
  and repair only this derived argument pair.
2086
3024
  """
2087
- if fname != "locate" or args_obj.get("tool_id") not in _REVERSE_GEOCODE_TOOL_IDS:
3025
+ wraps_root_primitive = False
3026
+ if fname == "locate" and args_obj.get("tool_id") in _REVERSE_GEOCODE_TOOL_IDS:
3027
+ raw_params = args_obj.get("params")
3028
+ wraps_root_primitive = True
3029
+ elif fname in _REVERSE_GEOCODE_TOOL_IDS:
3030
+ raw_params = args_obj
3031
+ else:
2088
3032
  return args_obj
2089
3033
 
2090
- raw_params = args_obj.get("params")
2091
3034
  if not isinstance(raw_params, dict):
2092
3035
  return args_obj
2093
3036
 
@@ -2106,12 +3049,45 @@ def _normalize_reverse_geocode_args_from_prior_locate(
2106
3049
  if coords is None:
2107
3050
  return args_obj
2108
3051
 
3052
+ next_params = dict(raw_params)
3053
+ next_params["lat"], next_params["lon"] = coords
3054
+ if wraps_root_primitive:
3055
+ normalized = dict(args_obj)
3056
+ normalized["params"] = next_params
3057
+ else:
3058
+ normalized = next_params
3059
+ logger.info(
3060
+ "locate: normalized %s rounded lat/lon from prior locate lat=%s lon=%s",
3061
+ args_obj.get("tool_id") if wraps_root_primitive else fname,
3062
+ coords[0],
3063
+ coords[1],
3064
+ )
3065
+ return normalized
3066
+
3067
+
3068
+ def _normalize_reverse_geocode_args_from_locate_result(
3069
+ args_obj: dict[str, object],
3070
+ locate_result: dict[str, object],
3071
+ ) -> dict[str, object]:
3072
+ """Fill reverse-geocode lat/lon from an already observed locate result."""
3073
+ if args_obj.get("tool_id") not in _REVERSE_GEOCODE_TOOL_IDS:
3074
+ return args_obj
3075
+ raw_params = args_obj.get("params")
3076
+ if not isinstance(raw_params, dict):
3077
+ return args_obj
3078
+ if not _is_whole_degree_pair(raw_params.get("lat"), raw_params.get("lon")):
3079
+ return args_obj
3080
+
3081
+ coords = _locate_result_coords(locate_result)
3082
+ if coords is None:
3083
+ return args_obj
3084
+
2109
3085
  next_params = dict(raw_params)
2110
3086
  next_params["lat"], next_params["lon"] = coords
2111
3087
  normalized = dict(args_obj)
2112
3088
  normalized["params"] = next_params
2113
3089
  logger.info(
2114
- "locate: normalized %s rounded lat/lon from prior locate lat=%s lon=%s",
3090
+ "locate: normalized cached %s rounded lat/lon from latest locate lat=%s lon=%s",
2115
3091
  args_obj.get("tool_id"),
2116
3092
  coords[0],
2117
3093
  coords[1],
@@ -2146,7 +3122,8 @@ def _normalize_nmc_lookup_args_from_prior_locate(
2146
3122
  and bool(_nonempty_str(params.get("q0")))
2147
3123
  and bool(_nonempty_str(params.get("q1")))
2148
3124
  )
2149
- if has_region_params and not needs_default_limit:
3125
+ needs_origin_repair = _nmc_origin_needs_locate_repair(params)
3126
+ if has_region_params and not needs_default_limit and not needs_origin_repair:
2150
3127
  if params != raw_params and isinstance(raw_params, dict):
2151
3128
  normalized = dict(args_obj)
2152
3129
  normalized["params"] = params
@@ -2159,13 +3136,24 @@ def _normalize_nmc_lookup_args_from_prior_locate(
2159
3136
  registry=registry,
2160
3137
  )
2161
3138
  if locate_result is None:
2162
- if has_region_params and needs_default_limit:
3139
+ if has_region_params:
2163
3140
  normalized = dict(args_obj)
2164
3141
  next_params = dict(params)
2165
- next_params["limit"] = 5
2166
- normalized["params"] = next_params
2167
- return normalized
3142
+ if needs_default_limit:
3143
+ next_params["limit"] = 5
3144
+ if next_params != raw_params and isinstance(raw_params, dict):
3145
+ normalized["params"] = next_params
3146
+ return normalized
2168
3147
  return args_obj
3148
+ if _locate_result_coords(locate_result) is None:
3149
+ locate_result_with_coords = _latest_successful_locate_result_with_coords(
3150
+ llm_messages,
3151
+ registry=registry,
3152
+ )
3153
+ if locate_result_with_coords is not None:
3154
+ merged_locate_result = dict(locate_result_with_coords)
3155
+ merged_locate_result.update(locate_result)
3156
+ locate_result = merged_locate_result
2169
3157
 
2170
3158
  return _normalize_nmc_lookup_args_from_locate_result(args_obj, locate_result)
2171
3159
 
@@ -2185,10 +3173,18 @@ def _normalize_nmc_lookup_args_from_locate_result(
2185
3173
  and bool(_nonempty_str(params.get("q0")))
2186
3174
  and bool(_nonempty_str(params.get("q1")))
2187
3175
  )
2188
- if has_region_params and not needs_default_limit:
2189
- if params != raw_params and isinstance(raw_params, dict):
3176
+ needs_origin_repair = _nmc_origin_needs_locate_repair(params)
3177
+ if has_region_params:
3178
+ origin_coords = _locate_result_coords(locate_result)
3179
+ next_params = dict(params)
3180
+ if needs_origin_repair and origin_coords is not None:
3181
+ next_params["origin_lat"] = origin_coords[0]
3182
+ next_params["origin_lon"] = origin_coords[1]
3183
+ if needs_default_limit:
3184
+ next_params["limit"] = 5
3185
+ if next_params != params or (params != raw_params and isinstance(raw_params, dict)):
2190
3186
  normalized = dict(args_obj)
2191
- normalized["params"] = params
3187
+ normalized["params"] = next_params
2192
3188
  return normalized
2193
3189
  return args_obj
2194
3190
 
@@ -2227,6 +3223,27 @@ def _normalize_nmc_lookup_args_from_locate_result(
2227
3223
  return normalized
2228
3224
 
2229
3225
 
3226
+ def _normalize_nmc_aed_args_from_locate_result(
3227
+ args_obj: dict[str, object],
3228
+ locate_result: dict[str, object],
3229
+ ) -> dict[str, object]:
3230
+ """Fill NMC AED origin coords for client-side distance sorting."""
3231
+ raw_params = args_obj.get("params")
3232
+ if not isinstance(raw_params, dict):
3233
+ return args_obj
3234
+ coords = _locate_result_coords(locate_result)
3235
+ if coords is None:
3236
+ return args_obj
3237
+ if not _nmc_origin_needs_locate_repair(raw_params):
3238
+ return args_obj
3239
+ next_params = dict(raw_params)
3240
+ next_params["origin_lat"] = coords[0]
3241
+ next_params["origin_lon"] = coords[1]
3242
+ normalized = dict(args_obj)
3243
+ normalized["params"] = next_params
3244
+ return normalized
3245
+
3246
+
2230
3247
  _HIRA_DEPARTMENT_HINTS: tuple[tuple[re.Pattern[str], str], ...] = (
2231
3248
  (re.compile(r"소아청소년과|소아과|pediatrics?", re.IGNORECASE), "소아청소년과"),
2232
3249
  (re.compile(r"이비인후과|ent\b", re.IGNORECASE), "이비인후과"),
@@ -2384,14 +3401,37 @@ def _normalize_lookup_args_from_cached_locate_result(
2384
3401
  args_obj: dict[str, object],
2385
3402
  locate_result: dict[str, object] | None,
2386
3403
  *,
3404
+ coordinate_locate_result: dict[str, object] | None = None,
2387
3405
  user_query: str = "",
2388
3406
  ) -> dict[str, object]:
2389
3407
  """Apply locate-derived argument repair in inbound concrete tool dispatch."""
2390
- if fname != "find" or locate_result is None:
3408
+ if locate_result is None:
3409
+ return args_obj
3410
+ if fname == "locate":
3411
+ return _normalize_reverse_geocode_args_from_locate_result(args_obj, locate_result)
3412
+ if fname != "find":
2391
3413
  return args_obj
2392
3414
  tool_id = args_obj.get("tool_id")
2393
3415
  if tool_id == "nmc_emergency_search":
3416
+ if (
3417
+ _locate_result_coords(locate_result) is None
3418
+ and coordinate_locate_result is not None
3419
+ and _locate_result_coords(coordinate_locate_result) is not None
3420
+ ):
3421
+ merged_locate_result = dict(coordinate_locate_result)
3422
+ merged_locate_result.update(locate_result)
3423
+ locate_result = merged_locate_result
2394
3424
  return _normalize_nmc_lookup_args_from_locate_result(args_obj, locate_result)
3425
+ if tool_id == "nmc_aed_site_locate":
3426
+ if (
3427
+ _locate_result_coords(locate_result) is None
3428
+ and coordinate_locate_result is not None
3429
+ and _locate_result_coords(coordinate_locate_result) is not None
3430
+ ):
3431
+ merged_locate_result = dict(coordinate_locate_result)
3432
+ merged_locate_result.update(locate_result)
3433
+ locate_result = merged_locate_result
3434
+ return _normalize_nmc_aed_args_from_locate_result(args_obj, locate_result)
2395
3435
  if tool_id == "hira_hospital_search":
2396
3436
  return _normalize_hira_lookup_args_from_locate_result(
2397
3437
  args_obj,
@@ -2588,82 +3628,47 @@ def _submit_requirement_for_query(user_query: str) -> dict[str, str] | None:
2588
3628
  and _query_contains_any(user_query, ("신고", "신고서", "제출"))
2589
3629
  )
2590
3630
  if asks_submit and asks_hometax_tax_return:
2591
- session_id = _extract_session_id(user_query, "HOMETAX-TAXRETURN-SESSION-001")
2592
- params = {
2593
- "tax_year": _extract_tax_year(user_query),
2594
- "income_type": "종합소득",
2595
- "total_income_krw": 42_000_000,
2596
- "session_id": session_id,
2597
- }
2598
3631
  return {
2599
3632
  "tool_id": "mock_submit_module_hometax_taxreturn",
2600
3633
  "verify_tool_id": "mock_verify_module_modid",
2601
3634
  "scope": "send:hometax.tax-return",
2602
3635
  "pre_submit_lookup_tool_id": "mock_lookup_module_hometax_simplified",
2603
- "params_json": _stdlib_json.dumps(params, ensure_ascii=False),
3636
+ "params_json": "{}",
2604
3637
  }
2605
3638
 
2606
3639
  if asks_submit and _query_contains_any(user_query, ("정부24", "주민등록등본", "등본", "민원")):
2607
- session_id = _extract_session_id(user_query, "GOV24-MINWON-SESSION-001")
2608
- params = {
2609
- "minwon_type": "주민등록등본",
2610
- "applicant_name": "홍길동" if "홍길동" in user_query else "MOCK_APPLICANT",
2611
- "delivery_method": "online",
2612
- "session_id": session_id,
2613
- }
2614
3640
  return {
2615
3641
  "tool_id": "mock_submit_module_gov24_minwon",
2616
3642
  "verify_tool_id": "mock_verify_module_simple_auth",
2617
3643
  "scope": "send:gov24.minwon",
2618
- "params_json": _stdlib_json.dumps(params, ensure_ascii=False),
3644
+ "params_json": "{}",
2619
3645
  }
2620
3646
 
2621
3647
  if asks_submit and _query_contains_any(
2622
3648
  user_query,
2623
3649
  ("복지 급여", "복지신청", "한부모가족", "한부모", "아동양육비"),
2624
3650
  ):
2625
- applicant_match = re.search(r"DI-[A-Z0-9-]+", user_query)
2626
- household_match = re.search(r"(\d+)\s*명", user_query)
2627
- params = {
2628
- "applicant_id": applicant_match.group(0)
2629
- if applicant_match
2630
- else "DI-MOCK-WELFARE-APPLICANT",
2631
- "benefit_code": "WLF00001068",
2632
- "application_type": "new",
2633
- "household_size": int(household_match.group(1)) if household_match else 1,
2634
- }
2635
3651
  return {
2636
3652
  "tool_id": "mock_welfare_application_submit_v1",
2637
3653
  "verify_tool_id": "mock_verify_mydata",
2638
3654
  "scope": "send:mydata.welfare_application",
2639
- "params_json": _stdlib_json.dumps(params, ensure_ascii=False),
3655
+ "params_json": "{}",
2640
3656
  }
2641
3657
 
2642
3658
  if asks_submit and _query_contains_any(user_query, ("과태료", "교통범칙금", "범칙금")):
2643
- params = {
2644
- "fine_reference": "MOCK-FINE-2026-001",
2645
- "payment_method": "virtual_account",
2646
- }
2647
3659
  return {
2648
3660
  "tool_id": "mock_traffic_fine_pay_v1",
2649
3661
  "verify_tool_id": "mock_verify_ganpyeon_injeung",
2650
3662
  "scope": "send:traffic.fine-pay",
2651
- "params_json": _stdlib_json.dumps(params, ensure_ascii=False),
3663
+ "params_json": "{}",
2652
3664
  }
2653
3665
 
2654
3666
  if asks_submit and _query_contains_any(user_query, ("마이데이터", "공공마이데이터")):
2655
- session_id = _extract_session_id(user_query, "MYDATA-ACTION-SESSION-001")
2656
- params = {
2657
- "action_type": "transfer_consent",
2658
- "target_institution_code": "PUBLIC-MYDATA-MOCK",
2659
- "applicant_di": "DI-MOCK-MYDATA-001",
2660
- "session_id": session_id,
2661
- }
2662
3667
  return {
2663
3668
  "tool_id": "mock_submit_module_public_mydata_action",
2664
3669
  "verify_tool_id": "mock_verify_mydata",
2665
3670
  "scope": "send:public_mydata.action",
2666
- "params_json": _stdlib_json.dumps(params, ensure_ascii=False),
3671
+ "params_json": "{}",
2667
3672
  }
2668
3673
 
2669
3674
  return None
@@ -2692,17 +3697,18 @@ def _check_submit_terminated_without_submit(
2692
3697
  tool_id=pre_submit_lookup_tool_id,
2693
3698
  ):
2694
3699
  return None
2695
- params_json = requirement["params_json"]
2696
3700
  tool_id = requirement["tool_id"]
3701
+ scope = requirement["scope"]
2697
3702
  return {
2698
3703
  **requirement,
2699
3704
  "message": (
2700
3705
  "Send follow-up missing: the citizen asked to complete a write, "
2701
3706
  "payment, consent, or filing flow and verification has already run, "
2702
3707
  f"but {tool_id!r} has not succeeded. RECOVERY: in the next turn call "
2703
- f"send(tool_id={tool_id!r}, params={params_json}). The backend will "
2704
- "inject the cached DelegationContext. Do NOT ask for additional mock "
2705
- "fields and do NOT end with guidance-only prose."
3708
+ f"send using tool_id {tool_id!r}, the verified {scope!r} delegation "
3709
+ "context, and params that satisfy the registered adapter schema. "
3710
+ "The backend will inject the cached DelegationContext. Do NOT invent "
3711
+ "mock fixture fields and do NOT end with guidance-only prose."
2706
3712
  ),
2707
3713
  }
2708
3714
 
@@ -2795,17 +3801,10 @@ def _canonicalize_submit_tool_id(
2795
3801
  def _apply_submit_canonical_params(
2796
3802
  params: dict[str, object],
2797
3803
  canonical: dict[str, object],
2798
- tool_id: str,
3804
+ _tool_id: str,
2799
3805
  ) -> bool:
2800
- """Apply submit fixture defaults, overwriting Hometax mock guesses."""
2801
3806
  changed = False
2802
- overwrite = tool_id == "mock_submit_module_hometax_taxreturn"
2803
3807
  for key, value in canonical.items():
2804
- if overwrite:
2805
- if params.get(key) != value:
2806
- params[key] = value
2807
- changed = True
2808
- continue
2809
3808
  if key not in params or params.get(key) in (None, ""):
2810
3809
  params[key] = value
2811
3810
  changed = True
@@ -2848,11 +3847,14 @@ def _normalize_submit_args_for_query(
2848
3847
 
2849
3848
  def _strip_hometax_lookup_context_noise(params: dict[str, object]) -> bool:
2850
3849
  """Remove model-invented lookup fields from delegation_context."""
3850
+ changed = False
3851
+ if "query" in params:
3852
+ params.pop("query", None)
3853
+ changed = True
2851
3854
  delegation_context = params.get("delegation_context")
2852
3855
  if not isinstance(delegation_context, dict):
2853
- return False
3856
+ return changed
2854
3857
  cleaned = dict(delegation_context)
2855
- changed = False
2856
3858
  for key in ("year", "resident_id_prefix"):
2857
3859
  if key in cleaned:
2858
3860
  cleaned.pop(key, None)
@@ -2898,6 +3900,58 @@ def _normalize_hometax_lookup_args_for_query(
2898
3900
  return normalized
2899
3901
 
2900
3902
 
3903
+ def _gov24_certificate_type_from_query(user_query: str) -> str | None:
3904
+ if _query_contains_any(user_query, ("가족관계증명서", "가족 관계")):
3905
+ return "family_relations"
3906
+ if _query_contains_any(user_query, ("사업자등록증", "사업자 등록")):
3907
+ return "business_registration"
3908
+ if _query_contains_any(user_query, ("주민등록등본", "등본")):
3909
+ return "resident_registration"
3910
+ return None
3911
+
3912
+
3913
+ def _gov24_certificate_purpose_from_query(user_query: str, certificate_type: str) -> str:
3914
+ if certificate_type == "resident_registration" and _query_contains_any(
3915
+ user_query,
3916
+ ("가능 여부", "준비물", "확인", "알려", "방법"),
3917
+ ):
3918
+ return "주민등록등본 발급 가능 여부와 준비물 확인"
3919
+ if certificate_type == "family_relations":
3920
+ return "가족관계증명서 발급 정보 확인"
3921
+ if certificate_type == "business_registration":
3922
+ return "사업자등록증 발급 정보 확인"
3923
+ return "정부24 증명서 발급 정보 확인"
3924
+
3925
+
3926
+ def _normalize_gov24_certificate_lookup_args_for_query(
3927
+ args_obj: dict[str, object],
3928
+ user_query: str,
3929
+ ) -> dict[str, object]:
3930
+ if args_obj.get("tool_id") != "mock_lookup_module_gov24_certificate":
3931
+ return args_obj
3932
+ if not _query_contains_any(
3933
+ user_query, ("정부24", "주민등록등본", "등본", "가족관계증명서", "사업자등록증")
3934
+ ):
3935
+ return args_obj
3936
+ certificate_type = _gov24_certificate_type_from_query(user_query)
3937
+ if certificate_type is None:
3938
+ return args_obj
3939
+ raw_params = args_obj.get("params")
3940
+ params = dict(raw_params) if isinstance(raw_params, dict) else {}
3941
+ changed = not isinstance(raw_params, dict)
3942
+ if params.get("certificate_type") in (None, ""):
3943
+ params["certificate_type"] = certificate_type
3944
+ changed = True
3945
+ if params.get("purpose") in (None, ""):
3946
+ params["purpose"] = _gov24_certificate_purpose_from_query(user_query, certificate_type)
3947
+ changed = True
3948
+ if not changed:
3949
+ return args_obj
3950
+ normalized = dict(args_obj)
3951
+ normalized["params"] = params
3952
+ return normalized
3953
+
3954
+
2901
3955
  def _canonicalize_lookup_tool_id_for_query(
2902
3956
  args_obj: dict[str, object],
2903
3957
  user_query: str,
@@ -2908,7 +3962,15 @@ def _canonicalize_lookup_tool_id_for_query(
2908
3962
  return args_obj
2909
3963
  sensitive_lookup = _sensitive_lookup_requirement_for_query(user_query)
2910
3964
  if sensitive_lookup is None:
2911
- return args_obj
3965
+ if not _query_contains_any(
3966
+ user_query,
3967
+ ("홈택스", "연말정산", "간소화", "종합소득세", "소득세 신고", "세금 신고"),
3968
+ ):
3969
+ return args_obj
3970
+ sensitive_lookup = {
3971
+ **_SENSITIVE_LOOKUP_AUTH_REQUIREMENTS["mock_lookup_module_hometax_simplified"],
3972
+ "tool_id": "mock_lookup_module_hometax_simplified",
3973
+ }
2912
3974
  normalized = dict(args_obj)
2913
3975
  normalized["tool_id"] = sensitive_lookup["tool_id"]
2914
3976
  logger.info(
@@ -2919,6 +3981,62 @@ def _canonicalize_lookup_tool_id_for_query(
2919
3981
  return normalized
2920
3982
 
2921
3983
 
3984
+ def _set_param_if_empty(
3985
+ params: dict[str, object],
3986
+ key: str,
3987
+ value: object,
3988
+ ) -> bool:
3989
+ if params.get(key) in (None, ""):
3990
+ params[key] = value
3991
+ return True
3992
+ return False
3993
+
3994
+
3995
+ def _set_param_if_changed(
3996
+ params: dict[str, object],
3997
+ key: str,
3998
+ value: object,
3999
+ ) -> bool:
4000
+ if params.get(key) != value:
4001
+ params[key] = value
4002
+ return True
4003
+ return False
4004
+
4005
+
4006
+ def _normalize_pps_bid_args_from_user_query(
4007
+ fname: str,
4008
+ args_obj: dict[str, object],
4009
+ user_query: str,
4010
+ ) -> dict[str, object]:
4011
+ """Fill PPS search-condition fields that are explicit in citizen wording."""
4012
+
4013
+ if fname != "find" or args_obj.get("tool_id") != _PPS_BID_TOOL_ID:
4014
+ return args_obj
4015
+ raw_params = args_obj.get("params")
4016
+ params: dict[str, object] = dict(raw_params) if isinstance(raw_params, dict) else {}
4017
+ changed = not isinstance(raw_params, dict)
4018
+
4019
+ if re.search(r"이번\s*주", user_query):
4020
+ start_dt, end_dt = _pps_current_week_window()
4021
+ changed = _set_param_if_changed(params, "inqry_bgn_dt", start_dt) or changed
4022
+ changed = _set_param_if_changed(params, "inqry_end_dt", end_dt) or changed
4023
+
4024
+ if re.search(r"전기\s*공사", user_query, re.IGNORECASE):
4025
+ changed = _set_param_if_empty(params, "bid_ntce_nm", "전기공사") or changed
4026
+ changed = _set_param_if_empty(params, "indstryty_nm", "전기공사업") or changed
4027
+
4028
+ region_name = _sido_name_from_user_query(user_query)
4029
+ if region_name is not None:
4030
+ changed = _set_param_if_empty(params, "region_name", region_name) or changed
4031
+ changed = _set_param_if_empty(params, "prtcpt_lmt_rgn_nm", region_name) or changed
4032
+
4033
+ if not changed:
4034
+ return args_obj
4035
+ normalized = dict(args_obj)
4036
+ normalized["params"] = params
4037
+ return normalized
4038
+
4039
+
2922
4040
  def _normalize_lookup_args_for_query(
2923
4041
  fname: str,
2924
4042
  args_obj: dict[str, object],
@@ -2931,11 +4049,13 @@ def _normalize_lookup_args_for_query(
2931
4049
  return args_obj
2932
4050
  args_obj = _canonicalize_lookup_tool_id_for_query(args_obj, user_query)
2933
4051
  args_obj = _normalize_hometax_lookup_args_for_query(args_obj, user_query)
4052
+ args_obj = _normalize_gov24_certificate_lookup_args_for_query(args_obj, user_query)
2934
4053
  args_obj = _normalize_lookup_result_count_args(
2935
4054
  args_obj,
2936
4055
  user_query,
2937
4056
  adapter_param_names=adapter_param_names,
2938
4057
  )
4058
+ args_obj = _normalize_pps_bid_args_from_user_query(fname, args_obj, user_query)
2939
4059
  if args_obj.get("tool_id") != "mohw_welfare_eligibility_search":
2940
4060
  return args_obj
2941
4061
  if not _query_contains_any(user_query, ("한부모가족", "한부모", "아동양육비")):
@@ -2965,6 +4085,21 @@ def _normalize_lookup_args_for_query(
2965
4085
  return normalized
2966
4086
 
2967
4087
 
4088
+ def _lookup_context_from_args(args_obj: dict[str, object]) -> str:
4089
+ chunks: list[str] = []
4090
+ for key in ("query", "request", "instruction", "purpose_ko"):
4091
+ value = args_obj.get(key)
4092
+ if isinstance(value, str) and value.strip():
4093
+ chunks.append(value.strip())
4094
+ raw_params = args_obj.get("params")
4095
+ if isinstance(raw_params, dict):
4096
+ for key in ("query", "request", "instruction", "purpose_ko"):
4097
+ value = raw_params.get(key)
4098
+ if isinstance(value, str) and value.strip():
4099
+ chunks.append(value.strip())
4100
+ return " ".join(chunks)
4101
+
4102
+
2968
4103
  _KOREAN_COUNT_WORDS: Final[dict[str, int]] = {
2969
4104
  "한": 1,
2970
4105
  "두": 2,
@@ -3159,9 +4294,13 @@ def _check_verify_tool_choice_prerequisite(
3159
4294
  purpose_ko = requirement["purpose_ko"]
3160
4295
  purpose_en = requirement["purpose_en"]
3161
4296
  if fname != "check":
4297
+ from ummaya.tools.verify_canonical_map import resolve_tool_id # noqa: PLC0415
4298
+
4299
+ canonical_verify_alias = resolve_tool_id(tool_id)
3162
4300
  wrong_verify_tool = (
3163
4301
  tool_id == "check"
3164
4302
  or tool_id.startswith("mock_verify_")
4303
+ or canonical_verify_alias is not None
3165
4304
  or tool_id in allowed_tool_ids
3166
4305
  or _verify_tool_matches_requirement(
3167
4306
  args_obj,
@@ -3861,6 +5000,155 @@ def _check_chain_prerequisite( # noqa: C901
3861
5000
  )
3862
5001
 
3863
5002
 
5003
+ def _check_kma_analysis_tool_choice_prerequisite(
5004
+ fname: str,
5005
+ args_obj: dict[str, object],
5006
+ user_query: str,
5007
+ ) -> str | None:
5008
+ """Reject cross-contaminated KMA analysis tool choices for map/chart wording."""
5009
+ if not _KMA_ANALYSIS_MAP_USER_QUERY_RE.search(user_query):
5010
+ return None
5011
+ tool_id = fname if fname in _KMA_ANALYSIS_TOOL_IDS else args_obj.get("tool_id")
5012
+ if not isinstance(tool_id, str):
5013
+ params = args_obj.get("params")
5014
+ if isinstance(params, dict):
5015
+ nested_tool_id = params.get("tool_id")
5016
+ tool_id = nested_tool_id if isinstance(nested_tool_id, str) else None
5017
+ if tool_id not in _KMA_ANALYSIS_TOOL_IDS:
5018
+ return None
5019
+ if tool_id == "kma_apihub_url_analysis_weather_chart_image":
5020
+ return None
5021
+ return (
5022
+ "KMA analysis tool-choice mismatch: the latest citizen request asks for "
5023
+ "analyzed weather charts/map evidence such as 일기도, 지도 자료, 비구름, "
5024
+ "or 바람 흐름. Do not carry over a prior point/grid-analysis path. "
5025
+ "RECOVERY: call find with tool_id "
5026
+ "kma_apihub_url_analysis_weather_chart_image for the latest query. If "
5027
+ "APIHub returns 403 approval-required or another upstream error, report "
5028
+ "that failure directly and do not substitute point-grid data or prior "
5029
+ "airport observations."
5030
+ )
5031
+
5032
+
5033
+ def _emitted_tool_id(fname: str, args_obj: dict[str, object]) -> str | None:
5034
+ """Return the concrete adapter id represented by a root or direct tool call."""
5035
+ tool_id = fname if fname not in _ROOT_PRIMITIVE_TOOL_NAMES else args_obj.get("tool_id")
5036
+ if isinstance(tool_id, str) and tool_id:
5037
+ return tool_id
5038
+ params = args_obj.get("params")
5039
+ if isinstance(params, dict):
5040
+ nested_tool_id = params.get("tool_id")
5041
+ if isinstance(nested_tool_id, str) and nested_tool_id:
5042
+ return nested_tool_id
5043
+ return None
5044
+
5045
+
5046
+ def _direct_public_data_target_for_query(
5047
+ user_query: str,
5048
+ ) -> tuple[frozenset[str], str, str, str] | None:
5049
+ """Return target adapter family for public-data wording that should not use substitutes."""
5050
+ if _KMA_ANALYSIS_MAP_USER_QUERY_RE.search(user_query):
5051
+ return (
5052
+ frozenset({_KMA_ANALYSIS_CHART_TOOL_ID}),
5053
+ _KMA_ANALYSIS_CHART_TOOL_ID,
5054
+ "weather_chart",
5055
+ "use official KMA APIHub analyzed weather-chart evidence and do not "
5056
+ "substitute location, AirKorea, or ordinary weather evidence.",
5057
+ )
5058
+ if _PPS_BID_USER_QUERY_RE.search(user_query):
5059
+ return (
5060
+ frozenset({_PPS_BID_TOOL_ID}),
5061
+ _PPS_BID_TOOL_ID,
5062
+ "procurement_bid",
5063
+ "use PPS/NaraJangteo bid notice date fields.",
5064
+ )
5065
+ if _AIRKOREA_USER_QUERY_RE.search(user_query):
5066
+ return (
5067
+ frozenset({_AIRKOREA_TOOL_ID}),
5068
+ _AIRKOREA_TOOL_ID,
5069
+ "air_quality",
5070
+ "use AirKorea city/province air-quality evidence with sido_name such as '부산'.",
5071
+ )
5072
+ if _TAGO_BUS_USER_QUERY_RE.search(user_query):
5073
+ preferred = (
5074
+ "tago_bus_route_search"
5075
+ if _TAGO_ROUTE_NO_RE.search(user_query)
5076
+ else "tago_bus_station_search"
5077
+ )
5078
+ return (
5079
+ _TAGO_TOOL_IDS,
5080
+ preferred,
5081
+ "bus_realtime",
5082
+ "use TAGO bus evidence; for a route number, start with route search, "
5083
+ "then route-station and arrival evidence.",
5084
+ )
5085
+ if _query_implies_current_weather_observation(user_query):
5086
+ return (
5087
+ _KMA_ORDINARY_WEATHER_TOOL_IDS | _KMA_LOCATION_TOOL_IDS,
5088
+ "kakao_keyword_search",
5089
+ "current_weather",
5090
+ "use location resolution first when coordinates are missing, then "
5091
+ "KMA current observation evidence for rain/umbrella/current-weather values.",
5092
+ )
5093
+ return None
5094
+
5095
+
5096
+ def _check_direct_public_data_tool_choice_prerequisite(
5097
+ fname: str,
5098
+ args_obj: dict[str, object],
5099
+ user_query: str,
5100
+ ) -> tuple[str, str] | None:
5101
+ """Reject concrete public-data adapters that do not match the latest citizen request."""
5102
+ target = _direct_public_data_target_for_query(user_query)
5103
+ if target is None:
5104
+ return None
5105
+ allowed_tool_ids, preferred_tool_id, route_label, hint = target
5106
+ emitted_tool_id = _emitted_tool_id(fname, args_obj)
5107
+ if emitted_tool_id is None or emitted_tool_id in allowed_tool_ids:
5108
+ return None
5109
+ return (
5110
+ preferred_tool_id,
5111
+ "Public-data tool-choice mismatch: "
5112
+ f"target={route_label}. The latest citizen request needs that route; "
5113
+ f"the previous tool choice does not match. RECOVERY: {hint}",
5114
+ )
5115
+
5116
+
5117
+ def _check_kma_aviation_tool_choice_prerequisite(
5118
+ fname: str,
5119
+ args_obj: dict[str, object],
5120
+ user_query: str,
5121
+ ) -> str | None:
5122
+ """Reject ordinary weather/location tools for airport aviation wording."""
5123
+ if not (
5124
+ _KMA_AIRPORT_PLACE_RE.search(user_query) and _KMA_AIRPORT_AVIATION_RE.search(user_query)
5125
+ ):
5126
+ return None
5127
+ tool_id = _emitted_tool_id(fname, args_obj)
5128
+ if tool_id in _KMA_AIR_TOOL_IDS:
5129
+ return None
5130
+ if tool_id is None:
5131
+ return None
5132
+ return (
5133
+ "KMA aviation tool-choice mismatch: the latest citizen request asks for "
5134
+ "airport METAR/AMOS aviation evidence such as flight operation, wind, "
5135
+ "runway, RVR, or visibility. RECOVERY: call find with tool_id "
5136
+ "kma_apihub_url_air_metar_decoded for airport METAR/시정/풍향/풍속 "
5137
+ "evidence, or kma_apihub_url_air_amos_minute for documented AMOS "
5138
+ "runway-minute evidence. Do not call locate or ordinary KMA current "
5139
+ "observation before the aviation adapter."
5140
+ )
5141
+
5142
+
5143
+ def _preferred_kma_aviation_tool_id(user_query: str) -> str:
5144
+ """Return the aviation adapter that best matches the airport wording."""
5145
+ if re.search(r"(김포|gimpo|rkss)", user_query, re.IGNORECASE) and re.search(
5146
+ r"(amos|활주로|rvr|runway|매분)", user_query, re.IGNORECASE
5147
+ ):
5148
+ return "kma_apihub_url_air_amos_minute"
5149
+ return "kma_apihub_url_air_metar_decoded"
5150
+
5151
+
3864
5152
  _CURRENT_WEATHER_KEYWORDS_KO: frozenset[str] = frozenset(
3865
5153
  {"날씨", "기온", "온도", "습도", "강수", "바람", "풍속"}
3866
5154
  )
@@ -3918,6 +5206,17 @@ _AVAILABLE_ADAPTER_FIND_LINE_RE: Final = re.compile(
3918
5206
  r"^\s*-\s+[A-Za-z0-9_.:-]+\s+\(primitive=find\)",
3919
5207
  re.MULTILINE,
3920
5208
  )
5209
+ _AVAILABLE_ADAPTER_TOOL_ID_LINE_RE: Final = re.compile(r"^\s*-\s*tool_id:\s*[A-Za-z0-9_.:-]+\s*$")
5210
+ _MEDICAL_COLLAPSE_RE: Final = re.compile(
5211
+ r"(사람[이가은는 ]*쓰러|쓰러졌|쓰러져|의식[을 ]*(?:잃|없)|심정지|"
5212
+ r"숨[을 ]*(?:안|못)|호흡[이가은는 ]*없|자동심장|심장충격|제세동|"
5213
+ r"\bAED\b|collapsed|unconscious|cardiac arrest|not breathing)",
5214
+ re.IGNORECASE,
5215
+ )
5216
+ _CIVIL_SAFETY_CALL_BOX_RE: Final = re.compile(
5217
+ r"(비상벨|안심벨|비상\s*호출|긴급\s*호출|emergency\s*bell|call\s*box)",
5218
+ re.IGNORECASE,
5219
+ )
3921
5220
 
3922
5221
 
3923
5222
  def _latest_available_adapters_block(llm_messages: list[Any]) -> str:
@@ -3942,7 +5241,40 @@ def _latest_available_adapters_block(llm_messages: list[Any]) -> str:
3942
5241
 
3943
5242
  def _available_adapters_block_has_find_candidate(block: str) -> bool:
3944
5243
  """Return True when retrieval surfaced a non-locate follow-up adapter."""
3945
- return bool(block and _AVAILABLE_ADAPTER_FIND_LINE_RE.search(block))
5244
+ if not block:
5245
+ return False
5246
+ if _AVAILABLE_ADAPTER_FIND_LINE_RE.search(block):
5247
+ return True
5248
+ in_projected_candidate = False
5249
+ for line in block.splitlines():
5250
+ stripped = line.strip()
5251
+ if _AVAILABLE_ADAPTER_TOOL_ID_LINE_RE.match(line):
5252
+ in_projected_candidate = True
5253
+ continue
5254
+ if stripped.startswith("- "):
5255
+ in_projected_candidate = False
5256
+ if in_projected_candidate and stripped == "primitive: find":
5257
+ return True
5258
+ return False
5259
+
5260
+
5261
+ def _available_adapters_block_has_tool_id(block: str, tool_id: str) -> bool:
5262
+ """Return True when the latest dynamic adapter block surfaced tool_id."""
5263
+ if not block or not tool_id:
5264
+ return False
5265
+ escaped = re.escape(tool_id)
5266
+ line_re = re.compile(rf"^\s*-\s*{escaped}(?:\s|\(|:)", re.MULTILINE)
5267
+ yaml_re = re.compile(rf"^\s*tool_id:\s*{escaped}\s*$", re.MULTILINE)
5268
+ return bool(line_re.search(block) or yaml_re.search(block) or f"tool_id: {tool_id}" in block)
5269
+
5270
+
5271
+ def _query_implies_medical_collapse_aed(user_query: str) -> bool:
5272
+ """Return True for medical collapse/cardiac-arrest wording that needs AED data."""
5273
+ if not user_query:
5274
+ return False
5275
+ if _CIVIL_SAFETY_CALL_BOX_RE.search(user_query):
5276
+ return False
5277
+ return _MEDICAL_COLLAPSE_RE.search(user_query) is not None
3946
5278
 
3947
5279
 
3948
5280
  def _query_implies_current_weather_observation(user_query: str) -> bool:
@@ -4008,6 +5340,47 @@ def _check_current_weather_terminated_without_observation(
4008
5340
  )
4009
5341
 
4010
5342
 
5343
+ def _check_medical_emergency_terminated_without_aed(
5344
+ llm_messages: list[Any],
5345
+ user_query: str,
5346
+ registry: Any = None,
5347
+ ) -> str | None:
5348
+ """Require AED search before final prose for collapse/cardiac-arrest wording."""
5349
+ if not _query_implies_medical_collapse_aed(user_query):
5350
+ return None
5351
+ available_adapters_block = _latest_available_adapters_block(llm_messages)
5352
+ if not _available_adapters_block_has_tool_id(
5353
+ available_adapters_block,
5354
+ "nmc_aed_site_locate",
5355
+ ):
5356
+ return None
5357
+ if _conversation_has_primitive_call(
5358
+ llm_messages,
5359
+ primitive="find",
5360
+ tool_id="nmc_aed_site_locate",
5361
+ ):
5362
+ return None
5363
+ if not _conversation_has_successful_primitive(
5364
+ llm_messages,
5365
+ primitive="find",
5366
+ tool_id="nmc_emergency_search",
5367
+ ):
5368
+ return None
5369
+ return (
5370
+ "Medical emergency chain incomplete: the citizen described a collapse, "
5371
+ "unconsciousness, cardiac arrest, or AED-relevant situation. The "
5372
+ "conversation already found emergency-room data but is about to answer "
5373
+ "without attempting the AED adapter that was surfaced in "
5374
+ "<available_adapters>. RECOVERY: call "
5375
+ "nmc_aed_site_locate({q0:<region from locate or NMC context>, "
5376
+ "q1:<district when available>, limit:5}) or the equivalent schema-valid "
5377
+ "parameters before final prose. If the AED adapter returns no data or an "
5378
+ "upstream error, report that result explicitly alongside 119/emergency-room "
5379
+ "guidance. Do NOT substitute emergency-room data for AED data. Do NOT "
5380
+ "produce a final answer this turn."
5381
+ )
5382
+
5383
+
4011
5384
  def _weather_value_tokens(value: object) -> set[str]:
4012
5385
  """Return compact numeric strings a final weather answer may cite."""
4013
5386
  if isinstance(value, bool):
@@ -4401,8 +5774,8 @@ async def run( # noqa: C901
4401
5774
 
4402
5775
  # ---- spec-multi-turn-contamination diagnostic — optional log file
4403
5776
  # The TUI bridge spawns this process with `stderr: 'pipe'` and never
4404
- # drains the pipe, so `logger.info(...)` lines are invisible to any
4405
- # external observer (tmux pane, asciinema cast). When the operator
5777
+ # drains the pipe, so `logger.info(...)` lines are invisible to the
5778
+ # normal terminal transcript. When the operator
4406
5779
  # sets UMMAYA_BACKEND_LOG_FILE=<path>, attach a FileHandler at INFO
4407
5780
  # so the diagnostic [CHAT_REQUEST_DUMP] / [LATEST_USER_UTT] /
4408
5781
  # [REASONING_PREVIEW] lines persist to disk for post-hoc analysis.
@@ -4423,6 +5796,7 @@ async def run( # noqa: C901
4423
5796
  _fh.setFormatter(
4424
5797
  logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s")
4425
5798
  )
5799
+ _fh.addFilter(_BackendSecretRedactionFilter())
4426
5800
  _root.addHandler(_fh)
4427
5801
  _root.setLevel(min(_root.level or logging.INFO, logging.INFO))
4428
5802
  logger.info(
@@ -4555,6 +5929,8 @@ async def run( # noqa: C901
4555
5929
  _session_auth_contexts: dict[str, object] = {}
4556
5930
  _session_auth_session_ids: dict[str, str] = {}
4557
5931
  _session_latest_locate_results: dict[str, dict[str, object]] = {}
5932
+ _session_latest_locate_results_with_coords: dict[str, dict[str, object]] = {}
5933
+ _session_latest_user_utterances: dict[str, str] = {}
4558
5934
 
4559
5935
  # Epic #2077 T010 — single ToolRegistry + ToolExecutor instance pair
4560
5936
  # reused across every chat_request. Adapter registration happens lazily
@@ -4634,6 +6010,19 @@ async def run( # noqa: C901
4634
6010
  _ensure_tool_registry() # populates both refs in one shot
4635
6011
  return _tool_executor_ref[0]
4636
6012
 
6013
+ def _is_local_document_harness_root_call(
6014
+ fname: str,
6015
+ args_obj: dict[str, object],
6016
+ ) -> bool:
6017
+ tool_id = str(args_obj.get("tool_id") or "")
6018
+ if not tool_id:
6019
+ return False
6020
+ try:
6021
+ tool = _ensure_tool_registry().find(tool_id)
6022
+ except Exception:
6023
+ return False
6024
+ return tool.primitive == fname and _is_local_document_harness_tool(tool)
6025
+
4637
6026
  async def _ensure_llm_client() -> object:
4638
6027
  if not _llm_client_ref:
4639
6028
  from ummaya.llm.client import LLMClient # noqa: PLC0415
@@ -4798,54 +6187,50 @@ async def run( # noqa: C901
4798
6187
  )
4799
6188
  _root_primitive_tool_ids = _ROOT_PRIMITIVE_TOOL_IDS
4800
6189
 
4801
- def _select_concrete_adapter_tools_for_turn(user_query: str) -> list[Any]:
4802
- """Return concrete, non-core adapter tools for this citizen turn.
4803
-
4804
- CC exposes concrete Tool objects to the model; UMMAYA keeps the same
4805
- model-facing shape and uses BM25/dense retrieval only as a loading
4806
- optimization so the tool list stays small.
4807
- """
6190
+ def _route_decision_for_turn(user_query: str) -> RouteDecision | None:
4808
6191
  q = (user_query or "").strip()
4809
6192
  if not q:
4810
- return []
6193
+ return None
4811
6194
  registry = _ensure_tool_registry()
4812
- selected: dict[str, Any] = {}
4813
- for tool in registry.all_tools():
4814
- if tool.id in _root_primitive_tool_ids:
4815
- continue
4816
- if tool.id in q:
4817
- selected[tool.id] = tool
4818
6195
  try:
4819
- from ummaya.tools.search import search # noqa: PLC0415
6196
+ from ummaya.tools.routing import RouteDecisionService # noqa: PLC0415
4820
6197
 
4821
6198
  raw_top_k = max(_AVAILABLE_ADAPTERS_TOP_K * 3, _AVAILABLE_ADAPTERS_TOP_K)
4822
- candidates = search(
4823
- query=q,
4824
- bm25_index=registry.bm25_index,
4825
- registry=registry,
6199
+ return RouteDecisionService(registry).select_adapters(
6200
+ q,
4826
6201
  top_k=min(raw_top_k, 20),
6202
+ max_selected=_AVAILABLE_ADAPTERS_TOP_K,
4827
6203
  )
4828
6204
  except Exception:
4829
- logger.exception("adapter tool retrieval failed for '%s'", q[:80])
4830
- candidates = []
4831
- for candidate in candidates:
4832
- try:
4833
- tool = registry.find(candidate.tool_id)
4834
- except Exception:
4835
- logger.debug(
4836
- "Skipping unavailable adapter candidate %s",
4837
- candidate.tool_id,
4838
- exc_info=True,
4839
- )
4840
- continue
4841
- if tool.id in _root_primitive_tool_ids:
4842
- continue
4843
- selected.setdefault(tool.id, tool)
4844
- if len(selected) >= _AVAILABLE_ADAPTERS_TOP_K:
4845
- break
4846
- return list(selected.values())[:_AVAILABLE_ADAPTERS_TOP_K]
6205
+ logger.exception("route decision failed for '%s'", q[:80])
6206
+ return None
6207
+
6208
+ def _select_concrete_adapter_tools_for_turn(
6209
+ user_query: str, route_decision: RouteDecision | None = None
6210
+ ) -> list[Any]:
6211
+ q = (user_query or "").strip()
6212
+ if not q:
6213
+ return []
6214
+ registry = _ensure_tool_registry()
6215
+ decision = route_decision or _route_decision_for_turn(q)
6216
+ if decision is None:
6217
+ return []
6218
+ from ummaya.tools.routing import selected_concrete_adapter_tools # noqa: PLC0415
6219
+
6220
+ return list(
6221
+ selected_concrete_adapter_tools(
6222
+ decision,
6223
+ registry,
6224
+ exclude_tool_ids=_root_primitive_tool_ids,
6225
+ max_tools=_AVAILABLE_ADAPTERS_TOP_K,
6226
+ )
6227
+ )
4847
6228
 
4848
- def _build_available_adapters_suffix(user_query: str) -> str: # noqa: C901
6229
+ def _build_available_adapters_suffix(
6230
+ user_query: str,
6231
+ route_decision: RouteDecision | None = None,
6232
+ visible_tool_ids: Iterable[str] | None = None,
6233
+ ) -> str: # noqa: C901
4849
6234
  """Run BM25 against the live registry and emit the citizen-turn
4850
6235
  ``<available_adapters>`` XML block for the dynamic system-prompt
4851
6236
  suffix.
@@ -4859,212 +6244,32 @@ async def run( # noqa: C901
4859
6244
  q = (user_query or "").strip()
4860
6245
  if not q:
4861
6246
  return ""
6247
+ route_decision = route_decision or _route_decision_for_turn(q)
6248
+ if route_decision is None:
6249
+ return ""
4862
6250
  try:
4863
- from ummaya.tools.search import search # noqa: PLC0415
6251
+ from ummaya.tools.routing import build_available_adapters_projection # noqa: PLC0415
4864
6252
 
4865
- registry = _ensure_tool_registry()
4866
- raw_top_k = max(_AVAILABLE_ADAPTERS_TOP_K * 3, _AVAILABLE_ADAPTERS_TOP_K)
4867
- candidates = search(
6253
+ visible_tool_ids_tuple = None if visible_tool_ids is None else tuple(visible_tool_ids)
6254
+ projection_level = (
6255
+ route_decision.schema_projection_level
6256
+ if route_decision.selected_tools or not visible_tool_ids_tuple
6257
+ else "summary"
6258
+ )
6259
+ projection = build_available_adapters_projection(
6260
+ route_decision,
6261
+ _ensure_tool_registry(),
4868
6262
  query=q,
4869
- bm25_index=registry.bm25_index,
4870
- registry=registry,
4871
- top_k=min(raw_top_k, 20),
6263
+ projection_level=projection_level,
6264
+ max_visible=_AVAILABLE_ADAPTERS_TOP_K
6265
+ if visible_tool_ids_tuple is None
6266
+ else len(visible_tool_ids_tuple),
6267
+ visible_tool_ids=visible_tool_ids_tuple,
4872
6268
  )
6269
+ return projection.content or ""
4873
6270
  except Exception:
4874
- logger.exception("BM25 retrieval failed for '%s'", q[:80])
4875
- return ""
4876
- filtered_candidates = []
4877
- for candidate in candidates:
4878
- try:
4879
- tool = registry.find(candidate.tool_id)
4880
- except Exception:
4881
- logger.debug(
4882
- "Skipping unavailable adapter candidate %s",
4883
- candidate.tool_id,
4884
- exc_info=True,
4885
- )
4886
- continue
4887
- if tool.id in _root_primitive_tool_ids:
4888
- continue
4889
- filtered_candidates.append(candidate)
4890
- if len(filtered_candidates) >= _AVAILABLE_ADAPTERS_TOP_K:
4891
- break
4892
- candidates = filtered_candidates
4893
- if not candidates:
6271
+ logger.exception("route decision projection failed for '%s'", q[:80])
4894
6272
  return ""
4895
- # Build a compact, LLM-readable block.
4896
- #
4897
- # Spec 2521 (2026-05-02) — emit per-field schema signatures so the
4898
- # LLM can fill ``params`` against each adapter's actual REST shape.
4899
- # The previous suffix only carried ``search_hint`` and assumed the
4900
- # LLM could "infer params from search_hint" — K-EXAONE on FriendliAI
4901
- # consistently invented ``{"location": "...", "date": "..."}`` style
4902
- # payloads which fail every adapter's pydantic validation
4903
- # (``Invalid parameters for tool``). Rendering each field with its
4904
- # type + required flag + truncated description gives K-EXAONE
4905
- # enough signal to call e.g. ``{"lat": 37.5, "lon": 129.0,
4906
- # "base_date": "20260502", "base_time": "0500"}`` correctly.
4907
- lines: list[str] = [
4908
- f'<available_adapters query="{q[:120]}">',
4909
- f"백엔드 BM25 후보 (top {len(candidates)}, 점수 내림차순):",
4910
- "",
4911
- ]
4912
- for c in candidates:
4913
- hint = (c.search_hint or "").strip()
4914
- if len(hint) > 90:
4915
- hint = hint[:87] + "..."
4916
- primitive = c.primitive or "find"
4917
- lines.append(
4918
- f"- {c.tool_id} (primitive={primitive}) [{c.score:.2f}] — {hint or '(설명 없음)'}"
4919
- )
4920
- # Render the adapter's llm_description (usage prose, ORDERING RULE,
4921
- # prerequisites, worked examples) so the LLM sees the complete
4922
- # "먼저 locate 호출" ordering rule.
4923
- # Bug: without this, the per-field description for nx is truncated
4924
- # and K-EXAONE skips locate, producing invalid_params.
4925
- if c.llm_description:
4926
- desc_text = c.llm_description.strip().replace("\n", " ")
4927
- # Emit at most 300 chars — enough for the ORDERING RULE and
4928
- # worked example without blowing the per-turn token budget.
4929
- if len(desc_text) > 300:
4930
- desc_text = desc_text[:297] + "..."
4931
- lines.append(f" 설명: {desc_text}")
4932
- # Render input schema signature so the LLM sees exact field
4933
- # names + types + required flags + (truncated) descriptions.
4934
- # Field desc limit raised 80→120 so nx/ny examples fit untruncated.
4935
- schema = c.input_schema_json or {}
4936
- properties = schema.get("properties") if isinstance(schema, dict) else None
4937
- required: set[str] = set()
4938
- raw_required = schema.get("required") if isinstance(schema, dict) else None
4939
- if isinstance(raw_required, list):
4940
- required = {str(item) for item in raw_required if isinstance(item, str)}
4941
- # Spec 2522 T010 — ORDERING directive removed.
4942
- # The Spec 2521 ORDERING block ("nx/ny 는 KMA 격자 좌표 — 반드시
4943
- # locate 을 먼저 호출") forced a cross-domain chain that
4944
- # contradicts both the user directive ("chain X / UMMAYA does not
4945
- # force cross-domain chain") and v4 description 5-section
4946
- # self_contained_decl ("이 도구 단독 호출로 완결. locate 등
4947
- # cross-domain chain 불필요"). With both signals present K-EXAONE
4948
- # ignored both and hallucinated nx/ny → Spec 2521 regression.
4949
- # Each adapter's description (섹션 4 domain_quirk + 섹션 5
4950
- # self_contained_decl + 섹션 3 short_reference 17 광역시도 표) is now
4951
- # self-sufficient. The model decides chain vs single-tool autonomously.
4952
- # Reference: research-stdio-ordering.md, frames-busan-weather/ T042 evidence.
4953
- # Spec 2522 T047 fix — resolve $ref to $defs and inline enum values.
4954
- # KOROAD KoroadAccidentSearchInput.search_year_cd uses
4955
- # `$ref: #/$defs/SearchYearCd` (20 values). The previous renderer
4956
- # only inlined `properties.<f>.enum` and gave up on $ref, leaving
4957
- # K-EXAONE to guess plain '2024' (invalid). Spec 2522 frames-gangnam-
4958
- # accident-fix2 evidence: invalid_params persisted after T042 fix.
4959
- # Fix: resolve $ref against schema['$defs'] + raise threshold 8→25.
4960
- defs_raw = schema.get("$defs") if isinstance(schema, dict) else None
4961
- defs: dict[str, Any] | None = defs_raw if isinstance(defs_raw, dict) else None
4962
-
4963
- def _resolve_enum(
4964
- meta: dict[str, Any], defs: dict[str, Any] | None
4965
- ) -> list[Any] | None:
4966
- # direct enum
4967
- e = meta.get("enum")
4968
- if isinstance(e, list):
4969
- return e
4970
- # $ref → $defs/<name>
4971
- ref = meta.get("$ref")
4972
- if isinstance(ref, str) and ref.startswith("#/$defs/") and isinstance(defs, dict):
4973
- name = ref.removeprefix("#/$defs/")
4974
- target = defs.get(name)
4975
- if isinstance(target, dict):
4976
- target_enum = target.get("enum")
4977
- if isinstance(target_enum, list):
4978
- return target_enum
4979
- return None
4980
-
4981
- def _resolve_enum_with_names(
4982
- meta: dict[str, Any], defs: dict[str, Any] | None
4983
- ) -> list[tuple[Any, str]] | None:
4984
- """Spec 2522 — agency 자체 코드체계 (KOROAD GugunCode SEOUL_GANGNAM=680
4985
- 등) 의 IntEnum name 을 의미 매핑으로 노출. pydantic JSON schema 의
4986
- $defs 안 IntEnum 의 'enum' (값) + 'x-enum-varnames' (name) 또는
4987
- 'description' (docstring) 을 묶어서 LLM 에 보여줌.
4988
- """
4989
- ref = meta.get("$ref")
4990
- if not (isinstance(ref, str) and ref.startswith("#/$defs/")):
4991
- return None
4992
- if not isinstance(defs, dict):
4993
- return None
4994
- name = ref.removeprefix("#/$defs/")
4995
- target = defs.get(name)
4996
- if not isinstance(target, dict):
4997
- return None
4998
- values = target.get("enum")
4999
- if not isinstance(values, list):
5000
- return None
5001
- # IntEnum name 추출 — pydantic v2 가 'x-enum-varnames' 또는
5002
- # 'enumNames' 로 export 하지 않음. 대신 module-level dict 조회.
5003
- varnames = target.get("x-enum-varnames")
5004
- if isinstance(varnames, list) and len(varnames) == len(values):
5005
- return list(zip(values, varnames, strict=False))
5006
- return None
5007
-
5008
- if isinstance(properties, dict) and properties:
5009
- for fname, fmeta in properties.items():
5010
- if not isinstance(fmeta, dict):
5011
- continue
5012
- ftype = fmeta.get("type") or fmeta.get("anyOf") or "any"
5013
- if isinstance(ftype, list):
5014
- ftype = "|".join(str(t) for t in ftype)
5015
- fdesc = str(fmeta.get("description", "")).strip().replace("\n", " ")
5016
- # Spec 2522 — agency 자체 코드체계 (KOROAD 68 시군구 매핑 ≈ 1600
5017
- # chars + 기존 description ≈ 600 chars = ~2200 chars / KMA 156
5018
- # station 등) 인라인 허용. 일반 도구는 100자 미만이라 영향 X.
5019
- if len(fdesc) > 5000:
5020
- fdesc = fdesc[:4997] + "..."
5021
- pat = fmeta.get("pattern")
5022
- pat_part = f" pattern={pat!r}" if isinstance(pat, str) else ""
5023
- enum = _resolve_enum(fmeta, defs)
5024
- # Spec 2522 T047 — threshold 25→200 — KOROAD GugunCode (115) /
5025
- # SearchYearCd (20) / SidoCode (17) 등 모두 노출. 의미 매핑은
5026
- # field description 에 따로 인라인 (Pydantic IntEnum 의 name
5027
- # 은 JSON schema 표준 export 안 됨).
5028
- if isinstance(enum, list) and len(enum) <= 200:
5029
- enum_part = f" enum={enum}"
5030
- else:
5031
- enum_part = ""
5032
- flag = "필수" if fname in required else "선택"
5033
- lines.append(
5034
- f" · {fname} ({ftype}, {flag}{pat_part}{enum_part})"
5035
- + (f" — {fdesc}" if fdesc else "")
5036
- )
5037
- lines.append("")
5038
- lines.append(
5039
- "규칙: 위 목록의 tool_id는 이미 model-facing concrete function name입니다. "
5040
- "루트 wrapper find/locate/check/send 를 호출하지 말고, 해당 tool_id 이름의 "
5041
- "함수를 직접 호출하세요. 인자는 schema 의 필드명 그대로 전달합니다. "
5042
- "동일 tool_id 를 한 turn 안에서 반복 호출하지 마세요."
5043
- )
5044
- listed_primitives = {str(candidate.primitive or "find") for candidate in candidates}
5045
- if listed_primitives == {"find"}:
5046
- lines.append(
5047
- "공개자료 조회 규칙: 위 후보가 모두 primitive=find 이면 시민이 "
5048
- "인증/본인확인/동의/신청/제출/납부/신고를 명시하지 않은 한 "
5049
- "check/send 계열 adapter를 호출하지 마세요. 성공한 find 결과가 있으면 "
5050
- "다음 turn 은 최종 답변입니다."
5051
- )
5052
- lines.append(
5053
- "호출 전 검증: 시민 발화의 명시 조건(개수, 반경/거리, 날짜/시간, 종류, "
5054
- "카테고리, 진료과/분야, 키워드, 행정구역 등)이 아래 schema 의 선택 "
5055
- "필드와 대응하면 그 필드를 반드시 params 에 포함하세요. 더 좁은 요청을 "
5056
- "넓은 무필터 조회로 실행하지 마세요."
5057
- )
5058
- lines.append(
5059
- 'params 는 위에 표시된 정확한 필드명만 사용하세요 — 일반적인 "location"/'
5060
- '"date" 같은 추측 키는 모든 어댑터에서 invalid_params 로 거부됩니다.'
5061
- )
5062
- lines.append(
5063
- "BM25 도구 발견은 백엔드 internal 기능입니다. 모델은 검색 함수를 호출하지 "
5064
- "않고, backend가 tools[]에 실어준 concrete function만 호출합니다."
5065
- )
5066
- lines.append("</available_adapters>")
5067
- return "\n".join(lines)
5068
6273
 
5069
6274
  # Spec 1978 T053 — eager-import the Mock adapter tree so every adapter
5070
6275
  # self-registers with its primitive dispatcher before the first chat
@@ -5514,9 +6719,28 @@ async def run( # noqa: C901
5514
6719
  span.set_attribute("ummaya.tool.dispatched", fname)
5515
6720
  span.set_attribute("ummaya.session.id", session_id)
5516
6721
 
6722
+ if fname == "document" and "tool_id" not in args_obj:
6723
+ args_obj = {
6724
+ "tool_id": "document",
6725
+ "params": dict(args_obj),
6726
+ }
6727
+ normalized_document_args = _normalize_document_root_call_for_user_intent(
6728
+ fname,
6729
+ args_obj,
6730
+ _session_latest_user_utterances.get(session_id, ""),
6731
+ )
6732
+ if normalized_document_args is not args_obj:
6733
+ span.set_attribute("ummaya.document.intent_normalized", True)
6734
+ logger.warning(
6735
+ "_dispatch_primitive: normalized document root call to match latest "
6736
+ "citizen write/save intent."
6737
+ )
6738
+ args_obj = normalized_document_args
6739
+
6740
+ local_document_harness_call = _is_local_document_harness_root_call(fname, args_obj)
5517
6741
  invalid_gated_tool_id = (
5518
6742
  _invalid_gated_primitive_tool_id_result(fname, args_obj)
5519
- if fname in _PERMISSION_GATED_PRIMITIVES
6743
+ if fname in _PERMISSION_GATED_PRIMITIVES and not local_document_harness_call
5520
6744
  else None
5521
6745
  )
5522
6746
  if invalid_gated_tool_id is not None:
@@ -5547,13 +6771,17 @@ async def run( # noqa: C901
5547
6771
  return
5548
6772
 
5549
6773
  # ----- Permission gate (T043-T049) -----
5550
- allowed = await _check_permission_gate(
5551
- call_id, fname, args_obj, session_id, correlation_id
5552
- )
5553
- if not allowed:
5554
- # Gate already resolved the Future with an error envelope.
5555
- span.set_attribute("ummaya.permission.decision", "deny")
5556
- return
6774
+ if not local_document_harness_call:
6775
+ allowed = await _check_permission_gate(
6776
+ call_id, fname, args_obj, session_id, correlation_id
6777
+ )
6778
+ if not allowed:
6779
+ # Gate already resolved the Future with an error envelope.
6780
+ span.set_attribute("ummaya.permission.decision", "deny")
6781
+ return
6782
+ else:
6783
+ span.set_attribute("ummaya.permission.mode", "local_document_harness")
6784
+ span.set_attribute("ummaya.permission.decision", "allow_once")
5557
6785
 
5558
6786
  result_payload: dict[str, object] = {}
5559
6787
  dispatch_error: str | None = None
@@ -5575,12 +6803,49 @@ async def run( # noqa: C901
5575
6803
  _outbound_trace_token = start_outbound_capture()
5576
6804
 
5577
6805
  try:
5578
- if fname == "check":
6806
+ document_harness_dispatched = False
6807
+ document_tool_id = str(args_obj.get("tool_id") or "")
6808
+ if document_tool_id:
6809
+ registry = _ensure_tool_registry()
6810
+ try:
6811
+ document_tool = registry.find(document_tool_id)
6812
+ except Exception:
6813
+ document_tool = None
6814
+ if document_tool is not None and _is_local_document_harness_tool(document_tool):
6815
+ document_harness_dispatched = True
6816
+ if document_tool.primitive != fname:
6817
+ dispatch_error = (
6818
+ f"Adapter {document_tool_id!r} is "
6819
+ f"primitive={document_tool.primitive!r}, "
6820
+ f"but was called through {fname}."
6821
+ )
6822
+ else:
6823
+ executor = _ensure_tool_executor()
6824
+ document_params = cast(
6825
+ "dict[str, object]",
6826
+ args_obj.get("params") or {},
6827
+ )
6828
+ raw = await executor.invoke_raw(
6829
+ tool_id=document_tool_id,
6830
+ params=document_params,
6831
+ request_id=str(uuid.uuid4()),
6832
+ session_identity=session_id,
6833
+ )
6834
+ result_payload = {
6835
+ "kind": fname,
6836
+ "result": _serialize_primitive_result(raw),
6837
+ }
6838
+
6839
+ if document_harness_dispatched:
6840
+ pass
6841
+
6842
+ elif fname == "check":
5579
6843
  from ummaya.primitives.verify import ( # noqa: PLC0415
5580
6844
  verify,
5581
6845
  )
5582
6846
  from ummaya.tools.verify_canonical_map import ( # noqa: PLC0415
5583
6847
  resolve_family,
6848
+ resolve_tool_id,
5584
6849
  )
5585
6850
 
5586
6851
  # Spec 2297 / Issue #C1 (2026-05-04) — translate
@@ -5595,7 +6860,11 @@ async def run( # noqa: C901
5595
6860
  # Accept both ``family`` (citizen-facing tool schema) and
5596
6861
  # ``family_hint`` (primitive's internal arg name) for
5597
6862
  # legacy / tools-bridge compatibility.
5598
- tool_id = str(args_obj.get("tool_id") or "")
6863
+ raw_tool_id = str(args_obj.get("tool_id") or "")
6864
+ canonical_tool_id = resolve_tool_id(raw_tool_id) or raw_tool_id
6865
+ if canonical_tool_id != raw_tool_id:
6866
+ args_obj = {**args_obj, "tool_id": canonical_tool_id}
6867
+ tool_id = canonical_tool_id
5599
6868
  if tool_id:
5600
6869
  registry = _ensure_tool_registry()
5601
6870
  try:
@@ -5643,6 +6912,14 @@ async def run( # noqa: C901
5643
6912
  LookupFetchInput,
5644
6913
  )
5645
6914
 
6915
+ lookup_context = _session_latest_user_utterances.get(
6916
+ session_id, ""
6917
+ ) or _lookup_context_from_args(args_obj)
6918
+ args_obj = _normalize_lookup_args_for_query(
6919
+ fname,
6920
+ args_obj,
6921
+ lookup_context,
6922
+ )
5646
6923
  requested_mode = args_obj.get("mode")
5647
6924
  if requested_mode is not None and str(requested_mode) != "fetch":
5648
6925
  logger.warning(
@@ -5656,7 +6933,8 @@ async def run( # noqa: C901
5656
6933
  message=(
5657
6934
  "find(mode='search') 는 백엔드 internal 기능입니다 — "
5658
6935
  "직접 호출하지 마십시오. 시스템 프롬프트의 "
5659
- "<available_adapters> 에서 tool_id 골라 fetch 호출만 사용하세요."
6936
+ "<available_adapters> 에서 concrete adapter function을 "
6937
+ "골라 schema 필드로 직접 호출하세요."
5660
6938
  ),
5661
6939
  retryable=False,
5662
6940
  )
@@ -5682,17 +6960,31 @@ async def run( # noqa: C901
5682
6960
  lookup_params,
5683
6961
  auth_context,
5684
6962
  )
5685
- inp_lk = LookupFetchInput(
5686
- mode="fetch",
5687
- tool_id=str(args_obj.get("tool_id", "")),
5688
- params=lookup_params,
5689
- )
5690
- raw = await find(
5691
- inp_lk,
5692
- registry=registry,
5693
- executor=executor,
5694
- session_identity=session_id,
5695
- )
6963
+ lookup_tool_id = str(args_obj.get("tool_id") or "").strip()
6964
+ if not lookup_tool_id or lookup_tool_id in _ROOT_PRIMITIVE_TOOL_IDS:
6965
+ raw = LookupError(
6966
+ kind="error",
6967
+ reason=LookupErrorReason.invalid_params,
6968
+ message=(
6969
+ "find(mode='fetch') requires a concrete adapter tool_id "
6970
+ "from the current available adapter set. No concrete "
6971
+ "adapter was selected, so UMMAYA stopped this malformed "
6972
+ "tool call instead of retrying it."
6973
+ ),
6974
+ retryable=False,
6975
+ )
6976
+ else:
6977
+ inp_lk = LookupFetchInput(
6978
+ mode="fetch",
6979
+ tool_id=lookup_tool_id,
6980
+ params=lookup_params,
6981
+ )
6982
+ raw = await find(
6983
+ inp_lk,
6984
+ registry=registry,
6985
+ executor=executor,
6986
+ session_identity=session_id,
6987
+ )
5696
6988
  result_payload = {
5697
6989
  "kind": "find",
5698
6990
  "result": _serialize_primitive_result(raw),
@@ -5813,6 +7105,8 @@ async def run( # noqa: C901
5813
7105
  locate_result = result_payload.get("result")
5814
7106
  if isinstance(locate_result, dict) and locate_result.get("kind") != "error":
5815
7107
  _session_latest_locate_results[session_id] = locate_result
7108
+ if _locate_result_coords(locate_result) is not None:
7109
+ _session_latest_locate_results_with_coords[session_id] = locate_result
5816
7110
 
5817
7111
  # Drain the outbound HTTP trace buffer + attach to the envelope.
5818
7112
  outbound_traces = consume_outbound_capture(_outbound_trace_token)
@@ -5900,6 +7194,29 @@ async def run( # noqa: C901
5900
7194
  if not isinstance(frame, ChatRequestFrame):
5901
7195
  return
5902
7196
 
7197
+ async def _emit_progress_event(
7198
+ phase: Literal[
7199
+ "analysis",
7200
+ "tool_selection",
7201
+ "tool_call",
7202
+ "tool_result",
7203
+ "answer_synthesis",
7204
+ ],
7205
+ message_ko: str,
7206
+ message_en: str,
7207
+ *,
7208
+ tool_id: str | None = None,
7209
+ call_id: str | None = None,
7210
+ ) -> None:
7211
+ _ = (phase, message_ko, message_en, tool_id, call_id)
7212
+ return
7213
+
7214
+ await _emit_progress_event(
7215
+ "analysis",
7216
+ "요청을 분석하고 있습니다.",
7217
+ "Analyzing the request.",
7218
+ )
7219
+
5903
7220
  # ---- spec-multi-turn-contamination diagnostic emit (FR-001/FR-002)
5904
7221
  # Increment the per-session turn counter and dump the inbound
5905
7222
  # ChatRequestFrame.messages tail so we can prove which user turn
@@ -5942,11 +7259,9 @@ async def run( # noqa: C901
5942
7259
  except Exception: # noqa: BLE001 — diagnostic must never raise
5943
7260
  logger.exception("[CHAT_REQUEST_DUMP] failed to serialise")
5944
7261
 
5945
- latest_user_utt = ""
5946
- for _msg in reversed(frame.messages):
5947
- if _msg.role == "user" and _msg.content:
5948
- latest_user_utt = _msg.content
5949
- break
7262
+ latest_user_utt = _latest_citizen_user_utterance(frame.messages)
7263
+ if latest_user_utt:
7264
+ _session_latest_user_utterances[frame.session_id] = latest_user_utt
5950
7265
 
5951
7266
  # Tool inventory — backend ToolRegistry is the single source of
5952
7267
  # truth. CC exposes concrete Tool objects as model-facing functions:
@@ -5958,9 +7273,23 @@ async def run( # noqa: C901
5958
7273
  # CC-style loop contract: the model can paint progress prose, then call
5959
7274
  # a primitive dispatcher with a concrete adapter in `tool_id`.
5960
7275
  registry = cast("Any", _ensure_tool_registry())
5961
- backend_tools_raw = [
5962
- t.to_openai_tool() for t in _select_concrete_adapter_tools_for_turn(latest_user_utt)
5963
- ]
7276
+ turn_route_decision = _route_decision_for_turn(latest_user_utt)
7277
+ from ummaya.ipc.route_diagnostics import ( # noqa: PLC0415
7278
+ log_route_decision_diagnostic,
7279
+ )
7280
+
7281
+ log_route_decision_diagnostic(
7282
+ logger=logger,
7283
+ turn_index=_diag_turn_idx,
7284
+ session_id=frame.session_id,
7285
+ correlation_id=frame.correlation_id,
7286
+ decision=turn_route_decision,
7287
+ )
7288
+ turn_concrete_adapter_tools = _select_concrete_adapter_tools_for_turn(
7289
+ latest_user_utt, route_decision=turn_route_decision
7290
+ )
7291
+ turn_concrete_adapter_tool_ids = tuple(t.id for t in turn_concrete_adapter_tools)
7292
+ backend_tools_raw = [t.to_openai_tool() for t in turn_concrete_adapter_tools]
5964
7293
  backend_tool_names: set[object] = set()
5965
7294
  for raw_tool in backend_tools_raw:
5966
7295
  if not isinstance(raw_tool, dict):
@@ -5971,6 +7300,11 @@ async def run( # noqa: C901
5971
7300
  llm_tools: list[LLMToolDefinition] = [
5972
7301
  LLMToolDefinition.model_validate(raw) for raw in backend_tools_raw
5973
7302
  ]
7303
+ await _emit_progress_event(
7304
+ "tool_selection",
7305
+ "도구 후보와 질의 맥락을 정리하고 있습니다.",
7306
+ "Preparing tool candidates and query context.",
7307
+ )
5974
7308
  has_concrete_backend_tools = bool(backend_tools_raw)
5975
7309
  for t in frame.tools:
5976
7310
  tui_name = getattr(getattr(t, "function", None), "name", None)
@@ -6073,10 +7407,9 @@ async def run( # noqa: C901
6073
7407
  # calls were the source of the "● find(search:)" phantom tool-UI
6074
7408
  # noise that user surfaced via Layer 5 frame capture.
6075
7409
  try:
6076
- for m in reversed(frame.messages):
6077
- if m.role == "user" and m.content:
6078
- latest_user_utt = m.content
6079
- break
7410
+ latest_user_utt = _latest_citizen_user_utterance(frame.messages)
7411
+ if latest_user_utt:
7412
+ _session_latest_user_utterances[frame.session_id] = latest_user_utt
6080
7413
  # spec-multi-turn-contamination diagnostic emit — log the
6081
7414
  # extracted latest user utterance BEFORE the BM25 suffix
6082
7415
  # builder runs. If this string disagrees with the wire-level
@@ -6090,7 +7423,11 @@ async def run( # noqa: C901
6090
7423
  (latest_user_utt or "")[:256],
6091
7424
  )
6092
7425
  if latest_user_utt:
6093
- suffix_block = _build_available_adapters_suffix(latest_user_utt)
7426
+ suffix_block = _build_available_adapters_suffix(
7427
+ latest_user_utt,
7428
+ route_decision=turn_route_decision,
7429
+ visible_tool_ids=turn_concrete_adapter_tool_ids,
7430
+ )
6094
7431
  if suffix_block:
6095
7432
  augmented_system = augmented_system + "\n\n" + suffix_block + "\n"
6096
7433
  except Exception: # noqa: BLE001 — fail-open per FR-002
@@ -6163,6 +7500,7 @@ async def run( # noqa: C901
6163
7500
  force_verify_next_turn: str | None = None
6164
7501
  force_lookup_next_turn: str | None = None
6165
7502
  force_submit_next_turn: str | None = None
7503
+ force_document_next_turn: str | None = None
6166
7504
  force_no_tools_next_turn = False
6167
7505
  continue_free_next_turn = False
6168
7506
  mock_disclosure_required = False
@@ -6170,6 +7508,10 @@ async def run( # noqa: C901
6170
7508
  verify_choice_mismatch_count = 0
6171
7509
  empty_final_retry_count = 0
6172
7510
  duplicate_nonprogress_count = 0
7511
+ initial_concrete_tool_choice = _initial_concrete_tool_choice_for_query(
7512
+ latest_user_utt,
7513
+ _tool_definition_names(llm_tools),
7514
+ )
6173
7515
 
6174
7516
  for _turn in range(_AGENTIC_LOOP_MAX_TURNS):
6175
7517
  message_id = str(uuid.uuid4())
@@ -6274,29 +7616,88 @@ async def run( # noqa: C901
6274
7616
  stream_tools = None
6275
7617
  no_tools_this_turn = True
6276
7618
  force_no_tools_next_turn = False
7619
+ elif (
7620
+ initial_concrete_tool_choice is not None
7621
+ and initial_concrete_tool_choice in _tool_definition_names(stream_tools)
7622
+ ):
7623
+ stream_tool_choice = _function_tool_choice(initial_concrete_tool_choice)
7624
+ logger.warning(
7625
+ "_handle_chat_request: forcing initial concrete adapter %s "
7626
+ "for unambiguous query",
7627
+ initial_concrete_tool_choice,
7628
+ )
7629
+ initial_concrete_tool_choice = None
7630
+ elif (
7631
+ force_lookup_next_turn is not None
7632
+ and force_lookup_next_turn in _tool_definition_names(stream_tools)
7633
+ ):
7634
+ stream_tool_choice = _function_tool_choice(force_lookup_next_turn)
7635
+ logger.warning(
7636
+ "_handle_chat_request: forcing concrete find adapter %s after validation gate",
7637
+ force_lookup_next_turn,
7638
+ )
7639
+ force_lookup_next_turn = None
7640
+ elif (
7641
+ force_verify_next_turn is not None
7642
+ and force_verify_next_turn in _tool_definition_names(stream_tools)
7643
+ ):
7644
+ stream_tool_choice = _function_tool_choice(force_verify_next_turn)
7645
+ logger.warning(
7646
+ "_handle_chat_request: forcing concrete check adapter %s after validation gate",
7647
+ force_verify_next_turn,
7648
+ )
7649
+ force_verify_next_turn = None
7650
+ elif (
7651
+ force_submit_next_turn is not None
7652
+ and force_submit_next_turn in _tool_definition_names(stream_tools)
7653
+ ):
7654
+ stream_tool_choice = _function_tool_choice(force_submit_next_turn)
7655
+ logger.warning(
7656
+ "_handle_chat_request: forcing concrete send adapter %s after validation gate",
7657
+ force_submit_next_turn,
7658
+ )
7659
+ force_submit_next_turn = None
7660
+ elif (
7661
+ force_document_next_turn is not None
7662
+ and force_document_next_turn in _tool_definition_names(stream_tools)
7663
+ ):
7664
+ stream_tool_choice = _function_tool_choice(force_document_next_turn)
7665
+ logger.warning(
7666
+ "_handle_chat_request: forcing concrete document adapter %s "
7667
+ "after validation gate",
7668
+ force_document_next_turn,
7669
+ )
7670
+ force_document_next_turn = None
6277
7671
  elif (
6278
7672
  force_locate_next_turn
6279
7673
  or force_verify_next_turn is not None
6280
7674
  or force_lookup_next_turn is not None
6281
7675
  or force_submit_next_turn is not None
7676
+ or force_document_next_turn is not None
6282
7677
  ):
6283
7678
  logger.warning(
6284
7679
  "_handle_chat_request: continuing turn %d with free tool_choice "
6285
- "after validation gate hint (locate=%s check=%s find=%s send=%s)",
7680
+ "after validation gate hint (locate=%s check=%s find=%s send=%s document=%s)",
6286
7681
  _turn,
6287
7682
  force_locate_next_turn,
6288
7683
  force_verify_next_turn,
6289
7684
  force_lookup_next_turn,
6290
7685
  force_submit_next_turn,
7686
+ force_document_next_turn,
6291
7687
  )
6292
7688
  try:
7689
+ stream_kwargs: dict[str, object] = {
7690
+ "messages": llm_messages,
7691
+ "tools": stream_tools,
7692
+ "temperature": frame.temperature,
7693
+ "top_p": frame.top_p,
7694
+ "max_tokens": _effective_chat_max_tokens(frame.max_tokens),
7695
+ "tool_choice": stream_tool_choice,
7696
+ }
7697
+ if frame.reasoning_mode is not None:
7698
+ stream_kwargs["reasoning_mode"] = frame.reasoning_mode
6293
7699
  async for event in client.stream( # type: ignore[attr-defined]
6294
- messages=llm_messages,
6295
- tools=stream_tools,
6296
- temperature=frame.temperature,
6297
- top_p=frame.top_p,
6298
- max_tokens=_effective_chat_max_tokens(frame.max_tokens),
6299
- tool_choice=stream_tool_choice,
7700
+ **stream_kwargs,
6300
7701
  ):
6301
7702
  event_type = getattr(event, "type", None)
6302
7703
  if event_type == "content_delta":
@@ -6553,6 +7954,19 @@ async def run( # noqa: C901
6553
7954
  buffered_visible.clear()
6554
7955
  continue
6555
7956
 
7957
+ medical_aed_followup_msg = _check_medical_emergency_terminated_without_aed(
7958
+ llm_messages,
7959
+ latest_user_utt,
7960
+ registry=_ensure_tool_registry(),
7961
+ )
7962
+ if medical_aed_followup_msg is not None:
7963
+ _append_tool_routing_observation(
7964
+ "rejected final-answer turn — collapse emergency missing AED lookup",
7965
+ medical_aed_followup_msg,
7966
+ )
7967
+ buffered_visible.clear()
7968
+ continue
7969
+
6556
7970
  current_weather_gate_msg = _check_current_weather_terminated_without_observation(
6557
7971
  llm_messages,
6558
7972
  latest_user_utt,
@@ -6566,6 +7980,37 @@ async def run( # noqa: C901
6566
7980
  buffered_visible.clear()
6567
7981
  continue
6568
7982
 
7983
+ document_followup_gate = _check_document_workflow_terminated_without_required_tool(
7984
+ llm_messages,
7985
+ latest_user_utt,
7986
+ )
7987
+ if document_followup_gate is not None:
7988
+ document_tool_id = document_followup_gate["tool_id"]
7989
+ try:
7990
+ document_tool = _ensure_tool_registry().find(document_tool_id)
7991
+ document_primitive = str(getattr(document_tool, "primitive", "") or "")
7992
+ except Exception: # noqa: BLE001
7993
+ document_primitive = ""
7994
+ if document_primitive == "find":
7995
+ force_lookup_next_turn = document_tool_id
7996
+ elif document_primitive == "send":
7997
+ force_submit_next_turn = document_tool_id
7998
+ elif document_primitive == "check":
7999
+ force_verify_next_turn = document_tool_id
8000
+ elif document_primitive == "document":
8001
+ force_document_next_turn = document_tool_id
8002
+ else:
8003
+ continue_free_next_turn = True
8004
+ _append_tool_routing_observation(
8005
+ (
8006
+ "rejected final-answer turn — document workflow "
8007
+ f"missing {document_tool_id}"
8008
+ ),
8009
+ document_followup_gate["message"],
8010
+ )
8011
+ buffered_visible.clear()
8012
+ continue
8013
+
6569
8014
  from ummaya.llm.tool_call_parser import ( # noqa: PLC0415
6570
8015
  strip_leaked_thinking_markers,
6571
8016
  )
@@ -6579,9 +8024,106 @@ async def run( # noqa: C901
6579
8024
  else:
6580
8025
  merged_prose = _remove_unneeded_mock_disclosure(merged_prose)
6581
8026
  merged_prose = _remove_unneeded_live_meta_disclosure(merged_prose)
8027
+ document_followup_gate = _check_document_workflow_terminated_without_required_tool(
8028
+ llm_messages,
8029
+ latest_user_utt,
8030
+ candidate_final_answer=merged_prose,
8031
+ )
8032
+ if document_followup_gate is not None:
8033
+ document_tool_id = document_followup_gate["tool_id"]
8034
+ try:
8035
+ document_tool = _ensure_tool_registry().find(document_tool_id)
8036
+ document_primitive = str(getattr(document_tool, "primitive", "") or "")
8037
+ except Exception: # noqa: BLE001
8038
+ document_primitive = ""
8039
+ if document_primitive == "find":
8040
+ force_lookup_next_turn = document_tool_id
8041
+ elif document_primitive == "send":
8042
+ force_submit_next_turn = document_tool_id
8043
+ elif document_primitive == "check":
8044
+ force_verify_next_turn = document_tool_id
8045
+ elif document_primitive == "document":
8046
+ force_document_next_turn = document_tool_id
8047
+ else:
8048
+ continue_free_next_turn = True
8049
+ _append_tool_routing_observation(
8050
+ (
8051
+ "rejected final-answer turn — document workflow "
8052
+ f"missing {document_tool_id}"
8053
+ ),
8054
+ document_followup_gate["message"],
8055
+ )
8056
+ buffered_visible.clear()
8057
+ continue
6582
8058
  has_successful_tool_result = _conversation_has_successful_any_primitive_result(
6583
8059
  llm_messages
6584
8060
  )
8061
+ diff_only_document_answer = _document_diff_only_final_answer(
8062
+ latest_user_utt,
8063
+ llm_messages,
8064
+ )
8065
+ if diff_only_document_answer is not None:
8066
+ merged_prose = diff_only_document_answer
8067
+ if (
8068
+ merged_prose.strip()
8069
+ and _final_answer_looks_like_tool_call_narration(merged_prose)
8070
+ and empty_final_retry_count < 2
8071
+ ):
8072
+ empty_final_retry_count += 1
8073
+ _append_tool_routing_observation(
8074
+ "rejected textual tool-call final answer",
8075
+ (
8076
+ "The previous assistant turn printed <tool_call> or JSON "
8077
+ "tool-call text as citizen-facing prose. Never print tool "
8078
+ "calls. If another lookup is required, emit a structured "
8079
+ "function call from the current tools[] list. If enough "
8080
+ "evidence is already available, write a Korean prose final "
8081
+ "answer only."
8082
+ ),
8083
+ )
8084
+ buffered_visible.clear()
8085
+ continue
8086
+ if (
8087
+ merged_prose.strip()
8088
+ and has_successful_tool_result
8089
+ and _final_answer_overclaims_document_edit(merged_prose, llm_messages)
8090
+ ):
8091
+ if empty_final_retry_count < 2:
8092
+ empty_final_retry_count += 1
8093
+ _append_final_answer_observation(
8094
+ "rejected document final answer overclaimed observed diff",
8095
+ (
8096
+ "The previous assistant turn claimed document content or "
8097
+ "work sections not present in the latest document diff. "
8098
+ "Document diff changes are the only approved edit claims. "
8099
+ "Produce a concise Korean final answer using only "
8100
+ "result.status, text_summary, and diff.changes "
8101
+ "display_label/target_path/before_value/after_value from "
8102
+ "the latest document tool_result. Do not add activity content, "
8103
+ "achievements, next plans, problems, improvements, render "
8104
+ "artifacts, or document sections unless they appear in "
8105
+ "diff.changes."
8106
+ ),
8107
+ )
8108
+ buffered_visible.clear()
8109
+ continue
8110
+ await write_frame(
8111
+ ErrorFrame(
8112
+ session_id=frame.session_id,
8113
+ correlation_id=frame.correlation_id or str(uuid.uuid4()),
8114
+ role="backend",
8115
+ ts=_utcnow(),
8116
+ kind="error",
8117
+ code="document_final_answer_overclaim",
8118
+ message=(
8119
+ "Model returned an ungrounded document final answer "
8120
+ "after successful document diff results. No synthetic "
8121
+ "answer was generated."
8122
+ ),
8123
+ details={"retry_count": empty_final_retry_count},
8124
+ )
8125
+ )
8126
+ return
6585
8127
  if not merged_prose.strip() and has_successful_tool_result:
6586
8128
  if empty_final_retry_count < 2:
6587
8129
  empty_final_retry_count += 1
@@ -6633,6 +8175,59 @@ async def run( # noqa: C901
6633
8175
  )
6634
8176
  buffered_visible.clear()
6635
8177
  continue
8178
+ if (
8179
+ merged_prose.strip()
8180
+ and _final_answer_looks_like_kma_analysis_fabrication(
8181
+ merged_prose,
8182
+ latest_user_utt,
8183
+ )
8184
+ and empty_final_retry_count < 2
8185
+ ):
8186
+ empty_final_retry_count += 1
8187
+ _append_final_answer_observation(
8188
+ "rejected KMA analysis final answer filled from general knowledge",
8189
+ (
8190
+ "The citizen asked for KMA analyzed-data evidence. The "
8191
+ "previous assistant turn described failed, empty, or "
8192
+ "unparseable KMA APIHub analysis lookups, then filled the "
8193
+ "weather answer with general knowledge. Do not fill gaps "
8194
+ "from prior knowledge. If the successful tool_results do "
8195
+ "not contain parseable analyzed values for the request, "
8196
+ "answer that the KMA APIHub lookup did not provide usable "
8197
+ "analyzed data in this run, cite the APIHub upstream/approval "
8198
+ "failure when present, and avoid weather-condition claims."
8199
+ ),
8200
+ )
8201
+ buffered_visible.clear()
8202
+ continue
8203
+ if (
8204
+ merged_prose.strip()
8205
+ and _final_answer_substitutes_after_kma_chart_failure(
8206
+ merged_prose,
8207
+ latest_user_utt,
8208
+ llm_messages,
8209
+ )
8210
+ and empty_final_retry_count < 2
8211
+ ):
8212
+ empty_final_retry_count += 1
8213
+ _append_final_answer_observation(
8214
+ (
8215
+ "rejected KMA chart answer substituted non-chart "
8216
+ "evidence after upstream failure"
8217
+ ),
8218
+ (
8219
+ "The citizen asked for analyzed weather-chart/map evidence. "
8220
+ "The KMA APIHub chart lookup failed or required approval, "
8221
+ "and the previous assistant answer substituted point-grid, "
8222
+ "AWS objective-analysis, or observation values. Do not "
8223
+ "substitute other evidence for this chart/map request. "
8224
+ "Answer that the KMA APIHub analyzed chart lookup could "
8225
+ "not be used in this run, cite the upstream approval/error "
8226
+ "state, and avoid weather-condition claims."
8227
+ ),
8228
+ )
8229
+ buffered_visible.clear()
8230
+ continue
6636
8231
  if (
6637
8232
  merged_prose.strip()
6638
8233
  and has_successful_tool_result
@@ -6852,6 +8447,7 @@ async def run( # noqa: C901
6852
8447
 
6853
8448
  model_tool_name = slot["name"]
6854
8449
  model_args_obj = dict(args_obj)
8450
+ canonical_model_tool_name = model_tool_name
6855
8451
 
6856
8452
  from ummaya.primitives import PRIMITIVE_REGISTRY # noqa: PLC0415
6857
8453
 
@@ -6859,8 +8455,15 @@ async def run( # noqa: C901
6859
8455
  fname = model_tool_name
6860
8456
  args_obj = dict(model_args_obj)
6861
8457
  else:
8458
+ from ummaya.tools.verify_canonical_map import ( # noqa: PLC0415
8459
+ resolve_tool_id as _resolve_verify_tool_id,
8460
+ )
8461
+
8462
+ canonical_model_tool_name = (
8463
+ _resolve_verify_tool_id(model_tool_name) or model_tool_name
8464
+ )
6862
8465
  try:
6863
- concrete_tool = registry.find(model_tool_name)
8466
+ concrete_tool = registry.find(canonical_model_tool_name)
6864
8467
  except Exception:
6865
8468
  await write_frame(
6866
8469
  ErrorFrame(
@@ -6894,8 +8497,12 @@ async def run( # noqa: C901
6894
8497
  )
6895
8498
  continue
6896
8499
  fname = primitive_name
6897
- args_obj = {"tool_id": model_tool_name, "params": dict(model_args_obj)}
8500
+ args_obj = {
8501
+ "tool_id": canonical_model_tool_name,
8502
+ "params": dict(model_args_obj),
8503
+ }
6898
8504
 
8505
+ args_obj = _normalize_root_primitive_adapter_envelope(fname, args_obj)
6899
8506
  raw_dispatch_args_obj = _copy_primitive_args(args_obj)
6900
8507
 
6901
8508
  args_obj = _maybe_reroute_locate_admin_keyword_args(fname, args_obj)
@@ -6944,10 +8551,12 @@ async def run( # noqa: C901
6944
8551
  latest_user_utt,
6945
8552
  adapter_param_names=adapter_param_names,
6946
8553
  )
6947
- emit_tool_name = model_tool_name
8554
+ emit_tool_name = (
8555
+ canonical_model_tool_name if model_tool_name != fname else model_tool_name
8556
+ )
6948
8557
  emit_args_obj = (
6949
8558
  dict(cast("dict[str, object]", args_obj.get("params") or {}))
6950
- if model_tool_name != fname
8559
+ if emit_tool_name != fname
6951
8560
  else args_obj
6952
8561
  )
6953
8562
 
@@ -6986,6 +8595,13 @@ async def run( # noqa: C901
6986
8595
  )
6987
8596
 
6988
8597
  await _emit_buffered_visible_before_tool(message_id)
8598
+ await _emit_progress_event(
8599
+ "tool_call",
8600
+ "선택된 도구를 호출하고 있습니다.",
8601
+ "Calling the selected tool.",
8602
+ tool_id=emit_tool_name,
8603
+ call_id=call_id,
8604
+ )
6989
8605
  await write_frame(
6990
8606
  ToolCallFrame(
6991
8607
  session_id=frame.session_id,
@@ -7114,6 +8730,43 @@ async def run( # noqa: C901
7114
8730
  continue_free_next_turn = True
7115
8731
  continue
7116
8732
 
8733
+ kma_aviation_choice_msg = _check_kma_aviation_tool_choice_prerequisite(
8734
+ fname,
8735
+ args_obj,
8736
+ latest_user_utt,
8737
+ )
8738
+ if kma_aviation_choice_msg is not None:
8739
+ force_lookup_next_turn = _preferred_kma_aviation_tool_id(latest_user_utt)
8740
+ _append_tool_routing_observation(
8741
+ f"rejected {fname} call_id={call_id[:12]} — KMA aviation tool mismatch",
8742
+ kma_aviation_choice_msg,
8743
+ )
8744
+ logger.warning(
8745
+ "_handle_chat_request: rejected %s call_id=%s — KMA aviation tool mismatch",
8746
+ fname,
8747
+ call_id[:12],
8748
+ )
8749
+ continue
8750
+
8751
+ direct_public_data_choice = _check_direct_public_data_tool_choice_prerequisite(
8752
+ fname,
8753
+ args_obj,
8754
+ latest_user_utt,
8755
+ )
8756
+ if direct_public_data_choice is not None:
8757
+ preferred_tool_id, direct_public_data_msg = direct_public_data_choice
8758
+ force_lookup_next_turn = preferred_tool_id
8759
+ _append_tool_routing_observation(
8760
+ f"rejected {fname} call_id={call_id[:12]} — public-data tool mismatch",
8761
+ direct_public_data_msg,
8762
+ )
8763
+ logger.warning(
8764
+ "_handle_chat_request: rejected %s call_id=%s — public-data tool mismatch",
8765
+ fname,
8766
+ call_id[:12],
8767
+ )
8768
+ continue
8769
+
7117
8770
  # Chain prerequisite gate — donga-univ-poi-bug Epic #2766.
7118
8771
  # CC mirror: ``Tool.validateInput?(input, context)`` from
7119
8772
  # ``.references/claude-code-sourcemap/restored-src/src/Tool.ts:489``
@@ -7132,11 +8785,9 @@ async def run( # noqa: C901
7132
8785
  # the coordinates AND no prior turn in llm_messages
7133
8786
  # invoked locate, that means the LLM guessed
7134
8787
  # the coordinates from prior knowledge instead of routing
7135
- # through the canonical resolver. Three live captures
7136
- # under specs/integration-verification/donga-univ-poi-bug/
7137
- # showed this exact pattern producing wrong-region
7138
- # hospital lists. Rejecting here forces the next turn
7139
- # through locate.
8788
+ # through the canonical resolver. Historical live captures
8789
+ # showed this exact pattern producing wrong-region hospital
8790
+ # lists. Rejecting here forces the next turn through locate.
7140
8791
  chain_error_msg = _check_chain_prerequisite(
7141
8792
  fname,
7142
8793
  args_obj,
@@ -7158,6 +8809,24 @@ async def run( # noqa: C901
7158
8809
  force_locate_next_turn = True
7159
8810
  continue
7160
8811
 
8812
+ kma_analysis_choice_msg = _check_kma_analysis_tool_choice_prerequisite(
8813
+ fname,
8814
+ args_obj,
8815
+ latest_user_utt,
8816
+ )
8817
+ if kma_analysis_choice_msg is not None:
8818
+ _append_tool_routing_observation(
8819
+ f"rejected {fname} call_id={call_id[:12]} — KMA analysis tool mismatch",
8820
+ kma_analysis_choice_msg,
8821
+ )
8822
+ logger.warning(
8823
+ "_handle_chat_request: rejected %s call_id=%s — KMA analysis tool mismatch",
8824
+ fname,
8825
+ call_id[:12],
8826
+ )
8827
+ continue_free_next_turn = True
8828
+ continue
8829
+
7161
8830
  verify_choice_gate = _check_verify_tool_choice_prerequisite(
7162
8831
  fname,
7163
8832
  args_obj,
@@ -7170,6 +8839,13 @@ async def run( # noqa: C901
7170
8839
  )
7171
8840
 
7172
8841
  await _emit_buffered_visible_before_tool(message_id)
8842
+ await _emit_progress_event(
8843
+ "tool_call",
8844
+ "선택된 도구를 호출하고 있습니다.",
8845
+ "Calling the selected tool.",
8846
+ tool_id=emit_tool_name,
8847
+ call_id=call_id,
8848
+ )
7173
8849
  await write_frame(
7174
8850
  ToolCallFrame(
7175
8851
  session_id=frame.session_id,
@@ -7326,6 +9002,13 @@ async def run( # noqa: C901
7326
9002
  )
7327
9003
 
7328
9004
  await _emit_buffered_visible_before_tool(message_id)
9005
+ await _emit_progress_event(
9006
+ "tool_call",
9007
+ "선택된 도구를 호출하고 있습니다.",
9008
+ "Calling the selected tool.",
9009
+ tool_id=emit_tool_name,
9010
+ call_id=call_id,
9011
+ )
7329
9012
  await write_frame(
7330
9013
  ToolCallFrame(
7331
9014
  session_id=frame.session_id,
@@ -7404,6 +9087,13 @@ async def run( # noqa: C901
7404
9087
  continue
7405
9088
 
7406
9089
  await _emit_buffered_visible_before_tool(message_id)
9090
+ await _emit_progress_event(
9091
+ "tool_call",
9092
+ "선택된 도구를 호출하고 있습니다.",
9093
+ "Calling the selected tool.",
9094
+ tool_id=emit_tool_name,
9095
+ call_id=call_id,
9096
+ )
7407
9097
  await write_frame(
7408
9098
  ToolCallFrame(
7409
9099
  session_id=frame.session_id,
@@ -7429,6 +9119,7 @@ async def run( # noqa: C901
7429
9119
  or force_verify_next_turn is not None
7430
9120
  or force_lookup_next_turn is not None
7431
9121
  or force_submit_next_turn is not None
9122
+ or force_document_next_turn is not None
7432
9123
  or continue_free_next_turn
7433
9124
  ):
7434
9125
  if continue_free_next_turn:
@@ -7730,7 +9421,7 @@ async def run( # noqa: C901
7730
9421
  if not fut.done():
7731
9422
  fut.set_result(frame)
7732
9423
 
7733
- async def _handle_tool_call(frame: IPCFrame) -> None:
9424
+ async def _handle_tool_call(frame: IPCFrame) -> None: # noqa: C901
7734
9425
  """Execute a TUI-owned Tool.call request and emit a tool_result frame.
7735
9426
 
7736
9427
  Claude Code's query loop, not the provider, owns tool execution. The
@@ -7754,8 +9445,13 @@ async def run( # noqa: C901
7754
9445
  dispatch_name = frame.name
7755
9446
  dispatch_args = args_obj
7756
9447
  if dispatch_name not in PRIMITIVE_REGISTRY:
9448
+ from ummaya.tools.verify_canonical_map import ( # noqa: PLC0415
9449
+ resolve_tool_id as _resolve_verify_tool_id,
9450
+ )
9451
+
9452
+ canonical_dispatch_name = _resolve_verify_tool_id(dispatch_name) or dispatch_name
7757
9453
  try:
7758
- concrete_tool = _ensure_tool_registry().find(dispatch_name)
9454
+ concrete_tool = _ensure_tool_registry().find(canonical_dispatch_name)
7759
9455
  except Exception:
7760
9456
  from ummaya.ipc.frame_schema import ( # noqa: PLC0415
7761
9457
  ToolResultEnvelope,
@@ -7811,13 +9507,153 @@ async def run( # noqa: C901
7811
9507
  )
7812
9508
  return
7813
9509
  dispatch_name = primitive_name
7814
- dispatch_args = {"tool_id": frame.name, "params": dict(args_obj)}
9510
+ dispatch_args = {"tool_id": canonical_dispatch_name, "params": dict(args_obj)}
7815
9511
 
7816
9512
  dispatch_args = _normalize_lookup_args_from_cached_locate_result(
7817
9513
  dispatch_name,
7818
9514
  dispatch_args,
7819
9515
  _session_latest_locate_results.get(frame.session_id),
9516
+ coordinate_locate_result=_session_latest_locate_results_with_coords.get(
9517
+ frame.session_id
9518
+ ),
9519
+ user_query=_session_latest_user_utterances.get(frame.session_id, ""),
9520
+ )
9521
+ dispatch_args = _normalize_root_primitive_adapter_envelope(dispatch_name, dispatch_args)
9522
+ lookup_context = _session_latest_user_utterances.get(
9523
+ frame.session_id, ""
9524
+ ) or _lookup_context_from_args(dispatch_args)
9525
+ dispatch_args = _normalize_lookup_args_for_query(
9526
+ dispatch_name,
9527
+ dispatch_args,
9528
+ lookup_context,
9529
+ )
9530
+
9531
+ kma_aviation_choice_msg = _check_kma_aviation_tool_choice_prerequisite(
9532
+ dispatch_name,
9533
+ dispatch_args,
9534
+ _session_latest_user_utterances.get(frame.session_id, ""),
9535
+ )
9536
+ if kma_aviation_choice_msg is not None:
9537
+ from ummaya.ipc.frame_schema import ( # noqa: PLC0415
9538
+ ToolResultEnvelope,
9539
+ ToolResultFrame,
9540
+ )
9541
+
9542
+ envelope = ToolResultEnvelope.model_validate(
9543
+ {
9544
+ "kind": cast("Any", dispatch_name),
9545
+ "result": {
9546
+ "kind": "error",
9547
+ "reason": "kma_aviation_tool_choice_mismatch",
9548
+ "message": kma_aviation_choice_msg,
9549
+ "retryable": False,
9550
+ },
9551
+ }
9552
+ )
9553
+ result_frame = ToolResultFrame(
9554
+ session_id=frame.session_id,
9555
+ correlation_id=frame.correlation_id,
9556
+ role="backend",
9557
+ ts=_utcnow(),
9558
+ kind="tool_result",
9559
+ call_id=frame.call_id,
9560
+ envelope=envelope,
9561
+ )
9562
+ await write_frame(result_frame)
9563
+ fut = _pending_calls.pop(frame.call_id, None)
9564
+ if fut is not None and not fut.done():
9565
+ fut.set_result(result_frame)
9566
+ logger.warning(
9567
+ "_handle_tool_call: rejected %s call_id=%s — KMA aviation tool mismatch",
9568
+ dispatch_name,
9569
+ frame.call_id[:12],
9570
+ )
9571
+ return
9572
+
9573
+ direct_public_data_choice = _check_direct_public_data_tool_choice_prerequisite(
9574
+ dispatch_name,
9575
+ dispatch_args,
9576
+ _session_latest_user_utterances.get(frame.session_id, ""),
9577
+ )
9578
+ if direct_public_data_choice is not None:
9579
+ from ummaya.ipc.frame_schema import ( # noqa: PLC0415
9580
+ ToolResultEnvelope,
9581
+ ToolResultFrame,
9582
+ )
9583
+
9584
+ _preferred_tool_id, direct_public_data_msg = direct_public_data_choice
9585
+ envelope = ToolResultEnvelope.model_validate(
9586
+ {
9587
+ "kind": cast("Any", dispatch_name),
9588
+ "result": {
9589
+ "kind": "error",
9590
+ "reason": "public_data_tool_choice_mismatch",
9591
+ "message": direct_public_data_msg,
9592
+ "retryable": False,
9593
+ },
9594
+ }
9595
+ )
9596
+ result_frame = ToolResultFrame(
9597
+ session_id=frame.session_id,
9598
+ correlation_id=frame.correlation_id,
9599
+ role="backend",
9600
+ ts=_utcnow(),
9601
+ kind="tool_result",
9602
+ call_id=frame.call_id,
9603
+ envelope=envelope,
9604
+ )
9605
+ await write_frame(result_frame)
9606
+ fut = _pending_calls.pop(frame.call_id, None)
9607
+ if fut is not None and not fut.done():
9608
+ fut.set_result(result_frame)
9609
+ logger.warning(
9610
+ "_handle_tool_call: rejected %s call_id=%s — public-data tool mismatch",
9611
+ dispatch_name,
9612
+ frame.call_id[:12],
9613
+ )
9614
+ return
9615
+
9616
+ kma_analysis_choice_msg = _check_kma_analysis_tool_choice_prerequisite(
9617
+ dispatch_name,
9618
+ dispatch_args,
9619
+ _session_latest_user_utterances.get(frame.session_id, ""),
7820
9620
  )
9621
+ if kma_analysis_choice_msg is not None:
9622
+ from ummaya.ipc.frame_schema import ( # noqa: PLC0415
9623
+ ToolResultEnvelope,
9624
+ ToolResultFrame,
9625
+ )
9626
+
9627
+ envelope = ToolResultEnvelope.model_validate(
9628
+ {
9629
+ "kind": cast("Any", dispatch_name),
9630
+ "result": {
9631
+ "kind": "error",
9632
+ "reason": "kma_analysis_tool_choice_mismatch",
9633
+ "message": kma_analysis_choice_msg,
9634
+ "retryable": False,
9635
+ },
9636
+ }
9637
+ )
9638
+ result_frame = ToolResultFrame(
9639
+ session_id=frame.session_id,
9640
+ correlation_id=frame.correlation_id,
9641
+ role="backend",
9642
+ ts=_utcnow(),
9643
+ kind="tool_result",
9644
+ call_id=frame.call_id,
9645
+ envelope=envelope,
9646
+ )
9647
+ await write_frame(result_frame)
9648
+ fut = _pending_calls.pop(frame.call_id, None)
9649
+ if fut is not None and not fut.done():
9650
+ fut.set_result(result_frame)
9651
+ logger.warning(
9652
+ "_handle_tool_call: rejected %s call_id=%s — KMA analysis tool mismatch",
9653
+ dispatch_name,
9654
+ frame.call_id[:12],
9655
+ )
9656
+ return
7821
9657
 
7822
9658
  await _dispatch_primitive(
7823
9659
  frame.call_id,
@@ -7912,6 +9748,35 @@ async def run( # noqa: C901
7912
9748
  await _handle_tool_call(frame)
7913
9749
  except Exception as exc: # noqa: BLE001
7914
9750
  logger.exception("tool_call handler failed: %s", exc)
9751
+ try:
9752
+ from ummaya.ipc.frame_schema import ( # noqa: PLC0415
9753
+ ToolCallFrame,
9754
+ ToolResultEnvelope,
9755
+ ToolResultFrame,
9756
+ )
9757
+ from ummaya.primitives import PRIMITIVE_REGISTRY # noqa: PLC0415
9758
+
9759
+ if isinstance(frame, ToolCallFrame):
9760
+ error_kind = frame.name if frame.name in PRIMITIVE_REGISTRY else "find"
9761
+ await write_frame(
9762
+ ToolResultFrame(
9763
+ session_id=frame.session_id,
9764
+ correlation_id=frame.correlation_id,
9765
+ role="backend",
9766
+ ts=_utcnow(),
9767
+ kind="tool_result",
9768
+ call_id=frame.call_id,
9769
+ envelope=ToolResultEnvelope.model_validate(
9770
+ {
9771
+ "kind": error_kind,
9772
+ "error": f"tool_call handler failed: {exc}",
9773
+ "tool_id": frame.name,
9774
+ }
9775
+ ),
9776
+ )
9777
+ )
9778
+ except Exception: # noqa: BLE001
9779
+ logger.exception("failed to emit tool_call failure result")
7915
9780
 
7916
9781
  elif frame.kind == "permission_response":
7917
9782
  # Spec 1978 T047 — resolve pending permission Future.