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/__init__.py +119 -0
- agnt5/_compat.py +16 -0
- agnt5/_core.abi3.so +0 -0
- agnt5/_retry_utils.py +196 -0
- agnt5/_schema_utils.py +312 -0
- agnt5/_sentry.py +515 -0
- agnt5/_telemetry.py +279 -0
- agnt5/agent/__init__.py +48 -0
- agnt5/agent/context.py +581 -0
- agnt5/agent/core.py +1782 -0
- agnt5/agent/decorator.py +112 -0
- agnt5/agent/handoff.py +105 -0
- agnt5/agent/registry.py +68 -0
- agnt5/agent/result.py +39 -0
- agnt5/checkpoint.py +246 -0
- agnt5/client.py +1556 -0
- agnt5/context.py +288 -0
- agnt5/emit.py +197 -0
- agnt5/entity.py +1230 -0
- agnt5/events.py +567 -0
- agnt5/exceptions.py +110 -0
- agnt5/function.py +330 -0
- agnt5/journal.py +212 -0
- agnt5/lm.py +1266 -0
- agnt5/memoization.py +379 -0
- agnt5/memory.py +521 -0
- agnt5/tool.py +721 -0
- agnt5/tracing.py +300 -0
- agnt5/types.py +111 -0
- agnt5/version.py +19 -0
- agnt5/worker.py +2094 -0
- agnt5/workflow.py +1632 -0
- agnt5-0.3.2a1.dist-info/METADATA +26 -0
- agnt5-0.3.2a1.dist-info/RECORD +35 -0
- agnt5-0.3.2a1.dist-info/WHEEL +4 -0
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)
|