Skip to content

Commit af6fc7d

Browse files
praisonai-triage-agent[bot]MervinPraisonCopilotclaude
authored
feat: Round-2 Gap Closure — Interrupts, Error Classifier, Path Overlap, Message Sanitization, Title Auto-Gen (5/12) (#1489)
* feat: implement Round-2 Gap Closure (G2, G5, G6, G8) - G2: Add InterruptController for cooperative cancellation of agent runs - G5: Add multi-category error classifier for intelligent retry logic - G6: Add message sanitization for surrogate/non-ASCII characters - G8: Add session title auto-generation from first exchange All features follow protocol-driven design with zero impact when disabled. Maintains full backward compatibility with existing APIs. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-authored-by: Mervin Praison <MervinPraison@users.noreply.github.com> * feat: implement G4 parallel tool path overlap detection - Add path_overlap module with conflict detection for write operations - Integrate with ParallelToolCallExecutor to fallback to sequential when conflicts detected - Prevent file corruption from concurrent writes to same/overlapping paths - Support path conflict grouping for sophisticated scheduling - Zero impact when no path conflicts exist 🤖 Generated with [Claude Code](https://claude.ai/code) Co-authored-by: Mervin Praison <MervinPraison@users.noreply.github.com> * fix: align gap-closure utilities with unit-test expectations Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/46d33039-4077-44a4-abb3-f7a4571f6162 Co-authored-by: MervinPraison <454862+MervinPraison@users.noreply.github.com> * docs+fix: guard retry attempt floor and align backoff examples Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/46d33039-4077-44a4-abb3-f7a4571f6162 Co-authored-by: MervinPraison <454862+MervinPraison@users.noreply.github.com> * fix: resolve all 8 hard blockers and CodeRabbit issues - B1: Wire error classifier into retry paths with proper attempt handling - B2: Add interrupt controller checks to sync run_autonomous() - B3: Fix silent failure - fail closed when path overlap unavailable - B4: Enhanced path overlap detection with custom write tool support - B5: Skip empty path strings (already implemented) - B6: Fix SessionMessage attribute access + async I/O blocking - B7: Handle asyncio.run() in running loop context - B8: Implement recursive sanitization for nested tool call arguments Additional fixes: - Add missing type imports (InterruptController) - Replace undefined MemoryManager with Any - Fix test determinism with threading.Event coordination - Add missing __all__ exports for error_classifier - Add InterruptControllerProtocol for extension point 🤖 Generated with [Claude Code](https://claude.ai/code) Co-authored-by: Claude <noreply@anthropic.com> * fix: resolve over-conservative write tool detection in path overlap - Add _READ_NAME_HINTS to distinguish read-only tools from writers - Update _is_potential_write_tool() to check read hints before fallback - Require both path args AND payload args for unknown tools - Fixes test_read_tool_no_paths while preserving custom write detection - Resolves performance regression from B4 fix in commit 76264e2 All 23 path overlap tests now pass. Read tools like read_file, get_file can run in parallel while custom write tools are still safely detected. Co-authored-by: Mervin Praison <MervinPraison@users.noreply.github.com> --------- Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com> Co-authored-by: Mervin Praison <MervinPraison@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: MervinPraison <454862+MervinPraison@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent ba1fade commit af6fc7d

16 files changed

Lines changed: 2015 additions & 11 deletions

File tree

src/praisonai-agents/praisonaiagents/agent/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ def __getattr__(name):
3333
from .heartbeat import HeartbeatConfig
3434
_lazy_cache[name] = HeartbeatConfig
3535
return HeartbeatConfig
36+
if name == 'InterruptController':
37+
from .interrupt import InterruptController
38+
_lazy_cache[name] = InterruptController
39+
return InterruptController
3640

3741
# Specialized agents - lazy loaded (import rich)
3842
if name == 'ImageAgent':
@@ -194,6 +198,7 @@ def __getattr__(name):
194198
'BudgetExceededError',
195199
'Heartbeat',
196200
'HeartbeatConfig',
201+
'InterruptController',
197202
'ImageAgent',
198203
'VideoAgent',
199204
'VideoConfig',

src/praisonai-agents/praisonaiagents/agent/agent.py

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,7 @@ def _get_default_server_registry() -> ServerRegistry:
245245
from ..context.models import ContextConfig
246246
from ..context.manager import ContextManager
247247
from ..knowledge.knowledge import Knowledge
248+
from .interrupt import InterruptController
248249
from ..agent.autonomy import AutonomyConfig
249250
from ..task.task import Task
250251
from .handoff import Handoff, HandoffConfig, HandoffResult
@@ -531,7 +532,7 @@ def __init__(
531532
# CONSOLIDATED FEATURE PARAMS (agent-centric API)
532533
# Each follows: False=disabled, True=defaults, Config=custom
533534
# ============================================================
534-
memory: Optional[Union[bool, str, 'MemoryConfig', 'MemoryManager']] = None,
535+
memory: Optional[Union[bool, str, 'MemoryConfig', Any]] = None,
535536
knowledge: Optional[Union[bool, str, List[str], 'KnowledgeConfig', 'Knowledge']] = None,
536537
planning: Optional[Union[bool, str, 'PlanningConfig']] = False,
537538
reflection: Optional[Union[bool, str, 'ReflectionConfig']] = None,
@@ -551,6 +552,7 @@ def __init__(
551552
parallel_tool_calls: bool = False, # Gap 2: Enable parallel execution of batched LLM tool calls
552553
learn: Optional[Union[bool, str, Dict[str, Any], 'LearnConfig']] = None, # Continuous learning (peer to memory)
553554
backend: Optional[Any] = None, # External managed agent backend (e.g., ManagedAgentIntegration)
555+
interrupt_controller: Optional['InterruptController'] = None, # G2: Cooperative cancellation
554556
):
555557
"""Initialize an Agent instance.
556558
@@ -575,7 +577,7 @@ def __init__(
575577
memory: Memory system configuration. Accepts:
576578
- bool: True enables defaults, False disables
577579
- MemoryConfig: Custom configuration
578-
- MemoryManager: Pre-configured instance
580+
- Any: Pre-configured memory instance
579581
knowledge: Knowledge sources. Accepts:
580582
- bool: True enables defaults
581583
- List[str]: File paths, URLs, or text content
@@ -1458,6 +1460,8 @@ def __init__(
14581460
self.instructions = instructions
14591461
# Gap 2: Store parallel tool calls setting for ToolCallExecutor selection
14601462
self.parallel_tool_calls = parallel_tool_calls
1463+
# G2: Store interrupt controller for cooperative cancellation
1464+
self.interrupt_controller = interrupt_controller
14611465
# Check for model name in environment variable if not provided
14621466
self._using_custom_llm = False
14631467
# Flag to track if final result has been displayed to prevent duplicates
@@ -2851,6 +2855,19 @@ def run_autonomous(
28512855
started_at=started_at,
28522856
)
28532857

2858+
# G2: Check for interrupt request (cooperative cancellation) - sync version
2859+
if self.interrupt_controller and self.interrupt_controller.is_set():
2860+
reason = self.interrupt_controller.reason or "unknown"
2861+
return AutonomyResult(
2862+
success=False,
2863+
output=f"Task interrupted: {reason}",
2864+
completion_reason="interrupted",
2865+
iterations=iterations,
2866+
stage=stage,
2867+
actions=actions_taken,
2868+
duration_seconds=time_module.time() - start_time,
2869+
started_at=started_at,
2870+
)
28542871

28552872
# Execute one turn using the agent's chat method
28562873
# Always use the original prompt (prompt re-injection)
@@ -3248,6 +3265,20 @@ async def main():
32483265
started_at=started_at,
32493266
)
32503267

3268+
# G2: Check for interrupt request (cooperative cancellation)
3269+
if self.interrupt_controller and self.interrupt_controller.is_set():
3270+
reason = self.interrupt_controller.reason or "unknown"
3271+
return AutonomyResult(
3272+
success=False,
3273+
output=f"Task interrupted: {reason}",
3274+
completion_reason="interrupted",
3275+
iterations=iterations,
3276+
stage=stage,
3277+
actions=actions_taken,
3278+
duration_seconds=time_module.time() - start_time,
3279+
started_at=started_at,
3280+
)
3281+
32513282

32523283
# Execute one turn using the agent's async chat method
32533284
# Always use the original prompt (prompt re-injection)
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
"""
2+
Interrupt Controller - Cooperative cancellation for agent runs.
3+
4+
Provides thread-safe, cooperative cancellation mechanism for long-running agent
5+
operations. Follows protocol-driven design with zero overhead when not used.
6+
"""
7+
8+
import threading
9+
from typing import Optional, Protocol
10+
from dataclasses import dataclass, field
11+
12+
__all__ = ["InterruptControllerProtocol", "InterruptController"]
13+
14+
15+
class InterruptControllerProtocol(Protocol):
16+
"""Protocol for interrupt controller extension point."""
17+
18+
def request(self, reason: str = "user") -> None:
19+
"""Request cancellation with optional reason."""
20+
...
21+
22+
def clear(self) -> None:
23+
"""Clear interrupt state."""
24+
...
25+
26+
def is_set(self) -> bool:
27+
"""Check if interrupt was requested."""
28+
...
29+
30+
@property
31+
def reason(self) -> Optional[str]:
32+
"""Get interrupt reason if set."""
33+
...
34+
35+
def check(self) -> None:
36+
"""Check for interrupt and raise if set."""
37+
...
38+
39+
40+
@dataclass
41+
class InterruptController:
42+
"""Thread-safe cooperative cancellation for agent runs.
43+
44+
Provides a lightweight mechanism for requesting cancellation of agent
45+
operations. Uses threading.Event for thread safety and cooperative
46+
checking patterns.
47+
48+
Examples:
49+
Basic usage:
50+
>>> controller = InterruptController()
51+
>>> # In another thread:
52+
>>> controller.request("user_cancel")
53+
>>> # In agent loop:
54+
>>> if controller.is_set():
55+
>>> return f"Cancelled: {controller.reason}"
56+
"""
57+
58+
_flag: threading.Event = field(default_factory=threading.Event, init=False, repr=False)
59+
_reason: Optional[str] = field(default=None, init=False)
60+
_lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False)
61+
62+
def request(self, reason: str = "user") -> None:
63+
"""Request cancellation with a reason.
64+
65+
Args:
66+
reason: Human-readable reason for cancellation
67+
"""
68+
with self._lock:
69+
if not self._flag.is_set():
70+
self._reason = reason
71+
self._flag.set()
72+
73+
def clear(self) -> None:
74+
"""Clear the cancellation request."""
75+
with self._lock:
76+
self._reason = None
77+
self._flag.clear()
78+
79+
def is_set(self) -> bool:
80+
"""Check if cancellation has been requested.
81+
82+
Returns:
83+
True if cancellation was requested
84+
"""
85+
return self._flag.is_set()
86+
87+
@property
88+
def reason(self) -> Optional[str]:
89+
"""Get the reason for cancellation.
90+
91+
Returns:
92+
Reason string if cancelled, None otherwise
93+
"""
94+
with self._lock:
95+
return self._reason
96+
97+
def check(self) -> None:
98+
"""Check for cancellation and raise if requested.
99+
100+
Raises:
101+
InterruptedError: If cancellation was requested
102+
"""
103+
if self.is_set():
104+
reason = self.reason or "unknown"
105+
raise InterruptedError(f"Operation cancelled: {reason}")

src/praisonai-agents/praisonaiagents/llm/__init__.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,26 @@ def __getattr__(name):
159159
from .unified_adapters import create_llm_dispatcher
160160
_lazy_cache[name] = create_llm_dispatcher
161161
return create_llm_dispatcher
162+
elif name == "sanitize_messages":
163+
from .sanitize import sanitize_messages
164+
_lazy_cache[name] = sanitize_messages
165+
return sanitize_messages
166+
elif name == "strip_surrogates":
167+
from .sanitize import strip_surrogates
168+
_lazy_cache[name] = strip_surrogates
169+
return strip_surrogates
170+
elif name == "sanitize_text":
171+
from .sanitize import sanitize_text
172+
_lazy_cache[name] = sanitize_text
173+
return sanitize_text
174+
elif name == "ErrorCategory":
175+
from .error_classifier import ErrorCategory
176+
_lazy_cache[name] = ErrorCategory
177+
return ErrorCategory
178+
elif name == "classify_error":
179+
from .error_classifier import classify_error
180+
_lazy_cache[name] = classify_error
181+
return classify_error
162182

163183
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
164184

@@ -199,5 +219,12 @@ def __getattr__(name):
199219
"LLMProviderError",
200220
"RateLimitError",
201221
"ModelNotAvailableError",
202-
"ContextLengthExceededError"
222+
"ContextLengthExceededError",
223+
# Sanitization (G6)
224+
"sanitize_messages",
225+
"strip_surrogates",
226+
"sanitize_text",
227+
# Error Classification (G5)
228+
"ErrorCategory",
229+
"classify_error"
203230
]

0 commit comments

Comments
 (0)