agent-runtime-core 0.8.0__py3-none-any.whl → 0.9.1__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.
@@ -1,11 +1,24 @@
1
1
  """
2
2
  Multi-agent support for agent_runtime_core.
3
3
 
4
- This module provides the "agent-as-tool" pattern, allowing agents to invoke
5
- other agents as tools. This enables:
6
- - Router/dispatcher patterns
7
- - Hierarchical agent systems
8
- - Specialist delegation
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:
@@ -231,7 +1230,17 @@ class SubAgentContext:
231
1230
  return ToolRegistry()
232
1231
 
233
1232
  async def emit(self, event_type: EventType | str, payload: dict) -> None:
234
- """Emit events through parent context with sub-agent tagging."""
1233
+ """Emit events through parent context with sub-agent tagging.
1234
+
1235
+ Note: ASSISTANT_MESSAGE events are suppressed for sub-agents because
1236
+ the parent agent will relay the response. This prevents duplicate
1237
+ messages in the UI.
1238
+ """
1239
+ # Suppress ASSISTANT_MESSAGE events from sub-agents to prevent duplicates
1240
+ # The parent agent will relay the sub-agent's response
1241
+ if event_type == EventType.ASSISTANT_MESSAGE or event_type == "assistant.message":
1242
+ return
1243
+
235
1244
  # Tag the event as coming from a sub-agent
236
1245
  tagged_payload = dict(payload)
237
1246
  tagged_payload["sub_agent_run_id"] = str(self._run_id)
@@ -434,6 +1443,16 @@ async def invoke_agent(
434
1443
  response = msg["content"]
435
1444
  break
436
1445
 
1446
+ # Try to parse structured handback from response
1447
+ handback = None
1448
+ if response:
1449
+ handback = HandbackResult.parse_from_response(response)
1450
+ if handback:
1451
+ logger.info(
1452
+ f"Sub-agent '{agent_tool.name}' returned structured handback: "
1453
+ f"status={handback.status.value}"
1454
+ )
1455
+
437
1456
  # Emit result event (tool result format)
438
1457
  await parent_ctx.emit(EventType.TOOL_RESULT, {
439
1458
  "name": agent_tool.name,
@@ -441,6 +1460,7 @@ async def invoke_agent(
441
1460
  "sub_agent_key": agent_tool.agent.key,
442
1461
  "response": response[:500] if response else "", # Truncate for event
443
1462
  "handoff": agent_tool.invocation_mode == InvocationMode.HANDOFF,
1463
+ "handback_status": handback.status.value if handback else None,
444
1464
  })
445
1465
 
446
1466
  # Also emit a custom sub_agent.end event for UI display
@@ -450,14 +1470,16 @@ async def invoke_agent(
450
1470
  "tool_name": agent_tool.name,
451
1471
  "success": True,
452
1472
  "handoff": agent_tool.invocation_mode == InvocationMode.HANDOFF,
1473
+ "handback": handback.to_dict() if handback else None,
453
1474
  })
454
-
1475
+
455
1476
  return AgentInvocationResult(
456
1477
  response=response,
457
1478
  messages=run_result.final_messages,
458
1479
  handoff=agent_tool.invocation_mode == InvocationMode.HANDOFF,
459
1480
  run_result=run_result,
460
1481
  sub_agent_key=agent_tool.agent.key,
1482
+ handback=handback,
461
1483
  )
462
1484
 
463
1485
 
@@ -551,9 +1573,9 @@ def register_agent_tools(
551
1573
  get_conversation_history=get_conversation_history,
552
1574
  parent_ctx=parent_ctx,
553
1575
  )
554
-
1576
+
555
1577
  schema = agent_tool.to_tool_schema()
556
-
1578
+
557
1579
  registry.register(Tool(
558
1580
  name=agent_tool.name,
559
1581
  description=agent_tool.description,
@@ -567,3 +1589,383 @@ def register_agent_tools(
567
1589
  },
568
1590
  ))
569
1591
 
1592
+
1593
+ # =============================================================================
1594
+ # Fallback-Enabled Invocation
1595
+ # =============================================================================
1596
+
1597
+
1598
+ async def invoke_agent_with_fallback(
1599
+ agent_tool: AgentTool,
1600
+ message: str,
1601
+ parent_ctx: RunContext,
1602
+ conversation_history: Optional[list[Message]] = None,
1603
+ additional_context: Optional[str] = None,
1604
+ fallback_config: Optional[FallbackConfig] = None,
1605
+ fallback_agent_tool: Optional[AgentTool] = None,
1606
+ ) -> AgentInvocationResult:
1607
+ """
1608
+ Invoke a sub-agent with automatic fallback on failure.
1609
+
1610
+ If the primary agent fails, automatically routes to a fallback agent
1611
+ (typically Triage) to ensure the user is never left without a response.
1612
+
1613
+ Args:
1614
+ agent_tool: The primary AgentTool to invoke
1615
+ message: The message/task to send
1616
+ parent_ctx: The parent agent's run context
1617
+ conversation_history: Full conversation history
1618
+ additional_context: Optional extra context
1619
+ fallback_config: Configuration for fallback behavior
1620
+ fallback_agent_tool: The fallback agent to use on failure
1621
+
1622
+ Returns:
1623
+ AgentInvocationResult from either primary or fallback agent
1624
+
1625
+ Example:
1626
+ result = await invoke_agent_with_fallback(
1627
+ agent_tool=billing_specialist,
1628
+ message="Process refund",
1629
+ parent_ctx=ctx,
1630
+ fallback_config=FallbackConfig(fallback_agent_key="triage"),
1631
+ fallback_agent_tool=triage_agent_tool,
1632
+ )
1633
+ """
1634
+ import asyncio
1635
+
1636
+ config = fallback_config or FallbackConfig()
1637
+ last_error: Optional[Exception] = None
1638
+
1639
+ # Try primary agent with retries
1640
+ for attempt in range(config.max_retries + 1):
1641
+ try:
1642
+ result = await invoke_agent(
1643
+ agent_tool=agent_tool,
1644
+ message=message,
1645
+ parent_ctx=parent_ctx,
1646
+ conversation_history=conversation_history,
1647
+ additional_context=additional_context,
1648
+ )
1649
+ return result
1650
+ except Exception as e:
1651
+ last_error = e
1652
+ logger.warning(
1653
+ f"Agent '{agent_tool.name}' failed (attempt {attempt + 1}/{config.max_retries + 1}): {e}"
1654
+ )
1655
+ if attempt < config.max_retries:
1656
+ await asyncio.sleep(config.retry_delay_seconds)
1657
+
1658
+ # Primary agent failed, try fallback
1659
+ if fallback_agent_tool:
1660
+ logger.info(
1661
+ f"Falling back to '{fallback_agent_tool.name}' after "
1662
+ f"'{agent_tool.name}' failed"
1663
+ )
1664
+
1665
+ # Build fallback context
1666
+ fallback_context = config.fallback_message
1667
+ if config.capture_error and last_error:
1668
+ fallback_context += f"\n\nPrevious agent error: {str(last_error)}"
1669
+
1670
+ # Emit fallback event
1671
+ await parent_ctx.emit("agent.fallback", {
1672
+ "failed_agent": agent_tool.name,
1673
+ "fallback_agent": fallback_agent_tool.name,
1674
+ "error": str(last_error) if last_error else None,
1675
+ })
1676
+
1677
+ try:
1678
+ return await invoke_agent(
1679
+ agent_tool=fallback_agent_tool,
1680
+ message=message,
1681
+ parent_ctx=parent_ctx,
1682
+ conversation_history=conversation_history,
1683
+ additional_context=fallback_context,
1684
+ )
1685
+ except Exception as fallback_error:
1686
+ logger.exception(f"Fallback agent '{fallback_agent_tool.name}' also failed")
1687
+ # Re-raise the original error
1688
+ raise last_error or fallback_error
1689
+
1690
+ # No fallback available, re-raise the error
1691
+ if last_error:
1692
+ raise last_error
1693
+ raise RuntimeError(f"Agent '{agent_tool.name}' failed with no fallback available")
1694
+
1695
+
1696
+ # =============================================================================
1697
+ # Journey Manager
1698
+ # =============================================================================
1699
+
1700
+
1701
+ # Memory key for storing active journey state
1702
+ JOURNEY_STATE_KEY = "system.active_journey"
1703
+
1704
+
1705
+ class JourneyManager:
1706
+ """
1707
+ Manages journey mode for multi-turn agent control.
1708
+
1709
+ A journey allows a delegated agent to maintain control across multiple
1710
+ user turns without routing back to the entry point (e.g., Triage) each time.
1711
+
1712
+ The JourneyManager:
1713
+ - Stores active journey state in conversation-scoped memory
1714
+ - Routes subsequent messages to the journey agent
1715
+ - Detects journey end conditions (completion, topic change, stuck, timeout)
1716
+ - Handles handback to the entry agent with summary
1717
+
1718
+ Example:
1719
+ manager = JourneyManager(memory_store=store, conversation_id=conv_id)
1720
+
1721
+ # Start a journey
1722
+ await manager.start_journey(JourneyState(
1723
+ agent_key="onboarding_agent",
1724
+ purpose="Complete user onboarding",
1725
+ ))
1726
+
1727
+ # Check if there's an active journey
1728
+ journey = await manager.get_active_journey()
1729
+ if journey:
1730
+ # Route to journey agent instead of Triage
1731
+ result = await invoke_agent(journey_agent_tool, message, ctx)
1732
+
1733
+ # Check for journey end
1734
+ end_result = await manager.check_journey_end(result, messages)
1735
+ if end_result:
1736
+ await manager.end_journey(end_result.reason)
1737
+ """
1738
+
1739
+ def __init__(
1740
+ self,
1741
+ memory_store: Optional["SharedMemoryStore"] = None,
1742
+ conversation_id: Optional[UUID] = None,
1743
+ stuck_detector: Optional[StuckDetector] = None,
1744
+ topic_change_threshold: float = 0.3,
1745
+ ):
1746
+ """
1747
+ Initialize the journey manager.
1748
+
1749
+ Args:
1750
+ memory_store: Store for persisting journey state
1751
+ conversation_id: The conversation this manager is for
1752
+ stuck_detector: Detector for stuck/loop conditions
1753
+ topic_change_threshold: Similarity threshold for topic change detection
1754
+ """
1755
+ self.memory_store = memory_store
1756
+ self.conversation_id = conversation_id
1757
+ self.stuck_detector = stuck_detector or StuckDetector()
1758
+ self.topic_change_threshold = topic_change_threshold
1759
+ self._journey_state: Optional[JourneyState] = None
1760
+
1761
+ async def get_active_journey(self) -> Optional[JourneyState]:
1762
+ """
1763
+ Get the currently active journey for this conversation.
1764
+
1765
+ Returns:
1766
+ JourneyState if there's an active journey, None otherwise
1767
+ """
1768
+ # Check in-memory cache first
1769
+ if self._journey_state:
1770
+ return self._journey_state
1771
+
1772
+ # Try to load from memory store
1773
+ if self.memory_store and self.conversation_id:
1774
+ try:
1775
+ item = await self.memory_store.get(
1776
+ JOURNEY_STATE_KEY,
1777
+ scope="conversation",
1778
+ conversation_id=self.conversation_id,
1779
+ )
1780
+ if item and item.value:
1781
+ self._journey_state = JourneyState.from_dict(item.value)
1782
+ return self._journey_state
1783
+ except Exception as e:
1784
+ logger.warning(f"Failed to load journey state: {e}")
1785
+
1786
+ return None
1787
+
1788
+ async def start_journey(self, journey: JourneyState) -> None:
1789
+ """
1790
+ Start a new journey.
1791
+
1792
+ Args:
1793
+ journey: The journey state to start
1794
+ """
1795
+ self._journey_state = journey
1796
+
1797
+ # Persist to memory store
1798
+ if self.memory_store and self.conversation_id:
1799
+ try:
1800
+ await self.memory_store.set(
1801
+ JOURNEY_STATE_KEY,
1802
+ journey.to_dict(),
1803
+ scope="conversation",
1804
+ conversation_id=self.conversation_id,
1805
+ source="journey_manager",
1806
+ )
1807
+ except Exception as e:
1808
+ logger.warning(f"Failed to persist journey state: {e}")
1809
+
1810
+ logger.info(
1811
+ f"Started journey '{journey.journey_id}' with agent '{journey.agent_key}': "
1812
+ f"{journey.purpose}"
1813
+ )
1814
+
1815
+ async def update_journey(self, checkpoint: Optional[str] = None) -> None:
1816
+ """
1817
+ Update the journey state (increment turn, add checkpoint).
1818
+
1819
+ Args:
1820
+ checkpoint: Optional checkpoint summary to add
1821
+ """
1822
+ if not self._journey_state:
1823
+ return
1824
+
1825
+ self._journey_state.increment_turn()
1826
+ if checkpoint:
1827
+ self._journey_state.add_checkpoint(checkpoint)
1828
+
1829
+ # Persist updated state
1830
+ if self.memory_store and self.conversation_id:
1831
+ try:
1832
+ await self.memory_store.set(
1833
+ JOURNEY_STATE_KEY,
1834
+ self._journey_state.to_dict(),
1835
+ scope="conversation",
1836
+ conversation_id=self.conversation_id,
1837
+ source="journey_manager",
1838
+ )
1839
+ except Exception as e:
1840
+ logger.warning(f"Failed to update journey state: {e}")
1841
+
1842
+ async def end_journey(self, reason: JourneyEndReason) -> JourneyEndResult:
1843
+ """
1844
+ End the current journey.
1845
+
1846
+ Args:
1847
+ reason: Why the journey is ending
1848
+
1849
+ Returns:
1850
+ JourneyEndResult with summary and handback info
1851
+ """
1852
+ journey = self._journey_state
1853
+ if not journey:
1854
+ return JourneyEndResult(
1855
+ reason=reason,
1856
+ summary="No active journey",
1857
+ )
1858
+
1859
+ # Build summary from checkpoints
1860
+ summary = f"Journey '{journey.purpose}' ended after {journey.current_turn} turns."
1861
+ if journey.checkpoints:
1862
+ summary += f" Checkpoints: {'; '.join(journey.checkpoints[-3:])}"
1863
+
1864
+ result = JourneyEndResult(
1865
+ reason=reason,
1866
+ summary=summary,
1867
+ )
1868
+
1869
+ # Clear journey state
1870
+ self._journey_state = None
1871
+
1872
+ # Remove from memory store
1873
+ if self.memory_store and self.conversation_id:
1874
+ try:
1875
+ await self.memory_store.delete(
1876
+ JOURNEY_STATE_KEY,
1877
+ scope="conversation",
1878
+ conversation_id=self.conversation_id,
1879
+ )
1880
+ except Exception as e:
1881
+ logger.warning(f"Failed to clear journey state: {e}")
1882
+
1883
+ logger.info(
1884
+ f"Ended journey '{journey.journey_id}' (reason={reason.value}): {summary}"
1885
+ )
1886
+
1887
+ return result
1888
+
1889
+ def check_journey_end(
1890
+ self,
1891
+ invocation_result: AgentInvocationResult,
1892
+ messages: list[Message],
1893
+ user_message: Optional[str] = None,
1894
+ ) -> Optional[JourneyEndResult]:
1895
+ """
1896
+ Check if the journey should end based on the latest interaction.
1897
+
1898
+ Args:
1899
+ invocation_result: Result from the journey agent
1900
+ messages: Full conversation history
1901
+ user_message: The latest user message (for topic change detection)
1902
+
1903
+ Returns:
1904
+ JourneyEndResult if journey should end, None otherwise
1905
+ """
1906
+ journey = self._journey_state
1907
+ if not journey:
1908
+ return None
1909
+
1910
+ # Check for structured handback completion
1911
+ if invocation_result.handback:
1912
+ handback = invocation_result.handback
1913
+ if handback.status == HandbackStatus.COMPLETED:
1914
+ return JourneyEndResult(
1915
+ reason=JourneyEndReason.COMPLETED,
1916
+ handback=handback,
1917
+ summary=handback.summary,
1918
+ )
1919
+ elif handback.status == HandbackStatus.ESCALATE:
1920
+ return JourneyEndResult(
1921
+ reason=JourneyEndReason.ESCALATION,
1922
+ handback=handback,
1923
+ summary=handback.summary,
1924
+ next_agent=handback.recommendation,
1925
+ )
1926
+
1927
+ # Check for timeout
1928
+ if journey.is_timeout():
1929
+ return JourneyEndResult(
1930
+ reason=JourneyEndReason.TIMEOUT,
1931
+ summary=f"Journey exceeded maximum turns ({journey.max_turns})",
1932
+ )
1933
+
1934
+ # Check for stuck condition
1935
+ stuck_result = self.stuck_detector.analyze(messages)
1936
+ if stuck_result.is_stuck and stuck_result.confidence >= 0.7:
1937
+ return JourneyEndResult(
1938
+ reason=JourneyEndReason.STUCK,
1939
+ summary=f"Conversation stuck: {stuck_result.condition.value if stuck_result.condition else 'unknown'}",
1940
+ )
1941
+
1942
+ # Check for user exit keywords
1943
+ if user_message:
1944
+ exit_keywords = ["cancel", "stop", "exit", "quit", "nevermind", "never mind", "forget it"]
1945
+ user_lower = user_message.lower()
1946
+ for keyword in exit_keywords:
1947
+ if keyword in user_lower:
1948
+ return JourneyEndResult(
1949
+ reason=JourneyEndReason.USER_EXIT,
1950
+ summary=f"User requested exit: '{user_message[:50]}'",
1951
+ )
1952
+
1953
+ # Check for topic change (simple heuristic)
1954
+ if user_message and journey.purpose:
1955
+ # Very basic topic change detection - could be enhanced with embeddings
1956
+ purpose_words = set(journey.purpose.lower().split())
1957
+ message_words = set(user_message.lower().split())
1958
+ overlap = len(purpose_words & message_words) / max(len(purpose_words), 1)
1959
+
1960
+ # If very low overlap and message is a question about something else
1961
+ if overlap < self.topic_change_threshold and "?" in user_message:
1962
+ # Check if it seems like a new topic
1963
+ new_topic_indicators = ["can you", "what about", "how do i", "tell me about", "help me with"]
1964
+ if any(indicator in user_message.lower() for indicator in new_topic_indicators):
1965
+ return JourneyEndResult(
1966
+ reason=JourneyEndReason.TOPIC_CHANGE,
1967
+ summary=f"User changed topic: '{user_message[:50]}'",
1968
+ )
1969
+
1970
+ return None
1971
+