agnt5 0.3.2a1__cp310-abi3-manylinux_2_34_aarch64.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/context.py ADDED
@@ -0,0 +1,581 @@
1
+ """Agent execution context with conversation state management."""
2
+
3
+ import logging
4
+ import os
5
+ import time
6
+ import warnings
7
+ from typing import Any, Dict, List, Optional, TYPE_CHECKING
8
+
9
+ from ..context import Context
10
+ from ..lm import Message
11
+
12
+ if TYPE_CHECKING:
13
+ pass
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ # Gateway URL for session history API (defaults to localhost dev server)
18
+ DEFAULT_GATEWAY_URL = "http://localhost:34181"
19
+
20
+
21
+ class AgentContext(Context):
22
+ """
23
+ Context for agent execution with conversation state management.
24
+
25
+ Extends base Context with:
26
+ - State management via EntityStateManager
27
+ - Conversation history persistence
28
+ - Context inheritance (child agents share parent's state)
29
+
30
+ Three initialization modes:
31
+ 1. Standalone: Creates own state manager (playground testing)
32
+ 2. Inherit WorkflowContext: Shares parent's state manager
33
+ 3. Inherit parent AgentContext: Shares parent's state manager
34
+
35
+ Example:
36
+ ```python
37
+ # Standalone agent with conversation history
38
+ ctx = AgentContext(run_id="session-1", agent_name="tutor")
39
+ result = await agent.run_sync("Hello", context=ctx)
40
+ result = await agent.run_sync("Continue", context=ctx) # Remembers previous message
41
+
42
+ # Agent in workflow - shares workflow state
43
+ @workflow
44
+ async def research_workflow(ctx: WorkflowContext):
45
+ agent_result = await research_agent.run_sync("Find AI trends", context=ctx)
46
+ # Agent has access to workflow state via inherited context
47
+ ```
48
+ """
49
+
50
+ def __init__(
51
+ self,
52
+ run_id: str,
53
+ agent_name: str,
54
+ session_id: Optional[str] = None,
55
+ state_manager: Optional[Any] = None,
56
+ parent_context: Optional[Context] = None,
57
+ attempt: int = 0,
58
+ runtime_context: Optional[Any] = None,
59
+ is_streaming: bool = False,
60
+ tenant_id: Optional[str] = None,
61
+ ):
62
+ """
63
+ Initialize agent context.
64
+
65
+ Args:
66
+ run_id: Unique execution identifier
67
+ agent_name: Name of the agent
68
+ session_id: Session identifier for conversation history (default: run_id)
69
+ state_manager: Optional state manager (for context inheritance)
70
+ parent_context: Parent context to inherit state from
71
+ attempt: Retry attempt number
72
+ runtime_context: RuntimeContext for trace correlation
73
+ is_streaming: Whether this is a streaming request (for real-time SSE log delivery)
74
+ tenant_id: Tenant identifier for multi-tenant deployments
75
+ """
76
+ # Inherit is_streaming and tenant_id from parent context if not explicitly provided
77
+ if parent_context and not is_streaming:
78
+ is_streaming = getattr(parent_context, '_is_streaming', False)
79
+ if parent_context and not tenant_id:
80
+ tenant_id = getattr(parent_context, '_tenant_id', None)
81
+
82
+ # Initialize parent Context with memoization enabled by default for agents
83
+ # This ensures LLM and tool calls are automatically journaled for replay
84
+ super().__init__(
85
+ run_id=run_id,
86
+ attempt=attempt,
87
+ runtime_context=runtime_context,
88
+ is_streaming=is_streaming,
89
+ tenant_id=tenant_id,
90
+ session_id=session_id,
91
+ enable_memoization=True, # Agents get memoization by default
92
+ )
93
+
94
+ self._agent_name = agent_name
95
+ self._session_id = session_id or run_id
96
+ self.parent_context = parent_context # Store for context chain traversal
97
+
98
+ # Determine state adapter based on parent context
99
+ from ..entity import EntityStateAdapter, _get_state_adapter
100
+
101
+ if state_manager:
102
+ # Explicit state adapter provided (parameter name kept for backward compat)
103
+ self._state_adapter = state_manager
104
+ elif parent_context:
105
+ # Try to inherit state adapter from parent
106
+ try:
107
+ # Check if parent is WorkflowContext or AgentContext
108
+ if hasattr(parent_context, '_workflow_entity'):
109
+ # WorkflowContext - get state adapter from worker context
110
+ self._state_adapter = _get_state_adapter()
111
+ elif hasattr(parent_context, '_state_adapter'):
112
+ # Parent AgentContext - share state adapter
113
+ self._state_adapter = parent_context._state_adapter
114
+ elif hasattr(parent_context, '_state_manager'):
115
+ # Backward compatibility: parent has old _state_manager
116
+ self._state_adapter = parent_context._state_manager
117
+ else:
118
+ # FunctionContext or base Context - create new state adapter
119
+ self._state_adapter = EntityStateAdapter()
120
+ except RuntimeError:
121
+ # _get_state_adapter() failed (not in worker context) - create standalone
122
+ self._state_adapter = EntityStateAdapter()
123
+ else:
124
+ # Try to get from worker context first
125
+ try:
126
+ self._state_adapter = _get_state_adapter()
127
+ except RuntimeError:
128
+ # Standalone - create new state adapter
129
+ self._state_adapter = EntityStateAdapter()
130
+
131
+ # Conversation key for state storage (used for in-memory state)
132
+ self._conversation_key = f"agent:{agent_name}:{self._session_id}:messages"
133
+ # Entity key for database persistence (without :messages suffix to match API expectations)
134
+ self._entity_key = f"agent:{agent_name}:{self._session_id}"
135
+
136
+ # Determine storage mode: "workflow" if parent is WorkflowContext, else "standalone"
137
+ self._storage_mode = "standalone" # Default mode
138
+ self._workflow_entity = None
139
+
140
+ if parent_context and hasattr(parent_context, '_workflow_entity'):
141
+ # Agent is running within a workflow - store conversation in workflow state
142
+ self._storage_mode = "workflow"
143
+ self._workflow_entity = parent_context._workflow_entity
144
+ logger.debug(
145
+ f"Agent '{agent_name}' using workflow storage mode "
146
+ f"(workflow entity: {self._workflow_entity.key})"
147
+ )
148
+
149
+ @property
150
+ def state(self):
151
+ """
152
+ Get state interface for agent state management.
153
+
154
+ Note: This is a simplified in-memory state interface for agent-specific data.
155
+ Conversation history is managed separately via get_conversation_history() and
156
+ save_conversation_history() which use the Rust-backed persistence layer.
157
+
158
+ Returns:
159
+ Dict-like object for state operations
160
+
161
+ Example:
162
+ # Store agent-specific data (in-memory only)
163
+ ctx.state["research_results"] = data
164
+ ctx.state["iteration_count"] = 5
165
+ """
166
+ # Simple dict-based state for agent-specific data
167
+ # This is in-memory only and not persisted to platform
168
+ if not hasattr(self, '_agent_state'):
169
+ self._agent_state = {}
170
+ return self._agent_state
171
+
172
+ @property
173
+ def session_id(self) -> str:
174
+ """Get session identifier for this agent context."""
175
+ return self._session_id
176
+
177
+ async def get_conversation_history(self) -> List[Message]:
178
+ """
179
+ Retrieve conversation history, preferring runs-based history from the platform.
180
+
181
+ Load order (as of Phase 5.2 - runs-first architecture):
182
+ 1. For workflow mode: Load from workflow entity state (shared state)
183
+ 2. For standalone mode:
184
+ a. Try loading from runs via gateway API (/v1/sessions/{id}/history)
185
+ b. Fall back to entity storage for legacy sessions (with deprecation warning)
186
+
187
+ Returns:
188
+ List of Message objects from conversation history
189
+ """
190
+ if self._storage_mode == "workflow":
191
+ return await self._load_from_workflow_state()
192
+ else:
193
+ # Try runs-based API first (Phase 5.2 architecture)
194
+ messages = await self._load_from_runs_api()
195
+ if messages:
196
+ return messages
197
+
198
+ # Fall back to entity storage for legacy sessions
199
+ legacy_messages = await self._load_from_entity_storage()
200
+ if legacy_messages:
201
+ warnings.warn(
202
+ "Loading conversation history from entity storage is deprecated. "
203
+ "New sessions use runs-based history. Consider migrating this session.",
204
+ DeprecationWarning,
205
+ stacklevel=2
206
+ )
207
+ return legacy_messages
208
+
209
+ async def _load_from_workflow_state(self) -> List[Message]:
210
+ """Load conversation history from workflow entity state."""
211
+ key = f"agent.{self._agent_name}"
212
+ agent_data = self._workflow_entity.state.get(key, {})
213
+ messages_data = agent_data.get("messages", [])
214
+
215
+ # Convert dict representations back to Message objects
216
+ return self._convert_dicts_to_messages(messages_data)
217
+
218
+ async def _load_from_runs_api(self) -> List[Message]:
219
+ """
220
+ Load conversation history from runs via gateway API.
221
+
222
+ This is the new Phase 5.2 architecture where conversation history
223
+ is derived from runs (each run = one conversation turn) rather than
224
+ stored in entity state.
225
+
226
+ Returns:
227
+ List of Message objects, or empty list if no runs found or API fails
228
+ """
229
+ import httpx
230
+
231
+ gateway_url = os.environ.get("AGNT5_GATEWAY_URL", DEFAULT_GATEWAY_URL)
232
+ tenant_id = self._tenant_id
233
+
234
+ try:
235
+ async with httpx.AsyncClient(timeout=30.0) as client:
236
+ url = f"{gateway_url}/v1/sessions/{self._session_id}/history"
237
+ headers = {}
238
+ if tenant_id:
239
+ headers["X-TENANT-ID"] = tenant_id
240
+
241
+ response = await client.get(url, headers=headers)
242
+
243
+ if response.status_code == 404:
244
+ # Session not found - this might be a new session or legacy session
245
+ logger.debug(f"Session {self._session_id} not found in runs API")
246
+ return []
247
+
248
+ if response.status_code != 200:
249
+ logger.warning(
250
+ f"Failed to load session history from runs API: "
251
+ f"status={response.status_code}"
252
+ )
253
+ return []
254
+
255
+ data = response.json()
256
+ messages_data = data.get("messages", [])
257
+
258
+ if not messages_data:
259
+ return []
260
+
261
+ # Convert API response to Message objects
262
+ messages = []
263
+ for msg in messages_data:
264
+ role = msg.get("role", "user")
265
+ content = msg.get("content", "")
266
+
267
+ # Content might be JSON-encoded if it was stored as structured data
268
+ if isinstance(content, dict):
269
+ # Extract text content if it's a structured message
270
+ content = content.get("text", content.get("message", str(content)))
271
+
272
+ if role == "user":
273
+ messages.append(Message.user(content))
274
+ elif role == "assistant":
275
+ messages.append(Message.assistant(content))
276
+ else:
277
+ # Handle other roles (system, etc.)
278
+ from ..lm import MessageRole
279
+ msg_role = MessageRole(role) if role in ("user", "assistant", "system") else MessageRole.USER
280
+ messages.append(Message(role=msg_role, content=content))
281
+
282
+ logger.debug(
283
+ f"Loaded {len(messages)} messages from runs API for session {self._session_id}"
284
+ )
285
+ return messages
286
+
287
+ except httpx.HTTPError as e:
288
+ logger.debug(f"HTTP error loading from runs API: {e}")
289
+ return []
290
+ except Exception as e:
291
+ logger.debug(f"Error loading from runs API: {e}")
292
+ return []
293
+
294
+ async def _load_from_entity_storage(self) -> List[Message]:
295
+ """Load conversation history from AgentSession entity (standalone mode)."""
296
+ entity_type = "AgentSession"
297
+ entity_key = self._entity_key
298
+
299
+ # Load session data via adapter (Rust handles cache + platform load)
300
+ # Use session scope with session_id for proper entity isolation
301
+ session_data = await self._state_adapter.load_state(
302
+ entity_type,
303
+ entity_key,
304
+ scope="session",
305
+ scope_id=self._session_id,
306
+ )
307
+
308
+ # Extract messages from session object
309
+ if isinstance(session_data, dict) and "messages" in session_data:
310
+ # New format with session metadata
311
+ messages_data = session_data["messages"]
312
+ elif isinstance(session_data, list):
313
+ # Old format - just messages array
314
+ messages_data = session_data
315
+ else:
316
+ # No messages found
317
+ messages_data = []
318
+
319
+ # Convert dict representations back to Message objects
320
+ return self._convert_dicts_to_messages(messages_data)
321
+
322
+ def _convert_dicts_to_messages(self, messages_data: list) -> List[Message]:
323
+ """Convert list of message dicts to Message objects."""
324
+ from ..lm import MessageRole
325
+
326
+ messages = []
327
+ for msg_dict in messages_data:
328
+ if isinstance(msg_dict, dict):
329
+ role = msg_dict.get("role", "user")
330
+ content = msg_dict.get("content", "")
331
+ if role == "user":
332
+ messages.append(Message.user(content))
333
+ elif role == "assistant":
334
+ messages.append(Message.assistant(content))
335
+ else:
336
+ # Generic message - create with MessageRole enum
337
+ msg_role = (
338
+ MessageRole(role)
339
+ if role in ("user", "assistant", "system")
340
+ else MessageRole.USER
341
+ )
342
+ msg = Message(role=msg_role, content=content)
343
+ messages.append(msg)
344
+ else:
345
+ # Already a Message object
346
+ messages.append(msg_dict)
347
+
348
+ return messages
349
+
350
+ async def save_conversation_history(self, messages: List[Message]) -> None:
351
+ """
352
+ Save conversation history to state and persist to database.
353
+
354
+ Uses the EntityStateAdapter which delegates to Rust core for version-checked saves.
355
+ If running within a workflow, saves to workflow entity state instead.
356
+
357
+ Args:
358
+ messages: List of Message objects to persist
359
+ """
360
+ if self._storage_mode == "workflow":
361
+ await self._save_to_workflow_state(messages)
362
+ else:
363
+ await self._save_to_entity_storage(messages)
364
+
365
+ async def _save_to_workflow_state(self, messages: List[Message]) -> None:
366
+ """Save conversation history to workflow entity state."""
367
+ # Convert Message objects to dict for JSON serialization
368
+ messages_data = []
369
+ for msg in messages:
370
+ messages_data.append({
371
+ "role": msg.role.value if hasattr(msg.role, 'value') else str(msg.role),
372
+ "content": msg.content,
373
+ "timestamp": time.time()
374
+ })
375
+
376
+ # Build agent data structure
377
+ key = f"agent.{self._agent_name}"
378
+ current_data = self._workflow_entity.state.get(key, {})
379
+ now = time.time()
380
+
381
+ agent_data = {
382
+ "session_id": self._session_id,
383
+ "agent_name": self._agent_name,
384
+ "created_at": current_data.get("created_at", now),
385
+ "last_message_time": now,
386
+ "message_count": len(messages_data),
387
+ "messages": messages_data,
388
+ "metadata": getattr(self, '_custom_metadata', {})
389
+ }
390
+
391
+ # Store in workflow state (WorkflowEntity handles persistence)
392
+ self._workflow_entity.state.set(key, agent_data)
393
+ logger.info(f"Saved conversation to workflow state: {key} ({len(messages_data)} messages)")
394
+
395
+ async def _save_to_entity_storage(self, messages: List[Message]) -> None:
396
+ """
397
+ Save conversation history to AgentSession entity (standalone mode).
398
+
399
+ DEPRECATED: This method saves to entity storage which is the legacy approach.
400
+ In the Phase 5.2 architecture, conversation history is derived from runs
401
+ (each run = one turn). New conversations should not need to call this
402
+ as the platform automatically records run inputs/outputs.
403
+ """
404
+ warnings.warn(
405
+ "Saving conversation history to entity storage is deprecated. "
406
+ "In the new architecture, conversation history is derived from runs. "
407
+ "This method will be removed in a future version.",
408
+ DeprecationWarning,
409
+ stacklevel=3
410
+ )
411
+
412
+ # Convert Message objects to dict for JSON serialization
413
+ messages_data = []
414
+ for msg in messages:
415
+ messages_data.append({
416
+ "role": msg.role.value if hasattr(msg.role, 'value') else str(msg.role),
417
+ "content": msg.content,
418
+ "timestamp": time.time() # Add timestamp for each message
419
+ })
420
+
421
+ entity_type = "AgentSession"
422
+ entity_key = self._entity_key
423
+
424
+ # Load current state with version for optimistic locking
425
+ # Use session scope with session_id for proper entity isolation
426
+ current_state, current_version = await self._state_adapter.load_with_version(
427
+ entity_type,
428
+ entity_key,
429
+ scope="session",
430
+ scope_id=self._session_id,
431
+ )
432
+
433
+ # Build session object with metadata
434
+ now = time.time()
435
+
436
+ # Get custom metadata from instance variable or preserve from loaded state
437
+ custom_metadata = getattr(self, '_custom_metadata', current_state.get("metadata", {}))
438
+
439
+ session_data = {
440
+ "session_id": self._session_id,
441
+ "agent_name": self._agent_name,
442
+ "created_at": current_state.get("created_at", now), # Preserve existing or set new
443
+ "last_message_time": now,
444
+ "message_count": len(messages_data),
445
+ "messages": messages_data,
446
+ "metadata": custom_metadata # Save custom metadata
447
+ }
448
+
449
+ # Save to platform via adapter (Rust handles optimistic locking)
450
+ # Use session scope with session_id for proper entity isolation
451
+ try:
452
+ new_version = await self._state_adapter.save_state(
453
+ entity_type,
454
+ entity_key,
455
+ session_data,
456
+ current_version,
457
+ scope="session",
458
+ scope_id=self._session_id,
459
+ )
460
+ logger.info(
461
+ f"Persisted conversation history: {entity_key} "
462
+ f"(version {current_version} -> {new_version})"
463
+ )
464
+ except Exception as e:
465
+ logger.error(f"Failed to persist conversation history to database: {e}")
466
+ # Don't fail - conversation is still in memory for this execution
467
+
468
+ async def get_metadata(self) -> Dict[str, Any]:
469
+ """
470
+ Get conversation session metadata.
471
+
472
+ Returns session metadata including:
473
+ - created_at: Timestamp of first message (float, Unix timestamp)
474
+ - last_activity: Timestamp of last message (float, Unix timestamp)
475
+ - message_count: Number of messages in conversation (int)
476
+ - custom: Dict of user-provided custom metadata
477
+
478
+ Returns:
479
+ Dictionary with metadata. If no conversation exists yet, returns defaults.
480
+
481
+ Example:
482
+ ```python
483
+ metadata = await context.get_metadata()
484
+ print(f"Session created: {metadata['created_at']}")
485
+ print(f"User ID: {metadata['custom'].get('user_id')}")
486
+ ```
487
+ """
488
+ if self._storage_mode == "workflow":
489
+ return await self._get_metadata_from_workflow()
490
+ else:
491
+ return await self._get_metadata_from_entity()
492
+
493
+ async def _get_metadata_from_workflow(self) -> Dict[str, Any]:
494
+ """Get metadata from workflow entity state."""
495
+ key = f"agent.{self._agent_name}"
496
+ agent_data = self._workflow_entity.state.get(key, {})
497
+
498
+ if not agent_data:
499
+ # No conversation exists yet - return defaults
500
+ return {
501
+ "created_at": None,
502
+ "last_activity": None,
503
+ "message_count": 0,
504
+ "custom": getattr(self, '_custom_metadata', {})
505
+ }
506
+
507
+ messages = agent_data.get("messages", [])
508
+ return {
509
+ "created_at": agent_data.get("created_at"),
510
+ "last_activity": agent_data.get("last_message_time"),
511
+ "message_count": len(messages),
512
+ "custom": agent_data.get("metadata", {})
513
+ }
514
+
515
+ async def _get_metadata_from_entity(self) -> Dict[str, Any]:
516
+ """Get metadata from AgentSession entity (standalone mode)."""
517
+ entity_type = "AgentSession"
518
+ entity_key = self._entity_key
519
+
520
+ # Load session data with session scope
521
+ session_data = await self._state_adapter.load_state(
522
+ entity_type,
523
+ entity_key,
524
+ scope="session",
525
+ scope_id=self._session_id,
526
+ )
527
+
528
+ if not session_data:
529
+ # No conversation exists yet - return defaults
530
+ return {
531
+ "created_at": None,
532
+ "last_activity": None,
533
+ "message_count": 0,
534
+ "custom": getattr(self, '_custom_metadata', {})
535
+ }
536
+
537
+ messages = session_data.get("messages", [])
538
+
539
+ # Derive timestamps from messages if available
540
+ created_at = session_data.get("created_at")
541
+ last_activity = session_data.get("last_message_time")
542
+
543
+ return {
544
+ "created_at": created_at,
545
+ "last_activity": last_activity,
546
+ "message_count": len(messages),
547
+ "custom": session_data.get("metadata", {})
548
+ }
549
+
550
+ def update_metadata(self, **kwargs) -> None:
551
+ """
552
+ Update custom session metadata.
553
+
554
+ Metadata will be persisted alongside conversation history on next save.
555
+ Use this to store application-specific data like user_id, preferences, etc.
556
+
557
+ Args:
558
+ **kwargs: Key-value pairs to store as metadata
559
+
560
+ Example:
561
+ ```python
562
+ # Store user identification and preferences
563
+ context.update_metadata(
564
+ user_id="user-123",
565
+ subscription_tier="premium",
566
+ preferences={"theme": "dark", "language": "en"}
567
+ )
568
+
569
+ # Later retrieve it
570
+ metadata = await context.get_metadata()
571
+ user_id = metadata["custom"]["user_id"]
572
+ ```
573
+
574
+ Note:
575
+ - Metadata is merged with existing metadata (doesn't replace)
576
+ - Changes persist on next save_conversation_history() call
577
+ - Use simple JSON-serializable types (str, int, float, dict, list)
578
+ """
579
+ if not hasattr(self, '_custom_metadata'):
580
+ self._custom_metadata = {}
581
+ self._custom_metadata.update(kwargs)