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.
Files changed (131) hide show
  1. agent/__init__.py +33 -0
  2. agent/acp/__init__.py +2 -0
  3. agent/acp/approval_adapter.py +134 -0
  4. agent/acp/content_adapter.py +45 -0
  5. agent/acp/jsonrpc.py +92 -0
  6. agent/acp/server.py +197 -0
  7. agent/acp/session_manager.py +193 -0
  8. agent/acp/update_adapter.py +192 -0
  9. agent/app_paths.py +25 -0
  10. agent/approval.py +169 -0
  11. agent/cancellation.py +52 -0
  12. agent/change_snapshot.py +186 -0
  13. agent/context_compressor.py +116 -0
  14. agent/graph.py +137 -0
  15. agent/llm_retry.py +434 -0
  16. agent/logger.py +97 -0
  17. agent/lsp/__init__.py +13 -0
  18. agent/lsp/client.py +151 -0
  19. agent/lsp/manager.py +234 -0
  20. agent/lsp/types.py +119 -0
  21. agent/message_context_manager.py +322 -0
  22. agent/message_format.py +105 -0
  23. agent/nodes/llm_node.py +58 -0
  24. agent/nodes/state.py +12 -0
  25. agent/nodes/task_guard_node.py +50 -0
  26. agent/nodes/tools_node.py +70 -0
  27. agent/plan_snapshot.py +70 -0
  28. agent/providers/__init__.py +13 -0
  29. agent/providers/anthropic_provider.py +268 -0
  30. agent/providers/base.py +52 -0
  31. agent/providers/openai_provider.py +279 -0
  32. agent/providers/text_tool_calls.py +118 -0
  33. agent/runtime/approval_service.py +184 -0
  34. agent/runtime/context.py +43 -0
  35. agent/runtime/tool_events.py +368 -0
  36. agent/runtime/tool_executor.py +208 -0
  37. agent/runtime/tool_output.py +261 -0
  38. agent/runtime/tool_registry.py +91 -0
  39. agent/runtime/tool_scheduler.py +35 -0
  40. agent/runtime/workflow_guard.py +217 -0
  41. agent/runtime/workspace.py +5 -0
  42. agent/runtime/workspace_tools.py +22 -0
  43. agent/session.py +787 -0
  44. agent/session_replay.py +95 -0
  45. agent/session_store.py +186 -0
  46. agent/skills.py +254 -0
  47. agent/streaming.py +248 -0
  48. agent/subagent.py +634 -0
  49. agent/task_memory.py +340 -0
  50. agent/todo_manager.py +304 -0
  51. agent/tool_retry.py +106 -0
  52. agent/tui/__init__.py +14 -0
  53. agent/tui/app.py +1325 -0
  54. agent/tui/approval.py +53 -0
  55. agent/tui/commands/__init__.py +6 -0
  56. agent/tui/commands/base.py +48 -0
  57. agent/tui/commands/clear.py +37 -0
  58. agent/tui/commands/help.py +27 -0
  59. agent/tui/commands/registry.py +94 -0
  60. agent/tui/help_content.py +108 -0
  61. agent/tui/renderers.py +1961 -0
  62. agent/tui/runner.py +439 -0
  63. agent/tui/state.py +653 -0
  64. main.py +465 -0
  65. tools/__init__.py +50 -0
  66. tools/apply_patch.py +305 -0
  67. tools/bash.py +76 -0
  68. tools/diff_utils.py +139 -0
  69. tools/edit_file.py +40 -0
  70. tools/git_diff.py +72 -0
  71. tools/git_show.py +65 -0
  72. tools/grep.py +149 -0
  73. tools/list_files.py +90 -0
  74. tools/list_skills.py +24 -0
  75. tools/load_skill.py +30 -0
  76. tools/lsp_definition.py +27 -0
  77. tools/lsp_diagnostics.py +32 -0
  78. tools/lsp_document_symbols.py +23 -0
  79. tools/lsp_hover.py +29 -0
  80. tools/lsp_references.py +37 -0
  81. tools/lsp_utils.py +38 -0
  82. tools/lsp_workspace_symbols.py +23 -0
  83. tools/read_file.py +61 -0
  84. tools/read_many_files.py +50 -0
  85. tools/safety.py +50 -0
  86. tools/subagent.py +57 -0
  87. tools/todo.py +89 -0
  88. tools/verify.py +107 -0
  89. tools/web_search.py +250 -0
  90. tools/workspace.py +36 -0
  91. tools/workspace_state.py +60 -0
  92. tools/write_file.py +88 -0
  93. utils/__init__.py +5 -0
  94. utils/retry.py +13 -0
  95. yycode-0.3.2.data/data/skills/code_review.md +61 -0
  96. yycode-0.3.2.data/data/skills/code_workflow.md +404 -0
  97. yycode-0.3.2.data/data/skills/drawio/SKILL.md +636 -0
  98. yycode-0.3.2.data/data/skills/drawio/agents/openai.yaml +19 -0
  99. yycode-0.3.2.data/data/skills/drawio/assets/demo-erd.drawio +84 -0
  100. yycode-0.3.2.data/data/skills/drawio/assets/demo-layered-cn.drawio +91 -0
  101. yycode-0.3.2.data/data/skills/drawio/assets/demo-layered-cn.png +0 -0
  102. yycode-0.3.2.data/data/skills/drawio/assets/demo-layered.drawio +112 -0
  103. yycode-0.3.2.data/data/skills/drawio/assets/demo-layered.png +0 -0
  104. yycode-0.3.2.data/data/skills/drawio/assets/demo-ml.drawio +90 -0
  105. yycode-0.3.2.data/data/skills/drawio/assets/demo-ring-cn.drawio +68 -0
  106. yycode-0.3.2.data/data/skills/drawio/assets/demo-ring-cn.png +0 -0
  107. yycode-0.3.2.data/data/skills/drawio/assets/demo-ring.drawio +86 -0
  108. yycode-0.3.2.data/data/skills/drawio/assets/demo-ring.png +0 -0
  109. yycode-0.3.2.data/data/skills/drawio/assets/demo-sequence.drawio +116 -0
  110. yycode-0.3.2.data/data/skills/drawio/assets/demo-star-cn.drawio +66 -0
  111. yycode-0.3.2.data/data/skills/drawio/assets/demo-star-cn.png +0 -0
  112. yycode-0.3.2.data/data/skills/drawio/assets/demo-star.drawio +79 -0
  113. yycode-0.3.2.data/data/skills/drawio/assets/demo-star.png +0 -0
  114. yycode-0.3.2.data/data/skills/drawio/assets/demo-uml-class.drawio +64 -0
  115. yycode-0.3.2.data/data/skills/drawio/assets/microservices-example.drawio +173 -0
  116. yycode-0.3.2.data/data/skills/drawio/assets/microservices-example.png +0 -0
  117. yycode-0.3.2.data/data/skills/drawio/assets/workflow-cn.drawio +120 -0
  118. yycode-0.3.2.data/data/skills/drawio/assets/workflow-cn.png +0 -0
  119. yycode-0.3.2.data/data/skills/drawio/assets/workflow.drawio +120 -0
  120. yycode-0.3.2.data/data/skills/drawio/assets/workflow.png +0 -0
  121. yycode-0.3.2.data/data/skills/drawio/docs/index.html +469 -0
  122. yycode-0.3.2.data/data/skills/drawio/docs/zh.html +456 -0
  123. yycode-0.3.2.data/data/skills/drawio/references/style-extraction.md +254 -0
  124. yycode-0.3.2.data/data/skills/drawio/styles/schema.json +112 -0
  125. yycode-0.3.2.data/data/skills/plan.md +115 -0
  126. yycode-0.3.2.data/data/skills/ppt/SKILL.md +254 -0
  127. yycode-0.3.2.dist-info/METADATA +12 -0
  128. yycode-0.3.2.dist-info/RECORD +131 -0
  129. yycode-0.3.2.dist-info/WHEEL +5 -0
  130. yycode-0.3.2.dist-info/entry_points.txt +2 -0
  131. 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")