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.
Files changed (53) hide show
  1. voxagent/__init__.py +143 -0
  2. voxagent/_version.py +5 -0
  3. voxagent/agent/__init__.py +32 -0
  4. voxagent/agent/abort.py +178 -0
  5. voxagent/agent/core.py +902 -0
  6. voxagent/code/__init__.py +9 -0
  7. voxagent/mcp/__init__.py +16 -0
  8. voxagent/mcp/manager.py +188 -0
  9. voxagent/mcp/tool.py +152 -0
  10. voxagent/providers/__init__.py +110 -0
  11. voxagent/providers/anthropic.py +498 -0
  12. voxagent/providers/augment.py +293 -0
  13. voxagent/providers/auth.py +116 -0
  14. voxagent/providers/base.py +268 -0
  15. voxagent/providers/chatgpt.py +415 -0
  16. voxagent/providers/claudecode.py +162 -0
  17. voxagent/providers/cli_base.py +265 -0
  18. voxagent/providers/codex.py +183 -0
  19. voxagent/providers/failover.py +90 -0
  20. voxagent/providers/google.py +532 -0
  21. voxagent/providers/groq.py +96 -0
  22. voxagent/providers/ollama.py +425 -0
  23. voxagent/providers/openai.py +435 -0
  24. voxagent/providers/registry.py +175 -0
  25. voxagent/py.typed +1 -0
  26. voxagent/security/__init__.py +14 -0
  27. voxagent/security/events.py +75 -0
  28. voxagent/security/filter.py +169 -0
  29. voxagent/security/registry.py +87 -0
  30. voxagent/session/__init__.py +39 -0
  31. voxagent/session/compaction.py +237 -0
  32. voxagent/session/lock.py +103 -0
  33. voxagent/session/model.py +109 -0
  34. voxagent/session/storage.py +184 -0
  35. voxagent/streaming/__init__.py +52 -0
  36. voxagent/streaming/emitter.py +286 -0
  37. voxagent/streaming/events.py +255 -0
  38. voxagent/subagent/__init__.py +20 -0
  39. voxagent/subagent/context.py +124 -0
  40. voxagent/subagent/definition.py +172 -0
  41. voxagent/tools/__init__.py +32 -0
  42. voxagent/tools/context.py +50 -0
  43. voxagent/tools/decorator.py +175 -0
  44. voxagent/tools/definition.py +131 -0
  45. voxagent/tools/executor.py +109 -0
  46. voxagent/tools/policy.py +89 -0
  47. voxagent/tools/registry.py +89 -0
  48. voxagent/types/__init__.py +46 -0
  49. voxagent/types/messages.py +134 -0
  50. voxagent/types/run.py +176 -0
  51. voxagent-0.1.0.dist-info/METADATA +186 -0
  52. voxagent-0.1.0.dist-info/RECORD +53 -0
  53. 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
+
@@ -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
+