synathic 0.1.0__tar.gz
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.
- synathic-0.1.0/PKG-INFO +7 -0
- synathic-0.1.0/README.md +0 -0
- synathic-0.1.0/setup.cfg +4 -0
- synathic-0.1.0/setup.py +9 -0
- synathic-0.1.0/synathic/__init__.py +4 -0
- synathic-0.1.0/synathic/decorators.py +110 -0
- synathic-0.1.0/synathic/monitor.py +110 -0
- synathic-0.1.0/synathic.egg-info/PKG-INFO +7 -0
- synathic-0.1.0/synathic.egg-info/SOURCES.txt +10 -0
- synathic-0.1.0/synathic.egg-info/dependency_links.txt +1 -0
- synathic-0.1.0/synathic.egg-info/requires.txt +1 -0
- synathic-0.1.0/synathic.egg-info/top_level.txt +1 -0
synathic-0.1.0/PKG-INFO
ADDED
synathic-0.1.0/README.md
ADDED
|
File without changes
|
synathic-0.1.0/setup.cfg
ADDED
synathic-0.1.0/setup.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import functools
|
|
2
|
+
import asyncio
|
|
3
|
+
import uuid
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from .monitor import monitor
|
|
6
|
+
|
|
7
|
+
def expect(
|
|
8
|
+
postcondition: str,
|
|
9
|
+
table: str = None,
|
|
10
|
+
match_field: str = None,
|
|
11
|
+
expected_field: str = None,
|
|
12
|
+
expected_value: str = None,
|
|
13
|
+
sync: bool = False,
|
|
14
|
+
timestamp_column: str | None = "updated_at",
|
|
15
|
+
):
|
|
16
|
+
"""Decorador para verificar una postcondicion tras ejecutar una herramienta.
|
|
17
|
+
|
|
18
|
+
Tipos soportados por el backend:
|
|
19
|
+
- "row_exists": pass si existe una fila en `table` donde
|
|
20
|
+
`match_field = <valor de match_field en la llamada>`.
|
|
21
|
+
- "row_not_exists": pass si NO existe tal fila.
|
|
22
|
+
- "field_equals": pass si ademas el valor real de `expected_field`
|
|
23
|
+
en esa fila es exactamente igual a `expected_value`.
|
|
24
|
+
"""
|
|
25
|
+
def decorator(func):
|
|
26
|
+
@functools.wraps(func)
|
|
27
|
+
async def async_wrapper(*args, **kwargs):
|
|
28
|
+
execution_id = str(uuid.uuid4())
|
|
29
|
+
execution_start = datetime.utcnow()
|
|
30
|
+
|
|
31
|
+
# Para flujos síncronos evitamos enviar el `tool_call`.
|
|
32
|
+
if not sync:
|
|
33
|
+
# Fire-and-forget the tool_call to avoid adding network/DB latency
|
|
34
|
+
try:
|
|
35
|
+
import asyncio as _asyncio
|
|
36
|
+
_asyncio.create_task(monitor.send_event(
|
|
37
|
+
execution_id=execution_id,
|
|
38
|
+
agent_name=func.__name__,
|
|
39
|
+
event_type="tool_call",
|
|
40
|
+
payload={"args": str(args), "kwargs": str(kwargs)}
|
|
41
|
+
))
|
|
42
|
+
except Exception:
|
|
43
|
+
# best-effort: if task creation fails, fall back to awaiting
|
|
44
|
+
await monitor.send_event(
|
|
45
|
+
execution_id=execution_id,
|
|
46
|
+
agent_name=func.__name__,
|
|
47
|
+
event_type="tool_call",
|
|
48
|
+
payload={"args": str(args), "kwargs": str(kwargs)}
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
result = await func(*args, **kwargs)
|
|
52
|
+
|
|
53
|
+
# Valor usado para localizar la fila: normalmente el argumento
|
|
54
|
+
# cuyo nombre coincide con match_field.
|
|
55
|
+
value = kwargs.get(match_field) or (args[0] if args else None)
|
|
56
|
+
|
|
57
|
+
postcondition_payload = {
|
|
58
|
+
"type": postcondition,
|
|
59
|
+
"table": table,
|
|
60
|
+
"field": match_field,
|
|
61
|
+
"value": value,
|
|
62
|
+
"execution_start": execution_start.isoformat(),
|
|
63
|
+
"timestamp_column": timestamp_column,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
# Para field_equals, incluir el campo y el valor esperado extra.
|
|
67
|
+
if postcondition == "field_equals":
|
|
68
|
+
postcondition_payload["expected_field"] = expected_field or match_field
|
|
69
|
+
postcondition_payload["expected_value"] = expected_value
|
|
70
|
+
|
|
71
|
+
# Si sync=True, usar el método que espera la verificación síncrona
|
|
72
|
+
if sync:
|
|
73
|
+
verification = await monitor.send_event_and_wait_verification(
|
|
74
|
+
execution_id=execution_id,
|
|
75
|
+
agent_name=func.__name__,
|
|
76
|
+
event_type="tool_result",
|
|
77
|
+
payload={
|
|
78
|
+
"result": str(result),
|
|
79
|
+
"postcondition": postcondition_payload,
|
|
80
|
+
}
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Devolver el resultado junto con la verificación para que el
|
|
84
|
+
# código llamante pueda tomar decisiones bloqueantes.
|
|
85
|
+
if isinstance(result, dict):
|
|
86
|
+
result["verification"] = verification
|
|
87
|
+
return result
|
|
88
|
+
return {"result": result, "verification": verification}
|
|
89
|
+
else:
|
|
90
|
+
await monitor.send_event(
|
|
91
|
+
execution_id=execution_id,
|
|
92
|
+
agent_name=func.__name__,
|
|
93
|
+
event_type="tool_result",
|
|
94
|
+
payload={
|
|
95
|
+
"result": str(result),
|
|
96
|
+
"postcondition": postcondition_payload,
|
|
97
|
+
}
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
return result
|
|
101
|
+
|
|
102
|
+
@functools.wraps(func)
|
|
103
|
+
def sync_wrapper(*args, **kwargs):
|
|
104
|
+
return func(*args, **kwargs)
|
|
105
|
+
|
|
106
|
+
if asyncio.iscoroutinefunction(func):
|
|
107
|
+
return async_wrapper
|
|
108
|
+
return sync_wrapper
|
|
109
|
+
|
|
110
|
+
return decorator
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
import time
|
|
3
|
+
from time import perf_counter
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
class SynathicMonitor:
|
|
7
|
+
def __init__(self):
|
|
8
|
+
self.api_key = None
|
|
9
|
+
self.endpoint = "http://localhost:8000/api/events"
|
|
10
|
+
self._client = None
|
|
11
|
+
|
|
12
|
+
def start(self, api_key: Optional[str] = None, endpoint: Optional[str] = None):
|
|
13
|
+
self.api_key = api_key
|
|
14
|
+
if endpoint:
|
|
15
|
+
self.endpoint = endpoint
|
|
16
|
+
# Crear el client asíncrono que se reutiliza en llamadas posteriores
|
|
17
|
+
self._client = httpx.AsyncClient(timeout=5.0)
|
|
18
|
+
|
|
19
|
+
# Warmup no bloqueante: lanzar ping a /health en background (no espera)
|
|
20
|
+
try:
|
|
21
|
+
import threading
|
|
22
|
+
def _bg_ping(url: str):
|
|
23
|
+
try:
|
|
24
|
+
with httpx.Client(timeout=1.0) as c:
|
|
25
|
+
c.get(url)
|
|
26
|
+
except Exception:
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
warm_url = self.endpoint.replace('/events', '/health')
|
|
30
|
+
t = threading.Thread(target=_bg_ping, args=(warm_url,), daemon=True)
|
|
31
|
+
t.start()
|
|
32
|
+
except Exception:
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
print("Synathic monitor started")
|
|
36
|
+
|
|
37
|
+
async def send_event(self, execution_id: str, agent_name: str, event_type: str, payload: dict):
|
|
38
|
+
if not self._client:
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
t0 = time.time()
|
|
43
|
+
print(f"[monitor] send_event start {t0} endpoint={self.endpoint} event_type={event_type}")
|
|
44
|
+
resp = await self._client.post(
|
|
45
|
+
self.endpoint,
|
|
46
|
+
json={
|
|
47
|
+
"execution_id": execution_id,
|
|
48
|
+
"agent_name": agent_name,
|
|
49
|
+
"event_type": event_type,
|
|
50
|
+
"payload": payload,
|
|
51
|
+
"timestamp": time.time()
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
t1 = time.time()
|
|
55
|
+
print(f"[monitor] send_event done elapsed_ms={(t1-t0)*1000:.1f} event_type={event_type}")
|
|
56
|
+
except Exception:
|
|
57
|
+
# Fire-and-forget: si el backend está caído, el agente sigue corriendo
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
async def send_event_and_wait_verification(self, execution_id: str, agent_name: str, event_type: str, payload: dict):
|
|
61
|
+
"""Enviar el evento y, si contiene una postcondition, pedir verificación síncrona al backend.
|
|
62
|
+
Devuelve el JSON de la respuesta del endpoint /api/events-verify cuando aplique.
|
|
63
|
+
"""
|
|
64
|
+
# Enviar un único request que inserta el evento y ejecuta la verificación
|
|
65
|
+
if not self._client:
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
timings = {}
|
|
70
|
+
t_wrapper_start = perf_counter()
|
|
71
|
+
|
|
72
|
+
client_created = False
|
|
73
|
+
if getattr(self, "_client", None) is None:
|
|
74
|
+
t_client_create_start = perf_counter()
|
|
75
|
+
self._client = httpx.AsyncClient(timeout=5.0)
|
|
76
|
+
t_client_create_end = perf_counter()
|
|
77
|
+
timings["client_create_ms"] = (t_client_create_end - t_client_create_start) * 1000
|
|
78
|
+
client_created = True
|
|
79
|
+
|
|
80
|
+
# Preparar payload y endpoint nuevo `/events-verify`
|
|
81
|
+
verify_url = self.endpoint.replace('/events', '/events-verify')
|
|
82
|
+
req_body = {
|
|
83
|
+
"execution_id": execution_id,
|
|
84
|
+
"agent_name": agent_name,
|
|
85
|
+
"event_type": event_type,
|
|
86
|
+
"payload": payload,
|
|
87
|
+
"timestamp": time.time()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
# Momento justo antes de iniciar la llamada HTTP
|
|
91
|
+
t_request_start = perf_counter()
|
|
92
|
+
resp = await self._client.post(verify_url, json=req_body)
|
|
93
|
+
t_response_received = perf_counter()
|
|
94
|
+
|
|
95
|
+
timings["func_to_request_start_ms"] = (t_request_start - t_wrapper_start) * 1000
|
|
96
|
+
timings["request_roundtrip_ms"] = (t_response_received - t_request_start) * 1000
|
|
97
|
+
|
|
98
|
+
# Intentar parsear la respuesta que incluye timings del servidor
|
|
99
|
+
try:
|
|
100
|
+
j = resp.json()
|
|
101
|
+
except Exception:
|
|
102
|
+
j = None
|
|
103
|
+
|
|
104
|
+
# Incluir measurementes del lado servidor si están presentes
|
|
105
|
+
result = {"client_timings": timings, "server_response": j}
|
|
106
|
+
return result
|
|
107
|
+
except Exception:
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
monitor = SynathicMonitor()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
httpx>=0.24.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
synathic
|