lucidicai 1.3.5__py3-none-any.whl → 2.0.2__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.
lucidicai/client.py CHANGED
@@ -1,22 +1,28 @@
1
1
  import os
2
2
  import time
3
+ import threading
3
4
  from datetime import datetime, timezone
4
- from typing import Optional, Tuple
5
+ from typing import Optional, Tuple, Dict, Any
5
6
 
6
7
  import requests
7
8
  import logging
9
+ import json
8
10
  from requests.adapters import HTTPAdapter, Retry
9
11
  from urllib3.util import Retry
10
12
 
11
13
 
12
14
  from .errors import APIKeyVerificationError, InvalidOperationError, LucidicNotInitializedError
13
- from .telemetry.base_provider import BaseProvider
14
15
  from .session import Session
15
16
  from .singleton import singleton, clear_singletons
16
17
  from .lru import LRUCache
18
+ from .event import Event
19
+ from .event_queue import EventQueue
20
+ import uuid
17
21
 
18
22
  NETWORK_RETRIES = 3
19
23
 
24
+ logger = logging.getLogger("Lucidic")
25
+
20
26
 
21
27
  @singleton
22
28
  class Client:
@@ -25,16 +31,16 @@ class Client:
25
31
  api_key: str,
26
32
  agent_id: str,
27
33
  ):
28
- self.base_url = "https://analytics.lucidic.ai/api" if not (os.getenv("LUCIDIC_DEBUG", 'False') == 'True') else "http://localhost:8000/api"
34
+ self.base_url = "https://backend.lucidic.ai/api" if not (os.getenv("LUCIDIC_DEBUG", 'False') == 'True') else "http://localhost:8000/api"
29
35
  self.initialized = False
30
36
  self.session = None
31
37
  self.previous_sessions = LRUCache(500) # For LRU cache of previously initialized sessions
32
38
  self.custom_session_id_translations = LRUCache(500) # For translations of custom session IDs to real session IDs
33
- self.providers = []
34
39
  self.api_key = api_key
35
40
  self.agent_id = agent_id
36
41
  self.masking_function = None
37
42
  self.auto_end = False # Default to False until explicitly set during init
43
+ self._shutdown = False # Flag to prevent requests after shutdown
38
44
  self.request_session = requests.Session()
