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.
- agent_observability.py +402 -0
- agent_os.py +180 -0
- agent_registry.py +170 -0
- ai_providers/__init__.py +20 -0
- ai_providers/base.py +30 -0
- ai_providers/deepseek.py +206 -0
- ai_providers/gemini.py +441 -0
- ai_providers/groq_provider.py +176 -0
- ai_providers/openrouter.py +254 -0
- ai_providers/router.py +371 -0
- ai_providers/types.py +73 -0
- anti_hallucination_engine.py +119 -0
- base.py +631 -0
- botnesia_core-0.1.0.dist-info/METADATA +253 -0
- botnesia_core-0.1.0.dist-info/RECORD +70 -0
- botnesia_core-0.1.0.dist-info/WHEEL +5 -0
- botnesia_core-0.1.0.dist-info/licenses/LICENSE +202 -0
- botnesia_core-0.1.0.dist-info/licenses/NOTICE +5 -0
- botnesia_core-0.1.0.dist-info/top_level.txt +34 -0
- cognitive_loop/__init__.py +16 -0
- cognitive_loop/loop.py +151 -0
- cognitive_loop/worker.py +32 -0
- cost_intelligence.py +196 -0
- devil_advocate_agent.py +116 -0
- evaluation/__init__.py +15 -0
- evaluation/evaluator.py +115 -0
- evaluation/schema.py +30 -0
- event_bus/__init__.py +46 -0
- event_bus/bus.py +101 -0
- event_bus/events.py +26 -0
- feature_flags/__init__.py +27 -0
- feature_flags/flags.py +101 -0
- first_principle_agent.py +139 -0
- groq_knowledge.py +345 -0
- intent_classifier.py +84 -0
- kb_embeddings.py +98 -0
- knowledge_access_engine.py +209 -0
- long_term_memory/__init__.py +19 -0
- long_term_memory/schema.py +57 -0
- long_term_memory/store.py +126 -0
- mcp_client.py +147 -0
- mcp_registry.py +148 -0
- multi_agent_orchestrator.py +437 -0
- perf_cache.py +76 -0
- planner_agent.py +81 -0
- platform_state/__init__.py +22 -0
- platform_state/base.py +73 -0
- platform_state/factory.py +26 -0
- platform_state/inprocess.py +140 -0
- platform_state/redis_store.py +124 -0
- policy_engine/__init__.py +21 -0
- policy_engine/engine.py +98 -0
- policy_engine/loader.py +76 -0
- prompt_registry/__init__.py +22 -0
- prompt_registry/factory.py +19 -0
- prompt_registry/registry.py +160 -0
- prompt_registry/schema.py +37 -0
- socratic_reasoning.py +119 -0
- task_engine.py +143 -0
- task_runtime/__init__.py +22 -0
- task_runtime/monitor.py +142 -0
- task_runtime/repository.py +230 -0
- task_runtime/runner.py +344 -0
- task_runtime/schema.py +64 -0
- task_runtime/worker.py +65 -0
- tool_executor.py +836 -0
- tool_registry.py +308 -0
- uncertainty_engine.py +195 -0
- vendor_bootstrap.py +28 -0
- workflow_engine.py +646 -0
agent_observability.py
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
"""Persistent, fail-open tracing for the BotNesia multi-agent pipeline."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import contextvars
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import time
|
|
9
|
+
import traceback
|
|
10
|
+
import uuid
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from decimal import Decimal
|
|
14
|
+
from typing import Any, Awaitable, Callable, TypeVar
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
# Optional-dependency seam (Open-Core extraction, blocker R1):
|
|
19
|
+
# In the Cloud/Enterprise deployment `bn_platform` is always present and these are
|
|
20
|
+
# the real Prometheus recorders — behaviour is byte-identical. When this module is
|
|
21
|
+
# reused inside the open `botnesia-core` package (where `bn_platform` is absent),
|
|
22
|
+
# the import degrades to fail-open no-ops instead of raising ImportError. This keeps
|
|
23
|
+
# the core importable without pulling in any business/cloud code. No public API
|
|
24
|
+
# changes: `add_token_usage`/`observe_agent` (imported by base.py) are unaffected.
|
|
25
|
+
try:
|
|
26
|
+
from bn_platform.observability import record_ai_request, record_token_usage
|
|
27
|
+
except ImportError: # pragma: no cover - core-only path (bn_platform not installed)
|
|
28
|
+
def record_ai_request(*args, **kwargs) -> None: # type: ignore[misc]
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
def record_token_usage(*args, **kwargs) -> None: # type: ignore[misc]
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
from cost_intelligence import choose_model, estimate_cost_usd, reset_model_route, set_model_route
|
|
35
|
+
|
|
36
|
+
T = TypeVar("T")
|
|
37
|
+
|
|
38
|
+
# ── Auto-retry untuk kegagalan TRANSIENT (timeout/jaringan/API sementara) ──
|
|
39
|
+
# Provider LLM sudah retry di level HTTP; ini jaring pengaman level-agent untuk
|
|
40
|
+
# error transient yang lolos dari provider. Konfigurasi via env.
|
|
41
|
+
_RETRY_MAX = int(os.getenv("AGENT_RETRY_MAX", "3"))
|
|
42
|
+
_RETRY_BASE_DELAY = float(os.getenv("AGENT_RETRY_BASE_DELAY", "0.5"))
|
|
43
|
+
_RETRY_MAX_DELAY = float(os.getenv("AGENT_RETRY_MAX_DELAY", "8"))
|
|
44
|
+
_TRANSIENT_TYPES = (
|
|
45
|
+
httpx.TimeoutException, httpx.ConnectError, httpx.NetworkError,
|
|
46
|
+
httpx.RemoteProtocolError, httpx.PoolTimeout, asyncio.TimeoutError,
|
|
47
|
+
ConnectionError,
|
|
48
|
+
)
|
|
49
|
+
_TRANSIENT_KEYWORDS = (
|
|
50
|
+
"timed out", "timeout", "temporarily unavailable", "service unavailable",
|
|
51
|
+
"connection reset", "connection aborted", "rate limit", "too many requests",
|
|
52
|
+
"502", "503", "504", "bad gateway", "gateway timeout",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ── Realtime event publisher (di-wire main ke ObservabilityHub) ──
|
|
57
|
+
# None = tidak ada dashboard realtime (fail-open). Dipanggil non-blocking.
|
|
58
|
+
_event_publisher: Callable[[str, dict], Any] | None = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def set_event_publisher(fn: Callable[[str, dict], Any] | None) -> None:
|
|
62
|
+
global _event_publisher
|
|
63
|
+
_event_publisher = fn
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _emit(org_id: str, event: dict) -> None:
|
|
67
|
+
"""Publish event realtime tanpa memblokir jalur eksekusi (fire-and-forget)."""
|
|
68
|
+
if not _event_publisher or not org_id:
|
|
69
|
+
return
|
|
70
|
+
try:
|
|
71
|
+
coro = _event_publisher(org_id, event)
|
|
72
|
+
if asyncio.iscoroutine(coro):
|
|
73
|
+
task = asyncio.ensure_future(coro)
|
|
74
|
+
task.add_done_callback(lambda t: t.exception()) # jangan bocorkan exc
|
|
75
|
+
except Exception:
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _is_transient(exc: BaseException) -> bool:
|
|
80
|
+
"""True bila error layak di-retry (transient), bukan bug logika/permanen."""
|
|
81
|
+
if isinstance(exc, _TRANSIENT_TYPES):
|
|
82
|
+
return True
|
|
83
|
+
resp = getattr(exc, "response", None)
|
|
84
|
+
if resp is not None and getattr(resp, "status_code", None) in (429, 500, 502, 503, 504):
|
|
85
|
+
return True
|
|
86
|
+
msg = str(exc).lower()
|
|
87
|
+
return any(k in msg for k in _TRANSIENT_KEYWORDS)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class ModelUsage:
|
|
92
|
+
model: str
|
|
93
|
+
prompt_tokens: int
|
|
94
|
+
completion_tokens: int
|
|
95
|
+
estimated_cost: Decimal
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class TokenUsage:
|
|
100
|
+
prompt_tokens: int = 0
|
|
101
|
+
completion_tokens: int = 0
|
|
102
|
+
model_usages: list[ModelUsage] = field(default_factory=list)
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def total_tokens(self) -> int:
|
|
106
|
+
return self.prompt_tokens + self.completion_tokens
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class TraceState:
|
|
111
|
+
trace_id: str
|
|
112
|
+
tenant_id: str
|
|
113
|
+
conversation_id: str
|
|
114
|
+
channel: str = "widget"
|
|
115
|
+
actual_model: str = ""
|
|
116
|
+
pool: Any = None
|
|
117
|
+
sequence: int = 0
|
|
118
|
+
request_tokens: TokenUsage = field(default_factory=TokenUsage)
|
|
119
|
+
|
|
120
|
+
def next_sequence(self) -> int:
|
|
121
|
+
self.sequence += 1
|
|
122
|
+
return self.sequence
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
_trace_state: contextvars.ContextVar[TraceState | None] = contextvars.ContextVar("botnesia_trace_state", default=None)
|
|
126
|
+
_execution_id: contextvars.ContextVar[str | None] = contextvars.ContextVar("botnesia_execution_id", default=None)
|
|
127
|
+
_execution_tokens: contextvars.ContextVar[TokenUsage | None] = contextvars.ContextVar("botnesia_execution_tokens", default=None)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def add_token_usage(*, model: str, prompt_tokens: int = 0, completion_tokens: int = 0) -> None:
|
|
131
|
+
"""Attach provider token usage and estimated cost to the active execution."""
|
|
132
|
+
prompt = max(0, int(prompt_tokens or 0))
|
|
133
|
+
completion = max(0, int(completion_tokens or 0))
|
|
134
|
+
model_name = model or "unknown"
|
|
135
|
+
state = _trace_state.get()
|
|
136
|
+
current = _execution_tokens.get()
|
|
137
|
+
if current is not None:
|
|
138
|
+
current.prompt_tokens += prompt
|
|
139
|
+
current.completion_tokens += completion
|
|
140
|
+
current.model_usages.append(
|
|
141
|
+
ModelUsage(
|
|
142
|
+
model=model_name,
|
|
143
|
+
prompt_tokens=prompt,
|
|
144
|
+
completion_tokens=completion,
|
|
145
|
+
estimated_cost=estimate_cost_usd(model_name, prompt, completion),
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
if state is not None:
|
|
149
|
+
state.request_tokens.prompt_tokens += prompt
|
|
150
|
+
state.request_tokens.completion_tokens += completion
|
|
151
|
+
state.actual_model = model_name
|
|
152
|
+
record_token_usage(
|
|
153
|
+
org_id=state.tenant_id if state else None,
|
|
154
|
+
model=model_name,
|
|
155
|
+
prompt_tokens=prompt,
|
|
156
|
+
completion_tokens=completion,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _confidence(value: Any) -> float | None:
|
|
161
|
+
output = getattr(value, "output", None)
|
|
162
|
+
if isinstance(output, dict):
|
|
163
|
+
raw = output.get("confidence_score", output.get("confidence"))
|
|
164
|
+
elif isinstance(value, dict):
|
|
165
|
+
raw = value.get("confidence_score", value.get("confidence"))
|
|
166
|
+
else:
|
|
167
|
+
raw = getattr(value, "confidence_score", None)
|
|
168
|
+
return float(raw) if isinstance(raw, (int, float)) else None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _status(value: Any) -> tuple[str, str | None]:
|
|
172
|
+
success = getattr(value, "success", True)
|
|
173
|
+
error = getattr(value, "error", None)
|
|
174
|
+
output = getattr(value, "output", value if isinstance(value, dict) else None)
|
|
175
|
+
if success is False:
|
|
176
|
+
return "error", str(error or "Agent execution failed")
|
|
177
|
+
if isinstance(output, dict) and any(output.get(k) for k in
|
|
178
|
+
("waiting", "needs_approval", "awaiting_approval", "pending_approval")):
|
|
179
|
+
return "waiting", None # menunggu approval/dependency (valid, bukan gagal)
|
|
180
|
+
if isinstance(output, dict) and output.get("skipped"):
|
|
181
|
+
return "skipped", None
|
|
182
|
+
return "success", None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _output_summary(value: Any) -> dict:
|
|
186
|
+
output = getattr(value, "output", value if isinstance(value, dict) else {})
|
|
187
|
+
if not isinstance(output, dict):
|
|
188
|
+
return {}
|
|
189
|
+
allowed = (
|
|
190
|
+
"reasoning_summary", "conclusion", "limitations", "suggested_next_action",
|
|
191
|
+
"verified", "issues", "complexity", "source", "matched", "has_objection",
|
|
192
|
+
"should_escalate", "urgency", "intent", "skipped", "reason",
|
|
193
|
+
"risk_if_wrong", "needs_clarification", "severity",
|
|
194
|
+
"needs_revision", "overstatement_risk", "revised",
|
|
195
|
+
"causal_links_count", "root_hypotheses_count",
|
|
196
|
+
"uncertainty_band", "uncertainty_score", "uncertainty_reasons",
|
|
197
|
+
)
|
|
198
|
+
return {key: output[key] for key in allowed if key in output}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
async def _execute(pool: Any, sql: str, *args: Any) -> None:
|
|
202
|
+
if pool is None:
|
|
203
|
+
return
|
|
204
|
+
try:
|
|
205
|
+
await pool.execute(sql, *args)
|
|
206
|
+
except Exception:
|
|
207
|
+
# Observability and cost accounting must never break customer responses.
|
|
208
|
+
return
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
async def observe_agent(agent_name: str, context: dict, operation: Callable[[], Awaitable[T]]) -> T:
|
|
212
|
+
"""Track one agent lifecycle, token usage, and provider cost."""
|
|
213
|
+
state = _trace_state.get()
|
|
214
|
+
if state is None:
|
|
215
|
+
return await operation()
|
|
216
|
+
|
|
217
|
+
execution_id = str(uuid.uuid4())
|
|
218
|
+
parent_id = _execution_id.get()
|
|
219
|
+
sequence = state.next_sequence()
|
|
220
|
+
started_at = datetime.now(timezone.utc)
|
|
221
|
+
started_perf = time.perf_counter()
|
|
222
|
+
usage = TokenUsage()
|
|
223
|
+
id_token = _execution_id.set(execution_id)
|
|
224
|
+
usage_token = _execution_tokens.set(usage)
|
|
225
|
+
await _execute(
|
|
226
|
+
state.pool,
|
|
227
|
+
"""INSERT INTO agent_executions
|
|
228
|
+
(id, trace_id, parent_execution_id, tenant_id, conversation_id,
|
|
229
|
+
agent_name, sequence_no, execution_start, status, created_at)
|
|
230
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'running',NOW())""",
|
|
231
|
+
execution_id, state.trace_id, parent_id, state.tenant_id,
|
|
232
|
+
state.conversation_id, agent_name, sequence, started_at,
|
|
233
|
+
)
|
|
234
|
+
_emit(state.tenant_id, {"type": "agent", "agent_name": agent_name,
|
|
235
|
+
"status": "running", "trace_id": state.trace_id,
|
|
236
|
+
"ts": started_at.isoformat()})
|
|
237
|
+
|
|
238
|
+
result: Any = None
|
|
239
|
+
retry_count = 0
|
|
240
|
+
error_stack: str | None = None
|
|
241
|
+
# Nilai default: bila alur keluar lewat BaseException tak terduga, finally tetap
|
|
242
|
+
# punya status/error/confidence yang terikat (cegah UnboundLocalError).
|
|
243
|
+
status, error, confidence = "running", None, None
|
|
244
|
+
try:
|
|
245
|
+
# Auto-retry TRANSIENT dengan exponential backoff (maks _RETRY_MAX).
|
|
246
|
+
# Error non-transient (bug logika, permission, value) gagal cepat.
|
|
247
|
+
while True:
|
|
248
|
+
try:
|
|
249
|
+
result = await operation()
|
|
250
|
+
break
|
|
251
|
+
except Exception as exc: # noqa: BLE001 — klasifikasi transient di bawah
|
|
252
|
+
# Cegah multiplikasi retry pada observe_agent BERSARANG (mis.
|
|
253
|
+
# supervisor membungkus operation yang juga memanggil observe_agent):
|
|
254
|
+
# exception transient yang sudah di-retry di level dalam ditandai,
|
|
255
|
+
# sehingga wrapper luar tidak me-retry ulang.
|
|
256
|
+
already_retried = getattr(exc, "_bn_retried", False)
|
|
257
|
+
if already_retried or retry_count >= _RETRY_MAX or not _is_transient(exc):
|
|
258
|
+
if _is_transient(exc) and retry_count and not already_retried:
|
|
259
|
+
try:
|
|
260
|
+
exc._bn_retried = True
|
|
261
|
+
except Exception:
|
|
262
|
+
pass
|
|
263
|
+
raise
|
|
264
|
+
retry_count += 1
|
|
265
|
+
await _execute(
|
|
266
|
+
state.pool,
|
|
267
|
+
"UPDATE agent_executions SET status='retrying', retry_count=$2 WHERE id=$1",
|
|
268
|
+
execution_id, retry_count,
|
|
269
|
+
)
|
|
270
|
+
_emit(state.tenant_id, {"type": "agent", "agent_name": agent_name,
|
|
271
|
+
"status": "retrying", "retry_count": retry_count,
|
|
272
|
+
"trace_id": state.trace_id})
|
|
273
|
+
await asyncio.sleep(min(_RETRY_BASE_DELAY * (2 ** (retry_count - 1)), _RETRY_MAX_DELAY))
|
|
274
|
+
status, error = _status(result)
|
|
275
|
+
confidence = _confidence(result)
|
|
276
|
+
return result
|
|
277
|
+
except asyncio.CancelledError:
|
|
278
|
+
# Request dibatalkan/timeout klien → CANCELLED (BaseException, TIDAK
|
|
279
|
+
# ditangkap `except Exception`). Bukan bug/kegagalan; harus di-reraise
|
|
280
|
+
# agar pembatalan task berjalan benar.
|
|
281
|
+
status, error, confidence = "cancelled", "Dibatalkan (request cancelled/timeout klien)", None
|
|
282
|
+
raise
|
|
283
|
+
except Exception as exc:
|
|
284
|
+
# str(exc) bisa KOSONG untuk sejumlah exception (mis. CancelledError,
|
|
285
|
+
# TimeoutError tanpa pesan) → dulu error_message tersimpan blank sehingga
|
|
286
|
+
# dashboard menampilkan FAILED tanpa alasan. Selalu sertakan tipe exception
|
|
287
|
+
# agar setiap kegagalan punya root cause yang bisa dibaca.
|
|
288
|
+
_detail = str(exc).strip()
|
|
289
|
+
error = f"{type(exc).__name__}: {_detail}" if _detail else type(exc).__name__
|
|
290
|
+
if retry_count:
|
|
291
|
+
error = f"{error} (setelah {retry_count} retry)"
|
|
292
|
+
# Stacktrace lengkap untuk panel error-detail (dipangkas agar tak membengkak).
|
|
293
|
+
error_stack = traceback.format_exc()[-6000:]
|
|
294
|
+
status, confidence = "error", None
|
|
295
|
+
raise
|
|
296
|
+
finally:
|
|
297
|
+
duration_ms = int((time.perf_counter() - started_perf) * 1000)
|
|
298
|
+
await _execute(
|
|
299
|
+
state.pool,
|
|
300
|
+
"""UPDATE agent_executions
|
|
301
|
+
SET execution_end=$2, duration_ms=$3, status=$4, error_message=$5,
|
|
302
|
+
confidence_score=$6, prompt_tokens=$7, completion_tokens=$8,
|
|
303
|
+
total_tokens=$9, metadata=$10::jsonb, retry_count=$11, error_stack=$12
|
|
304
|
+
WHERE id=$1""",
|
|
305
|
+
execution_id, datetime.now(timezone.utc), duration_ms, status, error, confidence,
|
|
306
|
+
usage.prompt_tokens, usage.completion_tokens, usage.total_tokens,
|
|
307
|
+
json.dumps(_output_summary(result), ensure_ascii=True), retry_count, error_stack,
|
|
308
|
+
)
|
|
309
|
+
_emit(state.tenant_id, {"type": "agent", "agent_name": agent_name,
|
|
310
|
+
"status": status, "retry_count": retry_count,
|
|
311
|
+
"error_message": error, "duration_ms": duration_ms,
|
|
312
|
+
"trace_id": state.trace_id})
|
|
313
|
+
for model_usage in usage.model_usages:
|
|
314
|
+
await _execute(
|
|
315
|
+
state.pool,
|
|
316
|
+
"""INSERT INTO cost_records
|
|
317
|
+
(id, tenant_id, conversation_id, trace_id, execution_id,
|
|
318
|
+
model_name, agent_name, prompt_tokens, completion_tokens,
|
|
319
|
+
token_count, estimated_cost, currency, channel, created_at)
|
|
320
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,'USD',$12,NOW())""",
|
|
321
|
+
str(uuid.uuid4()), state.tenant_id, state.conversation_id,
|
|
322
|
+
state.trace_id, execution_id, model_usage.model, agent_name,
|
|
323
|
+
model_usage.prompt_tokens, model_usage.completion_tokens,
|
|
324
|
+
model_usage.prompt_tokens + model_usage.completion_tokens,
|
|
325
|
+
model_usage.estimated_cost, state.channel,
|
|
326
|
+
)
|
|
327
|
+
record_ai_request(
|
|
328
|
+
agent=agent_name,
|
|
329
|
+
success=status in {"success", "skipped"},
|
|
330
|
+
duration_seconds=duration_ms / 1000,
|
|
331
|
+
)
|
|
332
|
+
_execution_tokens.reset(usage_token)
|
|
333
|
+
_execution_id.reset(id_token)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
async def trace_request(context: dict, operation: Callable[[], Awaitable[T]]) -> T:
|
|
337
|
+
"""Create one request trace and select the cost-efficient model tier."""
|
|
338
|
+
tenant_id = str(context.get("org_id") or context.get("tenant_id") or "")
|
|
339
|
+
conversation_id = str(context.get("conversation_id") or "")
|
|
340
|
+
pool = context.get("_observability_pool")
|
|
341
|
+
if not tenant_id or not conversation_id:
|
|
342
|
+
return await operation()
|
|
343
|
+
|
|
344
|
+
trace_id = str(uuid.uuid4())
|
|
345
|
+
metadata = context.get("metadata") or {}
|
|
346
|
+
channel = str(metadata.get("channel") or context.get("channel") or "widget")
|
|
347
|
+
route = choose_model(
|
|
348
|
+
str(context.get("user_message") or ""),
|
|
349
|
+
str(context.get("reasoning_mode") or "standard"),
|
|
350
|
+
str(context.get("_cheap_model") or "llama-3.1-8b-instant"),
|
|
351
|
+
str(context.get("_strong_model") or "meta-llama/llama-4-scout-17b-16e-instruct"),
|
|
352
|
+
)
|
|
353
|
+
route_token = set_model_route(route)
|
|
354
|
+
state = TraceState(
|
|
355
|
+
trace_id=trace_id, tenant_id=tenant_id, conversation_id=conversation_id,
|
|
356
|
+
channel=channel, actual_model=route.model, pool=pool,
|
|
357
|
+
)
|
|
358
|
+
state_token = _trace_state.set(state)
|
|
359
|
+
started = datetime.now(timezone.utc)
|
|
360
|
+
await _execute(
|
|
361
|
+
pool,
|
|
362
|
+
"""INSERT INTO ai_traces
|
|
363
|
+
(id, tenant_id, conversation_id, user_question, status, started_at,
|
|
364
|
+
routed_model, task_complexity, channel, created_at)
|
|
365
|
+
VALUES ($1,$2,$3,$4,'running',$5,$6,$7,$8,NOW())""",
|
|
366
|
+
trace_id, tenant_id, conversation_id,
|
|
367
|
+
str(context.get("user_message") or "")[:10000], started,
|
|
368
|
+
route.model, route.complexity, channel,
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
final_answer = ""
|
|
372
|
+
trace_status = "error"
|
|
373
|
+
try:
|
|
374
|
+
result = await observe_agent("supervisor_agent", context, operation)
|
|
375
|
+
final_answer = str(getattr(result, "final_answer", "") or "")
|
|
376
|
+
try:
|
|
377
|
+
result.prompt_tokens = state.request_tokens.prompt_tokens
|
|
378
|
+
result.completion_tokens = state.request_tokens.completion_tokens
|
|
379
|
+
result.total_tokens = state.request_tokens.total_tokens
|
|
380
|
+
result.routed_model = state.actual_model
|
|
381
|
+
result.task_complexity = route.complexity
|
|
382
|
+
except (AttributeError, TypeError):
|
|
383
|
+
pass
|
|
384
|
+
trace_status = "error" if getattr(result, "errors", []) and not final_answer else "success"
|
|
385
|
+
return result
|
|
386
|
+
finally:
|
|
387
|
+
duration_ms = int((datetime.now(timezone.utc) - started).total_seconds() * 1000)
|
|
388
|
+
await _execute(
|
|
389
|
+
pool,
|
|
390
|
+
"""UPDATE ai_traces
|
|
391
|
+
SET final_answer=$2, status=$3, ended_at=NOW(), duration_ms=$4,
|
|
392
|
+
prompt_tokens=$5, completion_tokens=$6, total_tokens=$7,
|
|
393
|
+
routed_model=$8
|
|
394
|
+
WHERE id=$1""",
|
|
395
|
+
trace_id, final_answer[:20000], trace_status, duration_ms,
|
|
396
|
+
state.request_tokens.prompt_tokens,
|
|
397
|
+
state.request_tokens.completion_tokens,
|
|
398
|
+
state.request_tokens.total_tokens,
|
|
399
|
+
state.actual_model,
|
|
400
|
+
)
|
|
401
|
+
_trace_state.reset(state_token)
|
|
402
|
+
reset_model_route(route_token)
|
agent_os.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agent_os.py — Agent OS Layer (transparansi pipeline) untuk BotNesia.
|
|
3
|
+
|
|
4
|
+
Modul ini TIDAK memanggil LLM, TIDAK mengubah state apapun, dan TIDAK
|
|
5
|
+
dipanggil dari `supervisor.py`'s `_process()`. Ini murni lapisan PELAPORAN
|
|
6
|
+
di atas pipeline yang SUDAH ADA -- `SupervisorAgent._process()` sudah
|
|
7
|
+
menjalankan plan -> tool-select -> execute -> verify -> retry -> report
|
|
8
|
+
secara penuh lewat modul-modul yang sudah berdiri sendiri (planner_agent,
|
|
9
|
+
reasoning_controller, cs_agent, verification_agent, reflection_engine,
|
|
10
|
+
uncertainty_engine). `build_execution_report()` di sini cuma membaca ulang
|
|
11
|
+
field yang sudah ada di `SupervisorResult` dan menyusunnya ke bentuk
|
|
12
|
+
"Agent OS 6-stage" supaya bisa ditampilkan (dashboard/identity) tanpa
|
|
13
|
+
membongkar pipeline aslinya.
|
|
14
|
+
|
|
15
|
+
Dipanggil oleh consumer DI LUAR pipeline (mis. endpoint dashboard di fase
|
|
16
|
+
berikutnya) -- bukan oleh `supervisor.py` sendiri, supaya modul ini tetap
|
|
17
|
+
nol-coupling terhadap jalur chat yang sudah stabil.
|
|
18
|
+
|
|
19
|
+
Sejak AI Agent Platform Phase: menambahkan service catalog untuk semua
|
|
20
|
+
service baru (ComputerUseService, FileSystemService, TerminalService, dll)
|
|
21
|
+
via `describe_agent_platform()` — murni dokumentasi, tidak ada eksekusi.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
AGENT_OS_STAGES: dict[str, dict] = {
|
|
28
|
+
"planning": {
|
|
29
|
+
"description": "Menyusun rencana analisis (lensa spesialis mana yang relevan) untuk pertanyaan kompleks.",
|
|
30
|
+
"implementation": "planner_agent.PlannerAgent",
|
|
31
|
+
},
|
|
32
|
+
"tool_selection": {
|
|
33
|
+
"description": "Menentukan sumber pengetahuan/tool mana yang relevan (memory, knowledge base, web search, dst) beserta alasannya.",
|
|
34
|
+
"implementation": "reasoning_controller.ReasoningController.analyze (knowledge_routing)",
|
|
35
|
+
},
|
|
36
|
+
"execution": {
|
|
37
|
+
"description": "Menjalankan lensa spesialis (Pro mode) dan menyusun jawaban akhir.",
|
|
38
|
+
"implementation": "reasoning_agent.ReasoningAgent + cs_agent.CSAgent.synthesize",
|
|
39
|
+
},
|
|
40
|
+
"verification": {
|
|
41
|
+
"description": "Memeriksa kualitas jawaban: risiko halusinasi, kelengkapan, konsistensi.",
|
|
42
|
+
"implementation": "verification_agent.VerificationAgent",
|
|
43
|
+
},
|
|
44
|
+
"retry": {
|
|
45
|
+
"description": "Mengulang sintesis (maksimal beberapa kali) bila verifikasi belum lolos confidence minimum.",
|
|
46
|
+
"implementation": "supervisor.SupervisorAgent._process MAX_RETRIES loop",
|
|
47
|
+
},
|
|
48
|
+
"reporting": {
|
|
49
|
+
"description": "Self-check akhir terhadap reasoning brief, lalu menetapkan band confidence (High/Medium/Low) untuk dilaporkan ke user.",
|
|
50
|
+
"implementation": "reflection_engine.reflect + uncertainty_engine.UncertaintyEngine",
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def describe_stage(name: str) -> dict:
|
|
56
|
+
"""Lookup-with-default, mirror tool_registry.describe_tool()."""
|
|
57
|
+
return AGENT_OS_STAGES.get(name, {"description": "Stage tidak dikenal.", "implementation": "-"})
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
AGENT_PLATFORM_SERVICES: dict[str, dict] = {
|
|
61
|
+
"ComputerUseService": {
|
|
62
|
+
"module": "computer_use_service",
|
|
63
|
+
"description": "Browser automation + native app interaction dengan permission gate",
|
|
64
|
+
"capabilities": ["navigate", "read_text", "screenshot", "scroll", "click", "fill", "submit",
|
|
65
|
+
"inspect_desktop", "move_mouse", "double_click", "right_click", "drag",
|
|
66
|
+
"type_text", "press_hotkey", "open_application", "login", "scrape_page"],
|
|
67
|
+
"requires_permission": ["browser_access", "browser_write", "screen"],
|
|
68
|
+
},
|
|
69
|
+
"FileSystemService": {
|
|
70
|
+
"module": "file_system_service",
|
|
71
|
+
"description": "Operasi file dengan permission gate (read/write/edit/rename/move/delete/compress/extract)",
|
|
72
|
+
"capabilities": ["read_file", "write_file", "edit_file", "rename_file", "move_file",
|
|
73
|
+
"copy_file", "delete_file", "list_directory", "search_files",
|
|
74
|
+
"compress", "extract", "understand_project"],
|
|
75
|
+
"requires_permission": ["read_files", "write_files", "delete_files"],
|
|
76
|
+
},
|
|
77
|
+
"TerminalService": {
|
|
78
|
+
"module": "terminal_service",
|
|
79
|
+
"description": "Shell command execution dengan timeout, approval gate, dan audit",
|
|
80
|
+
"capabilities": ["execute", "git", "run_python", "npm", "pnpm", "docker",
|
|
81
|
+
"read_log", "kill_process", "list_processes"],
|
|
82
|
+
"requires_permission": ["run_terminal"],
|
|
83
|
+
},
|
|
84
|
+
"PermissionManager": {
|
|
85
|
+
"module": "permission_manager",
|
|
86
|
+
"description": "Enterprise permission model: Allow Once/Always/Deny per resource per org",
|
|
87
|
+
"capabilities": ["check", "grant", "revoke", "list_grants"],
|
|
88
|
+
"permissions": ["read_files", "write_files", "delete_files", "run_terminal",
|
|
89
|
+
"browser_access", "browser_write", "github_access", "database_access",
|
|
90
|
+
"email_access", "api_access", "clipboard", "camera", "microphone", "screen"],
|
|
91
|
+
},
|
|
92
|
+
"SandboxManager": {
|
|
93
|
+
"module": "sandbox_manager",
|
|
94
|
+
"description": "Isolasi eksekusi: temporary workspace, rollback, resource limits",
|
|
95
|
+
"capabilities": ["create_session", "rollback", "cleanup", "session (context manager)"],
|
|
96
|
+
},
|
|
97
|
+
"ActionExecutor": {
|
|
98
|
+
"module": "action_executor",
|
|
99
|
+
"description": "Pipeline: Understand→Plan→Permission→Execute→Observe→Recover→Verify→Summarize",
|
|
100
|
+
"capabilities": ["execute", "understand_goal", "plan_goal", "verify_goal", "summarize_execution"],
|
|
101
|
+
},
|
|
102
|
+
"RecoveryManager": {
|
|
103
|
+
"module": "recovery_manager",
|
|
104
|
+
"description": "Auto-retry dengan backoff eksponensial + circuit breaker + fallback",
|
|
105
|
+
"capabilities": ["with_retry", "with_fallback", "reset_circuit", "get_circuit_status"],
|
|
106
|
+
},
|
|
107
|
+
"AgentMemoryStore": {
|
|
108
|
+
"module": "agent_memory_store",
|
|
109
|
+
"description": "Agent-level memory: project structure, file history, terminal history, browser history",
|
|
110
|
+
"capabilities": ["record_file_opened", "record_command", "record_url_visited",
|
|
111
|
+
"record_action", "update_project_structure", "get_summary",
|
|
112
|
+
"save_to_db", "load_from_db"],
|
|
113
|
+
},
|
|
114
|
+
"AuditLogger": {
|
|
115
|
+
"module": "audit_logger",
|
|
116
|
+
"description": "Audit trail semua aksi agent ke tabel agent_audit_log",
|
|
117
|
+
"capabilities": ["log_action", "update_log", "list_logs"],
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def describe_agent_platform_service(name: str) -> dict:
|
|
123
|
+
"""Lookup service catalog AI Agent Platform."""
|
|
124
|
+
return AGENT_PLATFORM_SERVICES.get(name, {"description": "Service tidak dikenal.", "module": "-"})
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def list_agent_platform_services() -> list[str]:
|
|
128
|
+
"""Daftar semua service AI Agent Platform."""
|
|
129
|
+
return list(AGENT_PLATFORM_SERVICES.keys())
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def build_execution_report(result: Any) -> dict:
|
|
133
|
+
"""Reshape SupervisorResult yang sudah ada menjadi laporan 6-stage Agent OS.
|
|
134
|
+
|
|
135
|
+
`result` adalah instance supervisor.SupervisorResult (diketik Any di sini
|
|
136
|
+
supaya modul ini tidak perlu import supervisor.py -- hindari circular
|
|
137
|
+
import, karena supervisor.py-lah yang nanti bisa memanggil modul ini,
|
|
138
|
+
bukan sebaliknya). Fungsi ini murni, tidak mengubah `result` sama sekali.
|
|
139
|
+
"""
|
|
140
|
+
reasoning_brief = getattr(result, "reasoning_brief", None) or {}
|
|
141
|
+
knowledge_routing = reasoning_brief.get("knowledge_routing") if isinstance(reasoning_brief, dict) else None
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
"planning": {
|
|
145
|
+
**describe_stage("planning"),
|
|
146
|
+
"data": getattr(result, "plan", None),
|
|
147
|
+
},
|
|
148
|
+
"tool_selection": {
|
|
149
|
+
**describe_stage("tool_selection"),
|
|
150
|
+
"data": knowledge_routing,
|
|
151
|
+
},
|
|
152
|
+
"execution": {
|
|
153
|
+
**describe_stage("execution"),
|
|
154
|
+
"data": {
|
|
155
|
+
"reasoning_mode_used": getattr(result, "reasoning_mode_used", None),
|
|
156
|
+
"specialist_results": getattr(result, "specialist_results", None),
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
"verification": {
|
|
160
|
+
**describe_stage("verification"),
|
|
161
|
+
"data": {
|
|
162
|
+
"verification_passed": getattr(result, "verification_passed", None),
|
|
163
|
+
"verification_issues": getattr(result, "verification_issues", None),
|
|
164
|
+
"confidence_score": getattr(result, "confidence_score", None),
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
"retry": {
|
|
168
|
+
**describe_stage("retry"),
|
|
169
|
+
"data": {"retry_count": getattr(result, "retry_count", None)},
|
|
170
|
+
},
|
|
171
|
+
"reporting": {
|
|
172
|
+
**describe_stage("reporting"),
|
|
173
|
+
"data": {
|
|
174
|
+
"reflection_review": getattr(result, "reflection_review", None),
|
|
175
|
+
"uncertainty_band": getattr(result, "uncertainty_band", None),
|
|
176
|
+
"uncertainty_score": getattr(result, "uncertainty_score", None),
|
|
177
|
+
"uncertainty_reasons": getattr(result, "uncertainty_reasons", None),
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
}
|