voidx 1.0.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.
- voidx/__init__.py +3 -0
- voidx/agent/__init__.py +0 -0
- voidx/agent/agents.py +439 -0
- voidx/agent/attachments.py +235 -0
- voidx/agent/graph.py +463 -0
- voidx/agent/graph_components/__init__.py +1 -0
- voidx/agent/graph_components/compaction.py +268 -0
- voidx/agent/graph_components/permissions.py +139 -0
- voidx/agent/graph_components/run_loop.py +532 -0
- voidx/agent/graph_components/runtime.py +14 -0
- voidx/agent/graph_components/streaming.py +351 -0
- voidx/agent/graph_components/subagent.py +278 -0
- voidx/agent/graph_components/tool_execution.py +208 -0
- voidx/agent/runtime_context.py +368 -0
- voidx/agent/slash.py +466 -0
- voidx/agent/slash_components/__init__.py +1 -0
- voidx/agent/slash_components/code_ide.py +68 -0
- voidx/agent/slash_components/lsp.py +105 -0
- voidx/agent/slash_components/mcp.py +332 -0
- voidx/agent/slash_components/model.py +419 -0
- voidx/agent/slash_components/runtime.py +55 -0
- voidx/agent/slash_components/skills.py +94 -0
- voidx/agent/state.py +32 -0
- voidx/agent/task_state.py +278 -0
- voidx/agent/tool_filters.py +27 -0
- voidx/config.py +707 -0
- voidx/llm/__init__.py +0 -0
- voidx/llm/catalog.py +188 -0
- voidx/llm/compaction.py +267 -0
- voidx/llm/context.py +43 -0
- voidx/llm/instruction.py +220 -0
- voidx/llm/provider.py +312 -0
- voidx/llm/usage.py +341 -0
- voidx/lsp/__init__.py +30 -0
- voidx/lsp/client.py +259 -0
- voidx/lsp/config.py +172 -0
- voidx/lsp/detector.py +512 -0
- voidx/lsp/errors.py +19 -0
- voidx/lsp/manager.py +280 -0
- voidx/lsp/schema.py +179 -0
- voidx/lsp/service.py +103 -0
- voidx/main.py +154 -0
- voidx/mcp/__init__.py +33 -0
- voidx/mcp/client.py +458 -0
- voidx/mcp/manager.py +267 -0
- voidx/mcp/schema.py +112 -0
- voidx/mcp/tool.py +122 -0
- voidx/mcp_servers/__init__.py +1 -0
- voidx/mcp_servers/web.py +104 -0
- voidx/memory/__init__.py +0 -0
- voidx/memory/context_frames.py +188 -0
- voidx/memory/model_profiles.py +98 -0
- voidx/memory/runtime_state.py +240 -0
- voidx/memory/session.py +272 -0
- voidx/memory/store.py +245 -0
- voidx/memory/transcript.py +137 -0
- voidx/permission/__init__.py +28 -0
- voidx/permission/engine.py +430 -0
- voidx/permission/evaluate.py +114 -0
- voidx/permission/sandbox.py +280 -0
- voidx/permission/schema.py +24 -0
- voidx/permission/service.py +314 -0
- voidx/permission/wildcard.py +34 -0
- voidx/skills/__init__.py +18 -0
- voidx/skills/bundled/superpowers/receiving-code-review/SKILL.md +30 -0
- voidx/skills/bundled/superpowers/requesting-code-review/SKILL.md +27 -0
- voidx/skills/bundled/superpowers/systematic-debugging/SKILL.md +36 -0
- voidx/skills/bundled/superpowers/test-driven-development/SKILL.md +33 -0
- voidx/skills/bundled/superpowers/verification-before-completion/SKILL.md +31 -0
- voidx/skills/bundled/superpowers/writing-plans/SKILL.md +27 -0
- voidx/skills/policy.py +97 -0
- voidx/skills/registry.py +162 -0
- voidx/skills/schema.py +47 -0
- voidx/skills/service.py +199 -0
- voidx/tools/__init__.py +0 -0
- voidx/tools/agent.py +81 -0
- voidx/tools/base.py +86 -0
- voidx/tools/bash.py +105 -0
- voidx/tools/file_ops.py +193 -0
- voidx/tools/lsp.py +155 -0
- voidx/tools/registry.py +104 -0
- voidx/tools/repomap.py +238 -0
- voidx/tools/search.py +162 -0
- voidx/tools/task_status.py +57 -0
- voidx/tools/task_tracker.py +81 -0
- voidx/tools/todo.py +82 -0
- voidx/tools/web_content.py +357 -0
- voidx/tools/web_mcp.py +107 -0
- voidx/tools/webfetch.py +155 -0
- voidx/tools/websearch.py +276 -0
- voidx/ui/__init__.py +0 -0
- voidx/ui/app.py +1033 -0
- voidx/ui/app_components/__init__.py +1 -0
- voidx/ui/app_components/clipboard_image.py +245 -0
- voidx/ui/app_components/commands.py +18 -0
- voidx/ui/app_components/controls.py +29 -0
- voidx/ui/app_components/file_picker.py +115 -0
- voidx/ui/app_components/formatting.py +187 -0
- voidx/ui/app_components/git_changes.py +51 -0
- voidx/ui/app_components/rendering.py +1169 -0
- voidx/ui/browse.py +160 -0
- voidx/ui/capture.py +169 -0
- voidx/ui/code_ide.py +251 -0
- voidx/ui/commands.py +83 -0
- voidx/ui/console.py +381 -0
- voidx/ui/console_components/__init__.py +1 -0
- voidx/ui/console_components/formatting.py +96 -0
- voidx/ui/console_components/streaming.py +253 -0
- voidx/ui/diff.py +331 -0
- voidx/ui/dock.py +372 -0
- voidx/ui/dock_components/__init__.py +1 -0
- voidx/ui/dock_components/formatting.py +123 -0
- voidx/ui/dock_components/nodes.py +401 -0
- voidx/ui/dock_components/state.py +51 -0
- voidx/ui/event_components/__init__.py +1 -0
- voidx/ui/event_components/schema.py +249 -0
- voidx/ui/events.py +341 -0
- voidx/ui/session_changes.py +163 -0
- voidx/ui/startup.py +161 -0
- voidx/ui/transcript.py +148 -0
- voidx/ui/tree.py +316 -0
- voidx-1.0.0.dist-info/METADATA +59 -0
- voidx-1.0.0.dist-info/RECORD +126 -0
- voidx-1.0.0.dist-info/WHEEL +5 -0
- voidx-1.0.0.dist-info/entry_points.txt +2 -0
- voidx-1.0.0.dist-info/top_level.txt +1 -0
voidx/llm/__init__.py
ADDED
|
File without changes
|
voidx/llm/catalog.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Model catalog — typed abstraction over provider model discovery.
|
|
2
|
+
|
|
3
|
+
Each provider registers a fetcher (async callable returning model names).
|
|
4
|
+
Providers without fetchers fall back to STATIC_MODELS.
|
|
5
|
+
|
|
6
|
+
Public interface:
|
|
7
|
+
async def list_models(provider: str) -> list[str]
|
|
8
|
+
|
|
9
|
+
Registering a custom fetcher:
|
|
10
|
+
register_fetcher("my_provider", my_async_fetcher)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections.abc import Awaitable, Callable
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
# ── static fallbacks ───────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
STATIC_MODELS: dict[str, list[str]] = {
|
|
22
|
+
"anthropic": [
|
|
23
|
+
"claude-opus-4-8",
|
|
24
|
+
"claude-sonnet-4-6",
|
|
25
|
+
"claude-opus-4-7",
|
|
26
|
+
"claude-haiku-4-5",
|
|
27
|
+
],
|
|
28
|
+
"openai": [
|
|
29
|
+
"gpt-5.5",
|
|
30
|
+
"gpt-5.4-mini",
|
|
31
|
+
"gpt-5.4-nano",
|
|
32
|
+
"o3",
|
|
33
|
+
"o4-mini",
|
|
34
|
+
],
|
|
35
|
+
"deepseek": [
|
|
36
|
+
"deepseek-v4-pro",
|
|
37
|
+
"deepseek-v4-flash",
|
|
38
|
+
],
|
|
39
|
+
"mimo": [
|
|
40
|
+
"mimo-v2.5-pro",
|
|
41
|
+
"mimo-v2.5",
|
|
42
|
+
"mimo-v2.5-tts",
|
|
43
|
+
],
|
|
44
|
+
"mimo-token-plan": [
|
|
45
|
+
"mimo-v2.5-pro",
|
|
46
|
+
"mimo-v2.5",
|
|
47
|
+
"mimo-v2.5-tts",
|
|
48
|
+
],
|
|
49
|
+
"qwen": [
|
|
50
|
+
"qwen3.7-max",
|
|
51
|
+
"qwen3-max",
|
|
52
|
+
"qwen3.6-plus",
|
|
53
|
+
"qwen-plus",
|
|
54
|
+
"qwen-turbo",
|
|
55
|
+
],
|
|
56
|
+
"zhipu": [
|
|
57
|
+
"glm-5.1",
|
|
58
|
+
"glm-5",
|
|
59
|
+
"glm-4.7",
|
|
60
|
+
"glm-4.7-flash",
|
|
61
|
+
],
|
|
62
|
+
"kimi": [
|
|
63
|
+
"kimi-k2.6",
|
|
64
|
+
"kimi-k2.5",
|
|
65
|
+
"kimi-k2",
|
|
66
|
+
],
|
|
67
|
+
"doubao": [
|
|
68
|
+
"doubao-seed-1.6-thinking",
|
|
69
|
+
"doubao-seed-1.6",
|
|
70
|
+
"doubao-seed-1.6-flash",
|
|
71
|
+
],
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
# ── fetcher registry ───────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
_fetchers: dict[str, Callable[[], Awaitable[list[str]]]] = {}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def register_fetcher(provider: str, fetcher: Callable[[], Awaitable[list[str]]]) -> None:
|
|
80
|
+
"""Register a dynamic fetcher for a provider. Replaces static list."""
|
|
81
|
+
_fetchers[provider] = fetcher
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ── built-in: OpenRouter public API ────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
OPENROUTER_API = "https://openrouter.ai/api/v1/models"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
async def _fetch_openrouter_models() -> list[str]:
|
|
90
|
+
"""Fetch models from OpenRouter's public /models endpoint. Free models first,
|
|
91
|
+
filtered to chat/language models only, limited to a manageable count."""
|
|
92
|
+
async with httpx.AsyncClient(timeout=15.0) as client:
|
|
93
|
+
try:
|
|
94
|
+
resp = await client.get(OPENROUTER_API)
|
|
95
|
+
resp.raise_for_status()
|
|
96
|
+
data = resp.json()
|
|
97
|
+
except Exception:
|
|
98
|
+
return STATIC_MODELS.get("openrouter", [])
|
|
99
|
+
|
|
100
|
+
_skip_keywords = (
|
|
101
|
+
"embed", "moderation", "image", "vision", "audio",
|
|
102
|
+
"whisper", "tts", "dall-e", "dalle", "transcribe",
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
free_models: list[str] = []
|
|
106
|
+
paid_models: list[str] = []
|
|
107
|
+
seen: set[str] = set()
|
|
108
|
+
|
|
109
|
+
for entry in data.get("data", []):
|
|
110
|
+
model_id = entry.get("id", "")
|
|
111
|
+
if not model_id:
|
|
112
|
+
continue
|
|
113
|
+
# Skip empty/invalid models
|
|
114
|
+
ctx_len = entry.get("context_length", 0) or 0
|
|
115
|
+
if ctx_len <= 0:
|
|
116
|
+
continue
|
|
117
|
+
# Skip obviously non-chat models
|
|
118
|
+
mid_lower = model_id.lower()
|
|
119
|
+
if any(kw in mid_lower for kw in _skip_keywords):
|
|
120
|
+
continue
|
|
121
|
+
# Dedup: if both x and x:free exist, prefer :free for free list
|
|
122
|
+
base = model_id.removesuffix(":free")
|
|
123
|
+
pricing = entry.get("pricing", {})
|
|
124
|
+
prompt_price = pricing.get("prompt", "-1")
|
|
125
|
+
completion_price = pricing.get("completion", "-1")
|
|
126
|
+
is_free = (
|
|
127
|
+
model_id.endswith(":free")
|
|
128
|
+
or (prompt_price == "0" and completion_price == "0")
|
|
129
|
+
)
|
|
130
|
+
if is_free:
|
|
131
|
+
if base not in seen:
|
|
132
|
+
seen.add(base)
|
|
133
|
+
free_models.append(model_id)
|
|
134
|
+
else:
|
|
135
|
+
if base not in seen:
|
|
136
|
+
seen.add(base)
|
|
137
|
+
paid_models.append(model_id)
|
|
138
|
+
|
|
139
|
+
result = free_models + paid_models
|
|
140
|
+
# Limit total to keep the selector usable
|
|
141
|
+
return result[:100]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
register_fetcher("openrouter", _fetch_openrouter_models)
|
|
145
|
+
|
|
146
|
+
# ── custom model support ────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
_settings = None
|
|
149
|
+
|
|
150
|
+
def bind_settings(settings) -> None:
|
|
151
|
+
"""Bind a Settings instance so list_models() can merge custom models."""
|
|
152
|
+
global _settings
|
|
153
|
+
_settings = settings
|
|
154
|
+
|
|
155
|
+
def _merge_custom(provider: str, base: list[str]) -> list[str]:
|
|
156
|
+
"""Merge custom models (from settings) in front of base list, deduplicating."""
|
|
157
|
+
if _settings is None:
|
|
158
|
+
return base
|
|
159
|
+
custom = _settings.list_custom_models(provider)
|
|
160
|
+
if not custom:
|
|
161
|
+
return base
|
|
162
|
+
seen: set[str] = set()
|
|
163
|
+
result: list[str] = []
|
|
164
|
+
for m in custom + base:
|
|
165
|
+
if m not in seen:
|
|
166
|
+
seen.add(m)
|
|
167
|
+
result.append(m)
|
|
168
|
+
return result
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ── public API ─────────────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
async def list_models(provider: str) -> list[str]:
|
|
174
|
+
"""Return available model names for a provider.
|
|
175
|
+
|
|
176
|
+
If a dynamic fetcher is registered, it's called first. On failure or if
|
|
177
|
+
no fetcher exists, falls back to STATIC_MODELS.
|
|
178
|
+
Custom models from settings are merged in front of the result.
|
|
179
|
+
"""
|
|
180
|
+
fetcher = _fetchers.get(provider)
|
|
181
|
+
if fetcher is not None:
|
|
182
|
+
try:
|
|
183
|
+
models = await fetcher()
|
|
184
|
+
if models:
|
|
185
|
+
return _merge_custom(provider, models)
|
|
186
|
+
except Exception:
|
|
187
|
+
pass
|
|
188
|
+
return _merge_custom(provider, STATIC_MODELS.get(provider, []))
|
voidx/llm/compaction.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Context compaction — three-layer management aligned with opencode.
|
|
2
|
+
|
|
3
|
+
Layer 1 — prune: Truncate old tool outputs. Zero API calls.
|
|
4
|
+
Layer 2 — overflow: Check if total_tokens >= usable_window.
|
|
5
|
+
Layer 3 — compact: LLM-generated structured summary. Preserve tail.
|
|
6
|
+
|
|
7
|
+
Token budget constants (from opencode):
|
|
8
|
+
PRUNE_MINIMUM = 20_000 — minimum to trigger prune
|
|
9
|
+
PRUNE_PROTECT = 40_000 — keep this many tokens of tool output
|
|
10
|
+
COMPACTION_BUFFER = 20_000 — reserved for output
|
|
11
|
+
DEFAULT_TAIL_TURNS = 3 — keep this many recent turns
|
|
12
|
+
MIN_PRESERVE_RECENT = 2_000
|
|
13
|
+
MAX_PRESERVE_RECENT = 8_000
|
|
14
|
+
TOOL_OUTPUT_MAX_CHARS = 2_000
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
|
|
21
|
+
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
|
|
22
|
+
|
|
23
|
+
from voidx.llm.context import count_tokens, count_messages_tokens
|
|
24
|
+
|
|
25
|
+
PRUNE_MINIMUM = 20_000
|
|
26
|
+
PRUNE_PROTECT = 40_000
|
|
27
|
+
COMPACTION_BUFFER = 20_000
|
|
28
|
+
DEFAULT_TAIL_TURNS = 3
|
|
29
|
+
MIN_PRESERVE_RECENT = 2_000
|
|
30
|
+
MAX_PRESERVE_RECENT = 8_000
|
|
31
|
+
TOOL_OUTPUT_MAX_CHARS = 2_000
|
|
32
|
+
PRUNE_PROTECTED_TOOLS = {"agent"}
|
|
33
|
+
|
|
34
|
+
SUMMARY_TEMPLATE = """Output exactly the Markdown structure shown inside <template> and keep the section order unchanged. Do not include the <template> tags in your response.
|
|
35
|
+
<template>
|
|
36
|
+
## Goal
|
|
37
|
+
- [single-sentence task summary]
|
|
38
|
+
|
|
39
|
+
## Constraints & Preferences
|
|
40
|
+
- [user constraints, preferences, specs, or "(none)"]
|
|
41
|
+
|
|
42
|
+
## Progress
|
|
43
|
+
### Done
|
|
44
|
+
- [completed work or "(none)"]
|
|
45
|
+
|
|
46
|
+
### In Progress
|
|
47
|
+
- [current work or "(none)"]
|
|
48
|
+
|
|
49
|
+
### Blocked
|
|
50
|
+
- [blockers or "(none)"]
|
|
51
|
+
|
|
52
|
+
## Key Decisions
|
|
53
|
+
- [decision and why, or "(none)"]
|
|
54
|
+
|
|
55
|
+
## Next Steps
|
|
56
|
+
- [ordered next actions or "(none)"]
|
|
57
|
+
|
|
58
|
+
## Critical Context
|
|
59
|
+
- [important technical facts, errors, open questions, or "(none)"]
|
|
60
|
+
|
|
61
|
+
## Relevant Files
|
|
62
|
+
- [file or directory path: why it matters, or "(none)"]
|
|
63
|
+
</template>
|
|
64
|
+
|
|
65
|
+
Rules:
|
|
66
|
+
- Keep every section, even when empty.
|
|
67
|
+
- Use terse bullets, not prose paragraphs.
|
|
68
|
+
- Preserve exact file paths, commands, error strings, and identifiers when known.
|
|
69
|
+
- Do not mention the summary process or that context was compacted."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class Turn:
|
|
74
|
+
"""A conversation turn starts at a user message and ends before the next."""
|
|
75
|
+
start: int # index in messages list
|
|
76
|
+
end: int # index in messages list (exclusive)
|
|
77
|
+
id: str # user message id
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class CompactionService:
|
|
81
|
+
"""Manages context window across the agent lifecycle."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, context_limit: int = 128_000, output_token_max: int = 8_192) -> None:
|
|
84
|
+
self.context_limit = context_limit
|
|
85
|
+
self.output_token_max = output_token_max
|
|
86
|
+
self.compaction_count: int = 0
|
|
87
|
+
|
|
88
|
+
# ── token budget helpers ────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
def usable_window(self) -> int:
|
|
91
|
+
"""How many tokens we can safely use before needing compaction."""
|
|
92
|
+
reserved = COMPACTION_BUFFER
|
|
93
|
+
return max(0, self.context_limit - reserved - self.output_token_max)
|
|
94
|
+
|
|
95
|
+
def preserve_recent_budget(self) -> int:
|
|
96
|
+
"""How many tokens worth of messages to preserve as 'tail'."""
|
|
97
|
+
usable = self.usable_window()
|
|
98
|
+
return min(MAX_PRESERVE_RECENT, max(MIN_PRESERVE_RECENT, int(usable * 0.25)))
|
|
99
|
+
|
|
100
|
+
def is_overflow(self, tokens: dict) -> bool:
|
|
101
|
+
"""Check if token usage exceeds usable window.
|
|
102
|
+
tokens: {input, output, reasoning, cache_read, cache_write, total}
|
|
103
|
+
"""
|
|
104
|
+
usable = self.usable_window()
|
|
105
|
+
if usable <= 0:
|
|
106
|
+
return False
|
|
107
|
+
total = tokens.get("total", 0) or (
|
|
108
|
+
tokens.get("input", 0) +
|
|
109
|
+
tokens.get("output", 0) +
|
|
110
|
+
tokens.get("reasoning", 0)
|
|
111
|
+
)
|
|
112
|
+
return total >= usable
|
|
113
|
+
|
|
114
|
+
# ── Layer 1: prune tool outputs ─────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
def prune(self, messages: list) -> int:
|
|
117
|
+
"""Walk backwards through messages, truncating old tool outputs.
|
|
118
|
+
Returns number of characters pruned.
|
|
119
|
+
|
|
120
|
+
Rules:
|
|
121
|
+
- Skip most recent 2 turns (user messages count as turn boundaries)
|
|
122
|
+
- Protected tools (agent) are never pruned
|
|
123
|
+
- Already compacted parts stop further pruning
|
|
124
|
+
- Cumulative tool output > PRUNE_PROTECT → truncate to TOOL_OUTPUT_MAX_CHARS
|
|
125
|
+
- Only prune if total pruned > PRUNE_MINIMUM
|
|
126
|
+
"""
|
|
127
|
+
turns_seen = 0
|
|
128
|
+
accumulated = 0
|
|
129
|
+
pruned_chars = 0
|
|
130
|
+
to_prune: list[tuple[int, str]] = [] # (msg_index, truncated_text)
|
|
131
|
+
|
|
132
|
+
for i in range(len(messages) - 1, -1, -1):
|
|
133
|
+
msg = messages[i]
|
|
134
|
+
|
|
135
|
+
# Count turns by user messages
|
|
136
|
+
if isinstance(msg, HumanMessage):
|
|
137
|
+
turns_seen += 1
|
|
138
|
+
if turns_seen > 2:
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
if isinstance(msg, AIMessage) and hasattr(msg, "summary") and msg.summary:
|
|
142
|
+
break # stop at compaction boundary
|
|
143
|
+
|
|
144
|
+
# Tool messages have role="tool" and a tool_call_id
|
|
145
|
+
if not hasattr(msg, "tool_call_id") or not msg.tool_call_id:
|
|
146
|
+
continue
|
|
147
|
+
|
|
148
|
+
tool_name = getattr(msg, "name", "")
|
|
149
|
+
if tool_name in PRUNE_PROTECTED_TOOLS:
|
|
150
|
+
continue
|
|
151
|
+
|
|
152
|
+
content = str(getattr(msg, "content", ""))
|
|
153
|
+
token_est = count_tokens(content)
|
|
154
|
+
|
|
155
|
+
accumulated += token_est
|
|
156
|
+
if accumulated <= PRUNE_PROTECT:
|
|
157
|
+
continue
|
|
158
|
+
|
|
159
|
+
if len(content) > TOOL_OUTPUT_MAX_CHARS:
|
|
160
|
+
truncated = content[:TOOL_OUTPUT_MAX_CHARS] + (
|
|
161
|
+
f"\n\n[Tool output truncated for context: omitted {len(content) - TOOL_OUTPUT_MAX_CHARS} chars]"
|
|
162
|
+
)
|
|
163
|
+
pruned_chars += len(content) - len(truncated)
|
|
164
|
+
to_prune.append((i, truncated))
|
|
165
|
+
|
|
166
|
+
if pruned_chars > PRUNE_MINIMUM:
|
|
167
|
+
for idx, truncated in to_prune:
|
|
168
|
+
messages[idx] = type(messages[idx])(
|
|
169
|
+
content=truncated,
|
|
170
|
+
tool_call_id=messages[idx].tool_call_id,
|
|
171
|
+
)
|
|
172
|
+
return pruned_chars
|
|
173
|
+
|
|
174
|
+
return 0
|
|
175
|
+
|
|
176
|
+
# ── Layer 3: compaction ─────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
def _turns(self, messages: list) -> list[Turn]:
|
|
179
|
+
"""Split messages into turns. A turn starts at a user message
|
|
180
|
+
and ends before the next user message."""
|
|
181
|
+
result: list[Turn] = []
|
|
182
|
+
for i, msg in enumerate(messages):
|
|
183
|
+
if isinstance(msg, HumanMessage):
|
|
184
|
+
# Skip synthetic continuation messages
|
|
185
|
+
content = str(getattr(msg, "content", ""))
|
|
186
|
+
if "Continue if you have next steps" in content:
|
|
187
|
+
continue
|
|
188
|
+
result.append(Turn(start=i, end=len(messages), id=getattr(msg, "id", str(i))))
|
|
189
|
+
for j in range(len(result) - 1):
|
|
190
|
+
result[j].end = result[j + 1].start
|
|
191
|
+
return result
|
|
192
|
+
|
|
193
|
+
def select(self, messages: list, tail_turns: int = DEFAULT_TAIL_TURNS) -> tuple[list, str | None]:
|
|
194
|
+
"""Split messages into head (to compact) and tail (to keep).
|
|
195
|
+
Returns (head_messages, tail_start_id_or_None).
|
|
196
|
+
|
|
197
|
+
Preserves recent turns up to preserve_recent_budget() tokens.
|
|
198
|
+
"""
|
|
199
|
+
budget = self.preserve_recent_budget()
|
|
200
|
+
turns = self._turns(messages)
|
|
201
|
+
if not turns or tail_turns <= 0:
|
|
202
|
+
return messages, None
|
|
203
|
+
|
|
204
|
+
recent = turns[-tail_turns:]
|
|
205
|
+
total = 0
|
|
206
|
+
keep_start: int | None = None
|
|
207
|
+
keep_id: str | None = None
|
|
208
|
+
|
|
209
|
+
for turn in reversed(recent):
|
|
210
|
+
turn_msgs = messages[turn.start:turn.end]
|
|
211
|
+
size = count_messages_tokens(
|
|
212
|
+
[{"role": "assistant" if isinstance(m, AIMessage) else "user",
|
|
213
|
+
"content": str(getattr(m, "content", ""))}
|
|
214
|
+
for m in turn_msgs]
|
|
215
|
+
)
|
|
216
|
+
if total + size <= budget:
|
|
217
|
+
total += size
|
|
218
|
+
keep_start = turn.start
|
|
219
|
+
keep_id = turn.id
|
|
220
|
+
else:
|
|
221
|
+
break
|
|
222
|
+
|
|
223
|
+
if keep_start is None or keep_start == 0:
|
|
224
|
+
return messages, None
|
|
225
|
+
|
|
226
|
+
return messages[:keep_start], keep_id
|
|
227
|
+
|
|
228
|
+
# ── build compaction prompt ─────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
def build_prompt(self, head_messages: list, previous_summary: str | None = None) -> str:
|
|
231
|
+
"""Build the compaction prompt. Extracts user message text from
|
|
232
|
+
head, truncates tool outputs, strips media references."""
|
|
233
|
+
context_parts: list[str] = []
|
|
234
|
+
|
|
235
|
+
for msg in head_messages:
|
|
236
|
+
content = str(getattr(msg, "content", ""))
|
|
237
|
+
if not content.strip():
|
|
238
|
+
continue
|
|
239
|
+
|
|
240
|
+
if isinstance(msg, HumanMessage):
|
|
241
|
+
context_parts.append(f"[User]: {content[:2000]}")
|
|
242
|
+
elif isinstance(msg, AIMessage):
|
|
243
|
+
# Extract text from assistant
|
|
244
|
+
text = content[:2000]
|
|
245
|
+
# Truncate tool calls in this message
|
|
246
|
+
if hasattr(msg, "tool_calls") and msg.tool_calls:
|
|
247
|
+
for tc in msg.tool_calls:
|
|
248
|
+
tc_name = tc.get("name", "?")
|
|
249
|
+
tc_args = str(tc.get("args", {}))[:200]
|
|
250
|
+
text += f"\n [called {tc_name}({tc_args})]"
|
|
251
|
+
context_parts.append(f"[Assistant]: {text}")
|
|
252
|
+
elif hasattr(msg, "tool_call_id"):
|
|
253
|
+
# Tool message — truncate
|
|
254
|
+
truncated = content[:TOOL_OUTPUT_MAX_CHARS]
|
|
255
|
+
if len(content) > TOOL_OUTPUT_MAX_CHARS:
|
|
256
|
+
truncated += f"\n[truncated: {len(content)} chars total]"
|
|
257
|
+
context_parts.append(f"[Tool result]: {truncated}")
|
|
258
|
+
|
|
259
|
+
anchor = (
|
|
260
|
+
"Update the anchored summary below using the conversation history above.\n"
|
|
261
|
+
"Preserve still-true details, remove stale details, and merge in the new facts.\n"
|
|
262
|
+
f"<previous-summary>\n{previous_summary}\n</previous-summary>"
|
|
263
|
+
if previous_summary
|
|
264
|
+
else "Create a new anchored summary from the conversation history above."
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
return "\n\n".join([anchor, SUMMARY_TEMPLATE] + context_parts[:20])
|
voidx/llm/context.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Context window management — token counting, not guessing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tiktoken
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
_ENCODING = None
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _get_encoding():
|
|
12
|
+
global _ENCODING
|
|
13
|
+
if _ENCODING is not None:
|
|
14
|
+
return _ENCODING
|
|
15
|
+
for name in ("cl100k_base", "o200k_base", "gpt2"):
|
|
16
|
+
try:
|
|
17
|
+
_ENCODING = tiktoken.get_encoding(name)
|
|
18
|
+
return _ENCODING
|
|
19
|
+
except Exception:
|
|
20
|
+
continue
|
|
21
|
+
raise RuntimeError("No tiktoken encoding available")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def count_tokens(text: str, model: str = "claude-sonnet-4-6") -> int:
|
|
25
|
+
"""Count tokens in text using tiktoken. Deterministic, not estimated."""
|
|
26
|
+
enc = _get_encoding()
|
|
27
|
+
return len(enc.encode(text))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def count_messages_tokens(messages: list[dict], model: str = "claude-sonnet-4-6") -> int:
|
|
31
|
+
"""Total token count across all messages."""
|
|
32
|
+
total = 0
|
|
33
|
+
for msg in messages:
|
|
34
|
+
for key, value in msg.items():
|
|
35
|
+
if isinstance(value, str):
|
|
36
|
+
total += count_tokens(value, model)
|
|
37
|
+
elif isinstance(value, list):
|
|
38
|
+
for item in value:
|
|
39
|
+
if isinstance(item, str):
|
|
40
|
+
total += count_tokens(item, model)
|
|
41
|
+
elif isinstance(item, dict):
|
|
42
|
+
total += count_tokens(str(item), model)
|
|
43
|
+
return total
|