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/session.py
ADDED
|
@@ -0,0 +1,787 @@
|
|
|
1
|
+
"""Reusable Session class encapsulating agent state and streaming."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import math
|
|
5
|
+
import uuid
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Iterable, Optional, AsyncGenerator
|
|
8
|
+
|
|
9
|
+
from langchain_core.messages import (
|
|
10
|
+
AIMessage,
|
|
11
|
+
HumanMessage,
|
|
12
|
+
ToolMessage,
|
|
13
|
+
BaseMessage,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from agent.approval import ApprovalCallback, ApprovalDenied
|
|
17
|
+
from agent.app_paths import resolve_app_root, resolve_runtime_data_dir
|
|
18
|
+
from agent.session_replay import ReplayEvent, build_session_replay
|
|
19
|
+
from agent.graph import build_graph
|
|
20
|
+
from agent.llm_retry import LLMCallError
|
|
21
|
+
from agent.message_format import messages_to_provider_format
|
|
22
|
+
from agent.message_context_manager import MessageContextManager, MessageContextSummary
|
|
23
|
+
from agent.lsp import shutdown_lsp_managers
|
|
24
|
+
from agent.providers.base import LLMProvider
|
|
25
|
+
from agent.providers import AnthropicProvider, OpenAIProvider
|
|
26
|
+
from agent.skills import SkillRegistry, parse_skill_paths
|
|
27
|
+
from agent.session_store import FileSessionStore, SessionStore, SessionStoreError
|
|
28
|
+
from agent.context_compressor import ContextCompressor
|
|
29
|
+
from agent.streaming import StreamEvent, StreamEventCallback, StreamPrinter
|
|
30
|
+
from agent.task_memory import (
|
|
31
|
+
TaskSummaryMemoryBuilder,
|
|
32
|
+
build_merged_task_summary_memory,
|
|
33
|
+
is_task_summary_memory,
|
|
34
|
+
)
|
|
35
|
+
from agent.todo_manager import TodoManager
|
|
36
|
+
from tools import TOOLS
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
DEFAULT_CONTEXT_WINDOW_TOKENS = 128_000
|
|
40
|
+
DOUBAO_CODE_CONTEXT_WINDOW_TOKENS = 224_000
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Session:
|
|
44
|
+
"""Reusable agent session with message history and streaming."""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
provider: LLMProvider,
|
|
49
|
+
workdir: Optional[Path] = None,
|
|
50
|
+
system_prompt: Optional[str] = None,
|
|
51
|
+
skill_dirs: Optional[Iterable[str]] = None,
|
|
52
|
+
stream_callback: Optional[StreamEventCallback] = None,
|
|
53
|
+
approval_callback: Optional[ApprovalCallback] = None,
|
|
54
|
+
stream_printer: Optional[StreamPrinter] = None,
|
|
55
|
+
todo_manager: Optional[TodoManager] = None,
|
|
56
|
+
session_id: Optional[str] = None,
|
|
57
|
+
context_window_tokens: Optional[int] = None,
|
|
58
|
+
app_root: Optional[Path] = None,
|
|
59
|
+
runtime_data_dir: Optional[Path] = None,
|
|
60
|
+
persist_messages: bool = True,
|
|
61
|
+
resume: bool = False,
|
|
62
|
+
message_store: Optional[SessionStore] = None,
|
|
63
|
+
):
|
|
64
|
+
self.id = session_id or str(uuid.uuid4())
|
|
65
|
+
self.provider = provider
|
|
66
|
+
self.workdir = (workdir or Path.cwd()).expanduser().resolve()
|
|
67
|
+
self.app_root = resolve_app_root(app_root)
|
|
68
|
+
self.runtime_data_dir = resolve_runtime_data_dir(self.app_root, runtime_data_dir)
|
|
69
|
+
self.skill_dirs = self._resolve_skill_dirs(skill_dirs)
|
|
70
|
+
self.skill_registry = SkillRegistry(self.workdir, self.skill_dirs)
|
|
71
|
+
self.skill_catalog_prompt = self.skill_registry.format_skill_catalog_prompt()
|
|
72
|
+
self.system_prompt = system_prompt or self._default_system_prompt()
|
|
73
|
+
if self.skill_catalog_prompt:
|
|
74
|
+
self.system_prompt = f"{self.system_prompt}\n\n{self.skill_catalog_prompt}"
|
|
75
|
+
self.persist_messages = persist_messages
|
|
76
|
+
self.message_store = message_store
|
|
77
|
+
if self.persist_messages and self.message_store is None:
|
|
78
|
+
session_root = None if os.environ.get("YOYO_SESSION_DIR") else self.runtime_data_dir / "sessions"
|
|
79
|
+
self.message_store = FileSessionStore(self.app_root, self.workdir, root=session_root)
|
|
80
|
+
self.messages: list[BaseMessage] = []
|
|
81
|
+
if self.persist_messages and resume and self.message_store is not None:
|
|
82
|
+
try:
|
|
83
|
+
self.messages = self.message_store.load(self.id)
|
|
84
|
+
except SessionStoreError as exc:
|
|
85
|
+
self.persist_messages = False
|
|
86
|
+
self.message_store = None
|
|
87
|
+
self.messages = []
|
|
88
|
+
if stream_callback:
|
|
89
|
+
self.stream_callback = stream_callback
|
|
90
|
+
else:
|
|
91
|
+
self.stream_callback = (stream_printer or StreamPrinter()).callback
|
|
92
|
+
# Defer normal callback initialization below by preserving the warning event.
|
|
93
|
+
self._session_persistence_warning = str(exc)
|
|
94
|
+
else:
|
|
95
|
+
self._session_persistence_warning = None
|
|
96
|
+
else:
|
|
97
|
+
self._session_persistence_warning = None
|
|
98
|
+
self.restored_message_count = len(self.messages)
|
|
99
|
+
self.stream_callback = getattr(self, "stream_callback", None) or stream_callback or (stream_printer or StreamPrinter()).callback
|
|
100
|
+
self.approval_callback = approval_callback
|
|
101
|
+
self._graph = None
|
|
102
|
+
self.todo_manager = todo_manager or TodoManager()
|
|
103
|
+
self.last_usage: Optional[dict[str, int]] = None
|
|
104
|
+
self.cumulative_usage = {
|
|
105
|
+
"input_tokens": 0,
|
|
106
|
+
"output_tokens": 0,
|
|
107
|
+
"total_tokens": 0,
|
|
108
|
+
}
|
|
109
|
+
self.context_window_tokens = context_window_tokens or infer_context_window_tokens(provider)
|
|
110
|
+
self.context_compressor = ContextCompressor(
|
|
111
|
+
context_window_tokens=self.context_window_tokens,
|
|
112
|
+
)
|
|
113
|
+
self.message_context_manager = MessageContextManager()
|
|
114
|
+
self.task_summary_memory_builder = TaskSummaryMemoryBuilder()
|
|
115
|
+
|
|
116
|
+
def _resolve_skill_dirs(self, skill_dirs: Optional[Iterable[str]]) -> list[str]:
|
|
117
|
+
default_dir = str(self.app_root / "skills")
|
|
118
|
+
if skill_dirs is None:
|
|
119
|
+
return [default_dir]
|
|
120
|
+
return [default_dir, *[str(path) for path in skill_dirs]]
|
|
121
|
+
|
|
122
|
+
def _default_system_prompt(self) -> str:
|
|
123
|
+
"""Get default system prompt."""
|
|
124
|
+
return f"""You are a coding agent at {self.workdir}. Use tools to inspect, modify, verify, and summarize work in the shared workspace.
|
|
125
|
+
|
|
126
|
+
Task State contract:
|
|
127
|
+
- Every user request must be represented in Task State with the todo tool before the task can finish.
|
|
128
|
+
- Create todo even for simple work; if the task only has one step, create one item.
|
|
129
|
+
- Keep exactly one active item in_progress while work remains.
|
|
130
|
+
- Keep Task State memory current: user_goal, constraints, files_inspected, files_modified, decisions, test_results, open_risks, and next_steps.
|
|
131
|
+
- Do not provide a final answer while any todo item is pending or in_progress.
|
|
132
|
+
- When work and verification are complete, call todo with all items marked completed, then give the final answer.
|
|
133
|
+
|
|
134
|
+
Core workflow:
|
|
135
|
+
- Before the first tool call for a new user request, briefly state your understood intent and execution approach in user-facing text: goal, likely files or areas, whether you expect to edit files, and how you plan to verify. Keep this to 1-3 short sentences or 2-4 bullets.
|
|
136
|
+
- For simple informational requests, this intent preview can be one sentence. For risky, destructive, ambiguous, or broad changes, ask for confirmation before making changes.
|
|
137
|
+
- Inspect before changing. For code changes, check workspace_state and relevant git_diff first so you do not overwrite user work.
|
|
138
|
+
- Prefer direct execution for small local tasks; use subagents only for focused subtasks that benefit from isolation.
|
|
139
|
+
- For ambiguous or multi-step work, identify goal, constraints, affected files, verification path, and risks before implementation.
|
|
140
|
+
- Use a short execution plan, usually 1-7 concrete todo items.
|
|
141
|
+
- Reconcile findings against Task State after major tool results: continue, revise, delegate, verify, or stop and ask if risk appears.
|
|
142
|
+
- Keep user-facing updates concise; explain intent and decisions, but do not expose long internal reasoning.
|
|
143
|
+
|
|
144
|
+
Tools and editing:
|
|
145
|
+
- Prefer code-navigation tools: list_files, grep, read_file, read_many_files, git_show, git_diff.
|
|
146
|
+
- For semantic code navigation, prefer LSP tools when available: lsp_workspace_symbols, lsp_document_symbols, lsp_definition, lsp_references, lsp_hover, and lsp_diagnostics. Fall back to grep/read_file when LSP is unavailable or plain text search is more appropriate.
|
|
147
|
+
- Use bash for workspace inspection only when the built-in navigation tools cannot express the query.
|
|
148
|
+
- Use apply_patch as the primary tool for editing existing files.
|
|
149
|
+
- Use write_file only for brand-new files or generated artifacts.
|
|
150
|
+
- When using apply_patch path/old_text/new_text mode, old_text must contain only the exact lines being changed, not the whole file.
|
|
151
|
+
- Never rewrite an existing file wholesale to make a small edit. Read the relevant section, then patch only the minimal changed block.
|
|
152
|
+
- After code changes, run verify with the narrowest useful target first, then broader checks when appropriate.
|
|
153
|
+
|
|
154
|
+
Subagent delegation:
|
|
155
|
+
- Use subagent only for focused, bounded subtasks.
|
|
156
|
+
- Use explorer for investigation, architect for design, worker for implementation, tester for verification, and security for security review.
|
|
157
|
+
- If the user writes an explicit delegation like "@architect /plan design X", call subagent with role="architect", skills=["plan"], and task="design X" instead of loading that skill in the main context.
|
|
158
|
+
- Give each subagent a specific task, relevant context, expected output, and clear boundaries.
|
|
159
|
+
- Do not delegate small one-or-two-tool-call tasks.
|
|
160
|
+
- After a subagent returns, integrate its result yourself and update Task State.
|
|
161
|
+
|
|
162
|
+
Skills:
|
|
163
|
+
- You only have skill names and descriptions by default.
|
|
164
|
+
- Use list_skills to discover available local skills.
|
|
165
|
+
- Use load_skill to load only the specific skill instructions needed for the current task.
|
|
166
|
+
|
|
167
|
+
Safety:
|
|
168
|
+
- Avoid destructive commands unless explicitly requested.
|
|
169
|
+
- If approval is required and silent mode is not enabled, wait for user approval before retrying.
|
|
170
|
+
- Set approved=true only after runtime approval has allowed the specific create/edit/command operation.
|
|
171
|
+
- Keep changes scoped to the user request.
|
|
172
|
+
- If unexpected file changes appear, avoid overwriting them.
|
|
173
|
+
|
|
174
|
+
Final answer:
|
|
175
|
+
- Final answer is allowed only after Task State is completed.
|
|
176
|
+
- Keep the final answer concise: what changed, how it was verified, and any remaining risk or follow-up."""
|
|
177
|
+
|
|
178
|
+
@classmethod
|
|
179
|
+
def from_config(
|
|
180
|
+
cls,
|
|
181
|
+
provider_type: Optional[str] = None,
|
|
182
|
+
api_key: Optional[str] = None,
|
|
183
|
+
api_base: Optional[str] = None,
|
|
184
|
+
model: Optional[str] = None,
|
|
185
|
+
workdir: Optional[Path] = None,
|
|
186
|
+
system_prompt: Optional[str] = None,
|
|
187
|
+
skill_dirs: Optional[Iterable[str]] = None,
|
|
188
|
+
session_id: Optional[str] = None,
|
|
189
|
+
context_window_tokens: Optional[int] = None,
|
|
190
|
+
approval_callback: Optional[ApprovalCallback] = None,
|
|
191
|
+
app_root: Optional[Path] = None,
|
|
192
|
+
runtime_data_dir: Optional[Path] = None,
|
|
193
|
+
persist_messages: bool = True,
|
|
194
|
+
resume: bool = False,
|
|
195
|
+
message_store: Optional[SessionStore] = None,
|
|
196
|
+
) -> "Session":
|
|
197
|
+
"""Create a Session from configuration parameters or environment variables."""
|
|
198
|
+
provider_type = (provider_type or os.environ.get("PROVIDER", "anthropic")).lower()
|
|
199
|
+
api_key = api_key or os.environ.get("API_KEY", "")
|
|
200
|
+
api_base = api_base or os.environ.get("API_BASE")
|
|
201
|
+
context_window_tokens = context_window_tokens or parse_context_window_tokens(
|
|
202
|
+
os.environ.get("YOYO_CONTEXT_WINDOW_TOKENS")
|
|
203
|
+
)
|
|
204
|
+
if skill_dirs is None:
|
|
205
|
+
skill_dirs = parse_skill_paths(os.environ.get("YOYO_SKILL_DIRS")) or None
|
|
206
|
+
|
|
207
|
+
if provider_type == "anthropic":
|
|
208
|
+
model = model or os.environ.get("AI_MODEL", "claude-3-5-sonnet-20241022")
|
|
209
|
+
provider = AnthropicProvider(
|
|
210
|
+
api_key=api_key,
|
|
211
|
+
model=model,
|
|
212
|
+
base_url=api_base,
|
|
213
|
+
)
|
|
214
|
+
elif provider_type == "openai":
|
|
215
|
+
model = model or os.environ.get("AI_MODEL", "gpt-4o")
|
|
216
|
+
provider = OpenAIProvider(
|
|
217
|
+
api_key=api_key,
|
|
218
|
+
model=model,
|
|
219
|
+
base_url=api_base,
|
|
220
|
+
)
|
|
221
|
+
else:
|
|
222
|
+
raise ValueError(f"Unknown provider: {provider_type}")
|
|
223
|
+
|
|
224
|
+
return cls(
|
|
225
|
+
provider=provider,
|
|
226
|
+
workdir=workdir,
|
|
227
|
+
system_prompt=system_prompt,
|
|
228
|
+
skill_dirs=skill_dirs,
|
|
229
|
+
session_id=session_id,
|
|
230
|
+
context_window_tokens=context_window_tokens,
|
|
231
|
+
approval_callback=approval_callback,
|
|
232
|
+
app_root=app_root,
|
|
233
|
+
runtime_data_dir=runtime_data_dir,
|
|
234
|
+
persist_messages=persist_messages,
|
|
235
|
+
resume=resume,
|
|
236
|
+
message_store=message_store,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
async def close(self) -> None:
|
|
240
|
+
"""Close the session and cleanup resources."""
|
|
241
|
+
self.todo_manager.clear()
|
|
242
|
+
try:
|
|
243
|
+
await shutdown_lsp_managers()
|
|
244
|
+
except Exception as exc:
|
|
245
|
+
self._session_lsp_shutdown_warning = str(exc)
|
|
246
|
+
await self.provider.close()
|
|
247
|
+
|
|
248
|
+
@property
|
|
249
|
+
def graph(self):
|
|
250
|
+
"""Lazy build the graph."""
|
|
251
|
+
if self._graph is None:
|
|
252
|
+
self._graph = build_graph(
|
|
253
|
+
self.provider,
|
|
254
|
+
self.system_prompt,
|
|
255
|
+
self.todo_manager,
|
|
256
|
+
self.workdir,
|
|
257
|
+
self.id,
|
|
258
|
+
self.skill_dirs,
|
|
259
|
+
self.app_root,
|
|
260
|
+
self.stream_callback,
|
|
261
|
+
self.approval_callback,
|
|
262
|
+
)
|
|
263
|
+
return self._graph
|
|
264
|
+
|
|
265
|
+
def reset(self) -> None:
|
|
266
|
+
"""Reset the session state."""
|
|
267
|
+
self.messages = []
|
|
268
|
+
self._save_messages()
|
|
269
|
+
self.stream_callback = StreamPrinter().callback
|
|
270
|
+
self._graph = None # Graph needs to be rebuilt as it binds to session state
|
|
271
|
+
self.todo_manager.reset()
|
|
272
|
+
|
|
273
|
+
def clear(self) -> None:
|
|
274
|
+
"""Clear message history only."""
|
|
275
|
+
self.messages = []
|
|
276
|
+
self._save_messages()
|
|
277
|
+
|
|
278
|
+
def set_model(self, model: str) -> None:
|
|
279
|
+
"""Switch the current provider model within the same provider."""
|
|
280
|
+
normalized = str(model or "").strip()
|
|
281
|
+
if not normalized:
|
|
282
|
+
raise ValueError("model must not be empty")
|
|
283
|
+
setattr(self.provider, "model", normalized)
|
|
284
|
+
self.context_window_tokens = infer_context_window_tokens(self.provider)
|
|
285
|
+
self.context_compressor = ContextCompressor(
|
|
286
|
+
context_window_tokens=self.context_window_tokens,
|
|
287
|
+
compression_ratio=self.context_compressor.compression_ratio,
|
|
288
|
+
keep_recent_messages=self.context_compressor.keep_recent_messages,
|
|
289
|
+
max_tool_chars=self.context_compressor.max_tool_chars,
|
|
290
|
+
)
|
|
291
|
+
self._graph = None
|
|
292
|
+
|
|
293
|
+
def replay_view(self) -> list[ReplayEvent]:
|
|
294
|
+
"""Return a display-friendly replay view derived from canonical messages."""
|
|
295
|
+
return build_session_replay(self.messages)
|
|
296
|
+
|
|
297
|
+
def add_message(self, message: BaseMessage) -> None:
|
|
298
|
+
"""Add a message to the history."""
|
|
299
|
+
self.messages.append(message)
|
|
300
|
+
|
|
301
|
+
def add_user_message(self, content: str) -> None:
|
|
302
|
+
"""Add a user message to the history."""
|
|
303
|
+
self.add_message(HumanMessage(content=content))
|
|
304
|
+
|
|
305
|
+
def add_ai_message(self, content: str) -> None:
|
|
306
|
+
"""Add an AI message to the history."""
|
|
307
|
+
self.add_message(AIMessage(content=content))
|
|
308
|
+
|
|
309
|
+
def get_history(self) -> list[BaseMessage]:
|
|
310
|
+
"""Get the message history."""
|
|
311
|
+
return self.messages.copy()
|
|
312
|
+
|
|
313
|
+
def estimate_token_usage(self) -> int:
|
|
314
|
+
"""Estimate current prompt/history token usage with a lightweight heuristic."""
|
|
315
|
+
return self.estimate_messages_token_usage(self.messages)
|
|
316
|
+
|
|
317
|
+
def estimate_messages_token_usage(self, messages: list[BaseMessage]) -> int:
|
|
318
|
+
"""Estimate token usage for the system prompt plus the provided messages."""
|
|
319
|
+
total_chars = len(self.system_prompt)
|
|
320
|
+
for message in messages:
|
|
321
|
+
total_chars += self._estimate_message_chars(message)
|
|
322
|
+
return math.ceil(total_chars / 4) if total_chars > 0 else 0
|
|
323
|
+
|
|
324
|
+
async def count_context_tokens(
|
|
325
|
+
self,
|
|
326
|
+
messages: Optional[list[BaseMessage]] = None,
|
|
327
|
+
) -> tuple[int, bool]:
|
|
328
|
+
"""Count context tokens with provider support, falling back to estimation."""
|
|
329
|
+
messages = self.messages if messages is None else messages
|
|
330
|
+
try:
|
|
331
|
+
exact = await self.provider.count_tokens(
|
|
332
|
+
messages=self._messages_to_provider_format(messages),
|
|
333
|
+
system_prompt=self.system_prompt,
|
|
334
|
+
tools=TOOLS,
|
|
335
|
+
)
|
|
336
|
+
except Exception:
|
|
337
|
+
exact = None
|
|
338
|
+
if exact is not None:
|
|
339
|
+
return exact, True
|
|
340
|
+
return self.estimate_messages_token_usage(messages), False
|
|
341
|
+
|
|
342
|
+
async def analyze_message_context(self) -> MessageContextSummary:
|
|
343
|
+
"""Analyze current message token pressure for user-facing management."""
|
|
344
|
+
total_tokens, exact = await self.count_context_tokens()
|
|
345
|
+
return self.message_context_manager.analyze(
|
|
346
|
+
self.messages,
|
|
347
|
+
system_prompt=self.system_prompt,
|
|
348
|
+
tools=TOOLS,
|
|
349
|
+
context_window_tokens=self.context_window_tokens,
|
|
350
|
+
total_tokens=total_tokens,
|
|
351
|
+
token_source="exact" if exact else "estimated",
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
async def compress_message_context(self, indexes: list[int]) -> int:
|
|
355
|
+
"""Manually compact selected old tool outputs and persist the session."""
|
|
356
|
+
if not indexes:
|
|
357
|
+
return 0
|
|
358
|
+
before = self.messages
|
|
359
|
+
after = self.message_context_manager.compress_selected(before, indexes)
|
|
360
|
+
compressed_count = sum(1 for old, new in zip(before, after) if old is not new)
|
|
361
|
+
if compressed_count == 0:
|
|
362
|
+
return 0
|
|
363
|
+
original_tokens, original_exact = await self.count_context_tokens(before)
|
|
364
|
+
self.messages = after
|
|
365
|
+
self._save_messages()
|
|
366
|
+
compressed_tokens, compressed_exact = await self.count_context_tokens(after)
|
|
367
|
+
token_source = "exact" if original_exact and compressed_exact else "estimated"
|
|
368
|
+
if self.stream_callback:
|
|
369
|
+
await self.stream_callback(
|
|
370
|
+
StreamEvent(
|
|
371
|
+
source="main",
|
|
372
|
+
session_id=self.id,
|
|
373
|
+
event_type="context_compressed",
|
|
374
|
+
content=(
|
|
375
|
+
f"manually compressed {compressed_count} old tool outputs "
|
|
376
|
+
f"({original_tokens} -> {compressed_tokens} tokens, {token_source})"
|
|
377
|
+
),
|
|
378
|
+
)
|
|
379
|
+
)
|
|
380
|
+
return compressed_count
|
|
381
|
+
|
|
382
|
+
def estimate_context_window_percent(self) -> float:
|
|
383
|
+
"""Estimate how much of the configured context window is currently used."""
|
|
384
|
+
if self.context_window_tokens <= 0:
|
|
385
|
+
return 0.0
|
|
386
|
+
return min((self.estimate_token_usage() / self.context_window_tokens) * 100, 999.9)
|
|
387
|
+
|
|
388
|
+
def _estimate_message_chars(self, message: BaseMessage) -> int:
|
|
389
|
+
"""Estimate message size from its content and basic metadata."""
|
|
390
|
+
total = 0
|
|
391
|
+
content = getattr(message, "content", "")
|
|
392
|
+
if isinstance(content, str):
|
|
393
|
+
total += len(content)
|
|
394
|
+
elif isinstance(content, list):
|
|
395
|
+
for item in content:
|
|
396
|
+
total += len(str(item))
|
|
397
|
+
|
|
398
|
+
name = getattr(message, "name", None)
|
|
399
|
+
if name:
|
|
400
|
+
total += len(str(name))
|
|
401
|
+
|
|
402
|
+
tool_call_id = getattr(message, "tool_call_id", None)
|
|
403
|
+
if tool_call_id:
|
|
404
|
+
total += len(str(tool_call_id))
|
|
405
|
+
|
|
406
|
+
additional_kwargs = getattr(message, "additional_kwargs", None)
|
|
407
|
+
if additional_kwargs:
|
|
408
|
+
total += len(str(additional_kwargs))
|
|
409
|
+
|
|
410
|
+
return total
|
|
411
|
+
|
|
412
|
+
def _messages_to_provider_format(self, messages: list[BaseMessage]) -> list[dict]:
|
|
413
|
+
"""Convert LangChain messages to the provider-neutral format used by providers."""
|
|
414
|
+
return messages_to_provider_format(messages)
|
|
415
|
+
|
|
416
|
+
async def send(self, content: str) -> AIMessage:
|
|
417
|
+
"""Send a user message and get response."""
|
|
418
|
+
# Clear todo for new planning session
|
|
419
|
+
self.todo_manager.prepare_for_new_input()
|
|
420
|
+
self._graph = None # Reset per-turn workflow and approval caches.
|
|
421
|
+
|
|
422
|
+
self.add_user_message(content)
|
|
423
|
+
await self._compress_context_if_needed()
|
|
424
|
+
previous_message_count = len(self.messages)
|
|
425
|
+
task_start_index = previous_message_count
|
|
426
|
+
|
|
427
|
+
completed_normally = False
|
|
428
|
+
try:
|
|
429
|
+
result = await self.graph.ainvoke({"messages": self.messages})
|
|
430
|
+
self.messages = result["messages"]
|
|
431
|
+
completed_normally = self.todo_manager.can_finish_task()
|
|
432
|
+
except ApprovalDenied as exc:
|
|
433
|
+
self.messages.append(self._approval_denied_message(exc))
|
|
434
|
+
except LLMCallError as exc:
|
|
435
|
+
self.messages.append(self._llm_failed_message(exc))
|
|
436
|
+
terminal_msg = self.messages[-1] if self.messages else None
|
|
437
|
+
new_messages_for_usage = self.messages[previous_message_count:]
|
|
438
|
+
if completed_normally:
|
|
439
|
+
summary_message = await self._summarize_completed_task_context(task_start_index)
|
|
440
|
+
self._prune_todo_artifacts(task_start_index)
|
|
441
|
+
if summary_message is not None:
|
|
442
|
+
self._collapse_completed_task_context(task_start_index)
|
|
443
|
+
self._save_messages()
|
|
444
|
+
self.last_usage = self._extract_usage_from_message(terminal_msg)
|
|
445
|
+
self._accumulate_usage_from_messages(new_messages_for_usage)
|
|
446
|
+
return terminal_msg
|
|
447
|
+
|
|
448
|
+
async def send_stream(self, content: str) -> AsyncGenerator[str, None]:
|
|
449
|
+
"""Send a user message and stream response text."""
|
|
450
|
+
self._graph = None # Reset per-turn workflow and approval caches.
|
|
451
|
+
self.add_user_message(content)
|
|
452
|
+
await self._compress_context_if_needed()
|
|
453
|
+
previous_message_count = len(self.messages)
|
|
454
|
+
task_start_index = previous_message_count
|
|
455
|
+
completed_normally = False
|
|
456
|
+
try:
|
|
457
|
+
result = await self.graph.ainvoke({"messages": self.messages})
|
|
458
|
+
self.messages = result["messages"]
|
|
459
|
+
completed_normally = self.todo_manager.can_finish_task()
|
|
460
|
+
except ApprovalDenied as exc:
|
|
461
|
+
self.messages.append(self._approval_denied_message(exc))
|
|
462
|
+
except LLMCallError as exc:
|
|
463
|
+
self.messages.append(self._llm_failed_message(exc))
|
|
464
|
+
terminal_msg = self.messages[-1] if self.messages else None
|
|
465
|
+
new_messages_for_usage = self.messages[previous_message_count:]
|
|
466
|
+
if completed_normally:
|
|
467
|
+
summary_message = await self._summarize_completed_task_context(task_start_index)
|
|
468
|
+
self._prune_todo_artifacts(task_start_index)
|
|
469
|
+
if summary_message is not None:
|
|
470
|
+
self._collapse_completed_task_context(task_start_index)
|
|
471
|
+
self._save_messages()
|
|
472
|
+
self.last_usage = self._extract_usage_from_message(terminal_msg)
|
|
473
|
+
self._accumulate_usage_from_messages(new_messages_for_usage)
|
|
474
|
+
if terminal_msg and hasattr(terminal_msg, "content"):
|
|
475
|
+
yield terminal_msg.content
|
|
476
|
+
|
|
477
|
+
def _approval_denied_message(self, exc: ApprovalDenied) -> AIMessage:
|
|
478
|
+
"""Create a terminal assistant message when the user denies approval."""
|
|
479
|
+
return AIMessage(
|
|
480
|
+
content=(
|
|
481
|
+
"Task stopped because the requested action was not approved.\n\n"
|
|
482
|
+
f"{exc.request.format()}"
|
|
483
|
+
)
|
|
484
|
+
)
|
|
485
|
+
|
|
486
|
+
def _llm_failed_message(self, exc: LLMCallError) -> AIMessage:
|
|
487
|
+
"""Create a terminal assistant message when model calls fail."""
|
|
488
|
+
return AIMessage(
|
|
489
|
+
content=(
|
|
490
|
+
"Task stopped because the model did not return a usable response.\n\n"
|
|
491
|
+
f"{exc}"
|
|
492
|
+
)
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
def _prune_todo_artifacts(self, start_index: int) -> None:
|
|
496
|
+
"""Remove todo tool-call artifacts produced after start_index."""
|
|
497
|
+
if start_index >= len(self.messages):
|
|
498
|
+
return
|
|
499
|
+
preserved = self.messages[:start_index]
|
|
500
|
+
for message in self.messages[start_index:]:
|
|
501
|
+
if self._is_ephemeral_context_message(message):
|
|
502
|
+
continue
|
|
503
|
+
if isinstance(message, ToolMessage) and message.name == "todo":
|
|
504
|
+
continue
|
|
505
|
+
if isinstance(message, AIMessage):
|
|
506
|
+
filtered = self._without_todo_tool_calls(message)
|
|
507
|
+
if filtered is None:
|
|
508
|
+
continue
|
|
509
|
+
preserved.append(filtered)
|
|
510
|
+
continue
|
|
511
|
+
preserved.append(message)
|
|
512
|
+
self.messages = preserved
|
|
513
|
+
|
|
514
|
+
async def _summarize_completed_task_context(self, start_index: int) -> BaseMessage | None:
|
|
515
|
+
"""Append deterministic task summary memory for a completed task."""
|
|
516
|
+
task_state = self.todo_manager.get_task_state()
|
|
517
|
+
if not self.task_summary_memory_builder.should_summarize(
|
|
518
|
+
self.messages,
|
|
519
|
+
start_index=start_index,
|
|
520
|
+
task_state=task_state,
|
|
521
|
+
):
|
|
522
|
+
return None
|
|
523
|
+
summary = self.task_summary_memory_builder.build(
|
|
524
|
+
self.messages,
|
|
525
|
+
start_index=start_index,
|
|
526
|
+
task_state=task_state,
|
|
527
|
+
)
|
|
528
|
+
summary_message = summary.to_message()
|
|
529
|
+
self.messages.append(summary_message)
|
|
530
|
+
if self.stream_callback:
|
|
531
|
+
await self.stream_callback(
|
|
532
|
+
StreamEvent(
|
|
533
|
+
source="main",
|
|
534
|
+
session_id=self.id,
|
|
535
|
+
event_type="context_summarized",
|
|
536
|
+
content=(
|
|
537
|
+
f"Task summary saved for messages "
|
|
538
|
+
f"{summary.covered_start_index}-{summary.covered_end_index}"
|
|
539
|
+
),
|
|
540
|
+
metadata={
|
|
541
|
+
"covered_start_index": summary.covered_start_index,
|
|
542
|
+
"covered_end_index": summary.covered_end_index,
|
|
543
|
+
},
|
|
544
|
+
)
|
|
545
|
+
)
|
|
546
|
+
return summary_message
|
|
547
|
+
|
|
548
|
+
def _collapse_completed_task_context(self, start_index: int) -> None:
|
|
549
|
+
"""Replace completed task execution history with its summary memory."""
|
|
550
|
+
if start_index >= len(self.messages):
|
|
551
|
+
return
|
|
552
|
+
preserved = self.messages[:start_index]
|
|
553
|
+
summaries = [
|
|
554
|
+
message
|
|
555
|
+
for message in self.messages[start_index:]
|
|
556
|
+
if is_task_summary_memory(message)
|
|
557
|
+
]
|
|
558
|
+
if not summaries:
|
|
559
|
+
return
|
|
560
|
+
preserved.extend(summaries)
|
|
561
|
+
self.messages = preserved
|
|
562
|
+
|
|
563
|
+
def _is_ephemeral_context_message(self, message: BaseMessage) -> bool:
|
|
564
|
+
"""Return whether a runtime-only reminder should be dropped after task completion."""
|
|
565
|
+
if is_task_summary_memory(message):
|
|
566
|
+
return False
|
|
567
|
+
additional_kwargs = getattr(message, "additional_kwargs", {}) or {}
|
|
568
|
+
return bool(additional_kwargs.get("context_ephemeral"))
|
|
569
|
+
|
|
570
|
+
def _without_todo_tool_calls(self, message: AIMessage) -> AIMessage | None:
|
|
571
|
+
"""Return an AIMessage with todo tool calls removed, or None if empty."""
|
|
572
|
+
tool_calls = list(getattr(message, "tool_calls", []) or [])
|
|
573
|
+
tool_calls_data = list(message.additional_kwargs.get("tool_calls_data") or [])
|
|
574
|
+
provider_blocks = list(message.additional_kwargs.get("provider_blocks") or [])
|
|
575
|
+
|
|
576
|
+
filtered_tool_calls = [
|
|
577
|
+
tool_call for tool_call in tool_calls
|
|
578
|
+
if self._tool_call_name(tool_call) != "todo"
|
|
579
|
+
]
|
|
580
|
+
filtered_tool_calls_data = [
|
|
581
|
+
tool_call for tool_call in tool_calls_data
|
|
582
|
+
if self._tool_call_name(tool_call) != "todo"
|
|
583
|
+
]
|
|
584
|
+
filtered_provider_blocks = [
|
|
585
|
+
block for block in provider_blocks
|
|
586
|
+
if not (isinstance(block, dict) and block.get("type") == "tool_use" and block.get("name") == "todo")
|
|
587
|
+
]
|
|
588
|
+
|
|
589
|
+
had_todo = (
|
|
590
|
+
len(filtered_tool_calls) != len(tool_calls)
|
|
591
|
+
or len(filtered_tool_calls_data) != len(tool_calls_data)
|
|
592
|
+
or len(filtered_provider_blocks) != len(provider_blocks)
|
|
593
|
+
)
|
|
594
|
+
if not had_todo:
|
|
595
|
+
return message
|
|
596
|
+
|
|
597
|
+
content = message.content
|
|
598
|
+
if not content and not filtered_tool_calls and not filtered_tool_calls_data and not filtered_provider_blocks:
|
|
599
|
+
return None
|
|
600
|
+
|
|
601
|
+
additional_kwargs = dict(message.additional_kwargs)
|
|
602
|
+
if tool_calls_data:
|
|
603
|
+
if filtered_tool_calls_data:
|
|
604
|
+
additional_kwargs["tool_calls_data"] = filtered_tool_calls_data
|
|
605
|
+
else:
|
|
606
|
+
additional_kwargs.pop("tool_calls_data", None)
|
|
607
|
+
if provider_blocks:
|
|
608
|
+
if filtered_provider_blocks:
|
|
609
|
+
additional_kwargs["provider_blocks"] = filtered_provider_blocks
|
|
610
|
+
else:
|
|
611
|
+
additional_kwargs.pop("provider_blocks", None)
|
|
612
|
+
|
|
613
|
+
return AIMessage(
|
|
614
|
+
content=content,
|
|
615
|
+
tool_calls=filtered_tool_calls,
|
|
616
|
+
additional_kwargs=additional_kwargs,
|
|
617
|
+
response_metadata=dict(getattr(message, "response_metadata", {}) or {}),
|
|
618
|
+
id=getattr(message, "id", None),
|
|
619
|
+
name=getattr(message, "name", None),
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
def _tool_call_name(self, tool_call) -> str | None:
|
|
623
|
+
if isinstance(tool_call, dict):
|
|
624
|
+
return tool_call.get("name")
|
|
625
|
+
return getattr(tool_call, "name", None)
|
|
626
|
+
|
|
627
|
+
def _extract_usage_from_message(
|
|
628
|
+
self,
|
|
629
|
+
message: Optional[BaseMessage],
|
|
630
|
+
) -> Optional[dict[str, int]]:
|
|
631
|
+
"""Extract normalized usage from a message if present."""
|
|
632
|
+
if message is None:
|
|
633
|
+
return None
|
|
634
|
+
additional_kwargs = getattr(message, "additional_kwargs", {}) or {}
|
|
635
|
+
usage = additional_kwargs.get("usage")
|
|
636
|
+
return usage if isinstance(usage, dict) else None
|
|
637
|
+
|
|
638
|
+
def has_real_usage(self) -> bool:
|
|
639
|
+
"""Return whether any real API usage has been accumulated."""
|
|
640
|
+
return self.cumulative_usage["total_tokens"] > 0
|
|
641
|
+
|
|
642
|
+
def _accumulate_usage(self, usage: Optional[dict[str, int]]) -> None:
|
|
643
|
+
"""Accumulate normalized usage totals."""
|
|
644
|
+
if not usage:
|
|
645
|
+
return
|
|
646
|
+
self.cumulative_usage["input_tokens"] += usage.get("input_tokens", 0)
|
|
647
|
+
self.cumulative_usage["output_tokens"] += usage.get("output_tokens", 0)
|
|
648
|
+
self.cumulative_usage["total_tokens"] += usage.get("total_tokens", 0)
|
|
649
|
+
|
|
650
|
+
def _accumulate_usage_from_messages(self, messages: list[BaseMessage]) -> None:
|
|
651
|
+
"""Accumulate usage from all newly added messages in a graph run."""
|
|
652
|
+
for message in messages:
|
|
653
|
+
self._accumulate_usage(self._extract_usage_from_message(message))
|
|
654
|
+
|
|
655
|
+
async def _compress_context_if_needed(self) -> None:
|
|
656
|
+
"""Compress canonical session history before invoking the graph."""
|
|
657
|
+
original_tokens, original_exact = await self.count_context_tokens()
|
|
658
|
+
result = self.context_compressor.maybe_compress(
|
|
659
|
+
self.messages,
|
|
660
|
+
original_tokens,
|
|
661
|
+
self.estimate_messages_token_usage,
|
|
662
|
+
)
|
|
663
|
+
if not result.did_compress:
|
|
664
|
+
return
|
|
665
|
+
|
|
666
|
+
self.messages = result.messages
|
|
667
|
+
compressed_tokens, compressed_exact = await self.count_context_tokens(result.messages)
|
|
668
|
+
token_source = "exact" if original_exact and compressed_exact else "estimated"
|
|
669
|
+
if self.stream_callback:
|
|
670
|
+
await self.stream_callback(
|
|
671
|
+
StreamEvent(
|
|
672
|
+
source="main",
|
|
673
|
+
session_id=self.id,
|
|
674
|
+
event_type="context_compressed",
|
|
675
|
+
content=(
|
|
676
|
+
f"compressed {result.compressed_messages} old tool outputs "
|
|
677
|
+
f"({original_tokens} -> {compressed_tokens} tokens, {token_source})"
|
|
678
|
+
),
|
|
679
|
+
)
|
|
680
|
+
)
|
|
681
|
+
await self._merge_old_task_summary_context_if_needed()
|
|
682
|
+
|
|
683
|
+
async def _merge_old_task_summary_context_if_needed(self) -> None:
|
|
684
|
+
"""Merge old completed-task summaries when context pressure remains high."""
|
|
685
|
+
original_tokens, original_exact = await self.count_context_tokens()
|
|
686
|
+
if original_tokens < self.context_compressor.threshold_tokens:
|
|
687
|
+
return
|
|
688
|
+
merge_end = self._task_summary_merge_end_index()
|
|
689
|
+
if merge_end is None:
|
|
690
|
+
return
|
|
691
|
+
before = self.messages
|
|
692
|
+
merged_summary = build_merged_task_summary_memory(
|
|
693
|
+
before[: merge_end + 1],
|
|
694
|
+
covered_start_index=0,
|
|
695
|
+
covered_end_index=merge_end,
|
|
696
|
+
).to_message()
|
|
697
|
+
after = [merged_summary, *before[merge_end + 1:]]
|
|
698
|
+
compressed_tokens = self.estimate_messages_token_usage(after)
|
|
699
|
+
if compressed_tokens >= original_tokens:
|
|
700
|
+
return
|
|
701
|
+
self.messages = after
|
|
702
|
+
exact_after, compressed_exact = await self.count_context_tokens(after)
|
|
703
|
+
token_source = "exact" if original_exact and compressed_exact else "estimated"
|
|
704
|
+
final_tokens = exact_after if compressed_exact else compressed_tokens
|
|
705
|
+
if self.stream_callback:
|
|
706
|
+
await self.stream_callback(
|
|
707
|
+
StreamEvent(
|
|
708
|
+
source="main",
|
|
709
|
+
session_id=self.id,
|
|
710
|
+
event_type="context_summarized",
|
|
711
|
+
content=(
|
|
712
|
+
f"merged completed task history "
|
|
713
|
+
f"({original_tokens} -> {final_tokens} tokens, {token_source})"
|
|
714
|
+
),
|
|
715
|
+
metadata={
|
|
716
|
+
"covered_start_index": 0,
|
|
717
|
+
"covered_end_index": merge_end,
|
|
718
|
+
"merged_messages": merge_end + 1,
|
|
719
|
+
},
|
|
720
|
+
)
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
def _task_summary_merge_end_index(self) -> int | None:
|
|
724
|
+
"""Return the inclusive prefix end that is safe to merge, if any."""
|
|
725
|
+
recent_cutoff = max(len(self.messages) - self.context_compressor.keep_recent_messages, 0)
|
|
726
|
+
latest_user_index = self._latest_user_message_index()
|
|
727
|
+
hard_limit = recent_cutoff
|
|
728
|
+
if latest_user_index is not None:
|
|
729
|
+
hard_limit = min(hard_limit, latest_user_index)
|
|
730
|
+
if hard_limit <= 0:
|
|
731
|
+
return None
|
|
732
|
+
|
|
733
|
+
last_summary_index: int | None = None
|
|
734
|
+
summary_count = 0
|
|
735
|
+
for index, message in enumerate(self.messages[:hard_limit]):
|
|
736
|
+
if is_task_summary_memory(message):
|
|
737
|
+
last_summary_index = index
|
|
738
|
+
summary_count += 1
|
|
739
|
+
if last_summary_index is None:
|
|
740
|
+
return None
|
|
741
|
+
if summary_count < 2 and last_summary_index + 1 >= hard_limit:
|
|
742
|
+
return None
|
|
743
|
+
return last_summary_index
|
|
744
|
+
|
|
745
|
+
def _latest_user_message_index(self) -> int | None:
|
|
746
|
+
for index in range(len(self.messages) - 1, -1, -1):
|
|
747
|
+
if isinstance(self.messages[index], HumanMessage):
|
|
748
|
+
return index
|
|
749
|
+
return None
|
|
750
|
+
|
|
751
|
+
def _save_messages(self) -> None:
|
|
752
|
+
"""Persist canonical message history when configured."""
|
|
753
|
+
if not self.persist_messages or self.message_store is None:
|
|
754
|
+
return
|
|
755
|
+
try:
|
|
756
|
+
self.message_store.save(
|
|
757
|
+
self.id,
|
|
758
|
+
self.messages,
|
|
759
|
+
metadata={"model": getattr(self.provider, "model", None)},
|
|
760
|
+
)
|
|
761
|
+
except (OSError, SessionStoreError) as exc:
|
|
762
|
+
self.persist_messages = False
|
|
763
|
+
self.message_store = None
|
|
764
|
+
self._session_persistence_warning = str(exc)
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def parse_context_window_tokens(value: Optional[str]) -> Optional[int]:
|
|
768
|
+
"""Parse an optional context window token setting."""
|
|
769
|
+
if not value:
|
|
770
|
+
return None
|
|
771
|
+
try:
|
|
772
|
+
parsed = int(value.replace("_", "").strip())
|
|
773
|
+
except ValueError:
|
|
774
|
+
return None
|
|
775
|
+
return parsed if parsed > 0 else None
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def infer_context_window_tokens(provider: LLMProvider) -> int:
|
|
779
|
+
"""Infer a reasonable context window from the configured provider/model."""
|
|
780
|
+
model = str(getattr(provider, "model", "")).lower()
|
|
781
|
+
if "doubao" in model and "code" in model:
|
|
782
|
+
return DOUBAO_CODE_CONTEXT_WINDOW_TOKENS
|
|
783
|
+
if "claude" in model:
|
|
784
|
+
return 200_000
|
|
785
|
+
if any(name in model for name in ("gpt-4o", "gpt-4.1", "gpt-5")):
|
|
786
|
+
return 128_000
|
|
787
|
+
return DEFAULT_CONTEXT_WINDOW_TOKENS
|