ummaya 0.2.3 → 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 +1287 -54
  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 +48 -18
  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,112 @@
1
+ import type { Message } from '../../types/message.js'
2
+ import {
3
+ resolveAdapter,
4
+ type AdapterManifestEntry,
5
+ } from '../../services/api/adapterManifest.js'
6
+ import { isNonSyntheticUserMessageText } from './citizenUserText.js'
7
+
8
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
9
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
10
+ ? (value as Record<string, unknown>)
11
+ : undefined
12
+ }
13
+
14
+ function messageRecord(message: unknown): Record<string, unknown> | undefined {
15
+ return asRecord(asRecord(message)?.message)
16
+ }
17
+
18
+ function messageRole(message: unknown): string | undefined {
19
+ const outer = asRecord(message)
20
+ const inner = messageRecord(message)
21
+ if (typeof inner?.role === 'string') return inner.role
22
+ if (typeof outer?.role === 'string') return outer.role
23
+ return typeof outer?.type === 'string' ? outer.type : undefined
24
+ }
25
+
26
+ function textFromContent(content: unknown): string {
27
+ if (typeof content === 'string') return content
28
+ if (!Array.isArray(content)) return ''
29
+ return content
30
+ .map(block => {
31
+ const record = asRecord(block)
32
+ return typeof record?.text === 'string' ? record.text : ''
33
+ })
34
+ .filter(Boolean)
35
+ .join('\n')
36
+ }
37
+
38
+ function latestUserText(messages: readonly unknown[]): string {
39
+ for (let idx = messages.length - 1; idx >= 0; idx -= 1) {
40
+ const message = messages[idx]
41
+ if (messageRole(message) !== 'user') continue
42
+ const text = textFromContent(messageRecord(message)?.content ?? asRecord(message)?.content)
43
+ if (isNonSyntheticUserMessageText(message, text)) return text
44
+ }
45
+ return ''
46
+ }
47
+
48
+ function adapterRequiresQuery(entry: AdapterManifestEntry | undefined): boolean {
49
+ if (!entry || entry.primitive !== 'locate') return false
50
+ const schema = asRecord(entry.input_schema_json)
51
+ const required = schema?.required
52
+ if (!Array.isArray(required) || !required.includes('query')) return false
53
+ const properties = asRecord(schema?.properties)
54
+ return asRecord(properties?.query) !== undefined
55
+ }
56
+
57
+ function hasUsableQuery(params: Record<string, unknown>): boolean {
58
+ return typeof params.query === 'string' && params.query.trim().length > 0
59
+ }
60
+
61
+ function cleanLocationCandidate(candidate: string): string | undefined {
62
+ const cleaned = candidate
63
+ .replace(/["'“”‘’]/g, '')
64
+ .replace(/^(?:오늘|내일|모레|지금|현재|퇴근하고|방금|아까)\s+/u, '')
65
+ .replace(/\s+/g, ' ')
66
+ .trim()
67
+ .replace(/[은는이가을를의]$/u, '')
68
+ .trim()
69
+
70
+ if (cleaned.length < 2 || cleaned.length > 30) return undefined
71
+ if (/^(?:사람|누구|어디|무엇|오늘|내일|지금)$/u.test(cleaned)) {
72
+ return undefined
73
+ }
74
+ return cleaned
75
+ }
76
+
77
+ export function deriveLocationQueryFromUserText(text: string): string | undefined {
78
+ const locativeMatch = text.match(
79
+ /(?:^|[\s,.;!?])([가-힣A-Za-z0-9][가-힣A-Za-z0-9().·'/-]*(?:\s+[가-힣A-Za-z0-9][가-힣A-Za-z0-9().·'/-]*){0,3})\s*(?:에서|근처|주변|부근|인근|앞|쪽)(?=[은는이가을를에\s,.;!?]|$)/u,
80
+ )
81
+ const locativeCandidate = locativeMatch?.[1]
82
+ ? cleanLocationCandidate(locativeMatch[1])
83
+ : undefined
84
+ if (locativeCandidate) return locativeCandidate
85
+
86
+ const poiMatch = text.match(
87
+ /([가-힣A-Za-z0-9][가-힣A-Za-z0-9().·'/-]*(?:역|공항|터미널|해수욕장|시장|공원|병원|학교|대학교|구청|시청|센터))/u,
88
+ )
89
+ return poiMatch?.[1] ? cleanLocationCandidate(poiMatch[1]) : undefined
90
+ }
91
+
92
+ export function repairLocateQueryParamsFromConversation(
93
+ input: Record<string, unknown>,
94
+ messages: readonly Message[],
95
+ ): Record<string, unknown> {
96
+ const toolId = typeof input.tool_id === 'string' ? input.tool_id : ''
97
+ if (!adapterRequiresQuery(resolveAdapter(toolId))) return input
98
+
99
+ const params = asRecord(input.params) ?? {}
100
+ if (hasUsableQuery(params)) return input
101
+
102
+ const query = deriveLocationQueryFromUserText(latestUserText(messages))
103
+ if (!query) return input
104
+
105
+ return {
106
+ ...input,
107
+ params: {
108
+ ...params,
109
+ query,
110
+ },
111
+ }
112
+ }
@@ -0,0 +1,234 @@
1
+ import type { ToolUseContext, ValidationResult } from '../../Tool.js'
2
+ import { isNonSyntheticUserMessageText } from './citizenUserText.js'
3
+
4
+ const NMC_EMERGENCY_TOOL_NAME = 'nmc_emergency_search'
5
+ const NMC_AED_TOOL_NAME = 'nmc_aed_site_locate'
6
+ const HIRA_TOOL_NAME_RE = /^hira_/u
7
+ const AED_RE = /(aed|자동심장|심장충격|제세동)/iu
8
+ const ER_RE = /(응급실|응급의료기관|응급의료센터|emergency\s*room|\ber\b)/iu
9
+ const MEDICAL_COLLAPSE_RE =
10
+ /(사람이\s*쓰러|쓰러졌|쓰러짐|쓰러져|의식[을이가은는\s]*(없|잃|불명)|무의식|심정지|심폐소생|cpr|호흡\s*(없|곤란)|숨\s*(안|못)|collapse|collapsed|unconscious|cardiac\s*arrest|not\s*breathing)/iu
11
+ const NON_MEDICAL_EMERGENCY_RE =
12
+ /(비상벨|안심벨|emergency\s*(call\s*)?box|call\s*box)/iu
13
+ const NMC_AED_FOLLOWUP_PROMPT =
14
+ 'Required follow-up for this tool chain: the citizen described a collapse, unconsciousness, cardiac-arrest, or AED-relevant medical emergency, and nmc_emergency_search has already returned a successful ER result. Before any final answer, call nmc_aed_site_locate with schema-valid fields using the latest region or location context from this turn. If AED returns no data or an upstream error, report that explicitly with 119 guidance. Do not write final prose until nmc_aed_site_locate has been attempted.'
15
+ const NMC_AED_COMPLETION_PROMPT =
16
+ 'Emergency evidence chain complete: nmc_emergency_search and nmc_aed_site_locate have both been attempted for this collapse/unconsciousness request. Do not emit <tool_call> text, JSON tool-call text, or request another medical search. Write the final Korean emergency guidance now using the actual ER and AED tool results already in this conversation. Put 119 first, then nearest ER, then nearby AED locations or AED upstream/no-data status. Copy AED org, buildAddress, buildPlace, clerkTel, operating-time fields, and distance_km exactly when present; do not rename or summarize building places into new labels. Do not invent distances, walking times, building labels, station-inside labels, operating hours, or phone numbers that are absent from the tool results; omit unavailable details instead.'
17
+
18
+ type ToolUseRecord = {
19
+ id: string
20
+ name: string
21
+ }
22
+
23
+ export function textFromContent(content: unknown): string {
24
+ if (typeof content === 'string') return content
25
+ if (!Array.isArray(content)) return ''
26
+ return content
27
+ .map(block => {
28
+ if (typeof block === 'string') return block
29
+ if (typeof block !== 'object' || block === null) return ''
30
+ const record = block as Record<string, unknown>
31
+ return typeof record.text === 'string' ? record.text : ''
32
+ })
33
+ .filter(Boolean)
34
+ .join('\n')
35
+ }
36
+
37
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
38
+ return typeof value === 'object' && value !== null
39
+ ? (value as Record<string, unknown>)
40
+ : undefined
41
+ }
42
+
43
+ function messageRecord(message: unknown): Record<string, unknown> | undefined {
44
+ const record = asRecord(message)
45
+ return asRecord(record?.message)
46
+ }
47
+
48
+ function messageRole(message: unknown): string | undefined {
49
+ const record = asRecord(message)
50
+ const inner = messageRecord(message)
51
+ if (typeof inner?.role === 'string') return inner.role
52
+ if (typeof record?.role === 'string') return record.role
53
+ return typeof record?.type === 'string' ? record.type : undefined
54
+ }
55
+
56
+ function messageContent(message: unknown): unknown {
57
+ return messageRecord(message)?.content ?? asRecord(message)?.content
58
+ }
59
+
60
+ function latestRoleText(context: ToolUseContext, role: 'assistant' | 'user'): string {
61
+ const messages = Array.isArray(context.messages) ? context.messages : []
62
+ for (let idx = messages.length - 1; idx >= 0; idx -= 1) {
63
+ const message = messages[idx]
64
+ if (messageRole(message) !== role) continue
65
+ const text = textFromContent(messageContent(message))
66
+ if (role === 'user' && !isNonSyntheticUserMessageText(message, text)) continue
67
+ if (text.trim()) return text
68
+ }
69
+ return ''
70
+ }
71
+
72
+ function latestUserTurnStartIndex(messages: readonly unknown[]): number {
73
+ for (let idx = messages.length - 1; idx >= 0; idx -= 1) {
74
+ const message = messages[idx]
75
+ if (messageRole(message) !== 'user') continue
76
+ const text = textFromContent(messageContent(message))
77
+ if (isNonSyntheticUserMessageText(message, text)) return idx
78
+ }
79
+ return -1
80
+ }
81
+
82
+ function latestUserTurnMessages(messages: readonly unknown[]): readonly unknown[] {
83
+ const startIdx = latestUserTurnStartIndex(messages)
84
+ return startIdx === -1 ? [] : messages.slice(startIdx)
85
+ }
86
+
87
+ function latestUserTextFromMessages(messages: readonly unknown[]): string {
88
+ const startIdx = latestUserTurnStartIndex(messages)
89
+ if (startIdx === -1) return ''
90
+ return textFromContent(messageContent(messages[startIdx]))
91
+ }
92
+
93
+ function isMedicalCollapseQuery(text: string): boolean {
94
+ if (!text.trim()) return false
95
+ if (AED_RE.test(text) || MEDICAL_COLLAPSE_RE.test(text)) return true
96
+ return false
97
+ }
98
+
99
+ function isOnlyNonMedicalEmergencyQuery(text: string): boolean {
100
+ return NON_MEDICAL_EMERGENCY_RE.test(text) && !isMedicalCollapseQuery(text)
101
+ }
102
+
103
+ function toolUsesFromMessages(messages: readonly unknown[]): ToolUseRecord[] {
104
+ const toolUses: ToolUseRecord[] = []
105
+ for (const message of messages) {
106
+ const content = messageContent(message)
107
+ if (!Array.isArray(content)) continue
108
+ for (const block of content) {
109
+ const record = asRecord(block)
110
+ if (record?.type !== 'tool_use') continue
111
+ if (typeof record.id !== 'string' || typeof record.name !== 'string') continue
112
+ const input = asRecord(record.input)
113
+ const nestedToolName =
114
+ typeof input?.tool_id === 'string' ? input.tool_id : undefined
115
+ toolUses.push({ id: record.id, name: nestedToolName ?? record.name })
116
+ }
117
+ }
118
+ return toolUses
119
+ }
120
+
121
+ function parseJsonObject(value: string): Record<string, unknown> | undefined {
122
+ try {
123
+ return asRecord(JSON.parse(value))
124
+ } catch {
125
+ return undefined
126
+ }
127
+ }
128
+
129
+ function isSuccessfulToolResult(block: Record<string, unknown>): boolean {
130
+ if (block.is_error === true) return false
131
+ if (typeof block.content !== 'string') return true
132
+
133
+ const parsed = parseJsonObject(block.content)
134
+ if (!parsed) return true
135
+ if (parsed.ok === false) return false
136
+ if (parsed.error || parsed.error_code || parsed.errorCode) return false
137
+
138
+ const result = asRecord(parsed.result)
139
+ if (result?.kind === 'error') return false
140
+ return true
141
+ }
142
+
143
+ function hasSuccessfulToolResultFor(
144
+ messages: readonly unknown[],
145
+ toolName: string,
146
+ ): boolean {
147
+ const idToName = new Map(
148
+ toolUsesFromMessages(messages).map(toolUse => [toolUse.id, toolUse.name]),
149
+ )
150
+ for (const message of messages) {
151
+ const content = messageContent(message)
152
+ if (!Array.isArray(content)) continue
153
+ for (const block of content) {
154
+ const record = asRecord(block)
155
+ if (record?.type !== 'tool_result') continue
156
+ if (typeof record.tool_use_id !== 'string') continue
157
+ if (idToName.get(record.tool_use_id) !== toolName) continue
158
+ if (isSuccessfulToolResult(record)) return true
159
+ }
160
+ }
161
+ return false
162
+ }
163
+
164
+ function hasToolUse(messages: readonly unknown[], toolName: string): boolean {
165
+ return toolUsesFromMessages(messages).some(toolUse => toolUse.name === toolName)
166
+ }
167
+
168
+ export function buildNmcAedFollowupPromptIfNeeded({
169
+ messages,
170
+ availableToolNames,
171
+ }: {
172
+ messages: readonly unknown[]
173
+ availableToolNames: Iterable<string>
174
+ }): string | undefined {
175
+ const available = new Set(availableToolNames)
176
+ if (!available.has(NMC_AED_TOOL_NAME)) return undefined
177
+
178
+ const userText = latestUserTextFromMessages(messages)
179
+ const turnMessages = latestUserTurnMessages(messages)
180
+ if (isOnlyNonMedicalEmergencyQuery(userText)) return undefined
181
+ if (!isMedicalCollapseQuery(userText)) return undefined
182
+ if (hasToolUse(turnMessages, NMC_AED_TOOL_NAME)) return undefined
183
+ if (!hasSuccessfulToolResultFor(turnMessages, NMC_EMERGENCY_TOOL_NAME)) {
184
+ return undefined
185
+ }
186
+ return NMC_AED_FOLLOWUP_PROMPT
187
+ }
188
+
189
+ export function buildNmcAedCompletionPromptIfNeeded({
190
+ messages,
191
+ }: {
192
+ messages: readonly unknown[]
193
+ }): string | undefined {
194
+ const userText = latestUserTextFromMessages(messages)
195
+ const turnMessages = latestUserTurnMessages(messages)
196
+ if (isOnlyNonMedicalEmergencyQuery(userText)) return undefined
197
+ if (!isMedicalCollapseQuery(userText)) return undefined
198
+ if (!hasToolUse(turnMessages, NMC_AED_TOOL_NAME)) return undefined
199
+ if (!hasToolUse(turnMessages, NMC_EMERGENCY_TOOL_NAME)) return undefined
200
+ return NMC_AED_COMPLETION_PROMPT
201
+ }
202
+
203
+ export function validateNmcAedToolChoice(
204
+ toolId: string,
205
+ context: ToolUseContext,
206
+ ): ValidationResult | undefined {
207
+ if (HIRA_TOOL_NAME_RE.test(toolId)) {
208
+ const userText = latestRoleText(context, 'user')
209
+ if (!isOnlyNonMedicalEmergencyQuery(userText) && isMedicalCollapseQuery(userText)) {
210
+ return {
211
+ result: false,
212
+ message:
213
+ 'NMC emergency tool-choice mismatch: a collapse, unconsciousness, cardiac-arrest, or AED-relevant emergency must use official emergency-care evidence, not the general HIRA hospital/clinic search. ' +
214
+ `Call ${NMC_EMERGENCY_TOOL_NAME} for nearest emergency-room/ER institution data and then ${NMC_AED_TOOL_NAME} for AED locations. ` +
215
+ 'If either NMC adapter returns no data or an upstream error, report that directly with 119 guidance.',
216
+ errorCode: 1,
217
+ }
218
+ }
219
+ }
220
+ if (toolId !== NMC_EMERGENCY_TOOL_NAME) return undefined
221
+ const assistantText = latestRoleText(context, 'assistant')
222
+ const userText = latestRoleText(context, 'user')
223
+ const assistantIsAskingForAed = AED_RE.test(assistantText) && !ER_RE.test(assistantText)
224
+ const userAskedOnlyForAed = AED_RE.test(userText) && !ER_RE.test(userText)
225
+ if (!assistantIsAskingForAed && !userAskedOnlyForAed) return undefined
226
+ return {
227
+ result: false,
228
+ message:
229
+ 'NMC tool-choice mismatch: nmc_emergency_search answers emergency-room/ER institution data, not AED locations. ' +
230
+ `Call ${NMC_AED_TOOL_NAME} for AED/자동심장충격기/자동제세동기 requests. ` +
231
+ 'If the AED API returns no data or an upstream error, report that result directly instead of substituting ER data.',
232
+ errorCode: 1,
233
+ }
234
+ }
@@ -0,0 +1,207 @@
1
+ import type { ToolUseContext, ValidationResult } from '../../Tool.js'
2
+ import {
3
+ listAdapters,
4
+ resolveAdapter,
5
+ } from '../../services/api/adapterManifest.js'
6
+ import { isNonSyntheticUserMessageText } from './citizenUserText.js'
7
+ import { textFromContent } from './nmcAedGuard.js'
8
+
9
+ const PROTECTED_QUERY_RE =
10
+ /(본인확인|인증|간편인증|모바일\s*(?:신분증|id)|mobile\s*id|마이데이터|mydata|증명원|소득금액증명|소득금액증명원|주민등록등본|민원|발급)/iu
11
+ const VERIFY_ALIAS_RE =
12
+ /^(?:check|mobile_id|modid|simple_auth_module|ganpyeon_injeung|gongdong_injeungseo|geumyung_injeungseo|mydata|any_id_sso)$/iu
13
+ const PROTECTED_CHECK_TOOL_NAMES = new Set([
14
+ 'mock_verify_module_simple_auth',
15
+ 'mock_verify_ganpyeon_injeung',
16
+ 'mock_verify_mobile_id',
17
+ 'mock_verify_mydata',
18
+ 'mock_verify_digital_onepass',
19
+ 'mock_verify_gongdong_injeungseo',
20
+ 'mock_verify_geumyung_injeungseo',
21
+ 'mock_verify_module_modid',
22
+ 'mock_verify_module_kec',
23
+ 'mock_verify_module_geumyung',
24
+ 'mock_verify_module_any_id_sso',
25
+ ])
26
+ const PROTECTED_CHECK_COMPLETION_PROMPT =
27
+ 'Protected-domain evidence chain complete: a registered check adapter has already been attempted for this certificate, authentication, identity, or protected-service request. Do not emit <tool_call> text, JSON tool-call text, or request another identity tool. Write the final Korean answer now using the actual check result already in this conversation. If the result is permission_denied, unavailable, or mock-only, state that UMMAYA cannot complete the protected action in-session, then give official handoff options without claiming issuance succeeded.'
28
+ const PROTECTED_CHECK_REPAIR_PROMPT =
29
+ 'Protected-domain final-answer repair: the previous assistant message was invalid because it printed JSON tool-call text after a registered check adapter result. Do not call or print any tool. Write one Korean prose answer only. Use the existing check result already in this conversation. If the result is permission_denied, unavailable, or mock-only, state that UMMAYA cannot complete the protected action in-session, then give official handoff options such as Government24, Hometax, mobile ID, or 간편인증 without claiming issuance succeeded.'
30
+ const PROTECTED_TOOL_CALL_TEXT_RE =
31
+ /<tool_call>|"name"\s*:\s*"[^"]*(?:check|verify|auth|mobile|simple|ganpyeon|mydata)[^"]*"|"arguments"\s*:\s*\{/iu
32
+
33
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
34
+ return typeof value === 'object' && value !== null
35
+ ? (value as Record<string, unknown>)
36
+ : undefined
37
+ }
38
+
39
+ function messageRecord(message: unknown): Record<string, unknown> | undefined {
40
+ return asRecord(asRecord(message)?.message)
41
+ }
42
+
43
+ function messageRole(message: unknown): string | undefined {
44
+ const record = asRecord(message)
45
+ const inner = messageRecord(message)
46
+ if (typeof inner?.role === 'string') return inner.role
47
+ if (typeof record?.role === 'string') return record.role
48
+ return typeof record?.type === 'string' ? record.type : undefined
49
+ }
50
+
51
+ function messageContent(message: unknown): unknown {
52
+ return messageRecord(message)?.content ?? asRecord(message)?.content
53
+ }
54
+
55
+ function latestUserText(context: ToolUseContext): string {
56
+ const messages = Array.isArray(context.messages) ? context.messages : []
57
+ for (let idx = messages.length - 1; idx >= 0; idx -= 1) {
58
+ const message = messages[idx]
59
+ if (messageRole(message) !== 'user') continue
60
+ const text = textFromContent(messageContent(message))
61
+ if (isNonSyntheticUserMessageText(message, text)) return text
62
+ }
63
+ return ''
64
+ }
65
+
66
+ function userTextFromMessages(messages: readonly unknown[]): string {
67
+ return messages
68
+ .filter(message => messageRole(message) === 'user')
69
+ .map(message => ({ message, text: textFromContent(messageContent(message)) }))
70
+ .filter(({ message, text }) => isNonSyntheticUserMessageText(message, text))
71
+ .map(({ text }) => text)
72
+ .join('\n')
73
+ }
74
+
75
+ function hasProtectedCheckToolUse(messages: readonly unknown[]): boolean {
76
+ for (const message of messages) {
77
+ const content = messageContent(message)
78
+ if (!Array.isArray(content)) continue
79
+ for (const block of content) {
80
+ const record = asRecord(block)
81
+ if (record?.type !== 'tool_use') continue
82
+ if (
83
+ typeof record.name === 'string' &&
84
+ PROTECTED_CHECK_TOOL_NAMES.has(record.name)
85
+ ) {
86
+ return true
87
+ }
88
+ }
89
+ }
90
+ return false
91
+ }
92
+
93
+ function latestAssistantText(messages: readonly unknown[]): string {
94
+ for (let idx = messages.length - 1; idx >= 0; idx -= 1) {
95
+ const message = messages[idx]
96
+ if (messageRole(message) !== 'assistant') continue
97
+ const text = textFromContent(messageContent(message))
98
+ if (text.trim()) return text
99
+ }
100
+ return ''
101
+ }
102
+
103
+ function hasRepairPrompt(messages: readonly unknown[]): boolean {
104
+ return messages.some(message =>
105
+ textFromContent(messageContent(message)).includes(
106
+ 'Protected-domain final-answer repair',
107
+ ),
108
+ )
109
+ }
110
+
111
+ function checkAdapterIds(): string[] {
112
+ return listAdapters()
113
+ .filter(entry => entry.primitive === 'check')
114
+ .map(entry => entry.tool_id)
115
+ }
116
+
117
+ function selectSuggestions(toolId: string, userText: string): string[] {
118
+ const available = checkAdapterIds()
119
+ const wantsMobile = /mobile\s*id|모바일\s*(?:신분증|id)|mobile_id/iu.test(
120
+ `${toolId} ${userText}`,
121
+ )
122
+ const wantsSimple =
123
+ /simple_auth|간편인증|ganpyeon|소득금액증명|증명원|민원|발급/iu.test(
124
+ `${toolId} ${userText}`,
125
+ )
126
+ const wantsMydata = /mydata|마이데이터/iu.test(`${toolId} ${userText}`)
127
+ const preferred = [
128
+ wantsMobile ? 'mock_verify_mobile_id' : undefined,
129
+ wantsSimple ? 'mock_verify_module_simple_auth' : undefined,
130
+ wantsSimple ? 'mock_verify_ganpyeon_injeung' : undefined,
131
+ wantsMydata ? 'mock_verify_mydata' : undefined,
132
+ ].filter((id): id is string => typeof id === 'string' && available.includes(id))
133
+ const merged = [...preferred, ...available]
134
+ return [...new Set(merged)].slice(0, 3)
135
+ }
136
+
137
+ export function validateProtectedCheckToolChoice(
138
+ toolId: string,
139
+ context: ToolUseContext,
140
+ ): ValidationResult | undefined {
141
+ const userText = latestUserText(context)
142
+ const adapter = resolveAdapter(toolId)
143
+ if (adapter?.primitive === 'check') {
144
+ return {
145
+ result: false,
146
+ message:
147
+ `Protected-domain tool-choice mismatch: ${toolId} is a check adapter but was called through find. ` +
148
+ `Call check({tool_id:${JSON.stringify(toolId)}, params:{...}}) instead. ` +
149
+ 'Do not call check adapters through find.',
150
+ errorCode: 1,
151
+ }
152
+ }
153
+ if (!PROTECTED_QUERY_RE.test(userText) && !VERIFY_ALIAS_RE.test(toolId)) {
154
+ return undefined
155
+ }
156
+ if (!VERIFY_ALIAS_RE.test(toolId)) return undefined
157
+ const suggestions = selectSuggestions(toolId, userText)
158
+ if (suggestions.length === 0) return undefined
159
+ return {
160
+ result: false,
161
+ message:
162
+ `Protected-domain tool-choice mismatch: ${toolId} is not a registered find adapter. ` +
163
+ `Use the check primitive with a registered check adapter such as ${suggestions.join(', ')}. ` +
164
+ 'Do not call identity, authentication, consent, or certificate adapters through find.',
165
+ errorCode: 1,
166
+ }
167
+ }
168
+
169
+ export function buildProtectedCheckCompletionPromptIfNeeded({
170
+ messages,
171
+ }: {
172
+ messages: readonly unknown[]
173
+ }): string | undefined {
174
+ const userText = userTextFromMessages(messages)
175
+ if (!PROTECTED_QUERY_RE.test(userText)) return undefined
176
+ if (!hasProtectedCheckToolUse(messages)) return undefined
177
+ return PROTECTED_CHECK_COMPLETION_PROMPT
178
+ }
179
+
180
+ export function buildProtectedCheckFinalAnswerRepairPromptIfNeeded({
181
+ messages,
182
+ }: {
183
+ messages: readonly unknown[]
184
+ }): string | undefined {
185
+ const userText = userTextFromMessages(messages)
186
+ if (!PROTECTED_QUERY_RE.test(userText)) return undefined
187
+ if (!hasProtectedCheckToolUse(messages)) return undefined
188
+ if (hasRepairPrompt(messages)) return undefined
189
+ const assistantText = latestAssistantText(messages)
190
+ if (!PROTECTED_TOOL_CALL_TEXT_RE.test(assistantText)) return undefined
191
+ return PROTECTED_CHECK_REPAIR_PROMPT
192
+ }
193
+
194
+ export function shouldWithholdProtectedCheckToolCallText({
195
+ messages,
196
+ candidate,
197
+ }: {
198
+ messages: readonly unknown[]
199
+ candidate: unknown
200
+ }): boolean {
201
+ if (hasRepairPrompt(messages)) return false
202
+ return (
203
+ buildProtectedCheckFinalAnswerRepairPromptIfNeeded({
204
+ messages: [...messages, candidate],
205
+ }) !== undefined
206
+ )
207
+ }
@@ -0,0 +1,67 @@
1
+ export const ROOT_PRIMITIVE_TOOL_IDS = new Set([
2
+ 'find',
3
+ 'locate',
4
+ 'check',
5
+ 'send',
6
+ ])
7
+
8
+ function asRecord(value: unknown): Record<string, unknown> | null {
9
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
10
+ ? (value as Record<string, unknown>)
11
+ : null
12
+ }
13
+
14
+ export function isRootPrimitiveToolId(value: unknown): value is string {
15
+ return typeof value === 'string' && ROOT_PRIMITIVE_TOOL_IDS.has(value)
16
+ }
17
+
18
+ export function normalizeRootPrimitiveAdapterEnvelope(
19
+ expectedPrimitive: string,
20
+ value: unknown,
21
+ ): unknown {
22
+ const record = asRecord(value)
23
+ if (!record) return value
24
+ const topLevelToolId = record.tool_id
25
+
26
+ const params = asRecord(record.params)
27
+ if (!params) return value
28
+ const nestedToolId = params.tool_id
29
+ if (
30
+ typeof topLevelToolId === 'string' &&
31
+ topLevelToolId.length > 0 &&
32
+ topLevelToolId !== expectedPrimitive &&
33
+ nestedToolId === topLevelToolId
34
+ ) {
35
+ const { tool_id: _nestedToolId, ...adapterParams } = params
36
+ return {
37
+ ...record,
38
+ params: adapterParams,
39
+ }
40
+ }
41
+
42
+ if (topLevelToolId !== expectedPrimitive) return value
43
+ if (
44
+ typeof nestedToolId !== 'string' ||
45
+ nestedToolId.trim().length === 0 ||
46
+ ROOT_PRIMITIVE_TOOL_IDS.has(nestedToolId)
47
+ ) {
48
+ return value
49
+ }
50
+
51
+ const { tool_id: _nestedToolId, ...adapterParams } = params
52
+ return {
53
+ ...record,
54
+ tool_id: nestedToolId,
55
+ params: adapterParams,
56
+ }
57
+ }
58
+
59
+ export function rootPrimitiveSelfTargetMessage(
60
+ toolId: string,
61
+ primitiveLabel: string,
62
+ ): string {
63
+ return (
64
+ `Root primitive '${toolId}' is not a ${primitiveLabel} adapter tool_id. ` +
65
+ 'Pick a concrete adapter from <available_adapters>.'
66
+ )
67
+ }