yycode 0.3.2__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.
- agent/__init__.py +33 -0
- agent/acp/__init__.py +2 -0
- agent/acp/approval_adapter.py +134 -0
- agent/acp/content_adapter.py +45 -0
- agent/acp/jsonrpc.py +92 -0
- agent/acp/server.py +197 -0
- agent/acp/session_manager.py +193 -0
- agent/acp/update_adapter.py +192 -0
- agent/app_paths.py +25 -0
- agent/approval.py +169 -0
- agent/cancellation.py +52 -0
- agent/change_snapshot.py +186 -0
- agent/context_compressor.py +116 -0
- agent/graph.py +137 -0
- agent/llm_retry.py +434 -0
- agent/logger.py +97 -0
- agent/lsp/__init__.py +13 -0
- agent/lsp/client.py +151 -0
- agent/lsp/manager.py +234 -0
- agent/lsp/types.py +119 -0
- agent/message_context_manager.py +322 -0
- agent/message_format.py +105 -0
- agent/nodes/llm_node.py +58 -0
- agent/nodes/state.py +12 -0
- agent/nodes/task_guard_node.py +50 -0
- agent/nodes/tools_node.py +70 -0
- agent/plan_snapshot.py +70 -0
- agent/providers/__init__.py +13 -0
- agent/providers/anthropic_provider.py +268 -0
- agent/providers/base.py +52 -0
- agent/providers/openai_provider.py +279 -0
- agent/providers/text_tool_calls.py +118 -0
- agent/runtime/approval_service.py +184 -0
- agent/runtime/context.py +43 -0
- agent/runtime/tool_events.py +368 -0
- agent/runtime/tool_executor.py +208 -0
- agent/runtime/tool_output.py +261 -0
- agent/runtime/tool_registry.py +91 -0
- agent/runtime/tool_scheduler.py +35 -0
- agent/runtime/workflow_guard.py +217 -0
- agent/runtime/workspace.py +5 -0
- agent/runtime/workspace_tools.py +22 -0
- agent/session.py +787 -0
- agent/session_replay.py +95 -0
- agent/session_store.py +186 -0
- agent/skills.py +254 -0
- agent/streaming.py +248 -0
- agent/subagent.py +634 -0
- agent/task_memory.py +340 -0
- agent/todo_manager.py +304 -0
- agent/tool_retry.py +106 -0
- agent/tui/__init__.py +14 -0
- agent/tui/app.py +1325 -0
- agent/tui/approval.py +53 -0
- agent/tui/commands/__init__.py +6 -0
- agent/tui/commands/base.py +48 -0
- agent/tui/commands/clear.py +37 -0
- agent/tui/commands/help.py +27 -0
- agent/tui/commands/registry.py +94 -0
- agent/tui/help_content.py +108 -0
- agent/tui/renderers.py +1961 -0
- agent/tui/runner.py +439 -0
- agent/tui/state.py +653 -0
- main.py +465 -0
- tools/__init__.py +50 -0
- tools/apply_patch.py +305 -0
- tools/bash.py +76 -0
- tools/diff_utils.py +139 -0
- tools/edit_file.py +40 -0
- tools/git_diff.py +72 -0
- tools/git_show.py +65 -0
- tools/grep.py +149 -0
- tools/list_files.py +90 -0
- tools/list_skills.py +24 -0
- tools/load_skill.py +30 -0
- tools/lsp_definition.py +27 -0
- tools/lsp_diagnostics.py +32 -0
- tools/lsp_document_symbols.py +23 -0
- tools/lsp_hover.py +29 -0
- tools/lsp_references.py +37 -0
- tools/lsp_utils.py +38 -0
- tools/lsp_workspace_symbols.py +23 -0
- tools/read_file.py +61 -0
- tools/read_many_files.py +50 -0
- tools/safety.py +50 -0
- tools/subagent.py +57 -0
- tools/todo.py +89 -0
- tools/verify.py +107 -0
- tools/web_search.py +250 -0
- tools/workspace.py +36 -0
- tools/workspace_state.py +60 -0
- tools/write_file.py +88 -0
- utils/__init__.py +5 -0
- utils/retry.py +13 -0
- yycode-0.3.2.data/data/skills/code_review.md +61 -0
- yycode-0.3.2.data/data/skills/code_workflow.md +404 -0
- yycode-0.3.2.data/data/skills/drawio/SKILL.md +636 -0
- yycode-0.3.2.data/data/skills/drawio/agents/openai.yaml +19 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-erd.drawio +84 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-layered-cn.drawio +91 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-layered-cn.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-layered.drawio +112 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-layered.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-ml.drawio +90 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-ring-cn.drawio +68 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-ring-cn.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-ring.drawio +86 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-ring.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-sequence.drawio +116 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-star-cn.drawio +66 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-star-cn.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-star.drawio +79 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-star.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/demo-uml-class.drawio +64 -0
- yycode-0.3.2.data/data/skills/drawio/assets/microservices-example.drawio +173 -0
- yycode-0.3.2.data/data/skills/drawio/assets/microservices-example.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/workflow-cn.drawio +120 -0
- yycode-0.3.2.data/data/skills/drawio/assets/workflow-cn.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/assets/workflow.drawio +120 -0
- yycode-0.3.2.data/data/skills/drawio/assets/workflow.png +0 -0
- yycode-0.3.2.data/data/skills/drawio/docs/index.html +469 -0
- yycode-0.3.2.data/data/skills/drawio/docs/zh.html +456 -0
- yycode-0.3.2.data/data/skills/drawio/references/style-extraction.md +254 -0
- yycode-0.3.2.data/data/skills/drawio/styles/schema.json +112 -0
- yycode-0.3.2.data/data/skills/plan.md +115 -0
- yycode-0.3.2.data/data/skills/ppt/SKILL.md +254 -0
- yycode-0.3.2.dist-info/METADATA +12 -0
- yycode-0.3.2.dist-info/RECORD +131 -0
- yycode-0.3.2.dist-info/WHEEL +5 -0
- yycode-0.3.2.dist-info/entry_points.txt +2 -0
- yycode-0.3.2.dist-info/top_level.txt +4 -0
agent/lsp/manager.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""High-level manager for read-only Python LSP navigation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import shutil
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from agent.lsp.client import LspClient, LspClientError
|
|
11
|
+
from agent.lsp.types import (
|
|
12
|
+
Diagnostic,
|
|
13
|
+
Location,
|
|
14
|
+
Symbol,
|
|
15
|
+
path_to_uri,
|
|
16
|
+
range_start,
|
|
17
|
+
symbol_kind_name,
|
|
18
|
+
uri_to_path,
|
|
19
|
+
)
|
|
20
|
+
from tools.workspace import Workspace
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class LspUnavailable(RuntimeError):
|
|
24
|
+
"""Raised when no supported language server is available."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _python_server_command() -> list[str] | None:
|
|
28
|
+
if shutil.which("pyright-langserver"):
|
|
29
|
+
return ["pyright-langserver", "--stdio"]
|
|
30
|
+
if shutil.which("pylsp"):
|
|
31
|
+
return ["pylsp"]
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class LspManager:
|
|
36
|
+
"""Lazy Python LSP manager scoped to one workspace."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, workdir: Path | str, timeout: float = 10.0):
|
|
39
|
+
self.workspace = Workspace(Path(workdir))
|
|
40
|
+
self.timeout = timeout
|
|
41
|
+
self._client: LspClient | None = None
|
|
42
|
+
self._opened: set[str] = set()
|
|
43
|
+
|
|
44
|
+
async def document_symbols(self, path: str) -> list[Symbol]:
|
|
45
|
+
file_path = self._safe_python_file(path)
|
|
46
|
+
client = await self._client_for_python()
|
|
47
|
+
await self._open(client, file_path)
|
|
48
|
+
result = await client.request(
|
|
49
|
+
"textDocument/documentSymbol",
|
|
50
|
+
{"textDocument": {"uri": path_to_uri(str(file_path))}},
|
|
51
|
+
)
|
|
52
|
+
return self._parse_document_symbols(result or [], file_path)
|
|
53
|
+
|
|
54
|
+
async def workspace_symbols(self, query: str) -> list[Symbol]:
|
|
55
|
+
client = await self._client_for_python()
|
|
56
|
+
result = await client.request("workspace/symbol", {"query": query})
|
|
57
|
+
return [self._parse_workspace_symbol(item) for item in (result or [])]
|
|
58
|
+
|
|
59
|
+
async def definition(self, path: str, line: int, character: int) -> list[Location]:
|
|
60
|
+
return await self._locations_request("textDocument/definition", path, line, character)
|
|
61
|
+
|
|
62
|
+
async def references(
|
|
63
|
+
self,
|
|
64
|
+
path: str,
|
|
65
|
+
line: int,
|
|
66
|
+
character: int,
|
|
67
|
+
include_declaration: bool = False,
|
|
68
|
+
) -> list[Location]:
|
|
69
|
+
return await self._locations_request(
|
|
70
|
+
"textDocument/references",
|
|
71
|
+
path,
|
|
72
|
+
line,
|
|
73
|
+
character,
|
|
74
|
+
extra={"context": {"includeDeclaration": include_declaration}},
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
async def hover(self, path: str, line: int, character: int) -> str:
|
|
78
|
+
file_path = self._safe_python_file(path)
|
|
79
|
+
client = await self._client_for_python()
|
|
80
|
+
await self._open(client, file_path)
|
|
81
|
+
result = await client.request("textDocument/hover", self._position_params(file_path, line, character))
|
|
82
|
+
contents = (result or {}).get("contents") if isinstance(result, dict) else result
|
|
83
|
+
return self._format_hover(contents)
|
|
84
|
+
|
|
85
|
+
async def diagnostics(self, path: str | None = None) -> list[Diagnostic]:
|
|
86
|
+
if path:
|
|
87
|
+
file_path = self._safe_python_file(path)
|
|
88
|
+
client = await self._client_for_python()
|
|
89
|
+
await self._open(client, file_path)
|
|
90
|
+
elif self._client is None:
|
|
91
|
+
await self._client_for_python()
|
|
92
|
+
# Pull diagnostics are not universally supported. Return an empty list rather than guessing.
|
|
93
|
+
return []
|
|
94
|
+
|
|
95
|
+
async def shutdown(self) -> None:
|
|
96
|
+
if self._client:
|
|
97
|
+
await self._client.shutdown()
|
|
98
|
+
self._client = None
|
|
99
|
+
self._opened.clear()
|
|
100
|
+
|
|
101
|
+
async def _client_for_python(self) -> LspClient:
|
|
102
|
+
if self._client is not None and self._client.process is not None and self._client.process.returncode is None:
|
|
103
|
+
return self._client
|
|
104
|
+
command = _python_server_command()
|
|
105
|
+
if not command:
|
|
106
|
+
raise LspUnavailable("pyright-langserver and pylsp not found")
|
|
107
|
+
self._client = LspClient(command, self.workspace.root, timeout=self.timeout)
|
|
108
|
+
try:
|
|
109
|
+
await self._client.start()
|
|
110
|
+
except FileNotFoundError as exc:
|
|
111
|
+
raise LspUnavailable(f"language server not found: {command[0]}") from exc
|
|
112
|
+
except (OSError, LspClientError, asyncio.TimeoutError) as exc:
|
|
113
|
+
raise LspUnavailable(f"language server failed to start: {exc}") from exc
|
|
114
|
+
return self._client
|
|
115
|
+
|
|
116
|
+
async def _open(self, client: LspClient, file_path: Path) -> None:
|
|
117
|
+
uri = path_to_uri(str(file_path))
|
|
118
|
+
if uri in self._opened:
|
|
119
|
+
return
|
|
120
|
+
await client.did_open(uri, file_path.read_text(), language_id="python")
|
|
121
|
+
self._opened.add(uri)
|
|
122
|
+
|
|
123
|
+
async def _locations_request(
|
|
124
|
+
self,
|
|
125
|
+
method: str,
|
|
126
|
+
path: str,
|
|
127
|
+
line: int,
|
|
128
|
+
character: int,
|
|
129
|
+
extra: dict[str, Any] | None = None,
|
|
130
|
+
) -> list[Location]:
|
|
131
|
+
file_path = self._safe_python_file(path)
|
|
132
|
+
client = await self._client_for_python()
|
|
133
|
+
await self._open(client, file_path)
|
|
134
|
+
params = self._position_params(file_path, line, character)
|
|
135
|
+
if extra:
|
|
136
|
+
params.update(extra)
|
|
137
|
+
result = await client.request(method, params)
|
|
138
|
+
if isinstance(result, dict):
|
|
139
|
+
result = [result]
|
|
140
|
+
locations = [self._parse_location(item) for item in (result or [])]
|
|
141
|
+
return [location for location in locations if location is not None]
|
|
142
|
+
|
|
143
|
+
def _position_params(self, file_path: Path, line: int, character: int) -> dict[str, Any]:
|
|
144
|
+
return {
|
|
145
|
+
"textDocument": {"uri": path_to_uri(str(file_path))},
|
|
146
|
+
"position": {"line": max(0, int(line)), "character": max(0, int(character))},
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
def _safe_python_file(self, path: str) -> Path:
|
|
150
|
+
file_path = self.workspace.safe_path(path)
|
|
151
|
+
if not file_path.exists():
|
|
152
|
+
raise ValueError(f"file does not exist: {path}")
|
|
153
|
+
if not file_path.is_file():
|
|
154
|
+
raise ValueError(f"path is not a file: {path}")
|
|
155
|
+
if file_path.suffix != ".py":
|
|
156
|
+
raise ValueError(f"only Python files are supported in LSP MVP: {path}")
|
|
157
|
+
return file_path
|
|
158
|
+
|
|
159
|
+
def _parse_document_symbols(self, items: list[dict[str, Any]], file_path: Path) -> list[Symbol]:
|
|
160
|
+
symbols: list[Symbol] = []
|
|
161
|
+
ignored_kinds = {"file", "module", "package", "namespace"}
|
|
162
|
+
|
|
163
|
+
def visit(item: dict[str, Any], container: str | None = None) -> None:
|
|
164
|
+
line, character = range_start(item)
|
|
165
|
+
name = str(item.get("name", "<unknown>"))
|
|
166
|
+
kind = symbol_kind_name(item.get("kind"))
|
|
167
|
+
if kind not in ignored_kinds:
|
|
168
|
+
symbols.append(
|
|
169
|
+
Symbol(
|
|
170
|
+
name=name,
|
|
171
|
+
kind=kind,
|
|
172
|
+
container_name=container,
|
|
173
|
+
location=Location(self.workspace.relative_path(file_path), line, character, name),
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
for child in item.get("children") or []:
|
|
177
|
+
visit(child, name)
|
|
178
|
+
|
|
179
|
+
for item in items:
|
|
180
|
+
visit(item)
|
|
181
|
+
return symbols
|
|
182
|
+
|
|
183
|
+
def _parse_workspace_symbol(self, item: dict[str, Any]) -> Symbol:
|
|
184
|
+
location = item.get("location") or {}
|
|
185
|
+
parsed_location = self._parse_location(location, name=item.get("name"))
|
|
186
|
+
return Symbol(
|
|
187
|
+
name=str(item.get("name", "<unknown>")),
|
|
188
|
+
kind=symbol_kind_name(item.get("kind")),
|
|
189
|
+
container_name=item.get("containerName"),
|
|
190
|
+
location=parsed_location,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
def _parse_location(self, item: dict[str, Any], name: str | None = None) -> Location | None:
|
|
194
|
+
uri = item.get("uri") or item.get("targetUri") or ""
|
|
195
|
+
path = Path(uri_to_path(uri))
|
|
196
|
+
try:
|
|
197
|
+
relative = self.workspace.relative_path(path)
|
|
198
|
+
except ValueError:
|
|
199
|
+
return None
|
|
200
|
+
line, character = range_start(item.get("range") and item or item.get("targetSelectionRange", {}) or {})
|
|
201
|
+
return Location(relative, line, character, str(name) if name else None)
|
|
202
|
+
|
|
203
|
+
def _format_hover(self, contents: Any) -> str:
|
|
204
|
+
if not contents:
|
|
205
|
+
return ""
|
|
206
|
+
if isinstance(contents, str):
|
|
207
|
+
return contents
|
|
208
|
+
if isinstance(contents, dict):
|
|
209
|
+
value = contents.get("value") or contents.get("language") or str(contents)
|
|
210
|
+
return str(value)
|
|
211
|
+
if isinstance(contents, list):
|
|
212
|
+
return "\n".join(self._format_hover(item) for item in contents if item)
|
|
213
|
+
return str(contents)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
_MANAGERS: dict[Path, LspManager] = {}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def get_lsp_manager(workdir: Path | str) -> LspManager:
|
|
220
|
+
"""Return a cached LSP manager for a workspace."""
|
|
221
|
+
root = Workspace(Path(workdir)).root
|
|
222
|
+
manager = _MANAGERS.get(root)
|
|
223
|
+
if manager is None:
|
|
224
|
+
manager = LspManager(root)
|
|
225
|
+
_MANAGERS[root] = manager
|
|
226
|
+
return manager
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
async def shutdown_lsp_managers() -> None:
|
|
230
|
+
"""Shutdown all cached LSP managers."""
|
|
231
|
+
managers = list(_MANAGERS.values())
|
|
232
|
+
_MANAGERS.clear()
|
|
233
|
+
for manager in managers:
|
|
234
|
+
await manager.shutdown()
|
agent/lsp/types.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Small typed containers and formatters for LSP results."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
SYMBOL_KINDS = {
|
|
8
|
+
1: "file",
|
|
9
|
+
2: "module",
|
|
10
|
+
3: "namespace",
|
|
11
|
+
4: "package",
|
|
12
|
+
5: "class",
|
|
13
|
+
6: "method",
|
|
14
|
+
7: "property",
|
|
15
|
+
8: "field",
|
|
16
|
+
9: "constructor",
|
|
17
|
+
10: "enum",
|
|
18
|
+
11: "interface",
|
|
19
|
+
12: "function",
|
|
20
|
+
13: "variable",
|
|
21
|
+
14: "constant",
|
|
22
|
+
15: "string",
|
|
23
|
+
16: "number",
|
|
24
|
+
17: "boolean",
|
|
25
|
+
18: "array",
|
|
26
|
+
19: "object",
|
|
27
|
+
20: "key",
|
|
28
|
+
21: "null",
|
|
29
|
+
22: "enumMember",
|
|
30
|
+
23: "struct",
|
|
31
|
+
24: "event",
|
|
32
|
+
25: "operator",
|
|
33
|
+
26: "typeParameter",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
SEVERITIES = {1: "error", 2: "warning", 3: "information", 4: "hint"}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class Location:
|
|
41
|
+
"""A workspace-relative source location."""
|
|
42
|
+
|
|
43
|
+
path: str
|
|
44
|
+
line: int
|
|
45
|
+
character: int
|
|
46
|
+
name: str | None = None
|
|
47
|
+
|
|
48
|
+
def format(self) -> str:
|
|
49
|
+
suffix = f" {self.name}" if self.name else ""
|
|
50
|
+
return f"{self.path}:{self.line + 1}:{self.character + 1}{suffix}"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class Symbol:
|
|
55
|
+
"""A document or workspace symbol."""
|
|
56
|
+
|
|
57
|
+
name: str
|
|
58
|
+
kind: str
|
|
59
|
+
location: Location
|
|
60
|
+
container_name: str | None = None
|
|
61
|
+
|
|
62
|
+
def format(self) -> str:
|
|
63
|
+
container = f" {self.container_name}." if self.container_name else " "
|
|
64
|
+
return f"{self.kind}{container}{self.name} {self.location.format()}"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class Diagnostic:
|
|
69
|
+
"""An LSP diagnostic."""
|
|
70
|
+
|
|
71
|
+
path: str
|
|
72
|
+
line: int
|
|
73
|
+
character: int
|
|
74
|
+
severity: str
|
|
75
|
+
message: str
|
|
76
|
+
code: str | None = None
|
|
77
|
+
|
|
78
|
+
def format(self) -> str:
|
|
79
|
+
code = f" {self.code}" if self.code else ""
|
|
80
|
+
return f"{self.path}:{self.line + 1}:{self.character + 1} {self.severity}{code} {self.message}"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def uri_to_path(uri: str) -> str:
|
|
84
|
+
"""Convert a file URI to a local path string."""
|
|
85
|
+
from urllib.parse import unquote, urlparse
|
|
86
|
+
|
|
87
|
+
parsed = urlparse(uri)
|
|
88
|
+
if parsed.scheme != "file":
|
|
89
|
+
return uri
|
|
90
|
+
return unquote(parsed.path)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def path_to_uri(path: str) -> str:
|
|
94
|
+
"""Convert a local path string to a file URI."""
|
|
95
|
+
from pathlib import Path
|
|
96
|
+
|
|
97
|
+
return Path(path).resolve().as_uri()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def range_start(item: dict[str, Any]) -> tuple[int, int]:
|
|
101
|
+
"""Return zero-based line/character from an LSP range-like item."""
|
|
102
|
+
location = item.get("location") if isinstance(item.get("location"), dict) else {}
|
|
103
|
+
start = (
|
|
104
|
+
item.get("range", {}).get("start")
|
|
105
|
+
or item.get("selectionRange", {}).get("start")
|
|
106
|
+
or location.get("range", {}).get("start")
|
|
107
|
+
or {}
|
|
108
|
+
)
|
|
109
|
+
return int(start.get("line", 0)), int(start.get("character", 0))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def symbol_kind_name(kind: Any) -> str:
|
|
113
|
+
"""Return a readable symbol kind."""
|
|
114
|
+
return SYMBOL_KINDS.get(int(kind or 0), "symbol")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def diagnostic_severity(severity: Any) -> str:
|
|
118
|
+
"""Return a readable diagnostic severity."""
|
|
119
|
+
return SEVERITIES.get(int(severity or 0), "diagnostic")
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""Analyze and compact current session message token usage."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage, ToolMessage
|
|
10
|
+
|
|
11
|
+
from agent.context_compressor import MANUAL_COMPRESSION_REASON, compress_tool_message
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
DEFAULT_KEEP_RECENT_MESSAGES = 20
|
|
15
|
+
DEFAULT_MIN_TOOL_TOKENS = 500
|
|
16
|
+
COMPACT_MARKER_TOKENS = 80
|
|
17
|
+
|
|
18
|
+
RiskLevel = Literal["low", "medium", "high"]
|
|
19
|
+
PressureLevel = Literal["low", "medium", "high", "critical"]
|
|
20
|
+
TokenSource = Literal["exact", "estimated"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class MessageTokenStat:
|
|
25
|
+
index: int
|
|
26
|
+
role: str
|
|
27
|
+
message_type: str
|
|
28
|
+
estimated_tokens: int
|
|
29
|
+
percent: float
|
|
30
|
+
preview: str
|
|
31
|
+
protected: bool
|
|
32
|
+
compressible: bool
|
|
33
|
+
recommendation: str
|
|
34
|
+
risk: RiskLevel
|
|
35
|
+
context_policy: str
|
|
36
|
+
ephemeral_kind: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class ContextBlockStat:
|
|
41
|
+
name: str
|
|
42
|
+
estimated_tokens: int
|
|
43
|
+
protected: bool
|
|
44
|
+
preview: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class MessageContextSummary:
|
|
49
|
+
total_tokens: int
|
|
50
|
+
token_source: TokenSource
|
|
51
|
+
context_window_tokens: int
|
|
52
|
+
remaining_tokens: int
|
|
53
|
+
pressure: PressureLevel
|
|
54
|
+
by_role: dict[str, int]
|
|
55
|
+
by_type: dict[str, int]
|
|
56
|
+
largest_messages: list[int]
|
|
57
|
+
compression_savings_estimate: int
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class CompressionSuggestion:
|
|
62
|
+
message_indexes: list[int]
|
|
63
|
+
strategy: str
|
|
64
|
+
reason: str
|
|
65
|
+
original_tokens: int
|
|
66
|
+
estimated_after_tokens: int
|
|
67
|
+
saved_tokens: int
|
|
68
|
+
risk: RiskLevel
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class MessageContextManager:
|
|
72
|
+
"""Read-only token analysis plus deterministic old-tool compression."""
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
*,
|
|
77
|
+
keep_recent_messages: int = DEFAULT_KEEP_RECENT_MESSAGES,
|
|
78
|
+
min_tool_tokens: int = DEFAULT_MIN_TOOL_TOKENS,
|
|
79
|
+
) -> None:
|
|
80
|
+
self.keep_recent_messages = keep_recent_messages
|
|
81
|
+
self.min_tool_tokens = min_tool_tokens
|
|
82
|
+
|
|
83
|
+
def analyze(
|
|
84
|
+
self,
|
|
85
|
+
messages: list[BaseMessage],
|
|
86
|
+
*,
|
|
87
|
+
system_prompt: str,
|
|
88
|
+
tools: list[dict],
|
|
89
|
+
context_window_tokens: int,
|
|
90
|
+
total_tokens: int | None = None,
|
|
91
|
+
token_source: TokenSource = "estimated",
|
|
92
|
+
) -> MessageContextSummary:
|
|
93
|
+
"""Return aggregate token pressure and breakdowns."""
|
|
94
|
+
blocks = self.context_blocks(system_prompt, tools)
|
|
95
|
+
stats = self.message_stats(messages)
|
|
96
|
+
estimated_total = sum(block.estimated_tokens for block in blocks) + sum(
|
|
97
|
+
stat.estimated_tokens for stat in stats
|
|
98
|
+
)
|
|
99
|
+
total = max(0, int(total_tokens if total_tokens is not None else estimated_total))
|
|
100
|
+
by_role: dict[str, int] = {block.name: block.estimated_tokens for block in blocks}
|
|
101
|
+
by_type: dict[str, int] = {block.name: block.estimated_tokens for block in blocks}
|
|
102
|
+
for stat in stats:
|
|
103
|
+
by_role[stat.role] = by_role.get(stat.role, 0) + stat.estimated_tokens
|
|
104
|
+
by_type[stat.message_type] = by_type.get(stat.message_type, 0) + stat.estimated_tokens
|
|
105
|
+
remaining = max(context_window_tokens - total, 0) if context_window_tokens > 0 else 0
|
|
106
|
+
suggestions = self.suggest_compression(messages)
|
|
107
|
+
return MessageContextSummary(
|
|
108
|
+
total_tokens=total,
|
|
109
|
+
token_source=token_source,
|
|
110
|
+
context_window_tokens=max(0, int(context_window_tokens or 0)),
|
|
111
|
+
remaining_tokens=remaining,
|
|
112
|
+
pressure=self._pressure(total, context_window_tokens),
|
|
113
|
+
by_role=by_role,
|
|
114
|
+
by_type=by_type,
|
|
115
|
+
largest_messages=[
|
|
116
|
+
stat.index
|
|
117
|
+
for stat in sorted(stats, key=lambda item: item.estimated_tokens, reverse=True)[:5]
|
|
118
|
+
],
|
|
119
|
+
compression_savings_estimate=sum(item.saved_tokens for item in suggestions),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def context_blocks(self, system_prompt: str, tools: list[dict]) -> list[ContextBlockStat]:
|
|
123
|
+
"""Return protected non-message context blocks."""
|
|
124
|
+
return [
|
|
125
|
+
ContextBlockStat(
|
|
126
|
+
name="system_prompt",
|
|
127
|
+
estimated_tokens=_estimate_text_tokens(system_prompt),
|
|
128
|
+
protected=True,
|
|
129
|
+
preview=_preview(system_prompt),
|
|
130
|
+
),
|
|
131
|
+
ContextBlockStat(
|
|
132
|
+
name="tools_schema",
|
|
133
|
+
estimated_tokens=_estimate_text_tokens(str(tools or [])),
|
|
134
|
+
protected=True,
|
|
135
|
+
preview=f"{len(tools or [])} tool definitions",
|
|
136
|
+
),
|
|
137
|
+
]
|
|
138
|
+
|
|
139
|
+
def message_stats(self, messages: list[BaseMessage]) -> list[MessageTokenStat]:
|
|
140
|
+
"""Return per-message estimated token stats."""
|
|
141
|
+
total = sum(_estimate_message_tokens(message) for message in messages)
|
|
142
|
+
latest_user = self._latest_user_index(messages)
|
|
143
|
+
return [
|
|
144
|
+
self._message_stat(index, message, total, latest_user, len(messages))
|
|
145
|
+
for index, message in enumerate(messages)
|
|
146
|
+
]
|
|
147
|
+
|
|
148
|
+
def suggest_compression(self, messages: list[BaseMessage]) -> list[CompressionSuggestion]:
|
|
149
|
+
"""Suggest deterministic compression for old large tool outputs."""
|
|
150
|
+
suggestions = []
|
|
151
|
+
for index, message in enumerate(messages):
|
|
152
|
+
if not self._is_compressible_tool(index, message, len(messages)):
|
|
153
|
+
continue
|
|
154
|
+
original = _estimate_message_tokens(message)
|
|
155
|
+
if original < self.min_tool_tokens:
|
|
156
|
+
continue
|
|
157
|
+
after = min(original, COMPACT_MARKER_TOKENS)
|
|
158
|
+
suggestions.append(
|
|
159
|
+
CompressionSuggestion(
|
|
160
|
+
message_indexes=[index],
|
|
161
|
+
strategy="old_tool_outputs",
|
|
162
|
+
reason="old tool output outside recent message window",
|
|
163
|
+
original_tokens=original,
|
|
164
|
+
estimated_after_tokens=after,
|
|
165
|
+
saved_tokens=max(original - after, 0),
|
|
166
|
+
risk="low",
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
return suggestions
|
|
170
|
+
|
|
171
|
+
def compress_selected(
|
|
172
|
+
self,
|
|
173
|
+
messages: list[BaseMessage],
|
|
174
|
+
indexes: list[int],
|
|
175
|
+
) -> list[BaseMessage]:
|
|
176
|
+
"""Return messages with selected compressible ToolMessages compacted."""
|
|
177
|
+
selected = set(indexes)
|
|
178
|
+
compressed: list[BaseMessage] = []
|
|
179
|
+
for index, message in enumerate(messages):
|
|
180
|
+
if index in selected and self._is_compressible_tool(index, message, len(messages)):
|
|
181
|
+
compressed.append(
|
|
182
|
+
compress_tool_message(
|
|
183
|
+
message,
|
|
184
|
+
reason=MANUAL_COMPRESSION_REASON,
|
|
185
|
+
estimated_original_tokens=_estimate_message_tokens(message),
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
else:
|
|
189
|
+
compressed.append(message)
|
|
190
|
+
return compressed
|
|
191
|
+
|
|
192
|
+
def _message_stat(
|
|
193
|
+
self,
|
|
194
|
+
index: int,
|
|
195
|
+
message: BaseMessage,
|
|
196
|
+
total_tokens: int,
|
|
197
|
+
latest_user_index: int | None,
|
|
198
|
+
message_count: int,
|
|
199
|
+
) -> MessageTokenStat:
|
|
200
|
+
estimated = _estimate_message_tokens(message)
|
|
201
|
+
compressible = self._is_compressible_tool(index, message, message_count)
|
|
202
|
+
protected = index == latest_user_index or self._is_recent(index, message_count)
|
|
203
|
+
if _is_compressed(message):
|
|
204
|
+
recommendation = "keep compressed"
|
|
205
|
+
elif compressible and estimated >= self.min_tool_tokens:
|
|
206
|
+
recommendation = "compress"
|
|
207
|
+
elif protected:
|
|
208
|
+
recommendation = "protected"
|
|
209
|
+
else:
|
|
210
|
+
recommendation = "keep"
|
|
211
|
+
return MessageTokenStat(
|
|
212
|
+
index=index,
|
|
213
|
+
role=_message_role(message),
|
|
214
|
+
message_type=type(message).__name__,
|
|
215
|
+
estimated_tokens=estimated,
|
|
216
|
+
percent=(estimated / total_tokens * 100) if total_tokens else 0.0,
|
|
217
|
+
preview=_preview(_message_content_text(message)),
|
|
218
|
+
protected=protected,
|
|
219
|
+
compressible=compressible,
|
|
220
|
+
recommendation=recommendation,
|
|
221
|
+
risk="low" if compressible else "medium",
|
|
222
|
+
context_policy=_context_policy(message),
|
|
223
|
+
ephemeral_kind=_ephemeral_kind(message),
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
def _latest_user_index(self, messages: list[BaseMessage]) -> int | None:
|
|
227
|
+
for index in range(len(messages) - 1, -1, -1):
|
|
228
|
+
if isinstance(messages[index], HumanMessage):
|
|
229
|
+
return index
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
def _is_recent(self, index: int, message_count: int) -> bool:
|
|
233
|
+
return index >= max(message_count - self.keep_recent_messages, 0)
|
|
234
|
+
|
|
235
|
+
def _is_compressible_tool(self, index: int, message: BaseMessage, message_count: int) -> bool:
|
|
236
|
+
if self._is_recent(index, message_count):
|
|
237
|
+
return False
|
|
238
|
+
if not isinstance(message, ToolMessage):
|
|
239
|
+
return False
|
|
240
|
+
if _is_compressed(message):
|
|
241
|
+
return False
|
|
242
|
+
content = message.content
|
|
243
|
+
return isinstance(content, str) and bool(content.strip())
|
|
244
|
+
|
|
245
|
+
def _pressure(self, total_tokens: int, context_window_tokens: int) -> PressureLevel:
|
|
246
|
+
if context_window_tokens <= 0:
|
|
247
|
+
return "low"
|
|
248
|
+
percent = total_tokens / context_window_tokens * 100
|
|
249
|
+
if percent >= 90:
|
|
250
|
+
return "critical"
|
|
251
|
+
if percent >= 75:
|
|
252
|
+
return "high"
|
|
253
|
+
if percent >= 50:
|
|
254
|
+
return "medium"
|
|
255
|
+
return "low"
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _estimate_message_tokens(message: BaseMessage) -> int:
|
|
259
|
+
total_chars = len(_message_content_text(message))
|
|
260
|
+
name = getattr(message, "name", None)
|
|
261
|
+
if name:
|
|
262
|
+
total_chars += len(str(name))
|
|
263
|
+
tool_call_id = getattr(message, "tool_call_id", None)
|
|
264
|
+
if tool_call_id:
|
|
265
|
+
total_chars += len(str(tool_call_id))
|
|
266
|
+
additional_kwargs = getattr(message, "additional_kwargs", None)
|
|
267
|
+
if additional_kwargs:
|
|
268
|
+
total_chars += len(str(additional_kwargs))
|
|
269
|
+
return _estimate_chars_as_tokens(total_chars)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _estimate_text_tokens(text: object) -> int:
|
|
273
|
+
return _estimate_chars_as_tokens(len(str(text or "")))
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _estimate_chars_as_tokens(chars: int) -> int:
|
|
277
|
+
return math.ceil(chars / 4) if chars > 0 else 0
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _message_content_text(message: BaseMessage) -> str:
|
|
281
|
+
content = getattr(message, "content", "")
|
|
282
|
+
if isinstance(content, str):
|
|
283
|
+
return content
|
|
284
|
+
if isinstance(content, list):
|
|
285
|
+
return "\n".join(str(item) for item in content)
|
|
286
|
+
return str(content)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _message_role(message: BaseMessage) -> str:
|
|
290
|
+
if isinstance(message, HumanMessage):
|
|
291
|
+
return "user"
|
|
292
|
+
if isinstance(message, AIMessage):
|
|
293
|
+
return "assistant"
|
|
294
|
+
if isinstance(message, ToolMessage):
|
|
295
|
+
return "tool"
|
|
296
|
+
if isinstance(message, SystemMessage):
|
|
297
|
+
return "system"
|
|
298
|
+
return "other"
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _preview(text: object, limit: int = 120) -> str:
|
|
302
|
+
value = " ".join(str(text or "").split())
|
|
303
|
+
if len(value) <= limit:
|
|
304
|
+
return value
|
|
305
|
+
return value[: max(0, limit - 3)] + "..."
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _is_compressed(message: BaseMessage) -> bool:
|
|
309
|
+
kwargs = getattr(message, "additional_kwargs", {}) or {}
|
|
310
|
+
return bool(kwargs.get("context_compressed"))
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _context_policy(message: BaseMessage) -> str:
|
|
314
|
+
kwargs = getattr(message, "additional_kwargs", {}) or {}
|
|
315
|
+
return str(kwargs.get("context_policy") or "full")
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _ephemeral_kind(message: BaseMessage) -> str:
|
|
319
|
+
kwargs = getattr(message, "additional_kwargs", {}) or {}
|
|
320
|
+
if not kwargs.get("context_ephemeral"):
|
|
321
|
+
return ""
|
|
322
|
+
return str(kwargs.get("ephemeral_kind") or "ephemeral")
|