ummaya 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/README.md +2 -1
  2. package/npm-shrinkwrap.json +2 -2
  3. package/package.json +1 -1
  4. package/prompts/manifest.yaml +2 -2
  5. package/prompts/session_guidance_v1.md +3 -1
  6. package/prompts/system_v1.md +8 -7
  7. package/pyproject.toml +2 -7
  8. package/src/ummaya/context/builder.py +17 -11
  9. package/src/ummaya/engine/engine.py +27 -7
  10. package/src/ummaya/engine/query.py +20 -0
  11. package/src/ummaya/evidence/__init__.py +25 -0
  12. package/src/ummaya/evidence/__main__.py +7 -0
  13. package/src/ummaya/evidence/models.py +58 -0
  14. package/src/ummaya/evidence/runner.py +308 -0
  15. package/src/ummaya/evidence/task_registry.py +264 -0
  16. package/src/ummaya/ipc/frame_schema.py +47 -0
  17. package/src/ummaya/ipc/stdio.py +1349 -90
  18. package/src/ummaya/llm/client.py +132 -56
  19. package/src/ummaya/llm/reasoning.py +84 -0
  20. package/src/ummaya/tools/discovery_bridge.py +17 -1
  21. package/src/ummaya/tools/executor.py +32 -12
  22. package/src/ummaya/tools/geocoding/kakao_client.py +1 -2
  23. package/src/ummaya/tools/kma/apihub_catalog.py +984 -1
  24. package/src/ummaya/tools/kma/apihub_structured_adapter.py +86 -6
  25. package/src/ummaya/tools/kma/apihub_url_adapter.py +593 -0
  26. package/src/ummaya/tools/kma/apihub_url_catalog.py +296 -0
  27. package/src/ummaya/tools/location_adapters.py +8 -6
  28. package/src/ummaya/tools/manifest_metadata.py +16 -3
  29. package/src/ummaya/tools/mvp_surface.py +2 -2
  30. package/src/ummaya/tools/nmc/emergency_search.py +8 -6
  31. package/src/ummaya/tools/register_all.py +9 -0
  32. package/src/ummaya/tools/resolve_location.py +4 -4
  33. package/src/ummaya/tools/search.py +664 -18
  34. package/src/ummaya/tools/verified_data_go_kr/_manifest.py +115 -25
  35. package/src/ummaya/tools/verified_data_go_kr/airkorea_air_quality.py +109 -4
  36. package/src/ummaya/tools/verified_data_go_kr/nmc_aed_site.py +108 -2
  37. package/src/ummaya/tools/verified_data_go_kr/pps_bid_public_info.py +174 -9
  38. package/src/ummaya/tools/verified_data_go_kr/tago_bus_arrival.py +66 -3
  39. package/src/ummaya/tools/verified_data_go_kr/tago_bus_location.py +12 -2
  40. package/src/ummaya/tools/verified_data_go_kr/tago_bus_route.py +8 -2
  41. package/src/ummaya/tools/verified_data_go_kr/tago_bus_route_station.py +114 -0
  42. package/src/ummaya/tools/verified_data_go_kr/tago_bus_station.py +14 -3
  43. package/src/ummaya/tools/verify_canonical_map.py +21 -0
  44. package/tui/package.json +1 -2
  45. package/tui/src/QueryEngine.ts +4 -0
  46. package/tui/src/cli/handlers/auth.ts +1 -1
  47. package/tui/src/cli/handlers/mcp.tsx +3 -3
  48. package/tui/src/cli/print.ts +69 -18
  49. package/tui/src/cli/update.ts +13 -13
  50. package/tui/src/commands/copy/index.ts +1 -1
  51. package/tui/src/commands/cost/cost.ts +2 -2
  52. package/tui/src/commands/init-verifiers.ts +5 -5
  53. package/tui/src/commands/init.ts +30 -30
  54. package/tui/src/commands/insights.ts +43 -43
  55. package/tui/src/commands/install-github-app/install-github-app.tsx +2 -2
  56. package/tui/src/commands/install-github-app/setupGitHubActions.ts +3 -3
  57. package/tui/src/commands/install.tsx +5 -5
  58. package/tui/src/commands/mcp/addCommand.ts +5 -5
  59. package/tui/src/commands/mcp/xaaIdpCommand.ts +2 -2
  60. package/tui/src/commands/plugin/ManageMarketplaces.tsx +2 -2
  61. package/tui/src/commands/reasoning/index.ts +13 -0
  62. package/tui/src/commands/reasoning/reasoning.tsx +177 -0
  63. package/tui/src/commands/thinkback/thinkback.tsx +3 -3
  64. package/tui/src/commands.ts +2 -0
  65. package/tui/src/components/Messages.tsx +2 -1
  66. package/tui/src/components/Spinner.tsx +2 -2
  67. package/tui/src/components/design-system/LoadingState.tsx +2 -2
  68. package/tui/src/ipc/codec.ts +26 -0
  69. package/tui/src/ipc/frames.generated.ts +398 -303
  70. package/tui/src/ipc/llmClient.ts +130 -51
  71. package/tui/src/ipc/llmTypes.ts +16 -1
  72. package/tui/src/ipc/schema/frame.schema.json +1 -3475
  73. package/tui/src/main.tsx +3 -0
  74. package/tui/src/query.ts +467 -2
  75. package/tui/src/screens/REPL.tsx +3 -3
  76. package/tui/src/services/api/claude.ts +54 -25
  77. package/tui/src/services/api/client.ts +33 -12
  78. package/tui/src/services/api/ummaya.ts +70 -16
  79. package/tui/src/skills/bundled/stuck.ts +12 -12
  80. package/tui/src/state/AppStateStore.ts +7 -0
  81. package/tui/src/tools/AdapterTool/AdapterTool.ts +590 -7
  82. package/tui/src/tools/LookupPrimitive/LookupPrimitive.ts +43 -17
  83. package/tui/src/tools/LookupPrimitive/prompt.ts +7 -6
  84. package/tui/src/tools/ResolveLocationPrimitive/ResolveLocationPrimitive.ts +40 -19
  85. package/tui/src/tools/SubmitPrimitive/SubmitPrimitive.ts +25 -9
  86. package/tui/src/tools/VerifyPrimitive/VerifyPrimitive.ts +25 -9
  87. package/tui/src/tools/_shared/citizenUserText.ts +49 -0
  88. package/tui/src/tools/_shared/directPublicDataGuard.ts +362 -0
  89. package/tui/src/tools/_shared/kmaAnalysisGuard.ts +197 -0
  90. package/tui/src/tools/_shared/kmaAviationGuard.ts +70 -0
  91. package/tui/src/tools/_shared/locationInputRepair.ts +112 -0
  92. package/tui/src/tools/_shared/nmcAedGuard.ts +234 -0
  93. package/tui/src/tools/_shared/protectedCheckGuard.ts +207 -0
  94. package/tui/src/tools/_shared/rootPrimitiveInput.ts +67 -0
  95. package/tui/src/tools/_shared/textToolCallGuard.ts +91 -0
  96. package/tui/src/tools/_shared/toolChoiceRepair.ts +866 -0
  97. package/tui/src/utils/attachments.ts +1 -1
  98. package/tui/src/utils/kExaoneReasoning.ts +138 -0
  99. package/tui/src/utils/messages.ts +1 -0
  100. package/tui/src/utils/multiToolLayout.ts +13 -0
  101. package/tui/src/utils/processUserInput/processSlashCommand.tsx +2 -2
  102. package/tui/src/utils/processUserInput/processUserInput.ts +26 -0
  103. package/tui/src/utils/settings/applySettingsChange.ts +4 -0
  104. package/tui/src/utils/settings/types.ts +9 -3
  105. package/tui/src/utils/stats.ts +1 -1
  106. package/uv.lock +1 -15
  107. package/assets/copilot-gate-logo.svg +0 -58
  108. package/assets/govon-logo.svg +0 -40
  109. package/src/ummaya/eval/__init__.py +0 -5
  110. package/src/ummaya/eval/retrieval.py +0 -713
  111. package/tui/src/utils/messageStream.ts +0 -186
