agnt5 0.2.8a13__cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.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.

Potentially problematic release.


This version of agnt5 might be problematic. Click here for more details.

agnt5/agent.py ADDED
@@ -0,0 +1,1774 @@
1
+ """Agent component implementation for AGNT5 SDK.
2
+
3
+ Provides simple agent with external LLM integration and tool orchestration.
4
+ Future: Platform-backed agents with durable execution and multi-agent coordination.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import functools
10
+ import json
11
+ import logging
12
+ import time
13
+ from typing import Any, Callable, Dict, List, Optional, Union
14
+
15
+ from .context import Context, get_current_context, set_current_context
16
+ from . import lm
17
+ from .lm import GenerateRequest, GenerateResponse, LanguageModel, Message, ModelConfig, ToolDefinition
18
+ from .tool import Tool, ToolRegistry
19
+ from ._telemetry import setup_module_logger
20
+ from .exceptions import WaitingForUserInputException
21
+
22
+ logger = setup_module_logger(__name__)
23
+
24
+ # Global agent registry
25
+ _AGENT_REGISTRY: Dict[str, "Agent"] = {}
26
+
27
+
28
+ class AgentContext(Context):
29
+ """
30
+ Context for agent execution with conversation state management.
31
+
32
+ Extends base Context with:
33
+ - State management via EntityStateManager
34
+ - Conversation history persistence
35
+ - Context inheritance (child agents share parent's state)
36
+
37
+ Three initialization modes:
38
+ 1. Standalone: Creates own state manager (playground testing)
39
+ 2. Inherit WorkflowContext: Shares parent's state manager
40
+ 3. Inherit parent AgentContext: Shares parent's state manager
41
+
42
+ Example:
43
+ ```python
44
+ # Standalone agent with conversation history
45
+ ctx = AgentContext(run_id="session-1", agent_name="tutor")
46
+ result = await agent.run("Hello", context=ctx)
47
+ result = await agent.run("Continue", context=ctx) # Remembers previous message
48
+
49
+ # Agent in workflow - shares workflow state
50
+ @workflow
51
+ async def research_workflow(ctx: WorkflowContext):
52
+ agent_result = await research_agent.run("Find AI trends", context=ctx)
53
+ # Agent has access to workflow state via inherited context
54
+ ```
55
+ """
56
+
57
+ def __init__(
58
+ self,
59
+ run_id: str,
60
+ agent_name: str,
61
+ session_id: Optional[str] = None,
62
+ state_manager: Optional[Any] = None,
63
+ parent_context: Optional[Context] = None,
64
+ attempt: int = 0,
65
+ runtime_context: Optional[Any] = None,
66
+ ):
67
+ """
68
+ Initialize agent context.
69
+
70
+ Args:
71
+ run_id: Unique execution identifier
72
+ agent_name: Name of the agent
73
+ session_id: Session identifier for conversation history (default: run_id)
74
+ state_manager: Optional state manager (for context inheritance)
75
+ parent_context: Parent context to inherit state from
76
+ attempt: Retry attempt number
77
+ runtime_context: RuntimeContext for trace correlation
78
+ """
79
+ super().__init__(run_id, attempt, runtime_context)
80
+
81
+ self._agent_name = agent_name
82
+ self._session_id = session_id or run_id
83
+ self.parent_context = parent_context # Store for context chain traversal
84
+
85
+ # Determine state adapter based on parent context
86
+ from .entity import EntityStateAdapter, _get_state_adapter
87
+
88
+ if state_manager:
89
+ # Explicit state adapter provided (parameter name kept for backward compat)
90
+ self._state_adapter = state_manager
91
+ elif parent_context:
92
+ # Try to inherit state adapter from parent
93
+ try:
94
+ # Check if parent is WorkflowContext or AgentContext
95
+ if hasattr(parent_context, '_workflow_entity'):
96
+ # WorkflowContext - get state adapter from worker context
97
+ self._state_adapter = _get_state_adapter()
98
+ elif hasattr(parent_context, '_state_adapter'):
99
+ # Parent AgentContext - share state adapter
100
+ self._state_adapter = parent_context._state_adapter
101
+ elif hasattr(parent_context, '_state_manager'):
102
+ # Backward compatibility: parent has old _state_manager
103
+ self._state_adapter = parent_context._state_manager
104
+ else:
105
+ # FunctionContext or base Context - create new state adapter
106
+ self._state_adapter = EntityStateAdapter()
107
+ except RuntimeError as e:
108
+ # _get_state_adapter() failed (not in worker context) - create standalone
109
+ self._state_adapter = EntityStateAdapter()
110
+ else:
111
+ # Try to get from worker context first
112
+ try:
113
+ self._state_adapter = _get_state_adapter()
114
+ except RuntimeError as e:
115
+ # Standalone - create new state adapter
116
+ self._state_adapter = EntityStateAdapter()
117
+
118
+ # Conversation key for state storage (used for in-memory state)
119
+ self._conversation_key = f"agent:{agent_name}:{self._session_id}:messages"
120
+ # Entity key for database persistence (without :messages suffix to match API expectations)
121
+ self._entity_key = f"agent:{agent_name}:{self._session_id}"
122
+
123
+ # Determine storage mode: "workflow" if parent is WorkflowContext, else "standalone"
124
+ self._storage_mode = "standalone" # Default mode
125
+ self._workflow_entity = None
126
+
127
+ if parent_context and hasattr(parent_context, '_workflow_entity'):
128
+ # Agent is running within a workflow - store conversation in workflow state
129
+ self._storage_mode = "workflow"
130
+ self._workflow_entity = parent_context._workflow_entity
131
+ logger.debug(f"Agent '{agent_name}' using workflow storage mode (workflow entity: {self._workflow_entity.key})")
132
+
133
+ @property
134
+ def state(self):
135
+ """
136
+ Get state interface for agent state management.
137
+
138
+ Note: This is a simplified in-memory state interface for agent-specific data.
139
+ Conversation history is managed separately via get_conversation_history() and
140
+ save_conversation_history() which use the Rust-backed persistence layer.
141
+
142
+ Returns:
143
+ Dict-like object for state operations
144
+
145
+ Example:
146
+ # Store agent-specific data (in-memory only)
147
+ ctx.state["research_results"] = data
148
+ ctx.state["iteration_count"] = 5
149
+ """
150
+ # Simple dict-based state for agent-specific data
151
+ # This is in-memory only and not persisted to platform
152
+ if not hasattr(self, '_agent_state'):
153
+ self._agent_state = {}
154
+ return self._agent_state
155
+
156
+ @property
157
+ def session_id(self) -> str:
158
+ """Get session identifier for this agent context."""
159
+ return self._session_id
160
+
161
+ async def get_conversation_history(self) -> List[Message]:
162
+ """
163
+ Retrieve conversation history from state, loading from database if needed.
164
+
165
+ Uses the EntityStateAdapter which delegates to Rust core for cache-first loading.
166
+ If running within a workflow, loads from workflow entity state instead.
167
+
168
+ Returns:
169
+ List of Message objects from conversation history
170
+ """
171
+ if self._storage_mode == "workflow":
172
+ return await self._load_from_workflow_state()
173
+ else:
174
+ return await self._load_from_entity_storage()
175
+
176
+ async def _load_from_workflow_state(self) -> List[Message]:
177
+ """Load conversation history from workflow entity state."""
178
+ key = f"agent.{self._agent_name}"
179
+ agent_data = self._workflow_entity.state.get(key, {})
180
+ messages_data = agent_data.get("messages", [])
181
+
182
+ # Convert dict representations back to Message objects
183
+ return self._convert_dicts_to_messages(messages_data)
184
+
185
+ async def _load_from_entity_storage(self) -> List[Message]:
186
+ """Load conversation history from AgentSession entity (standalone mode)."""
187
+ entity_type = "AgentSession"
188
+ entity_key = self._entity_key
189
+
190
+ # Load session data via adapter (Rust handles cache + platform load)
191
+ session_data = await self._state_adapter.load_state(entity_type, entity_key)
192
+
193
+ # Extract messages from session object
194
+ if isinstance(session_data, dict) and "messages" in session_data:
195
+ # New format with session metadata
196
+ messages_data = session_data["messages"]
197
+ elif isinstance(session_data, list):
198
+ # Old format - just messages array
199
+ messages_data = session_data
200
+ else:
201
+ # No messages found
202
+ messages_data = []
203
+
204
+ # Convert dict representations back to Message objects
205
+ return self._convert_dicts_to_messages(messages_data)
206
+
207
+ def _convert_dicts_to_messages(self, messages_data: list) -> List[Message]:
208
+ """Convert list of message dicts to Message objects."""
209
+ messages = []
210
+ for msg_dict in messages_data:
211
+ if isinstance(msg_dict, dict):
212
+ role = msg_dict.get("role", "user")
213
+ content = msg_dict.get("content", "")
214
+ if role == "user":
215
+ messages.append(Message.user(content))
216
+ elif role == "assistant":
217
+ messages.append(Message.assistant(content))
218
+ else:
219
+ # Generic message - create with MessageRole enum
220
+ from .lm import MessageRole
221
+ msg_role = MessageRole(role) if role in ("user", "assistant", "system") else MessageRole.USER
222
+ msg = Message(role=msg_role, content=content)
223
+ messages.append(msg)
224
+ else:
225
+ # Already a Message object
226
+ messages.append(msg_dict)
227
+
228
+ return messages
229
+
230
+ async def save_conversation_history(self, messages: List[Message]) -> None:
231
+ """
232
+ Save conversation history to state and persist to database.
233
+
234
+ Uses the EntityStateAdapter which delegates to Rust core for version-checked saves.
235
+ If running within a workflow, saves to workflow entity state instead.
236
+
237
+ Args:
238
+ messages: List of Message objects to persist
239
+ """
240
+ if self._storage_mode == "workflow":
241
+ await self._save_to_workflow_state(messages)
242
+ else:
243
+ await self._save_to_entity_storage(messages)
244
+
245
+ async def _save_to_workflow_state(self, messages: List[Message]) -> None:
246
+ """Save conversation history to workflow entity state."""
247
+ # Convert Message objects to dict for JSON serialization
248
+ messages_data = []
249
+ for msg in messages:
250
+ messages_data.append({
251
+ "role": msg.role.value if hasattr(msg.role, 'value') else str(msg.role),
252
+ "content": msg.content,
253
+ "timestamp": time.time()
254
+ })
255
+
256
+ # Build agent data structure
257
+ key = f"agent.{self._agent_name}"
258
+ current_data = self._workflow_entity.state.get(key, {})
259
+ now = time.time()
260
+
261
+ agent_data = {
262
+ "session_id": self._session_id,
263
+ "agent_name": self._agent_name,
264
+ "created_at": current_data.get("created_at", now),
265
+ "last_message_time": now,
266
+ "message_count": len(messages_data),
267
+ "messages": messages_data,
268
+ "metadata": getattr(self, '_custom_metadata', {})
269
+ }
270
+
271
+ # Store in workflow state (WorkflowEntity handles persistence)
272
+ self._workflow_entity.state.set(key, agent_data)
273
+ logger.info(f"Saved conversation to workflow state: {key} ({len(messages_data)} messages)")
274
+
275
+ async def _save_to_entity_storage(self, messages: List[Message]) -> None:
276
+ """Save conversation history to AgentSession entity (standalone mode)."""
277
+ # Convert Message objects to dict for JSON serialization
278
+ messages_data = []
279
+ for msg in messages:
280
+ messages_data.append({
281
+ "role": msg.role.value if hasattr(msg.role, 'value') else str(msg.role),
282
+ "content": msg.content,
283
+ "timestamp": time.time() # Add timestamp for each message
284
+ })
285
+
286
+ entity_type = "AgentSession"
287
+ entity_key = self._entity_key
288
+
289
+ # Load current state with version for optimistic locking
290
+ current_state, current_version = await self._state_adapter.load_with_version(
291
+ entity_type, entity_key
292
+ )
293
+
294
+ # Build session object with metadata
295
+ now = time.time()
296
+
297
+ # Get custom metadata from instance variable or preserve from loaded state
298
+ custom_metadata = getattr(self, '_custom_metadata', current_state.get("metadata", {}))
299
+
300
+ session_data = {
301
+ "session_id": self._session_id,
302
+ "agent_name": self._agent_name,
303
+ "created_at": current_state.get("created_at", now), # Preserve existing or set new
304
+ "last_message_time": now,
305
+ "message_count": len(messages_data),
306
+ "messages": messages_data,
307
+ "metadata": custom_metadata # Save custom metadata
308
+ }
309
+
310
+ # Save to platform via adapter (Rust handles optimistic locking)
311
+ try:
312
+ new_version = await self._state_adapter.save_state(
313
+ entity_type,
314
+ entity_key,
315
+ session_data,
316
+ current_version
317
+ )
318
+ logger.info(
319
+ f"Persisted conversation history: {entity_key} (version {current_version} -> {new_version})"
320
+ )
321
+ except Exception as e:
322
+ logger.error(f"Failed to persist conversation history to database: {e}")
323
+ # Don't fail - conversation is still in memory for this execution
324
+
325
+ async def get_metadata(self) -> Dict[str, Any]:
326
+ """
327
+ Get conversation session metadata.
328
+
329
+ Returns session metadata including:
330
+ - created_at: Timestamp of first message (float, Unix timestamp)
331
+ - last_activity: Timestamp of last message (float, Unix timestamp)
332
+ - message_count: Number of messages in conversation (int)
333
+ - custom: Dict of user-provided custom metadata
334
+
335
+ Returns:
336
+ Dictionary with metadata. If no conversation exists yet, returns defaults.
337
+
338
+ Example:
339
+ ```python
340
+ metadata = await context.get_metadata()
341
+ print(f"Session created: {metadata['created_at']}")
342
+ print(f"User ID: {metadata['custom'].get('user_id')}")
343
+ ```
344
+ """
345
+ if self._storage_mode == "workflow":
346
+ return await self._get_metadata_from_workflow()
347
+ else:
348
+ return await self._get_metadata_from_entity()
349
+
350
+ async def _get_metadata_from_workflow(self) -> Dict[str, Any]:
351
+ """Get metadata from workflow entity state."""
352
+ key = f"agent.{self._agent_name}"
353
+ agent_data = self._workflow_entity.state.get(key, {})
354
+
355
+ if not agent_data:
356
+ # No conversation exists yet - return defaults
357
+ return {
358
+ "created_at": None,
359
+ "last_activity": None,
360
+ "message_count": 0,
361
+ "custom": getattr(self, '_custom_metadata', {})
362
+ }
363
+
364
+ messages = agent_data.get("messages", [])
365
+ return {
366
+ "created_at": agent_data.get("created_at"),
367
+ "last_activity": agent_data.get("last_message_time"),
368
+ "message_count": len(messages),
369
+ "custom": agent_data.get("metadata", {})
370
+ }
371
+
372
+ async def _get_metadata_from_entity(self) -> Dict[str, Any]:
373
+ """Get metadata from AgentSession entity (standalone mode)."""
374
+ entity_type = "AgentSession"
375
+ entity_key = self._entity_key
376
+
377
+ # Load session data
378
+ session_data = await self._state_adapter.load_state(entity_type, entity_key)
379
+
380
+ if not session_data:
381
+ # No conversation exists yet - return defaults
382
+ return {
383
+ "created_at": None,
384
+ "last_activity": None,
385
+ "message_count": 0,
386
+ "custom": getattr(self, '_custom_metadata', {})
387
+ }
388
+
389
+ messages = session_data.get("messages", [])
390
+
391
+ # Derive timestamps from messages if available
392
+ created_at = session_data.get("created_at")
393
+ last_activity = session_data.get("last_message_time")
394
+
395
+ return {
396
+ "created_at": created_at,
397
+ "last_activity": last_activity,
398
+ "message_count": len(messages),
399
+ "custom": session_data.get("metadata", {})
400
+ }
401
+
402
+ def update_metadata(self, **kwargs) -> None:
403
+ """
404
+ Update custom session metadata.
405
+
406
+ Metadata will be persisted alongside conversation history on next save.
407
+ Use this to store application-specific data like user_id, preferences, etc.
408
+
409
+ Args:
410
+ **kwargs: Key-value pairs to store as metadata
411
+
412
+ Example:
413
+ ```python
414
+ # Store user identification and preferences
415
+ context.update_metadata(
416
+ user_id="user-123",
417
+ subscription_tier="premium",
418
+ preferences={"theme": "dark", "language": "en"}
419
+ )
420
+
421
+ # Later retrieve it
422
+ metadata = await context.get_metadata()
423
+ user_id = metadata["custom"]["user_id"]
424
+ ```
425
+
426
+ Note:
427
+ - Metadata is merged with existing metadata (doesn't replace)
428
+ - Changes persist on next save_conversation_history() call
429
+ - Use simple JSON-serializable types (str, int, float, dict, list)
430
+ """
431
+ if not hasattr(self, '_custom_metadata'):
432
+ self._custom_metadata = {}
433
+ self._custom_metadata.update(kwargs)
434
+
435
+
436
+ class Handoff:
437
+ """Configuration for agent-to-agent handoff.
438
+
439
+ Handoffs enable one agent to delegate control to another specialized agent,
440
+ following the pattern popularized by LangGraph and OpenAI Agents SDK.
441
+
442
+ The handoff is exposed to the LLM as a tool named 'transfer_to_{agent_name}'
443
+ that allows explicit delegation with conversation history.
444
+
445
+ Example:
446
+ ```python
447
+ specialist = Agent(name="specialist", ...)
448
+
449
+ # Simple: Pass agent directly (auto-wrapped with defaults)
450
+ coordinator = Agent(
451
+ name="coordinator",
452
+ handoffs=[specialist] # Agent auto-converted to Handoff
453
+ )
454
+
455
+ # Advanced: Use Handoff for custom configuration
456
+ coordinator = Agent(
457
+ name="coordinator",
458
+ handoffs=[
459
+ Handoff(
460
+ agent=specialist,
461
+ description="Custom description for LLM",
462
+ tool_name="custom_transfer_name",
463
+ pass_full_history=False
464
+ )
465
+ ]
466
+ )
467
+ ```
468
+ """
469
+
470
+ def __init__(
471
+ self,
472
+ agent: "Agent",
473
+ description: Optional[str] = None,
474
+ tool_name: Optional[str] = None,
475
+ pass_full_history: bool = True,
476
+ ):
477
+ """Initialize handoff configuration.
478
+
479
+ Args:
480
+ agent: Target agent to hand off to
481
+ description: Description shown to LLM (defaults to agent instructions)
482
+ tool_name: Custom tool name (defaults to 'transfer_to_{agent_name}')
483
+ pass_full_history: Whether to pass full conversation history to target agent
484
+ """
485
+ self.agent = agent
486
+ self.description = description or agent.instructions or f"Transfer to {agent.name}"
487
+ self.tool_name = tool_name or f"transfer_to_{agent.name}"
488
+ self.pass_full_history = pass_full_history
489
+
490
+
491
+ def handoff(
492
+ agent: "Agent",
493
+ description: Optional[str] = None,
494
+ tool_name: Optional[str] = None,
495
+ pass_full_history: bool = True,
496
+ ) -> Handoff:
497
+ """Create a handoff configuration for agent-to-agent delegation.
498
+
499
+ This is a convenience function for creating Handoff instances with a clean API.
500
+
501
+ Args:
502
+ agent: Target agent to hand off to
503
+ description: Description shown to LLM
504
+ tool_name: Custom tool name
505
+ pass_full_history: Whether to pass full conversation history
506
+
507
+ Returns:
508
+ Handoff configuration
509
+
510
+ Example:
511
+ ```python
512
+ from agnt5 import Agent, handoff
513
+
514
+ research_agent = Agent(name="researcher", ...)
515
+ writer_agent = Agent(name="writer", ...)
516
+
517
+ coordinator = Agent(
518
+ name="coordinator",
519
+ handoffs=[
520
+ handoff(research_agent, "Transfer for research tasks"),
521
+ handoff(writer_agent, "Transfer for writing tasks"),
522
+ ]
523
+ )
524
+ ```
525
+ """
526
+ return Handoff(
527
+ agent=agent,
528
+ description=description,
529
+ tool_name=tool_name,
530
+ pass_full_history=pass_full_history,
531
+ )
532
+
533
+
534
+ class AgentRegistry:
535
+ """Registry for agents."""
536
+
537
+ @staticmethod
538
+ def register(agent: "Agent") -> None:
539
+ """Register an agent."""
540
+ if agent.name in _AGENT_REGISTRY:
541
+ logger.warning(f"Overwriting existing agent '{agent.name}'")
542
+ _AGENT_REGISTRY[agent.name] = agent
543
+
544
+ @staticmethod
545
+ def get(name: str) -> Optional["Agent"]:
546
+ """Get agent by name."""
547
+ return _AGENT_REGISTRY.get(name)
548
+
549
+ @staticmethod
550
+ def all() -> Dict[str, "Agent"]:
551
+ """Get all registered agents."""
552
+ return _AGENT_REGISTRY.copy()
553
+
554
+ @staticmethod
555
+ def clear() -> None:
556
+ """Clear all registered agents."""
557
+ _AGENT_REGISTRY.clear()
558
+
559
+
560
+ class AgentResult:
561
+ """Result from agent execution."""
562
+
563
+ def __init__(
564
+ self,
565
+ output: str,
566
+ tool_calls: List[Dict[str, Any]],
567
+ context: Context,
568
+ handoff_to: Optional[str] = None,
569
+ handoff_metadata: Optional[Dict[str, Any]] = None,
570
+ ):
571
+ self.output = output
572
+ self.tool_calls = tool_calls
573
+ self.context = context
574
+ self.handoff_to = handoff_to # Name of agent that was handed off to
575
+ self.handoff_metadata = handoff_metadata or {} # Additional handoff info
576
+
577
+
578
+ class Agent:
579
+ """Autonomous LLM-driven agent with tool orchestration.
580
+
581
+ Current features:
582
+ - LLM integration (OpenAI, Anthropic, etc.)
583
+ - Tool selection and execution
584
+ - Multi-turn reasoning
585
+ - Context and state management
586
+
587
+ Future enhancements:
588
+ - Durable execution with checkpointing
589
+ - Multi-agent coordination
590
+ - Platform-backed tool execution
591
+ - Streaming responses
592
+
593
+ Example:
594
+ ```python
595
+ from agnt5 import Agent, tool, Context
596
+
597
+ @tool(auto_schema=True)
598
+ async def search_web(ctx: Context, query: str) -> List[Dict]:
599
+ # Search implementation
600
+ return [{"title": "Result", "url": "..."}]
601
+
602
+ # Simple usage with model string
603
+ agent = Agent(
604
+ name="researcher",
605
+ model="openai/gpt-4o-mini",
606
+ instructions="You are a research assistant.",
607
+ tools=[search_web],
608
+ temperature=0.7
609
+ )
610
+
611
+ result = await agent.run("What are the latest AI trends?")
612
+ print(result.output)
613
+ ```
614
+ """
615
+
616
+ def __init__(
617
+ self,
618
+ name: str,
619
+ model: Any, # Can be string like "openai/gpt-4o-mini" OR LanguageModel instance
620
+ instructions: str,
621
+ tools: Optional[List[Any]] = None,
622
+ handoffs: Optional[List[Union["Agent", Handoff]]] = None, # Accept Agent or Handoff instances
623
+ temperature: float = 0.7,
624
+ max_tokens: Optional[int] = None,
625
+ top_p: Optional[float] = None,
626
+ model_config: Optional[ModelConfig] = None,
627
+ max_iterations: int = 10,
628
+ model_name: Optional[str] = None, # For backwards compatibility with tests
629
+ ):
630
+ """Initialize agent.
631
+
632
+ Args:
633
+ name: Agent name/identifier
634
+ model: Model string with provider prefix (e.g., "openai/gpt-4o-mini") OR LanguageModel instance
635
+ instructions: System instructions for the agent
636
+ tools: List of tools available to the agent (functions, Tool instances, or Agent instances)
637
+ handoffs: List of handoff configurations - can be Agent instances (auto-wrapped) or Handoff instances for custom config
638
+ temperature: LLM temperature (0.0 to 1.0)
639
+ max_tokens: Maximum tokens to generate
640
+ top_p: Nucleus sampling parameter
641
+ model_config: Optional advanced configuration (custom endpoints, headers, etc.)
642
+ max_iterations: Maximum reasoning iterations
643
+ model_name: Optional model name (for backwards compatibility, used when model is a LanguageModel instance)
644
+ """
645
+ self.name = name
646
+ self.instructions = instructions
647
+ self.temperature = temperature
648
+ self.max_tokens = max_tokens
649
+ self.top_p = top_p
650
+ self.model_config = model_config
651
+ self.max_iterations = max_iterations
652
+
653
+ # Support both string model names and LanguageModel instances
654
+ if isinstance(model, str):
655
+ # New API: model is a string like "openai/gpt-4o-mini"
656
+ self.model = model
657
+ self.model_name = model_name or model
658
+ self._language_model = None # Will create on demand
659
+ elif isinstance(model, LanguageModel):
660
+ # Old API (for tests): model is a LanguageModel instance
661
+ self._language_model = model
662
+ self.model = model # Keep for backwards compatibility
663
+ self.model_name = model_name or "mock-model"
664
+ else:
665
+ raise TypeError(f"model must be a string or LanguageModel instance, got {type(model)}")
666
+
667
+ # Normalize handoffs: convert Agent instances to Handoff instances
668
+ self.handoffs: List[Handoff] = []
669
+ if handoffs:
670
+ for handoff_item in handoffs:
671
+ if isinstance(handoff_item, Agent):
672
+ # Auto-wrap Agent in Handoff with sensible defaults
673
+ self.handoffs.append(Handoff(agent=handoff_item))
674
+ logger.info(f"Auto-wrapped agent '{handoff_item.name}' in Handoff for '{self.name}'")
675
+ elif isinstance(handoff_item, Handoff):
676
+ self.handoffs.append(handoff_item)
677
+ else:
678
+ raise TypeError(f"handoffs must contain Agent or Handoff instances, got {type(handoff_item)}")
679
+
680
+ # Build tool registry (includes regular tools, agent-as-tools, and handoff tools)
681
+ self.tools: Dict[str, Tool] = {}
682
+ if tools:
683
+ for tool_item in tools:
684
+ # Check if it's an Agent instance (agents-as-tools pattern)
685
+ if isinstance(tool_item, Agent):
686
+ agent_tool = tool_item.to_tool()
687
+ self.tools[agent_tool.name] = agent_tool
688
+ logger.info(f"Added agent '{tool_item.name}' as tool to '{self.name}'")
689
+ # Check if it's a Tool instance
690
+ elif isinstance(tool_item, Tool):
691
+ self.tools[tool_item.name] = tool_item
692
+ # Check if it's a decorated function with config
693
+ elif hasattr(tool_item, "_agnt5_config"):
694
+ # Try to get from ToolRegistry first
695
+ tool_config = tool_item._agnt5_config
696
+ tool_instance = ToolRegistry.get(tool_config.name)
697
+ if tool_instance:
698
+ self.tools[tool_instance.name] = tool_instance
699
+ # Otherwise try to look up by function name
700
+ elif callable(tool_item):
701
+ # Try to find in registry by function name
702
+ tool_name = tool_item.__name__
703
+ tool_instance = ToolRegistry.get(tool_name)
704
+ if tool_instance:
705
+ self.tools[tool_instance.name] = tool_instance
706
+
707
+ # Build handoff tools
708
+ for handoff_config in self.handoffs:
709
+ handoff_tool = self._create_handoff_tool(handoff_config)
710
+ self.tools[handoff_tool.name] = handoff_tool
711
+ logger.info(f"Added handoff tool '{handoff_tool.name}' to '{self.name}'")
712
+
713
+ self.logger = logging.getLogger(f"agnt5.agent.{name}")
714
+
715
+ # Cost tracking for this agent's execution
716
+ self._cumulative_cost_usd = 0.0
717
+
718
+ # Define schemas based on the run method signature
719
+ # Input: user_message (string)
720
+ self.input_schema = {
721
+ "type": "object",
722
+ "properties": {
723
+ "user_message": {"type": "string"}
724
+ },
725
+ "required": ["user_message"]
726
+ }
727
+ # Output: AgentResult with output and tool_calls
728
+ self.output_schema = {
729
+ "type": "object",
730
+ "properties": {
731
+ "output": {"type": "string"},
732
+ "tool_calls": {
733
+ "type": "array",
734
+ "items": {"type": "object"}
735
+ }
736
+ }
737
+ }
738
+
739
+ # Auto-register agent for discovery
740
+ AgentRegistry.register(self)
741
+
742
+ # Store metadata
743
+ self.metadata = {
744
+ "description": instructions,
745
+ "model": model
746
+ }
747
+
748
+ @property
749
+ def cumulative_cost_usd(self) -> float:
750
+ """
751
+ Get cumulative LLM cost in USD for this agent's execution.
752
+
753
+ Returns the total cost of all LLM calls made by this agent instance
754
+ since it was created.
755
+
756
+ Returns:
757
+ float: Cumulative cost in USD
758
+
759
+ Example:
760
+ ```python
761
+ result = await agent.run("Analyze this data")
762
+ print(f"Total cost: ${agent.cumulative_cost_usd:.4f}")
763
+ ```
764
+ """
765
+ return self._cumulative_cost_usd
766
+
767
+ def _track_llm_cost(self, response: GenerateResponse, workflow_ctx: Optional[Any] = None) -> None:
768
+ """
769
+ Track LLM cost from response and emit cost accrual event.
770
+
771
+ Reads cost from OpenTelemetry span attributes (calculated by Rust core)
772
+ and emits a run.cost.accrued checkpoint event if in workflow context.
773
+
774
+ Args:
775
+ response: LLM response with usage data
776
+ workflow_ctx: Optional workflow context for checkpoint emission
777
+ """
778
+ # Try to get cost from current OpenTelemetry span
779
+ cost_usd = None
780
+ try:
781
+ from opentelemetry import trace
782
+ span = trace.get_current_span()
783
+ if span.is_recording():
784
+ # Cost is set by Rust telemetry in gen_ai.usage.cost attribute
785
+ attributes = span.attributes or {}
786
+ cost_usd = attributes.get("gen_ai.usage.cost")
787
+ except Exception as e:
788
+ self.logger.debug(f"Could not read cost from span: {e}")
789
+
790
+ # Fallback: calculate cost from usage if span doesn't have it
791
+ # (This shouldn't happen in production, but useful for testing)
792
+ if cost_usd is None and response.usage:
793
+ self.logger.debug("Span cost not available, skipping cost tracking")
794
+ return
795
+
796
+ if cost_usd is not None:
797
+ # Update cumulative cost
798
+ self._cumulative_cost_usd += cost_usd
799
+
800
+ # Emit cost accrual event if in workflow context
801
+ if workflow_ctx is not None:
802
+ event_data = {
803
+ "cost_usd": cost_usd,
804
+ "cumulative_cost_usd": self._cumulative_cost_usd,
805
+ "agent_name": self.name,
806
+ "model": self.model_name,
807
+ }
808
+
809
+ # Add token usage if available
810
+ if response.usage:
811
+ event_data["input_tokens"] = response.usage.prompt_tokens
812
+ event_data["output_tokens"] = response.usage.completion_tokens
813
+ event_data["total_tokens"] = response.usage.total_tokens
814
+
815
+ workflow_ctx._send_checkpoint("run.cost.accrued", event_data)
816
+
817
+ self.logger.debug(
818
+ f"Agent '{self.name}' cost: ${cost_usd:.6f} "
819
+ f"(cumulative: ${self._cumulative_cost_usd:.6f})"
820
+ )
821
+
822
+ def to_tool(self, description: Optional[str] = None) -> Tool:
823
+ """Convert this agent to a Tool that can be used by other agents.
824
+
825
+ This enables agents-as-tools pattern where one agent can invoke another
826
+ agent as if it were a regular tool.
827
+
828
+ Args:
829
+ description: Optional custom description (defaults to agent instructions)
830
+
831
+ Returns:
832
+ Tool instance that wraps this agent
833
+
834
+ Example:
835
+ ```python
836
+ research_agent = Agent(
837
+ name="researcher",
838
+ model="openai/gpt-4o-mini",
839
+ instructions="You are a research specialist."
840
+ )
841
+
842
+ # Use research agent as a tool for another agent
843
+ coordinator = Agent(
844
+ name="coordinator",
845
+ model="openai/gpt-4o-mini",
846
+ instructions="Coordinate tasks using specialist agents.",
847
+ tools=[research_agent.to_tool()]
848
+ )
849
+ ```
850
+ """
851
+ agent_name = self.name
852
+
853
+ # Handler that runs the agent
854
+ async def agent_tool_handler(ctx: Context, user_message: str) -> str:
855
+ """Execute agent and return output."""
856
+ ctx.logger.info(f"Invoking agent '{agent_name}' as tool")
857
+
858
+ # Run the agent with the user message
859
+ result = await self.run(user_message, context=ctx)
860
+
861
+ return result.output
862
+
863
+ # Create tool with agent's schema
864
+ tool_description = description or self.instructions or f"Agent: {self.name}"
865
+
866
+ agent_tool = Tool(
867
+ name=self.name,
868
+ description=tool_description,
869
+ handler=agent_tool_handler,
870
+ input_schema=self.input_schema,
871
+ auto_schema=False,
872
+ )
873
+
874
+ return agent_tool
875
+
876
+ def _create_handoff_tool(self, handoff_config: Handoff, current_messages_callback: Optional[Callable] = None) -> Tool:
877
+ """Create a tool for handoff to another agent.
878
+
879
+ Args:
880
+ handoff_config: Handoff configuration
881
+ current_messages_callback: Optional callback to get current conversation messages
882
+
883
+ Returns:
884
+ Tool instance that executes the handoff
885
+ """
886
+ target_agent = handoff_config.agent
887
+ tool_name = handoff_config.tool_name
888
+
889
+ # Handler that executes the handoff
890
+ async def handoff_handler(ctx: Context, message: str) -> Dict[str, Any]:
891
+ """Transfer control to target agent."""
892
+ ctx.logger.info(
893
+ f"Handoff from '{self.name}' to '{target_agent.name}': {message}"
894
+ )
895
+
896
+ # If we should pass conversation history, add it to context
897
+ if handoff_config.pass_full_history:
898
+ # Get current conversation from the agent's run loop
899
+ # (This will be set when we detect the handoff in run())
900
+ conversation_history = getattr(ctx, '_agent_data', {}).get("_current_conversation", [])
901
+
902
+ if conversation_history:
903
+ ctx.logger.info(
904
+ f"Passing {len(conversation_history)} messages to target agent"
905
+ )
906
+ # Store in context for target agent to optionally use
907
+ if not hasattr(ctx, '_agent_data'):
908
+ ctx._agent_data = {}
909
+ ctx._agent_data["_handoff_conversation_history"] = conversation_history
910
+
911
+ # Execute target agent with the message and shared context
912
+ result = await target_agent.run(message, context=ctx)
913
+
914
+ # Store handoff metadata - this signals that a handoff occurred
915
+ handoff_data = {
916
+ "_handoff": True,
917
+ "from_agent": self.name,
918
+ "to_agent": target_agent.name,
919
+ "message": message,
920
+ "output": result.output,
921
+ "tool_calls": result.tool_calls,
922
+ }
923
+
924
+ if not hasattr(ctx, '_agent_data'):
925
+ ctx._agent_data = {}
926
+ ctx._agent_data["_handoff_result"] = handoff_data
927
+
928
+ # Return the handoff data (will be detected in run() loop)
929
+ return handoff_data
930
+
931
+ # Create tool with handoff schema
932
+ handoff_tool = Tool(
933
+ name=tool_name,
934
+ description=handoff_config.description,
935
+ handler=handoff_handler,
936
+ input_schema={
937
+ "type": "object",
938
+ "properties": {
939
+ "message": {
940
+ "type": "string",
941
+ "description": "Message or task to pass to the target agent"
942
+ }
943
+ },
944
+ "required": ["message"]
945
+ },
946
+ auto_schema=False,
947
+ )
948
+
949
+ return handoff_tool
950
+
951
+ def _detect_memory_scope(self, context: Optional[Context]) -> tuple[str, str]:
952
+ """
953
+ Auto-detect memory scope from context for agent conversation persistence.
954
+
955
+ Implements priority logic:
956
+ 1. user_id → user-scoped memory (long-term)
957
+ 2. session_id → session-scoped memory (multi-turn)
958
+ 3. run_id → run-scoped memory (ephemeral)
959
+
960
+ Args:
961
+ context: WorkflowContext or other context with memory scoping fields
962
+
963
+ Returns:
964
+ Tuple of (entity_key, scope) where:
965
+ - entity_key: e.g., "user:user-456", "session:abc-123", "run:xyz-789"
966
+ - scope: "user", "session", or "run"
967
+
968
+ Example:
969
+ entity_key, scope = agent._detect_memory_scope(ctx)
970
+ # If ctx.user_id="user-123": ("user:user-123", "user")
971
+ # If ctx.session_id="sess-456": ("session:sess-456", "session")
972
+ # Otherwise: ("run:run-789", "run")
973
+ """
974
+ # Extract identifiers from context
975
+ user_id = getattr(context, 'user_id', None) if context else None
976
+ session_id = getattr(context, 'session_id', None) if context else None
977
+ run_id = getattr(context, 'run_id', None) if context else None
978
+
979
+ # Priority: user_id > session_id > run_id
980
+ if user_id:
981
+ return (f"user:{user_id}", "user")
982
+ elif session_id and session_id != run_id: # Explicit session (not defaulting to run_id)
983
+ return (f"session:{session_id}", "session")
984
+ elif run_id:
985
+ return (f"run:{run_id}", "run")
986
+ else:
987
+ # Fallback: create ephemeral key
988
+ import uuid
989
+ fallback_run_id = f"agent-{self.name}-{uuid.uuid4().hex[:8]}"
990
+ return (f"run:{fallback_run_id}", "run")
991
+
992
+ async def run(
993
+ self,
994
+ user_message: str,
995
+ context: Optional[Context] = None,
996
+ ) -> AgentResult:
997
+ """Run agent to completion.
998
+
999
+ Args:
1000
+ user_message: User's input message
1001
+ context: Optional context (auto-created if not provided, or read from contextvar)
1002
+
1003
+ Returns:
1004
+ AgentResult with output and execution details
1005
+
1006
+ Example:
1007
+ ```python
1008
+ result = await agent.run("Analyze recent tech news")
1009
+ print(result.output)
1010
+ ```
1011
+ """
1012
+ # Create or adapt context
1013
+ if context is None:
1014
+ # Try to get context from task-local storage (set by workflow/function decorator)
1015
+ context = get_current_context()
1016
+
1017
+ # IMPORTANT: Capture workflow context NOW before we replace it with AgentContext
1018
+ # This allows LM calls inside the agent to emit workflow checkpoints
1019
+ from .workflow import WorkflowContext
1020
+ workflow_ctx = context if isinstance(context, WorkflowContext) else None
1021
+
1022
+ if context is None:
1023
+ # Standalone execution - create AgentContext
1024
+ import uuid
1025
+ run_id = f"agent-{self.name}-{uuid.uuid4().hex[:8]}"
1026
+ context = AgentContext(
1027
+ run_id=run_id,
1028
+ agent_name=self.name,
1029
+ )
1030
+ elif isinstance(context, AgentContext):
1031
+ # Already AgentContext - use as-is
1032
+ pass
1033
+ elif hasattr(context, '_workflow_entity'):
1034
+ # WorkflowContext - create AgentContext that inherits state
1035
+ # Auto-detect memory scope based on user_id/session_id/run_id priority
1036
+ entity_key, scope = self._detect_memory_scope(context)
1037
+
1038
+ import uuid
1039
+ run_id = f"{context.run_id}:agent:{self.name}"
1040
+ # Extract the ID from entity_key (e.g., "session:abc-123" → "abc-123")
1041
+ detected_session_id = entity_key.split(":", 1)[1] if ":" in entity_key else context.run_id
1042
+
1043
+ context = AgentContext(
1044
+ run_id=run_id,
1045
+ agent_name=self.name,
1046
+ session_id=detected_session_id, # Use auto-detected scope
1047
+ parent_context=context,
1048
+ runtime_context=getattr(context, '_runtime_context', None), # Inherit trace context
1049
+ )
1050
+ else:
1051
+ # FunctionContext or other - create new AgentContext
1052
+ import uuid
1053
+ run_id = f"{context.run_id}:agent:{self.name}"
1054
+ context = AgentContext(
1055
+ run_id=run_id,
1056
+ agent_name=self.name,
1057
+ runtime_context=getattr(context, '_runtime_context', None), # Inherit trace context
1058
+ )
1059
+
1060
+ # Emit checkpoint if called within a workflow context
1061
+ if workflow_ctx is not None:
1062
+ workflow_ctx._send_checkpoint("agent.started", {
1063
+ "agent.name": self.name,
1064
+ "agent.model": self.model_name,
1065
+ "agent.tools": list(self.tools.keys()),
1066
+ "agent.max_iterations": self.max_iterations,
1067
+ "user_message": user_message,
1068
+ })
1069
+
1070
+ # NEW: Check if this is a resume from HITL
1071
+ if workflow_ctx and hasattr(workflow_ctx, "_agent_resume_info"):
1072
+ resume_info = workflow_ctx._agent_resume_info
1073
+ if resume_info["agent_name"] == self.name:
1074
+ self.logger.info("Detected HITL resume, calling resume_from_hitl()")
1075
+
1076
+ # Clear resume info to avoid re-entry
1077
+ delattr(workflow_ctx, "_agent_resume_info")
1078
+
1079
+ # Resume from checkpoint (context setup happens inside resume_from_hitl)
1080
+ return await self.resume_from_hitl(
1081
+ context=workflow_ctx,
1082
+ agent_context=resume_info["agent_context"],
1083
+ user_response=resume_info["user_response"],
1084
+ )
1085
+
1086
+ # Set context in task-local storage for automatic propagation to tools and LM calls
1087
+ token = set_current_context(context)
1088
+ try:
1089
+ try:
1090
+ # Load conversation history from state (if AgentContext)
1091
+ if isinstance(context, AgentContext):
1092
+ messages: List[Message] = await context.get_conversation_history()
1093
+ # Add new user message
1094
+ messages.append(Message.user(user_message))
1095
+ # Save updated conversation
1096
+ await context.save_conversation_history(messages)
1097
+ else:
1098
+ # Fallback for non-AgentContext (shouldn't happen with code above)
1099
+ messages = [Message.user(user_message)]
1100
+
1101
+ # Create span for agent execution with trace linking
1102
+ from ._core import create_span
1103
+
1104
+ with create_span(
1105
+ self.name,
1106
+ "agent",
1107
+ context._runtime_context if hasattr(context, "_runtime_context") else None,
1108
+ {
1109
+ "agent.name": self.name,
1110
+ "agent.model": self.model_name, # Use model_name (always a string)
1111
+ "agent.max_iterations": str(self.max_iterations),
1112
+ },
1113
+ ) as span:
1114
+ all_tool_calls: List[Dict[str, Any]] = []
1115
+
1116
+ # Reasoning loop
1117
+ for iteration in range(self.max_iterations):
1118
+ # Build tool definitions for LLM
1119
+ tool_defs = [
1120
+ ToolDefinition(
1121
+ name=tool.name,
1122
+ description=tool.description,
1123
+ parameters=tool.input_schema,
1124
+ )
1125
+ for tool in self.tools.values()
1126
+ ]
1127
+
1128
+ # Convert messages to dict format for lm.generate()
1129
+ messages_dict = []
1130
+ for msg in messages:
1131
+ messages_dict.append({
1132
+ "role": msg.role.value,
1133
+ "content": msg.content
1134
+ })
1135
+
1136
+ # Call LLM
1137
+ # Check if we have a legacy LanguageModel instance or need to create one
1138
+ if self._language_model is not None:
1139
+ # Legacy API: use provided LanguageModel instance
1140
+ request = GenerateRequest(
1141
+ model="mock-model", # Not used by MockLanguageModel
1142
+ system_prompt=self.instructions,
1143
+ messages=messages,
1144
+ tools=tool_defs if tool_defs else [],
1145
+ )
1146
+ request.config.temperature = self.temperature
1147
+ if self.max_tokens:
1148
+ request.config.max_tokens = self.max_tokens
1149
+ if self.top_p:
1150
+ request.config.top_p = self.top_p
1151
+ response = await self._language_model.generate(request)
1152
+
1153
+ # Track cost for this LLM call
1154
+ self._track_llm_cost(response, workflow_ctx)
1155
+ else:
1156
+ # New API: model is a string, create internal LM instance
1157
+ request = GenerateRequest(
1158
+ model=self.model,
1159
+ system_prompt=self.instructions,
1160
+ messages=messages,
1161
+ tools=tool_defs if tool_defs else [],
1162
+ )
1163
+ request.config.temperature = self.temperature
1164
+ if self.max_tokens:
1165
+ request.config.max_tokens = self.max_tokens
1166
+ if self.top_p:
1167
+ request.config.top_p = self.top_p
1168
+
1169
+ # Create internal LM instance for generation
1170
+ # TODO: Use model_config when provided
1171
+ from .lm import _LanguageModel
1172
+ provider, model_name = self.model.split('/', 1)
1173
+ internal_lm = _LanguageModel(provider=provider.lower(), default_model=None)
1174
+ response = await internal_lm.generate(request)
1175
+
1176
+ # Track cost for this LLM call
1177
+ self._track_llm_cost(response, workflow_ctx)
1178
+
1179
+ # Add assistant response to messages
1180
+ messages.append(Message.assistant(response.text))
1181
+
1182
+ # Check if LLM wants to use tools
1183
+ if response.tool_calls:
1184
+ self.logger.debug(f"Agent calling {len(response.tool_calls)} tool(s)")
1185
+
1186
+ # Store current conversation in context for potential handoffs
1187
+ # Use a simple dict attribute since we don't need full state persistence for this
1188
+ if not hasattr(context, '_agent_data'):
1189
+ context._agent_data = {}
1190
+ context._agent_data["_current_conversation"] = messages
1191
+
1192
+ # Execute tool calls
1193
+ tool_results = []
1194
+ for tool_call in response.tool_calls:
1195
+ tool_name = tool_call["name"]
1196
+ tool_args_str = tool_call["arguments"]
1197
+
1198
+ # Track tool call
1199
+ all_tool_calls.append(
1200
+ {
1201
+ "name": tool_name,
1202
+ "arguments": tool_args_str,
1203
+ "iteration": iteration + 1,
1204
+ }
1205
+ )
1206
+
1207
+ # Execute tool
1208
+ try:
1209
+ # Parse arguments
1210
+ tool_args = json.loads(tool_args_str)
1211
+
1212
+ # Get tool
1213
+ tool = self.tools.get(tool_name)
1214
+ if not tool:
1215
+ result_text = f"Error: Tool '{tool_name}' not found"
1216
+ else:
1217
+ # Execute tool
1218
+ result = await tool.invoke(context, **tool_args)
1219
+
1220
+ # Check if this was a handoff
1221
+ if isinstance(result, dict) and result.get("_handoff"):
1222
+ self.logger.info(
1223
+ f"Handoff detected to '{result['to_agent']}', "
1224
+ f"terminating current agent"
1225
+ )
1226
+ # Save conversation before returning
1227
+ if isinstance(context, AgentContext):
1228
+ await context.save_conversation_history(messages)
1229
+ # Return immediately with handoff result
1230
+ return AgentResult(
1231
+ output=result["output"],
1232
+ tool_calls=all_tool_calls + result.get("tool_calls", []),
1233
+ context=context,
1234
+ handoff_to=result["to_agent"],
1235
+ handoff_metadata=result,
1236
+ )
1237
+
1238
+ result_text = json.dumps(result) if result else "null"
1239
+
1240
+ tool_results.append(
1241
+ {"tool": tool_name, "result": result_text, "error": None}
1242
+ )
1243
+
1244
+ except WaitingForUserInputException as e:
1245
+ # HITL PAUSE: Capture agent state and propagate exception
1246
+ self.logger.info(f"Agent pausing for user input at iteration {iteration}")
1247
+
1248
+ # Serialize messages to dict format
1249
+ messages_dict = [
1250
+ {"role": msg.role.value, "content": msg.content}
1251
+ for msg in messages
1252
+ ]
1253
+
1254
+ # Enhance exception with agent execution context
1255
+ raise WaitingForUserInputException(
1256
+ question=e.question,
1257
+ input_type=e.input_type,
1258
+ options=e.options,
1259
+ checkpoint_state=e.checkpoint_state,
1260
+ agent_context={
1261
+ "agent_name": self.name,
1262
+ "iteration": iteration,
1263
+ "messages": messages_dict,
1264
+ "tool_results": tool_results,
1265
+ "pending_tool_call": {
1266
+ "name": tool_call["name"],
1267
+ "arguments": tool_call["arguments"],
1268
+ "tool_call_index": response.tool_calls.index(tool_call),
1269
+ },
1270
+ "all_tool_calls": all_tool_calls,
1271
+ "model_config": {
1272
+ "model": self.model,
1273
+ "temperature": self.temperature,
1274
+ "max_tokens": self.max_tokens,
1275
+ "top_p": self.top_p,
1276
+ },
1277
+ },
1278
+ ) from e
1279
+
1280
+ except Exception as e:
1281
+ # Regular tool errors - log and continue
1282
+ self.logger.error(f"Tool execution error: {e}")
1283
+ tool_results.append(
1284
+ {"tool": tool_name, "result": None, "error": str(e)}
1285
+ )
1286
+
1287
+ # Add tool results to conversation
1288
+ results_text = "\n".join(
1289
+ [
1290
+ f"Tool: {tr['tool']}\nResult: {tr['result']}"
1291
+ if tr["error"] is None
1292
+ else f"Tool: {tr['tool']}\nError: {tr['error']}"
1293
+ for tr in tool_results
1294
+ ]
1295
+ )
1296
+ messages.append(Message.user(f"Tool results:\n{results_text}\n\nPlease provide your final answer based on these results."))
1297
+
1298
+ # Continue loop for agent to process results
1299
+
1300
+ else:
1301
+ # No tool calls - agent is done
1302
+ self.logger.debug(f"Agent completed after {iteration + 1} iterations")
1303
+ # Save conversation before returning
1304
+ if isinstance(context, AgentContext):
1305
+ await context.save_conversation_history(messages)
1306
+
1307
+ # Emit completion checkpoint
1308
+ if workflow_ctx:
1309
+ workflow_ctx._send_checkpoint("agent.completed", {
1310
+ "agent.name": self.name,
1311
+ "agent.iterations": iteration + 1,
1312
+ "agent.tool_calls_count": len(all_tool_calls),
1313
+ "output_length": len(response.text),
1314
+ })
1315
+
1316
+ return AgentResult(
1317
+ output=response.text,
1318
+ tool_calls=all_tool_calls,
1319
+ context=context,
1320
+ )
1321
+
1322
+ # Max iterations reached
1323
+ self.logger.warning(f"Agent reached max iterations ({self.max_iterations})")
1324
+ final_output = messages[-1].content if messages else "No output generated"
1325
+ # Save conversation before returning
1326
+ if isinstance(context, AgentContext):
1327
+ await context.save_conversation_history(messages)
1328
+
1329
+ # Emit completion checkpoint with max iterations flag
1330
+ if workflow_ctx:
1331
+ workflow_ctx._send_checkpoint("agent.completed", {
1332
+ "agent.name": self.name,
1333
+ "agent.iterations": self.max_iterations,
1334
+ "agent.tool_calls_count": len(all_tool_calls),
1335
+ "agent.max_iterations_reached": True,
1336
+ "output_length": len(final_output),
1337
+ })
1338
+
1339
+ return AgentResult(
1340
+ output=final_output,
1341
+ tool_calls=all_tool_calls,
1342
+ context=context,
1343
+ )
1344
+ except Exception as e:
1345
+ # Emit error checkpoint for observability
1346
+ if workflow_ctx:
1347
+ workflow_ctx._send_checkpoint("agent.failed", {
1348
+ "agent.name": self.name,
1349
+ "error": str(e),
1350
+ "error_type": type(e).__name__,
1351
+ })
1352
+ raise
1353
+ finally:
1354
+ # Always reset context to prevent leakage between agent executions
1355
+ from .context import _current_context
1356
+ _current_context.reset(token)
1357
+
1358
+ async def resume_from_hitl(
1359
+ self,
1360
+ context: Context,
1361
+ agent_context: Dict,
1362
+ user_response: str,
1363
+ ) -> AgentResult:
1364
+ """
1365
+ Resume agent execution after HITL pause.
1366
+
1367
+ This method reconstructs agent state from the checkpoint and injects
1368
+ the user's response as the successful tool result, then continues
1369
+ the conversation loop.
1370
+
1371
+ Args:
1372
+ context: Current execution context (workflow or agent)
1373
+ agent_context: Agent state from WaitingForUserInputException.agent_context
1374
+ user_response: User's answer to the HITL question
1375
+
1376
+ Returns:
1377
+ AgentResult with final output and tool calls
1378
+ """
1379
+ self.logger.info(f"Resuming agent '{self.name}' from HITL pause")
1380
+
1381
+ # 1. Restore conversation state
1382
+ messages = [
1383
+ Message(role=lm.MessageRole(msg["role"]), content=msg["content"])
1384
+ for msg in agent_context["messages"]
1385
+ ]
1386
+ iteration = agent_context["iteration"]
1387
+ all_tool_calls = agent_context["all_tool_calls"]
1388
+
1389
+ # 2. Restore partial tool results for current iteration
1390
+ tool_results = agent_context["tool_results"]
1391
+
1392
+ # 3. Inject user response as successful tool result
1393
+ pending_tool = agent_context["pending_tool_call"]
1394
+ tool_results.append({
1395
+ "tool": pending_tool["name"],
1396
+ "result": json.dumps(user_response),
1397
+ "error": None,
1398
+ })
1399
+
1400
+ self.logger.debug(
1401
+ f"Injected user response for tool '{pending_tool['name']}': {user_response}"
1402
+ )
1403
+
1404
+ # 4. Add tool results to conversation
1405
+ results_text = "\n".join([
1406
+ f"Tool: {tr['tool']}\nResult: {tr['result']}"
1407
+ if tr["error"] is None
1408
+ else f"Tool: {tr['tool']}\nError: {tr['error']}"
1409
+ for tr in tool_results
1410
+ ])
1411
+ messages.append(Message.user(
1412
+ f"Tool results:\n{results_text}\n\n"
1413
+ f"Please provide your final answer based on these results."
1414
+ ))
1415
+
1416
+ # 5. Continue agent execution loop from next iteration
1417
+ return await self._continue_execution_from_iteration(
1418
+ context=context,
1419
+ messages=messages,
1420
+ iteration=iteration + 1, # Next iteration
1421
+ all_tool_calls=all_tool_calls,
1422
+ )
1423
+
1424
+ async def _continue_execution_from_iteration(
1425
+ self,
1426
+ context: Context,
1427
+ messages: List[Message],
1428
+ iteration: int,
1429
+ all_tool_calls: List[Dict],
1430
+ ) -> AgentResult:
1431
+ """
1432
+ Continue agent execution from a specific iteration.
1433
+
1434
+ This is the core execution loop extracted to support both:
1435
+ 1. Normal execution (starting from iteration 0)
1436
+ 2. Resume after HITL (starting from iteration N)
1437
+
1438
+ Args:
1439
+ context: Execution context
1440
+ messages: Conversation history
1441
+ iteration: Starting iteration number
1442
+ all_tool_calls: Accumulated tool calls
1443
+
1444
+ Returns:
1445
+ AgentResult with output and tool calls
1446
+ """
1447
+ # Extract workflow context for checkpointing
1448
+ workflow_ctx = None
1449
+ if hasattr(context, "_workflow_entity"):
1450
+ workflow_ctx = context
1451
+ elif hasattr(context, "_agent_data") and "_workflow_ctx" in context._agent_data:
1452
+ workflow_ctx = context._agent_data["_workflow_ctx"]
1453
+
1454
+ # Prepare tool definitions
1455
+ tool_defs = [
1456
+ ToolDefinition(
1457
+ name=name,
1458
+ description=tool.description or f"Tool: {name}",
1459
+ parameters=tool.input_schema if hasattr(tool, "input_schema") else {},
1460
+ )
1461
+ for name, tool in self.tools.items()
1462
+ ]
1463
+
1464
+ # Main iteration loop (continue from specified iteration)
1465
+ while iteration < self.max_iterations:
1466
+ self.logger.debug(f"Agent iteration {iteration + 1}/{self.max_iterations}")
1467
+
1468
+ # Call LLM for next response
1469
+ if self._language_model:
1470
+ # Legacy API: model is a LanguageModel instance
1471
+ request = GenerateRequest(
1472
+ system_prompt=self.instructions,
1473
+ messages=messages,
1474
+ tools=tool_defs if tool_defs else [],
1475
+ )
1476
+ request.config.temperature = self.temperature
1477
+ if self.max_tokens:
1478
+ request.config.max_tokens = self.max_tokens
1479
+ if self.top_p:
1480
+ request.config.top_p = self.top_p
1481
+ response = await self._language_model.generate(request)
1482
+
1483
+ # Track cost for this LLM call
1484
+ self._track_llm_cost(response, workflow_ctx)
1485
+ else:
1486
+ # New API: model is a string, create internal LM instance
1487
+ request = GenerateRequest(
1488
+ model=self.model,
1489
+ system_prompt=self.instructions,
1490
+ messages=messages,
1491
+ tools=tool_defs if tool_defs else [],
1492
+ )
1493
+ request.config.temperature = self.temperature
1494
+ if self.max_tokens:
1495
+ request.config.max_tokens = self.max_tokens
1496
+ if self.top_p:
1497
+ request.config.top_p = self.top_p
1498
+
1499
+ # Create internal LM instance for generation
1500
+ from .lm import _LanguageModel
1501
+ provider, model_name = self.model.split('/', 1)
1502
+ internal_lm = _LanguageModel(provider=provider.lower(), default_model=None)
1503
+ response = await internal_lm.generate(request)
1504
+
1505
+ # Track cost for this LLM call
1506
+ self._track_llm_cost(response, workflow_ctx)
1507
+
1508
+ # Add assistant response to messages
1509
+ messages.append(Message.assistant(response.text))
1510
+
1511
+ # Check if LLM wants to use tools
1512
+ if response.tool_calls:
1513
+ self.logger.debug(f"Agent calling {len(response.tool_calls)} tool(s)")
1514
+
1515
+ # Store current conversation in context for potential handoffs
1516
+ if not hasattr(context, '_agent_data'):
1517
+ context._agent_data = {}
1518
+ context._agent_data["_current_conversation"] = messages
1519
+
1520
+ # Execute tool calls
1521
+ tool_results = []
1522
+ for tool_call in response.tool_calls:
1523
+ tool_name = tool_call["name"]
1524
+ tool_args_str = tool_call["arguments"]
1525
+
1526
+ # Track tool call
1527
+ all_tool_calls.append({
1528
+ "name": tool_name,
1529
+ "arguments": tool_args_str,
1530
+ "iteration": iteration + 1,
1531
+ })
1532
+
1533
+ # Execute tool
1534
+ try:
1535
+ # Parse arguments
1536
+ tool_args = json.loads(tool_args_str)
1537
+
1538
+ # Get tool
1539
+ tool = self.tools.get(tool_name)
1540
+ if not tool:
1541
+ result_text = f"Error: Tool '{tool_name}' not found"
1542
+ else:
1543
+ # Execute tool
1544
+ result = await tool.invoke(context, **tool_args)
1545
+
1546
+ # Check if this was a handoff
1547
+ if isinstance(result, dict) and result.get("_handoff"):
1548
+ self.logger.info(
1549
+ f"Handoff detected to '{result['to_agent']}', "
1550
+ f"terminating current agent"
1551
+ )
1552
+ # Save conversation before returning
1553
+ if isinstance(context, AgentContext):
1554
+ await context.save_conversation_history(messages)
1555
+ # Return immediately with handoff result
1556
+ return AgentResult(
1557
+ output=result["output"],
1558
+ tool_calls=all_tool_calls + result.get("tool_calls", []),
1559
+ context=context,
1560
+ handoff_to=result["to_agent"],
1561
+ handoff_metadata=result,
1562
+ )
1563
+
1564
+ result_text = json.dumps(result) if result else "null"
1565
+
1566
+ tool_results.append(
1567
+ {"tool": tool_name, "result": result_text, "error": None}
1568
+ )
1569
+
1570
+ except WaitingForUserInputException as e:
1571
+ # HITL PAUSE: Capture agent state and propagate exception
1572
+ self.logger.info(f"Agent pausing for user input at iteration {iteration}")
1573
+
1574
+ # Serialize messages to dict format
1575
+ messages_dict = [
1576
+ {"role": msg.role.value, "content": msg.content}
1577
+ for msg in messages
1578
+ ]
1579
+
1580
+ # Enhance exception with agent execution context
1581
+ from .exceptions import WaitingForUserInputException
1582
+ raise WaitingForUserInputException(
1583
+ question=e.question,
1584
+ input_type=e.input_type,
1585
+ options=e.options,
1586
+ checkpoint_state=e.checkpoint_state,
1587
+ agent_context={
1588
+ "agent_name": self.name,
1589
+ "iteration": iteration,
1590
+ "messages": messages_dict,
1591
+ "tool_results": tool_results,
1592
+ "pending_tool_call": {
1593
+ "name": tool_call["name"],
1594
+ "arguments": tool_call["arguments"],
1595
+ "tool_call_index": response.tool_calls.index(tool_call),
1596
+ },
1597
+ "all_tool_calls": all_tool_calls,
1598
+ "model_config": {
1599
+ "model": self.model,
1600
+ "temperature": self.temperature,
1601
+ "max_tokens": self.max_tokens,
1602
+ "top_p": self.top_p,
1603
+ },
1604
+ },
1605
+ ) from e
1606
+
1607
+ except Exception as e:
1608
+ # Regular tool errors - log and continue
1609
+ self.logger.error(f"Tool execution error: {e}")
1610
+ tool_results.append(
1611
+ {"tool": tool_name, "result": None, "error": str(e)}
1612
+ )
1613
+
1614
+ # Add tool results to conversation
1615
+ results_text = "\n".join([
1616
+ f"Tool: {tr['tool']}\nResult: {tr['result']}"
1617
+ if tr["error"] is None
1618
+ else f"Tool: {tr['tool']}\nError: {tr['error']}"
1619
+ for tr in tool_results
1620
+ ])
1621
+ messages.append(Message.user(
1622
+ f"Tool results:\n{results_text}\n\n"
1623
+ f"Please provide your final answer based on these results."
1624
+ ))
1625
+
1626
+ # Continue loop for agent to process results
1627
+
1628
+ else:
1629
+ # No tool calls - agent is done
1630
+ self.logger.debug(f"Agent completed after {iteration + 1} iterations")
1631
+ # Save conversation before returning
1632
+ if isinstance(context, AgentContext):
1633
+ await context.save_conversation_history(messages)
1634
+
1635
+ # Emit completion checkpoint
1636
+ if workflow_ctx:
1637
+ workflow_ctx._send_checkpoint("agent.completed", {
1638
+ "agent.name": self.name,
1639
+ "agent.iterations": iteration + 1,
1640
+ "agent.tool_calls_count": len(all_tool_calls),
1641
+ "output_length": len(response.text),
1642
+ })
1643
+
1644
+ return AgentResult(
1645
+ output=response.text,
1646
+ tool_calls=all_tool_calls,
1647
+ context=context,
1648
+ )
1649
+
1650
+ iteration += 1
1651
+
1652
+ # Max iterations reached
1653
+ self.logger.warning(f"Agent reached max iterations ({self.max_iterations})")
1654
+ final_output = messages[-1].content if messages else "No output generated"
1655
+ # Save conversation before returning
1656
+ if isinstance(context, AgentContext):
1657
+ await context.save_conversation_history(messages)
1658
+
1659
+ # Emit completion checkpoint with max iterations flag
1660
+ if workflow_ctx:
1661
+ workflow_ctx._send_checkpoint("agent.completed", {
1662
+ "agent.name": self.name,
1663
+ "agent.iterations": self.max_iterations,
1664
+ "agent.tool_calls_count": len(all_tool_calls),
1665
+ "agent.max_iterations_reached": True,
1666
+ "output_length": len(final_output),
1667
+ })
1668
+
1669
+ return AgentResult(
1670
+ output=final_output,
1671
+ tool_calls=all_tool_calls,
1672
+ context=context,
1673
+ )
1674
+
1675
+
1676
+ def agent(
1677
+ _func: Optional[Callable] = None,
1678
+ *,
1679
+ name: Optional[str] = None,
1680
+ model: Optional[LanguageModel] = None,
1681
+ instructions: Optional[str] = None,
1682
+ tools: Optional[List[Any]] = None,
1683
+ model_name: str = "gpt-4o-mini",
1684
+ temperature: float = 0.7,
1685
+ max_iterations: int = 10,
1686
+ ) -> Callable:
1687
+ """
1688
+ Decorator to register a function as an agent and automatically register it.
1689
+
1690
+ This decorator allows you to define agents as functions that create and return Agent instances.
1691
+ The agent will be automatically registered in the AgentRegistry for discovery by the worker.
1692
+
1693
+ Args:
1694
+ name: Agent name (defaults to function name)
1695
+ model: Language model instance (required if not provided in function)
1696
+ instructions: System instructions (required if not provided in function)
1697
+ tools: List of tools available to the agent
1698
+ model_name: Model name to use
1699
+ temperature: LLM temperature
1700
+ max_iterations: Maximum reasoning iterations
1701
+
1702
+ Returns:
1703
+ Decorated function that returns an Agent instance
1704
+
1705
+ Example:
1706
+ ```python
1707
+ from agnt5 import agent, tool
1708
+ from agnt5.lm import OpenAILanguageModel
1709
+
1710
+ @agent(
1711
+ name="research_agent",
1712
+ model=OpenAILanguageModel(),
1713
+ instructions="You are a research assistant.",
1714
+ tools=[search_web, analyze_data]
1715
+ )
1716
+ def create_researcher():
1717
+ # Agent is created and registered automatically
1718
+ pass
1719
+
1720
+ # Or create agent directly
1721
+ @agent
1722
+ def my_agent():
1723
+ from agnt5.lm import OpenAILanguageModel
1724
+ return Agent(
1725
+ name="my_agent",
1726
+ model=OpenAILanguageModel(),
1727
+ instructions="You are a helpful assistant."
1728
+ )
1729
+ ```
1730
+ """
1731
+
1732
+ def decorator(func: Callable) -> Callable:
1733
+ # Determine agent name
1734
+ agent_name = name or func.__name__
1735
+
1736
+ # Create the agent
1737
+ @functools.wraps(func)
1738
+ def wrapper(*args, **kwargs) -> Agent:
1739
+ # Check if function returns an Agent
1740
+ result = func(*args, **kwargs)
1741
+ if isinstance(result, Agent):
1742
+ # Function creates its own agent
1743
+ agent_instance = result
1744
+ elif model is not None and instructions is not None:
1745
+ # Create agent from decorator parameters
1746
+ agent_instance = Agent(
1747
+ name=agent_name,
1748
+ model=model,
1749
+ instructions=instructions,
1750
+ tools=tools,
1751
+ model_name=model_name,
1752
+ temperature=temperature,
1753
+ max_iterations=max_iterations,
1754
+ )
1755
+ else:
1756
+ raise ValueError(
1757
+ f"Agent decorator for '{agent_name}' requires either "
1758
+ "the decorated function to return an Agent instance, "
1759
+ "or 'model' and 'instructions' parameters to be provided"
1760
+ )
1761
+
1762
+ # Register agent
1763
+ AgentRegistry.register(agent_instance)
1764
+ return agent_instance
1765
+
1766
+ # Create agent immediately and store reference
1767
+ agent_instance = wrapper()
1768
+
1769
+ # Return the agent instance itself (so it can be used directly)
1770
+ return agent_instance
1771
+
1772
+ if _func is None:
1773
+ return decorator
1774
+ return decorator(_func)