ummaya 0.2.4 → 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 (477) hide show
  1. package/README.md +15 -2
  2. package/bin/ummaya +10 -1
  3. package/npm-shrinkwrap.json +253 -2
  4. package/package.json +5 -1
  5. package/prompts/manifest.yaml +1 -1
  6. package/prompts/system_v1.md +1 -0
  7. package/pyproject.toml +26 -2
  8. package/specs/2803-document-production-hardening/contracts/document-tools.schema.json +1043 -0
  9. package/src/ummaya/_canonical/__init__.py +2 -0
  10. package/src/ummaya/engine/engine.py +29 -132
  11. package/src/ummaya/evidence/__init__.py +21 -2
  12. package/src/ummaya/evidence/dataset_contract.py +193 -0
  13. package/src/ummaya/evidence/document_authoring_cases.py +33 -0
  14. package/src/ummaya/evidence/document_harness.py +313 -0
  15. package/src/ummaya/evidence/document_viewer_ux.py +391 -0
  16. package/src/ummaya/evidence/gates.py +70 -0
  17. package/src/ummaya/evidence/json_types.py +20 -0
  18. package/src/ummaya/evidence/models.py +88 -1
  19. package/src/ummaya/evidence/output_payload.py +89 -0
  20. package/src/ummaya/evidence/payload_documents.py +233 -0
  21. package/src/ummaya/evidence/route_contracts.py +224 -0
  22. package/src/ummaya/evidence/route_helpers.py +150 -0
  23. package/src/ummaya/evidence/runner.py +81 -212
  24. package/src/ummaya/evidence/source_provenance.py +246 -0
  25. package/src/ummaya/evidence/source_provenance_redaction.py +176 -0
  26. package/src/ummaya/evidence/tool_layer.py +39 -0
  27. package/src/ummaya/evidence/tool_layer_models.py +151 -0
  28. package/src/ummaya/ipc/adapter_manifest_emitter.py +26 -10
  29. package/src/ummaya/ipc/document_intent_normalization.py +185 -0
  30. package/src/ummaya/ipc/frame_schema.py +5 -5
  31. package/src/ummaya/ipc/route_diagnostics.py +73 -0
  32. package/src/ummaya/ipc/stdio.py +1109 -477
  33. package/src/ummaya/llm/client.py +102 -3
  34. package/src/ummaya/llm/config.py +8 -3
  35. package/src/ummaya/primitives/__init__.py +6 -2
  36. package/src/ummaya/primitives/delegation.py +1 -1
  37. package/src/ummaya/primitives/document.py +28 -0
  38. package/src/ummaya/settings.py +0 -3
  39. package/src/ummaya/tools/discovery_bridge.py +17 -1
  40. package/src/ummaya/tools/documents/__init__.py +297 -0
  41. package/src/ummaya/tools/documents/adapter_registry.py +487 -0
  42. package/src/ummaya/tools/documents/archive_container_probe.py +167 -0
  43. package/src/ummaya/tools/documents/artifact_store.py +454 -0
  44. package/src/ummaya/tools/documents/authoring.py +283 -0
  45. package/src/ummaya/tools/documents/baselines.py +114 -0
  46. package/src/ummaya/tools/documents/capability.py +331 -0
  47. package/src/ummaya/tools/documents/contracts.py +112 -0
  48. package/src/ummaya/tools/documents/conversion.py +521 -0
  49. package/src/ummaya/tools/documents/diff.py +275 -0
  50. package/src/ummaya/tools/documents/engines.py +163 -0
  51. package/src/ummaya/tools/documents/evaluation.py +291 -0
  52. package/src/ummaya/tools/documents/explicit_values.py +108 -0
  53. package/src/ummaya/tools/documents/fixtures.py +174 -0
  54. package/src/ummaya/tools/documents/format_completion_audit.py +471 -0
  55. package/src/ummaya/tools/documents/formats/__init__.py +2 -0
  56. package/src/ummaya/tools/documents/formats/archive.py +528 -0
  57. package/src/ummaya/tools/documents/formats/base.py +41 -0
  58. package/src/ummaya/tools/documents/formats/code_file.py +211 -0
  59. package/src/ummaya/tools/documents/formats/data_file.py +272 -0
  60. package/src/ummaya/tools/documents/formats/hwp.py +284 -0
  61. package/src/ummaya/tools/documents/formats/hwpx.py +1837 -0
  62. package/src/ummaya/tools/documents/formats/odf.py +435 -0
  63. package/src/ummaya/tools/documents/formats/ooxml.py +1030 -0
  64. package/src/ummaya/tools/documents/formats/passive.py +766 -0
  65. package/src/ummaya/tools/documents/formats/pdf.py +702 -0
  66. package/src/ummaya/tools/documents/formats/text_web.py +268 -0
  67. package/src/ummaya/tools/documents/hwp_conversion_probe.py +178 -0
  68. package/src/ummaya/tools/documents/hwp_direct_candidate.py +141 -0
  69. package/src/ummaya/tools/documents/inspection.py +289 -0
  70. package/src/ummaya/tools/documents/intake.py +1079 -0
  71. package/src/ummaya/tools/documents/legacy_office_promotion_probe.py +366 -0
  72. package/src/ummaya/tools/documents/models.py +1598 -0
  73. package/src/ummaya/tools/documents/odf_promotion_probe.py +167 -0
  74. package/src/ummaya/tools/documents/orchestrator.py +96 -0
  75. package/src/ummaya/tools/documents/passive_capability_probe.py +251 -0
  76. package/src/ummaya/tools/documents/patch.py +170 -0
  77. package/src/ummaya/tools/documents/pdfa_conformance.py +284 -0
  78. package/src/ummaya/tools/documents/pdfa_promotion_probe.py +198 -0
  79. package/src/ummaya/tools/documents/permissions.py +110 -0
  80. package/src/ummaya/tools/documents/planner.py +616 -0
  81. package/src/ummaya/tools/documents/registry.py +2733 -0
  82. package/src/ummaya/tools/documents/render.py +978 -0
  83. package/src/ummaya/tools/documents/render_comparison.py +113 -0
  84. package/src/ummaya/tools/documents/render_comparison_models.py +74 -0
  85. package/src/ummaya/tools/documents/render_comparison_regions.py +73 -0
  86. package/src/ummaya/tools/documents/render_comparison_style.py +161 -0
  87. package/src/ummaya/tools/documents/reread.py +157 -0
  88. package/src/ummaya/tools/documents/runtime_authoring.py +244 -0
  89. package/src/ummaya/tools/documents/runtime_authoring_bundle.py +76 -0
  90. package/src/ummaya/tools/documents/scorecard.py +184 -0
  91. package/src/ummaya/tools/documents/socratic_planner.py +193 -0
  92. package/src/ummaya/tools/documents/style.py +48 -0
  93. package/src/ummaya/tools/documents/tool_defs.py +523 -0
  94. package/src/ummaya/tools/documents/validate.py +347 -0
  95. package/src/ummaya/tools/executor.py +29 -0
  96. package/src/ummaya/tools/live_proxy.py +0 -3
  97. package/src/ummaya/tools/models.py +5 -1
  98. package/src/ummaya/tools/register_all.py +8 -0
  99. package/src/ummaya/tools/registry.py +10 -1
  100. package/src/ummaya/tools/routing/__init__.py +59 -0
  101. package/src/ummaya/tools/routing/builder.py +105 -0
  102. package/src/ummaya/tools/routing/cards.py +29 -0
  103. package/src/ummaya/tools/routing/decision_service.py +534 -0
  104. package/src/ummaya/tools/routing/decision_types.py +74 -0
  105. package/src/ummaya/tools/routing/feasibility.py +122 -0
  106. package/src/ummaya/tools/routing/intent.py +17 -0
  107. package/src/ummaya/tools/routing/intent_extractor.py +207 -0
  108. package/src/ummaya/tools/routing/intent_patterns.py +160 -0
  109. package/src/ummaya/tools/routing/intent_public_data.py +150 -0
  110. package/src/ummaya/tools/routing/intent_types.py +48 -0
  111. package/src/ummaya/tools/routing/lint.py +78 -0
  112. package/src/ummaya/tools/routing/metadata.py +174 -0
  113. package/src/ummaya/tools/routing/projection.py +340 -0
  114. package/src/ummaya/tools/routing/retrieval_policy.py +629 -0
  115. package/src/ummaya/tools/routing/schema.py +81 -0
  116. package/src/ummaya/tools/routing/types.py +96 -0
  117. package/src/ummaya/tools/routing_index.py +2 -2
  118. package/src/ummaya/tools/search.py +34 -746
  119. package/tests/fixtures/documents/public_forms/baselines.yaml +113 -0
  120. package/tui/package.json +1 -1
  121. package/tui/src/.cc-byte-identical-whitelist.yaml +266 -0
  122. package/tui/src/QueryEngine.ts +12 -8
  123. package/tui/src/bridge/inboundAttachments.ts +3 -3
  124. package/tui/src/cli/handlers/auth.ts +3 -12
  125. package/tui/src/cli/print.ts +7 -7
  126. package/tui/src/commands/insights.ts +1 -1
  127. package/tui/src/commands/install-github-app/types.ts +8 -30
  128. package/tui/src/commands/plugin/types.ts +6 -28
  129. package/tui/src/commands/plugin/unifiedTypes.ts +4 -26
  130. package/tui/src/commands/rename/generateSessionName.ts +1 -1
  131. package/tui/src/components/Feedback.tsx +1 -1
  132. package/tui/src/components/LogoV2/EmergencyTip.tsx +11 -2
  133. package/tui/src/components/LogoV2/WelcomeV2.tsx +1 -3
  134. package/tui/src/components/ScrollKeybindingHandler.tsx +6 -6
  135. package/tui/src/components/Spinner/types.ts +6 -28
  136. package/tui/src/components/agents/generateAgent.ts +1 -1
  137. package/tui/src/components/agents/new-agent-creation/types.ts +4 -26
  138. package/tui/src/components/config/EnvSecretIsolatedEditor.tsx +1 -1
  139. package/tui/src/components/mcp/types.ts +16 -38
  140. package/tui/src/components/messages/AssistantToolUseMessage.tsx +3 -2
  141. package/tui/src/components/messages/UserCrossSessionMessage.ts +16 -4
  142. package/tui/src/components/messages/UserForkBoilerplateMessage.ts +16 -4
  143. package/tui/src/components/messages/UserGitHubWebhookMessage.ts +16 -4
  144. package/tui/src/components/messages/UserToolResultMessage/utils.tsx +3 -2
  145. package/tui/src/components/permissions/MonitorPermissionRequest/MonitorPermissionRequest.ts +9 -4
  146. package/tui/src/components/permissions/ReviewArtifactPermissionRequest/ReviewArtifactPermissionRequest.ts +9 -4
  147. package/tui/src/components/primitive/DocumentSocraticReviewBlock.tsx +129 -0
  148. package/tui/src/components/primitive/DocumentToolResultCard.tsx +224 -0
  149. package/tui/src/components/primitive/documentSocraticReview.ts +215 -0
  150. package/tui/src/components/primitive/index.tsx +43 -1
  151. package/tui/src/components/primitive/types.ts +137 -0
  152. package/tui/src/components/ui/option.ts +4 -26
  153. package/tui/src/constants/common.ts +0 -2
  154. package/tui/src/constants/prompts.ts +4 -3
  155. package/tui/src/constants/querySource.ts +4 -26
  156. package/tui/src/entrypoints/sdk/controlTypes.ts +26 -48
  157. package/tui/src/entrypoints/sdk/coreTypes.generated.ts +3 -25
  158. package/tui/src/entrypoints/sdk/runtimeTypes.ts +38 -60
  159. package/tui/src/entrypoints/sdk/sdkUtilityTypes.ts +4 -26
  160. package/tui/src/entrypoints/sdk/settingsTypes.generated.ts +3 -25
  161. package/tui/src/entrypoints/sdk/toolTypes.ts +3 -25
  162. package/tui/src/hooks/toolPermission/handlers/interactiveHandler.ts +10 -0
  163. package/tui/src/hooks/useApiKeyVerification.ts +1 -1
  164. package/tui/src/hooks/useVirtualScroll.ts +1 -1
  165. package/tui/src/ink/ink.tsx +33 -14
  166. package/tui/src/ink/reconciler.ts +2 -3
  167. package/tui/src/ink/render-to-screen.ts +30 -10
  168. package/tui/src/ipc/bridge.ts +62 -15
  169. package/tui/src/ipc/bridgeSingleton.ts +5 -1
  170. package/tui/src/ipc/codec.ts +3 -3
  171. package/tui/src/ipc/frames.generated.ts +12 -12
  172. package/tui/src/ipc/llmClient.ts +151 -27
  173. package/tui/src/ipc/schema/frame.schema.json +1 -1
  174. package/tui/src/keybindings/defaultBindings.ts +4 -0
  175. package/tui/src/main.tsx +29 -11
  176. package/tui/src/native-ts/file-index/index.ts +33 -3
  177. package/tui/src/observability/surface.ts +2 -2
  178. package/tui/src/probes/toolRegistryProbe.tsx +3 -1
  179. package/tui/src/projectOnboardingState.ts +7 -6
  180. package/tui/src/query/chatMessageTypes.ts +18 -0
  181. package/tui/src/query/chatMessagesBuilder.ts +1 -1
  182. package/tui/src/query/deps.ts +1 -1
  183. package/tui/src/query/messageGuards.ts +106 -0
  184. package/tui/src/query/publicDataTerminalRepair.ts +384 -0
  185. package/tui/src/query/run.ts +1075 -0
  186. package/tui/src/query/supportBoundary.ts +168 -0
  187. package/tui/src/query/toolResultErrors.ts +103 -0
  188. package/tui/src/query/toolRunner.ts +687 -0
  189. package/tui/src/query/unavailableToolRepair.ts +118 -0
  190. package/tui/src/query.ts +9 -2186
  191. package/tui/src/screens/REPL.tsx +40 -29
  192. package/tui/src/services/api/adapterManifest.ts +4 -0
  193. package/tui/src/services/api/backendChat/events.ts +117 -0
  194. package/tui/src/services/api/backendChat/finalMessage.ts +40 -0
  195. package/tui/src/services/api/backendChat/frame.ts +9 -0
  196. package/tui/src/services/api/backendChat/streaming.ts +430 -0
  197. package/tui/src/services/api/backendChat/types.ts +62 -0
  198. package/tui/src/services/api/backendChat.ts +1 -0
  199. package/tui/src/services/api/client.ts +65 -2
  200. package/tui/src/services/api/errorUtils.ts +5 -5
  201. package/tui/src/services/api/errors.ts +1 -1
  202. package/tui/src/services/api/logging.ts +1 -1
  203. package/tui/src/services/api/ummaya/evidence.ts +194 -0
  204. package/tui/src/services/api/ummaya/messages.ts +255 -0
  205. package/tui/src/services/api/ummaya/nonStreaming.ts +66 -0
  206. package/tui/src/services/api/ummaya/provider.ts +200 -0
  207. package/tui/src/services/api/ummaya/reasoning.ts +24 -0
  208. package/tui/src/services/api/ummaya/request.ts +200 -0
  209. package/tui/src/services/api/ummaya/selectionContext.ts +240 -0
  210. package/tui/src/services/api/ummaya/streaming.ts +365 -0
  211. package/tui/src/services/api/ummaya/streamingPayload.ts +129 -0
  212. package/tui/src/services/api/ummaya/streamingReader.ts +40 -0
  213. package/tui/src/services/api/ummaya/toolSelection.ts +217 -0
  214. package/tui/src/services/api/ummaya/types.ts +110 -0
  215. package/tui/src/services/api/ummaya/usage.ts +30 -0
  216. package/tui/src/services/api/ummaya.ts +26 -418
  217. package/tui/src/services/api/withRetry.ts +1 -1
  218. package/tui/src/services/awaySummary.ts +2 -2
  219. package/tui/src/services/claudeAiLimits.ts +1 -1
  220. package/tui/src/services/compact/autoCompact.ts +1 -1
  221. package/tui/src/services/compact/compact.ts +1 -1
  222. package/tui/src/services/lsp/types.ts +8 -30
  223. package/tui/src/services/tips/types.ts +6 -28
  224. package/tui/src/services/tokenEstimation.ts +1 -1
  225. package/tui/src/services/toolRegistry/bootGuard.ts +5 -5
  226. package/tui/src/services/toolUseSummary/toolUseSummaryGenerator.ts +1 -1
  227. package/tui/src/services/tools/toolExecution.ts +94 -1
  228. package/tui/src/store/pendingPermissionSlot.ts +1 -1
  229. package/tui/src/store/session-store.ts +10 -36
  230. package/tui/src/stubs/any-stub.ts +15 -10
  231. package/tui/src/stubs/color-diff-napi.ts +37 -23
  232. package/tui/src/stubs/globals.d.ts +3 -3
  233. package/tui/src/stubs/macro-preload.ts +23 -12
  234. package/tui/src/tools/AdapterTool/AdapterTool.ts +1207 -714
  235. package/tui/src/tools/AdapterTool/routeDiagnostics.ts +75 -0
  236. package/tui/src/tools/AgentTool/AgentTool.tsx +84 -1371
  237. package/tui/src/tools/AgentTool/agentToolHandoff.ts +114 -0
  238. package/tui/src/tools/AgentTool/agentToolPartialResult.ts +16 -0
  239. package/tui/src/tools/AgentTool/agentToolProgress.ts +32 -0
  240. package/tui/src/tools/AgentTool/agentToolResolver.ts +161 -0
  241. package/tui/src/tools/AgentTool/agentToolResult.ts +163 -0
  242. package/tui/src/tools/AgentTool/agentToolUtils.ts +14 -686
  243. package/tui/src/tools/AgentTool/asyncAgentLifecycle.ts +208 -0
  244. package/tui/src/tools/AgentTool/asyncLifecycle.ts +153 -0
  245. package/tui/src/tools/AgentTool/backgroundedCompletion.ts +126 -0
  246. package/tui/src/tools/AgentTool/backgroundedLifecycle.ts +174 -0
  247. package/tui/src/tools/AgentTool/foregroundBackground.ts +83 -0
  248. package/tui/src/tools/AgentTool/foregroundDrain.tsx +133 -0
  249. package/tui/src/tools/AgentTool/foregroundFinalize.ts +98 -0
  250. package/tui/src/tools/AgentTool/foregroundLifecycle.tsx +237 -0
  251. package/tui/src/tools/AgentTool/foregroundProgress.tsx +169 -0
  252. package/tui/src/tools/AgentTool/foregroundTask.ts +89 -0
  253. package/tui/src/tools/AgentTool/forkSubagent.ts +1 -12
  254. package/tui/src/tools/AgentTool/forkSubagentGate.ts +34 -0
  255. package/tui/src/tools/AgentTool/launchRouting.ts +203 -0
  256. package/tui/src/tools/AgentTool/lifecycle.ts +244 -0
  257. package/tui/src/tools/AgentTool/mcpRouting.ts +73 -0
  258. package/tui/src/tools/AgentTool/orchestrationSupport.ts +70 -0
  259. package/tui/src/tools/AgentTool/permissions.ts +39 -0
  260. package/tui/src/tools/AgentTool/promptSetup.ts +181 -0
  261. package/tui/src/tools/AgentTool/remoteRouting.ts +62 -0
  262. package/tui/src/tools/AgentTool/resultMapping.ts +116 -0
  263. package/tui/src/tools/AgentTool/resumeAgent.ts +39 -107
  264. package/tui/src/tools/AgentTool/resumeAgentHelpers.ts +140 -0
  265. package/tui/src/tools/AgentTool/runAgent.ts +1 -1
  266. package/tui/src/tools/AgentTool/runtimeConfig.ts +57 -0
  267. package/tui/src/tools/AgentTool/schemas.ts +196 -0
  268. package/tui/src/tools/AgentTool/sourceVerificationPropagation.ts +263 -0
  269. package/tui/src/tools/AgentTool/worktreeLifecycle.ts +105 -0
  270. package/tui/src/tools/AskUserQuestionTool/AskUserQuestionTool.tsx +174 -202
  271. package/tui/src/tools/BashTool/BashTool.tsx +71 -1072
  272. package/tui/src/tools/BashTool/bashCommandHelpers.ts +12 -12
  273. package/tui/src/tools/BashTool/bashPermissions/astPreflight.ts +173 -0
  274. package/tui/src/tools/BashTool/bashPermissions/classifierChecks.ts +199 -0
  275. package/tui/src/tools/BashTool/bashPermissions/compoundGuards.ts +53 -0
  276. package/tui/src/tools/BashTool/bashPermissions/constants.ts +99 -0
  277. package/tui/src/tools/BashTool/bashPermissions/index.ts +38 -0
  278. package/tui/src/tools/BashTool/bashPermissions/legacyMisparsing.ts +62 -0
  279. package/tui/src/tools/BashTool/bashPermissions/main.ts +135 -0
  280. package/tui/src/tools/BashTool/bashPermissions/normalizedCommands.ts +33 -0
  281. package/tui/src/tools/BashTool/bashPermissions/operatorFlow.ts +98 -0
  282. package/tui/src/tools/BashTool/bashPermissions/permissionChecks.ts +200 -0
  283. package/tui/src/tools/BashTool/bashPermissions/prefixSuggestions.ts +88 -0
  284. package/tui/src/tools/BashTool/bashPermissions/promptClassifierRules.ts +125 -0
  285. package/tui/src/tools/BashTool/bashPermissions/ruleDelegates.ts +19 -0
  286. package/tui/src/tools/BashTool/bashPermissions/ruleMatching.ts +145 -0
  287. package/tui/src/tools/BashTool/bashPermissions/sandboxAutoAllow.ts +75 -0
  288. package/tui/src/tools/BashTool/bashPermissions/subcommandFlow.ts +205 -0
  289. package/tui/src/tools/BashTool/bashPermissions/subcommandGuards.ts +73 -0
  290. package/tui/src/tools/BashTool/bashPermissions/subcommandResultHelpers.ts +116 -0
  291. package/tui/src/tools/BashTool/bashPermissions/types.ts +26 -0
  292. package/tui/src/tools/BashTool/bashPermissions/wrapperStripping.ts +139 -0
  293. package/tui/src/tools/BashTool/bashPermissions.ts +26 -2621
  294. package/tui/src/tools/BashTool/call.ts +202 -0
  295. package/tui/src/tools/BashTool/callLoader.ts +35 -0
  296. package/tui/src/tools/BashTool/commandClassification.ts +151 -0
  297. package/tui/src/tools/BashTool/commandClassificationLoader.ts +40 -0
  298. package/tui/src/tools/BashTool/cwdReset.ts +33 -0
  299. package/tui/src/tools/BashTool/lineTruncation.ts +11 -0
  300. package/tui/src/tools/BashTool/modeValidation.ts +13 -1
  301. package/tui/src/tools/BashTool/outputPersistence.ts +42 -0
  302. package/tui/src/tools/BashTool/permissionClassification.ts +66 -0
  303. package/tui/src/tools/BashTool/permissionLoader.ts +44 -0
  304. package/tui/src/tools/BashTool/resultLoader.ts +29 -0
  305. package/tui/src/tools/BashTool/resultMapping.ts +83 -0
  306. package/tui/src/tools/BashTool/sandboxPolicy.ts +79 -0
  307. package/tui/src/tools/BashTool/schemas.ts +65 -0
  308. package/tui/src/tools/BashTool/sedEditExecution.ts +59 -0
  309. package/tui/src/tools/BashTool/shellExecution.tsx +245 -0
  310. package/tui/src/tools/BashTool/shellOutputUtils.ts +85 -0
  311. package/tui/src/tools/BashTool/shellPermissionGauntlet.ts +97 -0
  312. package/tui/src/tools/BashTool/uiLoader.ts +37 -0
  313. package/tui/src/tools/BriefTool/upload.ts +1 -1
  314. package/tui/src/tools/CalculatorTool/parser.ts +2 -2
  315. package/tui/src/tools/DocumentPrimitive/DocumentPrimitive.ts +262 -0
  316. package/tui/src/tools/DocumentPrimitive/dispatchNormalization.ts +270 -0
  317. package/tui/src/tools/DocumentPrimitive/documentDestinationPath.ts +18 -0
  318. package/tui/src/tools/DocumentPrimitive/documentMutationGuard.ts +22 -0
  319. package/tui/src/tools/DocumentPrimitive/documentPatchNormalization.ts +248 -0
  320. package/tui/src/tools/DocumentPrimitive/documentSourceVerification.ts +245 -0
  321. package/tui/src/tools/DocumentPrimitive/documentSourceVerificationFields.ts +103 -0
  322. package/tui/src/tools/DocumentPrimitive/modelVisibleOutput.ts +40 -0
  323. package/tui/src/tools/DocumentPrimitive/prompt.ts +35 -0
  324. package/tui/src/tools/FileEditTool/FileEditTool.ts +9 -507
  325. package/tui/src/tools/FileEditTool/call.ts +228 -0
  326. package/tui/src/tools/FileEditTool/validateInput.ts +196 -0
  327. package/tui/src/tools/FileReadTool/imageProcessor.ts +13 -0
  328. package/tui/src/tools/FileWriteTool/FileWriteTool.ts +7 -300
  329. package/tui/src/tools/FileWriteTool/call.ts +223 -0
  330. package/tui/src/tools/FileWriteTool/validateInput.ts +80 -0
  331. package/tui/src/tools/ListMcpResourcesTool/ListMcpResourcesTool.ts +19 -3
  332. package/tui/src/tools/LookupPrimitive/LookupPrimitive.ts +25 -32
  333. package/tui/src/tools/LookupPrimitive/prompt.ts +0 -2
  334. package/tui/src/tools/MCPTool/trustPolicy.ts +118 -0
  335. package/tui/src/tools/McpAuthTool/McpAuthTool.ts +21 -3
  336. package/tui/src/tools/NotebookEditTool/NotebookEditTool.ts +7 -326
  337. package/tui/src/tools/NotebookEditTool/call.ts +254 -0
  338. package/tui/src/tools/NotebookEditTool/notebookModel.ts +51 -0
  339. package/tui/src/tools/NotebookEditTool/validateInput.ts +142 -0
  340. package/tui/src/tools/PowerShellTool/PowerShellTool.tsx +46 -937
  341. package/tui/src/tools/PowerShellTool/acceptEditsCommandValidation.ts +162 -0
  342. package/tui/src/tools/PowerShellTool/call.ts +179 -0
  343. package/tui/src/tools/PowerShellTool/callLoader.ts +37 -0
  344. package/tui/src/tools/PowerShellTool/commandClassification.ts +86 -0
  345. package/tui/src/tools/PowerShellTool/modeValidation.ts +25 -332
  346. package/tui/src/tools/PowerShellTool/outputPersistence.ts +42 -0
  347. package/tui/src/tools/PowerShellTool/permissionClassification.ts +28 -0
  348. package/tui/src/tools/PowerShellTool/resultLoader.ts +31 -0
  349. package/tui/src/tools/PowerShellTool/resultMapping.ts +75 -0
  350. package/tui/src/tools/PowerShellTool/schemas.ts +40 -0
  351. package/tui/src/tools/PowerShellTool/shellExecution.tsx +258 -0
  352. package/tui/src/tools/PowerShellTool/symlinkModeValidation.ts +44 -0
  353. package/tui/src/tools/PowerShellTool/uiLoader.ts +37 -0
  354. package/tui/src/tools/PowerShellTool/validation.ts +39 -0
  355. package/tui/src/tools/ReadMcpResourceTool/ReadMcpResourceTool.ts +19 -3
  356. package/tui/src/tools/ResolveLocationPrimitive/ResolveLocationPrimitive.ts +1 -11
  357. package/tui/src/tools/ResolveLocationPrimitive/prompt.ts +2 -6
  358. package/tui/src/tools/SkillTool/SkillTool.ts +2 -2
  359. package/tui/src/tools/SubmitPrimitive/SubmitPrimitive.ts +27 -10
  360. package/tui/src/tools/TaskCreateTool/TaskCreateTool.ts +16 -2
  361. package/tui/src/tools/TaskGetTool/TaskGetTool.ts +23 -3
  362. package/tui/src/tools/TaskListTool/TaskListTool.ts +22 -4
  363. package/tui/src/tools/TaskOutputTool/TaskOutputTool.tsx +46 -547
  364. package/tui/src/tools/TaskOutputTool/lookup.ts +216 -0
  365. package/tui/src/tools/TaskOutputTool/render.tsx +257 -0
  366. package/tui/src/tools/TaskOutputTool/schemas.ts +55 -0
  367. package/tui/src/tools/TaskOutputTool/serialization.ts +36 -0
  368. package/tui/src/tools/TaskStopTool/TaskStopTool.ts +10 -0
  369. package/tui/src/tools/TaskUpdateTool/TaskUpdateTool.ts +14 -364
  370. package/tui/src/tools/TaskUpdateTool/completion.ts +62 -0
  371. package/tui/src/tools/TaskUpdateTool/schemas.ts +62 -0
  372. package/tui/src/tools/TaskUpdateTool/serialization.ts +46 -0
  373. package/tui/src/tools/TaskUpdateTool/statusUpdate.ts +247 -0
  374. package/tui/src/tools/TodoWriteTool/TodoWriteTool.ts +21 -2
  375. package/tui/src/tools/ToolSearchTool/ToolSearchTool.ts +21 -302
  376. package/tui/src/tools/ToolSearchTool/ccSupportTools.ts +223 -0
  377. package/tui/src/tools/ToolSearchTool/descriptionCache.ts +50 -0
  378. package/tui/src/tools/ToolSearchTool/keywordSearch.ts +216 -0
  379. package/tui/src/tools/ToolSearchTool/prompt.ts +10 -4
  380. package/tui/src/tools/ToolSearchTool/resultMapping.ts +30 -0
  381. package/tui/src/tools/ToolSearchTool/schemas.ts +30 -0
  382. package/tui/src/tools/ToolSearchTool/searchPool.ts +47 -0
  383. package/tui/src/tools/ToolSearchTool/supportIntentHints.ts +140 -0
  384. package/tui/src/tools/TranslateTool/TranslateTool.ts +1 -1
  385. package/tui/src/tools/VerifyPrimitive/VerifyPrimitive.ts +2 -1
  386. package/tui/src/tools/WebFetchTool/WebFetchTool.ts +43 -138
  387. package/tui/src/tools/WebFetchTool/call.ts +227 -0
  388. package/tui/src/tools/WebFetchTool/resolvedAddressSafety.ts +78 -0
  389. package/tui/src/tools/WebFetchTool/sourceVerification.ts +204 -0
  390. package/tui/src/tools/WebFetchTool/types.ts +23 -0
  391. package/tui/src/tools/WebFetchTool/urlSafety.ts +181 -0
  392. package/tui/src/tools/WebFetchTool/utils.ts +1 -1
  393. package/tui/src/tools/WebSearchTool/UI.tsx +0 -1
  394. package/tui/src/tools/WebSearchTool/WebSearchTool.ts +9 -313
  395. package/tui/src/tools/WebSearchTool/call.ts +33 -0
  396. package/tui/src/tools/WebSearchTool/responseMapping.ts +190 -0
  397. package/tui/src/tools/WebSearchTool/resultBlock.ts +47 -0
  398. package/tui/src/tools/WebSearchTool/schemas.ts +47 -0
  399. package/tui/src/tools/WebSearchTool/toolSchema.ts +12 -0
  400. package/tui/src/tools/WorkspaceToolAdapter/WorkspaceToolAdapter.ts +79 -0
  401. package/tui/src/tools/WorkspaceToolAdapter/allowedRootPolicy.ts +85 -0
  402. package/tui/src/tools/WorkspaceToolAdapter/documentFormatGuards.ts +73 -0
  403. package/tui/src/tools/WorkspaceToolAdapter/inputNormalization.ts +105 -0
  404. package/tui/src/tools/WorkspaceToolAdapter/mcpExposurePolicy.ts +64 -0
  405. package/tui/src/tools/WorkspaceToolAdapter/toolDefFactory.ts +215 -0
  406. package/tui/src/tools/WorkspaceToolAdapter/toolNames.ts +6 -0
  407. package/tui/src/tools/WorkspaceToolAdapter/workspacePolicy.ts +15 -0
  408. package/tui/src/tools/_shared/dispatchPrimitive.ts +6 -6
  409. package/tui/src/tools/_shared/documentChangeToPatch.ts +125 -0
  410. package/tui/src/tools/_shared/documentDispatchArguments.ts +87 -0
  411. package/tui/src/tools/_shared/documentPrimitiveTimeout.ts +13 -0
  412. package/tui/src/tools/_shared/documentToolResultRender.ts +98 -0
  413. package/tui/src/tools/_shared/pendingCallRegistry.ts +1 -6
  414. package/tui/src/tools/_shared/rootPrimitiveInput.ts +1 -0
  415. package/tui/src/tools/_shared/toolChoiceRepair/documentCompletionPatterns.ts +58 -0
  416. package/tui/src/tools/_shared/toolChoiceRepair/documentCompletionPrompt.ts +271 -0
  417. package/tui/src/tools/_shared/toolChoiceRepair/documentRepair.ts +452 -0
  418. package/tui/src/tools/_shared/toolChoiceRepair/messageAccess.ts +80 -0
  419. package/tui/src/tools/_shared/toolChoiceRepair/publicDataRepair.ts +92 -0
  420. package/tui/src/tools/_shared/toolChoiceRepair/supportRepair.ts +135 -0
  421. package/tui/src/tools/_shared/toolChoiceRepair.ts +55 -860
  422. package/tui/src/tools/shared/mockDisclaimer.ts +1 -1
  423. package/tui/src/tools.ts +39 -190
  424. package/tui/src/types/fileSuggestion.ts +4 -26
  425. package/tui/src/types/generated/events_mono/claude_code/v1/claude_code_internal_event.ts +186 -148
  426. package/tui/src/types/generated/events_mono/common/v1/auth.ts +25 -11
  427. package/tui/src/types/generated/events_mono/growthbook/v1/growthbook_experiment_event.ts +47 -30
  428. package/tui/src/types/generated/google/protobuf/timestamp.ts +21 -7
  429. package/tui/src/types/message.ts +80 -102
  430. package/tui/src/types/messageQueueTypes.ts +6 -28
  431. package/tui/src/types/notebook.ts +16 -38
  432. package/tui/src/types/statusLine.ts +4 -26
  433. package/tui/src/types/tools.ts +24 -46
  434. package/tui/src/types/utils.ts +6 -28
  435. package/tui/src/upstreamproxy/relay.ts +7 -3
  436. package/tui/src/upstreamproxy/upstreamproxy.ts +1 -1
  437. package/tui/src/utils/assistantMessageFactories.ts +9 -3
  438. package/tui/src/utils/auth.ts +129 -139
  439. package/tui/src/utils/bash/ast.ts +23 -23
  440. package/tui/src/utils/bash/bashParser.ts +5 -5
  441. package/tui/src/utils/billing.ts +1 -1
  442. package/tui/src/utils/collapseReadSearch.ts +3 -3
  443. package/tui/src/utils/cronTasks.ts +1 -1
  444. package/tui/src/utils/execFileNoThrow.ts +1 -1
  445. package/tui/src/utils/filePersistence/types.ts +16 -38
  446. package/tui/src/utils/forkedAgent.ts +1 -1
  447. package/tui/src/utils/gracefulShutdown.ts +4 -4
  448. package/tui/src/utils/heapDumpService.ts +12 -8
  449. package/tui/src/utils/hooks/apiQueryHookHelper.ts +1 -1
  450. package/tui/src/utils/hooks/execPromptHook.ts +1 -1
  451. package/tui/src/utils/hooks/skillImprovement.ts +1 -1
  452. package/tui/src/utils/mcp/dateTimeParser.ts +1 -1
  453. package/tui/src/utils/messages.ts +18 -0
  454. package/tui/src/utils/migrateSessions.ts +3 -3
  455. package/tui/src/utils/model/model.ts +6 -6
  456. package/tui/src/utils/permissions/yoloClassifier.ts +1 -1
  457. package/tui/src/utils/plugins/headlessPluginInstall.ts +1 -1
  458. package/tui/src/utils/plugins/mcpPluginIntegration.ts +1 -1
  459. package/tui/src/utils/plugins/mcpbHandler.ts +1 -1
  460. package/tui/src/utils/plugins/pluginLoader.ts +8 -8
  461. package/tui/src/utils/protectedNamespace.ts +5 -3
  462. package/tui/src/utils/rawJsonToolCall.ts +242 -0
  463. package/tui/src/utils/ripgrep.ts +16 -7
  464. package/tui/src/utils/sessionTitle.ts +1 -1
  465. package/tui/src/utils/settings/permissionValidation.ts +14 -2
  466. package/tui/src/utils/shell/prefix.ts +1 -1
  467. package/tui/src/utils/sideQuery.ts +1 -1
  468. package/tui/src/utils/systemThemeWatcher.ts +13 -3
  469. package/tui/src/utils/teleport.tsx +1 -1
  470. package/uv.lock +400 -14
  471. package/tui/src/services/api/claude.ts +0 -3540
  472. package/tui/src/tools/_shared/directPublicDataGuard.ts +0 -362
  473. package/tui/src/tools/_shared/kmaAnalysisGuard.ts +0 -197
  474. package/tui/src/tools/_shared/kmaAviationGuard.ts +0 -70
  475. package/tui/src/tools/_shared/nmcAedGuard.ts +0 -234
  476. package/tui/src/tools/_shared/protectedCheckGuard.ts +0 -207
  477. package/tui/src/tools/_shared/textToolCallGuard.ts +0 -91
