minder-cli 0.2.0__py3-none-any.whl

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 (132) hide show
  1. minder/__init__.py +12 -0
  2. minder/api/routers/prompts.py +177 -0
  3. minder/application/__init__.py +1 -0
  4. minder/application/admin/__init__.py +11 -0
  5. minder/application/admin/dto.py +453 -0
  6. minder/application/admin/jobs.py +327 -0
  7. minder/application/admin/use_cases.py +1895 -0
  8. minder/auth/__init__.py +12 -0
  9. minder/auth/context.py +26 -0
  10. minder/auth/middleware.py +70 -0
  11. minder/auth/principal.py +59 -0
  12. minder/auth/rate_limiter.py +89 -0
  13. minder/auth/rbac.py +60 -0
  14. minder/auth/service.py +541 -0
  15. minder/bootstrap/__init__.py +9 -0
  16. minder/bootstrap/providers.py +109 -0
  17. minder/bootstrap/transport.py +807 -0
  18. minder/cache/__init__.py +10 -0
  19. minder/cache/providers.py +140 -0
  20. minder/chunking/__init__.py +4 -0
  21. minder/chunking/code_splitter.py +184 -0
  22. minder/chunking/splitter.py +136 -0
  23. minder/cli.py +1542 -0
  24. minder/config.py +179 -0
  25. minder/continuity.py +363 -0
  26. minder/dev.py +160 -0
  27. minder/embedding/__init__.py +9 -0
  28. minder/embedding/base.py +7 -0
  29. minder/embedding/local.py +65 -0
  30. minder/embedding/openai.py +7 -0
  31. minder/graph/__init__.py +11 -0
  32. minder/graph/edges.py +13 -0
  33. minder/graph/executor.py +127 -0
  34. minder/graph/graph.py +263 -0
  35. minder/graph/nodes/__init__.py +27 -0
  36. minder/graph/nodes/evaluator.py +21 -0
  37. minder/graph/nodes/guard.py +64 -0
  38. minder/graph/nodes/llm.py +59 -0
  39. minder/graph/nodes/planning.py +30 -0
  40. minder/graph/nodes/reasoning.py +87 -0
  41. minder/graph/nodes/reranker.py +141 -0
  42. minder/graph/nodes/retriever.py +86 -0
  43. minder/graph/nodes/verification.py +230 -0
  44. minder/graph/nodes/workflow_planner.py +250 -0
  45. minder/graph/runtime.py +15 -0
  46. minder/graph/state.py +26 -0
  47. minder/llm/__init__.py +5 -0
  48. minder/llm/base.py +14 -0
  49. minder/llm/local.py +381 -0
  50. minder/llm/openai.py +89 -0
  51. minder/models/__init__.py +109 -0
  52. minder/models/base.py +10 -0
  53. minder/models/client.py +137 -0
  54. minder/models/document.py +34 -0
  55. minder/models/error.py +32 -0
  56. minder/models/graph.py +114 -0
  57. minder/models/history.py +32 -0
  58. minder/models/job.py +62 -0
  59. minder/models/prompt.py +41 -0
  60. minder/models/repository.py +62 -0
  61. minder/models/rule.py +68 -0
  62. minder/models/session.py +51 -0
  63. minder/models/skill.py +52 -0
  64. minder/models/user.py +41 -0
  65. minder/models/workflow.py +35 -0
  66. minder/observability/__init__.py +57 -0
  67. minder/observability/audit.py +243 -0
  68. minder/observability/logging.py +253 -0
  69. minder/observability/metrics.py +448 -0
  70. minder/observability/tracing.py +215 -0
  71. minder/presentation/__init__.py +1 -0
  72. minder/presentation/http/__init__.py +1 -0
  73. minder/presentation/http/admin/__init__.py +3 -0
  74. minder/presentation/http/admin/api.py +1309 -0
  75. minder/presentation/http/admin/context.py +94 -0
  76. minder/presentation/http/admin/dashboard.py +111 -0
  77. minder/presentation/http/admin/jobs.py +208 -0
  78. minder/presentation/http/admin/memories.py +185 -0
  79. minder/presentation/http/admin/prompts.py +219 -0
  80. minder/presentation/http/admin/routes.py +127 -0
  81. minder/presentation/http/admin/runtime.py +650 -0
  82. minder/presentation/http/admin/search.py +368 -0
  83. minder/presentation/http/admin/skills.py +230 -0
  84. minder/prompts/__init__.py +646 -0
  85. minder/prompts/formatter.py +142 -0
  86. minder/resources/__init__.py +318 -0
  87. minder/retrieval/__init__.py +5 -0
  88. minder/retrieval/hybrid.py +178 -0
  89. minder/retrieval/mmr.py +116 -0
  90. minder/retrieval/multi_hop.py +115 -0
  91. minder/runtime.py +15 -0
  92. minder/server.py +145 -0
  93. minder/store/__init__.py +64 -0
  94. minder/store/document.py +115 -0
  95. minder/store/error.py +82 -0
  96. minder/store/feedback.py +114 -0
  97. minder/store/graph.py +588 -0
  98. minder/store/history.py +57 -0
  99. minder/store/interfaces.py +512 -0
  100. minder/store/milvus/__init__.py +11 -0
  101. minder/store/milvus/client.py +26 -0
  102. minder/store/milvus/collections.py +15 -0
  103. minder/store/milvus/vector_store.py +232 -0
  104. minder/store/mongodb/__init__.py +11 -0
  105. minder/store/mongodb/client.py +49 -0
  106. minder/store/mongodb/indexes.py +90 -0
  107. minder/store/mongodb/operational_store.py +993 -0
  108. minder/store/relational.py +1087 -0
  109. minder/store/repo_state.py +58 -0
  110. minder/store/rule.py +93 -0
  111. minder/store/vector.py +79 -0
  112. minder/tools/__init__.py +47 -0
  113. minder/tools/auth.py +94 -0
  114. minder/tools/graph.py +839 -0
  115. minder/tools/ingest.py +353 -0
  116. minder/tools/memory.py +381 -0
  117. minder/tools/query.py +307 -0
  118. minder/tools/registry.py +269 -0
  119. minder/tools/repo_scanner.py +1266 -0
  120. minder/tools/search.py +15 -0
  121. minder/tools/session.py +316 -0
  122. minder/tools/skills.py +899 -0
  123. minder/tools/workflow.py +215 -0
  124. minder/transport/__init__.py +4 -0
  125. minder/transport/base.py +286 -0
  126. minder/transport/sse.py +252 -0
  127. minder/transport/stdio.py +29 -0
  128. minder_cli-0.2.0.dist-info/METADATA +318 -0
  129. minder_cli-0.2.0.dist-info/RECORD +132 -0
  130. minder_cli-0.2.0.dist-info/WHEEL +4 -0
  131. minder_cli-0.2.0.dist-info/entry_points.txt +2 -0
  132. minder_cli-0.2.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,650 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import re
