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