agent-runtime-core 0.7.1__py3-none-any.whl → 0.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_runtime_core/__init__.py +65 -3
- agent_runtime_core/agentic_loop.py +275 -15
- agent_runtime_core/config.py +4 -0
- agent_runtime_core/contexts.py +72 -4
- agent_runtime_core/files/__init__.py +88 -0
- agent_runtime_core/files/base.py +343 -0
- agent_runtime_core/files/ocr.py +406 -0
- agent_runtime_core/files/processors.py +508 -0
- agent_runtime_core/files/tools.py +317 -0
- agent_runtime_core/files/vision.py +360 -0
- agent_runtime_core/llm/anthropic.py +83 -0
- agent_runtime_core/multi_agent.py +1408 -16
- agent_runtime_core/persistence/__init__.py +8 -0
- agent_runtime_core/persistence/base.py +318 -1
- agent_runtime_core/persistence/file.py +226 -2
- agent_runtime_core/privacy.py +250 -0
- {agent_runtime_core-0.7.1.dist-info → agent_runtime_core-0.9.0.dist-info}/METADATA +36 -1
- {agent_runtime_core-0.7.1.dist-info → agent_runtime_core-0.9.0.dist-info}/RECORD +20 -13
- {agent_runtime_core-0.7.1.dist-info → agent_runtime_core-0.9.0.dist-info}/WHEEL +0 -0
- {agent_runtime_core-0.7.1.dist-info → agent_runtime_core-0.9.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Multi-agent support for agent_runtime_core.
|
|
3
3
|
|
|
4
|
-
This module provides
|
|
5
|
-
|
|
6
|
-
-
|
|
7
|
-
|
|
8
|
-
|
|
4
|
+
This module provides:
|
|
5
|
+
|
|
6
|
+
1. **SystemContext** - Shared knowledge and configuration for multi-agent systems.
|
|
7
|
+
Allows defining identity, rules, and knowledge that all agents in a system share.
|
|
8
|
+
|
|
9
|
+
2. **Agent-as-tool pattern** - Allowing agents to invoke other agents as tools.
|
|
10
|
+
This enables router/dispatcher patterns, hierarchical systems, and specialist delegation.
|
|
11
|
+
|
|
12
|
+
3. **Structured Handback Protocol** - Consistent way for agents to signal completion
|
|
13
|
+
with status, summary, learnings, and recommendations.
|
|
14
|
+
|
|
15
|
+
4. **Journey Mode** - Allow delegated agents to maintain control across multiple
|
|
16
|
+
user turns without routing back to the entry point each time.
|
|
17
|
+
|
|
18
|
+
5. **Stuck/Loop Detection** - Detect when conversations are going in circles
|
|
19
|
+
and trigger escalation or alternative approaches.
|
|
20
|
+
|
|
21
|
+
6. **Fallback Routing** - Automatic return to a fallback agent when agents fail.
|
|
9
22
|
|
|
10
23
|
Two invocation modes are supported:
|
|
11
24
|
- DELEGATE: Sub-agent runs and returns result to parent (parent continues)
|
|
@@ -16,14 +29,43 @@ Context passing is configurable:
|
|
|
16
29
|
- SUMMARY: Summarized context + current message
|
|
17
30
|
- MESSAGE_ONLY: Only the invocation message
|
|
18
31
|
|
|
19
|
-
Example:
|
|
32
|
+
Example - SystemContext:
|
|
33
|
+
from agent_runtime_core.multi_agent import SystemContext, SharedKnowledge
|
|
34
|
+
|
|
35
|
+
# Define shared knowledge for a multi-agent system
|
|
36
|
+
system_ctx = SystemContext(
|
|
37
|
+
system_id="sai-system",
|
|
38
|
+
system_name="S'Ai Therapeutic System",
|
|
39
|
+
shared_knowledge=[
|
|
40
|
+
SharedKnowledge(
|
|
41
|
+
key="core_identity",
|
|
42
|
+
title="S'Ai Core Identity",
|
|
43
|
+
content="You are S'Ai, a therapeutic AI assistant...",
|
|
44
|
+
inject_as="system", # Prepend to system prompt
|
|
45
|
+
),
|
|
46
|
+
SharedKnowledge(
|
|
47
|
+
key="core_rules",
|
|
48
|
+
title="Core Rules",
|
|
49
|
+
content="1. Never diagnose...",
|
|
50
|
+
inject_as="system",
|
|
51
|
+
),
|
|
52
|
+
],
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# Use with a run context
|
|
56
|
+
ctx = InMemoryRunContext(
|
|
57
|
+
run_id=uuid4(),
|
|
58
|
+
system_context=system_ctx,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
Example - Agent-as-tool:
|
|
20
62
|
from agent_runtime_core.multi_agent import (
|
|
21
63
|
AgentTool,
|
|
22
64
|
InvocationMode,
|
|
23
65
|
ContextMode,
|
|
24
66
|
invoke_agent,
|
|
25
67
|
)
|
|
26
|
-
|
|
68
|
+
|
|
27
69
|
# Define a sub-agent as a tool
|
|
28
70
|
billing_agent_tool = AgentTool(
|
|
29
71
|
agent=billing_agent,
|
|
@@ -32,7 +74,7 @@ Example:
|
|
|
32
74
|
invocation_mode=InvocationMode.DELEGATE,
|
|
33
75
|
context_mode=ContextMode.FULL,
|
|
34
76
|
)
|
|
35
|
-
|
|
77
|
+
|
|
36
78
|
# Invoke it
|
|
37
79
|
result = await invoke_agent(
|
|
38
80
|
agent_tool=billing_agent_tool,
|
|
@@ -40,12 +82,43 @@ Example:
|
|
|
40
82
|
parent_ctx=ctx,
|
|
41
83
|
conversation_history=messages,
|
|
42
84
|
)
|
|
85
|
+
|
|
86
|
+
Example - Structured Handback:
|
|
87
|
+
from agent_runtime_core.multi_agent import HandbackResult, HandbackStatus
|
|
88
|
+
|
|
89
|
+
# Agent signals completion with structured handback
|
|
90
|
+
handback = HandbackResult(
|
|
91
|
+
status=HandbackStatus.COMPLETED,
|
|
92
|
+
summary="Processed refund for order #123",
|
|
93
|
+
learnings=[
|
|
94
|
+
{"key": "user.refund_history", "value": "Has requested 2 refunds"},
|
|
95
|
+
],
|
|
96
|
+
recommendation="Consider offering loyalty discount",
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
Example - Journey Mode:
|
|
100
|
+
from agent_runtime_core.multi_agent import JourneyState, JourneyManager
|
|
101
|
+
|
|
102
|
+
# Start a journey (agent maintains control across turns)
|
|
103
|
+
journey = JourneyState(
|
|
104
|
+
agent_key="onboarding_agent",
|
|
105
|
+
started_at=datetime.utcnow(),
|
|
106
|
+
purpose="Complete user onboarding",
|
|
107
|
+
expected_turns=5,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# Journey manager routes subsequent messages to the journey agent
|
|
111
|
+
manager = JourneyManager(memory_store=store)
|
|
112
|
+
await manager.start_journey(conversation_id, journey)
|
|
43
113
|
"""
|
|
44
114
|
|
|
115
|
+
import json
|
|
45
116
|
import logging
|
|
117
|
+
import re
|
|
46
118
|
from dataclasses import dataclass, field
|
|
119
|
+
from datetime import datetime
|
|
47
120
|
from enum import Enum
|
|
48
|
-
from typing import Any, Callable, Optional, Protocol, TYPE_CHECKING
|
|
121
|
+
from typing import Any, Callable, Literal, Optional, Protocol, TYPE_CHECKING
|
|
49
122
|
from uuid import UUID, uuid4
|
|
50
123
|
|
|
51
124
|
from agent_runtime_core.interfaces import (
|
|
@@ -60,10 +133,298 @@ from agent_runtime_core.interfaces import (
|
|
|
60
133
|
|
|
61
134
|
if TYPE_CHECKING:
|
|
62
135
|
from agent_runtime_core.contexts import InMemoryRunContext
|
|
136
|
+
from agent_runtime_core.persistence import SharedMemoryStore
|
|
63
137
|
|
|
64
138
|
logger = logging.getLogger(__name__)
|
|
65
139
|
|
|
66
140
|
|
|
141
|
+
# =============================================================================
|
|
142
|
+
# System Context - Shared knowledge for multi-agent systems
|
|
143
|
+
# =============================================================================
|
|
144
|
+
|
|
145
|
+
class InjectMode(str, Enum):
|
|
146
|
+
"""
|
|
147
|
+
How shared knowledge is injected into an agent's context.
|
|
148
|
+
|
|
149
|
+
SYSTEM: Prepended to the system prompt. Best for identity, rules, and
|
|
150
|
+
instructions that should always be present.
|
|
151
|
+
|
|
152
|
+
CONTEXT: Added as a separate context message before the conversation.
|
|
153
|
+
Good for reference material the agent can cite.
|
|
154
|
+
|
|
155
|
+
KNOWLEDGE: Added to the agent's knowledge base for RAG retrieval.
|
|
156
|
+
Best for large documents that should be searched.
|
|
157
|
+
"""
|
|
158
|
+
SYSTEM = "system"
|
|
159
|
+
CONTEXT = "context"
|
|
160
|
+
KNOWLEDGE = "knowledge"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@dataclass
|
|
164
|
+
class SharedKnowledge:
|
|
165
|
+
"""
|
|
166
|
+
A piece of shared knowledge that applies to all agents in a system.
|
|
167
|
+
|
|
168
|
+
Shared knowledge allows defining content once and having it automatically
|
|
169
|
+
available to all agents in a multi-agent system. This is useful for:
|
|
170
|
+
- Shared identity (who the system is, how to speak)
|
|
171
|
+
- Core rules (non-negotiable principles)
|
|
172
|
+
- Common context (company info, product details)
|
|
173
|
+
- Relational guidelines (how to interact with users)
|
|
174
|
+
|
|
175
|
+
Attributes:
|
|
176
|
+
key: Unique identifier for this knowledge item
|
|
177
|
+
title: Human-readable title
|
|
178
|
+
content: The actual knowledge content (markdown supported)
|
|
179
|
+
inject_as: How to inject this into agents (system, context, knowledge)
|
|
180
|
+
priority: Order priority (lower = injected first). Default 0.
|
|
181
|
+
enabled: Whether this knowledge is active
|
|
182
|
+
metadata: Additional metadata
|
|
183
|
+
|
|
184
|
+
Example:
|
|
185
|
+
SharedKnowledge(
|
|
186
|
+
key="core_identity",
|
|
187
|
+
title="S'Ai Core Identity",
|
|
188
|
+
content='''
|
|
189
|
+
# Identity
|
|
190
|
+
You are S'Ai, a therapeutic AI assistant. You speak as one unified
|
|
191
|
+
entity, even though you are composed of multiple specialist agents.
|
|
192
|
+
|
|
193
|
+
# Voice
|
|
194
|
+
- Warm but professional
|
|
195
|
+
- Curious and exploratory
|
|
196
|
+
- Never prescriptive or diagnostic
|
|
197
|
+
''',
|
|
198
|
+
inject_as=InjectMode.SYSTEM,
|
|
199
|
+
priority=0, # Injected first
|
|
200
|
+
)
|
|
201
|
+
"""
|
|
202
|
+
key: str
|
|
203
|
+
title: str
|
|
204
|
+
content: str
|
|
205
|
+
inject_as: InjectMode = InjectMode.SYSTEM
|
|
206
|
+
priority: int = 0
|
|
207
|
+
enabled: bool = True
|
|
208
|
+
metadata: dict = field(default_factory=dict)
|
|
209
|
+
|
|
210
|
+
def to_dict(self) -> dict:
|
|
211
|
+
"""Serialize to dictionary."""
|
|
212
|
+
return {
|
|
213
|
+
"key": self.key,
|
|
214
|
+
"title": self.title,
|
|
215
|
+
"content": self.content,
|
|
216
|
+
"inject_as": self.inject_as.value,
|
|
217
|
+
"priority": self.priority,
|
|
218
|
+
"enabled": self.enabled,
|
|
219
|
+
"metadata": self.metadata,
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
@classmethod
|
|
223
|
+
def from_dict(cls, data: dict) -> "SharedKnowledge":
|
|
224
|
+
"""Deserialize from dictionary."""
|
|
225
|
+
return cls(
|
|
226
|
+
key=data["key"],
|
|
227
|
+
title=data["title"],
|
|
228
|
+
content=data["content"],
|
|
229
|
+
inject_as=InjectMode(data.get("inject_as", "system")),
|
|
230
|
+
priority=data.get("priority", 0),
|
|
231
|
+
enabled=data.get("enabled", True),
|
|
232
|
+
metadata=data.get("metadata", {}),
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@dataclass
|
|
237
|
+
class SharedMemoryConfig:
|
|
238
|
+
"""
|
|
239
|
+
Configuration for shared memory within a multi-agent system.
|
|
240
|
+
|
|
241
|
+
Shared memory allows agents in a system to share learned information
|
|
242
|
+
about users. This is useful for:
|
|
243
|
+
- Cross-agent knowledge sharing (what one agent learns, others can access)
|
|
244
|
+
- User preferences that apply across all agents
|
|
245
|
+
- Accumulated context about the user
|
|
246
|
+
|
|
247
|
+
Attributes:
|
|
248
|
+
enabled: Whether shared memory is enabled for this system. Default: True.
|
|
249
|
+
require_auth: Whether authentication is required for shared memory.
|
|
250
|
+
Should always be True for privacy. Default: True.
|
|
251
|
+
retention_days: How long to retain shared memories. None = forever.
|
|
252
|
+
max_memories_per_user: Maximum memories to store per user. None = unlimited.
|
|
253
|
+
metadata: Additional configuration metadata.
|
|
254
|
+
|
|
255
|
+
Example:
|
|
256
|
+
config = SharedMemoryConfig(
|
|
257
|
+
enabled=True,
|
|
258
|
+
require_auth=True,
|
|
259
|
+
retention_days=365,
|
|
260
|
+
max_memories_per_user=1000,
|
|
261
|
+
)
|
|
262
|
+
"""
|
|
263
|
+
enabled: bool = True
|
|
264
|
+
require_auth: bool = True
|
|
265
|
+
retention_days: Optional[int] = None
|
|
266
|
+
max_memories_per_user: Optional[int] = None
|
|
267
|
+
metadata: dict = field(default_factory=dict)
|
|
268
|
+
|
|
269
|
+
def to_dict(self) -> dict:
|
|
270
|
+
"""Serialize to dictionary."""
|
|
271
|
+
return {
|
|
272
|
+
"enabled": self.enabled,
|
|
273
|
+
"require_auth": self.require_auth,
|
|
274
|
+
"retention_days": self.retention_days,
|
|
275
|
+
"max_memories_per_user": self.max_memories_per_user,
|
|
276
|
+
"metadata": self.metadata,
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
@classmethod
|
|
280
|
+
def from_dict(cls, data: dict) -> "SharedMemoryConfig":
|
|
281
|
+
"""Deserialize from dictionary."""
|
|
282
|
+
return cls(
|
|
283
|
+
enabled=data.get("enabled", True),
|
|
284
|
+
require_auth=data.get("require_auth", True),
|
|
285
|
+
retention_days=data.get("retention_days"),
|
|
286
|
+
max_memories_per_user=data.get("max_memories_per_user"),
|
|
287
|
+
metadata=data.get("metadata", {}),
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@dataclass
|
|
292
|
+
class SystemContext:
|
|
293
|
+
"""
|
|
294
|
+
Shared context for a multi-agent system.
|
|
295
|
+
|
|
296
|
+
SystemContext holds configuration and knowledge that applies to all agents
|
|
297
|
+
within a system. When an agent runs with a SystemContext, the shared
|
|
298
|
+
knowledge is automatically injected based on each item's inject_as mode.
|
|
299
|
+
|
|
300
|
+
This enables the "single source of truth" pattern where identity, rules,
|
|
301
|
+
and common knowledge are defined once and shared across all agents.
|
|
302
|
+
|
|
303
|
+
Attributes:
|
|
304
|
+
system_id: Unique identifier for the system
|
|
305
|
+
system_name: Human-readable name
|
|
306
|
+
shared_knowledge: List of SharedKnowledge items
|
|
307
|
+
shared_memory_config: Configuration for cross-agent shared memory
|
|
308
|
+
metadata: Additional system metadata
|
|
309
|
+
|
|
310
|
+
Example:
|
|
311
|
+
system_ctx = SystemContext(
|
|
312
|
+
system_id="sai-therapeutic",
|
|
313
|
+
system_name="S'Ai Therapeutic System",
|
|
314
|
+
shared_knowledge=[
|
|
315
|
+
SharedKnowledge(
|
|
316
|
+
key="identity",
|
|
317
|
+
title="Core Identity",
|
|
318
|
+
content="You are S'Ai...",
|
|
319
|
+
inject_as=InjectMode.SYSTEM,
|
|
320
|
+
),
|
|
321
|
+
SharedKnowledge(
|
|
322
|
+
key="rules",
|
|
323
|
+
title="Core Rules",
|
|
324
|
+
content="1. Never diagnose...",
|
|
325
|
+
inject_as=InjectMode.SYSTEM,
|
|
326
|
+
),
|
|
327
|
+
],
|
|
328
|
+
shared_memory_config=SharedMemoryConfig(
|
|
329
|
+
enabled=True,
|
|
330
|
+
require_auth=True,
|
|
331
|
+
),
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
# Get content to prepend to system prompt
|
|
335
|
+
system_prefix = system_ctx.get_system_prompt_prefix()
|
|
336
|
+
"""
|
|
337
|
+
system_id: str
|
|
338
|
+
system_name: str
|
|
339
|
+
shared_knowledge: list[SharedKnowledge] = field(default_factory=list)
|
|
340
|
+
shared_memory_config: SharedMemoryConfig = field(default_factory=SharedMemoryConfig)
|
|
341
|
+
metadata: dict = field(default_factory=dict)
|
|
342
|
+
|
|
343
|
+
def get_system_prompt_prefix(self) -> str:
|
|
344
|
+
"""
|
|
345
|
+
Get the content to prepend to an agent's system prompt.
|
|
346
|
+
|
|
347
|
+
Returns all enabled SharedKnowledge items with inject_as=SYSTEM,
|
|
348
|
+
sorted by priority, formatted as a single string.
|
|
349
|
+
"""
|
|
350
|
+
items = [
|
|
351
|
+
k for k in self.shared_knowledge
|
|
352
|
+
if k.enabled and k.inject_as == InjectMode.SYSTEM
|
|
353
|
+
]
|
|
354
|
+
items.sort(key=lambda x: x.priority)
|
|
355
|
+
|
|
356
|
+
if not items:
|
|
357
|
+
return ""
|
|
358
|
+
|
|
359
|
+
parts = []
|
|
360
|
+
for item in items:
|
|
361
|
+
# Add title as header if content doesn't start with one
|
|
362
|
+
if item.content.strip().startswith("#"):
|
|
363
|
+
parts.append(item.content.strip())
|
|
364
|
+
else:
|
|
365
|
+
parts.append(f"## {item.title}\n\n{item.content.strip()}")
|
|
366
|
+
|
|
367
|
+
return "\n\n---\n\n".join(parts)
|
|
368
|
+
|
|
369
|
+
def get_context_messages(self) -> list[Message]:
|
|
370
|
+
"""
|
|
371
|
+
Get messages to add as context before the conversation.
|
|
372
|
+
|
|
373
|
+
Returns all enabled SharedKnowledge items with inject_as=CONTEXT
|
|
374
|
+
as system messages.
|
|
375
|
+
"""
|
|
376
|
+
items = [
|
|
377
|
+
k for k in self.shared_knowledge
|
|
378
|
+
if k.enabled and k.inject_as == InjectMode.CONTEXT
|
|
379
|
+
]
|
|
380
|
+
items.sort(key=lambda x: x.priority)
|
|
381
|
+
|
|
382
|
+
return [
|
|
383
|
+
{"role": "system", "content": f"[{item.title}]\n\n{item.content}"}
|
|
384
|
+
for item in items
|
|
385
|
+
]
|
|
386
|
+
|
|
387
|
+
def get_knowledge_items(self) -> list[SharedKnowledge]:
|
|
388
|
+
"""
|
|
389
|
+
Get SharedKnowledge items that should be added to RAG.
|
|
390
|
+
|
|
391
|
+
Returns all enabled items with inject_as=KNOWLEDGE.
|
|
392
|
+
"""
|
|
393
|
+
return [
|
|
394
|
+
k for k in self.shared_knowledge
|
|
395
|
+
if k.enabled and k.inject_as == InjectMode.KNOWLEDGE
|
|
396
|
+
]
|
|
397
|
+
|
|
398
|
+
def to_dict(self) -> dict:
|
|
399
|
+
"""Serialize to dictionary."""
|
|
400
|
+
return {
|
|
401
|
+
"system_id": self.system_id,
|
|
402
|
+
"system_name": self.system_name,
|
|
403
|
+
"shared_knowledge": [k.to_dict() for k in self.shared_knowledge],
|
|
404
|
+
"shared_memory_config": self.shared_memory_config.to_dict(),
|
|
405
|
+
"metadata": self.metadata,
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
@classmethod
|
|
409
|
+
def from_dict(cls, data: dict) -> "SystemContext":
|
|
410
|
+
"""Deserialize from dictionary."""
|
|
411
|
+
shared_memory_data = data.get("shared_memory_config", {})
|
|
412
|
+
return cls(
|
|
413
|
+
system_id=data["system_id"],
|
|
414
|
+
system_name=data["system_name"],
|
|
415
|
+
shared_knowledge=[
|
|
416
|
+
SharedKnowledge.from_dict(k)
|
|
417
|
+
for k in data.get("shared_knowledge", [])
|
|
418
|
+
],
|
|
419
|
+
shared_memory_config=SharedMemoryConfig.from_dict(shared_memory_data),
|
|
420
|
+
metadata=data.get("metadata", {}),
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
# =============================================================================
|
|
425
|
+
# Invocation Modes and Context Modes
|
|
426
|
+
# =============================================================================
|
|
427
|
+
|
|
67
428
|
class InvocationMode(str, Enum):
|
|
68
429
|
"""
|
|
69
430
|
How the sub-agent is invoked.
|
|
@@ -82,14 +443,14 @@ class InvocationMode(str, Enum):
|
|
|
82
443
|
class ContextMode(str, Enum):
|
|
83
444
|
"""
|
|
84
445
|
What context is passed to the sub-agent.
|
|
85
|
-
|
|
446
|
+
|
|
86
447
|
FULL: Complete conversation history. Sub-agent sees everything the
|
|
87
448
|
parent has seen. Best for sensitive contexts where nothing
|
|
88
449
|
should be forgotten. This is the default.
|
|
89
|
-
|
|
450
|
+
|
|
90
451
|
SUMMARY: A summary of the conversation + the current message.
|
|
91
452
|
More efficient but may lose nuance.
|
|
92
|
-
|
|
453
|
+
|
|
93
454
|
MESSAGE_ONLY: Only the invocation message. Clean isolation but
|
|
94
455
|
sub-agent lacks context.
|
|
95
456
|
"""
|
|
@@ -98,6 +459,642 @@ class ContextMode(str, Enum):
|
|
|
98
459
|
MESSAGE_ONLY = "message_only"
|
|
99
460
|
|
|
100
461
|
|
|
462
|
+
# =============================================================================
|
|
463
|
+
# Structured Handback Protocol
|
|
464
|
+
# =============================================================================
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
class HandbackStatus(str, Enum):
|
|
468
|
+
"""
|
|
469
|
+
Status codes for structured handback from sub-agents.
|
|
470
|
+
|
|
471
|
+
COMPLETED: Task was successfully completed. The agent has done what
|
|
472
|
+
was asked and is ready to hand back control.
|
|
473
|
+
|
|
474
|
+
NEEDS_MORE_INFO: Agent needs additional information from the user
|
|
475
|
+
to complete the task. Includes what info is needed.
|
|
476
|
+
|
|
477
|
+
UNCERTAIN: Agent is unsure how to proceed. May need human review
|
|
478
|
+
or escalation to a different agent.
|
|
479
|
+
|
|
480
|
+
ESCALATE: Agent explicitly requests escalation to a supervisor
|
|
481
|
+
or different specialist. Includes reason for escalation.
|
|
482
|
+
|
|
483
|
+
PARTIAL: Task was partially completed. Some progress was made but
|
|
484
|
+
the full task couldn't be finished.
|
|
485
|
+
|
|
486
|
+
FAILED: Task failed and cannot be completed. Includes error details.
|
|
487
|
+
"""
|
|
488
|
+
COMPLETED = "completed"
|
|
489
|
+
NEEDS_MORE_INFO = "needs_more_info"
|
|
490
|
+
UNCERTAIN = "uncertain"
|
|
491
|
+
ESCALATE = "escalate"
|
|
492
|
+
PARTIAL = "partial"
|
|
493
|
+
FAILED = "failed"
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
@dataclass
|
|
497
|
+
class Learning:
|
|
498
|
+
"""
|
|
499
|
+
A piece of information learned during an agent's task.
|
|
500
|
+
|
|
501
|
+
Learnings are facts or insights discovered during task execution
|
|
502
|
+
that should be stored in memory for future reference.
|
|
503
|
+
|
|
504
|
+
Attributes:
|
|
505
|
+
key: Semantic key for the memory (e.g., "user.preferences.theme")
|
|
506
|
+
value: The learned value
|
|
507
|
+
scope: Memory scope - "conversation", "user", or "system"
|
|
508
|
+
confidence: How confident the agent is (0.0 to 1.0)
|
|
509
|
+
source: Where this learning came from
|
|
510
|
+
|
|
511
|
+
Example:
|
|
512
|
+
Learning(
|
|
513
|
+
key="user.communication_style",
|
|
514
|
+
value="Prefers concise responses",
|
|
515
|
+
scope="user",
|
|
516
|
+
confidence=0.8,
|
|
517
|
+
source="inferred from conversation",
|
|
518
|
+
)
|
|
519
|
+
"""
|
|
520
|
+
key: str
|
|
521
|
+
value: Any
|
|
522
|
+
scope: str = "user" # conversation, user, system
|
|
523
|
+
confidence: float = 1.0
|
|
524
|
+
source: str = "agent"
|
|
525
|
+
|
|
526
|
+
def to_dict(self) -> dict:
|
|
527
|
+
return {
|
|
528
|
+
"key": self.key,
|
|
529
|
+
"value": self.value,
|
|
530
|
+
"scope": self.scope,
|
|
531
|
+
"confidence": self.confidence,
|
|
532
|
+
"source": self.source,
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
@classmethod
|
|
536
|
+
def from_dict(cls, data: dict) -> "Learning":
|
|
537
|
+
return cls(
|
|
538
|
+
key=data["key"],
|
|
539
|
+
value=data["value"],
|
|
540
|
+
scope=data.get("scope", "user"),
|
|
541
|
+
confidence=data.get("confidence", 1.0),
|
|
542
|
+
source=data.get("source", "agent"),
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
@dataclass
|
|
547
|
+
class HandbackResult:
|
|
548
|
+
"""
|
|
549
|
+
Structured result from a sub-agent signaling task completion.
|
|
550
|
+
|
|
551
|
+
The handback protocol provides a consistent way for agents to signal
|
|
552
|
+
completion status, summarize what was done, share learnings, and
|
|
553
|
+
make recommendations for next steps.
|
|
554
|
+
|
|
555
|
+
Attributes:
|
|
556
|
+
status: The completion status (completed, needs_more_info, etc.)
|
|
557
|
+
summary: Human-readable summary of what was accomplished
|
|
558
|
+
learnings: List of facts/insights to store in memory
|
|
559
|
+
recommendation: Suggested next action for the parent agent
|
|
560
|
+
details: Additional structured details about the task
|
|
561
|
+
|
|
562
|
+
Example:
|
|
563
|
+
HandbackResult(
|
|
564
|
+
status=HandbackStatus.COMPLETED,
|
|
565
|
+
summary="Processed refund of $50 for order #123",
|
|
566
|
+
learnings=[
|
|
567
|
+
Learning(
|
|
568
|
+
key="user.refund_history",
|
|
569
|
+
value={"count": 2, "total": 75.00},
|
|
570
|
+
scope="user",
|
|
571
|
+
),
|
|
572
|
+
],
|
|
573
|
+
recommendation="Consider offering loyalty discount",
|
|
574
|
+
details={"order_id": "123", "refund_amount": 50.00},
|
|
575
|
+
)
|
|
576
|
+
"""
|
|
577
|
+
status: HandbackStatus
|
|
578
|
+
summary: str
|
|
579
|
+
learnings: list[Learning] = field(default_factory=list)
|
|
580
|
+
recommendation: Optional[str] = None
|
|
581
|
+
details: dict = field(default_factory=dict)
|
|
582
|
+
|
|
583
|
+
def to_dict(self) -> dict:
|
|
584
|
+
return {
|
|
585
|
+
"status": self.status.value,
|
|
586
|
+
"summary": self.summary,
|
|
587
|
+
"learnings": [l.to_dict() for l in self.learnings],
|
|
588
|
+
"recommendation": self.recommendation,
|
|
589
|
+
"details": self.details,
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
@classmethod
|
|
593
|
+
def from_dict(cls, data: dict) -> "HandbackResult":
|
|
594
|
+
return cls(
|
|
595
|
+
status=HandbackStatus(data["status"]),
|
|
596
|
+
summary=data["summary"],
|
|
597
|
+
learnings=[Learning.from_dict(l) for l in data.get("learnings", [])],
|
|
598
|
+
recommendation=data.get("recommendation"),
|
|
599
|
+
details=data.get("details", {}),
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
@classmethod
|
|
603
|
+
def parse_from_response(cls, response: str) -> Optional["HandbackResult"]:
|
|
604
|
+
"""
|
|
605
|
+
Attempt to parse a HandbackResult from an agent's response.
|
|
606
|
+
|
|
607
|
+
Looks for a JSON block with handback data in the response.
|
|
608
|
+
The JSON should be wrapped in ```handback or ```json tags,
|
|
609
|
+
or be a standalone JSON object with a "status" field.
|
|
610
|
+
|
|
611
|
+
Returns None if no valid handback is found.
|
|
612
|
+
"""
|
|
613
|
+
# Try to find handback JSON block
|
|
614
|
+
patterns = [
|
|
615
|
+
r'```handback\s*\n(.*?)\n```', # ```handback ... ```
|
|
616
|
+
r'```json\s*\n(\{[^`]*"status"[^`]*\})\n```', # ```json with status
|
|
617
|
+
r'<handback>(.*?)</handback>', # <handback>...</handback>
|
|
618
|
+
]
|
|
619
|
+
|
|
620
|
+
for pattern in patterns:
|
|
621
|
+
match = re.search(pattern, response, re.DOTALL | re.IGNORECASE)
|
|
622
|
+
if match:
|
|
623
|
+
try:
|
|
624
|
+
data = json.loads(match.group(1).strip())
|
|
625
|
+
if "status" in data:
|
|
626
|
+
return cls.from_dict(data)
|
|
627
|
+
except (json.JSONDecodeError, KeyError, ValueError):
|
|
628
|
+
continue
|
|
629
|
+
|
|
630
|
+
# Try to find a standalone JSON object with status
|
|
631
|
+
try:
|
|
632
|
+
# Look for JSON object pattern
|
|
633
|
+
json_match = re.search(r'\{[^{}]*"status"\s*:\s*"[^"]+?"[^{}]*\}', response)
|
|
634
|
+
if json_match:
|
|
635
|
+
data = json.loads(json_match.group(0))
|
|
636
|
+
if "status" in data and "summary" in data:
|
|
637
|
+
return cls.from_dict(data)
|
|
638
|
+
except (json.JSONDecodeError, KeyError, ValueError):
|
|
639
|
+
pass
|
|
640
|
+
|
|
641
|
+
return None
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
# =============================================================================
|
|
645
|
+
# Stuck/Loop Detection
|
|
646
|
+
# =============================================================================
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
class StuckCondition(str, Enum):
|
|
650
|
+
"""
|
|
651
|
+
Types of stuck conditions that can be detected.
|
|
652
|
+
|
|
653
|
+
REPEATED_QUESTION: User has asked the same/similar question multiple times
|
|
654
|
+
REPEATED_RESPONSE: Agent has given the same/similar response multiple times
|
|
655
|
+
CIRCULAR_PATTERN: Conversation is going in circles
|
|
656
|
+
NO_PROGRESS: Multiple turns with no meaningful progress
|
|
657
|
+
USER_FRUSTRATION: User is expressing frustration or confusion
|
|
658
|
+
"""
|
|
659
|
+
REPEATED_QUESTION = "repeated_question"
|
|
660
|
+
REPEATED_RESPONSE = "repeated_response"
|
|
661
|
+
CIRCULAR_PATTERN = "circular_pattern"
|
|
662
|
+
NO_PROGRESS = "no_progress"
|
|
663
|
+
USER_FRUSTRATION = "user_frustration"
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
@dataclass
|
|
667
|
+
class StuckDetectionResult:
|
|
668
|
+
"""
|
|
669
|
+
Result from stuck/loop detection analysis.
|
|
670
|
+
|
|
671
|
+
Attributes:
|
|
672
|
+
is_stuck: Whether a stuck condition was detected
|
|
673
|
+
condition: The type of stuck condition (if any)
|
|
674
|
+
confidence: How confident the detection is (0.0 to 1.0)
|
|
675
|
+
evidence: Messages or patterns that triggered detection
|
|
676
|
+
suggestion: Recommended action to break the loop
|
|
677
|
+
"""
|
|
678
|
+
is_stuck: bool
|
|
679
|
+
condition: Optional[StuckCondition] = None
|
|
680
|
+
confidence: float = 0.0
|
|
681
|
+
evidence: list[str] = field(default_factory=list)
|
|
682
|
+
suggestion: Optional[str] = None
|
|
683
|
+
|
|
684
|
+
def to_dict(self) -> dict:
|
|
685
|
+
return {
|
|
686
|
+
"is_stuck": self.is_stuck,
|
|
687
|
+
"condition": self.condition.value if self.condition else None,
|
|
688
|
+
"confidence": self.confidence,
|
|
689
|
+
"evidence": self.evidence,
|
|
690
|
+
"suggestion": self.suggestion,
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
class StuckDetector:
|
|
695
|
+
"""
|
|
696
|
+
Detects when conversations are stuck in loops or not making progress.
|
|
697
|
+
|
|
698
|
+
Uses simple heuristics to detect:
|
|
699
|
+
- Repeated similar messages (questions or responses)
|
|
700
|
+
- Circular conversation patterns
|
|
701
|
+
- Lack of progress over multiple turns
|
|
702
|
+
|
|
703
|
+
Example:
|
|
704
|
+
detector = StuckDetector(
|
|
705
|
+
similarity_threshold=0.8,
|
|
706
|
+
repetition_count=3,
|
|
707
|
+
window_size=10,
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
result = detector.analyze(messages)
|
|
711
|
+
if result.is_stuck:
|
|
712
|
+
# Take action - escalate, try different approach, etc.
|
|
713
|
+
print(f"Stuck: {result.condition}, suggestion: {result.suggestion}")
|
|
714
|
+
"""
|
|
715
|
+
|
|
716
|
+
def __init__(
|
|
717
|
+
self,
|
|
718
|
+
similarity_threshold: float = 0.85,
|
|
719
|
+
repetition_count: int = 3,
|
|
720
|
+
window_size: int = 10,
|
|
721
|
+
frustration_keywords: Optional[list[str]] = None,
|
|
722
|
+
):
|
|
723
|
+
"""
|
|
724
|
+
Initialize the stuck detector.
|
|
725
|
+
|
|
726
|
+
Args:
|
|
727
|
+
similarity_threshold: How similar messages must be to count as "same"
|
|
728
|
+
repetition_count: How many repetitions trigger stuck detection
|
|
729
|
+
window_size: How many recent messages to analyze
|
|
730
|
+
frustration_keywords: Words indicating user frustration
|
|
731
|
+
"""
|
|
732
|
+
self.similarity_threshold = similarity_threshold
|
|
733
|
+
self.repetition_count = repetition_count
|
|
734
|
+
self.window_size = window_size
|
|
735
|
+
self.frustration_keywords = frustration_keywords or [
|
|
736
|
+
"already said", "told you", "again", "not working",
|
|
737
|
+
"doesn't help", "frustrated", "confused", "same thing",
|
|
738
|
+
"not understanding", "wrong", "still", "keep asking",
|
|
739
|
+
]
|
|
740
|
+
|
|
741
|
+
def analyze(self, messages: list[Message]) -> StuckDetectionResult:
|
|
742
|
+
"""
|
|
743
|
+
Analyze conversation messages for stuck conditions.
|
|
744
|
+
|
|
745
|
+
Args:
|
|
746
|
+
messages: List of conversation messages
|
|
747
|
+
|
|
748
|
+
Returns:
|
|
749
|
+
StuckDetectionResult with detection details
|
|
750
|
+
"""
|
|
751
|
+
if len(messages) < self.repetition_count:
|
|
752
|
+
return StuckDetectionResult(is_stuck=False)
|
|
753
|
+
|
|
754
|
+
# Get recent messages within window
|
|
755
|
+
recent = messages[-self.window_size:]
|
|
756
|
+
|
|
757
|
+
# Separate user and assistant messages
|
|
758
|
+
user_messages = [m for m in recent if m.get("role") == "user"]
|
|
759
|
+
assistant_messages = [m for m in recent if m.get("role") == "assistant"]
|
|
760
|
+
|
|
761
|
+
# Check for repeated user questions
|
|
762
|
+
result = self._check_repeated_messages(
|
|
763
|
+
user_messages,
|
|
764
|
+
StuckCondition.REPEATED_QUESTION,
|
|
765
|
+
"User keeps asking similar questions"
|
|
766
|
+
)
|
|
767
|
+
if result.is_stuck:
|
|
768
|
+
result.suggestion = "Try a different approach or ask clarifying questions"
|
|
769
|
+
return result
|
|
770
|
+
|
|
771
|
+
# Check for repeated assistant responses
|
|
772
|
+
result = self._check_repeated_messages(
|
|
773
|
+
assistant_messages,
|
|
774
|
+
StuckCondition.REPEATED_RESPONSE,
|
|
775
|
+
"Agent keeps giving similar responses"
|
|
776
|
+
)
|
|
777
|
+
if result.is_stuck:
|
|
778
|
+
result.suggestion = "Acknowledge the repetition and try a new approach"
|
|
779
|
+
return result
|
|
780
|
+
|
|
781
|
+
# Check for user frustration
|
|
782
|
+
result = self._check_frustration(user_messages)
|
|
783
|
+
if result.is_stuck:
|
|
784
|
+
return result
|
|
785
|
+
|
|
786
|
+
# Check for circular patterns (A -> B -> A -> B)
|
|
787
|
+
result = self._check_circular_pattern(recent)
|
|
788
|
+
if result.is_stuck:
|
|
789
|
+
return result
|
|
790
|
+
|
|
791
|
+
return StuckDetectionResult(is_stuck=False)
|
|
792
|
+
|
|
793
|
+
def _check_repeated_messages(
|
|
794
|
+
self,
|
|
795
|
+
messages: list[Message],
|
|
796
|
+
condition: StuckCondition,
|
|
797
|
+
description: str,
|
|
798
|
+
) -> StuckDetectionResult:
|
|
799
|
+
"""Check for repeated similar messages."""
|
|
800
|
+
if len(messages) < self.repetition_count:
|
|
801
|
+
return StuckDetectionResult(is_stuck=False)
|
|
802
|
+
|
|
803
|
+
contents = [self._normalize(m.get("content", "")) for m in messages]
|
|
804
|
+
|
|
805
|
+
# Count similar messages
|
|
806
|
+
for i, content in enumerate(contents):
|
|
807
|
+
if not content:
|
|
808
|
+
continue
|
|
809
|
+
similar_count = 1
|
|
810
|
+
similar_msgs = [content[:100]]
|
|
811
|
+
|
|
812
|
+
for j, other in enumerate(contents):
|
|
813
|
+
if i != j and other and self._is_similar(content, other):
|
|
814
|
+
similar_count += 1
|
|
815
|
+
similar_msgs.append(other[:100])
|
|
816
|
+
|
|
817
|
+
if similar_count >= self.repetition_count:
|
|
818
|
+
return StuckDetectionResult(
|
|
819
|
+
is_stuck=True,
|
|
820
|
+
condition=condition,
|
|
821
|
+
confidence=min(0.5 + (similar_count - self.repetition_count) * 0.1, 1.0),
|
|
822
|
+
evidence=similar_msgs[:3],
|
|
823
|
+
)
|
|
824
|
+
|
|
825
|
+
return StuckDetectionResult(is_stuck=False)
|
|
826
|
+
|
|
827
|
+
def _check_frustration(self, user_messages: list[Message]) -> StuckDetectionResult:
|
|
828
|
+
"""Check for signs of user frustration."""
|
|
829
|
+
if not user_messages:
|
|
830
|
+
return StuckDetectionResult(is_stuck=False)
|
|
831
|
+
|
|
832
|
+
# Check recent user messages for frustration keywords
|
|
833
|
+
recent_user = user_messages[-3:] if len(user_messages) >= 3 else user_messages
|
|
834
|
+
frustration_evidence = []
|
|
835
|
+
|
|
836
|
+
for msg in recent_user:
|
|
837
|
+
content = msg.get("content", "").lower()
|
|
838
|
+
for keyword in self.frustration_keywords:
|
|
839
|
+
if keyword in content:
|
|
840
|
+
frustration_evidence.append(f"'{keyword}' in: {content[:50]}...")
|
|
841
|
+
|
|
842
|
+
if len(frustration_evidence) >= 2:
|
|
843
|
+
return StuckDetectionResult(
|
|
844
|
+
is_stuck=True,
|
|
845
|
+
condition=StuckCondition.USER_FRUSTRATION,
|
|
846
|
+
confidence=min(0.6 + len(frustration_evidence) * 0.1, 1.0),
|
|
847
|
+
evidence=frustration_evidence,
|
|
848
|
+
suggestion="Acknowledge frustration, apologize, and try a completely different approach",
|
|
849
|
+
)
|
|
850
|
+
|
|
851
|
+
return StuckDetectionResult(is_stuck=False)
|
|
852
|
+
|
|
853
|
+
def _check_circular_pattern(self, messages: list[Message]) -> StuckDetectionResult:
|
|
854
|
+
"""Check for circular conversation patterns."""
|
|
855
|
+
if len(messages) < 6:
|
|
856
|
+
return StuckDetectionResult(is_stuck=False)
|
|
857
|
+
|
|
858
|
+
# Look for A-B-A-B pattern in last 6 messages
|
|
859
|
+
contents = [self._normalize(m.get("content", "")) for m in messages[-6:]]
|
|
860
|
+
|
|
861
|
+
# Check if messages 0,2,4 are similar AND messages 1,3,5 are similar
|
|
862
|
+
if (self._is_similar(contents[0], contents[2]) and
|
|
863
|
+
self._is_similar(contents[2], contents[4]) and
|
|
864
|
+
self._is_similar(contents[1], contents[3]) and
|
|
865
|
+
self._is_similar(contents[3], contents[5])):
|
|
866
|
+
return StuckDetectionResult(
|
|
867
|
+
is_stuck=True,
|
|
868
|
+
condition=StuckCondition.CIRCULAR_PATTERN,
|
|
869
|
+
confidence=0.9,
|
|
870
|
+
evidence=[contents[0][:50], contents[1][:50]],
|
|
871
|
+
suggestion="Break the pattern by introducing new information or escalating",
|
|
872
|
+
)
|
|
873
|
+
|
|
874
|
+
return StuckDetectionResult(is_stuck=False)
|
|
875
|
+
|
|
876
|
+
def _normalize(self, text: str) -> str:
|
|
877
|
+
"""Normalize text for comparison."""
|
|
878
|
+
if not text:
|
|
879
|
+
return ""
|
|
880
|
+
# Lowercase, remove extra whitespace, remove punctuation
|
|
881
|
+
text = text.lower().strip()
|
|
882
|
+
text = re.sub(r'\s+', ' ', text)
|
|
883
|
+
text = re.sub(r'[^\w\s]', '', text)
|
|
884
|
+
return text
|
|
885
|
+
|
|
886
|
+
def _is_similar(self, text1: str, text2: str) -> bool:
|
|
887
|
+
"""
|
|
888
|
+
Check if two texts are similar using simple heuristics.
|
|
889
|
+
|
|
890
|
+
Uses a combination of:
|
|
891
|
+
- Exact match after normalization
|
|
892
|
+
- Word overlap ratio
|
|
893
|
+
"""
|
|
894
|
+
if not text1 or not text2:
|
|
895
|
+
return False
|
|
896
|
+
|
|
897
|
+
# Exact match
|
|
898
|
+
if text1 == text2:
|
|
899
|
+
return True
|
|
900
|
+
|
|
901
|
+
# Word overlap
|
|
902
|
+
words1 = set(text1.split())
|
|
903
|
+
words2 = set(text2.split())
|
|
904
|
+
|
|
905
|
+
if not words1 or not words2:
|
|
906
|
+
return False
|
|
907
|
+
|
|
908
|
+
intersection = words1 & words2
|
|
909
|
+
union = words1 | words2
|
|
910
|
+
|
|
911
|
+
jaccard = len(intersection) / len(union)
|
|
912
|
+
return jaccard >= self.similarity_threshold
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
# =============================================================================
|
|
916
|
+
# Journey Mode
|
|
917
|
+
# =============================================================================
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
class JourneyEndReason(str, Enum):
|
|
921
|
+
"""
|
|
922
|
+
Reasons why a journey ended.
|
|
923
|
+
|
|
924
|
+
COMPLETED: Agent signaled task completion
|
|
925
|
+
TOPIC_CHANGE: User changed to a different topic
|
|
926
|
+
STUCK: Conversation got stuck in a loop
|
|
927
|
+
TIMEOUT: Journey exceeded maximum turns
|
|
928
|
+
USER_EXIT: User explicitly asked to exit/cancel
|
|
929
|
+
ESCALATION: Agent requested escalation
|
|
930
|
+
ERROR: An error occurred during the journey
|
|
931
|
+
"""
|
|
932
|
+
COMPLETED = "completed"
|
|
933
|
+
TOPIC_CHANGE = "topic_change"
|
|
934
|
+
STUCK = "stuck"
|
|
935
|
+
TIMEOUT = "timeout"
|
|
936
|
+
USER_EXIT = "user_exit"
|
|
937
|
+
ESCALATION = "escalation"
|
|
938
|
+
ERROR = "error"
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
@dataclass
|
|
942
|
+
class JourneyState:
|
|
943
|
+
"""
|
|
944
|
+
State of an active journey (multi-turn agent control).
|
|
945
|
+
|
|
946
|
+
A journey allows a delegated agent to maintain control across multiple
|
|
947
|
+
user turns without routing back to the entry point (e.g., Triage) each time.
|
|
948
|
+
|
|
949
|
+
Attributes:
|
|
950
|
+
journey_id: Unique identifier for this journey
|
|
951
|
+
agent_key: The agent that owns this journey
|
|
952
|
+
started_at: When the journey started
|
|
953
|
+
purpose: What the journey is trying to accomplish
|
|
954
|
+
expected_turns: Estimated number of turns (for progress tracking)
|
|
955
|
+
current_turn: Current turn number
|
|
956
|
+
max_turns: Maximum allowed turns before timeout
|
|
957
|
+
context: Additional context for the journey
|
|
958
|
+
checkpoints: List of checkpoint summaries
|
|
959
|
+
|
|
960
|
+
Example:
|
|
961
|
+
journey = JourneyState(
|
|
962
|
+
agent_key="onboarding_agent",
|
|
963
|
+
purpose="Complete new user onboarding",
|
|
964
|
+
expected_turns=5,
|
|
965
|
+
max_turns=20,
|
|
966
|
+
)
|
|
967
|
+
"""
|
|
968
|
+
journey_id: UUID = field(default_factory=uuid4)
|
|
969
|
+
agent_key: str = ""
|
|
970
|
+
started_at: datetime = field(default_factory=datetime.utcnow)
|
|
971
|
+
purpose: str = ""
|
|
972
|
+
expected_turns: int = 5
|
|
973
|
+
current_turn: int = 0
|
|
974
|
+
max_turns: int = 50
|
|
975
|
+
context: dict = field(default_factory=dict)
|
|
976
|
+
checkpoints: list[str] = field(default_factory=list)
|
|
977
|
+
|
|
978
|
+
def to_dict(self) -> dict:
|
|
979
|
+
return {
|
|
980
|
+
"journey_id": str(self.journey_id),
|
|
981
|
+
"agent_key": self.agent_key,
|
|
982
|
+
"started_at": self.started_at.isoformat(),
|
|
983
|
+
"purpose": self.purpose,
|
|
984
|
+
"expected_turns": self.expected_turns,
|
|
985
|
+
"current_turn": self.current_turn,
|
|
986
|
+
"max_turns": self.max_turns,
|
|
987
|
+
"context": self.context,
|
|
988
|
+
"checkpoints": self.checkpoints,
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
@classmethod
|
|
992
|
+
def from_dict(cls, data: dict) -> "JourneyState":
|
|
993
|
+
return cls(
|
|
994
|
+
journey_id=UUID(data["journey_id"]) if isinstance(data.get("journey_id"), str) else data.get("journey_id", uuid4()),
|
|
995
|
+
agent_key=data.get("agent_key", ""),
|
|
996
|
+
started_at=datetime.fromisoformat(data["started_at"]) if isinstance(data.get("started_at"), str) else data.get("started_at", datetime.utcnow()),
|
|
997
|
+
purpose=data.get("purpose", ""),
|
|
998
|
+
expected_turns=data.get("expected_turns", 5),
|
|
999
|
+
current_turn=data.get("current_turn", 0),
|
|
1000
|
+
max_turns=data.get("max_turns", 50),
|
|
1001
|
+
context=data.get("context", {}),
|
|
1002
|
+
checkpoints=data.get("checkpoints", []),
|
|
1003
|
+
)
|
|
1004
|
+
|
|
1005
|
+
def increment_turn(self) -> None:
|
|
1006
|
+
"""Increment the turn counter."""
|
|
1007
|
+
self.current_turn += 1
|
|
1008
|
+
|
|
1009
|
+
def add_checkpoint(self, summary: str) -> None:
|
|
1010
|
+
"""Add a checkpoint summary."""
|
|
1011
|
+
self.checkpoints.append(summary)
|
|
1012
|
+
|
|
1013
|
+
def is_timeout(self) -> bool:
|
|
1014
|
+
"""Check if journey has exceeded max turns."""
|
|
1015
|
+
return self.current_turn >= self.max_turns
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
@dataclass
|
|
1019
|
+
class JourneyEndResult:
|
|
1020
|
+
"""
|
|
1021
|
+
Result when a journey ends.
|
|
1022
|
+
|
|
1023
|
+
Attributes:
|
|
1024
|
+
reason: Why the journey ended
|
|
1025
|
+
handback: The structured handback from the agent (if any)
|
|
1026
|
+
summary: Summary of what was accomplished
|
|
1027
|
+
next_agent: Suggested next agent to route to (if any)
|
|
1028
|
+
"""
|
|
1029
|
+
reason: JourneyEndReason
|
|
1030
|
+
handback: Optional[HandbackResult] = None
|
|
1031
|
+
summary: str = ""
|
|
1032
|
+
next_agent: Optional[str] = None
|
|
1033
|
+
|
|
1034
|
+
def to_dict(self) -> dict:
|
|
1035
|
+
return {
|
|
1036
|
+
"reason": self.reason.value,
|
|
1037
|
+
"handback": self.handback.to_dict() if self.handback else None,
|
|
1038
|
+
"summary": self.summary,
|
|
1039
|
+
"next_agent": self.next_agent,
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
|
|
1043
|
+
# =============================================================================
|
|
1044
|
+
# Fallback Routing
|
|
1045
|
+
# =============================================================================
|
|
1046
|
+
|
|
1047
|
+
|
|
1048
|
+
@dataclass
|
|
1049
|
+
class FallbackConfig:
|
|
1050
|
+
"""
|
|
1051
|
+
Configuration for fallback routing when agents fail.
|
|
1052
|
+
|
|
1053
|
+
Attributes:
|
|
1054
|
+
fallback_agent_key: The agent to route to on failure (e.g., "triage")
|
|
1055
|
+
max_retries: Maximum retries before falling back
|
|
1056
|
+
retry_delay_seconds: Delay between retries
|
|
1057
|
+
fallback_message: Message to include when falling back
|
|
1058
|
+
capture_error: Whether to include error details in fallback
|
|
1059
|
+
|
|
1060
|
+
Example:
|
|
1061
|
+
config = FallbackConfig(
|
|
1062
|
+
fallback_agent_key="triage",
|
|
1063
|
+
max_retries=1,
|
|
1064
|
+
fallback_message="I encountered an issue. Let me get you some help.",
|
|
1065
|
+
)
|
|
1066
|
+
"""
|
|
1067
|
+
fallback_agent_key: str = "triage"
|
|
1068
|
+
max_retries: int = 1
|
|
1069
|
+
retry_delay_seconds: float = 0.5
|
|
1070
|
+
fallback_message: str = "I apologize, but I encountered an issue. Let me connect you with someone who can help."
|
|
1071
|
+
capture_error: bool = True
|
|
1072
|
+
|
|
1073
|
+
def to_dict(self) -> dict:
|
|
1074
|
+
return {
|
|
1075
|
+
"fallback_agent_key": self.fallback_agent_key,
|
|
1076
|
+
"max_retries": self.max_retries,
|
|
1077
|
+
"retry_delay_seconds": self.retry_delay_seconds,
|
|
1078
|
+
"fallback_message": self.fallback_message,
|
|
1079
|
+
"capture_error": self.capture_error,
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
@classmethod
|
|
1083
|
+
def from_dict(cls, data: dict) -> "FallbackConfig":
|
|
1084
|
+
return cls(
|
|
1085
|
+
fallback_agent_key=data.get("fallback_agent_key", "triage"),
|
|
1086
|
+
max_retries=data.get("max_retries", 1),
|
|
1087
|
+
retry_delay_seconds=data.get("retry_delay_seconds", 0.5),
|
|
1088
|
+
fallback_message=data.get("fallback_message", cls.fallback_message),
|
|
1089
|
+
capture_error=data.get("capture_error", True),
|
|
1090
|
+
)
|
|
1091
|
+
|
|
1092
|
+
|
|
1093
|
+
# =============================================================================
|
|
1094
|
+
# Agent Tool and Invocation
|
|
1095
|
+
# =============================================================================
|
|
1096
|
+
|
|
1097
|
+
|
|
101
1098
|
@dataclass
|
|
102
1099
|
class AgentTool:
|
|
103
1100
|
"""
|
|
@@ -165,19 +1162,21 @@ class AgentTool:
|
|
|
165
1162
|
class AgentInvocationResult:
|
|
166
1163
|
"""
|
|
167
1164
|
Result from invoking a sub-agent.
|
|
168
|
-
|
|
1165
|
+
|
|
169
1166
|
Attributes:
|
|
170
1167
|
response: The sub-agent's final response text
|
|
171
1168
|
messages: All messages from the sub-agent's run
|
|
172
1169
|
handoff: True if this was a handoff (parent should exit)
|
|
173
1170
|
run_result: The full RunResult from the sub-agent
|
|
174
1171
|
sub_agent_key: The key of the agent that was invoked
|
|
1172
|
+
handback: Structured handback result (if agent provided one)
|
|
175
1173
|
"""
|
|
176
1174
|
response: str
|
|
177
1175
|
messages: list[Message]
|
|
178
1176
|
handoff: bool
|
|
179
1177
|
run_result: RunResult
|
|
180
1178
|
sub_agent_key: str
|
|
1179
|
+
handback: Optional[HandbackResult] = None
|
|
181
1180
|
|
|
182
1181
|
|
|
183
1182
|
class SubAgentContext:
|
|
@@ -434,6 +1433,16 @@ async def invoke_agent(
|
|
|
434
1433
|
response = msg["content"]
|
|
435
1434
|
break
|
|
436
1435
|
|
|
1436
|
+
# Try to parse structured handback from response
|
|
1437
|
+
handback = None
|
|
1438
|
+
if response:
|
|
1439
|
+
handback = HandbackResult.parse_from_response(response)
|
|
1440
|
+
if handback:
|
|
1441
|
+
logger.info(
|
|
1442
|
+
f"Sub-agent '{agent_tool.name}' returned structured handback: "
|
|
1443
|
+
f"status={handback.status.value}"
|
|
1444
|
+
)
|
|
1445
|
+
|
|
437
1446
|
# Emit result event (tool result format)
|
|
438
1447
|
await parent_ctx.emit(EventType.TOOL_RESULT, {
|
|
439
1448
|
"name": agent_tool.name,
|
|
@@ -441,6 +1450,7 @@ async def invoke_agent(
|
|
|
441
1450
|
"sub_agent_key": agent_tool.agent.key,
|
|
442
1451
|
"response": response[:500] if response else "", # Truncate for event
|
|
443
1452
|
"handoff": agent_tool.invocation_mode == InvocationMode.HANDOFF,
|
|
1453
|
+
"handback_status": handback.status.value if handback else None,
|
|
444
1454
|
})
|
|
445
1455
|
|
|
446
1456
|
# Also emit a custom sub_agent.end event for UI display
|
|
@@ -450,14 +1460,16 @@ async def invoke_agent(
|
|
|
450
1460
|
"tool_name": agent_tool.name,
|
|
451
1461
|
"success": True,
|
|
452
1462
|
"handoff": agent_tool.invocation_mode == InvocationMode.HANDOFF,
|
|
1463
|
+
"handback": handback.to_dict() if handback else None,
|
|
453
1464
|
})
|
|
454
|
-
|
|
1465
|
+
|
|
455
1466
|
return AgentInvocationResult(
|
|
456
1467
|
response=response,
|
|
457
1468
|
messages=run_result.final_messages,
|
|
458
1469
|
handoff=agent_tool.invocation_mode == InvocationMode.HANDOFF,
|
|
459
1470
|
run_result=run_result,
|
|
460
1471
|
sub_agent_key=agent_tool.agent.key,
|
|
1472
|
+
handback=handback,
|
|
461
1473
|
)
|
|
462
1474
|
|
|
463
1475
|
|
|
@@ -551,9 +1563,9 @@ def register_agent_tools(
|
|
|
551
1563
|
get_conversation_history=get_conversation_history,
|
|
552
1564
|
parent_ctx=parent_ctx,
|
|
553
1565
|
)
|
|
554
|
-
|
|
1566
|
+
|
|
555
1567
|
schema = agent_tool.to_tool_schema()
|
|
556
|
-
|
|
1568
|
+
|
|
557
1569
|
registry.register(Tool(
|
|
558
1570
|
name=agent_tool.name,
|
|
559
1571
|
description=agent_tool.description,
|
|
@@ -567,3 +1579,383 @@ def register_agent_tools(
|
|
|
567
1579
|
},
|
|
568
1580
|
))
|
|
569
1581
|
|
|
1582
|
+
|
|
1583
|
+
# =============================================================================
|
|
1584
|
+
# Fallback-Enabled Invocation
|
|
1585
|
+
# =============================================================================
|
|
1586
|
+
|
|
1587
|
+
|
|
1588
|
+
async def invoke_agent_with_fallback(
|
|
1589
|
+
agent_tool: AgentTool,
|
|
1590
|
+
message: str,
|
|
1591
|
+
parent_ctx: RunContext,
|
|
1592
|
+
conversation_history: Optional[list[Message]] = None,
|
|
1593
|
+
additional_context: Optional[str] = None,
|
|
1594
|
+
fallback_config: Optional[FallbackConfig] = None,
|
|
1595
|
+
fallback_agent_tool: Optional[AgentTool] = None,
|
|
1596
|
+
) -> AgentInvocationResult:
|
|
1597
|
+
"""
|
|
1598
|
+
Invoke a sub-agent with automatic fallback on failure.
|
|
1599
|
+
|
|
1600
|
+
If the primary agent fails, automatically routes to a fallback agent
|
|
1601
|
+
(typically Triage) to ensure the user is never left without a response.
|
|
1602
|
+
|
|
1603
|
+
Args:
|
|
1604
|
+
agent_tool: The primary AgentTool to invoke
|
|
1605
|
+
message: The message/task to send
|
|
1606
|
+
parent_ctx: The parent agent's run context
|
|
1607
|
+
conversation_history: Full conversation history
|
|
1608
|
+
additional_context: Optional extra context
|
|
1609
|
+
fallback_config: Configuration for fallback behavior
|
|
1610
|
+
fallback_agent_tool: The fallback agent to use on failure
|
|
1611
|
+
|
|
1612
|
+
Returns:
|
|
1613
|
+
AgentInvocationResult from either primary or fallback agent
|
|
1614
|
+
|
|
1615
|
+
Example:
|
|
1616
|
+
result = await invoke_agent_with_fallback(
|
|
1617
|
+
agent_tool=billing_specialist,
|
|
1618
|
+
message="Process refund",
|
|
1619
|
+
parent_ctx=ctx,
|
|
1620
|
+
fallback_config=FallbackConfig(fallback_agent_key="triage"),
|
|
1621
|
+
fallback_agent_tool=triage_agent_tool,
|
|
1622
|
+
)
|
|
1623
|
+
"""
|
|
1624
|
+
import asyncio
|
|
1625
|
+
|
|
1626
|
+
config = fallback_config or FallbackConfig()
|
|
1627
|
+
last_error: Optional[Exception] = None
|
|
1628
|
+
|
|
1629
|
+
# Try primary agent with retries
|
|
1630
|
+
for attempt in range(config.max_retries + 1):
|
|
1631
|
+
try:
|
|
1632
|
+
result = await invoke_agent(
|
|
1633
|
+
agent_tool=agent_tool,
|
|
1634
|
+
message=message,
|
|
1635
|
+
parent_ctx=parent_ctx,
|
|
1636
|
+
conversation_history=conversation_history,
|
|
1637
|
+
additional_context=additional_context,
|
|
1638
|
+
)
|
|
1639
|
+
return result
|
|
1640
|
+
except Exception as e:
|
|
1641
|
+
last_error = e
|
|
1642
|
+
logger.warning(
|
|
1643
|
+
f"Agent '{agent_tool.name}' failed (attempt {attempt + 1}/{config.max_retries + 1}): {e}"
|
|
1644
|
+
)
|
|
1645
|
+
if attempt < config.max_retries:
|
|
1646
|
+
await asyncio.sleep(config.retry_delay_seconds)
|
|
1647
|
+
|
|
1648
|
+
# Primary agent failed, try fallback
|
|
1649
|
+
if fallback_agent_tool:
|
|
1650
|
+
logger.info(
|
|
1651
|
+
f"Falling back to '{fallback_agent_tool.name}' after "
|
|
1652
|
+
f"'{agent_tool.name}' failed"
|
|
1653
|
+
)
|
|
1654
|
+
|
|
1655
|
+
# Build fallback context
|
|
1656
|
+
fallback_context = config.fallback_message
|
|
1657
|
+
if config.capture_error and last_error:
|
|
1658
|
+
fallback_context += f"\n\nPrevious agent error: {str(last_error)}"
|
|
1659
|
+
|
|
1660
|
+
# Emit fallback event
|
|
1661
|
+
await parent_ctx.emit("agent.fallback", {
|
|
1662
|
+
"failed_agent": agent_tool.name,
|
|
1663
|
+
"fallback_agent": fallback_agent_tool.name,
|
|
1664
|
+
"error": str(last_error) if last_error else None,
|
|
1665
|
+
})
|
|
1666
|
+
|
|
1667
|
+
try:
|
|
1668
|
+
return await invoke_agent(
|
|
1669
|
+
agent_tool=fallback_agent_tool,
|
|
1670
|
+
message=message,
|
|
1671
|
+
parent_ctx=parent_ctx,
|
|
1672
|
+
conversation_history=conversation_history,
|
|
1673
|
+
additional_context=fallback_context,
|
|
1674
|
+
)
|
|
1675
|
+
except Exception as fallback_error:
|
|
1676
|
+
logger.exception(f"Fallback agent '{fallback_agent_tool.name}' also failed")
|
|
1677
|
+
# Re-raise the original error
|
|
1678
|
+
raise last_error or fallback_error
|
|
1679
|
+
|
|
1680
|
+
# No fallback available, re-raise the error
|
|
1681
|
+
if last_error:
|
|
1682
|
+
raise last_error
|
|
1683
|
+
raise RuntimeError(f"Agent '{agent_tool.name}' failed with no fallback available")
|
|
1684
|
+
|
|
1685
|
+
|
|
1686
|
+
# =============================================================================
|
|
1687
|
+
# Journey Manager
|
|
1688
|
+
# =============================================================================
|
|
1689
|
+
|
|
1690
|
+
|
|
1691
|
+
# Memory key for storing active journey state
|
|
1692
|
+
JOURNEY_STATE_KEY = "system.active_journey"
|
|
1693
|
+
|
|
1694
|
+
|
|
1695
|
+
class JourneyManager:
|
|
1696
|
+
"""
|
|
1697
|
+
Manages journey mode for multi-turn agent control.
|
|
1698
|
+
|
|
1699
|
+
A journey allows a delegated agent to maintain control across multiple
|
|
1700
|
+
user turns without routing back to the entry point (e.g., Triage) each time.
|
|
1701
|
+
|
|
1702
|
+
The JourneyManager:
|
|
1703
|
+
- Stores active journey state in conversation-scoped memory
|
|
1704
|
+
- Routes subsequent messages to the journey agent
|
|
1705
|
+
- Detects journey end conditions (completion, topic change, stuck, timeout)
|
|
1706
|
+
- Handles handback to the entry agent with summary
|
|
1707
|
+
|
|
1708
|
+
Example:
|
|
1709
|
+
manager = JourneyManager(memory_store=store, conversation_id=conv_id)
|
|
1710
|
+
|
|
1711
|
+
# Start a journey
|
|
1712
|
+
await manager.start_journey(JourneyState(
|
|
1713
|
+
agent_key="onboarding_agent",
|
|
1714
|
+
purpose="Complete user onboarding",
|
|
1715
|
+
))
|
|
1716
|
+
|
|
1717
|
+
# Check if there's an active journey
|
|
1718
|
+
journey = await manager.get_active_journey()
|
|
1719
|
+
if journey:
|
|
1720
|
+
# Route to journey agent instead of Triage
|
|
1721
|
+
result = await invoke_agent(journey_agent_tool, message, ctx)
|
|
1722
|
+
|
|
1723
|
+
# Check for journey end
|
|
1724
|
+
end_result = await manager.check_journey_end(result, messages)
|
|
1725
|
+
if end_result:
|
|
1726
|
+
await manager.end_journey(end_result.reason)
|
|
1727
|
+
"""
|
|
1728
|
+
|
|
1729
|
+
def __init__(
|
|
1730
|
+
self,
|
|
1731
|
+
memory_store: Optional["SharedMemoryStore"] = None,
|
|
1732
|
+
conversation_id: Optional[UUID] = None,
|
|
1733
|
+
stuck_detector: Optional[StuckDetector] = None,
|
|
1734
|
+
topic_change_threshold: float = 0.3,
|
|
1735
|
+
):
|
|
1736
|
+
"""
|
|
1737
|
+
Initialize the journey manager.
|
|
1738
|
+
|
|
1739
|
+
Args:
|
|
1740
|
+
memory_store: Store for persisting journey state
|
|
1741
|
+
conversation_id: The conversation this manager is for
|
|
1742
|
+
stuck_detector: Detector for stuck/loop conditions
|
|
1743
|
+
topic_change_threshold: Similarity threshold for topic change detection
|
|
1744
|
+
"""
|
|
1745
|
+
self.memory_store = memory_store
|
|
1746
|
+
self.conversation_id = conversation_id
|
|
1747
|
+
self.stuck_detector = stuck_detector or StuckDetector()
|
|
1748
|
+
self.topic_change_threshold = topic_change_threshold
|
|
1749
|
+
self._journey_state: Optional[JourneyState] = None
|
|
1750
|
+
|
|
1751
|
+
async def get_active_journey(self) -> Optional[JourneyState]:
|
|
1752
|
+
"""
|
|
1753
|
+
Get the currently active journey for this conversation.
|
|
1754
|
+
|
|
1755
|
+
Returns:
|
|
1756
|
+
JourneyState if there's an active journey, None otherwise
|
|
1757
|
+
"""
|
|
1758
|
+
# Check in-memory cache first
|
|
1759
|
+
if self._journey_state:
|
|
1760
|
+
return self._journey_state
|
|
1761
|
+
|
|
1762
|
+
# Try to load from memory store
|
|
1763
|
+
if self.memory_store and self.conversation_id:
|
|
1764
|
+
try:
|
|
1765
|
+
item = await self.memory_store.get(
|
|
1766
|
+
JOURNEY_STATE_KEY,
|
|
1767
|
+
scope="conversation",
|
|
1768
|
+
conversation_id=self.conversation_id,
|
|
1769
|
+
)
|
|
1770
|
+
if item and item.value:
|
|
1771
|
+
self._journey_state = JourneyState.from_dict(item.value)
|
|
1772
|
+
return self._journey_state
|
|
1773
|
+
except Exception as e:
|
|
1774
|
+
logger.warning(f"Failed to load journey state: {e}")
|
|
1775
|
+
|
|
1776
|
+
return None
|
|
1777
|
+
|
|
1778
|
+
async def start_journey(self, journey: JourneyState) -> None:
|
|
1779
|
+
"""
|
|
1780
|
+
Start a new journey.
|
|
1781
|
+
|
|
1782
|
+
Args:
|
|
1783
|
+
journey: The journey state to start
|
|
1784
|
+
"""
|
|
1785
|
+
self._journey_state = journey
|
|
1786
|
+
|
|
1787
|
+
# Persist to memory store
|
|
1788
|
+
if self.memory_store and self.conversation_id:
|
|
1789
|
+
try:
|
|
1790
|
+
await self.memory_store.set(
|
|
1791
|
+
JOURNEY_STATE_KEY,
|
|
1792
|
+
journey.to_dict(),
|
|
1793
|
+
scope="conversation",
|
|
1794
|
+
conversation_id=self.conversation_id,
|
|
1795
|
+
source="journey_manager",
|
|
1796
|
+
)
|
|
1797
|
+
except Exception as e:
|
|
1798
|
+
logger.warning(f"Failed to persist journey state: {e}")
|
|
1799
|
+
|
|
1800
|
+
logger.info(
|
|
1801
|
+
f"Started journey '{journey.journey_id}' with agent '{journey.agent_key}': "
|
|
1802
|
+
f"{journey.purpose}"
|
|
1803
|
+
)
|
|
1804
|
+
|
|
1805
|
+
async def update_journey(self, checkpoint: Optional[str] = None) -> None:
|
|
1806
|
+
"""
|
|
1807
|
+
Update the journey state (increment turn, add checkpoint).
|
|
1808
|
+
|
|
1809
|
+
Args:
|
|
1810
|
+
checkpoint: Optional checkpoint summary to add
|
|
1811
|
+
"""
|
|
1812
|
+
if not self._journey_state:
|
|
1813
|
+
return
|
|
1814
|
+
|
|
1815
|
+
self._journey_state.increment_turn()
|
|
1816
|
+
if checkpoint:
|
|
1817
|
+
self._journey_state.add_checkpoint(checkpoint)
|
|
1818
|
+
|
|
1819
|
+
# Persist updated state
|
|
1820
|
+
if self.memory_store and self.conversation_id:
|
|
1821
|
+
try:
|
|
1822
|
+
await self.memory_store.set(
|
|
1823
|
+
JOURNEY_STATE_KEY,
|
|
1824
|
+
self._journey_state.to_dict(),
|
|
1825
|
+
scope="conversation",
|
|
1826
|
+
conversation_id=self.conversation_id,
|
|
1827
|
+
source="journey_manager",
|
|
1828
|
+
)
|
|
1829
|
+
except Exception as e:
|
|
1830
|
+
logger.warning(f"Failed to update journey state: {e}")
|
|
1831
|
+
|
|
1832
|
+
async def end_journey(self, reason: JourneyEndReason) -> JourneyEndResult:
|
|
1833
|
+
"""
|
|
1834
|
+
End the current journey.
|
|
1835
|
+
|
|
1836
|
+
Args:
|
|
1837
|
+
reason: Why the journey is ending
|
|
1838
|
+
|
|
1839
|
+
Returns:
|
|
1840
|
+
JourneyEndResult with summary and handback info
|
|
1841
|
+
"""
|
|
1842
|
+
journey = self._journey_state
|
|
1843
|
+
if not journey:
|
|
1844
|
+
return JourneyEndResult(
|
|
1845
|
+
reason=reason,
|
|
1846
|
+
summary="No active journey",
|
|
1847
|
+
)
|
|
1848
|
+
|
|
1849
|
+
# Build summary from checkpoints
|
|
1850
|
+
summary = f"Journey '{journey.purpose}' ended after {journey.current_turn} turns."
|
|
1851
|
+
if journey.checkpoints:
|
|
1852
|
+
summary += f" Checkpoints: {'; '.join(journey.checkpoints[-3:])}"
|
|
1853
|
+
|
|
1854
|
+
result = JourneyEndResult(
|
|
1855
|
+
reason=reason,
|
|
1856
|
+
summary=summary,
|
|
1857
|
+
)
|
|
1858
|
+
|
|
1859
|
+
# Clear journey state
|
|
1860
|
+
self._journey_state = None
|
|
1861
|
+
|
|
1862
|
+
# Remove from memory store
|
|
1863
|
+
if self.memory_store and self.conversation_id:
|
|
1864
|
+
try:
|
|
1865
|
+
await self.memory_store.delete(
|
|
1866
|
+
JOURNEY_STATE_KEY,
|
|
1867
|
+
scope="conversation",
|
|
1868
|
+
conversation_id=self.conversation_id,
|
|
1869
|
+
)
|
|
1870
|
+
except Exception as e:
|
|
1871
|
+
logger.warning(f"Failed to clear journey state: {e}")
|
|
1872
|
+
|
|
1873
|
+
logger.info(
|
|
1874
|
+
f"Ended journey '{journey.journey_id}' (reason={reason.value}): {summary}"
|
|
1875
|
+
)
|
|
1876
|
+
|
|
1877
|
+
return result
|
|
1878
|
+
|
|
1879
|
+
def check_journey_end(
|
|
1880
|
+
self,
|
|
1881
|
+
invocation_result: AgentInvocationResult,
|
|
1882
|
+
messages: list[Message],
|
|
1883
|
+
user_message: Optional[str] = None,
|
|
1884
|
+
) -> Optional[JourneyEndResult]:
|
|
1885
|
+
"""
|
|
1886
|
+
Check if the journey should end based on the latest interaction.
|
|
1887
|
+
|
|
1888
|
+
Args:
|
|
1889
|
+
invocation_result: Result from the journey agent
|
|
1890
|
+
messages: Full conversation history
|
|
1891
|
+
user_message: The latest user message (for topic change detection)
|
|
1892
|
+
|
|
1893
|
+
Returns:
|
|
1894
|
+
JourneyEndResult if journey should end, None otherwise
|
|
1895
|
+
"""
|
|
1896
|
+
journey = self._journey_state
|
|
1897
|
+
if not journey:
|
|
1898
|
+
return None
|
|
1899
|
+
|
|
1900
|
+
# Check for structured handback completion
|
|
1901
|
+
if invocation_result.handback:
|
|
1902
|
+
handback = invocation_result.handback
|
|
1903
|
+
if handback.status == HandbackStatus.COMPLETED:
|
|
1904
|
+
return JourneyEndResult(
|
|
1905
|
+
reason=JourneyEndReason.COMPLETED,
|
|
1906
|
+
handback=handback,
|
|
1907
|
+
summary=handback.summary,
|
|
1908
|
+
)
|
|
1909
|
+
elif handback.status == HandbackStatus.ESCALATE:
|
|
1910
|
+
return JourneyEndResult(
|
|
1911
|
+
reason=JourneyEndReason.ESCALATION,
|
|
1912
|
+
handback=handback,
|
|
1913
|
+
summary=handback.summary,
|
|
1914
|
+
next_agent=handback.recommendation,
|
|
1915
|
+
)
|
|
1916
|
+
|
|
1917
|
+
# Check for timeout
|
|
1918
|
+
if journey.is_timeout():
|
|
1919
|
+
return JourneyEndResult(
|
|
1920
|
+
reason=JourneyEndReason.TIMEOUT,
|
|
1921
|
+
summary=f"Journey exceeded maximum turns ({journey.max_turns})",
|
|
1922
|
+
)
|
|
1923
|
+
|
|
1924
|
+
# Check for stuck condition
|
|
1925
|
+
stuck_result = self.stuck_detector.analyze(messages)
|
|
1926
|
+
if stuck_result.is_stuck and stuck_result.confidence >= 0.7:
|
|
1927
|
+
return JourneyEndResult(
|
|
1928
|
+
reason=JourneyEndReason.STUCK,
|
|
1929
|
+
summary=f"Conversation stuck: {stuck_result.condition.value if stuck_result.condition else 'unknown'}",
|
|
1930
|
+
)
|
|
1931
|
+
|
|
1932
|
+
# Check for user exit keywords
|
|
1933
|
+
if user_message:
|
|
1934
|
+
exit_keywords = ["cancel", "stop", "exit", "quit", "nevermind", "never mind", "forget it"]
|
|
1935
|
+
user_lower = user_message.lower()
|
|
1936
|
+
for keyword in exit_keywords:
|
|
1937
|
+
if keyword in user_lower:
|
|
1938
|
+
return JourneyEndResult(
|
|
1939
|
+
reason=JourneyEndReason.USER_EXIT,
|
|
1940
|
+
summary=f"User requested exit: '{user_message[:50]}'",
|
|
1941
|
+
)
|
|
1942
|
+
|
|
1943
|
+
# Check for topic change (simple heuristic)
|
|
1944
|
+
if user_message and journey.purpose:
|
|
1945
|
+
# Very basic topic change detection - could be enhanced with embeddings
|
|
1946
|
+
purpose_words = set(journey.purpose.lower().split())
|
|
1947
|
+
message_words = set(user_message.lower().split())
|
|
1948
|
+
overlap = len(purpose_words & message_words) / max(len(purpose_words), 1)
|
|
1949
|
+
|
|
1950
|
+
# If very low overlap and message is a question about something else
|
|
1951
|
+
if overlap < self.topic_change_threshold and "?" in user_message:
|
|
1952
|
+
# Check if it seems like a new topic
|
|
1953
|
+
new_topic_indicators = ["can you", "what about", "how do i", "tell me about", "help me with"]
|
|
1954
|
+
if any(indicator in user_message.lower() for indicator in new_topic_indicators):
|
|
1955
|
+
return JourneyEndResult(
|
|
1956
|
+
reason=JourneyEndReason.TOPIC_CHANGE,
|
|
1957
|
+
summary=f"User changed topic: '{user_message[:50]}'",
|
|
1958
|
+
)
|
|
1959
|
+
|
|
1960
|
+
return None
|
|
1961
|
+
|