@@ -0,0 +1,866 @@
1
+ import type { BetaToolChoiceTool } from '../../sdk-compat.js'
2
+ import type { Tools } from '../../Tool.js'
3
+ import type { Message } from '../../types/message.js'
4
+ import {
5
+ buildNmcAedFollowupPromptIfNeeded,
6
+ textFromContent,
7
+ } from './nmcAedGuard.js'
8
+ import { resolveAdapter } from '../../services/api/adapterManifest.js'
9
+ import {
10
+ isKmaAnalysisMapText,
11
+ KMA_ANALYSIS_CHART_TOOL_NAME,
12
+ } from './kmaAnalysisGuard.js'
13
+ import { isNonSyntheticUserMessageText } from './citizenUserText.js'
14
+
15
+ const KMA_AIR_TOOLS = [
16
+ 'kma_apihub_url_air_metar_decoded',
17
+ 'kma_apihub_url_air_amos_minute',
18
+ ] as const
19
+ const NMC_EMERGENCY_TOOL_NAME = 'nmc_emergency_search'
20
+ const NMC_AED_TOOL_NAME = 'nmc_aed_site_locate'
21
+ const KAKAO_COORD_TO_REGION_TOOL_NAME = 'kakao_coord_to_region'
22
+ const TAGO_STATION_TOOL_NAME = 'tago_bus_station_search'
23
+ const TAGO_ROUTE_TOOL_NAME = 'tago_bus_route_search'
24
+ const TAGO_ROUTE_STATION_TOOL_NAME = 'tago_bus_route_station_search'
25
+ const TAGO_ARRIVAL_TOOL_NAME = 'tago_bus_arrival_search'
26
+ const PPS_BID_TOOL_NAME = 'pps_bid_public_info'
27
+ const AIRKOREA_TOOL_NAME = 'airkorea_ctprvn_air_quality'
28
+ const PROTECTED_CHECK_TOOLS = [
29
+ 'mock_verify_module_simple_auth',
30
+ 'mock_verify_ganpyeon_injeung',
31
+ 'mock_verify_mobile_id',
32
+ 'mock_verify_mydata',
33
+ ] as const
34
+
35
+ const AIRPORT_PLACE_RE =
36
+ /(김해|김포|김해공항|김포공항|gimhae|gimpo|rkpk|rkss|\bairport\b|공항)/iu
37
+ const AIRPORT_AVIATION_RE =
38
+ /(비행기|항공편|비행편|운항|이륙|착륙|결항|지연|뜰\s*만|뜨나|뜰\s*수|flight|take\s*off|landing|delay|cancel|metar|speci|amos|rvr|활주로|시정|visibility|공항기상|항공기상)/iu
39
+ const AMOS_PREFERENCE_RE = /(amos|rvr|활주로|runway)/iu
40
+ const GIMPO_RE = /(김포|김포공항|gimpo|rkss)/iu
41
+ const PROTECTED_QUERY_RE =
42
+ /(본인확인|인증|간편인증|모바일\s*(?:신분증|id)|mobile\s*id|마이데이터|mydata|증명원|소득금액증명|소득금액증명원|주민등록등본|민원|발급)/iu
43
+ const MOBILE_ID_RE = /(mobile\s*id|모바일\s*(?:신분증|id)|mobile_id)/iu
44
+ const SIMPLE_AUTH_RE =
45
+ /(simple_auth|간편인증|ganpyeon|소득금액증명|증명원|민원|발급)/iu
46
+ const MYDATA_RE = /(mydata|마이데이터)/iu
47
+ const MEDICAL_COLLAPSE_RE =
48
+ /(사람이\s*쓰러|쓰러졌|쓰러짐|쓰러져|의식[을이가은는\s]*(없|잃|불명)|무의식|심정지|심폐소생|cpr|호흡\s*(없|곤란)|숨\s*(안|못)|collapse|collapsed|unconscious|cardiac\s*arrest|not\s*breathing|aed|자동심장|심장충격|제세동)/iu
49
+ const MEDICAL_COLLAPSE_OR_ER_RE =
50
+ /(사람이\s*쓰러|쓰러졌|쓰러짐|쓰러져|의식[을이가은는\s]*(없|잃|불명)|무의식|심정지|심폐소생|cpr|호흡\s*(없|곤란)|숨\s*(안|못)|collapse|collapsed|unconscious|cardiac\s*arrest|not\s*breathing|응급실|응급의료기관|응급의료센터|emergency\s*room|\ber\b)/iu
51
+ const NON_MEDICAL_EMERGENCY_RE =
52
+ /(비상벨|안심벨|emergency\s*(call\s*)?box|call\s*box)/iu
53
+ const TAGO_BUS_RE =
54
+ /(버스|시내버스|정류장|정류소|노선|도착|언제\s*와|몇\s*분|bus|route|arrival|station)/iu
55
+ const TAGO_ROUTE_NO_RE = /(?:^|[^\d])(\d{1,4}(?:-\d)?)\s*번/u
56
+ const TAGO_PLACE_RE = /([가-힣A-Za-z0-9().·\s]+?)(?:에서|근처|앞|인근)/u
57
+ const PPS_BID_RE = /(입찰|나라장터|조달청|공고|공사조회|전기공사|bid|procurement)/iu
58
+ const AIRKOREA_RE =
59
+ /(미세먼지|초미세먼지|대기질|대기오염|마스크|pm\s*2\.?5|pm\s*10|air\s*korea|airkorea)/iu
60
+ const PUBLIC_DATA_MISMATCH_CALL_RE =
61
+ /Public-data tool-choice mismatch:[\s\S]*?\bCall\s+([a-z][a-z0-9_]*)\s+/iu
62
+ const TAGO_BUS_COMPLETION_PROMPT =
63
+ 'TAGO bus arrival evidence chain complete: the route search, route passing-stop lookup, and arrival lookup have already been attempted for this bus-arrival request. Do not call another location, public-data, or TAGO bus tool in this turn. Write the final Korean answer now from the actual TAGO results only. If the arrival result has zero items, say that no current matching arrival is shown for the checked stop/direction, and include the route/stop evidence that was found.'
64
+ const TAGO_BUS_REPAIR_PROMPT =
65
+ 'TAGO bus final answer repair: the previous answer still promised another stop/route lookup after TAGO arrival evidence was already attempted. Rewrite the final Korean answer now from the actual TAGO tool_result only. Do not say 확인하겠습니다, 확인해보겠습니다, 검색해 보겠습니다, 다시 조회, or that you will check another stop. If the arrival result has zero items, say no current arrival is shown for the checked 부산역 stop and ask the citizen for a specific route number or exact stop only as a next-step option.'
66
+ const AIRKOREA_COMPLETION_PROMPT =
67
+ 'AirKorea air-quality evidence complete: airkorea_ctprvn_air_quality has already returned the official result for this 미세먼지/초미세먼지 request. Do not call another location, weather, public-data, or AirKorea tool in this turn. Write the final Korean answer now from the actual tool_result only. Include stationName, dataTime, PM10 value and pm10GradeLabelKo, PM2.5 value and pm25GradeLabelKo, and CAI/khaiValue with khaiGradeLabelKo when present. This adapter returns city/province measurement rows, not a geocoded nearest-station result: say it is city/province station data, use only exact stationName rows present in tool_result, and do not infer the citizen place district, distance, nearest station, station groups, or value ranges unless those exact fields exist in tool_result. If totalCount is 0 or items are empty, say the official AirKorea API returned no rows for the checked sidoName and do not say you are still checking.'
68
+ const AIRKOREA_REPAIR_PROMPT =
69
+ 'AirKorea final answer repair: the previous answer inferred nearest/direction/distance, average/range values, district groups, or changed raw values beyond the official tool_result. Rewrite the final Korean answer using only the exact AirKorea rows listed below. Do not say 가장 가까운, 인근, 동쪽, 서쪽, 남쪽, 북쪽, distance, nearest, 평균, 범위, 대부분, 해운대구 지역, or average unless those exact fields exist in tool_result. Copy PM10, PM2.5, CAI, stationName, dataTime, and grade labels literally. If the citizen named a place but the tool returned only city/province rows, say this is city/province station data rather than a nearest-station answer.'
70
+ const AIRKOREA_UNSUPPORTED_LOCATION_CLAIM_RE =
71
+ /(가장\s*가까운|인근|동쪽|서쪽|남쪽|북쪽|거리|평균|범위|대부분|전체적으로|해운대구\s*지역|명확한\s*측정소|지역에\s*해당|nearest|distance|average|range)/iu
72
+ const TAGO_BUS_PENDING_FINAL_RE =
73
+ /(확인해\s*보겠습니다|확인하겠습니다|검색해\s*보겠습니다|검색하겠습니다|다른\s*정류장|다시\s*(?:조회|확인|검색)|조회하겠습니다|will\s+check|will\s+search|will\s+look\s+up)/iu
74
+ const GENERIC_PENDING_FINAL_RE =
75
+ /(답변을?\s*제공하겠습니다|제공하겠습니다|확인해\s*보겠습니다|확인하겠습니다|조회하겠습니다|찾아보겠습니다|검색해\s*보겠습니다|검색하겠습니다|최종\s*답변은|final answer should|will\s+(?:answer|provide|check|search|look\s+up))/iu
76
+ const GENERIC_PENDING_FINAL_REPAIR_PROMPT =
77
+ 'Final answer repair: successful tool_result evidence already exists, but the previous assistant message was still a plan or promise to answer later. Write the final Korean answer now from the actual tool_result values only. Do not say 제공하겠습니다, 확인하겠습니다, 조회하겠습니다, 찾아보겠습니다, 검색해 보겠습니다, or describe what you will answer next.'
78
+
79
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
80
+ return typeof value === 'object' && value !== null
81
+ ? (value as Record<string, unknown>)
82
+ : undefined
83
+ }
84
+
85
+ function messageRecord(message: unknown): Record<string, unknown> | undefined {
86
+ return asRecord(asRecord(message)?.message)
87
+ }
88
+
89
+ function messageRole(message: unknown): string | undefined {
90
+ const outer = asRecord(message)
91
+ const inner = messageRecord(message)
92
+ if (typeof inner?.role === 'string') return inner.role
93
+ if (typeof outer?.role === 'string') return outer.role
94
+ return typeof outer?.type === 'string' ? outer.type : undefined
95
+ }
96
+
97
+ function messageContent(message: unknown): unknown {
98
+ return messageRecord(message)?.content ?? asRecord(message)?.content
99
+ }
100
+
101
+ function latestUserText(messages: readonly unknown[]): string {
102
+ for (let idx = messages.length - 1; idx >= 0; idx -= 1) {
103
+ const message = messages[idx]
104
+ if (messageRole(message) !== 'user') continue
105
+ const text = textFromContent(messageContent(message))
106
+ if (isNonSyntheticUserMessageText(message, text)) return text
107
+ }
108
+ return ''
109
+ }
110
+
111
+ function toolUseNames(messages: readonly unknown[]): Set<string> {
112
+ const names = new Set<string>()
113
+ for (const message of messages) {
114
+ const content = messageContent(message)
115
+ if (!Array.isArray(content)) continue
116
+ for (const block of content) {
117
+ const record = asRecord(block)
118
+ if (record?.type === 'tool_use') {
119
+ const input = asRecord(record.input)
120
+ const nestedToolName =
121
+ typeof input?.tool_id === 'string' ? input.tool_id : undefined
122
+ if (nestedToolName) names.add(nestedToolName)
123
+ else if (typeof record.name === 'string') names.add(record.name)
124
+ continue
125
+ }
126
+ if (record?.type !== 'tool_result') continue
127
+ if (typeof record.content !== 'string') continue
128
+ const parsed = parseJsonRecord(record.content)
129
+ const source = asRecord(asRecord(parsed?.result)?.meta)?.source
130
+ if (typeof source === 'string') names.add(source)
131
+ }
132
+ }
133
+ return names
134
+ }
135
+
136
+ function latestToolResultText(messages: readonly unknown[]): string {
137
+ for (let idx = messages.length - 1; idx >= 0; idx -= 1) {
138
+ const content = messageContent(messages[idx])
139
+ if (!Array.isArray(content)) continue
140
+ const parts: string[] = []
141
+ for (const block of content) {
142
+ const record = asRecord(block)
143
+ if (record?.type !== 'tool_result') continue
144
+ if (typeof record.content === 'string') {
145
+ parts.push(record.content)
146
+ } else {
147
+ const text = textFromContent(record.content)
148
+ if (text.trim()) parts.push(text)
149
+ }
150
+ }
151
+ if (parts.length > 0) return parts.join('\n')
152
+ }
153
+ return ''
154
+ }
155
+
156
+ function latestAssistantText(messages: readonly unknown[]): string {
157
+ for (let idx = messages.length - 1; idx >= 0; idx -= 1) {
158
+ if (messageRole(messages[idx]) !== 'assistant') continue
159
+ const text = textFromContent(messageContent(messages[idx]))
160
+ if (text.trim()) return text
161
+ }
162
+ return ''
163
+ }
164
+
165
+ function hasAirKoreaRepairPrompt(messages: readonly unknown[]): boolean {
166
+ return messages.some(message => textFromContent(messageContent(message)).includes('AirKorea final answer repair'))
167
+ }
168
+
169
+ function hasTagoBusRepairPrompt(messages: readonly unknown[]): boolean {
170
+ return messages.some(message => textFromContent(messageContent(message)).includes('TAGO bus final answer repair'))
171
+ }
172
+
173
+ function hasGenericPendingFinalRepairPrompt(messages: readonly unknown[]): boolean {
174
+ return messages.some(message => textFromContent(messageContent(message)).includes('Final answer repair: successful tool_result'))
175
+ }
176
+
177
+ function hasToolResult(messages: readonly unknown[]): boolean {
178
+ return messages.some(message => {
179
+ const content = messageContent(message)
180
+ if (!Array.isArray(content)) return false
181
+ return content.some(block => asRecord(block)?.type === 'tool_result')
182
+ })
183
+ }
184
+
185
+ function toolResultTextsFor(
186
+ messages: readonly unknown[],
187
+ toolName: string,
188
+ ): string[] {
189
+ const idToName = toolUseById(messages)
190
+ const texts: string[] = []
191
+ for (const message of messages) {
192
+ const content = messageContent(message)
193
+ if (!Array.isArray(content)) continue
194
+ for (const block of content) {
195
+ const record = asRecord(block)
196
+ if (record?.type !== 'tool_result') continue
197
+ if (typeof record.tool_use_id !== 'string') continue
198
+ const mappedToolName = idToName.get(record.tool_use_id)
199
+ if (mappedToolName === toolName) {
200
+ if (typeof record.content === 'string') texts.push(record.content)
201
+ continue
202
+ }
203
+ if (typeof record.content !== 'string') continue
204
+ const parsed = parseJsonRecord(record.content)
205
+ const source = asRecord(asRecord(parsed?.result)?.meta)?.source
206
+ if (source === toolName) texts.push(record.content)
207
+ }
208
+ }
209
+ return texts
210
+ }
211
+
212
+ function toolUseById(messages: readonly unknown[]): Map<string, string> {
213
+ const ids = new Map<string, string>()
214
+ for (const message of messages) {
215
+ const content = messageContent(message)
216
+ if (!Array.isArray(content)) continue
217
+ for (const block of content) {
218
+ const record = asRecord(block)
219
+ if (record?.type !== 'tool_use') continue
220
+ if (typeof record.id !== 'string' || typeof record.name !== 'string') {
221
+ continue
222
+ }
223
+ const input = asRecord(record.input)
224
+ const nestedToolName =
225
+ typeof input?.tool_id === 'string' ? input.tool_id : undefined
226
+ ids.set(record.id, nestedToolName ?? record.name)
227
+ }
228
+ }
229
+ return ids
230
+ }
231
+
232
+ function parseJsonRecord(value: string): Record<string, unknown> | undefined {
233
+ try {
234
+ return asRecord(JSON.parse(value))
235
+ } catch {
236
+ return undefined
237
+ }
238
+ }
239
+
240
+ function stringField(record: Record<string, unknown>, key: string): string | undefined {
241
+ const value = record[key]
242
+ if (value === null || value === undefined) return undefined
243
+ const text = String(value).trim()
244
+ return text === '' ? undefined : text
245
+ }
246
+
247
+ function airKoreaExactRowsSummary(messages: readonly unknown[]): string | undefined {
248
+ const rows: string[] = []
249
+ for (const text of toolResultTextsFor(messages, AIRKOREA_TOOL_NAME)) {
250
+ const parsed = parseJsonRecord(text)
251
+ const result = asRecord(parsed?.result)
252
+ const items = Array.isArray(result?.items) ? result.items : []
253
+ for (const item of items) {
254
+ const record = asRecord(asRecord(item)?.record)
255
+ if (!record) continue
256
+ const stationName = stringField(record, 'stationName')
257
+ const dataTime = stringField(record, 'dataTime')
258
+ const pm10Value = stringField(record, 'pm10Value')
259
+ const pm10Grade = stringField(record, 'pm10GradeLabelKo')
260
+ const pm25Value = stringField(record, 'pm25Value')
261
+ const pm25Grade = stringField(record, 'pm25GradeLabelKo')
262
+ const khaiValue = stringField(record, 'khaiValue')
263
+ const khaiGrade = stringField(record, 'khaiGradeLabelKo')
264
+ if (!stationName) continue
265
+ rows.push(
266
+ `- ${stationName}: dataTime=${dataTime ?? '없음'}, PM10=${pm10Value ?? '없음'}${pm10Grade ? `(${pm10Grade})` : ''}, PM2.5=${pm25Value ?? '없음'}${pm25Grade ? `(${pm25Grade})` : ''}, CAI=${khaiValue ?? '없음'}${khaiGrade ? `(${khaiGrade})` : ''}`,
267
+ )
268
+ if (rows.length >= 8) break
269
+ }
270
+ if (rows.length >= 8) break
271
+ }
272
+ if (rows.length === 0) return undefined
273
+ return `\n\nExact AirKorea rows you may cite verbatim:\n${rows.join('\n')}`
274
+ }
275
+
276
+ function hasSuccessfulRegionLocateResult(messages: readonly unknown[]): boolean {
277
+ const idToName = toolUseById(messages)
278
+ for (const message of messages) {
279
+ const content = messageContent(message)
280
+ if (!Array.isArray(content)) continue
281
+ for (const block of content) {
282
+ const record = asRecord(block)
283
+ if (record?.type !== 'tool_result') continue
284
+ if (record.is_error === true) continue
285
+ if (typeof record.tool_use_id !== 'string') continue
286
+ const toolName = idToName.get(record.tool_use_id)
287
+ if (toolName !== 'locate' && !toolName?.startsWith('kakao_')) continue
288
+ if (typeof record.content !== 'string') continue
289
+ const parsed = parseJsonRecord(record.content)
290
+ if (parsed?.ok === false) continue
291
+ const result = asRecord(parsed?.result ?? parsed)
292
+ if (result?.kind !== 'region') continue
293
+ if (
294
+ typeof result.region_1depth_name === 'string' &&
295
+ typeof result.region_2depth_name === 'string'
296
+ ) {
297
+ return true
298
+ }
299
+ }
300
+ }
301
+ return false
302
+ }
303
+
304
+ function hasSuccessfulPoiLocateResult(messages: readonly unknown[]): boolean {
305
+ const idToName = toolUseById(messages)
306
+ for (const message of messages) {
307
+ const content = messageContent(message)
308
+ if (!Array.isArray(content)) continue
309
+ for (const block of content) {
310
+ const record = asRecord(block)
311
+ if (record?.type !== 'tool_result') continue
312
+ if (record.is_error === true) continue
313
+ if (typeof record.tool_use_id !== 'string') continue
314
+ const toolName = idToName.get(record.tool_use_id)
315
+ if (toolName !== 'locate' && !toolName?.startsWith('kakao_')) continue
316
+ if (typeof record.content !== 'string') continue
317
+ const parsed = parseJsonRecord(record.content)
318
+ if (parsed?.ok === false) continue
319
+ const result = asRecord(parsed?.result ?? parsed)
320
+ if (result?.kind !== 'poi') continue
321
+ if (typeof result.lat === 'number' && typeof result.lon === 'number') {
322
+ return true
323
+ }
324
+ }
325
+ }
326
+ return false
327
+ }
328
+
329
+ function availableToolNamesFromTools(tools: Tools): Set<string> {
330
+ return new Set(tools.map(tool => tool.name))
331
+ }
332
+
333
+ function isAvailableOrSyncedAdapter(
334
+ available: Set<string>,
335
+ toolName: string,
336
+ ): boolean {
337
+ return available.has(toolName) || resolveAdapter(toolName) !== undefined
338
+ }
339
+
340
+ function chooseAvailableOrSyncedAdapter(
341
+ available: Set<string>,
342
+ candidates: readonly string[],
343
+ ): string | undefined {
344
+ return candidates.find(candidate => isAvailableOrSyncedAdapter(available, candidate))
345
+ }
346
+
347
+ function isAirportAviationQuery(text: string): boolean {
348
+ return AIRPORT_PLACE_RE.test(text) && AIRPORT_AVIATION_RE.test(text)
349
+ }
350
+
351
+ function isMedicalCollapseQuery(text: string): boolean {
352
+ return MEDICAL_COLLAPSE_RE.test(text) && !NON_MEDICAL_EMERGENCY_RE.test(text)
353
+ }
354
+
355
+ function routeNoFromUserText(text: string): string | undefined {
356
+ return TAGO_ROUTE_NO_RE.exec(text)?.[1]
357
+ }
358
+
359
+ function placeNameFromUserText(text: string): string | undefined {
360
+ const match = TAGO_PLACE_RE.exec(text)
361
+ const place = match?.[1]?.trim()
362
+ if (!place) return undefined
363
+ return place.replace(/\s+/gu, ' ')
364
+ }
365
+
366
+ function isTagoRoutePlaceQuery(text: string): boolean {
367
+ return TAGO_BUS_RE.test(text) && routeNoFromUserText(text) !== undefined
368
+ }
369
+
370
+ function isTagoBusQuery(text: string): boolean {
371
+ return TAGO_BUS_RE.test(text)
372
+ }
373
+
374
+ function isTagoBusOriginDestinationQuery(text: string): boolean {
375
+ if (!isTagoBusQuery(text) || routeNoFromUserText(text) !== undefined) return false
376
+ return /에서[\s\S]{1,50}(?:까지|으로|로|가는|가려고|가야|가려면|이동|방향|행)/u.test(
377
+ text,
378
+ )
379
+ }
380
+
381
+ function hasSuccessfulStationSearch(messages: readonly unknown[]): boolean {
382
+ return toolResultTextsFor(messages, TAGO_STATION_TOOL_NAME).some(
383
+ text => text.includes('"ok":true') && text.includes('"nodeid"'),
384
+ )
385
+ }
386
+
387
+ function hasSuccessfulRouteSearch(messages: readonly unknown[]): boolean {
388
+ return toolResultTextsFor(messages, TAGO_ROUTE_TOOL_NAME).some(
389
+ text => text.includes('"ok":true') && text.includes('"routeid"'),
390
+ )
391
+ }
392
+
393
+ function hasSuccessfulRouteStationSearch(messages: readonly unknown[]): boolean {
394
+ return toolResultTextsFor(messages, TAGO_ROUTE_STATION_TOOL_NAME).some(
395
+ text => text.includes('"ok":true') && text.includes('"nodeid"'),
396
+ )
397
+ }
398
+
399
+ function selectTagoBusToolChoice(
400
+ userText: string,
401
+ available: Set<string>,
402
+ usedToolNames: Set<string>,
403
+ messages: readonly unknown[],
404
+ ): string | undefined {
405
+ if (!isTagoBusQuery(userText)) return undefined
406
+ if (routeNoFromUserText(userText) === undefined) {
407
+ if (
408
+ !usedToolNames.has(TAGO_STATION_TOOL_NAME) &&
409
+ isAvailableOrSyncedAdapter(available, TAGO_STATION_TOOL_NAME)
410
+ ) {
411
+ return TAGO_STATION_TOOL_NAME
412
+ }
413
+ if (isTagoBusOriginDestinationQuery(userText)) {
414
+ return undefined
415
+ }
416
+ if (
417
+ hasSuccessfulStationSearch(messages) &&
418
+ !usedToolNames.has(TAGO_ARRIVAL_TOOL_NAME) &&
419
+ isAvailableOrSyncedAdapter(available, TAGO_ARRIVAL_TOOL_NAME)
420
+ ) {
421
+ return TAGO_ARRIVAL_TOOL_NAME
422
+ }
423
+ return undefined
424
+ }
425
+ if (
426
+ !usedToolNames.has(TAGO_ROUTE_TOOL_NAME) &&
427
+ isAvailableOrSyncedAdapter(available, TAGO_ROUTE_TOOL_NAME)
428
+ ) {
429
+ return TAGO_ROUTE_TOOL_NAME
430
+ }
431
+ if (
432
+ hasSuccessfulRouteSearch(messages) &&
433
+ !usedToolNames.has(TAGO_ROUTE_STATION_TOOL_NAME) &&
434
+ isAvailableOrSyncedAdapter(available, TAGO_ROUTE_STATION_TOOL_NAME)
435
+ ) {
436
+ return TAGO_ROUTE_STATION_TOOL_NAME
437
+ }
438
+ if (
439
+ hasSuccessfulRouteStationSearch(messages) &&
440
+ !usedToolNames.has(TAGO_ARRIVAL_TOOL_NAME) &&
441
+ isAvailableOrSyncedAdapter(available, TAGO_ARRIVAL_TOOL_NAME)
442
+ ) {
443
+ return TAGO_ARRIVAL_TOOL_NAME
444
+ }
445
+ return undefined
446
+ }
447
+
448
+ export function buildTagoBusFollowupPromptIfNeeded({
449
+ messages,
450
+ availableToolNames,
451
+ }: {
452
+ messages: readonly unknown[]
453
+ availableToolNames: Iterable<string>
454
+ }): string | undefined {
455
+ const userText = latestUserText(messages)
456
+ if (!isTagoBusQuery(userText)) return undefined
457
+ const available = new Set(availableToolNames)
458
+ const usedToolNames = toolUseNames(messages)
459
+ const routeNo = routeNoFromUserText(userText)
460
+ const placeName = placeNameFromUserText(userText) ?? 'the citizen-named place'
461
+ if (
462
+ routeNo === undefined &&
463
+ isTagoBusOriginDestinationQuery(userText) &&
464
+ hasSuccessfulStationSearch(messages) &&
465
+ !usedToolNames.has(TAGO_ARRIVAL_TOOL_NAME)
466
+ ) {
467
+ return (
468
+ 'TAGO bus origin-destination limitation: the citizen asked for an origin-to-destination trip without a route number. ' +
469
+ 'The available TAGO adapters expose stop search, route-number search, route passing stops, and stop-based current arrivals; ' +
470
+ 'they do not prove an origin-destination route from stop arrivals alone. Do not call tago_bus_arrival_search for this OD request. ' +
471
+ 'Write the final Korean answer now from the station evidence, explain that current TAGO tools need a route number or exact stop/route to check arrivals, ' +
472
+ 'and do not claim that a bus route to the destination was found.'
473
+ )
474
+ }
475
+ if (
476
+ routeNo === undefined &&
477
+ hasSuccessfulStationSearch(messages) &&
478
+ !usedToolNames.has(TAGO_ARRIVAL_TOOL_NAME) &&
479
+ available.has(TAGO_ARRIVAL_TOOL_NAME)
480
+ ) {
481
+ return (
482
+ 'Required follow-up for this TAGO bus chain: the station search has returned nodeid. ' +
483
+ `Before any final answer, call ${TAGO_ARRIVAL_TOOL_NAME} with city_code:"21" ` +
484
+ 'and the best matching node_id from the station result to get current arrivals. ' +
485
+ 'Do not switch to route search unless the citizen named a route number.'
486
+ )
487
+ }
488
+ if (
489
+ routeNo !== undefined &&
490
+ hasSuccessfulRouteSearch(messages) &&
491
+ !usedToolNames.has(TAGO_ROUTE_STATION_TOOL_NAME) &&
492
+ available.has(TAGO_ROUTE_STATION_TOOL_NAME)
493
+ ) {
494
+ return (
495
+ 'Required follow-up for this TAGO bus chain: the route search has returned route_id. ' +
496
+ `Before any final answer, call ${TAGO_ROUTE_STATION_TOOL_NAME} with city_code:"21", ` +
497
+ `that route_id, and node_nm:"${placeName}" to find the route's passing stop nodeid.`
498
+ )
499
+ }
500
+ if (
501
+ routeNo !== undefined &&
502
+ hasSuccessfulRouteStationSearch(messages) &&
503
+ !usedToolNames.has(TAGO_ARRIVAL_TOOL_NAME) &&
504
+ available.has(TAGO_ARRIVAL_TOOL_NAME)
505
+ ) {
506
+ return (
507
+ 'Required follow-up for this TAGO bus chain: route passing-stop evidence has returned nodeid. ' +
508
+ `Before any final answer, call ${TAGO_ARRIVAL_TOOL_NAME} with city_code:"21", ` +
509
+ `a matching node_id, and route_no:"${routeNo}" or route_id to get current arrivals. ` +
510
+ 'If the first matching direction returns no arrivals, try the other matching nodeid before final prose.'
511
+ )
512
+ }
513
+ return undefined
514
+ }
515
+
516
+ export function buildTagoBusCompletionPromptIfNeeded({
517
+ messages,
518
+ }: {
519
+ messages: readonly unknown[]
520
+ }): string | undefined {
521
+ const userText = latestUserText(messages)
522
+ if (!isTagoBusQuery(userText)) return undefined
523
+ const usedToolNames = toolUseNames(messages)
524
+ if (routeNoFromUserText(userText) === undefined) {
525
+ return usedToolNames.has(TAGO_ARRIVAL_TOOL_NAME)
526
+ ? TAGO_BUS_COMPLETION_PROMPT
527
+ : undefined
528
+ }
529
+ if (
530
+ !usedToolNames.has(TAGO_ROUTE_STATION_TOOL_NAME) ||
531
+ !usedToolNames.has(TAGO_ARRIVAL_TOOL_NAME)
532
+ ) {
533
+ return undefined
534
+ }
535
+ return TAGO_BUS_COMPLETION_PROMPT
536
+ }
537
+
538
+ export function buildTagoBusFinalAnswerRepairPromptIfNeeded({
539
+ messages,
540
+ }: {
541
+ messages: readonly unknown[]
542
+ }): string | undefined {
543
+ const userText = latestUserText(messages)
544
+ if (!isTagoBusQuery(userText)) return undefined
545
+ const usedToolNames = toolUseNames(messages)
546
+ if (!usedToolNames.has(TAGO_ARRIVAL_TOOL_NAME)) return undefined
547
+ if (hasTagoBusRepairPrompt(messages)) return undefined
548
+ const assistantText = latestAssistantText(messages)
549
+ if (!TAGO_BUS_PENDING_FINAL_RE.test(assistantText)) return undefined
550
+ return TAGO_BUS_REPAIR_PROMPT
551
+ }
552
+
553
+ export function shouldWithholdTagoBusFinalAnswer({
554
+ messages,
555
+ candidate,
556
+ }: {
557
+ messages: readonly unknown[]
558
+ candidate: unknown
559
+ }): boolean {
560
+ if (hasTagoBusRepairPrompt(messages)) return false
561
+ return (
562
+ buildTagoBusFinalAnswerRepairPromptIfNeeded({
563
+ messages: [...messages, candidate],
564
+ }) !== undefined
565
+ )
566
+ }
567
+
568
+ export function buildAirKoreaCompletionPromptIfNeeded({
569
+ messages,
570
+ }: {
571
+ messages: readonly unknown[]
572
+ }): string | undefined {
573
+ const userText = latestUserText(messages)
574
+ if (!AIRKOREA_RE.test(userText)) return undefined
575
+ const usedToolNames = toolUseNames(messages)
576
+ if (!usedToolNames.has(AIRKOREA_TOOL_NAME)) return undefined
577
+ return AIRKOREA_COMPLETION_PROMPT + (airKoreaExactRowsSummary(messages) ?? '')
578
+ }
579
+
580
+ export function buildAirKoreaFinalAnswerRepairPromptIfNeeded({
581
+ messages,
582
+ }: {
583
+ messages: readonly unknown[]
584
+ }): string | undefined {
585
+ const userText = latestUserText(messages)
586
+ if (!AIRKOREA_RE.test(userText)) return undefined
587
+ const usedToolNames = toolUseNames(messages)
588
+ if (!usedToolNames.has(AIRKOREA_TOOL_NAME)) return undefined
589
+ if (hasAirKoreaRepairPrompt(messages)) return undefined
590
+ const assistantText = latestAssistantText(messages)
591
+ if (!AIRKOREA_UNSUPPORTED_LOCATION_CLAIM_RE.test(assistantText)) return undefined
592
+ return AIRKOREA_REPAIR_PROMPT + (airKoreaExactRowsSummary(messages) ?? '')
593
+ }
594
+
595
+ export function shouldWithholdAirKoreaFinalAnswer({
596
+ messages,
597
+ candidate,
598
+ }: {
599
+ messages: readonly unknown[]
600
+ candidate: unknown
601
+ }): boolean {
602
+ if (hasAirKoreaRepairPrompt(messages)) return false
603
+ return (
604
+ buildAirKoreaFinalAnswerRepairPromptIfNeeded({
605
+ messages: [...messages, candidate],
606
+ }) !== undefined
607
+ )
608
+ }
609
+
610
+ export function buildGenericPendingFinalAnswerRepairPromptIfNeeded({
611
+ messages,
612
+ }: {
613
+ messages: readonly unknown[]
614
+ }): string | undefined {
615
+ if (!hasToolResult(messages)) return undefined
616
+ if (hasGenericPendingFinalRepairPrompt(messages)) return undefined
617
+ const assistantText = latestAssistantText(messages)
618
+ if (!GENERIC_PENDING_FINAL_RE.test(assistantText)) return undefined
619
+ return GENERIC_PENDING_FINAL_REPAIR_PROMPT
620
+ }
621
+
622
+ export function shouldWithholdGenericPendingFinalAnswer({
623
+ messages,
624
+ candidate,
625
+ }: {
626
+ messages: readonly unknown[]
627
+ candidate: unknown
628
+ }): boolean {
629
+ if (hasGenericPendingFinalRepairPrompt(messages)) return false
630
+ return (
631
+ buildGenericPendingFinalAnswerRepairPromptIfNeeded({
632
+ messages: [...messages, candidate],
633
+ }) !== undefined
634
+ )
635
+ }
636
+
637
+ function needsEmergencyRoomSearch(text: string): boolean {
638
+ return MEDICAL_COLLAPSE_OR_ER_RE.test(text) && !NON_MEDICAL_EMERGENCY_RE.test(text)
639
+ }
640
+
641
+ function selectKmaAviationTool(
642
+ userText: string,
643
+ available: Set<string>,
644
+ usedToolNames: Set<string>,
645
+ preferUnused: boolean,
646
+ ): string | undefined {
647
+ const preferAmos = AMOS_PREFERENCE_RE.test(userText) && GIMPO_RE.test(userText)
648
+ const candidates = preferAmos
649
+ ? ['kma_apihub_url_air_amos_minute', 'kma_apihub_url_air_metar_decoded']
650
+ : ['kma_apihub_url_air_metar_decoded', 'kma_apihub_url_air_amos_minute']
651
+ if (preferUnused) {
652
+ const unused = candidates.filter(candidate => !usedToolNames.has(candidate))
653
+ const unusedChoice = chooseAvailableOrSyncedAdapter(available, unused)
654
+ if (unusedChoice) return unusedChoice
655
+ }
656
+ return chooseAvailableOrSyncedAdapter(available, candidates)
657
+ }
658
+
659
+ function selectProtectedCheckTool(
660
+ userText: string,
661
+ available: Set<string>,
662
+ ): string | undefined {
663
+ const candidates = [
664
+ MOBILE_ID_RE.test(userText) ? 'mock_verify_mobile_id' : undefined,
665
+ SIMPLE_AUTH_RE.test(userText) ? 'mock_verify_module_simple_auth' : undefined,
666
+ SIMPLE_AUTH_RE.test(userText) ? 'mock_verify_ganpyeon_injeung' : undefined,
667
+ MYDATA_RE.test(userText) ? 'mock_verify_mydata' : undefined,
668
+ ...PROTECTED_CHECK_TOOLS,
669
+ ].filter((toolName): toolName is string => typeof toolName === 'string')
670
+ return chooseAvailableOrSyncedAdapter(available, candidates)
671
+ }
672
+
673
+ function hasAnyToolUse(usedToolNames: Set<string>, candidates: readonly string[]): boolean {
674
+ return candidates.some(candidate => usedToolNames.has(candidate))
675
+ }
676
+
677
+ function requiredKmaAviationTools(
678
+ userText: string,
679
+ available: Set<string>,
680
+ ): string[] {
681
+ const candidates = GIMPO_RE.test(userText)
682
+ ? ['kma_apihub_url_air_metar_decoded', 'kma_apihub_url_air_amos_minute']
683
+ : ['kma_apihub_url_air_metar_decoded']
684
+ return candidates.filter(candidate => isAvailableOrSyncedAdapter(available, candidate))
685
+ }
686
+
687
+ export function shouldSuppressUmmayaToolCallsForAnswerSynthesis({
688
+ messages,
689
+ tools,
690
+ }: {
691
+ messages: readonly Message[]
692
+ tools: Tools
693
+ }): boolean {
694
+ const available = availableToolNamesFromTools(tools)
695
+ const userText = latestUserText(messages)
696
+ const usedToolNames = toolUseNames(messages)
697
+ if (
698
+ isMedicalCollapseQuery(userText) &&
699
+ usedToolNames.has(NMC_AED_TOOL_NAME)
700
+ ) {
701
+ return true
702
+ }
703
+ if (
704
+ PROTECTED_QUERY_RE.test(userText) &&
705
+ hasAnyToolUse(usedToolNames, PROTECTED_CHECK_TOOLS)
706
+ ) {
707
+ return true
708
+ }
709
+ if (
710
+ isKmaAnalysisMapText(userText) &&
711
+ usedToolNames.has(KMA_ANALYSIS_CHART_TOOL_NAME)
712
+ ) {
713
+ return true
714
+ }
715
+ if (AIRKOREA_RE.test(userText) && usedToolNames.has(AIRKOREA_TOOL_NAME)) {
716
+ return true
717
+ }
718
+ if (
719
+ isTagoBusQuery(userText) &&
720
+ usedToolNames.has(TAGO_ARRIVAL_TOOL_NAME)
721
+ ) {
722
+ return true
723
+ }
724
+ if (!isAirportAviationQuery(userText)) return false
725
+
726
+ const requiredTools = requiredKmaAviationTools(userText, available)
727
+ if (requiredTools.length === 0) return false
728
+
729
+ return requiredTools.every(toolName => usedToolNames.has(toolName))
730
+ }
731
+
732
+ function publicDataMismatchTargetTool(
733
+ latestToolResult: string,
734
+ ): string | undefined {
735
+ const candidate = PUBLIC_DATA_MISMATCH_CALL_RE.exec(latestToolResult)?.[1]
736
+ if (!candidate) return undefined
737
+ return candidate
738
+ }
739
+
740
+ export function selectUmmayaToolChoiceOverride({
741
+ messages,
742
+ tools,
743
+ }: {
744
+ messages: readonly Message[]
745
+ tools: Tools
746
+ }): BetaToolChoiceTool | undefined {
747
+ const available = availableToolNamesFromTools(tools)
748
+ const userText = latestUserText(messages)
749
+ const latestToolResult = latestToolResultText(messages)
750
+ const usedToolNames = toolUseNames(messages)
751
+ const airportAviationQuery = isAirportAviationQuery(userText)
752
+ const usedAviationTool = hasAnyToolUse(usedToolNames, KMA_AIR_TOOLS)
753
+ const publicDataTargetTool = publicDataMismatchTargetTool(latestToolResult)
754
+ if (
755
+ shouldSuppressUmmayaToolCallsForAnswerSynthesis({
756
+ messages,
757
+ tools,
758
+ })
759
+ ) {
760
+ return undefined
761
+ }
762
+
763
+ if (
764
+ publicDataTargetTool &&
765
+ !usedToolNames.has(publicDataTargetTool) &&
766
+ isAvailableOrSyncedAdapter(available, publicDataTargetTool)
767
+ ) {
768
+ return { type: 'tool', name: publicDataTargetTool }
769
+ }
770
+
771
+ if (
772
+ AIRKOREA_RE.test(userText) &&
773
+ !usedToolNames.has(AIRKOREA_TOOL_NAME) &&
774
+ isAvailableOrSyncedAdapter(available, AIRKOREA_TOOL_NAME)
775
+ ) {
776
+ return { type: 'tool', name: AIRKOREA_TOOL_NAME }
777
+ }
778
+
779
+ if (
780
+ PPS_BID_RE.test(userText) &&
781
+ !usedToolNames.has(PPS_BID_TOOL_NAME) &&
782
+ isAvailableOrSyncedAdapter(available, PPS_BID_TOOL_NAME)
783
+ ) {
784
+ return { type: 'tool', name: PPS_BID_TOOL_NAME }
785
+ }
786
+
787
+ if (
788
+ needsEmergencyRoomSearch(userText) &&
789
+ available.has(KAKAO_COORD_TO_REGION_TOOL_NAME) &&
790
+ !usedToolNames.has(KAKAO_COORD_TO_REGION_TOOL_NAME) &&
791
+ !hasSuccessfulRegionLocateResult(messages) &&
792
+ hasSuccessfulPoiLocateResult(messages)
793
+ ) {
794
+ return { type: 'tool', name: KAKAO_COORD_TO_REGION_TOOL_NAME }
795
+ }
796
+
797
+ if (
798
+ needsEmergencyRoomSearch(userText) &&
799
+ available.has(NMC_EMERGENCY_TOOL_NAME) &&
800
+ !usedToolNames.has(NMC_EMERGENCY_TOOL_NAME) &&
801
+ hasSuccessfulRegionLocateResult(messages)
802
+ ) {
803
+ return { type: 'tool', name: NMC_EMERGENCY_TOOL_NAME }
804
+ }
805
+
806
+ const needsGimpoAviationFollowup =
807
+ airportAviationQuery &&
808
+ GIMPO_RE.test(userText) &&
809
+ usedToolNames.has('kma_apihub_url_air_metar_decoded') &&
810
+ !usedToolNames.has('kma_apihub_url_air_amos_minute') &&
811
+ isAvailableOrSyncedAdapter(available, 'kma_apihub_url_air_amos_minute')
812
+
813
+ const shouldForceAviation =
814
+ latestToolResult.includes('KMA aviation tool-choice mismatch') ||
815
+ (airportAviationQuery && !usedAviationTool) ||
816
+ needsGimpoAviationFollowup
817
+ if (shouldForceAviation) {
818
+ const name = selectKmaAviationTool(
819
+ userText,
820
+ available,
821
+ usedToolNames,
822
+ usedAviationTool,
823
+ )
824
+ if (name) return { type: 'tool', name }
825
+ }
826
+
827
+ const shouldForceKmaAnalysis =
828
+ latestToolResult.includes('KMA analysis tool-choice mismatch') ||
829
+ (isKmaAnalysisMapText(userText) &&
830
+ !usedToolNames.has(KMA_ANALYSIS_CHART_TOOL_NAME))
831
+ if (
832
+ shouldForceKmaAnalysis &&
833
+ isAvailableOrSyncedAdapter(available, KMA_ANALYSIS_CHART_TOOL_NAME)
834
+ ) {
835
+ return { type: 'tool', name: KMA_ANALYSIS_CHART_TOOL_NAME }
836
+ }
837
+
838
+ const shouldForceAed = buildNmcAedFollowupPromptIfNeeded({
839
+ messages,
840
+ availableToolNames: available,
841
+ })
842
+ if (shouldForceAed && available.has(NMC_AED_TOOL_NAME)) {
843
+ return { type: 'tool', name: NMC_AED_TOOL_NAME }
844
+ }
845
+
846
+ const tagoToolName = selectTagoBusToolChoice(
847
+ userText,
848
+ available,
849
+ usedToolNames,
850
+ messages,
851
+ )
852
+ if (tagoToolName) {
853
+ return { type: 'tool', name: tagoToolName }
854
+ }
855
+
856
+ const shouldForceProtected =
857
+ latestToolResult.includes('Protected-domain tool-choice mismatch') ||
858
+ (PROTECTED_QUERY_RE.test(userText) &&
859
+ !hasAnyToolUse(usedToolNames, PROTECTED_CHECK_TOOLS))
860
+ if (shouldForceProtected) {
861
+ const name = selectProtectedCheckTool(`${userText}\n${latestToolResult}`, available)
862
+ if (name) return { type: 'tool', name }
863
+ }
864
+
865
+ return undefined
866
+ }