6
+ import uuid
7
+ from collections.abc import Mapping
8
+ from typing import Any
9
+
10
+ from pydantic import BaseModel, Field
11
+ from starlette.responses import JSONResponse, StreamingResponse
12
+ from starlette.routing import BaseRoute, Route
13
+
14
+ from minder.tools.memory import MemoryTools
15
+ from minder.tools.query import QueryTools
16
+ from minder.tools.session import SessionTools
17
+ from minder.tools.skills import SkillTools
18
+
19
+ from .context import AdminRouteContext
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ _PROMPT_LEAK_MARKERS = (
24
+ "Workflow instruction:",
25
+ "Instruction envelope:",
26
+ "Continuity packet:",
27
+ "Tool capabilities:",
28
+ "Data access policy:",
29
+ "Repository context note:",
30
+ "User query:",
31
+ "Retrieved context:",
32
+ "Correction required:",
33
+ )
34
+
35
+
36
+ class RuntimeQueryRequest(BaseModel):
37
+ query: str
38
+ repo_id: str | None = None
39
+ workflow_name: str | None = None
40
+ max_attempts: int = Field(default=2, ge=1, le=4)
41
+
42
+
43
+ _READ_LIST_VERBS = (
44
+ "list",
45
+ "show",
46
+ "view",
47
+ "what",
48
+ "which",
49
+ "liệt kê",
50
+ "liet ke",
51
+ "xem",
52
+ "danh sách",
53
+ "danh sach",
54
+ )
55
+ _CREATE_VERBS = (
56
+ "create",
57
+ "add",
58
+ "store",
59
+ "save",
60
+ "new",
61
+ "tạo",
62
+ "tao",
63
+ "thêm",
64
+ "them",
65
+ "lưu",
66
+ "luu",
67
+ )
68
+ _DELETE_VERBS = ("delete", "remove", "xoá", "xóa", "xoa")
69
+ _CLEANUP_VERBS = ("cleanup", "clean up", "purge", "dọn", "don")
70
+
71
+
72
+ def _contains_any(text: str, needles: tuple[str, ...]) -> bool:
73
+ lowered = text.lower()
74
+ return any(needle in lowered for needle in needles)
75
+
76
+
77
+ def _extract_quoted_field(query: str, aliases: tuple[str, ...]) -> str | None:
78
+ for alias in aliases:
79
+ pattern = rf"(?:{re.escape(alias)})\s*[:=]?\s*[\"']([^\"']+)[\"']"
80
+ match = re.search(pattern, query, flags=re.IGNORECASE)
81
+ if match:
82
+ value = match.group(1).strip()
83
+ if value:
84
+ return value
85
+ return None
86
+
87
+
88
+ def _extract_uuid(query: str) -> str | None:
89
+ match = re.search(
90
+ r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}\b",
91
+ query,
92
+ )
93
+ if match:
94
+ return match.group(0)
95
+ return None
96
+
97
+
98
+ def _extract_tags(query: str) -> list[str]:
99
+ raw = _extract_quoted_field(query, ("tags", "tag"))
100
+ if not raw:
101
+ return []
102
+ return [item.strip() for item in raw.split(",") if item.strip()]
103
+
104
+
105
+ def _agentic_payload(
106
+ *,
107
+ query: str,
108
+ answer: str,
109
+ repository: dict[str, Any],
110
+ agent_actions: list[dict[str, Any]],
111
+ ) -> dict[str, Any]:
112
+ return {
113
+ "query": query,
114
+ "repository": repository,
115
+ "answer": answer,
116
+ "answer_sanitized": False,
117
+ "answer_warning": None,
118
+ "sources": [],
119
+ "workflow": {},
120
+ "guard_result": None,
121
+ "verification_result": None,
122
+ "evaluation": None,
123
+ "provider": "minder",
124
+ "model": "agentic-tool-router",
125
+ "runtime": "internal",
126
+ "orchestration_runtime": "agentic-tool-executor",
127
+ "transition_log": [
128
+ {"edge": "agent_tool_executed", "tool": action.get("tool")}
129
+ for action in agent_actions
130
+ ],
131
+ "edge": "agent_tool_executed",
132
+ "cross_repo_graph": None,
133
+ "agent_actions": agent_actions,
134
+ }
135
+
136
+
137
+ class RuntimeAgentExecutor:
138
+ def __init__(self, context: AdminRouteContext) -> None:
139
+ self._memory_tools = MemoryTools(context.store, context.config)
140
+ self._skill_tools = SkillTools(context.store, context.config)
141
+ self._session_tools = SessionTools(context.store)
142
+
143
+ async def execute(
144
+ self,
145
+ *,
146
+ query: str,
147
+ repository: dict[str, Any],
148
+ admin_user_id: uuid.UUID,
149
+ ) -> dict[str, Any] | None:
150
+ normalized = query.lower().strip()
151
+ if not normalized:
152
+ return None
153
+
154
+ if "memory" in normalized or "memories" in normalized:
155
+ return await self._execute_memory(query=query, repository=repository)
156
+ if "skill" in normalized or "skills" in normalized:
157
+ return await self._execute_skill(query=query, repository=repository)
158
+ if "session" in normalized or "sessions" in normalized:
159
+ return await self._execute_session(
160
+ query=query,
161
+ repository=repository,
162
+ admin_user_id=admin_user_id,
163
+ )
164
+ return None
165
+
166
+ async def _execute_memory(
167
+ self,
168
+ *,
169
+ query: str,
170
+ repository: dict[str, Any],
171
+ ) -> dict[str, Any] | None:
172
+ normalized = query.lower()
173
+ if _contains_any(normalized, _READ_LIST_VERBS):
174
+ memories = await self._memory_tools.minder_memory_list()
175
+ preview = (
176
+ "\n".join(f"- {item['id']}: {item['title']}" for item in memories[:10])
177
+ if memories
178
+ else "- No memories found."
179
+ )
180
+ return _agentic_payload(
181
+ query=query,
182
+ repository=repository,
183
+ answer=f"Listed {len(memories)} memories.\n{preview}",
184
+ agent_actions=[
185
+ {
186
+ "tool": "minder_memory_list",
187
+ "mode": "read",
188
+ "status": "success",
189
+ "count": len(memories),
190
+ }
191
+ ],
192
+ )
193
+
194
+ if _contains_any(normalized, _CREATE_VERBS):
195
+ title = _extract_quoted_field(query, ("title", "memory", "memory title"))
196
+ content = _extract_quoted_field(
197
+ query,
198
+ ("content", "body", "note", "memory content"),
199
+ )
200
+ if not title or not content:
201
+ return None
202
+ created = await self._memory_tools.minder_memory_store(
203
+ title=title,
204
+ content=content,
205
+ tags=_extract_tags(query),
206
+ language=_extract_quoted_field(query, ("language",)) or "markdown",
207
+ )
208
+ return _agentic_payload(
209
+ query=query,
210
+ repository=repository,
211
+ answer=f"Created memory '{created['title']}' with id {created['id']}.",
212
+ agent_actions=[
213
+ {
214
+ "tool": "minder_memory_store",
215
+ "mode": "write",
216
+ "status": "success",
217
+ "result": created,
218
+ }
219
+ ],
220
+ )
221
+
222
+ if _contains_any(normalized, _DELETE_VERBS):
223
+ memory_id = _extract_uuid(query)
224
+ if not memory_id:
225
+ return None
226
+ deleted = await self._memory_tools.minder_memory_delete(memory_id)
227
+ return _agentic_payload(
228
+ query=query,
229
+ repository=repository,
230
+ answer=(
231
+ f"Deleted memory {memory_id}."
232
+ if deleted.get("deleted")
233
+ else f"Memory {memory_id} was not deleted."
234
+ ),
235
+ agent_actions=[
236
+ {
237
+ "tool": "minder_memory_delete",
238
+ "mode": "write",
239
+ "status": "success",
240
+ "result": deleted,
241
+ }
242
+ ],
243
+ )
244
+ return None
245
+
246
+ async def _execute_skill(
247
+ self,
248
+ *,
249
+ query: str,
250
+ repository: dict[str, Any],
251
+ ) -> dict[str, Any] | None:
252
+ normalized = query.lower()
253
+ if _contains_any(normalized, _READ_LIST_VERBS):
254
+ skills = await self._skill_tools.minder_skill_list()
255
+ preview = (
256
+ "\n".join(f"- {item['id']}: {item['title']}" for item in skills[:10])
257
+ if skills
258
+ else "- No skills found."
259
+ )
260
+ return _agentic_payload(
261
+ query=query,
262
+ repository=repository,
263
+ answer=f"Listed {len(skills)} skills.\n{preview}",
264
+ agent_actions=[
265
+ {
266
+ "tool": "minder_skill_list",
267
+ "mode": "read",
268
+ "status": "success",
269
+ "count": len(skills),
270
+ }
271
+ ],
272
+ )
273
+
274
+ if _contains_any(normalized, _CREATE_VERBS):
275
+ title = _extract_quoted_field(query, ("title", "skill", "skill title"))
276
+ content = _extract_quoted_field(
277
+ query,
278
+ ("content", "body", "skill content"),
279
+ )
280
+ if not title or not content:
281
+ return None
282
+ created = await self._skill_tools.minder_skill_store(
283
+ title=title,
284
+ content=content,
285
+ language=_extract_quoted_field(query, ("language",)) or "markdown",
286
+ tags=_extract_tags(query),
287
+ )
288
+ return _agentic_payload(
289
+ query=query,
290
+ repository=repository,
291
+ answer=f"Created skill '{created['title']}' with id {created['id']}.",
292
+ agent_actions=[
293
+ {
294
+ "tool": "minder_skill_store",
295
+ "mode": "write",
296
+ "status": "success",
297
+ "result": created,
298
+ }
299
+ ],
300
+ )
301
+
302
+ if _contains_any(normalized, _DELETE_VERBS):
303
+ skill_id = _extract_uuid(query)
304
+ if not skill_id:
305
+ return None
306
+ deleted = await self._skill_tools.minder_skill_delete(skill_id)
307
+ return _agentic_payload(
308
+ query=query,
309
+ repository=repository,
310
+ answer=(
311
+ f"Deleted skill {skill_id}."
312
+ if deleted.get("deleted")
313
+ else f"Skill {skill_id} was not deleted."
314
+ ),
315
+ agent_actions=[
316
+ {
317
+ "tool": "minder_skill_delete",
318
+ "mode": "write",
319
+ "status": "success",
320
+ "result": deleted,
321
+ }
322
+ ],
323
+ )
324
+ return None
325
+
326
+ async def _execute_session(
327
+ self,
328
+ *,
329
+ query: str,
330
+ repository: dict[str, Any],
331
+ admin_user_id: uuid.UUID,
332
+ ) -> dict[str, Any] | None:
333
+ normalized = query.lower()
334
+ if _contains_any(normalized, _READ_LIST_VERBS):
335
+ sessions = await self._session_tools.minder_session_list(
336
+ user_id=admin_user_id
337
+ )
338
+ items = list(sessions.get("sessions", []) or [])
339
+ preview = (
340
+ "\n".join(
341
+ f"- {item['session_id']}: {item.get('name') or 'unnamed'}"
342
+ for item in items[:10]
343
+ )
344
+ if items
345
+ else "- No sessions found."
346
+ )
347
+ return _agentic_payload(
348
+ query=query,
349
+ repository=repository,
350
+ answer=f"Listed {len(items)} sessions.\n{preview}",
351
+ agent_actions=[
352
+ {
353
+ "tool": "minder_session_list",
354
+ "mode": "read",
355
+ "status": "success",
356
+ "count": len(items),
357
+ }
358
+ ],
359
+ )
360
+
361
+ if _contains_any(normalized, _CLEANUP_VERBS):
362
+ cleaned = await self._session_tools.minder_session_cleanup(
363
+ user_id=admin_user_id
364
+ )
365
+ return _agentic_payload(
366
+ query=query,
367
+ repository=repository,
368
+ answer=(
369
+ f"Cleaned up {cleaned.get('deleted_sessions', 0)} expired sessions and "
370
+ f"{cleaned.get('deleted_history', 0)} history records."
371
+ ),
372
+ agent_actions=[
373
+ {
374
+ "tool": "minder_session_cleanup",
375
+ "mode": "write",
376
+ "status": "success",
377
+ "result": cleaned,
378
+ }
379
+ ],
380
+ )
381
+
382
+ if _contains_any(normalized, _CREATE_VERBS):
383
+ name = _extract_quoted_field(query, ("name", "session", "session name"))
384
+ if not name:
385
+ return None
386
+ repo_id_value = repository.get("id")
387
+ created = await self._session_tools.minder_session_create(
388
+ user_id=admin_user_id,
389
+ name=name,
390
+ repo_id=uuid.UUID(str(repo_id_value)) if repo_id_value else None,
391
+ project_context=(
392
+ {"repository_name": repository.get("name")}
393
+ if repository.get("name")
394
+ else None
395
+ ),
396
+ )
397
+ return _agentic_payload(
398
+ query=query,
399
+ repository=repository,
400
+ answer=f"Created session '{created.get('name') or name}' with id {created['session_id']}.",
401
+ agent_actions=[
402
+ {
403
+ "tool": "minder_session_create",
404
+ "mode": "write",
405
+ "status": "success",
406
+ "result": created,
407
+ }
408
+ ],
409
+ )
410
+ return None
411
+
412
+
413
+ def _fallback_answer(query: str, sources: list[dict[str, object]]) -> str:
414
+ source_paths = [
415
+ str(source.get("path", "")).strip()
416
+ for source in sources[:3]
417
+ if isinstance(source, dict) and str(source.get("path", "")).strip()
418
+ ]
419
+ if source_paths:
420
+ return (
421
+ f'The local runtime did not return a clean natural-language answer for "{query}". '
422
+ f"Start by inspecting: {', '.join(source_paths)}."
423
+ )
424
+ return (
425
+ f'The local runtime did not return a clean natural-language answer for "{query}". '
426
+ "Try a narrower question or inspect the transition log for the current reasoning path."
427
+ )
428
+
429
+
430
+ def _sanitize_answer(
431
+ answer: object,
432
+ *,
433
+ query: str,
434
+ sources: list[dict[str, object]],
435
+ ) -> tuple[str, bool, str | None]:
436
+ text = str(answer or "").strip()
437
+ if not text:
438
+ return (
439
+ _fallback_answer(query, sources),
440
+ True,
441
+ "Empty model response replaced with a runtime summary.",
442
+ )
443
+
444
+ marker_hits = sum(text.count(marker) for marker in _PROMPT_LEAK_MARKERS)
445
+ looks_like_prompt_echo = marker_hits >= 2 or text.startswith(
446
+ "Workflow instruction:"
447
+ )
448
+
449
+ if not looks_like_prompt_echo:
450
+ return text, False, None
451
+
452
+ for line in text.splitlines():
453
+ stripped = line.strip()
454
+ if stripped.lower().startswith("answer:"):
455
+ candidate = stripped.split(":", 1)[1].strip()
456
+ if candidate:
457
+ return (
458
+ candidate,
459
+ True,
460
+ "Prompt envelope was removed from the visible answer.",
461
+ )
462
+
463
+ return (
464
+ _fallback_answer(query, sources),
465
+ True,
466
+ "Prompt envelope leaked into the model output, so the dashboard replaced it with a cleaner summary.",
467
+ )
468
+
469
+
470
+ def build_runtime_routes(context: AdminRouteContext) -> list[BaseRoute]:
471
+ async def _resolve_request(
472
+ request,
473
+ ) -> tuple[RuntimeQueryRequest, Mapping[str, object], str | None] | JSONResponse:
474
+ try:
475
+ await context.admin_user_from_request(request)
476
+ except PermissionError:
477
+ return JSONResponse({"error": "Admin role required"}, status_code=403)
478
+ except Exception as exc:
479
+ return JSONResponse({"error": str(exc)}, status_code=401)
480
+
481
+ try:
482
+ payload = RuntimeQueryRequest(**(await request.json()))
483
+ except Exception as exc:
484
+ return JSONResponse({"error": str(exc)}, status_code=400)
485
+
486
+ query = str(payload.query).strip()
487
+ if not query:
488
+ return JSONResponse({"error": "query is required"}, status_code=400)
489
+
490
+ if not payload.repo_id:
491
+ return payload, {}, None
492
+
493
+ try:
494
+ repo_id = uuid.UUID(str(payload.repo_id))
495
+ except ValueError:
496
+ return JSONResponse({"error": "Invalid repo_id"}, status_code=400)
497
+
498
+ try:
499
+ repository_payload = await context.use_cases.get_repository_detail(repo_id)
500
+ except LookupError:
501
+ return JSONResponse({"error": "Repository not found"}, status_code=404)
502
+
503
+ repository = (
504
+ repository_payload.get("repository", {})
505
+ if isinstance(repository_payload, dict)
506
+ else {}
507
+ )
508
+ repo_path = str(repository.get("path", "") or "").strip()
509
+ if not repo_path:
510
+ return JSONResponse(
511
+ {"error": "Repository path is required for runtime query"},
512
+ status_code=400,
513
+ )
514
+ return payload, repository, repo_path
515
+
516
+ async def runtime_query(request) -> JSONResponse:
517
+ resolved = await _resolve_request(request)
518
+ if isinstance(resolved, JSONResponse):
519
+ return resolved
520
+ payload, repository, repo_path = resolved
521
+ admin_user = await context.admin_user_from_request(request)
522
+ query = str(payload.query).strip()
523
+ repo_id = uuid.UUID(str(payload.repo_id)) if payload.repo_id else None
524
+ repository_payload = {
525
+ "id": (
526
+ str(repository.get("id") or payload.repo_id)
527
+ if (repository.get("id") or payload.repo_id)
528
+ else None
529
+ ),
530
+ "name": repository.get("name") if repository else None,
531
+ "path": repo_path,
532
+ }
533
+
534
+ agentic_result = await RuntimeAgentExecutor(context).execute(
535
+ query=query,
536
+ repository=repository_payload,
537
+ admin_user_id=admin_user.id,
538
+ )
539
+ if agentic_result is not None:
540
+ return JSONResponse(agentic_result)
541
+
542
+ try:
543
+ result = await QueryTools(context.store, context.config).minder_query(
544
+ query=query,
545
+ repo_path=repo_path,
546
+ repo_id=repo_id,
547
+ workflow_name=payload.workflow_name,
548
+ max_attempts=payload.max_attempts,
549
+ )
550
+ except Exception as exc:
551
+ logger.exception("Runtime query failed", exc_info=exc)
552
+ return JSONResponse({"error": str(exc)}, status_code=400)
553
+
554
+ sources = list(result.get("sources", []) or [])
555
+ cleaned_answer, answer_sanitized, answer_warning = _sanitize_answer(
556
+ result.get("answer", ""),
557
+ query=query,
558
+ sources=sources,
559
+ )
560
+
561
+ return JSONResponse(
562
+ {
563
+ **result,
564
+ "query": query,
565
+ "repository": repository_payload,
566
+ "answer": cleaned_answer,
567
+ "answer_sanitized": answer_sanitized,
568
+ "answer_warning": answer_warning,
569
+ "agent_actions": [],
570
+ }
571
+ )
572
+
573
+ async def runtime_query_stream(request) -> StreamingResponse | JSONResponse:
574
+ resolved = await _resolve_request(request)
575
+ if isinstance(resolved, JSONResponse):
576
+ return resolved
577
+ payload, repository, repo_path = resolved
578
+ admin_user = await context.admin_user_from_request(request)
579
+ query = str(payload.query).strip()
580
+ repo_id = uuid.UUID(str(payload.repo_id)) if payload.repo_id else None
581
+
582
+ async def event_stream():
583
+ repository_payload = {
584
+ "id": (
585
+ str(repository.get("id") or payload.repo_id)
586
+ if (repository.get("id") or payload.repo_id)
587
+ else None
588
+ ),
589
+ "name": repository.get("name") if repository else None,
590
+ "path": repo_path,
591
+ }
592
+ yield json.dumps({"type": "meta", "repository": repository_payload}) + "\n"
593
+ agentic_result = await RuntimeAgentExecutor(context).execute(
594
+ query=query,
595
+ repository=repository_payload,
596
+ admin_user_id=admin_user.id,
597
+ )
598
+ if agentic_result is not None:
599
+ yield json.dumps({"type": "final", "payload": agentic_result}) + "\n"
600
+ return
601
+
602
+ query_tools = QueryTools(context.store, context.config)
603
+ try:
604
+ async for event in query_tools.minder_query_stream(
605
+ query=query,
606
+ repo_path=repo_path,
607
+ repo_id=repo_id,
608
+ workflow_name=payload.workflow_name,
609
+ max_attempts=payload.max_attempts,
610
+ ):
611
+ event_type = str(event.get("type"))
612
+ if event_type == "final":
613
+ payload_result = dict(event.get("payload", {}) or {})
614
+ sources = list(payload_result.get("sources", []) or [])
615
+ cleaned_answer, answer_sanitized, answer_warning = (
616
+ _sanitize_answer(
617
+ payload_result.get("answer", ""),
618
+ query=query,
619
+ sources=sources,
620
+ )
621
+ )
622
+ yield json.dumps(
623
+ {
624
+ "type": "final",
625
+ "payload": {
626
+ **payload_result,
627
+ "query": query,
628
+ "repository": repository_payload,
629
+ "answer": cleaned_answer,
630
+ "answer_sanitized": answer_sanitized,
631
+ "answer_warning": answer_warning,
632
+ "agent_actions": [],
633
+ },
634
+ }
635
+ ) + "\n"
636
+ continue
637
+ yield json.dumps(event) + "\n"
638
+ except Exception as exc:
639
+ logger.exception("Runtime query stream failed", exc_info=exc)
640
+ yield json.dumps({"type": "error", "error": str(exc)}) + "\n"
641
+
642
+ return StreamingResponse(
643
+ event_stream(),
644
+ media_type="application/x-ndjson",
645
+ )
646
+
647
+ return [
648
+ Route("/api/v1/runtime/query", runtime_query, methods=["POST"]),
649
+ Route("/api/v1/runtime/query/stream", runtime_query_stream, methods=["POST"]),
650
+ ]