1"""AgentExecutorImpl — runs agents with governance, memory, tracing, and metrics.
2
3The executor is the bridge between the agent framework and the rest
4of the Lexigram infrastructure.
5"""
6
7from __future__ import annotations
8
9from dataclasses import dataclass, replace
10from datetime import UTC
11from typing import Any
12
13from lexigram.ai.agents.exceptions import BudgetExceededError
14from lexigram.ai.agents.executor.streaming import astream as _astream
15from lexigram.ai.agents.observability import AgentMetrics, AgentTracer
16from lexigram.ai.agents.strategies.guard_hook import (
17 ToolObservationBlockedError,
18 ToolObservationGuardError,
19)
20from lexigram.contracts.ai.agents import (
21 AgentError,
22 AgentExecutorProtocol,
23 AgentProtocol,
24 AgentResponse,
25 MemoryProtocol,
26)
27from lexigram.contracts.ai.governance import AIGovernanceProtocol
28from lexigram.contracts.ai.guards import GuardPipelineProtocol
29from lexigram.contracts.ai.llm import (
30 ChatMessage,
31 CostEstimatorProtocol,
32 LLMClientProtocol,
33 Role,
34)
35from lexigram.contracts.ai.memory import WorkingMemoryProtocol
36from lexigram.contracts.ai.session import SessionManagerProtocol
37from lexigram.contracts.ai.skills import (
38 SkillExecutorProtocol,
39 SkillRegistryProtocol,
40)
41from lexigram.contracts.events.protocols import EventBusProtocol
42from lexigram.logging import (
43 get_logger,
44)
45from lexigram.result import Err, Ok, Result
46
47logger = get_logger(__name__)
48
49
50@dataclass
51class AgentObservability:
52 """Composite for agent observability components.
53
54 Groups metrics, tracing, and event publishing into a single injectable unit.
55 """
56
57 metrics: AgentMetrics | None = None
58 tracer: AgentTracer | None = None
59 event_bus: EventBusProtocol | None = None
60
61
62@dataclass
63class AgentSafetyInfra:
64 """Composite for agent safety infrastructure.
65
66 Groups governance and guard pipeline into a single injectable unit.
67 """
68
69 governance: AIGovernanceProtocol | None = None
70 guard_pipeline: GuardPipelineProtocol | None = None
71
72
73class AgentExecutorImpl(AgentExecutorProtocol):
74 """Runs an agent with full infrastructure integration.
75
76 Wraps agent strategy execution with:
77 1. **Governance** — budget/rate limit checks
78 2. **Memory** — conversation history load/save (legacy + working memory)
79 3. **Metrics** — execution duration, tokens, tool calls
80 4. **Tracing** — distributed spans per execution and tool call
81 5. **Events** — domain events for agent lifecycle
82 6. **Resilience** — circuit breakers on tool calls (via ToolRegistry)
83 7. **Sessions** — stateful multi-turn conversation management
84 8. **Skills** — composable skill execution and discovery
85
86 Usage::
87
88 executor = AgentExecutorImpl(llm=llm_client)
89 result = await executor.run(
90 agent=my_agent,
91 message="Where is my order?",
92 session_id="session-123",
93 )
94 """
95
96 def __init__(
97 self,
98 llm: LLMClientProtocol | None = None,
99 memory: MemoryProtocol | None = None,
100 working_memory: WorkingMemoryProtocol | None = None,
101 session_manager: SessionManagerProtocol | None = None,
102 skill_executor: SkillExecutorProtocol | None = None,
103 skill_registry: SkillRegistryProtocol | None = None,
104 observability: AgentObservability | None = None,
105 safety: AgentSafetyInfra | None = None,
106 cost_estimator: CostEstimatorProtocol | None = None,
107 ) -> None:
108 """Initialize the agent executor.
109
110 Args:
111 llm: LLM client for agent reasoning.
112 memory: Conversation memory for multi-turn sessions (legacy).
113 working_memory: Working memory for context assembly.
114 session_manager: Session manager for stateful conversations.
115 skill_executor: Skill executor for running skills.
116 skill_registry: Skill registry for discovering available skills.
117 observability: Composite for metrics, tracer, and event bus.
118 Defaults to isolated ``AgentMetrics`` and ``AgentTracer``.
119 safety: Composite for governance and guard pipeline.
120 cost_estimator: Estimates monetary cost of LLM usage for
121 governance tracking. When omitted, cost is not tracked
122 (no fabricated estimates).
123 """
124 self._llm = llm
125 self._memory = memory
126 self._working_memory = working_memory
127 self._session_manager = session_manager
128 self._skill_executor = skill_executor
129 self._skill_registry = skill_registry
130 self._cost_estimator = cost_estimator
131
132 self._metrics = (
133 observability.metrics
134 if observability and observability.metrics
135 else AgentMetrics()
136 )
137 self._tracer = (
138 observability.tracer
139 if observability and observability.tracer
140 else AgentTracer()
141 )
142 self._event_bus = observability.event_bus if observability else None
143 self._governance = safety.governance if safety else None
144 self._guard_pipeline = safety.guard_pipeline if safety else None
145
146 async def run(
147 self,
148 agent: AgentProtocol,
149 message: str,
150 session_id: str | None = None,
151 user_id: str | None = None,
152 **kwargs: Any,
153 ) -> Result[AgentResponse, AgentError]:
154 """Execute an agent with full infrastructure integration.
155
156 Args:
157 agent: The agent to execute.
158 message: User's input message.
159 session_id: Session ID for multi-turn memory.
160 user_id: User ID for governance tracking.
161 **kwargs: Additional parameters passed to the strategy.
162
163 Returns:
164 ``Ok(AgentResponse)`` on success,
165 ``Err(AgentError)`` on failure.
166 """
167 logger.info(
168 "agent_execution_start",
169 agent=agent.name,
170 session_id=session_id,
171 user_id=user_id,
172 message_length=len(message),
173 )
174
175 # Publish start event
176 await self._publish_event(
177 "AgentExecutionStarted",
178 {
179 "agent_name": agent.name,
180 "session_id": session_id,
181 "user_id": user_id,
182 },
183 )
184
185 # 1. Governance check
186 if self._governance:
187 model = getattr(self._llm, "model", "unknown")
188 provider = getattr(self._llm, "provider", "unknown")
189 try:
190 allowed = await self._governance.check_request(
191 model=model,
192 provider=provider,
193 user_id=user_id,
194 )
195 if not allowed:
196 self._metrics.record_governance_denied(agent.name)
197 logger.warning(
198 "agent_governance_denied",
199 agent=agent.name,
200 user_id=user_id,
201 )
202 await self._publish_event(
203 "AgentExecutionFailed",
204 {
205 "agent_name": agent.name,
206 "error": "Governance denied",
207 "error_type": "BudgetExceededError",
208 },
209 )
210 return Err(
211 BudgetExceededError(
212 "Agent request denied by governance policy",
213 )
214 )
215 except (RuntimeError, OSError) as e:
216 logger.warning("governance_check_failed", error=str(e))
217
218 # Agent-level pipeline (AgentBuilder.with_guard_pipeline) wins over the
219 # DI-provided one; the DI pipeline remains the standard default.
220 agent_pipeline = getattr(agent, "guard_pipeline", None)
221 guard_pipeline = (
222 agent_pipeline if agent_pipeline is not None else self._guard_pipeline
223 )
224
225 # 1.5 Guard check for input
226 if guard_pipeline:
227 try:
228 guard_res = await guard_pipeline.check_input(
229 content=message,
230 metadata={"session_id": session_id, "user_id": user_id},
231 )
232 if not guard_res.is_ok():
233 return Err(
234 AgentError(f"Guard pipeline failure: {guard_res.unwrap_err()}")
235 )
236
237 agg = guard_res.unwrap()
238 blocked = bool(getattr(agg, "blocked", False))
239 if blocked:
240 blocking_result = getattr(agg, "blocking_result", None)
241 reason = (
242 getattr(blocking_result, "reason", None)
243 or "Input blocked by security guards"
244 )
245 return Err(
246 AgentError(f"Input blocked by security guards: {reason}")
247 )
248
249 message = str(getattr(agg, "final_content", message))
250 except (RuntimeError, OSError) as e:
251 logger.exception("input_guard_evaluation_failed", agent=agent.name)
252 return Err(AgentError(f"Guard evaluation failed: {e}", cause=e))
253
254 # 2a. Resume or create session
255 session_state = None
256 if self._session_manager and session_id:
257 try:
258 session_state = await self._session_manager.resume(session_id)
259 if session_state is None and user_id:
260 session_state = await self._session_manager.create(
261 user_id=user_id,
262 metadata={"agent": agent.name},
263 )
264 session_id = session_state.session_id
265 logger.debug(
266 "session_loaded",
267 session_id=session_id,
268 turns=session_state.turn_count if session_state else 0,
269 )
270 except (RuntimeError, TypeError, AttributeError, LookupError) as e:
271 logger.warning("session_load_failed", error=str(e))
272
273 # 2b. Assemble context via working memory (preferred) or legacy memory
274 history: list[ChatMessage] = []
275 if self._working_memory:
276 try:
277 entries = await self._working_memory.assemble(
278 query=message,
279 token_budget=4096,
280 owner_id=user_id or session_id or "anonymous",
281 session_id=session_id,
282 )
283 history = [
284 ChatMessage(role=Role(e.role), content=e.content) for e in entries
285 ]
286 except (RuntimeError, TypeError, AttributeError, LookupError) as e:
287 logger.warning("working_memory_assemble_failed", error=str(e))
288 elif self._memory and session_id:
289 try:
290 if hasattr(self._memory, "get_messages"):
291 msgs = await self._memory.get_messages()
292 history = [
293 ChatMessage(role=Role(m.role), content=m.content) for m in msgs
294 ]
295 except (RuntimeError, TypeError, AttributeError, LookupError) as e:
296 logger.warning("memory_load_failed", error=str(e))
297
298 # 2c. Merge skills as additional tools
299 tools: list[Any] = list(agent.tools) if agent.tools else []
300 if self._skill_registry:
301 try:
302 skill_schemas = self._skill_registry.get_schemas()
303 tools.extend(skill_schemas)
304 except (RuntimeError, TypeError, AttributeError, LookupError) as e:
305 logger.warning("skill_registry_merge_failed", error=str(e))
306
307 # 3. Execute strategy with tracing
308 strategy = getattr(agent, "strategy", None)
309 if strategy is None:
310 from lexigram.ai.agents.strategies.react import ReActStrategy
311
312 strategy = ReActStrategy()
313
314 if self._llm is None:
315 logger.error("agent_executor_missing_llm", agent=agent.name)
316 await self._publish_event(
317 "AgentExecutionFailed",
318 {
319 "agent_name": agent.name,
320 "error": "No LLM client configured",
321 "error_type": "ConfigurationError",
322 },
323 )
324 return Err(AgentError("Agent executor has no LLM client configured"))
325
326 try:
327 async with self._tracer.trace_execution(
328 agent.name,
329 message,
330 session_id,
331 ) as span:
332 strategy_history = [
333 {"role": m.role, "content": m.content} for m in history
334 ]
335
336 result = await strategy.execute(
337 message=message,
338 tools=tools,
339 history=strategy_history,
340 llm=self._llm,
341 system_prompt=agent.system_prompt,
342 temperature=getattr(agent, "temperature", 0.7),
343 tool_registry=kwargs.get("tool_registry"),
344 memory=getattr(agent, "memory", None),
345 guard_pipeline=guard_pipeline,
346 **kwargs,
347 )
348
349 if span and hasattr(result, "is_ok"):
350 if result.is_ok():
351 response = result.unwrap()
352 if hasattr(span, "set_attribute"):
353 span.set_attribute("agent.steps", response.step_count)
354 span.set_attribute("agent.tokens", response.total_tokens)
355 span.set_attribute(
356 "agent.tool_calls", response.tool_call_count
357 )
358
359 except (ToolObservationBlockedError, ToolObservationGuardError) as e:
360 logger.warning(
361 "agent.guard_blocked_observation",
362 agent=agent.name,
363 error=str(e),
364 )
365 self._metrics.record_error(agent.name, type(e).__name__)
366 await self._publish_event(
367 "AgentExecutionFailed",
368 {
369 "agent_name": agent.name,
370 "error": str(e),
371 "error_type": type(e).__name__,
372 },
373 )
374 if isinstance(e, ToolObservationBlockedError):
375 message_text = f"Tool observation blocked by guards: {e}"
376 else:
377 message_text = f"Tool observation guard evaluation failed: {e}"
378 return Err(AgentError(message_text, cause=e))
379
380 except (RuntimeError, OSError) as e:
381 logger.exception("strategy_execution_failed", agent=agent.name)
382 self._metrics.record_error(agent.name, type(e).__name__)
383 await self._publish_event(
384 "AgentExecutionFailed",
385 {
386 "agent_name": agent.name,
387 "error": str(e),
388 "error_type": type(e).__name__,
389 },
390 )
391 return Err(AgentError(f"Strategy failed: {e}", cause=e))
392
393 if not result.is_ok():
394 error = result.unwrap_err()
395 error_type = type(error).__name__
396 self._metrics.record_error(agent.name, error_type)
397 await self._publish_event(
398 "AgentExecutionFailed",
399 {
400 "agent_name": agent.name,
401 "error": str(error),
402 "error_type": error_type,
403 },
404 )
405 if isinstance(error, AgentError):
406 return Err(error)
407 return Err(AgentError(str(error), cause=error))
408
409 response = result.unwrap()
410
411 # 3.5 Guard check for output
412 if guard_pipeline:
413 try:
414 out_guard_res = await guard_pipeline.check_output(
415 content=response.message,
416 original_input=message,
417 metadata={"session_id": session_id, "user_id": user_id},
418 )
419 if not out_guard_res.is_ok():
420 return Err(
421 AgentError(
422 f"Guard pipeline failure: {out_guard_res.unwrap_err()}"
423 )
424 )
425
426 agg = out_guard_res.unwrap()
427 blocked = bool(getattr(agg, "blocked", False))
428 if blocked:
429 blocking_result = getattr(agg, "blocking_result", None)
430 reason = (
431 getattr(blocking_result, "reason", None)
432 or "Output blocked by security guards"
433 )
434 return Err(
435 AgentError(f"Output blocked by security guards: {reason}")
436 )
437
438 response = replace(
439 response,
440 message=str(getattr(agg, "final_content", response.message)),
441 )
442 except (RuntimeError, OSError) as e:
443 logger.exception("output_guard_evaluation_failed", agent=agent.name)
444 return Err(AgentError(f"Guard evaluation failed: {e}", cause=e))
445
446 # 4. Record metrics
447 self._metrics.record_execution(agent.name, response)
448 for tc in response.tool_calls:
449 self._metrics.record_tool_call(agent.name, tc)
450
451 # 5. Save to memory (working memory, session, and/or legacy)
452 if self._working_memory:
453 try:
454 from datetime import datetime
455
456 from lexigram.contracts.ai.memory import MemoryEntry
457
458 owner = user_id or session_id or "anonymous"
459 user_entry = MemoryEntry(
460 id=f"{session_id or 'no-session'}-user-{response.step_count}",
461 owner_id=owner,
462 content=message,
463 role="user",
464 timestamp=datetime.now(UTC),
465 )
466 assistant_entry = MemoryEntry(
467 id=f"{session_id or 'no-session'}-assistant-{response.step_count}",
468 owner_id=owner,
469 content=response.message,
470 role="assistant",
471 timestamp=datetime.now(UTC),
472 )
473 await self._working_memory.add(user_entry)
474 await self._working_memory.add(assistant_entry)
475 except (RuntimeError, TypeError, AttributeError, LookupError) as e:
476 logger.warning("working_memory_save_failed", error=str(e))
477
478 if self._session_manager and session_id:
479 try:
480 from datetime import datetime
481
482 from lexigram.contracts.ai.session import SessionTurn
483
484 user_turn = SessionTurn(
485 turn_id=f"{session_id}-user-{response.step_count}",
486 role="user",
487 content=message,
488 timestamp=datetime.now(UTC),
489 )
490 assistant_turn = SessionTurn(
491 turn_id=f"{session_id}-assistant-{response.step_count}",
492 role="assistant",
493 content=response.message,
494 timestamp=datetime.now(UTC),
495 tokens_used=response.total_tokens,
496 tool_calls=[
497 {"name": tc.tool_name, "result": tc.result}
498 for tc in response.tool_calls
499 ],
500 )
501 await self._session_manager.add_turn(session_id, user_turn)
502 await self._session_manager.add_turn(session_id, assistant_turn)
503 except (RuntimeError, TypeError, AttributeError, LookupError) as e:
504 logger.warning("session_turn_save_failed", error=str(e))
505
506 if self._memory and session_id:
507 try:
508 if hasattr(self._memory, "add"):
509 await self._memory.add("user", message)
510 await self._memory.add("assistant", response.message)
511 elif hasattr(self._memory, "add_message"):
512 await self._memory.add_message(
513 ChatMessage(role=Role.USER, content=message),
514 )
515 await self._memory.add_message(
516 ChatMessage(role=Role.ASSISTANT, content=response.message),
517 )
518 except (RuntimeError, TypeError, AttributeError, LookupError) as e:
519 logger.warning("memory_save_failed", error=str(e))
520
521 # 6. Track cost (only when a real estimator is configured)
522 if self._governance and self._cost_estimator and response.total_tokens > 0:
523 try:
524 model = getattr(self._llm, "model", "unknown")
525 estimated_cost = self._cost_estimator.estimate_cost(
526 model=model,
527 total_tokens=response.total_tokens,
528 provider=getattr(self._llm, "provider", None),
529 prompt_tokens=response.prompt_tokens,
530 completion_tokens=response.completion_tokens,
531 )
532 await self._governance.track_cost(
533 cost=estimated_cost,
534 model=model,
535 user_id=user_id,
536 )
537 response = replace(response, total_cost=estimated_cost)
538 except (RuntimeError, OSError) as e:
539 logger.warning("cost_tracking_failed", error=str(e))
540
541 response = replace(response, session_id=session_id)
542
543 # 7. Publish completion event
544 await self._publish_event(
545 "AgentExecutionCompleted",
546 {
547 "agent_name": agent.name,
548 "session_id": session_id,
549 "steps": response.step_count,
550 "tool_calls": response.tool_call_count,
551 "total_tokens": response.total_tokens,
552 "prompt_tokens": response.prompt_tokens,
553 "completion_tokens": response.completion_tokens,
554 "total_cost": response.total_cost,
555 "duration_ms": response.duration_ms,
556 },
557 )
558
559 logger.info(
560 "agent_execution_complete",
561 agent=agent.name,
562 steps=response.step_count,
563 tool_calls=response.tool_call_count,
564 tokens=response.total_tokens,
565 duration_ms=round(response.duration_ms, 2),
566 )
567
568 return Ok(response)
569
570 async def astream(
571 self,
572 agent: AgentProtocol,
573 message: str,
574 session_id: str | None = None,
575 user_id: str | None = None,
576 **kwargs: Any,
577 ):
578 """Stream agent execution events.
579
580 Yields AgentEvent objects as the agent executes, enabling
581 real-time monitoring of thoughts, tool calls, and messages.
582
583 Args:
584 agent: The agent to execute.
585 message: User's input message.
586 session_id: Session ID for multi-turn memory.
587 user_id: User ID for governance tracking.
588 **kwargs: Additional parameters passed to the strategy.
589
590 Yields:
591 AgentEvent objects with type, data, and run_id.
592 """
593 async for event in _astream(
594 self,
595 agent,
596 message,
597 session_id,
598 user_id,
599 **kwargs,
600 ):
601 yield event
602
603 async def _publish_event(
604 self,
605 event_type: str,
606 data: dict[str, Any],
607 ) -> None:
608 """Publish an agent domain event if EventBusProtocol is available."""
609 if not self._event_bus:
610 return
611
612 try:
613 from lexigram.ai.agents import events as agent_events
614
615 event_cls = getattr(agent_events, event_type, None)
616 if event_cls:
617 event_obj = event_cls(**data)
618 await self._event_bus.publish(event_obj)
619 except (ImportError, AttributeError):
620 pass
621 except (RuntimeError, OSError, ConnectionError, TypeError) as e:
622 logger.debug("event_publish_failed", event_type=event_type, error=str(e))