@@ -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",
@@ -266,6 +296,11 @@ def _should_append_tui_tool_to_llm_tools(
266
296
  return True
267
297
 
268
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
+
269
304
  def _normalize_root_primitive_adapter_envelope(
270
305
  fname: str,
271
306
  args_obj: dict[str, object],
@@ -871,6 +906,80 @@ _KMA_ANALYSIS_MAP_USER_QUERY_RE: Final = re.compile(
871
906
  r"(일기도|분석일기도|지도\s*자료|비구름|바람\s*흐름|synoptic|weather\s*chart)",
872
907
  re.IGNORECASE,
873
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
+ )
874
983
  _KMA_ANALYSIS_TOOL_IDS: Final[frozenset[str]] = frozenset(
875
984
  {
876
985
  "kma_apihub_url_high_resolution_grid_point",
@@ -883,7 +992,8 @@ _PPS_BID_USER_QUERY_RE: Final = re.compile(
883
992
  re.IGNORECASE,
884
993
  )
885
994
  _AIRKOREA_USER_QUERY_RE: Final = re.compile(
886
- r"(미세먼지|초미세먼지|대기질|대기오염|마스크|pm\s*2\.?5|pm\s*10|air\s*korea|airkorea)",
995
+ r"(미세먼지|초미세먼지|초미세|대기질|대기오염|공기질|마스크|"
996
+ r"pm\s*2\.?5|pm\s*10|air\s*korea|airkorea|air\s*quality|airquality)",
887
997
  re.IGNORECASE,
888
998
  )
889
999
  _TAGO_BUS_USER_QUERY_RE: Final = re.compile(
@@ -911,29 +1021,24 @@ def _initial_concrete_tool_choice_for_query(
911
1021
  ) -> str | None:
912
1022
  """Force direct first calls only for unambiguous single-adapter lookups."""
913
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
+
914
1034
  if (
915
- _KMA_ANALYSIS_MAP_USER_QUERY_RE.search(user_query)
916
- and _KMA_ANALYSIS_CHART_TOOL_ID in available
1035
+ _DOCUMENT_ARTIFACT_ID_RE.search(user_query)
1036
+ and _DOCUMENT_REVIEW_REQUEST_RE.search(user_query)
1037
+ and "document" in available
917
1038
  ):
918
- return _KMA_ANALYSIS_CHART_TOOL_ID
919
- if _KMA_AIRPORT_PLACE_RE.search(user_query) and _KMA_AIRPORT_AVIATION_RE.search(user_query):
920
- if (
921
- re.search(r"(김포|gimpo|rkss)", user_query, re.IGNORECASE)
922
- and re.search(r"(amos|활주로|rvr|runway|매분)", user_query, re.IGNORECASE)
923
- and "kma_apihub_url_air_amos_minute" in available
924
- ):
925
- return "kma_apihub_url_air_amos_minute"
926
- if "kma_apihub_url_air_metar_decoded" in available:
927
- return "kma_apihub_url_air_metar_decoded"
928
- if _PPS_BID_USER_QUERY_RE.search(user_query) and _PPS_BID_TOOL_ID in available:
929
- return _PPS_BID_TOOL_ID
930
- if _AIRKOREA_USER_QUERY_RE.search(user_query) and _AIRKOREA_TOOL_ID in available:
931
- return _AIRKOREA_TOOL_ID
932
- if _TAGO_BUS_USER_QUERY_RE.search(user_query):
933
- if _TAGO_ROUTE_NO_RE.search(user_query) and "tago_bus_route_search" in available:
934
- return "tago_bus_route_search"
935
- if "tago_bus_station_search" in available:
936
- return "tago_bus_station_search"
1039
+ return "document"
1040
+ if is_document_harness_query(user_query) and "document" in available:
1041
+ return "document"
937
1042
  return None
938
1043
 
939
1044
 
@@ -1036,6 +1141,7 @@ def _conversation_has_successful_any_primitive_result(llm_messages: list[Any]) -
1036
1141
  or _conversation_has_successful_primitive_any_tool(llm_messages, primitive="locate")
1037
1142
  or _conversation_has_successful_primitive_any_tool(llm_messages, primitive="check")
1038
1143
  or _conversation_has_successful_primitive_any_tool(llm_messages, primitive="send")
1144
+ or _conversation_has_successful_primitive_any_tool(llm_messages, primitive="document")
1039
1145
  )
1040
1146
 
1041
1147
 
@@ -1653,6 +1759,80 @@ def _payload_dict_is_error_like(payload: dict[str, object]) -> bool:
1653
1759
  return isinstance(error, str) and bool(error)
1654
1760
 
1655
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
+
1656
1836
  def _tool_result_payload_is_error(payload: object) -> bool:
1657
1837
  """Return True for structured tool-result payloads that are errors."""
1658
1838
  if not isinstance(payload, dict):
@@ -1665,6 +1845,224 @@ def _tool_result_payload_is_error(payload: object) -> bool:
1665
1845
  )
1666
1846
 
1667
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
+
1668
2066
  def _lookup_call_ids_for_tool(
1669
2067
  llm_messages: list[Any],
1670
2068
  *,
@@ -1708,24 +2106,10 @@ def _tool_result_payload_for_call(
1708
2106
  matching_call_ids: set[str],
1709
2107
  ) -> object | None:
1710
2108
  """Parse a lookup tool-result message when it matches one of call IDs."""
1711
- role = getattr(msg, "role", None) or (msg.get("role") if isinstance(msg, dict) else None)
1712
- if role != "tool":
1713
- return None
1714
- call_id = getattr(msg, "tool_call_id", None) or (
1715
- msg.get("tool_call_id") if isinstance(msg, dict) else None
1716
- )
1717
- if not isinstance(call_id, str) or call_id not in matching_call_ids:
1718
- return None
1719
- content = getattr(msg, "content", None) or (
1720
- msg.get("content") if isinstance(msg, dict) else None
1721
- )
1722
- if not isinstance(content, str):
1723
- return None
1724
- try:
1725
- payload: object = _stdlib_json.loads(content)
1726
- return payload
1727
- except _stdlib_json.JSONDecodeError:
1728
- 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
1729
2113
 
1730
2114
 
1731
2115
  def _conversation_has_successful_lookup(
@@ -1791,24 +2175,11 @@ def _tool_result_payload_for_primitive_call(
1791
2175
  matching_call_ids: set[str],
1792
2176
  ) -> object | None:
1793
2177
  """Parse a primitive tool-result message when it matches one of call IDs."""
1794
- role = getattr(msg, "role", None) or (msg.get("role") if isinstance(msg, dict) else None)
1795
- if role != "tool":
1796
- return None
1797
- call_id = getattr(msg, "tool_call_id", None) or (
1798
- msg.get("tool_call_id") if isinstance(msg, dict) else None
1799
- )
1800
- if not isinstance(call_id, str) or call_id not in matching_call_ids:
1801
- return None
1802
- content = getattr(msg, "content", None) or (
1803
- msg.get("content") if isinstance(msg, dict) else None
1804
- )
1805
- if not isinstance(content, str):
1806
- return None
1807
- try:
1808
- payload: object = _stdlib_json.loads(content)
1809
- return payload
1810
- except _stdlib_json.JSONDecodeError:
1811
- 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
1812
2183
 
1813
2184
 
1814
2185
  def _tool_result_payload_for_primitive(
@@ -1823,24 +2194,17 @@ def _tool_result_payload_for_primitive(
1823
2194
  resolved state of the most recent primitive invocation, not a specific
1824
2195
  call handle.
1825
2196
  """
1826
- role = getattr(msg, "role", None) or (msg.get("role") if isinstance(msg, dict) else None)
1827
- if role != "tool":
1828
- return None
1829
- name = getattr(msg, "name", None) or (msg.get("name") if isinstance(msg, dict) else None)
1830
- content = getattr(msg, "content", None) or (
1831
- msg.get("content") if isinstance(msg, dict) else None
1832
- )
1833
- if not isinstance(content, str):
1834
- return None
1835
- try:
1836
- payload: object = _stdlib_json.loads(content)
2197
+ for _, name, payload in _iter_tool_result_payloads(msg):
1837
2198
  if name == primitive:
1838
2199
  return payload
1839
2200
  if isinstance(payload, dict) and payload.get("kind") == primitive:
1840
2201
  return payload
1841
- return None
1842
- except _stdlib_json.JSONDecodeError:
1843
- 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
1844
2208
 
1845
2209
 
1846
2210
  def _primitive_payload_is_success(payload: object, *, primitive: str) -> bool:
@@ -1854,6 +2218,10 @@ def _primitive_payload_is_success(payload: object, *, primitive: str) -> bool:
1854
2218
  if isinstance(result, dict) and result.get("status") == "succeeded":
1855
2219
  return True
1856
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"
1857
2225
  return True
1858
2226
 
1859
2227
 
@@ -2172,7 +2540,7 @@ def _latest_successful_primitive_observation(
2172
2540
  )
2173
2541
  primitive: object = tool_message_name
2174
2542
  payload: object | None = None
2175
- if primitive not in {"find", "locate", "check", "send"}:
2543
+ if primitive not in {"find", "locate", "check", "send", "document"}:
2176
2544
  if not isinstance(content, str):
2177
2545
  continue
2178
2546
  try:
@@ -2182,7 +2550,7 @@ def _latest_successful_primitive_observation(
2182
2550
  if not isinstance(parsed_payload, dict):
2183
2551
  continue
2184
2552
  primitive = parsed_payload.get("kind")
2185
- if primitive not in {"find", "locate", "check", "send"}:
2553
+ if primitive not in {"find", "locate", "check", "send", "document"}:
2186
2554
  continue
2187
2555
  payload = parsed_payload
2188
2556
  if payload is None:
@@ -2200,6 +2568,183 @@ def _latest_successful_primitive_observation(
2200
2568
  return None
2201
2569
 
2202
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
+
2203
2748
  def _final_answer_observation_message(
2204
2749
  *,
2205
2750
  message: str,
@@ -2220,6 +2765,16 @@ def _final_answer_observation_message(
2220
2765
  observation_json[:_FINAL_ANSWER_OBSERVATION_JSON_LIMIT] + "...[truncated]"
2221
2766
  )
2222
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
+
2223
2778
  return (
2224
2779
  "[UMMAYA FINAL ANSWER OBSERVATION]\n"
2225
2780
  f"{message}\n\n"
@@ -2227,6 +2782,7 @@ def _final_answer_observation_message(
2227
2782
  f"{latest_user_utt}\n\n"
2228
2783
  "Latest successful primitive tool_result JSON:\n"
2229
2784
  f"{observation_json}\n\n"
2785
+ f"{document_guidance}"
2230
2786
  "Use only the observed tool_result data above and the prior tool_result "
2231
2787
  "messages. Do not call another tool. Do not invent names, addresses, "
2232
2788
  "phone numbers, timestamps, weather values, receipt IDs, or source "
@@ -3072,82 +3628,47 @@ def _submit_requirement_for_query(user_query: str) -> dict[str, str] | None:
3072
3628
  and _query_contains_any(user_query, ("신고", "신고서", "제출"))
3073
3629
  )
3074
3630
  if asks_submit and asks_hometax_tax_return:
3075
- session_id = _extract_session_id(user_query, "HOMETAX-TAXRETURN-SESSION-001")
3076
- params = {
3077
- "tax_year": _extract_tax_year(user_query),
3078
- "income_type": "종합소득",
3079
- "total_income_krw": 42_000_000,
3080
- "session_id": session_id,
3081
- }
3082
3631
  return {
3083
3632
  "tool_id": "mock_submit_module_hometax_taxreturn",
3084
3633
  "verify_tool_id": "mock_verify_module_modid",
3085
3634
  "scope": "send:hometax.tax-return",
3086
3635
  "pre_submit_lookup_tool_id": "mock_lookup_module_hometax_simplified",
3087
- "params_json": _stdlib_json.dumps(params, ensure_ascii=False),
3636
+ "params_json": "{}",
3088
3637
  }
3089
3638
 
3090
3639
  if asks_submit and _query_contains_any(user_query, ("정부24", "주민등록등본", "등본", "민원")):
3091
- session_id = _extract_session_id(user_query, "GOV24-MINWON-SESSION-001")
3092
- params = {
3093
- "minwon_type": "주민등록등본",
3094
- "applicant_name": "홍길동" if "홍길동" in user_query else "MOCK_APPLICANT",
3095
- "delivery_method": "online",
3096
- "session_id": session_id,
3097
- }
3098
3640
  return {
3099
3641
  "tool_id": "mock_submit_module_gov24_minwon",
3100
3642
  "verify_tool_id": "mock_verify_module_simple_auth",
3101
3643
  "scope": "send:gov24.minwon",
3102
- "params_json": _stdlib_json.dumps(params, ensure_ascii=False),
3644
+ "params_json": "{}",
3103
3645
  }
3104
3646
 
3105
3647
  if asks_submit and _query_contains_any(
3106
3648
  user_query,
3107
3649
  ("복지 급여", "복지신청", "한부모가족", "한부모", "아동양육비"),
3108
3650
  ):
3109
- applicant_match = re.search(r"DI-[A-Z0-9-]+", user_query)
3110
- household_match = re.search(r"(\d+)\s*명", user_query)
3111
- params = {
3112
- "applicant_id": applicant_match.group(0)
3113
- if applicant_match
3114
- else "DI-MOCK-WELFARE-APPLICANT",
3115
- "benefit_code": "WLF00001068",
3116
- "application_type": "new",
3117
- "household_size": int(household_match.group(1)) if household_match else 1,
3118
- }
3119
3651
  return {
3120
3652
  "tool_id": "mock_welfare_application_submit_v1",
3121
3653
  "verify_tool_id": "mock_verify_mydata",
3122
3654
  "scope": "send:mydata.welfare_application",
3123
- "params_json": _stdlib_json.dumps(params, ensure_ascii=False),
3655
+ "params_json": "{}",
3124
3656
  }
3125
3657
 
3126
3658
  if asks_submit and _query_contains_any(user_query, ("과태료", "교통범칙금", "범칙금")):
3127
- params = {
3128
- "fine_reference": "MOCK-FINE-2026-001",
3129
- "payment_method": "virtual_account",
3130
- }
3131
3659
  return {
3132
3660
  "tool_id": "mock_traffic_fine_pay_v1",
3133
3661
  "verify_tool_id": "mock_verify_ganpyeon_injeung",
3134
3662
  "scope": "send:traffic.fine-pay",
3135
- "params_json": _stdlib_json.dumps(params, ensure_ascii=False),
3663
+ "params_json": "{}",
3136
3664
  }
3137
3665
 
3138
3666
  if asks_submit and _query_contains_any(user_query, ("마이데이터", "공공마이데이터")):
3139
- session_id = _extract_session_id(user_query, "MYDATA-ACTION-SESSION-001")
3140
- params = {
3141
- "action_type": "transfer_consent",
3142
- "target_institution_code": "PUBLIC-MYDATA-MOCK",
3143
- "applicant_di": "DI-MOCK-MYDATA-001",
3144
- "session_id": session_id,
3145
- }
3146
3667
  return {
3147
3668
  "tool_id": "mock_submit_module_public_mydata_action",
3148
3669
  "verify_tool_id": "mock_verify_mydata",
3149
3670
  "scope": "send:public_mydata.action",
3150
- "params_json": _stdlib_json.dumps(params, ensure_ascii=False),
3671
+ "params_json": "{}",
3151
3672
  }
3152
3673
 
3153
3674
  return None
@@ -3176,17 +3697,18 @@ def _check_submit_terminated_without_submit(
3176
3697
  tool_id=pre_submit_lookup_tool_id,
3177
3698
  ):
3178
3699
  return None
3179
- params_json = requirement["params_json"]
3180
3700
  tool_id = requirement["tool_id"]
3701
+ scope = requirement["scope"]
3181
3702
  return {
3182
3703
  **requirement,
3183
3704
  "message": (
3184
3705
  "Send follow-up missing: the citizen asked to complete a write, "
3185
3706
  "payment, consent, or filing flow and verification has already run, "
3186
3707
  f"but {tool_id!r} has not succeeded. RECOVERY: in the next turn call "
3187
- f"send(tool_id={tool_id!r}, params={params_json}). The backend will "
3188
- "inject the cached DelegationContext. Do NOT ask for additional mock "
3189
- "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."
3190
3712
  ),
3191
3713
  }
3192
3714
 
@@ -3279,17 +3801,10 @@ def _canonicalize_submit_tool_id(
3279
3801
  def _apply_submit_canonical_params(
3280
3802
  params: dict[str, object],
3281
3803
  canonical: dict[str, object],
3282
- tool_id: str,
3804
+ _tool_id: str,
3283
3805
  ) -> bool:
3284
- """Apply submit fixture defaults, overwriting Hometax mock guesses."""
3285
3806
  changed = False
3286
- overwrite = tool_id == "mock_submit_module_hometax_taxreturn"
3287
3807
  for key, value in canonical.items():
3288
- if overwrite:
3289
- if params.get(key) != value:
3290
- params[key] = value
3291
- changed = True
3292
- continue
3293
3808
  if key not in params or params.get(key) in (None, ""):
3294
3809
  params[key] = value
3295
3810
  changed = True
@@ -3332,11 +3847,14 @@ def _normalize_submit_args_for_query(
3332
3847
 
3333
3848
  def _strip_hometax_lookup_context_noise(params: dict[str, object]) -> bool:
3334
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
3335
3854
  delegation_context = params.get("delegation_context")
3336
3855
  if not isinstance(delegation_context, dict):
3337
- return False
3856
+ return changed
3338
3857
  cleaned = dict(delegation_context)
3339
- changed = False
3340
3858
  for key in ("year", "resident_id_prefix"):
3341
3859
  if key in cleaned:
3342
3860
  cleaned.pop(key, None)
@@ -3382,6 +3900,58 @@ def _normalize_hometax_lookup_args_for_query(
3382
3900
  return normalized
3383
3901
 
3384
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
+
3385
3955
  def _canonicalize_lookup_tool_id_for_query(
3386
3956
  args_obj: dict[str, object],
3387
3957
  user_query: str,
@@ -3392,7 +3962,15 @@ def _canonicalize_lookup_tool_id_for_query(
3392
3962
  return args_obj
3393
3963
  sensitive_lookup = _sensitive_lookup_requirement_for_query(user_query)
3394
3964
  if sensitive_lookup is None:
3395
- 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
+ }
3396
3974
  normalized = dict(args_obj)
3397
3975
  normalized["tool_id"] = sensitive_lookup["tool_id"]
3398
3976
  logger.info(
@@ -3471,6 +4049,7 @@ def _normalize_lookup_args_for_query(
3471
4049
  return args_obj
3472
4050
  args_obj = _canonicalize_lookup_tool_id_for_query(args_obj, user_query)
3473
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)
3474
4053
  args_obj = _normalize_lookup_result_count_args(
3475
4054
  args_obj,
3476
4055
  user_query,
@@ -3506,6 +4085,21 @@ def _normalize_lookup_args_for_query(
3506
4085
  return normalized
3507
4086
 
3508
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
+
3509
4103
  _KOREAN_COUNT_WORDS: Final[dict[str, int]] = {
3510
4104
  "한": 1,
3511
4105
  "두": 2,
@@ -4449,26 +5043,31 @@ def _emitted_tool_id(fname: str, args_obj: dict[str, object]) -> str | None:
4449
5043
  return None
4450
5044
 
4451
5045
 
4452
- def _direct_public_data_target_for_query(user_query: str) -> tuple[frozenset[str], str, str] | None:
5046
+ def _direct_public_data_target_for_query(
5047
+ user_query: str,
5048
+ ) -> tuple[frozenset[str], str, str, str] | None:
4453
5049
  """Return target adapter family for public-data wording that should not use substitutes."""
4454
5050
  if _KMA_ANALYSIS_MAP_USER_QUERY_RE.search(user_query):
4455
5051
  return (
4456
5052
  frozenset({_KMA_ANALYSIS_CHART_TOOL_ID}),
4457
5053
  _KMA_ANALYSIS_CHART_TOOL_ID,
4458
- "call the KMA APIHub analyzed weather-chart adapter and do not "
4459
- "substitute location, AirKorea, or ordinary weather tools.",
5054
+ "weather_chart",
5055
+ "use official KMA APIHub analyzed weather-chart evidence and do not "
5056
+ "substitute location, AirKorea, or ordinary weather evidence.",
4460
5057
  )
4461
5058
  if _PPS_BID_USER_QUERY_RE.search(user_query):
4462
5059
  return (
4463
5060
  frozenset({_PPS_BID_TOOL_ID}),
4464
5061
  _PPS_BID_TOOL_ID,
4465
- "call the PPS/NaraJangteo bid adapter with its bid notice date fields.",
5062
+ "procurement_bid",
5063
+ "use PPS/NaraJangteo bid notice date fields.",
4466
5064
  )
4467
5065
  if _AIRKOREA_USER_QUERY_RE.search(user_query):
4468
5066
  return (
4469
5067
  frozenset({_AIRKOREA_TOOL_ID}),
4470
5068
  _AIRKOREA_TOOL_ID,
4471
- "call the AirKorea city/province air-quality adapter with sido_name such as '부산'.",
5069
+ "air_quality",
5070
+ "use AirKorea city/province air-quality evidence with sido_name such as '부산'.",
4472
5071
  )
4473
5072
  if _TAGO_BUS_USER_QUERY_RE.search(user_query):
4474
5073
  preferred = (
@@ -4479,15 +5078,17 @@ def _direct_public_data_target_for_query(user_query: str) -> tuple[frozenset[str
4479
5078
  return (
4480
5079
  _TAGO_TOOL_IDS,
4481
5080
  preferred,
4482
- "use TAGO bus schemas; for a route number, start with "
4483
- "tago_bus_route_search, then route_station and arrival.",
5081
+ "bus_realtime",
5082
+ "use TAGO bus evidence; for a route number, start with route search, "
5083
+ "then route-station and arrival evidence.",
4484
5084
  )
4485
5085
  if _query_implies_current_weather_observation(user_query):
4486
5086
  return (
4487
5087
  _KMA_ORDINARY_WEATHER_TOOL_IDS | _KMA_LOCATION_TOOL_IDS,
4488
5088
  "kakao_keyword_search",
4489
- "use a location adapter first when coordinates are missing, then "
4490
- "KMA current observation for rain/umbrella/current-weather values.",
5089
+ "current_weather",
5090
+ "use location resolution first when coordinates are missing, then "
5091
+ "KMA current observation evidence for rain/umbrella/current-weather values.",
4491
5092
  )
4492
5093
  return None
4493
5094
 
@@ -4501,15 +5102,15 @@ def _check_direct_public_data_tool_choice_prerequisite(
4501
5102
  target = _direct_public_data_target_for_query(user_query)
4502
5103
  if target is None:
4503
5104
  return None
4504
- allowed_tool_ids, preferred_tool_id, hint = target
5105
+ allowed_tool_ids, preferred_tool_id, route_label, hint = target
4505
5106
  emitted_tool_id = _emitted_tool_id(fname, args_obj)
4506
5107
  if emitted_tool_id is None or emitted_tool_id in allowed_tool_ids:
4507
5108
  return None
4508
5109
  return (
4509
5110
  preferred_tool_id,
4510
- "Public-data tool-choice mismatch: the latest citizen request matches "
4511
- f"{preferred_tool_id}. The model emitted {emitted_tool_id} instead. "
4512
- f"RECOVERY: {hint}",
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}",
4513
5114
  )
4514
5115
 
4515
5116
 
@@ -4605,6 +5206,7 @@ _AVAILABLE_ADAPTER_FIND_LINE_RE: Final = re.compile(
4605
5206
  r"^\s*-\s+[A-Za-z0-9_.:-]+\s+\(primitive=find\)",
4606
5207
  re.MULTILINE,
4607
5208
  )
5209
+ _AVAILABLE_ADAPTER_TOOL_ID_LINE_RE: Final = re.compile(r"^\s*-\s*tool_id:\s*[A-Za-z0-9_.:-]+\s*$")
4608
5210
  _MEDICAL_COLLAPSE_RE: Final = re.compile(
4609
5211
  r"(사람[이가은는 ]*쓰러|쓰러졌|쓰러져|의식[을 ]*(?:잃|없)|심정지|"
4610
5212
  r"숨[을 ]*(?:안|못)|호흡[이가은는 ]*없|자동심장|심장충격|제세동|"
@@ -4639,7 +5241,21 @@ def _latest_available_adapters_block(llm_messages: list[Any]) -> str:
4639
5241
 
4640
5242
  def _available_adapters_block_has_find_candidate(block: str) -> bool:
4641
5243
  """Return True when retrieval surfaced a non-locate follow-up adapter."""
4642
- 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
4643
5259
 
4644
5260
 
4645
5261
  def _available_adapters_block_has_tool_id(block: str, tool_id: str) -> bool:
@@ -5180,6 +5796,7 @@ async def run( # noqa: C901
5180
5796
  _fh.setFormatter(
5181
5797
  logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s")
5182
5798
  )
5799
+ _fh.addFilter(_BackendSecretRedactionFilter())
5183
5800
  _root.addHandler(_fh)
5184
5801
  _root.setLevel(min(_root.level or logging.INFO, logging.INFO))
5185
5802
  logger.info(
@@ -5393,6 +6010,19 @@ async def run( # noqa: C901
5393
6010
  _ensure_tool_registry() # populates both refs in one shot
5394
6011
  return _tool_executor_ref[0]
5395
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
+
5396
6026
  async def _ensure_llm_client() -> object:
5397
6027
  if not _llm_client_ref:
5398
6028
  from ummaya.llm.client import LLMClient # noqa: PLC0415
@@ -5557,54 +6187,50 @@ async def run( # noqa: C901
5557
6187
  )
5558
6188
  _root_primitive_tool_ids = _ROOT_PRIMITIVE_TOOL_IDS
5559
6189
 
5560
- def _select_concrete_adapter_tools_for_turn(user_query: str) -> list[Any]:
5561
- """Return concrete, non-core adapter tools for this citizen turn.
5562
-
5563
- CC exposes concrete Tool objects to the model; UMMAYA keeps the same
5564
- model-facing shape and uses BM25/dense retrieval only as a loading
5565
- optimization so the tool list stays small.
5566
- """
6190
+ def _route_decision_for_turn(user_query: str) -> RouteDecision | None:
5567
6191
  q = (user_query or "").strip()
5568
6192
  if not q:
5569
- return []
6193
+ return None
5570
6194
  registry = _ensure_tool_registry()
5571
- selected: dict[str, Any] = {}
5572
- for tool in registry.all_tools():
5573
- if tool.id in _root_primitive_tool_ids:
5574
- continue
5575
- if tool.id in q:
5576
- selected[tool.id] = tool
5577
6195
  try:
5578
- from ummaya.tools.search import search # noqa: PLC0415
6196
+ from ummaya.tools.routing import RouteDecisionService # noqa: PLC0415
5579
6197
 
5580
6198
  raw_top_k = max(_AVAILABLE_ADAPTERS_TOP_K * 3, _AVAILABLE_ADAPTERS_TOP_K)
5581
- candidates = search(
5582
- query=q,
5583
- bm25_index=registry.bm25_index,
5584
- registry=registry,
6199
+ return RouteDecisionService(registry).select_adapters(
6200
+ q,
5585
6201
  top_k=min(raw_top_k, 20),
6202
+ max_selected=_AVAILABLE_ADAPTERS_TOP_K,
5586
6203
  )
5587
6204
  except Exception:
5588
- logger.exception("adapter tool retrieval failed for '%s'", q[:80])
5589
- candidates = []
5590
- for candidate in candidates:
5591
- try:
5592
- tool = registry.find(candidate.tool_id)
5593
- except Exception:
5594
- logger.debug(
5595
- "Skipping unavailable adapter candidate %s",
5596
- candidate.tool_id,
5597
- exc_info=True,
5598
- )
5599
- continue
5600
- if tool.id in _root_primitive_tool_ids:
5601
- continue
5602
- selected.setdefault(tool.id, tool)
5603
- if len(selected) >= _AVAILABLE_ADAPTERS_TOP_K:
5604
- break
5605
- return list(selected.values())[:_AVAILABLE_ADAPTERS_TOP_K]
6205
+ logger.exception("route decision failed for '%s'", q[:80])
6206
+ return None
5606
6207
 
5607
- def _build_available_adapters_suffix(user_query: str) -> str: # noqa: C901
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
+ )
6228
+
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
5608
6234
  """Run BM25 against the live registry and emit the citizen-turn
5609
6235
  ``<available_adapters>`` XML block for the dynamic system-prompt
5610
6236
  suffix.
@@ -5618,281 +6244,32 @@ async def run( # noqa: C901
5618
6244
  q = (user_query or "").strip()
5619
6245
  if not q:
5620
6246
  return ""
6247
+ route_decision = route_decision or _route_decision_for_turn(q)
6248
+ if route_decision is None:
6249
+ return ""
5621
6250
  try:
5622
- from ummaya.tools.search import search # noqa: PLC0415
6251
+ from ummaya.tools.routing import build_available_adapters_projection # noqa: PLC0415
5623
6252
 
5624
- registry = _ensure_tool_registry()
5625
- raw_top_k = max(_AVAILABLE_ADAPTERS_TOP_K * 3, _AVAILABLE_ADAPTERS_TOP_K)
5626
- 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(),
5627
6262
  query=q,
5628
- bm25_index=registry.bm25_index,
5629
- registry=registry,
5630
- 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,
5631
6268
  )
6269
+ return projection.content or ""
5632
6270
  except Exception:
5633
- logger.exception("BM25 retrieval failed for '%s'", q[:80])
5634
- return ""
5635
- filtered_candidates = []
5636
- for candidate in candidates:
5637
- try:
5638
- tool = registry.find(candidate.tool_id)
5639
- except Exception:
5640
- logger.debug(
5641
- "Skipping unavailable adapter candidate %s",
5642
- candidate.tool_id,
5643
- exc_info=True,
5644
- )
5645
- continue
5646
- if tool.id in _root_primitive_tool_ids:
5647
- continue
5648
- filtered_candidates.append(candidate)
5649
- if len(filtered_candidates) >= _AVAILABLE_ADAPTERS_TOP_K:
5650
- break
5651
- candidates = filtered_candidates
5652
- if not candidates:
6271
+ logger.exception("route decision projection failed for '%s'", q[:80])
5653
6272
  return ""
5654
- candidate_ids = tuple(candidate.tool_id for candidate in candidates)
5655
- first_candidate_id = candidate_ids[0]
5656
- has_amos_candidate = "kma_apihub_url_air_amos_minute" in candidate_ids
5657
- has_metar_candidate = "kma_apihub_url_air_metar_decoded" in candidate_ids
5658
- has_analysis_candidate = any(
5659
- candidate_id
5660
- in {
5661
- "kma_apihub_url_high_resolution_grid_point",
5662
- "kma_apihub_url_aws_objective_analysis_grid",
5663
- "kma_apihub_url_analysis_weather_chart_image",
5664
- }
5665
- for candidate_id in candidate_ids
5666
- )
5667
- is_gimpo_runway_query = bool(
5668
- re.search(r"(김포공항|Gimpo|RKSS)", q, re.IGNORECASE)
5669
- and re.search(
5670
- r"(AMOS|활주로|RVR|runway|시정|visibility|공항기상관측|매분)",
5671
- q,
5672
- re.IGNORECASE,
5673
- )
5674
- )
5675
- # Build a compact, LLM-readable block.
5676
- #
5677
- # Spec 2521 (2026-05-02) — emit per-field schema signatures so the
5678
- # LLM can fill ``params`` against each adapter's actual REST shape.
5679
- # The previous suffix only carried ``search_hint`` and assumed the
5680
- # LLM could "infer params from search_hint" — K-EXAONE on FriendliAI
5681
- # consistently invented ``{"location": "...", "date": "..."}`` style
5682
- # payloads which fail every adapter's pydantic validation
5683
- # (``Invalid parameters for tool``). Rendering each field with its
5684
- # type + required flag + truncated description gives K-EXAONE
5685
- # enough signal to call e.g. ``{"lat": 37.5, "lon": 129.0,
5686
- # "base_date": "20260502", "base_time": "0500"}`` correctly.
5687
- lines: list[str] = [
5688
- f'<available_adapters query="{q[:120]}">',
5689
- f"백엔드 BM25 후보 (top {len(candidates)}, 점수 내림차순):",
5690
- "",
5691
- ]
5692
- for c in candidates:
5693
- hint = (c.search_hint or "").strip()
5694
- if len(hint) > 90:
5695
- hint = hint[:87] + "..."
5696
- primitive = c.primitive or "find"
5697
- lines.append(
5698
- f"- {c.tool_id} (primitive={primitive}) [{c.score:.2f}] — {hint or '(설명 없음)'}"
5699
- )
5700
- lines.append(f" 호출: {c.tool_id}({{...schema fields...}})")
5701
- # Render the adapter's llm_description (usage prose, ORDERING RULE,
5702
- # prerequisites, worked examples) so the LLM sees the complete
5703
- # "먼저 locate 호출" ordering rule.
5704
- # Bug: without this, the per-field description for nx is truncated
5705
- # and K-EXAONE skips locate, producing invalid_params.
5706
- if c.llm_description:
5707
- desc_text = c.llm_description.strip().replace("\n", " ")
5708
- # Emit enough text for adapter-specific negative routing and
5709
- # output-use rules. KMA METAR/AMOS descriptions carry critical
5710
- # "Gimhae is not AMOS" and "safe_weather only" instructions
5711
- # after the purpose sentence; truncating them makes the TUI
5712
- # path claim no METAR tool exists.
5713
- if len(desc_text) > 900:
5714
- desc_text = desc_text[:897] + "..."
5715
- lines.append(f" 설명: {desc_text}")
5716
- # Render input schema signature so the LLM sees exact field
5717
- # names + types + required flags + (truncated) descriptions.
5718
- # Field desc limit raised 80→120 so nx/ny examples fit untruncated.
5719
- schema = c.input_schema_json or {}
5720
- properties = schema.get("properties") if isinstance(schema, dict) else None
5721
- required: set[str] = set()
5722
- raw_required = schema.get("required") if isinstance(schema, dict) else None
5723
- if isinstance(raw_required, list):
5724
- required = {str(item) for item in raw_required if isinstance(item, str)}
5725
- # Spec 2522 T010 — ORDERING directive removed.
5726
- # The Spec 2521 ORDERING block ("nx/ny 는 KMA 격자 좌표 — 반드시
5727
- # locate 을 먼저 호출") forced a cross-domain chain that
5728
- # contradicts both the user directive ("chain X / UMMAYA does not
5729
- # force cross-domain chain") and v4 description 5-section
5730
- # self_contained_decl ("이 도구 단독 호출로 완결. locate 등
5731
- # cross-domain chain 불필요"). With both signals present K-EXAONE
5732
- # ignored both and hallucinated nx/ny → Spec 2521 regression.
5733
- # Each adapter's description (섹션 4 domain_quirk + 섹션 5
5734
- # self_contained_decl + 섹션 3 short_reference 17 광역시도 표) is now
5735
- # self-sufficient. The model decides chain vs single-tool autonomously.
5736
- # Reference: research-stdio-ordering.md, frames-busan-weather/ T042 evidence.
5737
- # Spec 2522 T047 fix — resolve $ref to $defs and inline enum values.
5738
- # KOROAD KoroadAccidentSearchInput.search_year_cd uses
5739
- # `$ref: #/$defs/SearchYearCd` (20 values). The previous renderer
5740
- # only inlined `properties.<f>.enum` and gave up on $ref, leaving
5741
- # K-EXAONE to guess plain '2024' (invalid). Spec 2522 frames-gangnam-
5742
- # accident-fix2 evidence: invalid_params persisted after T042 fix.
5743
- # Fix: resolve $ref against schema['$defs'] + raise threshold 8→25.
5744
- defs_raw = schema.get("$defs") if isinstance(schema, dict) else None
5745
- defs: dict[str, Any] | None = defs_raw if isinstance(defs_raw, dict) else None
5746
-
5747
- def _resolve_enum(
5748
- meta: dict[str, Any], defs: dict[str, Any] | None
5749
- ) -> list[Any] | None:
5750
- # direct enum
5751
- e = meta.get("enum")
5752
- if isinstance(e, list):
5753
- return e
5754
- # $ref → $defs/<name>
5755
- ref = meta.get("$ref")
5756
- if isinstance(ref, str) and ref.startswith("#/$defs/") and isinstance(defs, dict):
5757
- name = ref.removeprefix("#/$defs/")
5758
- target = defs.get(name)
5759
- if isinstance(target, dict):
5760
- target_enum = target.get("enum")
5761
- if isinstance(target_enum, list):
5762
- return target_enum
5763
- return None
5764
-
5765
- def _resolve_enum_with_names(
5766
- meta: dict[str, Any], defs: dict[str, Any] | None
5767
- ) -> list[tuple[Any, str]] | None:
5768
- """Spec 2522 — agency 자체 코드체계 (KOROAD GugunCode SEOUL_GANGNAM=680
5769
- 등) 의 IntEnum name 을 의미 매핑으로 노출. pydantic JSON schema 의
5770
- $defs 안 IntEnum 의 'enum' (값) + 'x-enum-varnames' (name) 또는
5771
- 'description' (docstring) 을 묶어서 LLM 에 보여줌.
5772
- """
5773
- ref = meta.get("$ref")
5774
- if not (isinstance(ref, str) and ref.startswith("#/$defs/")):
5775
- return None
5776
- if not isinstance(defs, dict):
5777
- return None
5778
- name = ref.removeprefix("#/$defs/")
5779
- target = defs.get(name)
5780
- if not isinstance(target, dict):
5781
- return None
5782
- values = target.get("enum")
5783
- if not isinstance(values, list):
5784
- return None
5785
- # IntEnum name 추출 — pydantic v2 가 'x-enum-varnames' 또는
5786
- # 'enumNames' 로 export 하지 않음. 대신 module-level dict 조회.
5787
- varnames = target.get("x-enum-varnames")
5788
- if isinstance(varnames, list) and len(varnames) == len(values):
5789
- return list(zip(values, varnames, strict=False))
5790
- return None
5791
-
5792
- if isinstance(properties, dict) and properties:
5793
- for fname, fmeta in properties.items():
5794
- if not isinstance(fmeta, dict):
5795
- continue
5796
- ftype = fmeta.get("type") or fmeta.get("anyOf") or "any"
5797
- if isinstance(ftype, list):
5798
- ftype = "|".join(str(t) for t in ftype)
5799
- fdesc = str(fmeta.get("description", "")).strip().replace("\n", " ")
5800
- # Spec 2522 — agency 자체 코드체계 (KOROAD 68 시군구 매핑 ≈ 1600
5801
- # chars + 기존 description ≈ 600 chars = ~2200 chars / KMA 156
5802
- # station 등) 인라인 허용. 일반 도구는 100자 미만이라 영향 X.
5803
- if len(fdesc) > 5000:
5804
- fdesc = fdesc[:4997] + "..."
5805
- pat = fmeta.get("pattern")
5806
- pat_part = f" pattern={pat!r}" if isinstance(pat, str) else ""
5807
- enum = _resolve_enum(fmeta, defs)
5808
- # Spec 2522 T047 — threshold 25→200 — KOROAD GugunCode (115) /
5809
- # SearchYearCd (20) / SidoCode (17) 등 모두 노출. 의미 매핑은
5810
- # field description 에 따로 인라인 (Pydantic IntEnum 의 name
5811
- # 은 JSON schema 표준 export 안 됨).
5812
- if isinstance(enum, list) and len(enum) <= 200:
5813
- enum_part = f" enum={enum}"
5814
- else:
5815
- enum_part = ""
5816
- flag = "필수" if fname in required else "선택"
5817
- lines.append(
5818
- f" · {fname} ({ftype}, {flag}{pat_part}{enum_part})"
5819
- + (f" — {fdesc}" if fdesc else "")
5820
- )
5821
- lines.append("")
5822
- lines.append(
5823
- "규칙: 위 목록의 tool_id는 concrete adapter id입니다. model-facing "
5824
- "함수명도 tools[]에 로드된 concrete tool_id입니다. concrete adapter "
5825
- "function은 schema 필드만 받으므로 tool_id/params envelope를 그 안에 "
5826
- "넣지 마세요. concrete function이 로드되지 않고 root primitive만 "
5827
- '있을 때만 legacy envelope 예: find({"tool_id":"...", "params":{...}}) '
5828
- "형식을 사용합니다. 동일 tool_id 를 한 turn 안에서 반복 호출하지 "
5829
- "마세요. 위 목록에 요청과 일치하는 adapter가 있으면 도구가 없다고 "
5830
- "답하지 마세요."
5831
- )
5832
- if has_analysis_candidate:
5833
- lines.append(
5834
- "분석자료 특수 규칙: 위 후보에 고해상도 격자자료, AWS 객관분석, "
5835
- "분석일기도 이미지가 있으면 기상청이 이미 분석한 자료 도구가 있는 "
5836
- "것입니다. 공항 관측값/METAR/AMOS/일반 예보가 아니라 시민이 말한 "
5837
- "분석자료 계열 후보를 호출하세요. 지도/일기도/비구름/바람 흐름 "
5838
- "질의는 kma_apihub_url_analysis_weather_chart_image 를 우선 호출하고, "
5839
- "특정 지점 주변 값은 locate 뒤 "
5840
- "kma_apihub_url_high_resolution_grid_point 또는 "
5841
- "kma_apihub_url_aws_objective_analysis_grid 를 호출하세요. 공항/랜드마크 "
5842
- "주변 좌표는 kakao_keyword_search 를 kakao_address_search 보다 먼저 "
5843
- "사용하세요. locate 가 실패하면 다른 후보 위치 도구를 시도하고, 도구 "
5844
- "결과 없이 좌표를 추정하지 마세요. APIHub 승인 대기나 upstream 오류가 "
5845
- "나면 그 실패를 그대로 설명하고, 도구 결과 없이 지도 기반 내용을 "
5846
- "추정하지 마세요."
5847
- )
5848
- if has_amos_candidate and (
5849
- is_gimpo_runway_query or first_candidate_id == "kma_apihub_url_air_amos_minute"
5850
- ):
5851
- lines.append(
5852
- "AMOS 특수 규칙: kma_apihub_url_air_amos_minute 가 김포공항 "
5853
- "활주로/시정/RVR/매분 관측 후보이면 AMOS 공항기상관측 도구가 "
5854
- "있는 것입니다. 김포공항은 stn=110 을 사용하세요. 이 후보는 "
5855
- "좌표를 요구하지 않으므로 locate/kma_current_observation 을 먼저 "
5856
- '호출하지 말고 즉시 kma_apihub_url_air_amos_minute({"stn":"110",'
5857
- '"help":1}) 를 호출하세요. METAR 는 '
5858
- "보조 확인이 필요할 때만 추가로 사용하세요."
5859
- )
5860
- if has_metar_candidate and not (has_amos_candidate and is_gimpo_runway_query):
5861
- lines.append(
5862
- "METAR 특수 규칙: kma_apihub_url_air_metar_decoded 가 후보에 있으면 "
5863
- "공항 METAR 해독자료 조회 도구가 있는 것입니다. 김해공항/RKPK는 "
5864
- "decoded_records 의 station 153 Gimhae Airport / RKPK record를 "
5865
- "사용하고, 날씨 값은 decoded_records[].safe_weather 만 사용하세요. "
5866
- "raw_fields/raw_report에서 별도 값을 만들지 마세요. 이 후보는 좌표를 "
5867
- "요구하지 않으므로 locate/kma_current_observation 을 먼저 호출하지 "
5868
- '말고 즉시 kma_apihub_url_air_metar_decoded({"org":"K","help":1}) '
5869
- "를 호출하세요."
5870
- )
5871
- listed_primitives = {str(candidate.primitive or "find") for candidate in candidates}
5872
- if listed_primitives == {"find"}:
5873
- lines.append(
5874
- "공개자료 조회 규칙: 위 후보가 모두 primitive=find 이면 시민이 "
5875
- "인증/본인확인/동의/신청/제출/납부/신고를 명시하지 않은 한 "
5876
- "check/send 계열 adapter를 호출하지 마세요. 성공한 find 결과가 있으면 "
5877
- "다음 turn 은 최종 답변입니다."
5878
- )
5879
- lines.append(
5880
- "호출 전 검증: 시민 발화의 명시 조건(개수, 반경/거리, 날짜/시간, 종류, "
5881
- "카테고리, 진료과/분야, 키워드, 행정구역 등)이 아래 schema 의 선택 "
5882
- "필드와 대응하면 그 필드를 반드시 params 에 포함하세요. 더 좁은 요청을 "
5883
- "넓은 무필터 조회로 실행하지 마세요."
5884
- )
5885
- lines.append(
5886
- 'params 는 위에 표시된 정확한 필드명만 사용하세요 — 일반적인 "location"/'
5887
- '"date" 같은 추측 키는 모든 어댑터에서 invalid_params 로 거부됩니다.'
5888
- )
5889
- lines.append(
5890
- "BM25 도구 발견은 백엔드 internal 기능입니다. 모델은 검색 함수를 호출하지 "
5891
- "않고, backend가 tools[]에 실어준 concrete adapter function을 우선 "
5892
- "호출합니다."
5893
- )
5894
- lines.append("</available_adapters>")
5895
- return "\n".join(lines)
5896
6273
 
5897
6274
  # Spec 1978 T053 — eager-import the Mock adapter tree so every adapter
5898
6275
  # self-registers with its primitive dispatcher before the first chat
@@ -6342,9 +6719,28 @@ async def run( # noqa: C901
6342
6719
  span.set_attribute("ummaya.tool.dispatched", fname)
6343
6720
  span.set_attribute("ummaya.session.id", session_id)
6344
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)
6345
6741
  invalid_gated_tool_id = (
6346
6742
  _invalid_gated_primitive_tool_id_result(fname, args_obj)
6347
- if fname in _PERMISSION_GATED_PRIMITIVES
6743
+ if fname in _PERMISSION_GATED_PRIMITIVES and not local_document_harness_call
6348
6744
  else None
6349
6745
  )
6350
6746
  if invalid_gated_tool_id is not None:
@@ -6375,13 +6771,17 @@ async def run( # noqa: C901
6375
6771
  return
6376
6772
 
6377
6773
  # ----- Permission gate (T043-T049) -----
6378
- allowed = await _check_permission_gate(
6379
- call_id, fname, args_obj, session_id, correlation_id
6380
- )
6381
- if not allowed:
6382
- # Gate already resolved the Future with an error envelope.
6383
- span.set_attribute("ummaya.permission.decision", "deny")
6384
- 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")
6385
6785
 
6386
6786
  result_payload: dict[str, object] = {}
6387
6787
  dispatch_error: str | None = None
@@ -6403,7 +6803,43 @@ async def run( # noqa: C901
6403
6803
  _outbound_trace_token = start_outbound_capture()
6404
6804
 
6405
6805
  try:
6406
- 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":
6407
6843
  from ummaya.primitives.verify import ( # noqa: PLC0415
6408
6844
  verify,
6409
6845
  )
@@ -6476,6 +6912,14 @@ async def run( # noqa: C901
6476
6912
  LookupFetchInput,
6477
6913
  )
6478
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
+ )
6479
6923
  requested_mode = args_obj.get("mode")
6480
6924
  if requested_mode is not None and str(requested_mode) != "fetch":
6481
6925
  logger.warning(
@@ -6516,17 +6960,31 @@ async def run( # noqa: C901
6516
6960
  lookup_params,
6517
6961
  auth_context,
6518
6962
  )
6519
- inp_lk = LookupFetchInput(
6520
- mode="fetch",
6521
- tool_id=str(args_obj.get("tool_id", "")),
6522
- params=lookup_params,
6523
- )
6524
- raw = await find(
6525
- inp_lk,
6526
- registry=registry,
6527
- executor=executor,
6528
- session_identity=session_id,
6529
- )
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
+ )
6530
6988
  result_payload = {
6531
6989
  "kind": "find",
6532
6990
  "result": _serialize_primitive_result(raw),
@@ -6815,9 +7273,23 @@ async def run( # noqa: C901
6815
7273
  # CC-style loop contract: the model can paint progress prose, then call
6816
7274
  # a primitive dispatcher with a concrete adapter in `tool_id`.
6817
7275
  registry = cast("Any", _ensure_tool_registry())
6818
- backend_tools_raw = [
6819
- t.to_openai_tool() for t in _select_concrete_adapter_tools_for_turn(latest_user_utt)
6820
- ]
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]
6821
7293
  backend_tool_names: set[object] = set()
6822
7294
  for raw_tool in backend_tools_raw:
6823
7295
  if not isinstance(raw_tool, dict):
@@ -6951,7 +7423,11 @@ async def run( # noqa: C901
6951
7423
  (latest_user_utt or "")[:256],
6952
7424
  )
6953
7425
  if latest_user_utt:
6954
- 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
+ )
6955
7431
  if suffix_block:
6956
7432
  augmented_system = augmented_system + "\n\n" + suffix_block + "\n"
6957
7433
  except Exception: # noqa: BLE001 — fail-open per FR-002
@@ -7024,6 +7500,7 @@ async def run( # noqa: C901
7024
7500
  force_verify_next_turn: str | None = None
7025
7501
  force_lookup_next_turn: str | None = None
7026
7502
  force_submit_next_turn: str | None = None
7503
+ force_document_next_turn: str | None = None
7027
7504
  force_no_tools_next_turn = False
7028
7505
  continue_free_next_turn = False
7029
7506
  mock_disclosure_required = False
@@ -7180,20 +7657,33 @@ async def run( # noqa: C901
7180
7657
  force_submit_next_turn,
7181
7658
  )
7182
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
7183
7671
  elif (
7184
7672
  force_locate_next_turn
7185
7673
  or force_verify_next_turn is not None
7186
7674
  or force_lookup_next_turn is not None
7187
7675
  or force_submit_next_turn is not None
7676
+ or force_document_next_turn is not None
7188
7677
  ):
7189
7678
  logger.warning(
7190
7679
  "_handle_chat_request: continuing turn %d with free tool_choice "
7191
- "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)",
7192
7681
  _turn,
7193
7682
  force_locate_next_turn,
7194
7683
  force_verify_next_turn,
7195
7684
  force_lookup_next_turn,
7196
7685
  force_submit_next_turn,
7686
+ force_document_next_turn,
7197
7687
  )
7198
7688
  try:
7199
7689
  stream_kwargs: dict[str, object] = {
@@ -7490,6 +7980,37 @@ async def run( # noqa: C901
7490
7980
  buffered_visible.clear()
7491
7981
  continue
7492
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
+
7493
8014
  from ummaya.llm.tool_call_parser import ( # noqa: PLC0415
7494
8015
  strip_leaked_thinking_markers,
7495
8016
  )
@@ -7503,9 +8024,46 @@ async def run( # noqa: C901
7503
8024
  else:
7504
8025
  merged_prose = _remove_unneeded_mock_disclosure(merged_prose)
7505
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
7506
8058
  has_successful_tool_result = _conversation_has_successful_any_primitive_result(
7507
8059
  llm_messages
7508
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
7509
8067
  if (
7510
8068
  merged_prose.strip()
7511
8069
  and _final_answer_looks_like_tool_call_narration(merged_prose)
@@ -7525,6 +8083,47 @@ async def run( # noqa: C901
7525
8083
  )
7526
8084
  buffered_visible.clear()
7527
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
7528
8127
  if not merged_prose.strip() and has_successful_tool_result:
7529
8128
  if empty_final_retry_count < 2:
7530
8129
  empty_final_retry_count += 1
@@ -8520,6 +9119,7 @@ async def run( # noqa: C901
8520
9119
  or force_verify_next_turn is not None
8521
9120
  or force_lookup_next_turn is not None
8522
9121
  or force_submit_next_turn is not None
9122
+ or force_document_next_turn is not None
8523
9123
  or continue_free_next_turn
8524
9124
  ):
8525
9125
  if continue_free_next_turn:
@@ -8919,10 +9519,13 @@ async def run( # noqa: C901
8919
9519
  user_query=_session_latest_user_utterances.get(frame.session_id, ""),
8920
9520
  )
8921
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)
8922
9525
  dispatch_args = _normalize_lookup_args_for_query(
8923
9526
  dispatch_name,
8924
9527
  dispatch_args,
8925
- _session_latest_user_utterances.get(frame.session_id, ""),
9528
+ lookup_context,
8926
9529
  )
8927
9530
 
8928
9531
  kma_aviation_choice_msg = _check_kma_aviation_tool_choice_prerequisite(
@@ -9145,6 +9748,35 @@ async def run( # noqa: C901
9145
9748
  await _handle_tool_call(frame)
9146
9749
  except Exception as exc: # noqa: BLE001
9147
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")
9148
9780
 
9149
9781
  elif frame.kind == "permission_response":
9150
9782
  # Spec 1978 T047 — resolve pending permission Future.