39
45
  retry_cfg = Retry(
40
46
  total=3, # 3 attempts in total
@@ -46,6 +52,19 @@ class Client:
46
52
  self.request_session.mount("https://", adapter)
47
53
  self.set_api_key(api_key)
48
54
  self.prompts = dict()
55
+ # Initialize event queue (non-blocking event delivery)
56
+ self._event_queue = EventQueue(self)
57
+
58
+ # Track telemetry state to prevent re-initialization
59
+ # These are process-wide singletons for telemetry
60
+ self._telemetry_lock = threading.Lock() # Prevent race conditions
61
+ self._tracer_provider = None
62
+ self._instrumentors = {} # Dict to track which providers are instrumented
63
+ self._telemetry_initialized = False
64
+
65
+ # Track active sessions to prevent premature EventQueue shutdown
66
+ self._active_sessions_lock = threading.Lock()
67
+ self._active_sessions = set() # Set of active session IDs
49
68
 
50
69
  def set_api_key(self, api_key: str):
51
70
  self.api_key = api_key
@@ -56,40 +75,30 @@ class Client:
56
75
  raise APIKeyVerificationError("Invalid API Key")
57
76
 
58
77
  def clear(self):
59
- self.undo_overrides()
78
+ # Clean up singleton state
60
79
  clear_singletons()
61
80
  self.initialized = False
62
81
  self.session = None
63
- self.providers = []
64
82
  del self
65
83
 
66
84
  def verify_api_key(self, base_url: str, api_key: str) -> Tuple[str, str]:
67
85
  data = self.make_request('verifyapikey', 'GET', {}) # TODO: Verify against agent ID provided
68
86
  return data["project"], data["project_id"]
69
87
 
70
- def set_provider(self, provider: BaseProvider) -> None:
71
- """Set the LLM provider to track"""
72
- # Avoid duplicate provider registration of the same class
73
- for existing in self.providers:
74
- if type(existing) is type(provider):
75
- return
76
- self.providers.append(provider)
77
- provider.override()
78
-
79
- def undo_overrides(self):
80
- for provider in self.providers:
81
- provider.undo_override()
88
+ def set_provider(self, provider) -> None:
89
+ """Deprecated: manual provider overrides removed (no-op)."""
90
+ return
82
91
 
83
92
  def init_session(
84
93
  self,
85
94
  session_name: str,
86
- mass_sim_id: Optional[str] = None,
87
95
  task: Optional[str] = None,
88
96
  rubrics: Optional[list] = None,
89
97
  tags: Optional[list] = None,
90
98
  production_monitoring: Optional[bool] = False,
91
99
  session_id: Optional[str] = None,
92
100
  experiment_id: Optional[str] = None,
101
+ dataset_item_id: Optional[str] = None,
93
102
  ) -> None:
94
103
  if session_id:
95
104
  # Check if it's a known session ID, maybe custom and maybe real
@@ -111,11 +120,12 @@ class Client:
111
120
  "agent_id": self.agent_id,
112
121
  "session_name": session_name,
113
122
  "task": task,
114
- "mass_sim_id": mass_sim_id,
115
123
  "experiment_id": experiment_id,
116
124
  "rubrics": rubrics,
117
125
  "tags": tags,
118
- "session_id": session_id
126
+ "session_id": session_id,
127
+ "dataset_item_id": dataset_item_id,
128
+ "production_monitoring": production_monitoring
119
129
  }
120
130
  data = self.make_request('initsession', 'POST', request_data)
121
131
  real_session_id = data["session_id"]
@@ -129,47 +139,47 @@ class Client:
129
139
  agent_id=self.agent_id,
130
140
  session_id=real_session_id,
131
141
  session_name=session_name,
132
- mass_sim_id=mass_sim_id,
133
142
  experiment_id=experiment_id,
134
143
  task=task,
135
144
  rubrics=rubrics,
136
145
  tags=tags,
137
146
  )
147
+
148
+ # Track this as an active session
149
+ with self._active_sessions_lock:
150
+ self._active_sessions.add(real_session_id)
151
+ if logger.isEnabledFor(logging.DEBUG):
152
+ logger.debug(f"[Client] Added active session {real_session_id[:8]}..., total: {len(self._active_sessions)}")
153
+
138
154
  self.initialized = True
139
155
  return self.session.session_id
140
156
 
157
+ def mark_session_inactive(self, session_id: str) -> None:
158
+ """Mark a session as inactive. Used when ending a session."""
159
+ with self._active_sessions_lock:
160
+ if session_id in self._active_sessions:
161
+ self._active_sessions.discard(session_id)
162
+ if logger.isEnabledFor(logging.DEBUG):
163
+ logger.debug(f"[Client] Removed active session {session_id[:8]}..., remaining: {len(self._active_sessions)}")
164
+
165
+ def has_active_sessions(self) -> bool:
166
+ """Check if there are any active sessions."""
167
+ with self._active_sessions_lock:
168
+ return len(self._active_sessions) > 0
169
+
141
170
  def create_event_for_session(self, session_id: str, **kwargs) -> str:
142
- """Create an event for a specific session id without mutating global session.
171
+ """Create an event for a specific session id (new typed model).
143
172
 
