botnesia-core 0.1.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 (70) hide show
  1. agent_observability.py +402 -0
  2. agent_os.py +180 -0
  3. agent_registry.py +170 -0
  4. ai_providers/__init__.py +20 -0
  5. ai_providers/base.py +30 -0
  6. ai_providers/deepseek.py +206 -0
  7. ai_providers/gemini.py +441 -0
  8. ai_providers/groq_provider.py +176 -0
  9. ai_providers/openrouter.py +254 -0
  10. ai_providers/router.py +371 -0
  11. ai_providers/types.py +73 -0
  12. anti_hallucination_engine.py +119 -0
  13. base.py +631 -0
  14. botnesia_core-0.1.0.dist-info/METADATA +253 -0
  15. botnesia_core-0.1.0.dist-info/RECORD +70 -0
  16. botnesia_core-0.1.0.dist-info/WHEEL +5 -0
  17. botnesia_core-0.1.0.dist-info/licenses/LICENSE +202 -0
  18. botnesia_core-0.1.0.dist-info/licenses/NOTICE +5 -0
  19. botnesia_core-0.1.0.dist-info/top_level.txt +34 -0
  20. cognitive_loop/__init__.py +16 -0
  21. cognitive_loop/loop.py +151 -0
  22. cognitive_loop/worker.py +32 -0
  23. cost_intelligence.py +196 -0
  24. devil_advocate_agent.py +116 -0
  25. evaluation/__init__.py +15 -0
  26. evaluation/evaluator.py +115 -0
  27. evaluation/schema.py +30 -0
  28. event_bus/__init__.py +46 -0
  29. event_bus/bus.py +101 -0
  30. event_bus/events.py +26 -0
  31. feature_flags/__init__.py +27 -0
  32. feature_flags/flags.py +101 -0
  33. first_principle_agent.py +139 -0
  34. groq_knowledge.py +345 -0
  35. intent_classifier.py +84 -0
  36. kb_embeddings.py +98 -0
  37. knowledge_access_engine.py +209 -0
  38. long_term_memory/__init__.py +19 -0
  39. long_term_memory/schema.py +57 -0
  40. long_term_memory/store.py +126 -0
  41. mcp_client.py +147 -0
  42. mcp_registry.py +148 -0
  43. multi_agent_orchestrator.py +437 -0
  44. perf_cache.py +76 -0
  45. planner_agent.py +81 -0
  46. platform_state/__init__.py +22 -0
  47. platform_state/base.py +73 -0
  48. platform_state/factory.py +26 -0
  49. platform_state/inprocess.py +140 -0
  50. platform_state/redis_store.py +124 -0
  51. policy_engine/__init__.py +21 -0
  52. policy_engine/engine.py +98 -0
  53. policy_engine/loader.py +76 -0
  54. prompt_registry/__init__.py +22 -0
  55. prompt_registry/factory.py +19 -0
  56. prompt_registry/registry.py +160 -0
  57. prompt_registry/schema.py +37 -0
  58. socratic_reasoning.py +119 -0
  59. task_engine.py +143 -0
  60. task_runtime/__init__.py +22 -0
  61. task_runtime/monitor.py +142 -0
  62. task_runtime/repository.py +230 -0
  63. task_runtime/runner.py +344 -0
  64. task_runtime/schema.py +64 -0
  65. task_runtime/worker.py +65 -0
  66. tool_executor.py +836 -0
  67. tool_registry.py +308 -0
  68. uncertainty_engine.py +195 -0
  69. vendor_bootstrap.py +28 -0
  70. workflow_engine.py +646 -0
