ummaya 0.2.1 → 0.2.3

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/bin/ummaya CHANGED
@@ -7,9 +7,27 @@ import { delimiter, dirname, join } from 'node:path'
7
7
  import { fileURLToPath } from 'node:url'
8
8
 
9
9
  const MINIMUM_BUN_VERSION = '1.3.0'
10
- const launcherPath = realpathSync(fileURLToPath(import.meta.url))
10
+ const PACKAGED_PRIMITIVE_TIMEOUT_MS = '90000'
11
11
 
12
- function bunVersionAtLeast(version, minimum) {
12
+ function currentLauncherPath() {
13
+ return realpathSync(fileURLToPath(import.meta.url))
14
+ }
15
+
16
+ function invokedLauncherPath() {
17
+ const argvPath = process.argv[1]
18
+ if (!argvPath) return null
19
+ try {
20
+ return realpathSync(argvPath)
21
+ } catch {
22
+ return null
23
+ }
24
+ }
25
+
26
+ function isDirectLaunch() {
27
+ return invokedLauncherPath() === currentLauncherPath()
28
+ }
29
+
30
+ export function bunVersionAtLeast(version, minimum) {
13
31
  const current = String(version)
14
32
  .split('.')
15
33
  .map((part) => Number.parseInt(part, 10))
@@ -62,7 +80,7 @@ function probeBunVersion(candidate) {
62
80
  return (result.stdout || result.stderr).trim()
63
81
  }
64
82
 
65
- function launchWithCompatibleBun(currentVersion = null) {
83
+ function launchWithCompatibleBun(launcherPath, currentVersion = null) {
66
84
  const checked = []
67
85
  for (const candidate of collectBunCandidates()) {
68
86
  const version = probeBunVersion(candidate)
@@ -94,17 +112,7 @@ function launchWithCompatibleBun(currentVersion = null) {
94
112
  process.exit(1)
95
113
  }
96
114
 
97
- const runningBun = globalThis.Bun
98
- if (!runningBun) {
99
- launchWithCompatibleBun()
100
- }
101
- if (!bunVersionAtLeast(runningBun.version, MINIMUM_BUN_VERSION)) {
102
- launchWithCompatibleBun(runningBun.version)
103
- }
104
-
105
- const packageRoot = dirname(dirname(launcherPath))
106
-
107
- function loadPackageDotenv(root) {
115
+ export function loadPackageDotenv(root, env = process.env) {
108
116
  const envPath = join(root, '.env')
109
117
  if (!existsSync(envPath)) return
110
118
  const lines = readFileSync(envPath, 'utf8').split(/\r?\n/)
@@ -114,29 +122,66 @@ function loadPackageDotenv(root) {
114
122
  const index = line.indexOf('=')
115
123
  const key = line.slice(0, index).trim()
116
124
  let value = line.slice(index + 1).trim()
117
- if (!key || process.env[key] !== undefined) continue
125
+ if (!key || env[key] !== undefined) continue
118
126
  if (
119
127
  (value.startsWith('"') && value.endsWith('"')) ||
120
128
  (value.startsWith("'") && value.endsWith("'"))
121
129
  ) {
122
130
  value = value.slice(1, -1)
123
131
  }
124
- process.env[key] = value
132
+ env[key] = value
133
+ }
134
+ }
135
+
136
+ export function buildBackendCommand(root) {
137
+ const packagedPython = join(root, '.venv', 'bin', 'python')
138
+ if (existsSync(packagedPython)) {
139
+ return [packagedPython, '-m', 'ummaya.cli', '--ipc', 'stdio']
125
140
  }
141
+
142
+ return ['uv', '--directory', root, 'run', '--frozen', '--no-dev', 'ummaya', '--ipc', 'stdio']
143
+ }
144
+
145
+ export function configurePackageEnv(root, env = process.env) {
146
+ env.UMMAYA_PACKAGE_ROOT = root
147
+ if (env.UMMAYA_ALLOW_BACKEND_CMD_OVERRIDE !== '1' || !env.UMMAYA_BACKEND_CMD_JSON) {
148
+ env.UMMAYA_BACKEND_CMD_JSON = JSON.stringify(buildBackendCommand(root))
149
+ }
150
+ env.UMMAYA_TUI_PRIMITIVE_TIMEOUT_MS ??= PACKAGED_PRIMITIVE_TIMEOUT_MS
151
+ return env
152
+ }
153
+
154
+ function inspectLauncherAndExit() {
155
+ if (process.env.UMMAYA_LAUNCHER_INSPECT !== '1') return
156
+ process.stdout.write(
157
+ `${JSON.stringify({
158
+ packageRoot: process.env.UMMAYA_PACKAGE_ROOT,
159
+ backendCommand: JSON.parse(process.env.UMMAYA_BACKEND_CMD_JSON ?? '[]'),
160
+ primitiveTimeoutMs: process.env.UMMAYA_TUI_PRIMITIVE_TIMEOUT_MS,
161
+ })}\n`,
162
+ )
163
+ process.exit(0)
126
164
  }
127
165
 
128
- loadPackageDotenv(packageRoot)
129
-
130
- process.env.UMMAYA_PACKAGE_ROOT ??= packageRoot
131
- process.env.UMMAYA_BACKEND_CMD_JSON ??= JSON.stringify([
132
- 'uv',
133
- '--directory',
134
- packageRoot,
135
- 'run',
136
- 'ummaya',
137
- '--ipc',
138
- 'stdio',
139
- ])
140
-
141
- await import(join(packageRoot, 'tui/src/stubs/macro-preload.ts'))
142
- await import(join(packageRoot, 'tui/src/entrypoints/cli.tsx'))
166
+ export async function main() {
167
+ const launcherPath = currentLauncherPath()
168
+ const runningBun = globalThis.Bun
169
+ if (!runningBun) {
170
+ launchWithCompatibleBun(launcherPath)
171
+ }
172
+ if (!bunVersionAtLeast(runningBun.version, MINIMUM_BUN_VERSION)) {
173
+ launchWithCompatibleBun(launcherPath, runningBun.version)
174
+ }
175
+
176
+ const packageRoot = dirname(dirname(launcherPath))
177
+ loadPackageDotenv(packageRoot)
178
+ configurePackageEnv(packageRoot)
179
+ inspectLauncherAndExit()
180
+
181
+ await import(join(packageRoot, 'tui/src/stubs/macro-preload.ts'))
182
+ await import(join(packageRoot, 'tui/src/entrypoints/cli.tsx'))
183
+ }
184
+
185
+ if (isDirectLaunch()) {
186
+ await main()
187
+ }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "ummaya",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "ummaya",
9
- "version": "0.2.1",
9
+ "version": "0.2.3",
10
10
  "license": "Apache-2.0",
11
11
  "dependencies": {
12
12
  "@alcalzone/ansi-tokenize": "^0.3.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ummaya",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Conversational multi-agent harness for Korean public-service channels",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ummaya"
3
- version = "0.2.1"
3
+ version = "0.2.3"
4
4
  description = "Conversational multi-agent platform for Korean public APIs"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -314,7 +314,7 @@ min_confidence = 80
314
314
 
315
315
  [tool.commitizen]
316
316
  name = "cz_conventional_commits"
317
- version = "0.2.1"
317
+ version = "0.2.3"
318
318
  tag_format = "v$version"
319
319
 
320
320
  # PyTorch CPU-only wheel for Docker image size discipline (SC-1: ≤ 2 GB).
@@ -75,17 +75,6 @@ def _contains_location_dependent_key(value: object) -> bool:
75
75
  return False
76
76
 
77
77
 
78
- def _allowed_core_tools_for_available_adapters(
79
- visible_primitives: set[str],
80
- has_location_dependent_schema: bool,
81
- ) -> frozenset[str] | None:
82
- """Constrain root primitive exposure for location-independent find turns."""
83
-
84
- if visible_primitives == {"find"} and not has_location_dependent_schema:
85
- return frozenset({"find"})
86
- return None
87
-
88
-
89
78
  class QueryEngine:
90
79
  """Per-session orchestrator for the UMMAYA query engine.
91
80
 
@@ -216,7 +205,7 @@ class QueryEngine:
216
205
  ChatMessage(role="user", content=assembled.turn_attachment.content),
217
206
  )
218
207
 
219
- dynamic_adapter_message, allowed_core_tool_ids = self._build_available_adapters_context(
208
+ dynamic_adapter_message, turn_tool_ids = self._build_available_adapters_context(
220
209
  user_message
221
210
  )
222
211
  if dynamic_adapter_message is not None:
@@ -235,7 +224,7 @@ class QueryEngine:
235
224
  tool_registry=self._tool_registry,
236
225
  config=self._config,
237
226
  session_context=self._permission_session,
238
- allowed_core_tool_ids=allowed_core_tool_ids,
227
+ turn_tool_ids=turn_tool_ids,
239
228
  turn_start_message_index=turn_start_message_index,
240
229
  )
241
230
 
@@ -281,13 +270,13 @@ class QueryEngine:
281
270
  def _build_available_adapters_message(self, user_message: str) -> ChatMessage | None:
282
271
  """Inject BM25 adapter candidates for the current citizen utterance."""
283
272
 
284
- message, _allowed_core_tool_ids = self._build_available_adapters_context(user_message)
273
+ message, _turn_tool_ids = self._build_available_adapters_context(user_message)
285
274
  return message
286
275
 
287
276
  def _build_available_adapters_context(
288
277
  self, user_message: str
289
- ) -> tuple[ChatMessage | None, frozenset[str] | None]:
290
- """Build dynamic adapter context and per-turn primitive exposure."""
278
+ ) -> tuple[ChatMessage | None, tuple[str, ...]]:
279
+ """Build dynamic adapter context and per-turn concrete tool exposure."""
291
280
 
292
281
  try:
293
282
  from ummaya.tools.search import search # noqa: PLC0415
@@ -300,11 +289,10 @@ class QueryEngine:
300
289
  )
301
290
  except Exception: # noqa: BLE001
302
291
  logger.exception("available_adapters auto-inject failed")
303
- return None, None
292
+ return None, ()
304
293
 
305
294
  adapter_lines: list[str] = []
306
- visible_primitives: set[str] = set()
307
- has_location_dependent_schema = False
295
+ selected_tool_ids: list[str] = []
308
296
  primary_find_without_location = False
309
297
  visible_count = 0
310
298
  for candidate in candidates:
@@ -312,6 +300,8 @@ class QueryEngine:
312
300
  tool = self._tool_registry.find(candidate.tool_id)
313
301
  except ToolNotFoundError:
314
302
  continue
303
+ if candidate.score <= 0:
304
+ continue
315
305
  if tool.is_core or tool.ministry == "UMMAYA":
316
306
  continue
317
307
  primitive = candidate.primitive if isinstance(candidate.primitive, str) else None
@@ -324,15 +314,12 @@ class QueryEngine:
324
314
  )
325
315
  if visible_count > 0 and primary_find_without_location and requires_location:
326
316
  continue
327
- if primitive is not None:
328
- visible_primitives.add(primitive)
329
- if requires_location:
330
- has_location_dependent_schema = True
331
317
  schema_json = json.dumps(
332
318
  candidate.input_schema_json,
333
319
  ensure_ascii=False,
334
320
  sort_keys=True,
335
321
  )
322
+ selected_tool_ids.append(candidate.tool_id)
336
323
  adapter_lines.extend(
337
324
  [
338
325
  f"- tool_id: {candidate.tool_id}",
@@ -340,8 +327,7 @@ class QueryEngine:
340
327
  f" description: {candidate.llm_description or tool.name_ko}",
341
328
  f" required_params: {candidate.required_params}",
342
329
  f" input_schema_json: {schema_json}",
343
- f" call_hint: {candidate.primitive}("
344
- f'{{"tool_id":"{candidate.tool_id}","params":{{...}}}})',
330
+ f" call_hint: {candidate.tool_id}({{...}})",
345
331
  f" policy_url: {candidate.real_classification_url or ''}",
346
332
  ]
347
333
  )
@@ -350,19 +336,16 @@ class QueryEngine:
350
336
  break
351
337
 
352
338
  if not adapter_lines:
353
- return None, None
354
-
355
- allowed_core_tool_ids = _allowed_core_tools_for_available_adapters(
356
- visible_primitives,
357
- has_location_dependent_schema,
358
- )
339
+ return None, ()
359
340
 
360
341
  content = "\n".join(
361
342
  [
362
343
  "<available_adapters>",
363
344
  "Use these adapter candidates for this citizen request. "
364
- "For public-data lookup/list/statistics requests, call "
365
- "find({tool_id, params}) with a tool_id from this block. "
345
+ "Call the function named exactly as tool_id with that adapter's "
346
+ "schema arguments. Do not wrap adapter calls in root primitives "
347
+ "such as find({tool_id, params}), locate({tool_id, params}), "
348
+ "check({tool_id, params}), or send({tool_id, params}). "
366
349
  "Do not call locate just because the citizen text contains a "
367
350
  "city/province name; treat that as the dataset/filter term. "
368
351
  "Call locate only when the selected adapter schema requires "
@@ -371,7 +354,7 @@ class QueryEngine:
371
354
  "</available_adapters>",
372
355
  ]
373
356
  )
374
- return ChatMessage(role="system", content=content), allowed_core_tool_ids
357
+ return ChatMessage(role="system", content=content), tuple(selected_tool_ids)
375
358
 
376
359
  def set_permission_session(self, session: SessionContext | None) -> None:
377
360
  """Update the permission-pipeline session used for subsequent turns.
@@ -121,11 +121,17 @@ class QueryContext(BaseModel):
121
121
  """
122
122
 
123
123
  allowed_core_tool_ids: frozenset[str] | None = None
124
- """Optional per-turn provider tool allow-list for root primitives.
124
+ """Legacy per-turn allow-list for primitive wrappers.
125
125
 
126
- Used by the Rich REPL path when BM25 has already selected location-independent
127
- public-data adapters. It keeps the exposed provider surface aligned with the
128
- selected adapter primitive instead of letting unrelated root primitives race.
126
+ Preserved for callers that still expose the old root primitives. New turns
127
+ should prefer ``turn_tool_ids`` so the model sees concrete adapter schemas.
128
+ """
129
+
130
+ turn_tool_ids: tuple[str, ...] = ()
131
+ """Concrete adapter tool IDs selected for this citizen turn.
132
+
133
+ When populated, the query loop exports these concrete adapter schemas as the
134
+ provider tool surface instead of dumping the root primitive wrappers.
129
135
  """
130
136
 
131
137
  turn_start_message_index: int = 0
@@ -82,7 +82,7 @@ def _assemble_tool_calls(
82
82
 
83
83
 
84
84
  def _tool_definition_name(tool_def: ToolDefinition | dict[str, object]) -> str | None:
85
- """Extract a root primitive name from an OpenAI tool definition."""
85
+ """Extract a function name from an OpenAI tool definition."""
86
86
 
87
87
  if isinstance(tool_def, ToolDefinition):
88
88
  return tool_def.function.name
@@ -93,6 +93,27 @@ def _tool_definition_name(tool_def: ToolDefinition | dict[str, object]) -> str |
93
93
  return name if isinstance(name, str) else None
94
94
 
95
95
 
96
+ def _export_turn_tool_definitions(
97
+ tool_registry: ToolRegistry,
98
+ tool_ids: tuple[str, ...],
99
+ ) -> list[dict[str, object]]:
100
+ """Export selected concrete adapter schemas in ranking order."""
101
+
102
+ tool_defs: list[dict[str, object]] = []
103
+ seen: set[str] = set()
104
+ for tool_id in tool_ids:
105
+ if tool_id in seen:
106
+ continue
107
+ seen.add(tool_id)
108
+ try:
109
+ tool = tool_registry.find(tool_id)
110
+ except ToolNotFoundError:
111
+ logger.warning("Selected turn tool disappeared from registry: %s", tool_id)
112
+ continue
113
+ tool_defs.append(tool.to_openai_tool())
114
+ return tool_defs
115
+
116
+
96
117
  def _latest_successful_tool_payload(messages: list[ChatMessage]) -> dict[str, object] | None:
97
118
  """Return the latest non-error tool-result JSON payload, if present."""
98
119
 
@@ -296,14 +317,38 @@ async def dispatch_tool_calls( # noqa: C901
296
317
 
297
318
  async def _dispatch_one(tc: ToolCall) -> ToolResult:
298
319
  """Dispatch a single tool call via the executor."""
299
- if tc.function.name in {"find", "locate"}:
320
+ if tc.function.name in {"find", "locate", "check", "send"}:
300
321
  return await _dispatch_root_primitive(
301
322
  tc,
302
323
  tool_registry,
303
324
  tool_executor,
304
325
  session_context=session_context,
305
326
  )
306
- return await tool_executor.dispatch(tc.function.name, tc.function.arguments)
327
+ try:
328
+ tool = tool_registry.find(tc.function.name)
329
+ except ToolNotFoundError:
330
+ return await tool_executor.dispatch(tc.function.name, tc.function.arguments)
331
+ gate = tool.policy.citizen_facing_gate if tool.policy is not None else None
332
+ if gate in {None, "read-only"}:
333
+ return await tool_executor.dispatch(
334
+ tc.function.name,
335
+ tc.function.arguments,
336
+ tool_call_id=tc.id,
337
+ )
338
+ primitive = tool.primitive
339
+ if primitive is None:
340
+ return ToolResult(
341
+ tool_id=tc.function.name,
342
+ success=False,
343
+ error=f"{tc.function.name} is missing primitive metadata for gated dispatch.",
344
+ error_type="schema_mismatch",
345
+ )
346
+ return await _dispatch_concrete_adapter(
347
+ tc,
348
+ primitive,
349
+ tool_executor,
350
+ session_context=session_context,
351
+ )
307
352
 
308
353
  async def _flush_group(items: list[tuple[int, ToolCall]], safe: bool) -> None:
309
354
  """Execute a group of tool calls, concurrently if safe."""
@@ -407,6 +452,59 @@ async def _dispatch_root_primitive(
407
452
  return ToolResult(tool_id=primitive, success=True, data=data)
408
453
 
409
454
 
455
+ async def _dispatch_concrete_adapter(
456
+ tc: ToolCall,
457
+ primitive: str,
458
+ tool_executor: ToolExecutor,
459
+ *,
460
+ session_context: SessionContext | None,
461
+ ) -> ToolResult:
462
+ """Dispatch a directly model-facing concrete adapter call."""
463
+
464
+ try:
465
+ raw_args = json.loads(tc.function.arguments)
466
+ except (TypeError, json.JSONDecodeError) as exc:
467
+ return ToolResult(
468
+ tool_id=tc.function.name,
469
+ success=False,
470
+ error=str(exc),
471
+ error_type="validation",
472
+ )
473
+ if not isinstance(raw_args, dict):
474
+ return ToolResult(
475
+ tool_id=tc.function.name,
476
+ success=False,
477
+ error=f"{tc.function.name} requires a JSON object argument.",
478
+ error_type="validation",
479
+ )
480
+
481
+ request_id = tc.id or f"{tc.function.name}-call"
482
+ if primitive == "find":
483
+ output = await tool_executor.invoke(
484
+ tc.function.name,
485
+ raw_args,
486
+ request_id=request_id,
487
+ session_identity=session_context,
488
+ )
489
+ else:
490
+ output = await tool_executor.invoke_raw(
491
+ tc.function.name,
492
+ raw_args,
493
+ request_id=request_id,
494
+ session_identity=session_context,
495
+ )
496
+
497
+ data = _primitive_output_dict(output)
498
+ if data.get("kind") == "error":
499
+ return ToolResult(
500
+ tool_id=tc.function.name,
501
+ success=False,
502
+ error=str(data.get("message") or data),
503
+ error_type="execution",
504
+ )
505
+ return ToolResult(tool_id=tc.function.name, success=True, data=data)
506
+
507
+
410
508
  def _primitive_output_dict(output: object) -> dict[str, object]:
411
509
  """Convert primitive facade output to ToolResult data."""
412
510
 
@@ -532,8 +630,13 @@ async def _query_inner(ctx: QueryContext) -> AsyncIterator[QueryEvent]: # noqa:
532
630
  tool_defs: list[ToolDefinition | dict[str, object]] | None = None
533
631
  force_no_tools_next_turn = False
534
632
  else:
535
- raw_defs = ctx.tool_registry.export_core_tools_openai()
536
- if ctx.allowed_core_tool_ids is not None:
633
+ raw_defs = _export_turn_tool_definitions(
634
+ ctx.tool_registry,
635
+ ctx.turn_tool_ids,
636
+ )
637
+ if not raw_defs:
638
+ raw_defs = ctx.tool_registry.export_core_tools_openai()
639
+ if ctx.allowed_core_tool_ids is not None and not ctx.turn_tool_ids:
537
640
  raw_defs = [
538
641
  tool_def
539
642
  for tool_def in raw_defs