144
- This avoids cross-thread races by not switching the active session on
145
- the singleton client. It constructs an ephemeral Session facade to send
146
- requests under the provided session id.
173
+ This avoids mutating the global session and directly uses the new
174
+ event API. Prefer passing typed fields and a 'type' argument.
147
175
  """
148
- temp_session = Session(agent_id=self.agent_id, session_id=session_id)
149
- return temp_session.create_event(**kwargs)
150
-
151
- def continue_session(self, session_id: str):
152
- if session_id in self.custom_session_id_translations:
153
- session_id = self.custom_session_id_translations[session_id]
154
- if self.session and self.session.session_id == session_id:
155
- return self.session.session_id
156
- if self.session:
157
- self.previous_sessions[self.session.session_id] = self.session
158
- data = self.make_request('continuesession', 'POST', {"session_id": session_id})
159
- real_session_id = data["session_id"]
160
- if session_id != real_session_id:
161
- self.custom_session_id_translations[session_id] = real_session_id
162
- self.session = Session(
163
- agent_id=self.agent_id,
164
- session_id=real_session_id
165
- )
166
- import logging as _logging
167
- _logging.getLogger('Lucidic').info(f"Session {data.get('session_name', '')} continuing...")
168
- return self.session.session_id
176
+ kwargs = dict(kwargs)
177
+ kwargs['session_id'] = session_id
178
+ return self.create_event(**kwargs)
169
179
 
170
- def init_mass_sim(self, **kwargs) -> str:
180
+ def create_experiment(self, **kwargs) -> str:
171
181
  kwargs['agent_id'] = self.agent_id
172
- return self.make_request('initmasssim', 'POST', kwargs)['mass_sim_id']
182
+ return self.make_request('createexperiment', 'POST', kwargs)['experiment_id']
173
183
 
174
184
  def get_prompt(self, prompt_name, cache_ttl, label) -> str:
175
185
  current_time = time.time()
@@ -194,6 +204,13 @@ class Client:
194
204
  return prompt
195
205
 
196
206
  def make_request(self, endpoint, method, data):
207
+ # Check if client is shutting down
208
+ if self._shutdown:
209
+ logger.warning(f"[HTTP] Attempted request after shutdown: {endpoint}")
210
+ return {}
211
+
212
+ data = {k: v for k, v in data.items() if v is not None}
213
+
197
214
  http_methods = {
198
215
  "GET": lambda data: self.request_session.get(f"{self.base_url}/{endpoint}", params=data),
199
216
  "POST": lambda data: self.request_session.post(f"{self.base_url}/{endpoint}", json=data),
@@ -201,7 +218,14 @@ class Client:
201
218
  "DELETE": lambda data: self.request_session.delete(f"{self.base_url}/{endpoint}", params=data),
202
219
  } # TODO: make into enum
203
220
  data['current_time'] = datetime.now().astimezone(timezone.utc).isoformat()
221
+ # Debug: print final payload about to be sent
222
+ try:
223
+ dbg = json.dumps({"endpoint": endpoint, "method": method, "body": data}, ensure_ascii=False)
224
+ logger.debug(f"[HTTP] Sending request: {dbg}")
225
+ except Exception:
226
+ logger.debug(f"[HTTP] Sending request to {endpoint} {method}")
204
227
  func = http_methods[method]
228
+ response = None
205
229
  for _ in range(NETWORK_RETRIES):
206
230
  try:
207
231
  response = func(data)
@@ -222,6 +246,150 @@ class Client:
222
246
  raise InvalidOperationError(f"Request to Lucidic AI Backend failed: {e.response.text}")
223
247
  return response.json()
224
248
 
249
+ # ==== New Typed Event Model Helpers ====
250
+ def _build_payload(self, type: str, kwargs: Dict[str, Any]) -> Dict[str, Any]:
251
+ """Build type-specific payload and place unrecognized keys in misc."""
252
+ # Remove non-payload top-level fields from kwargs copy
253
+ non_payload_fields = [
254
+ 'parent_event_id', 'tags', 'metadata', 'occurred_at', 'duration', 'session_id',
255
+ 'event_id'
256
+ ]
257
+ for field in non_payload_fields:
258
+ if field in kwargs:
259
+ kwargs.pop(field, None)
260
+
261
+ if type == "llm_generation":
262
+ return self._build_llm_payload(kwargs)
263
+ elif type == "function_call":
264
+ return self._build_function_payload(kwargs)
265
+ elif type == "error_traceback":
266
+ return self._build_error_payload(kwargs)
267
+ else:
268
+ return self._build_generic_payload(kwargs)
269
+
270
+ def _build_llm_payload(self, kwargs: Dict[str, Any]) -> Dict[str, Any]:
271
+ payload: Dict[str, Any] = {
272
+ "request": {},
273
+ "response": {},
274
+ "usage": {},
275
+ "status": "ok",
276
+ "misc": {}
277
+ }
278
+ # Request fields
279
+ for field in ["provider", "model", "messages", "params"]:
280
+ if field in kwargs:
281
+ payload["request"][field] = kwargs.pop(field)
282
+ # Response fields
283
+ for field in ["output", "messages", "tool_calls", "thinking", "raw"]:
284
+ if field in kwargs:
285
+ payload["response"][field] = kwargs.pop(field)
286
+ # Usage fields
287
+ for field in ["input_tokens", "output_tokens", "cache", "cost"]:
288
+ if field in kwargs:
289
+ payload["usage"][field] = kwargs.pop(field)
290
+ # Status / error
291
+ if 'status' in kwargs:
292
+ payload['status'] = kwargs.pop('status')
293
+ if 'error' in kwargs:
294
+ payload['error'] = kwargs.pop('error')
295
+ payload["misc"] = kwargs
296
+ return payload
297
+
298
+ def _build_function_payload(self, kwargs: Dict[str, Any]) -> Dict[str, Any]:
299
+ payload: Dict[str, Any] = {
300
+ "function_name": kwargs.pop("function_name", "unknown"),
301
+ "arguments": kwargs.pop("arguments", {}),
302
+ "return_value": kwargs.pop("return_value", None),
303
+ "misc": kwargs
304
+ }
305
+ return payload
306
+
307
+ def _build_error_payload(self, kwargs: Dict[str, Any]) -> Dict[str, Any]:
308
+ payload: Dict[str, Any] = {
309
+ "error": kwargs.pop("error", ""),
310
+ "traceback": kwargs.pop("traceback", ""),
311
+ "misc": kwargs
312
+ }
313
+ return payload
314
+
315
+ def _build_generic_payload(self, kwargs: Dict[str, Any]) -> Dict[str, Any]:
316
+ payload: Dict[str, Any] = {
317
+ "details": kwargs.pop("details", kwargs.pop("description", "")),
318
+ "misc": kwargs
319
+ }
320
+ return payload
321
+
322
+ def create_event(self, type: str = "generic", **kwargs) -> str:
323
+ """Create a typed event (non-blocking) and return client-side UUID.
324
+
325
+ - Generates and returns client_event_id immediately
326
+ - Enqueues the full event for background processing via EventQueue
327
+ - Supports parent nesting via client-side parent_event_id
328
+ - Handles client-side blob thresholding in the queue
329
+ """
330
+ # Resolve session_id: explicit -> context -> current session
331
+ session_id = kwargs.pop('session_id', None)
332
+ if not session_id:
333
+ try:
334
+ from .context import current_session_id
335
+ session_id = current_session_id.get(None)
336
+ except Exception:
337
+ session_id = None
338
+ if not session_id and self.session:
339
+ session_id = self.session.session_id
340
+ if not session_id:
341
+ raise InvalidOperationError("No active session for event creation")
342
+
343
+ # Parent event id from kwargs or parent context (client-side)
344
+ parent_event_id = kwargs.get('parent_event_id')
345
+ if not parent_event_id:
346
+ try:
347
+ from .context import current_parent_event_id
348
+ parent_event_id = current_parent_event_id.get(None)
349
+ except Exception:
350
+ parent_event_id = None
351
+
352
+ # Build payload (typed)
353
+ payload = self._build_payload(type, dict(kwargs))
354
+
355
+ # Occurred-at
356
+ from datetime import datetime as _dt
357
+ _occ = kwargs.get("occurred_at")
358
+ if isinstance(_occ, str):
359
+ occurred_at_str = _occ
360
+ elif isinstance(_occ, _dt):
361
+ if _occ.tzinfo is None:
362
+ local_tz = _dt.now().astimezone().tzinfo
363
+ occurred_at_str = _occ.replace(tzinfo=local_tz).isoformat()
364
+ else:
365
+ occurred_at_str = _occ.isoformat()
366
+ else:
367
+ occurred_at_str = _dt.now().astimezone().isoformat()
368
+
369
+ # Client-side UUIDs
370
+ client_event_id = kwargs.get('event_id') or str(uuid.uuid4())
371
+
372
+ # Build request body with client ids
373
+ event_request: Dict[str, Any] = {
374
+ "session_id": session_id,
375
+ "client_event_id": client_event_id,
376
+ "client_parent_event_id": parent_event_id,
377
+ "type": type,
378
+ "occurred_at": occurred_at_str,
379
+ "duration": kwargs.get("duration"),
380
+ "tags": kwargs.get("tags", []),
381
+ "metadata": kwargs.get("metadata", {}),
382
+ "payload": payload,
383
+ }
384
+
385
+ # Queue for background processing and return immediately
386
+ self._event_queue.queue_event(event_request)
387
+ return client_event_id
388
+
389
+ def update_event(self, event_id: str, type: Optional[str] = None, **kwargs) -> str:
390
+ """Deprecated: events are immutable in the new model."""
391
+ raise InvalidOperationError("update_event is no longer supported. Events are immutable.")
392
+
225
393
  def mask(self, data):
226
394
  if not self.masking_function:
227
395
  return data
@@ -232,4 +400,114 @@ class Client:
232
400
  except Exception as e:
233
401
  logger = logging.getLogger('Lucidic')
234
402
  logger.error(f"Error in custom masking function: {repr(e)}")
235
- return "<Error in custom masking function, this is a fully-masked placeholder>"
403
+ return "<Error in custom masking function, this is a fully-masked placeholder>"
404
+
405
+ def initialize_telemetry(self, providers: list) -> bool:
406
+ """
407
+ Initialize telemetry with the given providers.
408
+ This is a true singleton - only the first call creates the TracerProvider.
409
+ Subsequent calls only add new instrumentors if needed.
410
+
411
+ Args:
412
+ providers: List of provider names to instrument
413
+
414
+ Returns:
415
+ True if telemetry was successfully initialized or already initialized
416
+ """
417
+ with self._telemetry_lock:
418
+ try:
419
+ # Create TracerProvider only once per process
420
+ if self._tracer_provider is None:
421
+ logger.debug("[Telemetry] Creating TracerProvider (first initialization)")
422
+
423
+ from opentelemetry import trace
424
+ from opentelemetry.sdk.trace import TracerProvider
425
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor
426
+ from opentelemetry.sdk.resources import Resource
427
+
428
+ resource = Resource.create({
429
+ "service.name": "lucidic-ai",
430
+ "service.version": "1.0.0",
431
+ "lucidic.agent_id": self.agent_id,
432
+ })
433
+
434
+ # Create provider with shutdown_on_exit=False for our control
435
+ self._tracer_provider = TracerProvider(resource=resource, shutdown_on_exit=False)
436
+
437
+ # Add context capture processor FIRST
438
+ from .telemetry.context_capture_processor import ContextCaptureProcessor
439
+ context_processor = ContextCaptureProcessor()
440
+ self._tracer_provider.add_span_processor(context_processor)
441
+
442
+ # Add exporter processor for sending spans to Lucidic
443
+ from .telemetry.lucidic_exporter import LucidicSpanExporter
444
+ exporter = LucidicSpanExporter()
445
+ # Configure for faster export: 100ms interval instead of default 5000ms
446
+ # This matches the TypeScript SDK's flush interval pattern
447
+ export_processor = BatchSpanProcessor(
448
+ exporter,
449
+ schedule_delay_millis=100, # Export every 100ms
450
+ max_export_batch_size=512, # Reasonable batch size
451
+ max_queue_size=2048 # Larger queue for burst handling
452
+ )
453
+ self._tracer_provider.add_span_processor(export_processor)
454
+
455
+ # Set as global provider (only happens once)
456
+ try:
457
+ trace.set_tracer_provider(self._tracer_provider)
458
+ logger.debug("[Telemetry] Set global TracerProvider")
459
+ except Exception as e:
460
+ # This is OK - might already be set
461
+ logger.debug(f"[Telemetry] Global provider already set: {e}")
462
+
463
+ self._telemetry_initialized = True
464
+
465
+ # Now instrument the requested providers (can happen multiple times)
466
+ if providers:
467
+ from .telemetry.telemetry_init import instrument_providers
468
+ new_instrumentors = instrument_providers(providers, self._tracer_provider, self._instrumentors)
469
+ # Update our tracking dict
470
+ self._instrumentors.update(new_instrumentors)
471
+ logger.debug(f"[Telemetry] Instrumented providers: {list(new_instrumentors.keys())}")
472
+
473
+ return True
474
+
475
+ except Exception as e:
476
+ logger.error(f"[Telemetry] Failed to initialize: {e}")
477
+ return False
478
+
479
+ def flush_telemetry(self, timeout_seconds: float = 2.0) -> bool:
480
+ """
481
+ Flush all OpenTelemetry spans to ensure they're exported.
482
+
483
+ This method blocks until all buffered spans in the TracerProvider
484
+ are exported or the timeout is reached. Critical for ensuring
485
+ LLM generation events are not lost during shutdown.
486
+
487
+ Handles both active and shutdown TracerProviders gracefully.
488
+
489
+ Args:
490
+ timeout_seconds: Maximum time to wait for flush completion
491
+
492
+ Returns:
493
+ True if flush succeeded, False if timeout occurred
494
+ """
495
+ try:
496
+ if self._tracer_provider:
497
+ # Check if provider is already shutdown
498
+ if hasattr(self._tracer_provider, '_shutdown') and self._tracer_provider._shutdown:
499
+ logger.debug("[Telemetry] TracerProvider already shutdown, skipping flush")
500
+ return True
501
+
502
+ # Convert seconds to milliseconds for OpenTelemetry
503
+ timeout_millis = int(timeout_seconds * 1000)
504
+ success = self._tracer_provider.force_flush(timeout_millis)
505
+ if success:
506
+ logger.debug(f"[Telemetry] Successfully flushed spans (timeout={timeout_seconds}s)")
507
+ else:
508
+ logger.warning(f"[Telemetry] Flush timed out after {timeout_seconds}s")
509
+ return success
510
+ return True # No provider = nothing to flush = success
511
+ except Exception as e:
512
+ logger.error(f"[Telemetry] Failed to flush spans: {e}")
513
+ return False
lucidicai/constants.py CHANGED
@@ -1,33 +1,6 @@
1
- """Constants used throughout the Lucidic SDK"""
1
+ """Constants used throughout the Lucidic SDK (steps removed)."""
2
2
 
3
- # Step states
4
- class StepState:
5
- """Constants for step states"""
6
- RUNNING = "Running: {agent_name}"
7
- FINISHED = "Finished: {agent_name}"
8
- HANDOFF = "Handoff: {agent_name}"
9
- TRANSFERRED = "Transferred to {agent_name}"
10
- ERROR = "Error in {agent_name}"
11
-
12
- # Step actions
13
- class StepAction:
14
- """Constants for step actions"""
15
- EXECUTE = "Execute {agent_name}"
16
- TRANSFER = "Transfer from {from_agent}"
17
- HANDOFF = "Handoff from {from_agent}"
18
- DELIVERED = "{agent_name} finished processing"
19
- FAILED = "Agent execution failed"
20
-
21
- # Step goals
22
- class StepGoal:
23
- """Constants for step goals"""
24
- PROCESS_REQUEST = "Process request"
25
- CONTINUE_PROCESSING = "Continue processing"
26
- CONTINUE_WITH = "Continue with {agent_name}"
27
- PROCESSING_FINISHED = "Processing finished"
28
- ERROR = "Error: {error}"
29
-
30
- # Event descriptions
3
+ # Event descriptions (generic)
31
4
  class EventDescription:
32
5
  """Constants for event descriptions"""
33
6
  TOOL_CALL = "Tool call: {tool_name}"
@@ -48,12 +21,9 @@ class LogMessage:
48
21
  """Constants for log messages"""
49
22
  SESSION_INIT = "Session initialized successfully"
50
23
  SESSION_CONTINUE = "Session {session_id} continuing..."
51
- INSTRUMENTATION_ENABLED = "OpenAI Agents SDK instrumentation enabled"
52
- INSTRUMENTATION_DISABLED = "OpenAI Agents SDK instrumentation disabled"
53
- NO_ACTIVE_SESSION = "No active session for agent tracking"
24
+ INSTRUMENTATION_ENABLED = "Instrumentation enabled"
25
+ INSTRUMENTATION_DISABLED = "Instrumentation disabled"
26
+ NO_ACTIVE_SESSION = "No active session for tracking"
54
27
  HANDLER_INTERCEPTED = "Intercepted {method} call"
55
- AGENT_RUNNING = "Running agent '{agent_name}' with prompt: {prompt}"
56
- AGENT_COMPLETED = "Agent completed successfully"
57
- STEP_CREATED = "Created step: {step_id}"
58
- STEP_ENDED = "Step ended: {step_id}"
59
- HANDOFF_DETECTED = "Handoff chain detected: {chain}"
28
+ AGENT_RUNNING = "Running agent '{agent_name}'"
29
+ AGENT_COMPLETED = "Agent completed successfully"
lucidicai/context.py CHANGED
@@ -19,6 +19,12 @@ current_session_id: contextvars.ContextVar[Optional[str]] = contextvars.ContextV
19
19
  )
20
20
 
21
21
 
22
+ # NEW: Context variable for parent event nesting
23
+ current_parent_event_id: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar(
24
+ "lucidic.parent_event_id", default=None
25
+ )
26
+
27
+
22
28
  def set_active_session(session_id: Optional[str]) -> None:
23
29
  """Bind the given session id to the current execution context."""
24
30
  current_session_id.set(session_id)
@@ -49,6 +55,25 @@ async def bind_session_async(session_id: str) -> AsyncIterator[None]:
49
55
  current_session_id.reset(token)
50
56
 
51
57
 
58
+ # NEW: Parent event context managers
59
+ @contextmanager
60
+ def event_context(event_id: str) -> Iterator[None]:
61
+ token = current_parent_event_id.set(event_id)
62
+ try:
63
+ yield
64
+ finally:
65
+ current_parent_event_id.reset(token)
66
+
67
+
68
+ @asynccontextmanager
69
+ async def event_context_async(event_id: str) -> AsyncIterator[None]:
70
+ token = current_parent_event_id.set(event_id)
71
+ try:
72
+ yield
73
+ finally:
74
+ current_parent_event_id.reset(token)
75
+
76
+
52
77
  @contextmanager
53
78
  def session(**init_params) -> Iterator[None]:
54
79
  """All-in-one context manager: init → bind → yield → clear → end.