voxagent 0.1.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.
- voxagent/__init__.py +143 -0
- voxagent/_version.py +5 -0
- voxagent/agent/__init__.py +32 -0
- voxagent/agent/abort.py +178 -0
- voxagent/agent/core.py +902 -0
- voxagent/code/__init__.py +9 -0
- voxagent/mcp/__init__.py +16 -0
- voxagent/mcp/manager.py +188 -0
- voxagent/mcp/tool.py +152 -0
- voxagent/providers/__init__.py +110 -0
- voxagent/providers/anthropic.py +498 -0
- voxagent/providers/augment.py +293 -0
- voxagent/providers/auth.py +116 -0
- voxagent/providers/base.py +268 -0
- voxagent/providers/chatgpt.py +415 -0
- voxagent/providers/claudecode.py +162 -0
- voxagent/providers/cli_base.py +265 -0
- voxagent/providers/codex.py +183 -0
- voxagent/providers/failover.py +90 -0
- voxagent/providers/google.py +532 -0
- voxagent/providers/groq.py +96 -0
- voxagent/providers/ollama.py +425 -0
- voxagent/providers/openai.py +435 -0
- voxagent/providers/registry.py +175 -0
- voxagent/py.typed +1 -0
- voxagent/security/__init__.py +14 -0
- voxagent/security/events.py +75 -0
- voxagent/security/filter.py +169 -0
- voxagent/security/registry.py +87 -0
- voxagent/session/__init__.py +39 -0
- voxagent/session/compaction.py +237 -0
- voxagent/session/lock.py +103 -0
- voxagent/session/model.py +109 -0
- voxagent/session/storage.py +184 -0
- voxagent/streaming/__init__.py +52 -0
- voxagent/streaming/emitter.py +286 -0
- voxagent/streaming/events.py +255 -0
- voxagent/subagent/__init__.py +20 -0
- voxagent/subagent/context.py +124 -0
- voxagent/subagent/definition.py +172 -0
- voxagent/tools/__init__.py +32 -0
- voxagent/tools/context.py +50 -0
- voxagent/tools/decorator.py +175 -0
- voxagent/tools/definition.py +131 -0
- voxagent/tools/executor.py +109 -0
- voxagent/tools/policy.py +89 -0
- voxagent/tools/registry.py +89 -0
- voxagent/types/__init__.py +46 -0
- voxagent/types/messages.py +134 -0
- voxagent/types/run.py +176 -0
- voxagent-0.1.0.dist-info/METADATA +186 -0
- voxagent-0.1.0.dist-info/RECORD +53 -0
- voxagent-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Redaction filters for voxagent."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from voxagent.security.events import SecurityEvent, SecurityEventEmitter
|
|
8
|
+
from voxagent.security.registry import SecretRegistry
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from voxagent.types.messages import Message
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RedactionFilter:
|
|
15
|
+
"""Filters secrets from text."""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
registry: SecretRegistry,
|
|
20
|
+
placeholder: str = "[REDACTED]",
|
|
21
|
+
event_emitter: SecurityEventEmitter | None = None,
|
|
22
|
+
) -> None:
|
|
23
|
+
"""Initialize a redaction filter.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
registry: The secret registry to use for detection.
|
|
27
|
+
placeholder: The text to replace secrets with.
|
|
28
|
+
event_emitter: Optional event emitter for security events.
|
|
29
|
+
"""
|
|
30
|
+
self.registry = registry
|
|
31
|
+
self.placeholder = placeholder
|
|
32
|
+
self.event_emitter = event_emitter
|
|
33
|
+
|
|
34
|
+
def redact(self, text: str) -> str:
|
|
35
|
+
"""Redact all secrets from text.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
text: The text to redact secrets from.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
Text with all secrets replaced by the placeholder.
|
|
42
|
+
"""
|
|
43
|
+
secrets = self.registry.find_secrets(text)
|
|
44
|
+
if not secrets:
|
|
45
|
+
return text
|
|
46
|
+
|
|
47
|
+
# Emit events for each secret found
|
|
48
|
+
if self.event_emitter:
|
|
49
|
+
for match, start, end in secrets:
|
|
50
|
+
# Emit SECRET_REDACTED event
|
|
51
|
+
self.event_emitter.emit(
|
|
52
|
+
SecurityEvent.SECRET_REDACTED,
|
|
53
|
+
{"match": match, "start": start, "end": end},
|
|
54
|
+
)
|
|
55
|
+
# Emit PATTERN_MATCHED event
|
|
56
|
+
self.event_emitter.emit(
|
|
57
|
+
SecurityEvent.PATTERN_MATCHED,
|
|
58
|
+
{"match": match, "start": start, "end": end},
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# Replace from end to start to preserve positions
|
|
62
|
+
result = text
|
|
63
|
+
for match, start, end in reversed(secrets):
|
|
64
|
+
result = result[:start] + self.placeholder + result[end:]
|
|
65
|
+
|
|
66
|
+
return result
|
|
67
|
+
|
|
68
|
+
def redact_message(self, message: Message) -> Message:
|
|
69
|
+
"""Redact secrets from a message.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
message: The message to redact secrets from.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
A new message with redacted content, or the original if unchanged.
|
|
76
|
+
"""
|
|
77
|
+
from voxagent.types.messages import Message as MessageClass
|
|
78
|
+
|
|
79
|
+
# Handle None or non-string content
|
|
80
|
+
if message.content is None or not isinstance(message.content, str):
|
|
81
|
+
return message
|
|
82
|
+
|
|
83
|
+
redacted_content = self.redact(message.content)
|
|
84
|
+
if redacted_content == message.content:
|
|
85
|
+
return message
|
|
86
|
+
|
|
87
|
+
# Create new message with redacted content
|
|
88
|
+
return MessageClass(
|
|
89
|
+
role=message.role,
|
|
90
|
+
content=redacted_content,
|
|
91
|
+
tool_calls=message.tool_calls,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class StreamFilter:
|
|
96
|
+
"""Streaming-aware redaction with buffering."""
|
|
97
|
+
|
|
98
|
+
def __init__(
|
|
99
|
+
self,
|
|
100
|
+
registry: SecretRegistry,
|
|
101
|
+
buffer_size: int = 100,
|
|
102
|
+
) -> None:
|
|
103
|
+
"""Initialize a stream filter.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
registry: The secret registry to use for detection.
|
|
107
|
+
buffer_size: Number of characters to buffer for partial match detection.
|
|
108
|
+
"""
|
|
109
|
+
self.registry = registry
|
|
110
|
+
self.buffer_size = buffer_size
|
|
111
|
+
self._buffer: str = ""
|
|
112
|
+
|
|
113
|
+
def process_chunk(self, chunk: str) -> str:
|
|
114
|
+
"""Process a streaming chunk, buffering for potential secrets.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
chunk: The chunk of text to process.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
The safe portion of text that can be output.
|
|
121
|
+
"""
|
|
122
|
+
self._buffer += chunk
|
|
123
|
+
|
|
124
|
+
# If buffer is smaller than buffer_size, hold everything
|
|
125
|
+
if len(self._buffer) <= self.buffer_size:
|
|
126
|
+
return ""
|
|
127
|
+
|
|
128
|
+
# Find safe output point (keep buffer_size chars for potential matches)
|
|
129
|
+
safe_end = len(self._buffer) - self.buffer_size
|
|
130
|
+
output = self._buffer[:safe_end]
|
|
131
|
+
self._buffer = self._buffer[safe_end:]
|
|
132
|
+
|
|
133
|
+
# Redact the output portion
|
|
134
|
+
return self._redact_text(output)
|
|
135
|
+
|
|
136
|
+
def flush(self) -> str:
|
|
137
|
+
"""Flush remaining buffer.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
The remaining buffered text, redacted as needed.
|
|
141
|
+
"""
|
|
142
|
+
output = self._buffer
|
|
143
|
+
self._buffer = ""
|
|
144
|
+
|
|
145
|
+
# Redact remaining buffer
|
|
146
|
+
return self._redact_text(output)
|
|
147
|
+
|
|
148
|
+
def _redact_text(self, text: str) -> str:
|
|
149
|
+
"""Redact secrets from text.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
text: The text to redact.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
The redacted text.
|
|
156
|
+
"""
|
|
157
|
+
if not text:
|
|
158
|
+
return text
|
|
159
|
+
|
|
160
|
+
secrets = self.registry.find_secrets(text)
|
|
161
|
+
if not secrets:
|
|
162
|
+
return text
|
|
163
|
+
|
|
164
|
+
result = text
|
|
165
|
+
for match, start, end in reversed(secrets):
|
|
166
|
+
result = result[:start] + "[REDACTED]" + result[end:]
|
|
167
|
+
|
|
168
|
+
return result
|
|
169
|
+
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Secret registry for voxagent."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import threading
|
|
7
|
+
from typing import Pattern
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SecretRegistry:
|
|
11
|
+
"""Thread-safe registry for secret patterns and values."""
|
|
12
|
+
|
|
13
|
+
def __init__(self) -> None:
|
|
14
|
+
"""Initialize an empty registry."""
|
|
15
|
+
self._patterns: list[Pattern[str]] = []
|
|
16
|
+
self._values: dict[str, str] = {} # name -> value
|
|
17
|
+
self._lock = threading.Lock()
|
|
18
|
+
|
|
19
|
+
def register_pattern(self, pattern: str) -> None:
|
|
20
|
+
"""Register a regex pattern to detect secrets.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
pattern: A regex pattern string to match secrets.
|
|
24
|
+
"""
|
|
25
|
+
with self._lock:
|
|
26
|
+
compiled = re.compile(pattern)
|
|
27
|
+
self._patterns.append(compiled)
|
|
28
|
+
|
|
29
|
+
def register_value(self, name: str, value: str) -> None:
|
|
30
|
+
"""Register a specific value to redact.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
name: A name/key for the secret.
|
|
34
|
+
value: The actual secret value to detect.
|
|
35
|
+
"""
|
|
36
|
+
with self._lock:
|
|
37
|
+
self._values[name] = value
|
|
38
|
+
|
|
39
|
+
def contains_secret(self, text: str) -> bool:
|
|
40
|
+
"""Check if text contains any registered secrets.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
text: The text to check for secrets.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
True if any registered secret pattern or value is found.
|
|
47
|
+
"""
|
|
48
|
+
with self._lock:
|
|
49
|
+
# Check patterns
|
|
50
|
+
for pattern in self._patterns:
|
|
51
|
+
if pattern.search(text):
|
|
52
|
+
return True
|
|
53
|
+
# Check values
|
|
54
|
+
for value in self._values.values():
|
|
55
|
+
if value in text:
|
|
56
|
+
return True
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
def find_secrets(self, text: str) -> list[tuple[str, int, int]]:
|
|
60
|
+
"""Find all secrets in text.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
text: The text to search for secrets.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
List of (match, start, end) tuples for each found secret.
|
|
67
|
+
"""
|
|
68
|
+
results: list[tuple[str, int, int]] = []
|
|
69
|
+
with self._lock:
|
|
70
|
+
# Find pattern matches
|
|
71
|
+
for pattern in self._patterns:
|
|
72
|
+
for match in pattern.finditer(text):
|
|
73
|
+
results.append((match.group(), match.start(), match.end()))
|
|
74
|
+
# Find value matches
|
|
75
|
+
for value in self._values.values():
|
|
76
|
+
start = 0
|
|
77
|
+
while True:
|
|
78
|
+
idx = text.find(value, start)
|
|
79
|
+
if idx == -1:
|
|
80
|
+
break
|
|
81
|
+
results.append((value, idx, idx + len(value)))
|
|
82
|
+
start = idx + 1
|
|
83
|
+
|
|
84
|
+
# Sort by position
|
|
85
|
+
results.sort(key=lambda x: x[1])
|
|
86
|
+
return results
|
|
87
|
+
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Session management and persistence.
|
|
2
|
+
|
|
3
|
+
This subpackage provides:
|
|
4
|
+
- Session model with messages and metadata
|
|
5
|
+
- File-based session storage (JSONL format)
|
|
6
|
+
- Session locking for concurrent access
|
|
7
|
+
- Context compaction and token management
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from voxagent.session.compaction import (
|
|
11
|
+
CompactionStrategy,
|
|
12
|
+
compact_context,
|
|
13
|
+
count_message_tokens,
|
|
14
|
+
count_tokens,
|
|
15
|
+
needs_compaction,
|
|
16
|
+
)
|
|
17
|
+
from voxagent.session.lock import LockTimeoutError, SessionLock, SessionLockManager
|
|
18
|
+
from voxagent.session.model import Session, resolve_session_key
|
|
19
|
+
from voxagent.session.storage import (
|
|
20
|
+
FileSessionStorage,
|
|
21
|
+
InMemorySessionStorage,
|
|
22
|
+
SessionStorage,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"CompactionStrategy",
|
|
27
|
+
"FileSessionStorage",
|
|
28
|
+
"InMemorySessionStorage",
|
|
29
|
+
"LockTimeoutError",
|
|
30
|
+
"Session",
|
|
31
|
+
"SessionLock",
|
|
32
|
+
"SessionLockManager",
|
|
33
|
+
"SessionStorage",
|
|
34
|
+
"compact_context",
|
|
35
|
+
"count_message_tokens",
|
|
36
|
+
"count_tokens",
|
|
37
|
+
"needs_compaction",
|
|
38
|
+
"resolve_session_key",
|
|
39
|
+
]
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Context compaction for voxagent.
|
|
2
|
+
|
|
3
|
+
This module provides functionality to manage token limits by:
|
|
4
|
+
- Counting tokens in messages and text
|
|
5
|
+
- Detecting when compaction is needed
|
|
6
|
+
- Compacting context using various strategies
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from voxagent.types.messages import Message, ToolResultBlock
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CompactionStrategy(Enum):
|
|
18
|
+
"""Available compaction strategies."""
|
|
19
|
+
|
|
20
|
+
REMOVE_TOOL_RESULTS = "remove_tool_results"
|
|
21
|
+
TRUNCATE_OLDEST = "truncate_oldest"
|
|
22
|
+
SUMMARIZE = "summarize" # Requires LLM call (placeholder)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def count_tokens(text: str, model: str = "gpt-4") -> int:
|
|
26
|
+
"""Count tokens in text.
|
|
27
|
+
|
|
28
|
+
Uses tiktoken for OpenAI models, estimates for others.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
text: The text to count tokens for.
|
|
32
|
+
model: The model name for tokenization.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
Number of tokens in the text.
|
|
36
|
+
"""
|
|
37
|
+
if not text:
|
|
38
|
+
return 0
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
import tiktoken
|
|
42
|
+
|
|
43
|
+
# Map model names to tiktoken encodings
|
|
44
|
+
if "gpt-4" in model or "gpt-3.5" in model:
|
|
45
|
+
try:
|
|
46
|
+
encoding = tiktoken.encoding_for_model(model)
|
|
47
|
+
except KeyError:
|
|
48
|
+
encoding = tiktoken.get_encoding("cl100k_base")
|
|
49
|
+
else:
|
|
50
|
+
# Default to cl100k_base for other models
|
|
51
|
+
encoding = tiktoken.get_encoding("cl100k_base")
|
|
52
|
+
|
|
53
|
+
return len(encoding.encode(text))
|
|
54
|
+
except ImportError:
|
|
55
|
+
# Fallback: estimate ~4 chars per token
|
|
56
|
+
return len(text) // 4
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def count_message_tokens(messages: list[Message], model: str = "gpt-4") -> int:
|
|
60
|
+
"""Count total tokens in a list of messages.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
messages: List of messages to count tokens for.
|
|
64
|
+
model: The model name for tokenization.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
Total token count across all messages.
|
|
68
|
+
"""
|
|
69
|
+
if not messages:
|
|
70
|
+
return 0
|
|
71
|
+
|
|
72
|
+
total = 0
|
|
73
|
+
for msg in messages:
|
|
74
|
+
# Role overhead (tokens for role, separators, message framing, metadata)
|
|
75
|
+
# Include overhead for message structure in API calls
|
|
76
|
+
total += 20
|
|
77
|
+
|
|
78
|
+
# Content tokens
|
|
79
|
+
if isinstance(msg.content, str):
|
|
80
|
+
total += count_tokens(msg.content, model)
|
|
81
|
+
elif isinstance(msg.content, list):
|
|
82
|
+
# Content blocks
|
|
83
|
+
for block in msg.content:
|
|
84
|
+
if hasattr(block, "text"):
|
|
85
|
+
total += count_tokens(block.text, model)
|
|
86
|
+
elif hasattr(block, "content"):
|
|
87
|
+
total += count_tokens(block.content, model)
|
|
88
|
+
elif isinstance(block, dict):
|
|
89
|
+
if "text" in block:
|
|
90
|
+
total += count_tokens(block["text"], model)
|
|
91
|
+
elif "content" in block:
|
|
92
|
+
total += count_tokens(block["content"], model)
|
|
93
|
+
|
|
94
|
+
# Tool calls
|
|
95
|
+
if msg.tool_calls:
|
|
96
|
+
for tc in msg.tool_calls:
|
|
97
|
+
total += count_tokens(tc.name, model)
|
|
98
|
+
if isinstance(tc.params, str):
|
|
99
|
+
total += count_tokens(tc.params, model)
|
|
100
|
+
elif isinstance(tc.params, dict):
|
|
101
|
+
import json
|
|
102
|
+
|
|
103
|
+
total += count_tokens(json.dumps(tc.params), model)
|
|
104
|
+
|
|
105
|
+
return total
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def needs_compaction(
|
|
109
|
+
messages: list[Message],
|
|
110
|
+
system_prompt: str,
|
|
111
|
+
max_tokens: int,
|
|
112
|
+
reserve_tokens: int = 0,
|
|
113
|
+
model: str = "gpt-4",
|
|
114
|
+
) -> bool:
|
|
115
|
+
"""Check if context needs compaction.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
messages: List of messages in the conversation.
|
|
119
|
+
system_prompt: The system prompt text.
|
|
120
|
+
max_tokens: Maximum allowed tokens.
|
|
121
|
+
reserve_tokens: Tokens to reserve for response.
|
|
122
|
+
model: The model name for tokenization.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
True if current tokens > (max_tokens - reserve_tokens).
|
|
126
|
+
"""
|
|
127
|
+
available = max_tokens - reserve_tokens
|
|
128
|
+
|
|
129
|
+
# Count system prompt tokens
|
|
130
|
+
current = count_tokens(system_prompt, model)
|
|
131
|
+
|
|
132
|
+
# Count message tokens
|
|
133
|
+
current += count_message_tokens(messages, model)
|
|
134
|
+
|
|
135
|
+
return current > available
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _has_tool_result_content(msg: Message) -> bool:
|
|
139
|
+
"""Check if a message contains tool result content blocks."""
|
|
140
|
+
if not isinstance(msg.content, list):
|
|
141
|
+
return False
|
|
142
|
+
return any(isinstance(block, ToolResultBlock) for block in msg.content)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def compact_context(
|
|
146
|
+
messages: list[Message],
|
|
147
|
+
target_tokens: int,
|
|
148
|
+
preserve_recent: int = 4,
|
|
149
|
+
model: str = "gpt-4",
|
|
150
|
+
aggressive: bool = False,
|
|
151
|
+
) -> list[Message]:
|
|
152
|
+
"""Compact messages to fit within target token count.
|
|
153
|
+
|
|
154
|
+
Strategies (in order):
|
|
155
|
+
1. Remove messages with tool result content blocks
|
|
156
|
+
2. Truncate from oldest messages
|
|
157
|
+
3. If aggressive: more aggressive truncation
|
|
158
|
+
|
|
159
|
+
Always preserves the most recent `preserve_recent` turns.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
messages: List of messages to compact.
|
|
163
|
+
target_tokens: Target maximum token count.
|
|
164
|
+
preserve_recent: Number of recent messages to preserve.
|
|
165
|
+
model: The model name for tokenization.
|
|
166
|
+
aggressive: Whether to use aggressive compaction.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
Compacted list of messages.
|
|
170
|
+
"""
|
|
171
|
+
if not messages:
|
|
172
|
+
return []
|
|
173
|
+
|
|
174
|
+
# Handle negative or zero target with preserve_recent=0
|
|
175
|
+
if target_tokens <= 0 and preserve_recent == 0:
|
|
176
|
+
return []
|
|
177
|
+
|
|
178
|
+
# Check if already under target
|
|
179
|
+
current_tokens = count_message_tokens(messages, model)
|
|
180
|
+
if current_tokens <= target_tokens:
|
|
181
|
+
return list(messages)
|
|
182
|
+
|
|
183
|
+
result = list(messages)
|
|
184
|
+
|
|
185
|
+
# Strategy 1: Remove messages with tool result content blocks from outside preserve window
|
|
186
|
+
if len(result) > preserve_recent:
|
|
187
|
+
if preserve_recent > 0:
|
|
188
|
+
compactable = result[:-preserve_recent]
|
|
189
|
+
preserved = result[-preserve_recent:]
|
|
190
|
+
else:
|
|
191
|
+
compactable = result
|
|
192
|
+
preserved = []
|
|
193
|
+
|
|
194
|
+
# Remove messages with tool result content blocks from compactable portion
|
|
195
|
+
compactable = [m for m in compactable if not _has_tool_result_content(m)]
|
|
196
|
+
result = compactable + preserved
|
|
197
|
+
|
|
198
|
+
current_tokens = count_message_tokens(result, model)
|
|
199
|
+
if current_tokens <= target_tokens:
|
|
200
|
+
return result
|
|
201
|
+
|
|
202
|
+
# If still over target, also remove tool results from preserved portion
|
|
203
|
+
if count_message_tokens(result, model) > target_tokens:
|
|
204
|
+
result = [m for m in result if not _has_tool_result_content(m)]
|
|
205
|
+
|
|
206
|
+
current_tokens = count_message_tokens(result, model)
|
|
207
|
+
if current_tokens <= target_tokens:
|
|
208
|
+
return result
|
|
209
|
+
|
|
210
|
+
# Strategy 2: Truncate from oldest messages (respecting preserve_recent)
|
|
211
|
+
while len(result) > preserve_recent and count_message_tokens(result, model) > target_tokens:
|
|
212
|
+
# Skip system messages at the beginning
|
|
213
|
+
if result[0].role == "system":
|
|
214
|
+
if len(result) > 1:
|
|
215
|
+
result = [result[0]] + result[2:]
|
|
216
|
+
else:
|
|
217
|
+
break
|
|
218
|
+
else:
|
|
219
|
+
result = result[1:] # Remove oldest message
|
|
220
|
+
|
|
221
|
+
# Strategy 3: If aggressive and still over, truncate more aggressively
|
|
222
|
+
if aggressive:
|
|
223
|
+
while len(result) > 1 and count_message_tokens(result, model) > target_tokens:
|
|
224
|
+
# In aggressive mode, we can remove from preserved messages too
|
|
225
|
+
# But keep at least one message
|
|
226
|
+
if result[0].role == "system":
|
|
227
|
+
if len(result) > 1:
|
|
228
|
+
result = [result[0]] + result[2:]
|
|
229
|
+
else:
|
|
230
|
+
break
|
|
231
|
+
else:
|
|
232
|
+
result = result[1:]
|
|
233
|
+
|
|
234
|
+
# If still over and only one message left, keep it (graceful handling)
|
|
235
|
+
|
|
236
|
+
return result
|
|
237
|
+
|
voxagent/session/lock.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Session locking for voxagent."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import threading
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LockTimeoutError(Exception):
|
|
11
|
+
"""Raised when lock acquisition times out."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, session_key: str, timeout: float) -> None:
|
|
14
|
+
self.session_key = session_key
|
|
15
|
+
self.timeout = timeout
|
|
16
|
+
super().__init__(f"Lock timeout for session {session_key} after {timeout}s")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Module-level lock registry keyed by (event_loop_id, session_key)
|
|
20
|
+
_lock_registry: dict[tuple[int, str], asyncio.Lock] = {}
|
|
21
|
+
_registry_thread_lock = threading.Lock()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _get_lock_for_session(session_key: str) -> asyncio.Lock:
|
|
25
|
+
"""Get or create the asyncio.Lock for a session key in the current event loop."""
|
|
26
|
+
loop = asyncio.get_running_loop()
|
|
27
|
+
key = (id(loop), session_key)
|
|
28
|
+
|
|
29
|
+
with _registry_thread_lock:
|
|
30
|
+
if key not in _lock_registry:
|
|
31
|
+
_lock_registry[key] = asyncio.Lock()
|
|
32
|
+
return _lock_registry[key]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SessionLock:
|
|
36
|
+
"""Async lock for session access."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, session_key: str, timeout: float = 30.0) -> None:
|
|
39
|
+
self.session_key = session_key
|
|
40
|
+
self.timeout = timeout
|
|
41
|
+
self._acquired: bool = False
|
|
42
|
+
|
|
43
|
+
async def acquire(self) -> bool:
|
|
44
|
+
"""Acquire the lock. Returns True if acquired, raises LockTimeoutError on timeout."""
|
|
45
|
+
lock = _get_lock_for_session(self.session_key)
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
await asyncio.wait_for(lock.acquire(), timeout=self.timeout)
|
|
49
|
+
self._acquired = True
|
|
50
|
+
return True
|
|
51
|
+
except asyncio.TimeoutError:
|
|
52
|
+
raise LockTimeoutError(self.session_key, self.timeout)
|
|
53
|
+
|
|
54
|
+
async def release(self) -> None:
|
|
55
|
+
"""Release the lock."""
|
|
56
|
+
if self._acquired:
|
|
57
|
+
lock = _get_lock_for_session(self.session_key)
|
|
58
|
+
try:
|
|
59
|
+
lock.release()
|
|
60
|
+
except RuntimeError:
|
|
61
|
+
# Lock was not held
|
|
62
|
+
pass
|
|
63
|
+
self._acquired = False
|
|
64
|
+
|
|
65
|
+
async def __aenter__(self) -> "SessionLock":
|
|
66
|
+
"""Async context manager entry."""
|
|
67
|
+
await self.acquire()
|
|
68
|
+
return self
|
|
69
|
+
|
|
70
|
+
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
71
|
+
"""Async context manager exit."""
|
|
72
|
+
await self.release()
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def is_locked(self) -> bool:
|
|
76
|
+
"""Check if lock is currently held by this instance."""
|
|
77
|
+
return self._acquired
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class SessionLockManager:
|
|
81
|
+
"""Manages locks for multiple sessions."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, timeout: float = 30.0) -> None:
|
|
84
|
+
self._locks: dict[str, SessionLock] = {}
|
|
85
|
+
self._timeout = timeout
|
|
86
|
+
|
|
87
|
+
def get_lock(self, session_key: str) -> SessionLock:
|
|
88
|
+
"""Get or create a lock for a session."""
|
|
89
|
+
if session_key not in self._locks:
|
|
90
|
+
self._locks[session_key] = SessionLock(session_key, self._timeout)
|
|
91
|
+
return self._locks[session_key]
|
|
92
|
+
|
|
93
|
+
async def acquire(self, session_key: str) -> SessionLock:
|
|
94
|
+
"""Acquire lock for a session."""
|
|
95
|
+
lock = self.get_lock(session_key)
|
|
96
|
+
await lock.acquire()
|
|
97
|
+
return lock
|
|
98
|
+
|
|
99
|
+
async def release(self, session_key: str) -> None:
|
|
100
|
+
"""Release lock for a session."""
|
|
101
|
+
if session_key in self._locks:
|
|
102
|
+
await self._locks[session_key].release()
|
|
103
|
+
|