agent_registry.py ADDED
@@ -0,0 +1,170 @@
1
+ """
2
+ agent_registry.py — Agent Directory + Admin Agent (AI Agent Platform Phase 5).
3
+
4
+ Katalog deklaratif setiap "digital employee" agent di platform ini (BUKAN
5
+ agent mesin-internal reasoning pipeline seperti Devil's Advocate/Verification/
6
+ Planner/Intent Classifier -- itu komponen reasoning, bukan "karyawan").
7
+ `list_agents()` membaca atribut `skills`/`tools`/`goals` yang SUDAH ADA di
8
+ tiap class lewat import dinamis -- tidak hardcode duplikat supaya tidak
9
+ drift kalau atribut itu berubah. Katalog agent (data proprietary) dan
10
+ `AdminAgent` (fitur agregasi Cloud) berada di modul terpisah dan di-import
11
+ secara guarded, supaya mekanisme registry ini reusable di `botnesia-core`.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import importlib
16
+ from dataclasses import dataclass
17
+
18
+ from base import BaseAgent
19
+
20
+ CHAT_PIPELINE = "chat_pipeline"
21
+ AUTHENTICATED_API = "authenticated_api"
22
+
23
+ # ── Proprietary catalog seam (Open-Core extraction) ─────────────────────────
24
+ # The agent *catalog* (which digital-employee agents exist, their RBAC per
25
+ # category, and the heuristic routing keywords) is BotNesia's proprietary AI
26
+ # Workforce definition. It lives in `agent_directory_data` (Cloud/Enterprise
27
+ # only) so this registry *mechanism* can be reused inside the open
28
+ # `botnesia-core` package without exposing the catalog.
29
+ #
30
+ # In the Cloud/Enterprise deployment `agent_directory_data` is always present,
31
+ # so these names bind to the full catalog and behaviour is byte-identical.
32
+ # In botnesia-core (where that module is absent) they fall back to empty
33
+ # structures: the registry machinery still works, users register their own
34
+ # agents, and no proprietary names are shipped.
35
+ try:
36
+ from agent_directory_data import (
37
+ AGENT_DIRECTORY,
38
+ ORCHESTRATION_EXTRA,
39
+ AGENT_PERMISSION_BY_CATEGORY,
40
+ AGENT_CAPABILITY_KEYWORDS,
41
+ )
42
+ except ImportError: # pragma: no cover - core-only path (catalog not installed)
43
+ AGENT_DIRECTORY: list[tuple[str, str, str, str]] = []
44
+ ORCHESTRATION_EXTRA: list[tuple[str, str, str, str]] = []
45
+ AGENT_PERMISSION_BY_CATEGORY: dict[str, str | None] = {}
46
+ AGENT_CAPABILITY_KEYWORDS: dict[str, list[str]] = {}
47
+
48
+
49
+ @dataclass
50
+ class OrchestrationAgentSpec:
51
+ """Deskriptor satu agent yang bisa dipanggil orkestrator internal."""
52
+ name: str
53
+ class_name: str
54
+ category: str
55
+ module_path: str
56
+ permission: str | None
57
+ capabilities: list[str]
58
+
59
+
60
+ def _overrides_run(cls: type) -> bool:
61
+ """True bila class benar-benar mengimplementasikan run() sendiri.
62
+
63
+ Dasar penemuan DINAMIS: hanya agent dengan run(context) sendiri yang bisa
64
+ di-orkestrasi seragam via safe_run(). Agent berbasis fungsi-modul (mis.
65
+ operations/security tanpa run()) otomatis TIDAK terdaftar sampai punya run()
66
+ — tak perlu daftar hardcode terpisah.
67
+ """
68
+ return getattr(cls, "run", None) is not getattr(BaseAgent, "run", None)
69
+
70
+
71
+ def build_agent(module_path: str, class_name: str, **kwargs) -> BaseAgent:
72
+ """Instansiasi agent secara dinamis dari (module_path, class_name).
73
+
74
+ Kwarg yang tidak diterima __init__ agent DIBUANG otomatis (kecuali agent
75
+ memakai **kwargs) supaya config LLM bersama aman dipakai lintas agent yang
76
+ signature-nya beda-beda — tanpa TypeError.
77
+ """
78
+ import inspect
79
+ module = importlib.import_module(module_path)
80
+ cls = getattr(module, class_name)
81
+ try:
82
+ sig = inspect.signature(cls.__init__)
83
+ has_var_kw = any(p.kind is inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values())
84
+ if not has_var_kw:
85
+ allowed = set(sig.parameters) - {"self"}
86
+ kwargs = {k: v for k, v in kwargs.items() if k in allowed}
87
+ except (TypeError, ValueError):
88
+ pass
89
+ return cls(**kwargs)
90
+
91
+
92
+ def orchestration_agents(
93
+ *, allowed_permissions: set[str] | None = None
94
+ ) -> list[OrchestrationAgentSpec]:
95
+ """Daftar agent yang boleh dipanggil orkestrator, difilter RBAC.
96
+
97
+ Penemuan dinamis: gabung AGENT_DIRECTORY + ORCHESTRATION_EXTRA, sisakan yang
98
+ override run(), lalu filter berdasarkan permission efektif user. Bila
99
+ allowed_permissions None → tanpa filter (mis. konteks super-admin/test).
100
+ """
101
+ specs: list[OrchestrationAgentSpec] = []
102
+ seen: set[str] = set()
103
+ for module_path, class_name, category, _channel in (*AGENT_DIRECTORY, *ORCHESTRATION_EXTRA):
104
+ if class_name in seen:
105
+ continue
106
+ try:
107
+ module = importlib.import_module(module_path)
108
+ cls = getattr(module, class_name)
109
+ except Exception:
110
+ continue
111
+ if not _overrides_run(cls):
112
+ continue
113
+ perm = AGENT_PERMISSION_BY_CATEGORY.get(category)
114
+ if (
115
+ perm is not None
116
+ and allowed_permissions is not None
117
+ and perm not in allowed_permissions
118
+ and "*" not in allowed_permissions
119
+ ):
120
+ continue
121
+ seen.add(class_name)
122
+ specs.append(OrchestrationAgentSpec(
123
+ name=getattr(cls, "name", class_name),
124
+ class_name=class_name,
125
+ category=category,
126
+ module_path=module_path,
127
+ permission=perm,
128
+ capabilities=AGENT_CAPABILITY_KEYWORDS.get(category, []),
129
+ ))
130
+ return specs
131
+
132
+
133
+ def list_agents() -> list[dict]:
134
+ agents: list[dict] = []
135
+ for module_path, class_name, category, channel in AGENT_DIRECTORY:
136
+ try:
137
+ module = importlib.import_module(module_path)
138
+ cls = getattr(module, class_name)
139
+ except Exception:
140
+ continue
141
+ agents.append({
142
+ "name": getattr(cls, "name", class_name),
143
+ "class_name": class_name,
144
+ "category": category,
145
+ "channel": channel,
146
+ "skills": list(getattr(cls, "skills", [])),
147
+ "tools": list(getattr(cls, "tools", [])),
148
+ "goals": list(getattr(cls, "goals", [])),
149
+ })
150
+ return agents
151
+
152
+
153
+ def get_agent(name: str) -> dict | None:
154
+ for agent in list_agents():
155
+ if agent["name"] == name:
156
+ return agent
157
+ return None
158
+
159
+
160
+ # ── AdminAgent seam (Open-Core extraction) ──────────────────────────────────
161
+ # AdminAgent.platform_overview() aggregates Cloud subsystems (execution log,
162
+ # workforce orchestrator, computer agent, local-agent commands), so the class
163
+ # is a Cloud/Enterprise feature and lives in `agent_admin` (not open-sourced).
164
+ # Re-exported here so `from agent_registry import AdminAgent` keeps working in
165
+ # the Cloud deployment; in botnesia-core the module is absent and AdminAgent is
166
+ # None (the open registry ships without it and without those subsystem names).
167
+ try:
168
+ from agent_admin import AdminAgent # noqa: F401
169
+ except ImportError: # pragma: no cover - core-only path (AdminAgent not installed)
170
+ AdminAgent = None # type: ignore[assignment,misc]
@@ -0,0 +1,20 @@
1
+ from ai_providers.types import LLMRequest, LLMResponse, ProviderType, TaskType
2
+ from ai_providers.base import AIProvider
3
+ from ai_providers.gemini import GeminiProvider
4
+ from ai_providers.groq_provider import GroqProvider
5
+ from ai_providers.openrouter import OpenRouterProvider
6
+ from ai_providers.deepseek import DeepSeekProvider
7
+ from ai_providers.router import SmartModelRouter
8
+
9
+ __all__ = [
10
+ "LLMRequest",
11
+ "LLMResponse",
12
+ "ProviderType",
13
+ "TaskType",
14
+ "AIProvider",
15
+ "GeminiProvider",
16
+ "GroqProvider",
17
+ "OpenRouterProvider",
18
+ "DeepSeekProvider",
19
+ "SmartModelRouter",
20
+ ]
ai_providers/base.py ADDED
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+
5
+ from ai_providers.types import LLMRequest, LLMResponse
6
+
7
+
8
+ class AIProvider(ABC):
9
+ @abstractmethod
10
+ async def complete(self, request: LLMRequest, *, model: str | None = None) -> LLMResponse:
11
+ """Non-streaming completion. `model` overrides the provider default."""
12
+ ...
13
+
14
+ @abstractmethod
15
+ async def stream(self, request: LLMRequest, *, model: str | None = None):
16
+ """Async generator yielding str chunks."""
17
+ ...
18
+
19
+ @abstractmethod
20
+ def is_available(self) -> bool:
21
+ """True if provider has a usable API key."""
22
+ ...
23
+
24
+ @property
25
+ @abstractmethod
26
+ def provider_name(self) -> str: ...
27
+
28
+ @property
29
+ @abstractmethod
30
+ def default_model(self) -> str: ...
@@ -0,0 +1,206 @@
1
+ """
2
+ ai_providers/deepseek.py — DeepSeek provider.
3
+
4
+ DeepSeek exposes an OpenAI-compatible API at https://api.deepseek.com.
5
+ Models available:
6
+ deepseek-chat — DeepSeek-V3, general purpose (fast, cheap, excellent for coding)
7
+ deepseek-reasoner — DeepSeek-R1, chain-of-thought reasoning (slower, deeper analysis)
8
+
9
+ Set DEEPSEEK_API_KEY in .env to activate. When set, the SmartModelRouter
10
+ uses DeepSeek directly for coding and reasoning tasks — without the OpenRouter
11
+ markup — before falling back to OpenRouter or Groq.
12
+ """
13
+ import asyncio
14
+ import json as _json
15
+ import logging
16
+ import time
17
+
18
+ import httpx
19
+
20
+ from ai_providers.base import AIProvider
21
+ from ai_providers.types import LLMRequest, LLMResponse
22
+
23
+ logger = logging.getLogger("botnesia.deepseek")
24
+
25
+ _BASE_URL = "https://api.deepseek.com"
26
+ _RETRYABLE = frozenset({429, 500, 502, 503, 504})
27
+
28
+ # Default model per task type when routing through DeepSeek directly.
29
+ # CATATAN: task-routing internal ini SENGAJA memakai model DeepSeek yang sudah
30
+ # terbukti (deepseek-chat/deepseek-reasoner) agar pipeline lama tetap stabil.
31
+ # Nama model per-tier "3 otak" yang env-driven ditangani terpisah di
32
+ # deepseek_brain.py (lihat docs/DEEPSEEK_BOTNESIA_BRAIN.md).
33
+ DEEPSEEK_TASK_MODELS: dict = {
34
+ "coding": "deepseek-chat",
35
+ "advanced_coding": "deepseek-chat",
36
+ "reasoning": "deepseek-reasoner",
37
+ "deep_reasoning": "deepseek-reasoner",
38
+ "planning": "deepseek-chat",
39
+ "document": "deepseek-chat",
40
+ "document_analysis": "deepseek-chat",
41
+ }
42
+
43
+ # Tasks where DeepSeek should NOT be the primary (let Gemini handle these)
44
+ _SKIP_TASKS = frozenset({
45
+ "chat", "cs", "customer_service", "faq", "sales",
46
+ "marketing", "hr", "knowledge", "knowledge_search", "internal",
47
+ })
48
+
49
+
50
+ def deepseek_model_for_task(task_type: str) -> str | None:
51
+ """Return DeepSeek model for task, or None if task is better handled elsewhere."""
52
+ task = (task_type or "chat").lower()
53
+ if task in _SKIP_TASKS:
54
+ return None
55
+ return DEEPSEEK_TASK_MODELS.get(task, "deepseek-chat")
56
+
57
+
58
+ def _add_token_usage(model: str, pt: int, ct: int) -> None:
59
+ try:
60
+ from agent_observability import add_token_usage
61
+ add_token_usage(model=model, prompt_tokens=pt, completion_tokens=ct)
62
+ except Exception:
63
+ pass
64
+
65
+
66
+ class DeepSeekProvider(AIProvider):
67
+ """
68
+ DeepSeek direct API — best for coding (deepseek-chat/V3) and
69
+ chain-of-thought reasoning (deepseek-reasoner/R1).
70
+
71
+ Cheaper than routing through OpenRouter because there is no intermediary markup.
72
+ """
73
+
74
+ def __init__(
75
+ self,
76
+ api_key: str,
77
+ model: str = "deepseek-chat",
78
+ base_url: str = _BASE_URL,
79
+ max_retries: int = 2,
80
+ timeout: float = 120.0,
81
+ ):
82
+ self.api_key = api_key
83
+ self.model = model
84
+ self.base_url = base_url.rstrip("/")
85
+ self.max_retries = max_retries
86
+ self.timeout = timeout
87
+ self._client: httpx.AsyncClient | None = None
88
+
89
+ def _get_client(self) -> httpx.AsyncClient:
90
+ if self._client is None or self._client.is_closed:
91
+ self._client = httpx.AsyncClient(timeout=self.timeout)
92
+ return self._client
93
+
94
+ async def aclose(self) -> None:
95
+ if self._client and not self._client.is_closed:
96
+ await self._client.aclose()
97
+ self._client = None
98
+
99
+ def is_available(self) -> bool:
100
+ return bool(self.api_key)
101
+
102
+ @property
103
+ def provider_name(self) -> str:
104
+ return "deepseek"
105
+
106
+ @property
107
+ def default_model(self) -> str:
108
+ return self.model
109
+
110
+ def _headers(self) -> dict:
111
+ return {
112
+ "Authorization": f"Bearer {self.api_key}",
113
+ "Content-Type": "application/json",
114
+ }
115
+
116
+ async def complete(self, request: LLMRequest, *, model: str | None = None) -> LLMResponse:
117
+ resolved = model or self.model
118
+ payload: dict = {
119
+ "model": resolved,
120
+ "messages": request.messages,
121
+ "temperature": request.temperature,
122
+ "max_tokens": request.max_tokens,
123
+ }
124
+ if request.response_format:
125
+ payload["response_format"] = request.response_format
126
+
127
+ client = self._get_client()
128
+ t0 = time.monotonic()
129
+ last_exc: Exception | None = None
130
+ retries = 0
131
+
132
+ for attempt in range(self.max_retries + 1):
133
+ if attempt:
134
+ await asyncio.sleep(min(2 ** (attempt - 1), 8))
135
+ retries += 1
136
+ try:
137
+ resp = await client.post(
138
+ f"{self.base_url}/chat/completions",
139
+ json=payload,
140
+ headers=self._headers(),
141
+ )
142
+ if resp.status_code in _RETRYABLE and attempt < self.max_retries:
143
+ last_exc = httpx.HTTPStatusError(
144
+ f"status {resp.status_code}", request=resp.request, response=resp
145
+ )
146
+ continue
147
+ resp.raise_for_status()
148
+ data = resp.json() or {}
149
+ break
150
+ except (httpx.TimeoutException, httpx.HTTPStatusError) as exc:
151
+ last_exc = exc
152
+ if attempt >= self.max_retries:
153
+ return LLMResponse(
154
+ content="", model=resolved, provider="deepseek",
155
+ latency_ms=int((time.monotonic() - t0) * 1000),
156
+ error=str(exc), retries=retries,
157
+ )
158
+ else:
159
+ return LLMResponse(
160
+ content="", model=resolved, provider="deepseek",
161
+ latency_ms=int((time.monotonic() - t0) * 1000),
162
+ error=str(last_exc), retries=retries,
163
+ )
164
+
165
+ latency_ms = int((time.monotonic() - t0) * 1000)
166
+ usage = data.get("usage") or {}
167
+ pt = int(usage.get("prompt_tokens") or 0)
168
+ ct = int(usage.get("completion_tokens") or 0)
169
+ _add_token_usage(resolved, pt, ct)
170
+ choices = data.get("choices") or []
171
+ content = str(((choices[0] or {}).get("message") or {}).get("content") or "").strip()
172
+ return LLMResponse(
173
+ content=content, model=resolved, provider="deepseek",
174
+ prompt_tokens=pt, completion_tokens=ct,
175
+ latency_ms=latency_ms, retries=retries,
176
+ )
177
+
178
+ async def stream(self, request: LLMRequest, *, model: str | None = None):
179
+ resolved = model or self.model
180
+ payload = {
181
+ "model": resolved,
182
+ "messages": request.messages,
183
+ "temperature": request.temperature,
184
+ "max_tokens": request.max_tokens,
185
+ "stream": True,
186
+ }
187
+ client = self._get_client()
188
+ async with client.stream(
189
+ "POST", f"{self.base_url}/chat/completions",
190
+ json=payload, headers=self._headers(),
191
+ ) as resp:
192
+ resp.raise_for_status()
193
+ async for line in resp.aiter_lines():
194
+ if not line.startswith("data: "):
195
+ continue
196
+ raw = line[6:].strip()
197
+ if not raw or raw == "[DONE]":
198
+ continue
199
+ try:
200
+ chunk = _json.loads(raw)
201
+ delta = ((chunk.get("choices") or [{}])[0].get("delta") or {})
202
+ text = delta.get("content")
203
+ if text:
204
+ yield text
205
+ except Exception:
206
+ continue