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.
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: synathic
3
+ Version: 0.1.0
4
+ Requires-Python: >=3.9
5
+ Requires-Dist: httpx>=0.24.0
6
+ Dynamic: requires-dist
7
+ Dynamic: requires-python
File without changes
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,9 @@
1
+ from setuptools import setup, find_packages
2
+
3
+ setup(
4
+ name="synathic",
5
+ version="0.1.0",
6
+ packages=find_packages(),
7
+ install_requires=["httpx>=0.24.0"],
8
+ python_requires=">=3.9",
9
+ )
@@ -0,0 +1,4 @@
1
+ from .monitor import monitor
2
+ from .decorators import expect
3
+
4
+ __all__ = ["monitor", "expect"]
@@ -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,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: synathic
3
+ Version: 0.1.0
4
+ Requires-Python: >=3.9
5
+ Requires-Dist: httpx>=0.24.0
6
+ Dynamic: requires-dist
7
+ Dynamic: requires-python
@@ -0,0 +1,10 @@
1
+ README.md
2
+ setup.py
3
+ synathic/__init__.py
4
+ synathic/decorators.py
5
+ synathic/monitor.py
6
+ synathic.egg-info/PKG-INFO
7
+ synathic.egg-info/SOURCES.txt
8
+ synathic.egg-info/dependency_links.txt
9
+ synathic.egg-info/requires.txt
10
+ synathic.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ httpx>=0.24.0
@@ -0,0 +1 @@
1
+ synathic